CVE-2017-13287-PoC

CVE 2017-13287 复现

2018年4月,Android安全公告公布了CVE-2017-13287漏洞。与同期披露的其他漏洞一起,同属于框架中Parcelable对象的写入(序列化)与读出(反序列化)的不一致所造成的漏洞。在刚看到谷歌对于漏洞给出的补丁时一头雾水,

在这里要感谢heeeeen@MS509Team在这个问题上的成果,启发了我的进一步研究。

原理

谷歌在Android中提供了Parcelable作为高效的序列化实现,用来支持IPC调用中多样的对象传递需求。但是序列化和反序列化的过程依旧依靠程序员编写的代码进行同步。那么当不同步的时候,漏洞就产生了。

Bundle

传输的时候Parcelable对象按照键值对的形式存储在Bundle内,Bundle内部有一个ArrayMap用hash表进行管理。反序列化过程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* package */ void unparcel() {
synchronized (this) {
final Parcel parcelledData = mParcelledData;
int N = parcelledData.readInt();
if (N < 0) {
return;
}
ArrayMap<String, Object> map = mMap;
try {
parcelledData.readArrayMapInternal(map, N, mClassLoader);
} catch (BadParcelableException e) {
} finally {
mMap = map;
parcelledData.recycle();
mParcelledData = null;
}
}
}

首先读取一个int指示里面有多少对键值对。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* package */ void readArrayMapInternal(ArrayMap outVal, int N,
ClassLoader loader) {
if (DEBUG_ARRAY_MAP) {
RuntimeException here = new RuntimeException("here");
here.fillInStackTrace();
Log.d(TAG, "Reading " + N + " ArrayMap entries", here);
}
int startPos;
while (N > 0) {
if (DEBUG_ARRAY_MAP) startPos = dataPosition();
String key = readString();
Object value = readValue(loader);
outVal.append(key, value);
N--;
}
outVal.validate();
}

之后的每一对先是Key的字符串,然后是对应的Value。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final Object readValue(ClassLoader loader) {
int type = readInt();

switch (type) {
case VAL_NULL:
return null;

case VAL_STRING:
return readString();

case VAL_INTEGER:
return readInt();

case VAL_MAP:
return readHashMap(loader);

case VAL_PARCELABLE:
return readParcelable(loader);

case VAL_SHORT:
return (short) readInt();

case VAL_LONG:
return readLong();

值内部先是一个int指示值的类型,再存储实际值。

当Bundle被写入Parcel时:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
void writeToParcelInner(Parcel parcel, int flags) {
final ArrayMap<String, Object> map;
synchronized (this) {
if (mParcelledData != null) {
if (mParcelledData == NoImagePreloadHolder.EMPTY_PARCEL) {
parcel.writeInt(0);
} else {
int length = mParcelledData.dataSize();
parcel.writeInt(length);
parcel.writeInt(BUNDLE_MAGIC);
parcel.appendFrom(mParcelledData, 0, length);
}
return;
}
map = mMap;
}
}

先写入Bundle总共的字节数,再写入魔数,之后是指示键值对数的N,还有相应的键值对。

LaunchAnyWhere

弄明白Bundle的内部结构后,先来看看漏洞触发的地方:

这个流程是AppA在请求添加一个帐号:

  1. AppA请求添加一个帐号
  2. System_server接受到请求,找到可以提供帐号服务的AppB,并发起请求
  3. AppB返回了一个Bundle给系统,系统把Bundle转发给AppA
  4. AccountManagerResponse在AppA的进程空间中调用startActivity(intent)调起一个Activity。

在第4步中,如果AppA的权限较高,比如Settings,那么AppA可以调用正常App无法调用的未导出Activity。

并且在第3步中,AppB提供的Bundle在system_server端被反序列化,之后system_server根据之前得到的内容再序列化并传递给AppA。那么如果对应的传递内容的序列化和反序列化代码不一样,就会影响到自己以及之后的内容的结果。

传递的Bundle对象中包含一个重要键值对{KEY_INTENT:intent},指定了AppA稍后调用的Activity。如果这个被指定成Setting中的com.android.settings.password.ChooseLockPassword,就可以在不需要原本锁屏密码的情况下重新设置锁屏密码。

谷歌在这个过程中进行了检查,保证Intent中包含的Activity所属的签名和AppB一致,并且不是未导出的系统Actiivity。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
protected void checkKeyIntent(int authUid, Intent intent) throws SecurityException {
long bid = Binder.clearCallingIdentity();
try {
PackageManager pm = mContext.getPackageManager();
ResolveInfo resolveInfo = pm.resolveActivityAsUser(intent, 0, mAccounts.userId);
ActivityInfo targetActivityInfo = resolveInfo.activityInfo;
int targetUid = targetActivityInfo.applicationInfo.uid;
if (!isExportedSystemActivity(targetActivityInfo)
&& (PackageManager.SIGNATURE_MATCH != pm.checkSignatures(authUid, targetUid))) {
String pkgName = targetActivityInfo.packageName;
String activityName = targetActivityInfo.name;
String tmpl = "KEY_INTENT resolved to an Activity (%s) in a package (%s) that "
+ "does not share a signature with the supplying authenticator (%s).";
throw new SecurityException(
String.format(tmpl, activityName, pkgName, mAccountType));
}
} finally {
Binder.restoreCallingIdentity(bid);
}
}

攻击思路便是在system_server进行检查时Bundle中的恶意{KEY_INTENT:intent}看不到,但是在重新序列化之后在Setting出现,这样就绕过了检查。

利用

首先来看看漏洞所在的代码

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
public static final Parcelable.Creator<VerifyCredentialResponse> CREATOR
= new Parcelable.Creator<VerifyCredentialResponse>() {
@Override
public VerifyCredentialResponse createFromParcel(Parcel source) {
int responseCode = source.readInt();
VerifyCredentialResponse response = new VerifyCredentialResponse(responseCode, 0, null);
if (responseCode == RESPONSE_RETRY) {
response.setTimeout(source.readInt());
} else if (responseCode == RESPONSE_OK) {
int size = source.readInt();
if (size > 0) {
byte[] payload = new byte[size];
source.readByteArray(payload);
response.setPayload(payload);
}
}
return response;
}

@Override
public VerifyCredentialResponse[] newArray(int size) {
return new VerifyCredentialResponse[size];
}

};

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

仔细阅读,会发现在mResponseCodeRESPONSE_OK时,如果mPayloadnull,那么writeToParcel不会在末尾写入0来正确的指示Payload部分的长度。而在createFromParcel中是需要readInt来获知的,这个就带来了序列化与反序列化过程的不一致。可以通过精心构造的payload来绕过检查。

难点在于和已经有人公开过的CVE-2017-13288和CVE-2017-13315不同,它们是重新序列化之后会多出来4个字节。这里是重新序列化之后会少4个字节。

解决方案

利用String的结构,把恶意intent隐藏在String里。上图每段注释的括号里写了其所占用的字节数。

在第一次反序列化时,VerifyCredentialResponse内部的0还在,恶意intent被包装在第二对的Key中。第二对的值的类型被制定为VAL_NULL,也就是什么都没有,常量值为-1。

再次序列化时writeToParcel没有writeInt(0),所以到达Setting的Bundle在RESPONSE_OK之后没有0,原本的String length被视作payload length,调用readByteArray读取。

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
static jbyteArray android_os_Parcel_createByteArray(JNIEnv* env, jclass clazz, jlong nativePtr)    
{
jbyteArray ret = NULL;

Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr);
if (parcel != NULL) {
int32_t len = parcel->readInt32();

// sanity check the stored length against the true data size
if (len >= 0 && len <= (int32_t)parcel->dataAvail()) {
ret = env->NewByteArray(len);

if (ret != NULL) {
jbyte* a2 = (jbyte*)env->GetPrimitiveArrayCritical(ret, 0);
if (a2) {
const void* data = parcel->readInplace(len);
memcpy(a2, data, len);
env->ReleasePrimitiveArrayCritical(ret, a2, 0);
}
}
}
}

return ret;
}

再次调用readInt32读取长度,之后截取数组内容。相应的从Payload length开始的指定长度的内容都被视作payload。只要设置得当,恶意intent就会显露出来成为实质上的第二对键值对。

那么之前作为第二对值的VAL_NULL怎么办?之前提过它的常量值是-1,上一对恶意intent刚结束,在这里调用的是readString这个函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const char16_t* Parcel::readString16Inplace(size_t* outLen) const
{
int32_t size = readInt32();
// watch for potential int overflow from size+1
if (size >= 0 && size < INT32_MAX) {
*outLen = size;
const char16_t* str = (const char16_t*)readInplace((size+1)*sizeof(char16_t));
if (str != NULL) {
return str;
}
}
*outLen = 0;
return NULL;
}

再次的readInt32,得到-1,直接返回null,长度为0,会在JNI层中创建一个空字符串返回到java层。那么就是说:VAL_NULL单独作为一个空字符串被读取,之后的三个蓝色块被视作值。

这里因为之后的字符串是123456,所以string_length是6.

这个很关键,因为在Settings这里被readValue视作type,而6正好是VAL_STRING,也即字符串类型。于是ord('1')= 0x31被视作String length正常使用,正常读取字符串。

至此Settings侧正常读取完毕,恶意intent被读取并执行。

假String的构造

之前略过了包含恶意intent的假String的具体padding过程,这里展开:

String_length(4) + Payload_length(4) + PADDING(Size + 16) + EVIL_INTENT(Size) + PADDING(8)
String_length = Payload_length = (4 + 4 + Size + 16 + Size + 8) / 2 - 1 = Size + 15

这里先给出公式,Size在这里就是Evil_intent部分的长度,String_lengthPayload_lengthSetting侧都被视作payload的长度使用,故相同。

从两个视角去审视这个公式:

  1. system_server侧

对于system_server来说,从String_length开始的部分就是单纯的一个字符串,那么它先读取String_length并套用readString16Inplace中的公式。它会从String_length之后读取$ 2(1 + Size + 15)=2Size + 32 $,正好包括总长。

  1. Settings侧

对于Settings来说,从Payload_length之后会直接截取对应长度的内容作为数组,即Payload_length之后$Size + 15$,因为Parcel底层的操作对4向上凑整,所以正好露出EVIL_INTENT

这样就可以达成效果。

结果

POC: https://github.com/FXTi/CVE201713287POC

总结

在IPC这块就算谷歌引入了AIDL这种方式来规定接口,哪怕只是中间所用到的类的序列化过程出现一点失误都会造成如此严重的漏洞。可见安全编程以及代码审计的必要性,没准以后还会有类似机理的漏洞被发掘出来。