Java 在历史上出现过许多反序列化的漏洞,但大部分出自 J2EE 的组件。即便是 FastJSON 这种漏洞,似乎也很少看到在 Android 中被实际的触发和利用。本文即为对历史上曾出现过的 Android Java 反序列化漏洞的分析和研究记录。
序列化和反序列化是指将内存数据结构转换为字节流,通过网络传输或者保存到磁盘,然后再将字节流恢复为内存对象的过程。在 Web 安全领域,出现过很多反序列化漏洞,比如 PHP 反序列化、Java 反序列化等。由于在反序列化的过程中触发了非预期的程序逻辑,从而被攻击者用精心构造的字节流触发并利用漏洞从而最终实现任意代码执行等目的。
Android 中除了传统的 Java 序列化机制,还有一个特殊的序列化方法,即 Parcel。根据官方文档的介绍,Parcelable 和 Bundle 对象主要的作用是用于跨进程边界的数据传输(IPC/Binder),但 Parcel 并不是一个通用的序列化方法,因此不建议开发者将 Parcel 数据保存到磁盘或者通过网络传输。
作为 IPC 传输的数据结构,Parcel 的设计初衷是轻量和高效,因此缺乏完善的安全校验。这就引发了历史上出现过多次的 Android 反序列化漏洞,本文就按照时间线对其进行简单的分析和梳理。
注: 本文中所展现的 AOSP 示例代码,如无特殊说明则都来自文章发表时的 master 分支。
Parcel 101
在介绍漏洞之前,我们还是按照惯例先来了解下基础知识。对于有过 Android 开发或者逆向分析经验的同学应该对 Parcel 都不陌生,但通常也很少直接使用该类去序列化/反序列化数据然后进行 IPC 通信,而是通过 AIDL 等方法去自动生成模版,然后集成实现对应接口。
AIDL
关于 AIDL 开发的示例可以参考 Android 进程间通信与逆向分析 一文,简单来说,假设有以下 AIDL 文件:
代码语言:javascript复制package com.evilpan;
interface IFooService {
parcelable Person {
String name;
int age;
boolean gender;
}
String foo(int a, String b, in Person c);
}
那么生成的(Java)模版大致结构如下:
代码语言:javascript复制public interface IFooService extends android.os.IInterface {
public java.lang.String foo(int a, java.lang.String b, com.evilpan.IFooService.Person c) throws android.os.RemoteException;
public static class Person implements android.os.Parcelable { /* ... */ }
public static abstract class Stub extends android.os.Binder implements com.evilpan.IFooService {
@Override public boolean onTransact(int code, android.os.Parcel data, android.os.Parcel reply, int flags) throws android.os.RemoteException {
data.enforceInterface(descriptor);
// ...
switch(code) {
case TRANSACTION_foo {
int _arg0;
_arg0 = data.readInt();
java.lang.String _arg1;
_arg1 = data.readString();
com.evilpan.IFooService.Person _arg2;
_arg2 = _Parcel.readTypedObject(data, com.evilpan.IFooService.Person.CREATOR);
java.lang.String _result = this.foo(_arg0, _arg1, _arg2);
reply.writeNoException();
reply.writeString(_result);
break;
}
}
}
}
private static class Proxy implements com.evilpan.IFooService {
@Override public java.lang.String foo(int a, java.lang.String b, com.evilpan.IFooService.Person c) throws android.os.RemoteException
{
android.os.Parcel _data = android.os.Parcel.obtain();
android.os.Parcel _reply = android.os.Parcel.obtain();
java.lang.String _result;
try {
_data.writeInterfaceToken(DESCRIPTOR);
_data.writeInt(a);
_data.writeString(b);
_Parcel.writeTypedObject(_data, c, 0);
boolean _status = mRemote.transact(Stub.TRANSACTION_foo, _data, _reply, 0);
_reply.readException();
_result = _reply.readString();
}
finally {
_reply.recycle();
_data.recycle();
}
return _result;
}
}
// ...
}
其中 IFooService.Stub
类是本地的 IPC 实现,即服务端代码通过继承至该类并实现其 foo
方法;而 Proxy
则是客户端的的辅助类,客户端可以通过调用 Proxy.foo
方法间接地调用服务端的对应代码。数据传输的过程通过 transact
方法实现,其底层是 Android 的 Binder IPC;而数据的封装过程则通过 Parcel 实现。
可以看到上面模版代码中客户端分别调用了 writeInterfaceToken
、writeInt
、writeString
和 writeTypedObject
来填充传输的 _data
,而 Stub 类的 onTransact 中以同样的顺序分别调用了 enforceInterface
、readInt
、readString
、readTypedObject
来获取 _data
中的数据。
Parcelable
在上面的 AIDL 中,我们还定义了一个数据结构 Person,该结构同样会由 AIDL 生成对应的模版类:
代码语言:javascript复制public static class Person implements android.os.Parcelable
{
public java.lang.String name;
public int age = 0;
public boolean gender = false;
public static final android.os.Parcelable.Creator<Person> CREATOR = new android.os.Parcelable.Creator<Person>() {
@Override
public Person createFromParcel(android.os.Parcel _aidl_source) {
Person _aidl_out = new Person();
_aidl_out.readFromParcel(_aidl_source);
return _aidl_out;
}
@Override
public Person[] newArray(int _aidl_size) {
return new Person[_aidl_size];
}
};
@Override public final void writeToParcel(android.os.Parcel _aidl_parcel, int _aidl_flag)
{
int _aidl_start_pos = _aidl_parcel.dataPosition();
_aidl_parcel.writeInt(0);
_aidl_parcel.writeString(name);
_aidl_parcel.writeInt(age);
_aidl_parcel.writeInt(((gender)?(1):(0)));
int _aidl_end_pos = _aidl_parcel.dataPosition();
_aidl_parcel.setDataPosition(_aidl_start_pos);
_aidl_parcel.writeInt(_aidl_end_pos - _aidl_start_pos);
_aidl_parcel.setDataPosition(_aidl_end_pos);
}
public final void readFromParcel(android.os.Parcel _aidl_parcel)
{
int _aidl_start_pos = _aidl_parcel.dataPosition();
int _aidl_parcelable_size = _aidl_parcel.readInt();
try {
if (_aidl_parcelable_size < 4) throw new android.os.BadParcelableException("Parcelable too small");;
if (_aidl_parcel.dataPosition() - _aidl_start_pos >= _aidl_parcelable_size) return;
name = _aidl_parcel.readString();
if (_aidl_parcel.dataPosition() - _aidl_start_pos >= _aidl_parcelable_size) return;
age = _aidl_parcel.readInt();
if (_aidl_parcel.dataPosition() - _aidl_start_pos >= _aidl_parcelable_size) return;
gender = (0!=_aidl_parcel.readInt());
} finally {
if (_aidl_start_pos > (Integer.MAX_VALUE - _aidl_parcelable_size)) {
throw new android.os.BadParcelableException("Overflow in the size of parcelable");
}
_aidl_parcel.setDataPosition(_aidl_start_pos _aidl_parcelable_size);
}
}
@Override
public int describeContents() {
int _mask = 0;
return _mask;
}
}
其中关键的是 writeToParcel 和 CREATOR.createFromParcel
方法,分别填充了该自定义结构序列化和反序列化的实现,当然我们也可以自己继承 Parcelable
去实现自己的可序列化数据结构。
内存布局
从接口上看,Parcel 可以支持按照一定顺序写入和读取 int、long 等原子数据,也支持 String、IBinder、和 FileDescriptor 这些复杂的数据结构。为了理解后文介绍的漏洞,还需要了解在二进制层面这些数据的存储方式。
Parcel 的代码接口实现在 android/os/Parcel.java 中,但大部分方法最终都会调用到其中的 native 方法,其 JNI 定义在 frameworks/base/core/jni/android_os_Parcel.cpp 文件里,最终的实现则是在 frameworks/native/libs/binder/Parcel.cpp 中。
以 Parcel.writeInt
为例,其 Java 实现很简单,直接转到 native 方法:
private static native int nativeWriteInt(long nativePtr, int val);
public final void writeInt(int val) {
int err = nativeWriteInt(mNativePtr, val);
if (err != OK) {
nativeSignalExceptionForError(err);
}
}
C 中的 JNI 实现则是先将 nativePtr 转换为 Parcel 指针,而后直接调用 writeInt32
方法:
static int android_os_Parcel_writeInt(jlong nativePtr, jint val) {
Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr);
return (parcel != NULL) ? parcel->writeInt32(val) : OK;
}
接下来就是最终实际的实现了:
代码语言:javascript复制status_t Parcel::writeInt32(int32_t val)
{
return writeAligned(val);
}
template<class T>
status_t Parcel::writeAligned(T val) {
static_assert(PAD_SIZE_UNSAFE(sizeof(T)) == sizeof(T));
static_assert(std::is_trivially_copyable_v<T>);
if ((mDataPos sizeof(val)) <= mDataCapacity) {
restart_write:
memcpy(mData mDataPos, &val, sizeof(val));
return finishWrite(sizeof(val));
}
status_t err = growData(sizeof(val));
if (err == NO_ERROR) goto restart_write;
return err;
}
writeAligned
是个模版函数,用于写入基础的 C 数据类型,即 int、float、double 等,也可以写入指针数据。实现也相对简单,这里面就涉及到了 Parcel 内部的几个重要数据结构:
- mData: 序列化数据内存缓冲区的内存起始地址(指针);
- mDataPos: 序列化数据当前解析到的相对位置;
- mDataCapacity: 缓冲区的总大小;
还有一个字段 mDataSize 表示当前序列化数据的大小,其实这个字段基本上和 mDataPos 的值是一致的,二者都在 finishWrite 函数中进行更新:
代码语言:javascript复制status_t Parcel::finishWrite(size_t len)
{
if (len > INT32_MAX) {
// don't accept size_t values which may have come from an
// inadvertent conversion from a negative int.
return BAD_VALUE;
}
//printf("Finish write of %dn", len);
mDataPos = len;
ALOGV("finishWrite Setting data pos of %p to %zu", this, mDataPos);
if (mDataPos > mDataSize) {
mDataSize = mDataPos;
ALOGV("finishWrite Setting data size of %p to %zu", this, mDataSize);
}
//printf("New pos=%d, size=%dn", mDataPos, mDataSize);
return NO_ERROR;
}
而如果当前缓冲区的内存不足,则会使用 growData 方法进行更新:
代码语言:javascript复制status_t Parcel::growData(size_t len)
{
if (len > INT32_MAX) {
// don't accept size_t values which may have come from an
// inadvertent conversion from a negative int.
return BAD_VALUE;
}
if (len > SIZE_MAX - mDataSize) return NO_MEMORY; // overflow
if (mDataSize len > SIZE_MAX / 3) return NO_MEMORY; // overflow
size_t newSize = ((mDataSize len)*3)/2;
return (newSize <= mDataSize)
? (status_t) NO_MEMORY
: continueWrite(std::max(newSize, (size_t) 128));
}
continueWrite
方法的实现比较复杂,因为其中还支持传入小于 mDataSize 的参数去缩小 Parcel 内存,不过在我们这里的上下文中仅用于增长内存,因此实际上最终只是通过 realloc
或者 malloc
去分配更多的内存:
if (desired > mDataCapacity) {
uint8_t* data = reallocZeroFree(mData, mDataCapacity, desired, mDeallocZero);
if (data) {
LOG_ALLOC("Parcel %p: continue from %zu to %zu capacity", this, mDataCapacity,
desired);
gParcelGlobalAllocSize = desired;
gParcelGlobalAllocSize -= mDataCapacity;
mData = data;
mDataCapacity = desired;
} else {
mError = NO_MEMORY;
return NO_MEMORY;
}
}
这部分目前只需要了解即可,我们关注的还是前面写入数据的逻辑,回顾一下是在 writeAligned 方法中,直接通过 memcpy
去写入的数据,因此对于基础数字类型是没有额外开销的,且序列化的字节序就是当前机器的字节序。这也是为什么 Parcel 只适合在同一设备中实现 IPC,如果对于不同设备中可能会出现字节序的问题。
String
说完了 Int 我们接着看常用的 String 类型,JNI 的定义和实现如下:
代码语言:javascript复制static void android_os_Parcel_writeString16(JNIEnv *env, jclass clazz, jlong nativePtr,
jstring val) {
Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr);
if (parcel != nullptr) {
status_t err = NO_ERROR;
if (val) {
// NOTE: Keep this logic in sync with Parcel.cpp
const size_t len = env->GetStringLength(val);
const size_t allocLen = len * sizeof(char16_t);
err = parcel->writeInt32(len);
char *data = reinterpret_cast<char*>(parcel->writeInplace(allocLen sizeof(char16_t)));
if (data != nullptr) {
env->GetStringRegion(val, 0, len, reinterpret_cast<jchar*>(data));
*reinterpret_cast<char16_t*>(data allocLen) = 0;
} else {
err = NO_MEMORY;
}
} else {
err = parcel->writeString16(nullptr, 0);
}
if (err != NO_ERROR) {
signalExceptionForError(env, clazz, err);
}
}
}
和之前差别不大,值得注意的是 Parcel::writeInplace
返回的是待写入的内存地址,直接用了 JNIEnv::GetStringRegion
去进行写入。如果 Java 传入的字符串是 null
,则使用 writeString16(nullptr)
去写入,其内部实现是写入特殊的整数 -1
:
代码语言:javascript复制GetStringRegion 返回的是 Unicode 字符,因此每个字符占 2 字节;
status_t Parcel::writeString16(const char16_t* str, size_t len)
{
if (str == nullptr) return writeInt32(-1);
// ...
}
因此对于字符串结构,Parcel 的序列化也是无开销顺序写入的。
Array
数组也是个常用的数据类型,但不同的数组传输格式有所不同。对于 char/int/long 等原始类型而言,传输数组实际上就是逐个写入每个元素,并且在前面写入数组的大小:
代码语言:javascript复制public final void writeIntArray(@Nullable int[] val) {
if (val != null) {
int N = val.length;
writeInt(N);
for (int i=0; i<N; i ) {
writeInt(val[i]);
}
} else {
writeInt(-1);
}
}
但是 wirteByteArray 有所优化:
代码语言:javascript复制public final void writeByteArray(@Nullable byte[] b, int offset, int len) {
if (b == null) {
writeInt(-1);
return;
}
ArrayUtils.throwsIfOutOfBounds(b.length, offset, len);
nativeWriteByteArray(mNativePtr, b, offset, len);
}
JNI 中直接使用 memcpy 去写入:
代码语言:javascript复制static void android_os_Parcel_writeByteArray(JNIEnv* env, jclass clazz, jlong nativePtr,
jobject data, jint offset, jint length)
{
Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr);
parcel->writeInt32(length);
void* dest = parcel->writeInplace(length);
jbyte* ar = (jbyte*)env->GetPrimitiveArrayCritical((jarray)data, 0);
if (ar) {
memcpy(dest, ar offset, length);
env->ReleasePrimitiveArrayCritical((jarray)data, ar, 0);
}
}
Parcelable
对于 Parcelable
类型的数据,使用 writeParcelable 方法进行写入:
public final void writeParcelable(@Nullable Parcelable p, int parcelableFlags) {
if (p == null) {
writeString(null);
return;
}
writeParcelableCreator(p);
p.writeToParcel(this, parcelableFlags);
}
public final void writeParcelableCreator(@NonNull Parcelable p) {
String name = p.getClass().getName();
writeString(name);
}
其中需要注意的是在调用 Parcelable.writeToParcel
之前,会先获取 Parcelable 实际的类名,并以字符串的方式写入。
FileDescriptor
Andorid IPC 的一个特点是可以支持传输文件句柄,其 JNI 实现如下:
代码语言:javascript复制static void android_os_Parcel_writeFileDescriptor(JNIEnv* env, jclass clazz, jlong nativePtr, jobject object)
{
Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr);
if (parcel != NULL) {
const status_t err =
parcel->writeDupFileDescriptor(jniGetFDFromFileDescriptor(env, object));
if (err != NO_ERROR) {
signalExceptionForError(env, clazz, err);
}
}
}
object 为 FileDescriptor
对象,这里的实现就是获取 FileDescriptor.fd
,即 open(2)
返回的,int 格式的文件描述符,进行 fcntl(oldFd, F_DUPFD_CLOEXEC, 0)
复制后进行写入。
Binder 的系统调用本身就支持传输文件类型的数据,因此这里 Parcel 的实现只需要做好上层的封装:
代码语言:javascript复制status_t Parcel::writeFileDescriptor(int fd, bool takeOwnership) {
// ...
switch (rpcFields->mSession->getFileDescriptorTransportMode()) {
case RpcSession::FileDescriptorTransportMode::UNIX:
case RpcSession::FileDescriptorTransportMode::TRUSTY: {
if (status_t err = writeInt32(RpcFields::TYPE_NATIVE_FILE_DESCRIPTOR); err != OK) {
return err;
}
if (status_t err = writeInt32(rpcFields->mFds->size()); err != OK) {
return err;
}
}
}
flat_binder_object obj;
obj.hdr.type = BINDER_TYPE_FD;
obj.flags = 0;
obj.binder = 0; /* Don't pass uninitialized stack data to a remote process */
obj.handle = fd;
obj.cookie = takeOwnership ? 1 : 0;
return writeObject(obj, true);
}
值得一提的是,writeObject 中写入数据除了更新上面提到的 mData 等字段,还需要更新 kernelFields
,如 mObjects
字段,作为内核参数的记录。
Binder
在 Android IPC 中一个重要的参数类型就是回调,即客户端发送一个 IBinder 类型的对象给服务端,然后服务端可以调用其 onTransact 方法实现反向异步的数据传输。对于这种类型的数据,主要通过 writeStrongBinder 方法:
代码语言:javascript复制public final void writeStrongBinder(IBinder val) {
nativeWriteStrongBinder(mNativePtr, val);
}
JNI 实现和传输文件类似:
代码语言:javascript复制static void android_os_Parcel_writeStrongBinder(JNIEnv* env, jclass clazz, jlong nativePtr, jobject object)
{
Parcel* parcel = reinterpret_cast<Parcel*>(nativePtr);
if (parcel != NULL) {
const status_t err = parcel->writeStrongBinder(ibinderForJavaObject(env, object));
if (err != NO_ERROR) {
signalExceptionForError(env, clazz, err);
}
}
}
主要分为两步,第一步取出 IBinder Java 对象的 Native 指针,即 IBinder.mObject
,然后将其转为 C 的 IBinder 指针传入:
status_t Parcel::writeStrongBinder(const sp<IBinder>& val)
{
return flattenBinder(val);
}
传输 IBinder 类型的数据同样需要更新 mObjects 缓存。
其他
除了上面介绍的这些,Parcel 实现中还有许多值得关注的细节,比如 writeBlob 同样也是写入 byte[]
,但对于过大的数据会选择用共享内存的方式去进行传输。但根据上面的简单分析,我们也能大致看出 Parcel 的一些问题,即序列化和反序列化纯属手工操作,并且在某些操作中没有严格的类型检查等,下面我们就来逐一探讨。
Bundle
在 Andorid 中,Bundle 类是一个类似 HashMap 的数据结构,但其是为了在 Parcel 序列化/反序列化的使用中而高度优化的。因此理解 Bundle 本身的序列化过程对我们理解后面的内容也至关重要。
序列化
Bundle 的序列化过程调用链路如下:
- Bundle.writeToParcel
- BaseBundle.writeToParcelInner
- parcel.writeArrayMapInternal
writeToParcelInner 中主要负责写入 Bundle 相关的头部字段:
代码语言:javascript复制void writeToParcelInner(Parcel parcel, int flags) {
// Keep implementation in sync with writeToParcel() in
// frameworks/native/libs/binder/PersistableBundle.cpp.
final ArrayMap<String, Object> map;
synchronized (this) {
// ...
}
// Special case for empty bundles.
if (map == null || map.size() <= 0) {
parcel.writeInt(0);
return;
}
int lengthPos = parcel.dataPosition();
parcel.writeInt(-1); // dummy, will hold length
parcel.writeInt(BUNDLE_MAGIC);
int startPos = parcel.dataPosition();
parcel.writeArrayMapInternal(map);
int endPos = parcel.dataPosition();
// Backpatch length
parcel.setDataPosition(lengthPos);
int length = endPos - startPos;
parcel.writeInt(length);
parcel.setDataPosition(endPos);
}
writeArrayMapInternal 则主要实现 Bundle 内部字典数据的写入:
代码语言:javascript复制void writeArrayMapInternal(@Nullable ArrayMap<String, Object> val) {
if (val == null) {
writeInt(-1);
return;
}
// Keep the format of this Parcel in sync with writeToParcelInner() in
// frameworks/native/libs/binder/PersistableBundle.cpp.
final int N = val.size();
writeInt(N);
int startPos;
for (int i=0; i<N; i ) {
writeString(val.keyAt(i));
writeValue(val.valueAt(i));
}
}
可以看到序列化 Bundle 的过程是和 序列化 ArrayMap 一致的,即先写入整型的 size,然后依次写入每个 key 和 value。这里值得注意的是 wirteValue 的实现:
代码语言:javascript复制public final void writeValue(@Nullable Object v) {
if (v == null) {
writeInt(VAL_NULL);
} else if (v instanceof String) {
writeInt(VAL_STRING);
writeString((String) v);
} else if (v instanceof Integer) {
writeInt(VAL_INTEGER);
writeInt((Integer) v);
} else if (v instanceof Map) {
writeInt(VAL_MAP);
writeMap((Map) v);
} // ....
else {
Class<?> clazz = v.getClass();
if (clazz.isArray() && clazz.getComponentType() == Object.class) {
// Only pure Object[] are written here, Other arrays of non-primitive types are
// handled by serialization as this does not record the component type.
writeInt(VAL_OBJECTARRAY);
writeArray((Object[]) v);
} else if (v instanceof Serializable) {
// Must be last
writeInt(VAL_SERIALIZABLE);
writeSerializable((Serializable) v);
} else {
throw new RuntimeException("Parcel: unable to marshal value " v);
}
}
}
注意,为了便于按照时间线理解历史漏洞,这里代码使用的是 Android 8.0 中的版本。
writeValue
会根据对象类型分别写入一个代表类型的整数以及具体的数据。所支持的类型如下所示:
// Keep in sync with frameworks/native/include/private/binder/ParcelValTypes.h.
private static final int VAL_NULL = -1;
private static final int VAL_STRING = 0;
private static final int VAL_INTEGER = 1;
private static final int VAL_MAP = 2;
private static final int VAL_BUNDLE = 3;
private static final int VAL_PARCELABLE = 4;
private static final int VAL_SHORT = 5;
private static final int VAL_LONG = 6;
private static final int VAL_FLOAT = 7;
private static final int VAL_DOUBLE = 8;
private static final int VAL_BOOLEAN = 9;
private static final int VAL_CHARSEQUENCE = 10;
private static final int VAL_LIST = 11;
private static final int VAL_SPARSEARRAY = 12;
private static final int VAL_BYTEARRAY = 13;
private static final int VAL_STRINGARRAY = 14;
private static final int VAL_IBINDER = 15;
private static final int VAL_PARCELABLEARRAY = 16;
private static final int VAL_OBJECTARRAY = 17;
private static final int VAL_INTARRAY = 18;
private static final int VAL_LONGARRAY = 19;
private static final int VAL_BYTE = 20;
private static final int VAL_SERIALIZABLE = 21;
private static final int VAL_SPARSEBOOLEANARRAY = 22;
private static final int VAL_BOOLEANARRAY = 23;
private static final int VAL_CHARSEQUENCEARRAY = 24;
private static final int VAL_PERSISTABLEBUNDLE = 25;
private static final int VAL_SIZE = 26;
private static final int VAL_SIZEF = 27;
private static final int VAL_DOUBLEARRAY = 28;
可以看到其中支持大部分基础类型以及 Parcelable、IBinder 等 Parcel 本身所支持的类型。
综上所述,我们可以大致得出 Bundle 的内存布局:
size | type | value |
---|---|---|
4 | Int | length |
4 | Int | BUNDLE_MAGIC (0x4C444E42) |
4 | Int | map.size() |
xxx | String | key0 |
xxx | Dynamic | val0 |
… | … | … |
xxx | String | keymap.size() - 1 |
xxx | Dynamic | valmap.size() - 1 |
其中 key 因为是 String 类型,因此内部是长度 String16 的布局,而 value 则根据类型不同使用不同的结构。
key:
- length Int32
- data String16
value:
- VAL_XXX Int32
- data writeInt/writeString/…
下面是在一个 Bundle 序列化的示例,原始内容为:
代码语言:javascript复制Bundle A = Bundle()
A.putInt("key1", 0x1337)
A.putString("key2", "hello")
A.putLong("key33", 0x0123456789)
使用 Parcel 序列化后的二进制数据如下:
Bundle 序列化
其中有几个需要注意的细节:
- length 字段是不包括 length magic 部分的,因此实际上序列化数据的总大小会比头部的结果大 8 字节即 100;
- String 数据是 4 字节对齐的(如 key33);如果本身已经对齐,也会在后面进行特殊拓展,如(key1/key2);特殊拓展的值为
Parcel::writeInplace
中的 padMask,对于已经 4 字节对齐的 mask 正好是 0;
存储
Bundle 虽然内部是 ArrayMap 结构,但实际存储的时候还有一点优化。在平时开发中细心的朋友可能会发现,有时候在获取远程的 Bundle 比如 CotentResolver.call 的结果时候,直接打印输出会是 Bundle[mParcelledData.dataSize=xxx]
的结果。但是对于自己构建的 Bundle,却可以打印出完整的 Map 元素。
这其实可以从 Bundle 的源码中看出来:
代码语言:javascript复制@Override
public synchronized String toString() {
if (mParcelledData != null) {
if (isEmptyParcel()) {
return "Bundle[EMPTY_PARCEL]";
} else {
return "Bundle[mParcelledData.dataSize="
mParcelledData.dataSize() "]";
}
}
return "Bundle[" mMap.toString() "]";
}
关键就是这个 mParcelledData
字段,该字段定义在父类即 BaseBundle 中,为 Parcel 类型。由于 Bundle 经常在进程间进行传输,因此设计上认为可以不要到对端的时候马上就反序列化出所有的 ArrayMap,而是等需要用到时再进行解析,这也可以认为是一种懒加载的技术。对于一些收到 Bundle 后马上又传输给其他服务的场景,这种设计可以直接传输未解析的 Parcel 数据而不需要来回的序列化。
因此对于 Bundle 而言,对于一些需要获取内部元素的调用时才会进行反序列,比如 size 和 isEmpty 的实现:
代码语言:javascript复制public int size() {
unparcel();
return mMap.size();
}
public boolean isEmpty() {
unparcel();
return mMap.isEmpty();
}
值得注意的是,在 Bundle 内部,ArrayMap(mMap) 和 Parcel(mParcelledData) 同一时间只能存在一个,反序列化后会填充 mMap 把 mParcelledData 回收并置为 null。
反序列化与 Bundle 风水
最早提交 Android 反序列化漏洞的是 @BednarTildeOne 在 2014 年提交的 ParceledListSlice 漏洞,但第一次引起公众关注的应该是 CVE-2017-0806。也是同样的作者,不过给出了详细的分析以及漏洞 POC 代码,这才得以引起关注。
该漏洞的原理已经有很多师傅分析过了,就不再赘述,这里直接给出一个简化的版本。假设有这么一个 Parcelable 数据结构,请问是否存在漏洞?漏洞如何利用?
代码语言:javascript复制public class Vulnerable implements Parcelable {
private long mData;
protected Vulnerable(Parcel in) {
mData = m.readInt();
}
@Override
public void writeToParcel(Parcel parcel, int flags) {
parcel.writeLong(mData)
}
// ...
}
问题很明显,可以看到其中序列化使用了 writeLong,但是反序列化过程却用了 readInt,二者不匹配,这可能会导致一些数据错误,但这能算得上一个漏洞吗?
回想 LunchAnywhere 漏洞以及对应的 patch,实际上是用户可控的 Intent 数据中带有 KEY_INTENT
extra 导致的启动任意 Activity 的问题。修复过程是判断用户提供的 Intent 是否带有该 extra,如果有则校验调用者的签名。当时的修复可以说没什么问题,但如果配合上述的漏洞,就有可能出现绕过。
这里还是以测试代码举例,假设我们的校验函数如下:
代码语言:javascript复制@Override
public void onResult(Bundle result) {
Intent intent = result.getParcelable(AccountManager.KEY_INTENT)
if (intent != null) {
checkCallingSignature()
}
// ...
send(result)
}
public void send(Bundle result); // AIDL
校验时如果 intent 不为空则进行签名校验,随后会调用 AIDL 的客户端方法 send
,该方法将 bundle 再次序列化然后发送给服务端,而服务端的实现如下:
class Server extends IServer.Stub {
@Override
public void send(Bundle result) {
Intent intent = bundle.getParcelable(KEY_INTENT);
if (intent != null) {
mContext.startActivity(intent)
}
}
}
因为客户端进行了校验,所以服务就不需要再次校验而是直接信任该 Bundle 并使用了。这里其实有个经典的漏洞模式即 TOCTOU 问题,一般情况下这个逻辑是没问题的,即 bundle 检查完后不会被再次修改。但在我们的漏洞场景中,就有可能会出现不同的 bundle,即 Self-Changing Bundle
。
自修改 Bundle
Bundle 自修改,主要还是针对上面的这种 IPC 场景,一端对 Bundle 进行了校验,发现没问题后使用 IPC 发送给另一端,且对方不加校验就直接使用。即 Bundle 在序列化 反序列化的过程中进行了改变。
假设服务端 B 接收我们的 Bundle 数据进行反序列化后再次序列化发送给服务端 C,而我们的 bundle 中带有一个类型为 Vunerable 的 key,那在 B 接收到数据后再发出去的数据会如下图右边所示,其中本该是 Int 的字段被写成了 Long,导致 C 再次反序列化时候对后续数据的解析出现异常:
From: Android Parcels: The Bad, the Good and the Better - Introducing Android's Safer Parcel
通过精心构造发送给 B 的数据,我们可以令 B 和 C 都能正常序列化出 Bundle 对象,甚至让这两个 Bundle 含有不同的 key!
漏洞利用
该漏洞如何利用呢?前文中我们已知 Bundle 序列化数据的头部后面每个元素都是 key value 的组合,那利用思路应该就是将多出来的 4 字节进行类型混淆。这种场景类似于二进制的缓冲区溢出,我们需要做的就是布局好数据使得同时满足下述条件:
- 类型混淆前后,Bundle 的 length 和 items 字段保持一致;
- 两次反序列化得到的对象都需要是合法对象;
- 第二次反序列化要能够出现一个前面没有的 Bundle key;
溢出的 4 个字节会作为后面一个 key 的一部分,而 key 的类型是 String,根据前面的介绍,其内存是长度(Int32) String16。其中 String16 是 unicode,使用 2 字节保存一个字符。因此,如果 map 元素足够的话,这 4 个字节会形成下一个 key 的长度。
在大部分情况下,溢出的部分是 Long 数据的高有效位,因此会是 0,如果此时第二次反序列化处理到这里,会认为这有个 key 长度为 0 的元素,查看读取字符串相关的代码,如下所示:
代码语言:javascript复制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 != nullptr) {
if (str[size] == u'