这次我们从 Android 安全公告 中作为 CVE-2023-45777 修复而出现的补丁开始:```diff 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;
}
Few people were puzzled by it enough to ask me, previously I've replied to them with some hints and now I'm publishing full writeup for this issue
But first lets provide some context about what is going on in this patch
This is change in [`checkKeyIntent()` method](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/accounts/AccountManagerService.java;l=4938-4954;drc=47de64a38aa1799cb41f41b2ea0c539ee61de64d). This method performs multiple checks to ensure that `Intent` provided by application is safe for system to launch (using privileges of system)
First, this method uses `checkKeyIntentParceledCorrectly()` which serializes and deserializes again `Bundle` which we're checking and checks if `Intent` taken from `Bundle` before that matches `Intent` from `Bundle` after such cycle. Since launch of `Intent` happens in other system app processes than one which performs validation, it was previously [possible to construct `Bundle`-s which appeared safe during validation inside `AccountManagerService`, but contained different `Intent` after being sent to next process](https://github.com/michalbednarski/IntentsLab/issues/2#issuecomment-344365482). This simulates sending `Bundle` to next process in order to detect such situations.
After `checkKeyIntentParceledCorrectly()` we have `bundle.getParcelable()` call, which this patch switches from deprecated version that could construct any object to one that validates that object that is about to be deserialized is of type which was specified in second parameter
That version with type parameter was introduced in Android 13, as part of larger `Parcel`/`Bundle` hardening. In particular, before Android 13 when `Bundle` was sent between processes, it kept raw copy of whole serialized data until any item was accessed, at which point every value was deserialized. Now when any value is accessed for first time after `Bundle` has been received, only `String` keys and the values of primitive types are deserialized, while non-primitive values are left as `LazyValue`-s, which have their length stored as part of serialized data in order to ensure that even when serialization/deserialization logic is mismatched, such mismatches won't affect other entries
Before we dive in, lets have a look at `LazyValue`: In it's source code [we've got nice comment explaining it's data structure](https://cs.android.com/android/platform/superproject/+/master:frameworks/base/core/java/android/os/Parcel.java;l=4392-4399;drc=03c34f57c05feecfb090de3917787f049cb5f804)```
| 4B | 4B |
mSource = Parcel{... | type | length | object | ...}
a b c d
length = d - c
mPosition = a
mLength = d - a
mPosition 和 mLength 描述了整个 LazyValue 数据在原始 Parcel 中的位置,包括 type 和 length。“length”(开头没有“m”)指的是写入 Parcel 的长度值,且不包含头部(type 和 length)
如果包含 LazyValue 的 Bundle 被转发到另一个进程,整个 LazyValue(包括 type 和 length 字段)会从 Bundle.mParcelledData 原样复制到目标 Parcel
当访问由 LazyValue 表示的 Bundle 项时,Parcel 会被回退到 mPosition 并调用 readValue()。如果向 bundle.getParcelable() 传入了类型参数,该参数会传递给 readValue(),后者既会确保即将反序列化的类型是预期的类型,也会在反序列化后验证反序列化出的值类型是否为预期类型。反序列化后 LazyValue 会被替换,因此下次将 Bundle 写入 Parcel 时,该值会再次通过 writeValue() 进行序列化
使用带类型的 Bundle.get*()/Parcel.read*() 参数主要与诸如 Parcel.readParcelableList() 之类的方法相关,该方法返回 ArrayList,并且由于 Java 类型擦除,即使你写了类似 List<SomeParcelableType> field = parcel.readParcelableList(); 的代码,<SomeParcelableType> 部分在运行时也不会被强制检查,因此这样的 List 可以包含系统中可用的任何 Parcelable 类,从而系统中所有可用的 createFromParcel/writeToParcel 都可能被用作包含此类 List 的类型的序列化/反序列化的一部分
你可能还想查看 Android 安全与隐私团队关于引入这些机制的演示(幻灯片,视频)
不过在这里,使用带类型的版本似乎是多余的,因为我们也显式检查了返回对象的类型。那么这里到底发生了什么,修复的又是什么漏洞呢?
再看一下开头的补丁
"intent" 键下反序列化的值是一个 Intent
Intent 对象触发不匹配,那我们将面临更大的问题Intent
Bundle 被发送到另一个进程后在其中包含一个 Intent,但 Parcelable 的类型保存在任何可能的不匹配偏移量之前,并且 LazyValue 的长度前缀机制阻止了我们在 writeToParcel/createFromParcel 不匹配的情况下修改后续的键值对那么,这里调用不带类型参数的 bundle.getParcelable(AccountManager.KEY_INTENT) 可能做什么危险的事情呢?
[答案在下一段,先猜一猜再继续读。如果我有一个兽设,这里会是放一些艺术图的地方]
答案是调用不相关的 createFromParcel(),它实际上修改了存储在不同键下的 LazyValue 的原始数据,而这些数据将被原样传递给下一个进程
我们有一个 createFromParcel() 实现,它实际上可以在提供的 Parcel 上调用 writeInt()
但这并不是因为 writeInt 被错误地放置,而是因为不受限制的反射。特别是在 PackageParser 内部有以下代码:```java
final Class cls = (Class) Class.forName(componentName);
final Constructor cons = cls.getConstructor(Parcel.class);
intentsList = new ArrayList<>(N); for (int i = 0; i < N; ++i) { intentsList.add(cons.newInstance(in)); }
We can have `Parcel` object which was passed to `createFromParcel` passed to any available in system `public` constructor that accepts single `Parcel` argument
And then [we have following code](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/PooledStringWriter.java;l=51-56;drc=782d49826862cbdc9d020fc9d85f8a6f64675dcb):```java
public PooledStringWriter(Parcel out) {
mOut = out;
mPool = new HashMap<>();
mStart = out.dataPosition();
out.writeInt(0); // reserve space for final pool size.
}
我们有一个构造函数,它会对传入的 Parcel 调用 writeInt(0),但有几个因素使利用变得复杂。
首先,虽然在源代码中并不直接可见,但在调用 newInstance() 之后,会立即执行一次类型转换,并抛出 ClassCastException。
我需要某种东西,能在 createFromParcel 期间于 try 块内调用另一个类的 createFromParcel,然后失败并传播捕获到的异常。
这部分是漏洞利用在纯 AOSP 上无法实际生效的地方,我使用了三星特有的类。
我在本仓库中包含了该类相关部分的副本。
本仓库还包含将其集成到 AOSP 中的脚本,因此你可以运行它进行测试(将 AOSP 检出路径作为参数传入,例如 ./make-aosp-buggy.sh /path/to/aosp),还原本文开头描述的更改,然后针对你的 AOSP 构建运行此漏洞利用。
我之前曾使用 AOSP 中的 OutputConfiguration 类来吞掉异常,在 Android 13 之前,在 createFromParcel() 中吞掉异常并允许构造其他 Parcelable 对象本身就是一种漏洞,但在 SemImageClipData 的情况下,这些 Android 版本上并不存在异常吞掉行为。
然而,SemImageClipData 与之前使用的 OutputConfiguration 之间有一个重要区别:即使 SemImageClipData 捕获了异常,它仍然会返回非空对象,如果该对象稍后被转换为另一种类型,就会触发 ClassCastException,而这正是我们试图避免的。
Java 类型擦除意味着泛型方法实际上并不知道调用者使用的泛型类型。这通常有助于漏洞利用。```java // When we read some List, this actually didn't check if list contains only SomeParcelableType List myList = sourceParcel.readParcelableList();
// Above is why Android 13 has introduced typed methods that enforce type at runtime List myList = sourceParcel.readParcelableList(SomeParcelableType.class);
// If untyped method was used when reading, list can contain non-SomeParcelableType // items and they would be written without errors targetParcel.writeParcelableList(myList, 0);
// However if List contains non-SomeParcelableType item, this would throw during item access // (That however commonly didn't happen if we used Parcelable object only as container in gadget chain) SomeParcelableType myItem = myList.get(0);
这次类型擦除没有帮到我们。首先我们有一个方法,它实际上通过反射调用了构造函数```java
private static <T extends IntentInfo> ArrayList<T> createIntentsList(Parcel in) {
// ...
final ArrayList<T> intentsList;
// ...
intentsList.add(cons.newInstance(in));
// ...
return intentsList;
}
此方法具有泛型参数 T。调用方使用了什么参数类型并不重要,但由于此方法的声明中有 <T extends IntentInfo>,因此带有 newInstance() 调用的那一行变成了 intentsList.add((IntentInfo) cons.newInstance(in));,尽管 newInstance() 返回的是 Object,而 ArrayList.add() 接受 Object 作为参数。这就导致需要将该调用包装在某个能吞掉 Exception 的 Parcelable 中。
然后我们还有 bundle.getParcelable() 调用。```java
@Deprecated
@Nullable
public T getParcelable(@Nullable String key) {
unparcel();
Object o = getValue(key);
if (o == null) {
return null;
}
try {
return (T) o;
} catch (ClassCastException e) {
typeWarning(key, o, "Parcelable", e);
return null;
}
}
反序列化过程由 `getValue()` 调用执行,该调用实际上会触发 `createFromParcel()` 调用。如果在此处发生 `ClassCastException`,它将不会被捕获。`getValue()` 现在会返回通过 [`parcel.readValue()`](https://developer.android.com/reference/android/os/Parcel#readValue(java.lang.ClassLoader)) 为该键反序列化得到的任何值。
然而,如果我们将 `SemImageClipData` 作为值放入 `try`-`catch` 块中,我们会尝试将其转换为 `T`,在这种情况下,`T` 是方法泛型声明中声明的 `Parcelable`。调用方将此方法作为泛型使用,其中 `T` 为 `Intent`,但 `getParcelable()` 并不知道这一点,转换为 `Intent` 的操作发生在调用方,因此 `ClassCastException` 会在 `try` 块之外抛出。
不过,我们可以将 `SemImageClipData` 包装在 `Parcelable[]` 数组中,然后 `getParcelable()` 内部将 `Parcelable[]` 转换为 `Parcelable` 的尝试会失败,并在 `try` 块内抛出 `ClassCastException`,该 `Exception` 会被记录日志,并返回 `null`,随后被 `checkKeyIntent()` 接受。
# 布局
所以现在我们需要在 `Bundle` 内对齐内容,以便在 `writeToParcel`/`createFromParcel` 循环之后,其内容是我们准备好的内容。
但与典型的“`Bundle` FengShui”不同——后者是通过让 `createFromParcel` 读取比之前匹配的 `writeToParcel` 更多或更少的数据来触发——这里我们通过 `writeInt(0)` 覆盖了未反序列化的 `LazyValue` 的一部分。
以下是 `Bundle.mParcelledData` 首次被 `AccountManagerService` 反序列化时的样子(偏移量通过调试器附加到 `system_server` 后调用 `dataPosition()` 获取):
<table>
<tr><th>偏移量</th><th>值</th><th>备注</th></tr>
<tr><td>0</td><td>3</td><td>键值对数量</td></tr>
<tr><td>4</td><td>"intent"</td><td><code>Bundle</code> 中的第一个键,即通过 <code>getParcelable(AccountManager.KEY_INTENT)</code> 访问的键</td></tr>
<tr><td>24</td><td>16</td><td>第一个 <code>LazyValue</code> 从这里开始,类型为 <code>VAL_PARCELABLEARRAY</code></td></tr>
<tr><td>28</td><td>340</td><td><code>LazyValue</code> 的声明长度,用于在 <code>Bundle</code> 中查找下一个键。我们的 <code>LazyValue</code> 在被读取后实际上不会有这个大小,但 <code>LazyValue.apply</code> <a href="https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/os/Parcel.java;l=4501-4505;drc=4d6b008243a5b1b1fb4e725e37e14651a24a4a4d">会通过 <code>Slog.wtfStack()</code> 报告这一点</a>,而 <a href="https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/Slog.java;l=230-235;drc=4d6b008243a5b1b1fb4e725e37e14651a24a4a4d">这不会抛出异常</a></td></tr>
<tr><td>32</td><td>1</td><td><code>Parcelable[]</code> 数组的长度,数组只有一个元素,其存在是为了让 <code>ClassCastException</code> 发生在 <code>bundle.getParcelable()</code> 内部的 <code>try</code> 块中</td></tr>
<tr><td>36</td><td>"com.samsung.android.<br>content.clipboard.data.<br>SemImageClipData"</td><td><code>Parcelable</code> 类的名称,这是将吞掉异常的包装类</td></tr>
<tr><td>160</td><td>2</td><td><code>createClipBoardData()</code> 使用的类型标签</td></tr>
<tr><td>164</td><td></td><td><code>SemImageClipData</code> 超类构造函数读取的项目(包括 <code>readParcelable()</code> 调用,但该调用发生在 <code>try</code> 块之外)。这些并不重要,但我们需要先经过它们才能到达 <code>createFromParcel()</code> 中感兴趣的部分</td></tr>
<tr><td>252</td><td></td><td><code>SemImageClipData.readFromSource()</code> 读取的数据</td></tr>
<tr><td>272</td><td>"android.content.pm.<br>PackageParser$Activity"</td><td>通过 <code>mExtraParcelFd = in.readParcelable()</code> 读取的 <code>Parcelable</code> 类的名称。类型不匹配,但在转换发生之前无论如何都会抛出异常</td></tr>
<tr><td>360</td><td></td><td><code>PackageParser$Component</code> 的 <code>className</code> 和 <code>metadata</code> 字段</td></tr>
<tr><td>368</td><td>1</td><td><code>createIntentsList()</code> 中的项目数量</td></tr>
<tr><td>372</td><td>"android.os.<br>PooledStringWriter"</td><td>我们将通过 <code>Class.forName().getConstructor(Parcel.class).newInstance()</code> 实例化的类的名称。在此位置第一个 <code>LazyValue</code> 结束,但由于 <code>readValue()</code> 未到达末尾,其解析仍在继续。在初始 <code>unparcel()</code> 期间,这也被解释为 <code>Bundle</code> 中的第二个键</td></tr>
<tr><td>436</td><td>4</td><td>第二个 <code>LazyValue</code> 从这里开始,这个 4 是 <code>VAL_PARCELABLE</code>,对于它,<code>Parcel.isLengthPrefixed()</code> 将返回 <code>true</code>。该值稍后会被 <code>PooledStringWriter</code> 构造函数覆盖,之后抛出异常,<code>getParcelable(AccountManager.KEY_INTENT)</code> 结束</td></tr>
<tr><td>440</td><td>240</td><td>声明类型为 <code>VAL_PARCELABLE</code> 的 <code>LazyValue</code> 的长度,用于确定下一个条目的位置以及在重新序列化期间需要复制到目标 <code>Bundle</code> 的数据量。这个 <code>LazyValue</code> 实际上不会被反序列化,而是作为原始数据容器使用</td></tr>
<tr><td>684</td><td>"1&y~pw"</td><td rowspan="2">第三个键值对,键是随机生成的,使其 Java <code>hashCode()</code> 高于之前使用的键(存储在 <code>ArrayMap</code> 中的项目按键的 <code>hashCode()</code> 升序排序,这也是 <code>Bundle</code> 中的项目写入 <code>Parcel</code> 的顺序)。此键值对仅用于增加写入的总对数,因为读取的对数将与此相同,尽管实际上不会读取这一对</td></tr>
<tr><td>704</td><td>-1 (<code>VAL_NULL</code>)</td></tr>
</table>
然后,当 `Bundle` 再次被序列化时,它看起来像这样:
<table>
<tr><th>偏移量</th><th>值</th><th>备注</th></tr>
<tr><td>0</td><td>3</td><td>键值对数量</td></tr>
<tr><td>4</td><td>"intent"</td><td><code>Bundle</code> 中的第一个键</td></tr>
<tr><td>24</td><td>16</td><td><code>VAL_PARCELABLEARRAY</code>,之前反序列化的 <code>Parcelable[]</code> 数组现在被再次序列化</td></tr>
<tr><td>28</td><td>196</td><td><code>LazyValue</code> 的长度,即我们包装的 <code>SemImageClipData</code> 对象。此长度取自使用我的模拟 <code>SemImageClipData</code> 的执行,因此从这一点开始呈现的偏移量不会与真实三星设备上出现的偏移量匹配,但此 <code>LazyValue</code> 不会被再次反序列化,因此这对漏洞利用执行无关紧要</td></tr>
<tr><td>224</td><td>"android.os.<br>PooledStringWriter"</td><td><code>Bundle</code> 中的第二个键</td></tr>
<tr><td>288</td><td>0</td><td>第二个 <code>LazyValue</code> 从这里开始,<code>"android.os.PooledStringWriter"</code> 键下的项目未被访问,因此此 <code>LazyValue</code> 正在从原始数据复制,但类型标签已被 <code>PooledStringWriter</code> 构造函数执行的 <code>writeInt(0)</code> 调用覆盖,到达目标进程后,它不再被解释为 <code>LazyValue</code></td></tr>
<tr><td>292</td><td>240</td><td>这是从原始 <code>Bundle</code> 复制的第二个 <code>LazyValue</code> 的长度,但由于类型标签已被 <code>writeInt(0)</code> 覆盖(即 <code>VAL_STRING</code>),该值现在通过 <code>readString()</code> 读取。之前,对于 <code>LazyValue</code>,长度以字节表示,但现在对于 <code>String</code>,长度以双字节字符表示。源 <code>Parcel</code> 中没有足够的数据,因此 <a href="https://cs.android.com/android/platform/superproject/main/+/main:frameworks/native/libs/binder/Parcel.cpp;l=2221-2226;drc=4d6b008243a5b1b1fb4e725e37e14651a24a4a4d">原生 <code>parcel->readString16Inplace()</code> 在读取长度后失败</a>,但这不会在 Java 端引发异常</td></tr>
<tr><td>296</td><td>"intent"</td><td><code>Bundle</code> 中的“第三个”键。实际上覆盖了第一个键:由于 <a href="https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/ArrayMap.java;l=651-659;drc=584140c83a456b5de99880b440c2d5dfc3c70506">"intent" 的 <code>hashCode()</code> 小于之前看到的键,<code>ArrayMap.append()</code> 方法使用 <code>put()</code>,它允许替换值</a>,否则我们会 <a href="https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/util/ArrayMap.java;l=667-675;drc=584140c83a456b5de99880b440c2d5dfc3c70506">得到重复键,稍后会被 <code>validate()</code> 拒绝</a></td></tr>
<tr><td>316</td><td>4</td><td><code>VAL_PARCELABLE</code>,这里开始包含将被启动的实际 <code>Intent</code> 的 <code>LazyValue</code></td></tr>
<tr><td>536</td><td>"1&y~pw"</td><td>已写入但不会被读取的填充项,因为所有 3 个键值对都已读取。<a href="https://cs.android.com/android/_/android/platform/system/tools/aidl/+/96a02f50fdfa4d20aa46ae2dde927257eac46d4a">与 AIDL 接口不同</a>,<code>Bundle</code> 上没有执行 <code>enforceNoDataAvail()</code> 检查(但即使有,它<a href="https://github.com/michalbednarski/ReparcelBug2/issues/3">也可以通过插入指定预期长度的虚拟条目来绕过</a>)</td></tr>
</table>
# 它如何发生了两次
现在让我们讨论四个补丁,其中两个修复了本文所描述的漏洞:
* CVE-2023-20944([公告](https://source.android.com/docs/security/bulletin/2023-02-01#framework),[补丁](https://android.googlesource.com/platform/frameworks/base/+/d0bc9026e2e62e09fa88c1bcbf1dc1c3fb001375%5E%21/)):这是我发现的另一个漏洞。与此类似,补丁并没有明确说明如何利用它,但<a href="https://konata.github.io/posts/creator-mismatch/">看起来其他人已经弄清楚了(中文博客文章)</a>
* CVE-2023-21098([公告](https://source.android.com/docs/security/bulletin/2023-04-01#framework),[补丁](https://android.googlesource.com/platform/frameworks/base/+/107e6377328486fca55131ea06ca9d6a3c1585e0%5E%21/)):这是我第一次报告此处展示的漏洞。该补丁还引入了对适用于 Android 13 之前版本的 `checkKeyIntentParceledCorrectly()` 绕过方法的修复
* CVE-2023-35669([公告](https://source.android.com/docs/security/bulletin/2023-09-01#framework),[补丁](https://android.googlesource.com/platform/frameworks/base/+/f810d81839af38ee121c446105ca67cb12992fc6%5E%21/)):这个不是针对我的报告,但我认为它是为了修复与 CVE-2023-20944 相同的问题,但针对 `AccountManager.KEY_INTENT` 由 `ChooseTypeAndAccountActivity` 以外的 Activity 启动的情况(例如 [`AddAccountSettings`](https://cs.android.com/android/platform/superproject/main/+/main:packages/apps/Settings/src/com/android/settings/accounts/AddAccountSettings.java;l=95-107;drc=32813a2bef49b172aed89122b4eb50bf14026ddc),我第一次报告漏洞时遗漏了这一点)。此更改将使用类型化的 `bundle.getParcelable()` 替换为使用非类型化的方法并手动进行 `getClass() != Intent.class` 检查,这实际上撤销了 CVE-2023-21098 的修复
* CVE-2023-45777([公告](https://source.android.com/docs/security/bulletin/2023-12-01#framework),[补丁](https://android.googlesource.com/platform/frameworks/base/+/f4644b55d36a549710ba35b6fb797ba744807da6%5E%21/)):这是我第二次报告此漏洞。补丁保留了手动 `getClass() != Intent.class` 检查,但除此之外还恢复了使用类型化的 `bundle.getParcelable()`,这是修复这两个问题的好方法
虽然相同的漏洞利用对 CVE-2023-21098 和 CVE-2023-45777 都有效,但绕过 `checkKeyIntentParceledCorrectly()` 的方式有所不同。
对于 CVE-2023-21098,如果被检查的 `Bundle` 没有 `Intent`,[`checkKeyIntent()` 实际上不会被调用](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/accounts/AccountManagerService.java;l=3519-3521;drc=cdd30b5c040ba7ebd0a1cc6009183ff602434fc0)。由于 `checkKeyIntent()` 是调用 `checkKeyIntentParceledCorrectly()` 的函数,在原始 `Bundle` 看起来不包含 `Intent` 的情况下,重新序列化后的 `Bundle` 不会被检查。
对于 CVE-2023-45777,`checkKeyIntentParceledCorrectly()` 被正确调用,然而[在此处,`writeBundle()` 发生在不带类型参数的 `getParcelable()` 调用之前(在此之前 `Bundle` 的内容没有改变)](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/services/core/java/com/android/server/accounts/AccountManagerService.java;l=4921-4929;drc=b0f6558fb36eb76df35c516ec5a65030a34a8734)。