Android Parcel 系列系列漏洞整理 (一)

Brief

在Android系统中Parcel对象是一个非常常用的进程间消息传递的载体。由于该结构原本并没有一些内容的完整性校验,这导致了发送者所发送的数据与接收者所接受的数据可能并不相同。从这一漏洞点出发,攻击者不断开发出了如self-changing Bundle等多种利用方案;

在去年的BlackHat EU中,Google 宣布在Android 13中引入了全新安全的Parcel机制。针对Parcel传输数据中存在的问题作出了改进,封堵了大部分对Parcel Mismatch利用的场景;

笔者从2022年初才开始关注Parcel Mismatch的问题,但是由于拖延症直到最近michalbednarski 爆出在Android 13上的LazyValue 才正式开始梳理相关的漏洞原理,利用场景以及Google的修复方案。主要整合了michalbednarski在其Github上公布的PoC以及BlackHat EU上的slide,希望能对相关漏洞的挖掘有些启示。(如果想深入研究建议还是要看一下相关的代码以及PoC)

为方便观看,整个Parcel Mismatch的问题将被分为三个部分:

  • 第一部分(本篇) : 介绍Bundle Mismatch的原理,利用以及Android 13中的修复方案

  • 第二部分:介绍ReparcelBug 2的利用链以及在Android 13的修复方案部分

  • 第三部分: 介绍LazyValue的漏洞原理以及利用方式,并总结下未来Parcel Mismatch漏洞的生存问题;

关于Parcel的一些基础知识

image-1.png

Parcel可以将Int, String等基础类型以及可序列化的对象(继承自andrid.os.Parcel)打包起来在sender与recevier之间传递。

发送者(sender)通过对象中的writeToParcel方法将对象存入Parcel中,接受者收到Parcel之后,通过createFromParcel中反序列化出对应的原始对象。当Android开发者创建一个可序列化的对象时候,必须要实现其中的writeToParcel方法以及createFromParcel方法。这里就存在一个风险,即通过writeToParcel方法向Parcel中写入的内容与通过createFromParcel 方法读取到的内容可能是不一致的,从而导致接受着(receiver)接受到了完全不一样的内容。在攻击者的巧妙布局下,可以触发许多严重的安全漏洞。

举个例子,在 CVE-2017-0806 的修复patch中,可以看在writeToParcel过程中如果mPayload == null时不会向Parcel中写入内容(包括mPayload.size),但是createFromParcel 仍然会通过int size = source.readInt()来从Parcel中读取一个size,这就导致了Parcel读写不一致的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public GateKeeperResponse createFromParcel(Parcel source) {
int responseCode = source.readInt();
final GateKeeperResponse response;
if (responseCode == RESPONSE_RETRY) {
response = createRetryResponse(source.readInt());
} else if (responseCode == RESPONSE_OK) {
final boolean shouldReEnroll = source.readInt() == 1;
byte[] payload = null;
int size = source.readInt();
if (size > 0) {
payload = new byte[size];
source.readByteArray(payload);
}
response = createOkResponse(payload, shouldReEnroll);
} else {
response = createGenericResponse(responseCode);
}
return response;
}


@Override
public void writeToParcel(Parcel dest, int flags) {
dest.writeInt(mResponseCode);
if (mResponseCode == RESPONSE_RETRY) {
dest.writeInt(mTimeout);
} else if (mResponseCode == RESPONSE_OK) {
dest.writeInt(mShouldReEnroll ? 1 : 0);
if (mPayload != null) {
dest.writeInt(mPayload.length);
dest.writeByteArray(mPayload);
}
}
}

攻击者可以自由布局Parcel的另一个关键点是,Parcel对于如List等数据的处理,由于Java Type Erasure机制的存在,攻击者可以向一个可序列化对象的某个参与序列化的List变量中,写入一个与之List声明类型完全不匹配的对象进去,再其反序列化时仍然不受任何影响。在攻击者进行布局时,也可以利用这个特性来一些序列化的对象进去(这段描述可能有些抽象,但是配合下篇文章中的例子一起,就会容易理解一些)。

image-2.png

到此为止,就完成了对Parcel Mismatch部分的解读,然而让这种不匹配完全转化成一个有价值的安全漏洞就需要配合Android Framework中的一些关键流程,后面会介绍几种常见的套路。更多有价值的利用链有待于安全研究员们继续不断的探索喽。

self-changing Bundle

这是一种非常典型的利用场景,一般攻击者会配合AccountManagerService中一个已经修复过的LaunchAnywhere漏洞

image-3.png

在这个流程中:

  • Attacker APP : 发起AddAccount请求

  • system_server : 负责检查 KEY_INTENT 中是否包含恶意的信息

  • Settings APP : 提取Bundle中的KEY_INTENT字段,并执行startActivity

在上面的传递过程中, 利用 Parcel Mismatch 的漏洞,令AccountManagerService 获取到的KEY_INTENTSettings APP 获取到的 KEY_INTENT 为不同内容,可以做到利用Settings APP 的LaunchAnywhere.

编写exploit时,需要根据Parcel Mismatch漏洞具体的读写差异调整对Bundle的布局,情况太多以后有机会单独写一篇BLOG整理一下。而在michalbednarski的ReparcelBug项目中整理了Bundle key替换的类 Ambiguator 其中的设计非常的巧妙,仅需少量的修改以及测试就可以完成一个全新的Parcel Mismatch exploit的编写;在heeen的self-changing Bundle的文章中也展示了一些对Bundle布局的思路。

在这里的addAccount流程仅是self-changing Bundle的一个例子,如果尝试以更加抽象一点的语言总结self-changing Bundle的利用应该是这样的:如果在一段代码逻辑中,存在攻击者(Attacker), 对Bundle内容的检查者(Checker)以及实际执行代码者(Executor);Attacker向Checker发送一个Bundle,Checker检查完Bundle中的内容之后将其序列化后传递给Executor执行;那么Attacker可以利用一些存在Parcel Mismatch来调整Bundle中的布局让Checker与Executor尝试从Bundle中读取某个相同的KEY时,读取到完全不同的内容,进而绕过Checker对于Bundle内容的检查。

Lazy Bundle in Android 13

为了更加有效的解决self-changing Bundle类型的利用方式,在 Android 13中通过调整了Parcel中数据的布局引入了Lazy Bundle机制,Google的工程师对该功能是这样描述的:

So, there are basically 3 states:

  1. We received the bundle but haven’t queried anything about it (not
    even isEmpty()): in this case the original parcel is held inside and
    we haven’t attempted any deserialization (except for the metadata at
    the beginning such as the magic, etc)
  2. We queried something on it (eg. isEmpty()): Now we deserialize the
    bundle skipping the custom values above (we’re able to do this now
    with the length written on the wire) and instead placing LazyValue
    objects for them in the map.
  3. We query one of the lazy values: Now, we deserialize the object
    represented by LazyValue and replace it on the map.

从宏观的角度,可以通过下面这张图片来理解:

image-4.png

通过LazyBundle的机制,不再需要将整个Bundle对象反序列化并将内容存入Map中读取,调用bundle.get*()方法可以独立的在Parcel中读取Key-Value 值。这样做一方面可以减少Bundle中某些Key-Value解析异常导致的程序崩溃,另一方面也意味着序列化存入Parcel后的每个Key-Value对都会有独立的存储区域,不会再由于某些对象的解析Mismatch而导致后续解析异常问题(例如前面提到的self-changing Bundle)。

从代码的角度来看,将Bundle中的每个Key-Value对中的Value以LazyValue的形式存入Parcel中,这也是Android 13中引入的一种新的机制,代码中有如下的说明:

1
2
3
4
5
6
                     |   4B   |   4B   |
mSource = Parcel{... | type | length | object | ...}
a b c d
length = d - c
mPosition = a
mLength = d - a

由于对每个LazyValue对象都有类型和长度的标注,则即使object 部分解析异常,通过对length类型的检查也能够解释的发现,通过抛出异常来阻止一些漏洞的利用。

下面通过代码看下,引入LazyBundle机制之后是如何实现读取内容的(以下的所有代码均可在https://cs.android.com 中找到);

对于从Parcel对象中读取的Bundle对象,会通过BaseBundle.readFromParcelInner 方法进行解析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// frameworks/base/core/java/android/os/BaseBundle.java
private void readFromParcelInner(Parcel parcel, int length) {
...
final int magic = parcel.readInt();
final boolean isJavaBundle = magic == BUNDLE_MAGIC;
final boolean isNativeBundle = magic == BUNDLE_MAGIC_NATIVE;
...
// Advance within this Parcel
int offset = parcel.dataPosition();
parcel.setDataPosition(MathUtils.addOrThrow(offset, length));

Parcel p = Parcel.obtain();
p.setDataPosition(0);
p.appendFrom(parcel, offset, length);
p.adoptClassCookies(parcel);
if (DEBUG) Log.d(TAG, "Retrieving " + Integer.toHexString(System.identityHashCode(this))
+ ": " + length + " bundle bytes starting at " + offset);
p.setDataPosition(0);

mParcelledByNative = isNativeBundle;
mParcelledData = p;
}

在完成JavaBundle/NativeBundle的判定之后,会根据Bundle对象的长度重新设置Parcel对象的position,并将偏移过内容直接拷贝到一个新的Parcel并记录到mParcelledData中,即可完成预读取,在这个过程中不会解析ParcelData。

在有尝试从Bundle中读取数据的操作(get*(String key))时会触发unparcel方法来解析预先读取到mParcelData中的数据,以getString为例:

1
2
3
4
5
6
7
8
9
10
11
12
// frameworks/base/core/java/android/os/BaseBundle.java
@Nullable
public String getString(@Nullable String key) {
unparcel();
final Object o = mMap.get(key);
try {
return (String) o;
} catch (ClassCastException e) {
typeWarning(key, o, "String", e);
return null;
}
}

unparcel会触发一下的解析流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// frameworks/base/core/java/android/os/BaseBundle.java
@UnsupportedAppUsage
final void unparcel() {
unparcel(/* itemwise */ false);
}

/** Deserializes the underlying data and each item if {@code itemwise} is true. */
final void unparcel(boolean itemwise) {
synchronized (this) {
final Parcel source = mParcelledData;
if (source != null) {
initializeFromParcelLocked(source, /*recycleParcel=*/ true, mParcelledByNative);
} else {
if (DEBUG) {
Log.d(TAG, "unparcel "
+ Integer.toHexString(System.identityHashCode(this))
+ ": no parcelled data");
}
}
...
}
}

private void initializeFromParcelLocked(@NonNull Parcel parcelledData, boolean recycleParcel,
boolean parcelledByNative) {
if (isEmptyParcel(parcelledData)) {
...
if (mMap == null) {
mMap = new ArrayMap<>(1);
} else {
mMap.erase();
}
mParcelledByNative = false;
mParcelledData = null;
return;
}

final int count = parcelledData.readInt();
...
try {
// recycleParcel being false implies that we do not own the parcel. In this case, do
// not use lazy values to be safe, as the parcel could be recycled outside of our
// control.
recycleParcel &= parcelledData.readArrayMap(map, count, !parcelledByNative,
/* lazy */ recycleParcel, mClassLoader);
} catch (BadParcelableException e) {
...
} finally {
mMap = map;
if (recycleParcel) {
recycleParcel(parcelledData);
mWeakParcelledData = null;
} else {
mWeakParcelledData = new WeakReference<>(parcelledData);
}
mParcelledByNative = false;
mParcelledData = null;
}
...
}

unparcel只会实际触发一次解析,通过initializeFromParcelLocked方法来处理,即从 mParcelledData中读取一个长度为count的ArrayMap并将其存入Map中,至此对于mParcelledData的解析完成一半会通过recycleParcel将其回收,对Bundle资源的读取可以直接通过mMap中的key索引即可获取;

但是,由于LazyValue的引入,此时mMap中存储Value值的并非全部都是原始类型的对象,具体看下readArrayMap方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// frameworks/base/core/java/android/os/Parcel.java
boolean readArrayMap(ArrayMap<? super String, Object> map, int size, boolean sorted,
boolean lazy, @Nullable ClassLoader loader) {
boolean recycle = true;
while (size > 0) {
String key = readString();
Object value = (lazy) ? readLazyValue(loader) : readValue(loader);
if (value instanceof LazyValue) {
recycle = false;
}
if (sorted) {
map.append(key, value);
} else {
map.put(key, value);
}
size--;
}
if (sorted) {
map.validate();
}
return recycle;
}

private boolean isLengthPrefixed(int type) {
// In general, we want custom types and containers of custom types to be length-prefixed,
// this allows clients (eg. Bundle) to skip their content during deserialization. The
// exception to this is Bundle, since Bundle is already length-prefixed and already copies
// the correspondent section of the parcel internally.
switch (type) {
case VAL_MAP:
case VAL_PARCELABLE:
case VAL_LIST:
case VAL_SPARSEARRAY:
case VAL_PARCELABLEARRAY:
case VAL_OBJECTARRAY:
case VAL_SERIALIZABLE:
return true;
default:
return false;
}
}

public Object readLazyValue(@Nullable ClassLoader loader) {
int start = dataPosition();
int type = readInt();
if (isLengthPrefixed(type)) {
int objectLength = readInt();
if (objectLength < 0) {
return null;
}
int end = MathUtils.addOrThrow(dataPosition(), objectLength);
int valueLength = end - start;
setDataPosition(end);
return new LazyValue(this, start, valueLength, type, loader);
} else {
return readValue(type, loader, /* clazz */ null);
}
}

当readArrayMap方法的lazy被设置为True的时候,会通过readLazyValue方法来读取mMap中的Value,对于支持LazeValue的类型(如MAP, PARCELABLE等)会打包成一个LazyValue对象存入Bundle.mMap中, 这样做是为了减少对于Bundle中某些对象的解析异常导致整个进程崩溃的情况。

当实际触发这些类型的value读取时(以getArrayList为例):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// frameworks/base/core/java/android/os/BaseBundle.java
@SuppressWarnings("unchecked")
@Nullable
<T> ArrayList<T> getArrayList(@Nullable String key, @NonNull Class<? extends T> clazz) {
unparcel();
try {
return getValue(key, ArrayList.class, requireNonNull(clazz));
} catch (ClassCastException | BadTypeParcelableException e) {
typeWarning(key, "ArrayList<" + clazz.getCanonicalName() + ">", e);
return null;
}
}

@Nullable
final <T> T getValue(String key, @Nullable Class<T> clazz, @Nullable Class<?>... itemTypes) {
int i = mMap.indexOfKey(key);
return (i >= 0) ? getValueAt(i, clazz, itemTypes) : null;
}

@SuppressWarnings("unchecked")
@Nullable
final <T> T getValueAt(int i, @Nullable Class<T> clazz, @Nullable Class<?>... itemTypes) {
Object object = mMap.valueAt(i);
if (object instanceof BiFunction<?, ?, ?>) {
try {
object = ((BiFunction<Class<?>, Class<?>[], ?>) object).apply(clazz, itemTypes);
} catch (BadParcelableException e) {
if (sShouldDefuse) {
Log.w(TAG, "Failed to parse item " + mMap.keyAt(i) + ", returning null.", e);
return null;
} else {
throw e;
}
}
mMap.setValueAt(i, object);
}
return (clazz != null) ? clazz.cast(object) : (T) object;
}

会触发对应类型的转换完成对LazyValue对象的读取;

至此,对于self-changing Bundle的修复方案的解读完毕,在如此的机制下,是能够有效的防御到self-changing Bundle的利用思路的,至少暂时笔者没想到很好的绕过方案,之后有的话,可能会再开一篇文章来说明的。

Insight

Android 13 通过引入LazyValue机制虽然可以有效的解决self-changing Bundle的问题,但是个人认为self-changing Bundle仍然是一个很有趣的研究方向,一方面Android 13的普及仍需要一段时间,但是仍会有大量的Mismatch Parcel对象被找出来,这些对象仍然可以被利用来攻击很多的手机系统。另一方面,Android Framework/APP 中必然会有许多符合self-changing Bundle的其他利用场景存在,更多的利用方案配合不同的Bundle布局也会是个很不错的挑战。

Reference