神马,都 2025 年啦,还有 Parcel Mismatch 漏洞?!?对!你没听错!最经典最被广泛利用的 Parcel Mismatch 漏洞,它又双叒叕来了!怕了吗?!?
去年我和 Cxxsheng 对整个 Parcelable IPC 机制进行了一些研究,并开发了一个扫描器用以扫描相关漏洞。此漏洞就是我们研究过程中发现的漏洞之一。我们于 2024 年 12 月 3 日提交漏洞报告,后被告知与另一份漏洞报告重复。以下记录了我们当时的分析。部分敏感信息已被移除或脱敏以保证安全。
本文包含了一个简单的 PoC。由于大部分用户都已经升级到有 checkKeyIntentParceledCorrectly
额外补丁的版本,参照以前类似漏洞的利用方法直接把本文里的 PoC 代码拿去攻击无法成功,故我们认为大部分用户已经得到保护,披露相关细节是安全的。
我们在此次研究中产出的其他文章(持续更新):
- 万众瞩目却又被大家忽略的历史漏洞:CVE-2022-20474分析——LazyValue下的Self-changed Bundle by Cxxsheng
细节
检出 android-12.0.0_r1 分支的源码,查看 InputMethodSubtypeArray.java 这个文件(点击可直接跳转),关注它的序列化和反序列化过程。
反序列化:
1 | /** |
序列化:
1 | /** |
很明显能看出来两边逻辑不一致。反序列化的时候首先会读一个 int 作为 count,count 大于 0 的时候才会读 decompressed size(int)和 compressed data (byte[])。而序列化的时候,count == 0
的时候确实只会写一个 int 0 进去,如果 count 不为 0,底下有两个分支,一个分支会写 count(int)、decompressed size(int)和 compressed data(byte[]),另一个分支只会写 int 0 作为 count。
所以问题就在于这个 count。简单枚举一下所有情况:count > 0
的时候,一边写 int、int、byte[] 进去,另一边读 int、int、byte[] 出来,没问题;count == 0
的时候,一边只会写 int 0 进去,另一边读 int 出来,也没问题。而如果 count < 0
,就有可能发生一边写了 int、int、byte[] 进去,而另一边只读了一个 int 出来的情况,造成数据残留,也即所谓的 parcel mismatch 问题。
那么问题就转换成,在攻击者指定负数 count 的情况下,是否存在能让 compressedData != null && decompressedSize > 0
的情况,从而多写内容进 parcel?
注意到这个 if 上面还有个 if,当 compressedData == null && decompressedSize == 0
时,会调用 marshall 和 compress 对两个字段进行重新赋值。这就是 count < 0
的时候会发生的情况,看一下这两个函数的实现:
1 | private static byte[] marshall(final InputMethodSubtype[] array) { |
我们的例子里要压缩的数据 mInstance
是 null,在 writeTypedArray
里会写一个 -1 进去作为长度,返回一个有数据的 byte[],然后进入 compress
里进行 gzip 压缩,最后生成有内容的 byte[],刚好满足下面的条件,触发漏洞。
需要注意的是,这个漏洞在 Android 14 中已经被修复,补丁: https://github.com/aosp-mirror/platform_frameworks_base/commit/bc2fbfc0b73535ce9d0c9f73b5130cfffaf4daee
然而这个补丁在很长的一段时间内并没有被当成安全补丁反向移植到旧版本。
复现
此漏洞属于我们所谓的 Parcel Mismatch 漏洞,这是一类最早可追溯至 2014 年的漏洞,最早由 Michal Bednarski 报告。对于此类漏洞应该如何利用,需要一些前置知识。这里不赘述,给出一些相关文章供大家参考:
- 经典的入门教程:Bundle风水——Android序列化与反序列化不匹配漏洞详解 by heeeeen,虽然年代久远但并不影响学习
- 比较新的文章,cover 了更多 case:Parcelable和Bundle的爱恨情仇(一)——读写不匹配 by OPPO安珀实验室
- 另一篇很好的入门文章,PoC 里详细解释了每一个值的作用及 mismatch 后的新意义:再谈Parcelable反序列化漏洞和Bundle mismatch by 小路
- 相关漏洞大总结:Android 反序列化漏洞攻防史话 by 有价值炮灰
- 一篇主要关注实际各类漏洞如何利用的文章:LaunchAnyWhere 补丁绕过:Android Bundle Mismatch 系列漏洞 复现与分析 by Clang裁缝店
读者也可以自行上网搜索,关键词: Android Parcel Mismatch
、Bundle Mismatch
、Self-changing Bundle
及 EvilParcel bug
等。
参考以往 parcel mismatch 漏洞的利用,我们这里构造一个 self changing bundle 并把一个恶意的指向系统私有未导出 PlatLogoActivity
的 intent 隐藏进去。构造的恶意 intent:
1 | new Intent().setClassName("android", "com.android.internal.app.PlatLogoActivity") |
需要注意的是,Android 13 引入的 Lazy Bundle 增强措施导致我们无法再使用这种 parcel mismatch 构造 self changing bundle,所以需要使用 Android 12 或更旧的版本进行测试。较新的安全补丁里引入了一个额外的安全检查 checkKeyIntentParceledCorrectly
对整个 AccountManagerService
进行了加固,导致我们即使使用 android 12,如果安全补丁比较新,像往常一样直接把恶意 bundle 返回给 AccountManagerService
也无法触发漏洞。这里我们只在代码里模拟一下整个流程。
InputMethodInfo
如果你直接按照往常的经验在 parcel 里写入 InputMethodSubtypeArray 的类名,写入恶意数据然后直接使用,恭喜你收到一个 BadParcelableException 作为结果。这是因为,我们可爱的 InputMethodSubtypeArray 这个类,根本就没有实现 Parcelable 这个接口,也就是说它根本不是一个 parcelable。
代码里唯一会用到这个类的地方就是 InputMethodInfo,这个类可就是根正苗红的 Parcelable 了,它序列化和反序列化的过程都会调用到 InputMethodSubtypeArray:
1 | InputMethodInfo(Parcel source) { |
mSubtypes
就是 InputMethodSubtypeArray。
需要注意的是我们这里用的是 Android 12 的源码,新的版本里往这个类加入了其他字段,会影响我们构造 bundle 的结构,这部分不重要,这里略过不讲。
多字节?少字节!
往常的漏洞里,多出的或者少掉的数据一般是攻击者可控的,因为 parcelable 本来就是传输对象内部数据的,从 parcel 读出攻击者指定的恶意数据然后再把它写进 parcel 是非常合理的。像多出 4 个字节的漏洞,一般是指定一个 0 来构造 bundle,让 bundle 读出一个原本不存在的 0 长字符串当成 key 从而影响后续解析。
而这个漏洞里多出来的可不止四个字节,而是一个 int 和一个 byte[],数据稳定但并不受我们控制。经过实验,parcel 内会残留 32 字节数据,多写的 (int) decompressedSize 作为 size 会在 InputMethodInfo 被当成 mHandledConfigChanges 读取,后续 bundle 再读取一个 String16 当成 key,这里实际读取位置已经在 byte[] 里。读 String16 会先读一个 int 作为 length,而写 byte[] 正好也会先写 length 进去,所以 byte[] 的 length 会被当成 String16 的 length。编写测试代码把残留的第一个 int 读出来,结果为 24,而总的残留大小为 32,减去两个 int 的大小,的确就是 24。很合理。
然后你会觉得 key 长度刚好等于 byte[] 长度所以刚好会把整个 byte[] 都消耗掉?当然不对,key 作为一个 String16,里面一个字符占两个字节,同时结尾必须是 \0
作为结束符,所以实际消耗的数据会比 byte[] 更多,按照多出字节的思路去构造是不对的,我们应该按照少了字节的思路去补齐这个 key,然后再在后面去填充恶意 payload。
一开始由于长度的计算错误,我以为这个长度不够我按以往的“插入一个 VAL_BYTEARRAY ”的方式去隐藏恶意 intent,所以我这里给出的 PoC 是把 intent 隐藏在了下一个 key 里面,以前都没怎么看见过有人藏进 string 里面,算是比较新的思路?后面发现大小应该是够的,懒得再改代码了。不过这种方法有个缺陷,因为 intent 现在是 key 的一部分,修改 intent 会导致 key 的 hash code 改变,而 bundle 的排序是按 hash code 排的,需要对 bundle 里的所有 key 都精心布局避免发生错位。我这里只使用一个固定的 intent 进行演示,因此在代码里直接写死就好。PoC 如下:
1 | public static Bundle makeBundle(Intent intent) { |
我这里用 BUNDLE_MAGIC_NATIVE
纯粹是我个人喜欢用,不过看其他人似乎都不喜欢用这个,用 BUNDLE_MAGIC
应该也能触发。随便打了几个字符串是为了凑 hash,虽然不太好看但是 it works
模拟系统读入 bundle 然后转发 bundle 的测试代码:
1 | Parcel parcel = Parcel.obtain(); |
运行测试代码可以发现,第一次读 intent 的时候返回 null,第二次恶意 intent 出现。
在未打补丁且没有 checkKeyIntentParceledCorrectly
的 Android 12 系统上,把构造好的 bundle 返回给 AccountManagerService
,PlatLogoActivity
直接被成功拉起。至于它在有 checkKeyIntentParceledCorrectly
的系统上有什么作用,能造成什么危害,就留给读者自己去思考。
修复
- 2025 年 2 月 Android 安全公告 对该漏洞发布了一个补丁 InputMethodSubtypeArray: prevent negative count injection,
mCount < 0
的情况会直接抛出异常中断反序列化流程。这个补丁被移植到了 Android 12、12L、13 (14 已经包含该修复),Android 11 或更低版本已经 EOL。 - AccountManagerService 整个流程可能会引入新的保护:Sanitize Bundle from AbstractAccountAuthenticator,app 返回的 bundle 中的内容会被拷贝到一个新的 bundle 中,且只有在白名单的 key 和 value 会被保留,而允许的 Parcelable 类型仅有
AccountManager.KEY_INTENT
一项,阻止了 bundle 内含有任意 Parcelable 类型进而 mismatch。需要注意的是该提交目前是已经被回滚的状态所以并未生效,但描述中表示A new version will be created.
,所以可以预计将会在未来版本中上线。
总结
这个漏洞的历史非常久远,2014 年 3 月 6 日引入此类的第一个提交 Introduce InputMethodSubtypeArray for memory efficient IPCs 就已经存在此漏洞,可能是 Android 史上生存时间最长的 parcel mismatch 漏洞。而由于其直接存在于 AOSP 而非 OEM 代码内,影响范围可谓是极大。
挖掘此类漏洞的人千千万,这个类存在的判断差异也非常明显,为什么各种扫描器都没扫出来这个洞?我觉得可能是因为这个类并没有直接实现 Parcelable 导致大部分扫描器一开始就直接把它忽略了,即使有爆出警告,由于逻辑较为复杂,很可能在后续的人工 review 过程里被认为是误报而忽略。
除了 Google 给出的修复方法,另一种思路是把序列化流程里的 if (mCount == 0)
改成 if (mCount <= 0)
,让负数 count 也只写一个 int 进去即可。可惜这种方法并没有被采用,要不然此漏洞跟上一次被大规模利用的同类漏洞 CVE-2023-20963 就可以一起被称为“少打一个运算符的代价”了(
那么,此漏洞终结之后,我们有了一个新的疑问:自 2014 年,Android 系统开发人员、供应链开发者及各设备厂商程序员就不断与此类反序列化漏洞作斗争,漏洞不断被写出来又不断被发现和修复,直到 2025 年的今天,开发人员也时常因为各种疏漏而写出类似 bug。那么,这个漏洞,会是最后一个 Parcel Mismatch 漏洞吗?我们拭目以待。