回顾 Android 的发展历程,各种反序列化相关的漏洞曾多次被公开披露和修复。
以下为本期深蓝洞察年度安全报告的第五篇。
05
Bundle, 是 Android 用于数据传递的一种特殊的键值对容器类型,通常用于 Android 进程间通信。
Bundle Mismatch 则利用序列化和反序列化不一致的差异绕过系统校验,获取以系统权限启动任意 Activity 的能力(LaunchAnyWhere)。
为了彻底解决 Bundle Mismatch 问题,Google 设计了一套名为 Lazy Bundle 的补丁。该补丁调整了不定长类型的序列化结构,在类型之后、具体数据之前,增加了一个数据块的长度字段。
Lazy Bundle 的“lazy” 体现在,当 Bundle 反序列化遇到了不定长类型的值时,不直接对具体数据进行读取,而是创建一个 LazyValue 作为值,并记录下该数据在整个 parcel 中的偏移和数据的完整长度,然后跳过相应长度读取下一组键值对。
只有明确需要取出某个 LazyValue 的真实值时,才会跳回到记录的偏移处进行反序列化,最后将 LazyValue 替换为真实值。
AOSP 的源码注释中标注了相应的数据结构和 LazyValue 的参数以便人们理解,其中 mPosition
和 mLength
即 LazyValue 记录的偏移和长度。
Lazy Bundle 于 2022 年推出的 Android 13 中实装。
如此一来,即使某个类型存在 Mismatch,Bundle 其他键值对的内容也不会受影响,缓解了 Bundle Mismatch 的利用。
Bundle Mismatch 利用的作者是 Michal Bednarski(@BednarTildeOne)。自 2017 年起,他在 GitHub 上分享了多种 Android 反序列化漏洞的利用和 writeup。
这一次,他不仅找到了 LazyValue 实现中存在的 UAF 漏洞,随后更是发现了一个可以绕过 Lazy Bundle 缓解措施的漏洞 "TheLastBundleMismatch (CVE-2023-21098、CVE-2023-45777)" ,在 Android 13 和 14 上成功重现了 LaunchAnyWhere 攻击。
通过分析 CVE-2023-45777 的补丁容易发现,TheLastBundleMismatch 漏洞在于 AccountManagerService
读取 intent 字段时并没有显式指定 getParcelable
的类型参数。
Android 13 弃用了 Parcel 和 Bundle 中原先可以反序列化任意类型的一系列方法,取而代之的是增加了 clazz 参数限制类型的版本。补丁即是使用更为健壮的函数替换了弃用版本。
diff --git a/services/core/java/com/android/server/accounts/AccountManagerService.java b/services/core/java/com/android/server/accounts/AccountManagerService.java
index 7a19d034c2c8..5238595fe2a2 100644
--- a/services/core/java/com/android/server/accounts/AccountManagerService.java
+++ b/services/core/java/com/android/server/accounts/AccountManagerService.java
@@ -4923,7 +4923,7 @@ public class AccountManagerService
p.setDataPosition(0);
Bundle simulateBundle = p.readBundle();
p.recycle();
- Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT);
+ Intent intent = bundle.getParcelable(AccountManager.KEY_INTENT, Intent.class);
if (intent != null && intent.getClass() != Intent.class) {
return false;
}
该漏洞的利用和 LazyValue 的实现弱点有关。
当一个 Bundle 重新序列化时,尚未被读出的 LazyValue 会使用 appendFrom
直接将其指向的对应数据块(包括开头的 type
和 length
两个字段)复制到新的 parcel 中。
乍一看,这种处理方式似乎没有什么不妥之处,但如果攻击者有能力修改旧 parcel 中 type
和 length
两处数据,让 LazyValue 复制改动后的数据,事情就变得不一样了:
在 Bundle 下一次被反序列化时,修改的 type
可以让这个 LazyValue 不再 “lazy”,从而改变读取后续键值对的位置,读出原本不存在的键,实现 Lazy Bundle 的绕过;修改 length
也可以达到同样的效果。
那么问题就转化为,是否存在这样一个 Parcelable 类型,其在反序列化的过程中会修改原始 parcel 的数据?
这种类型(至少在 AOSP 中)并不存在。
不过 @BednarTildeOne 找到了一个等效的组合:android.os.PooledStringWriter
和 android.content.pm.PackageParser$Activity
。
前者并非 Parcelable 类型,但是却可以接受 Parcel 类型作为构造函数的参数。作为一个 “writer”,它向传入的 parcel 调用了 writeInt(0)
的写入操作。
后者是一个 Parcelable,在反序列化时可以使用反射调用任意类型的构造函数,并传入 parcel 对象,实现了和 PooledStringWriter
的串联。
如果构造数据让写入的 0
覆盖 LazyValue 的 type
,就能让 Bundle 下一次反序列化时将这个 LazyValue 解释成字符串类型,绕过缓解措施。
美中不足的是,PackageParser$Activity
会将反射构造的结果转换为 IntentInfo
类型,不可避免地导致类型转换异常。因此需要寻找另一个包含异常处理的 Parcelable,在它反序列化的 try-catch 流程中进行 PackageParser$Activity
和 PooledStringWriter
的反序列化。
这一次 AOSP 既不存在这样的 Parcelable,也不存在其他的等效组合。这可能要归功于上文提到的 Android 13 新增限制类型的 Parcel 方法,即使有少数几个 Parcelable 使用了异常处理,也无法控制它们去反序列化我们想要的类型。
AOSP 中不存在,不代表其他厂商 OEM 系统中不存在。
在 2022 年度最“不可赦”的漏洞利用 中,我们介绍了一个三星 OEM 的漏洞利用链。这次 @BednarTildeOne 也是在三星中发现了 SemImageClipData
类型完美满足利用要求。
事情结束了吗?
还差最后一小步。
如果直接在 intent 字段放一个 SemImageClipData
类型,反序列化的结果会被 AccountManagerService
强制转换为 Intent
,再次出现异常。bundle.getParcelable
内部实际上有一层类型转换的错误处理,只要把 SemImageClipData
套一层数组成为 Parcelable[]
,函数最终就可返回null
。
至此,整个攻击利用链大功告成,其复杂程度和精妙程度令人叹为观止。
只可惜 SemImageClipData
这种类型不可多得。DARKNAVY 在尝试对国内品牌的旗舰机型复现漏洞时,并不能找到相似的替代品。
考虑到漏洞的核心在于反序列化时对 parcel 的修改操作,我们放弃 PooledStringWriter
和 PackageParser$Activity
的组合拳,去寻找反序列化时直接修改 parcel、并且不会触发异常的类型。
最终我们找到一个 Parcelable,直接调用 unmarshall
修改了整个 parcel 的数据,借此成功完成了复现。
在复现中,我们利用漏洞分别启动了修改密码的页面和 Android 14 的彩蛋页面。这两个页面分别是设置应用和安卓系统本身的未导出 Activity。
该机型最新版本上已经修复此漏洞。值得一提的是,我们利用的这个 Parcelable 并非第一次出现在该品牌的手机中。在其他机型的 Android 12 上,我们就发现过它的存在。
而,TheLastBundleMismatch 在 Android 12 及之前并不能被利用:
原本 parcel 的读取是线性的,如果读取的途中修改了 parcel 数据,也只能在新旧数据之间二选一读到。LazyValue 的出现改变了这种线性,也让这个潜藏多年的类型能够被利用。
在 2014 年,为了修复 LaunchAnyWhere 漏洞,Android 增加了对 intent 的校验,于是 2017 年出现了 Bundle Mismatch 绕过校验。
2022 年新的 Lazy Bundle 修复 Bundle Mismatch,又在一年后被 TheLastBundleMismatch 绕过。
细心的读者可能发现,TheLastBundleMismatch 有两个 CVE 编号。
这是因为,第一次的补丁在修复另外一个漏洞时无意间被去除,导致漏洞死而复生。
旧的攻击面的修复却引入了新的攻击面,只是每次的利用越来越复杂、有越来越多的限制。
我们相信在持续不断的攻防对抗中系统会变得更安全,但:TheLastBundleMismatch 真的会是 "The Last" 吗?
参 考:
[1] https://github.com/michalbednarski/TheLastBundleMismatch
明日,请继续关注《深蓝洞察 | 2023 年度安全报告》第六篇。