今年 10 月份的时候,Android 安全公告用 CVE-2024-40676 的编号公布了一个很奇怪的 patch
AccountManagerService checkKeyIntent() 负责检查 account authenticator 传回的 intent,确保它安全再传回给 caller,防止 launch anywhere 漏洞。这个补丁看起来很暴力也很奇怪,直接 ban 了所有带有 content URI 的 intent,似乎完全不考虑兼容性。是什么样的漏洞才要上如此暴力的修复方法?
注:如下全是我的猜测,由于联系不到漏洞作者本人,无法确认这是否就是原本的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
diff --git a/services/core/java/com/android/server/accounts/AccountManagerService.java b/services/core/java/com/android/server/accounts/AccountManagerService.java
index b45bcb4..b59a5ea 100644
--- a/services/core/java/com/android/server/accounts/AccountManagerService.java
+++ b/services/core/java/com/android/server/accounts/AccountManagerService.java
@@ -4959,6 +4959,9 @@
if (resolveInfo == null) {
return false;
}
+ if ("content".equals(intent.getScheme())) {
+ return false;
+ }
ActivityInfo targetActivityInfo = resolveInfo.activityInfo;
int targetUid = targetActivityInfo.applicationInfo.uid;
PackageManagerInternal pmi = LocalServices.getService(PackageManagerInternal.class);

分析

前置知识:launch anywhere 漏洞,这里推荐几篇解析:

读者也可以自己上网搜索。

猜想:URI grant?

初看这个补丁,虽然提交信息非常谜语人,但很明显它和 content URI 相关,而且很明显这个 intent 最后会被返回给调用者进行 startActivity,很容易能想到的就是 URI grant。
然而这个猜想被官方公布的信息否决了,从其他地方拿到了这个漏洞的描述和报告原标题,其中 Summary 是报告标题,Details 是官方给出的漏洞描述:

Summary: In AccountManagerService’s checkKeyIntent method there is a possible way to bypass Intent check and lead to LaunchAnyWhere on All Android version
Details: In checkKeyIntent of AccountManagerService.java, there is a possible way to bypass intent security check and install an unknown app due to a confused deputy. This could lead to local escalation of privilege with no additional execution privileges needed. User interaction is not needed for exploitation.

虽然仍然是谜语人,但其中出现了 Launch Anywhereinstall an unknown app 字眼,显然这不可能是单纯的 URI grant 能完成的事。思路自此中断了……吗?

抽丝剥茧:影响 resolveActivity 流程?

如果你对 AccountManagerService 及 2014 年 Launch Anywhere 漏洞足够熟悉,你应该会知道补丁 checkKeyIntent() 的详细实现。简单来说,它先检查了 Bundle 中的 intent 在反序列化前后一致,然后调用 resolveActivityAsUser() 检查该 intent 指向的 activity 是否能启动,所有检查通过后才会返回 bundle 让调用者进行 startActivity。
基于上述漏洞信息,再结合补丁及上下文细节,我们暂时得出如下结论:

  1. 它能绕过 checkKeyIntent() 里的安全检查从而启动不安全的 activity
  2. 它需要在 intent 中设置一个 content URI 作为 data 才能实现(废话),而且很可能只需要这一步或者这是最关键的一步,否则不至于上这种暴力补丁
  3. 很可能不是 bundle mismatch 类问题,理由:作者表示此漏洞能在所有 Android 版本上触发,而我个人想不到应该怎么绕过 Lazy Bundle 及 checkKeyIntentParcelledCorrectly() 两重缓解措施,而且考虑到这个补丁只会在 intent != null 时触发,如果是 bundle mismatch 的话,直接让它在 AccountManagerService 中拿到 null 即可绕过。从这一点我们还可以推出 AccountManagerService 与 caller 拿到的 intent 应该是相同的。

基于 3 我们假设这里没有 self-changing Bundle 等技术会影响到 intent 本身,往下推得 AccountManagerService 和 caller 拿到的 intent 相同,那剩下的就只有一点可以怀疑:难道是 AccountManagerService resolveActivity() 到的结果与 caller 实际调用 startActivity 启动的 activity 存在差异?

Intent Filter Data Mimetype

如果读者有医学相关的经验,应该会知道医学上有个“一元论”,即对于病人的多种症状或现象,首先尝试用单一的一种疾病去解释所有症状,而不是单独片面去看待每个症状将其认为是多个疾病叠加而成。我们这里尝试根据有限的信息去尝试找出问题所在,和医生诊断疾病很像,也可以尝试用这种思维去思考。
翻看 startActivity() 的流程,可以发现它解析目标组件也是用的 resolveActivity(),如果说两边存在差异,先基于一元论假设 PackageManagerService resolveActivity() 本身被正确实现没有 bug,那么一定是两个调用中间有什么东西被改变了,即我们说的 TOCTTOU 问题。
但是这样我们还是没什么头绪,因为能够影响 intent 解析结果的因素太多了。如果上述情况发生,那么几乎可以肯定这个 intent 一定是一个隐式 intent(显式 intent 连组件名都设置好了,没什么操作余地);再考虑到补丁中的 content uri data,可以大胆假设,content uri 会影响隐式 intent 解析流程,导致两次解析到不一样的结果。

我们知道一个组件只有声明了 intent filter 而且匹配,才有可能被选中为隐式 intent 的目标。翻看 intent filter 的文档,我们可以发现其实它是支持通过 intent data 来匹配的,只需要声明一个 data 标签:

1
2
3
4
5
6
7
8
9
<data android:scheme="string"
android:host="string"
android:port="string"
android:path="string"
android:pathPattern="string"
android:pathPrefix="string"
android:pathSuffix="string"
android:pathAdvancedPattern="string"
android:mimeType="string" />

这里其实还漏了一些属性,比如 android:ssp 这个属性就没提到。里面的大部分属性,比如 scheme、host 这种基本上都是直接从 URI String 中解析出来的,而根据上述的分析,攻击者应该是没法让 data 这个 uri 本身发生改变的(否则直接改掉 scheme 就直接绕过补丁了),所以应该跟本漏洞没多大关联。看起来能搞些名堂的就只有最后的 mimeType 属性?
至于什么是 Mime Type 不了解的读者可以自行百度,这里就不赘述了。这里引入了一个很有趣的问题,我们知道对于 file:///sdcard/abc.txt 我们可以一眼看出来它的 mime type 是 text/plain,而对于 content URI,它的值需要调用其指向的 content provider 才能获得,那么它的 type 是怎么拿到的呢?
答案很简单:也是调用 content provider 获取。根据官方文档, ContentProvider 有一个专门的 getType() 方法用来返回 mime type。因为它是主动调用我们自己才能得到结果,换句话说也就是结果是通过执行我们自己自定义的代码得到的,这里其实操作空间就很大了。
还有一个问题,这个 type 会被实际用在解析流程中吗?答案是肯定的。简单追一下 resolveActivity() 的流程,甚至不用追进 system server 里就能看见:

1
2
3
4
5
6
7
8
9
10
11
12
@Override
public ResolveInfo resolveActivityAsUser(Intent intent, ResolveInfoFlags flags, int userId) {
try {
return mPM.resolveIntent(
intent,
intent.resolveTypeIfNeeded(mContext.getContentResolver()),
updateFlagsForComponent(flags.getValue(), userId, intent),
userId);
} catch (RemoteException e) {
throw e.rethrowFromSystemServer();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* Return the MIME data type of this intent, only if it will be needed for
* intent resolution. This is not generally useful for application code;
* it is used by the frameworks for communicating with back-end system
* services.
*
* @param resolver A ContentResolver that can be used to determine the MIME
* type of the intent's data.
*
* @return The MIME type of this intent, or null if it is unknown or not
* needed.
*/
public @Nullable String resolveTypeIfNeeded(@NonNull ContentResolver resolver) {
// Match logic in PackageManagerService#applyEnforceIntentFilterMatching(...)
if (mComponent != null && (Process.myUid() == Process.ROOT_UID
|| Process.myUid() == Process.SYSTEM_UID
|| mComponent.getPackageName().equals(ActivityThread.currentPackageName()))) {
return mType;
}
return resolveType(resolver);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* Return the MIME data type of this intent. If the type field is
* explicitly set, that is simply returned. Otherwise, if the data is set,
* the type of that data is returned. If neither fields are set, a null is
* returned.
*
* @param resolver A ContentResolver that can be used to determine the MIME
* type of the intent's data.
*
* @return The MIME type of this intent.
*
* @see #getType
* @see #resolveType(Context)
*/
public @Nullable String resolveType(@NonNull ContentResolver resolver) {
if (mType != null) {
return mType;
}
if (mData != null) {
if ("content".equals(mData.getScheme())) {
return resolver.getType(mData);
}
}
return null;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
/**
* Return the MIME type of the given content URL.
*
* @param url A Uri identifying content (either a list or specific type),
* using the content:// scheme.
* @return A MIME type for the content, or null if the URL is invalid or the type is unknown
*/
@Override
public final @Nullable String getType(@NonNull Uri url) {
Objects.requireNonNull(url, "url");

try {
if (mWrapped != null) return mWrapped.getType(url);
} catch (RemoteException e) {
return null;
}

IContentProvider provider = null;
try {
provider = acquireProvider(url);
} catch (Exception e) {
// if unable to acquire the provider, then it should try to get the type
// using getTypeAnonymous via ActivityManagerService
}
if (provider != null) {
try {
final StringResultListener resultListener = new StringResultListener();
provider.getTypeAsync(mContext.getAttributionSource(),
url, new RemoteCallback(resultListener));
resultListener.waitForResult(CONTENT_PROVIDER_TIMEOUT_MILLIS);
if (resultListener.exception != null) {
throw resultListener.exception;
}
return resultListener.result;
} catch (RemoteException e) {
// Arbitrary and not worth documenting, as Activity
// Manager will kill this process shortly anyway.
return null;
} catch (java.lang.Exception e) {
Log.w(TAG, "Failed to get type for: " + url + " (" + e.getMessage() + ")");
return null;
} finally {
try {
releaseProvider(provider);
} catch (java.lang.NullPointerException e) {
// does nothing, Binder connection already null
}
}
}

如果你去看 startActivity 的源码,你会发现这个 resolved type 也会被用到,这里不赘述。也就是说,这个 type 可以影响解析的结果。
至此答案就已经呼之欲出了:我们可以在这个 content provider 的 getType() 中动手脚,让其进行安全检查时和实际启动 activity 时得到两个不同的 type,就能绕过检查启动恶意 activity!

复现

有了如上的分析积累,接下来复现它就是很简单的事。我们已知 AccountManagerService 只允许启动我们 app 自己的 activity (还有两个系统自己的 activity 但是在这个漏洞中完全没用),那么我们需要自己声明一个 activity 让它匹配一个 mime type,然后在 Content Provider 中进行一个简单的计数来决定返回哪个 mime type。
很明显,这个漏洞本身只允许我们启动接收隐式 intent 的 activity,那我们能选择攻击什么组件,又能造成多大的危害呢?

尝试1:Re-Redirection?

首先想到的是,如果我们能通过隐式 intent 启动某个受保护 activity,而这个 activity 又能按我们意图启动其他组件,例如从 extras 里拿到一个 intent 然后直接发送出去,那么我们通过这种二次重定向就能调用到没有声明 intent filter 的组件,大大扩展我们的攻击面。
那么有没有这样的 activity 呢?有。Settings 中有一个叫做 SearchResultTrampoline 的 activity,可以通过发送 com.android.settings.SEARCH_RESULT_TRAMPOLINE 启动,其本身会在校验调用者为 Settings 本身后从 extras 取出调用者指定的参数,然后启动任意 activity 或 Settings 内的任意 fragment。更美好的是,这里的入参 intent 甚至也是以一个 URI String 的方式给出,所以即使是我们没法往里面放入 Parcelable 对象的情况也可以利用这个 activity 实现 launch anywhere。

1
2
3
4
5
6
7
8
9
10
<activity android:name=".search.SearchResultTrampoline"
android:theme="@android:style/Theme.NoDisplay"
android:excludeFromRecents="true"
android:knownActivityEmbeddingCerts="@array/config_known_host_certs"
android:exported="true">
<intent-filter>
<action android:name="com.android.settings.SEARCH_RESULT_TRAMPOLINE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

看起来很美好,是吧?不过这个 intent filter 并没有指定 <data> 标签,这种情况我们能利用吗?一个很自然的思路就是,我们可以通过把 type 设置成 null 的方式来让一个带有 content URI 的隐式 intent 匹配到它吗?
很遗憾的是,此路不通。尝试自己声明一个 activity 接收同 action 再加上一个 mimeType 为 application/canyie 的 data 标签,经过实验,type 设置为 application/canyie 时确实可以解析到自己的 activity,但设置为 null 即会触发 ActivityNotFoundException。
那还有这样的 activity 吗?还有很多,比如 ChooserActivity 和 ResolverActivity,两者都是 framework 中的自带 activity,会通过 startAsCaller 的方式以调用者的权限发送指定 intent。如果我们能让 system uid 启动这两个 activity,就能再次获得以 system uid 发送任意 intent 的能力。可惜的是,ResolverActivity 并没有声明任何 intent filter,而 ChooserActivity 虽然声明了 android.intent.action.CHOOSER 这个 action,但同样也没有声明 data 标签。其他类似的 activity 也大多是如此情况,我并没有在 AOSP 中找到符合条件的 activity,所以只能放弃这条路。

尝试2:CALL_PRIVILEGED?

我们已经知道,利用此漏洞只能启动带有 data 标签 intent filter 的 activity。满足这些条件而且本身又要有危害的 activity 很少,复现 Launch Anywhere 类漏洞时我们常用的 PlatLogoActivity 还有重设锁屏密码的页面都没法用这种方法启动。不过我们还常用 android.intent.action.CALL_PRIVILEGED 这个 action 让手机直接拨打任意电话包括紧急电话来完成复现,来看看哪个 activity 处理这个 action:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- Works like CallActivity with CALL_PRIVILEGED instead of CALL intent.
CALL_PRIVILEGED allows calls to emergency numbers unlike CALL which disallows it.
Intent-sender must have the CALL_PRIVILEGED permission or the broadcast will not be
processed. High priority of 1000 is used in all intent filters to prevent anything but
the system from processing this intent (b/8871505). -->
<activity-alias android:name="PrivilegedCallActivity"
android:targetActivity=".components.UserCallActivity"
android:permission="android.permission.CALL_PRIVILEGED"
android:exported="true"
android:process=":ui">
<intent-filter android:priority="1000">
<action android:name="android.intent.action.CALL_PRIVILEGED"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="tel"/>
</intent-filter>
<intent-filter android:priority="1000"
android:icon="@drawable/ic_launcher_sip_call">
<action android:name="android.intent.action.CALL_PRIVILEGED"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="sip"/>
</intent-filter>
<intent-filter android:priority="1000">
<action android:name="android.intent.action.CALL_PRIVILEGED"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:scheme="voicemail"/>
</intent-filter>
<intent-filter android:priority="1000">
<action android:name="android.intent.action.CALL_PRIVILEGED"/>
<data android:mimeType="vnd.android.cursor.item/phone"/>
<data android:mimeType="vnd.android.cursor.item/phone_v2"/>
<data android:mimeType="vnd.android.cursor.item/person"/>
</intent-filter>
</activity-alias>

这个 PrivilegedCallActivity 定义在 telecomm 里,其本身被 android.permission.CALL_PRIVILEGED 权限保护所以第三方应用无法调用,而且所有 intent filter 都有 data 标签!这不就是我们梦寐以求的受害组件吗?
等等,先别半场开香槟!回过头仔细看看它定义的所有 intent filter,前三个强制规定了 scheme 所以我们带有 content URI 的 intent 是无法匹配的;最后一个只规定了 mimeType 没有规定 scheme,看起来完全可以利用,可以再把香槟打开了……吗?
事实上,隐式 intent 是无法匹配到最后一个 intent filter 的。这是因为它没声明 android.intent.category.DEFAULT 这个 category。至于原因嘛,上面 UserCallActivity 的注释里写了,就是故意的:

1
2
3
4
5
6
7
8
<!-- Omit default category below so that all Intents sent to this filter must be
explicit. -->
<intent-filter>
<action android:name="android.intent.action.CALL"/>
<data android:mimeType="vnd.android.cursor.item/phone"/>
<data android:mimeType="vnd.android.cursor.item/phone_v2"/>
<data android:mimeType="vnd.android.cursor.item/person"/>
</intent-filter>

摆明了就是歧视隐式 intent。得,你写的代码,你说了算。再次此路不通。

尝试3:安装未知应用……什么是“未知应用”?

回头看漏洞描述,里面提到了“bypass intent security check and install an unknown app”,正好启动 PackageInstaller 的私有 Activity 静默安装应用也是 launch anywhere 型漏洞常用的利用方法,那就再点开 PackageInstaller 看一眼。
我们一般攻击 InstallInstalling 这个 activity 进行静默安装应用,对于静默卸载则使用 UninstallUninstalling,而很遗憾这两个 activity 都没有声明任何 intent filter。观察 PackageInstaller 的 AndroidManifest.xml,用漏洞唯一能进来的就是这个 InstallStart:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<activity android:name=".InstallStart"
android:exported="true"
android:excludeFromRecents="true">
<intent-filter android:priority="1">
<action android:name="android.intent.action.VIEW" />
<action android:name="android.intent.action.INSTALL_PACKAGE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="content" />
<data android:mimeType="application/vnd.android.package-archive" />
</intent-filter>
<intent-filter android:priority="1">
<action android:name="android.intent.action.INSTALL_PACKAGE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="package" />
<data android:scheme="content" />
</intent-filter>
<intent-filter android:priority="1">
<action android:name="android.content.pm.action.CONFIRM_INSTALL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter android:priority="1">
<action android:name="android.content.pm.action.CONFIRM_PRE_APPROVAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>

这个 activity 完全没被保护,我们平常写代码请求安装应用或者在文件管理器里点 apk 安装的时候进来的就是这个 activity。而观察里面的代码,也没有诸如“调用者是可信系统应用就直接安装不进行确认”这种逻辑。
那么漏洞作者有没有可能是攻击了应用市场的私有 activity?的确有这个可能,但是我简单扫了一眼 play store,也没找到能用的。这部分我没有完全反编译确认,只是简单看了一眼。欢迎勘误。
突然间,我灵光一闪……漏洞描述为什么说“unknown app”而不是“arbitrary app without user interaction”?说明很可能根本就不是静默安装 app?
这里简单介绍一下“未知来源”的概念:一个第三方不可信应用尝试安装其他 app 的时候需要用户手动先授权它安装未知 app,然后才能进入正常的安装确认页面。这个功能是在 PackageInstaller 中检测调用者然后判断它权限实现的。如果有办法让其他有权限的 app 帮忙启动 PackageInstaller,就能绕过这个限制。所以,漏洞作者很可能只是绕过了这个“未知来源”,而不是实现了静默安装!
说实话,我分析到这里的时候自己都被无语住了,跟我玩文字游戏呢……
后续我没有再去复现,从代码来看理论上是可行的,但是我个人觉得这种绕过的危害实在是低到可以忽略不计。感兴趣的读者可以自己试试。

尝试4:静默安装证书

我们常用的利用方法都不行,这里我提出一种新的方法:调用 CertInstaller 静默安装证书。其实很久之前就有人提出过这种利用思路,但是一直没什么热度,考虑到 Android 7.0 开始不再默认信任用户安装的证书,具体能造成什么危害也有些许疑问。不管怎么样,反正它能静默安装(
先介绍一下 CertInstaller,我们的漏洞唯一能打到的就是这个叫 CertInstallerMain 的页面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<activity android:name=".CertInstallerMain"
android:theme="@style/Transparent"
android:configChanges="orientation|keyboardHidden|screenSize"
android:exported="true">
<intent-filter>
<action android:name="android.credentials.INSTALL"/>
<category android:name="android.intent.category.DEFAULT"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<data android:mimeType="application/x-x509-ca-cert"/>
<data android:mimeType="application/x-x509-user-cert"/>
<data android:mimeType="application/x-x509-server-cert"/>
<data android:mimeType="application/x-pkcs12"/>
<data android:mimeType="application/x-pem-file"/>
<data android:mimeType="application/pkix-cert"/>
<data android:mimeType="application/x-wifi-config"/>
</intent-filter>
</activity>

很明显我们可以通过发送带有 action=android.intent.action.VIEW type=底下那一堆的 intent 触发它,而虽然这个 activity 没被保护,但是它里面兜兜转转其实最后会判断调用者,如果是 Settings 就直接无确认安装,否则显示个对话框要求你进 Settings 里手动安装。
所以我们大概的思路就是,声明一个 activity 处理 VIEW 的 action + 一个随机 mime type,自己继承一下 FileProvider 然后重写 getType 在里面写个计数决定返回哪个 type,然后让系统帮我们启动 intent 即可。
两个需要注意的点:1. intent 要带上一个 certificate_install_usage 的 extra 否则 CertInstaller 认不出证书类型;2. system uid 不允许发出不在白名单的 URI grant,所以在 intent 里添加 grant flags 并不能给 CertInstaller 授权读写自己的 provider。解决方法可以是直接把它声明成 exported(但是这样要改 FileProvider源码),我偷懒就直接硬编码调用 grantUriPermission() 给 CertInstaller 授权了。

总结

我个人觉得虽然这个洞能调起被保护的 activity,Google 也给出了高危评级,但条件实在太过苛刻(我自己是想不到会有多少 activity 又有 mimeType 又被保护),在 AOSP 中的影响不是很大,但是这个利用本身还是非常精巧的,需要对 Android 有很深的了解才能写出来。
我一开始参考 Parcel Mismatch 把这种漏洞叫做“Intent Data Type Mismatch”,后面考虑“mismatch”这个英文单词可能更加强调的是“配对错了”的意思,而不是两次解析过程中因为其他因素造成的不一致,改成参考 Self-changing Bundle 这个名字命名为“Self-changing Data Type”。当然我不是漏洞发现者,没有命名权,这个命名看看就好。(所以你的 double reflection 无人问 别人一朝命名成 meta reflection 天下知怎么算……)
AccountManager API 是 Android 2.0 也即 2009 年添加的,Launch Anywhere 漏洞已经是 2014 年爆出来的,完全没想到除了 bundle mismatch 十年之后还能爆出来这样一种优雅的利用方法,实在佩服。这个漏洞的原理还是比较简单的,就是一个类似 TOCTTOU race condition 的问题。除了直接拒绝 content uri 以外,还有一种修复思路是做完安全检查之后直接给 intent 设置上 component name 让它成为一个显式 intent,后续 startActivity() 时就不会再进入隐式 intent 的解析流程,两种方法都能阻止它指向的组件发生变化也就修复了漏洞。
由于联系不到漏洞作者本人,所以以上文字纯属我的推理,没法和漏洞作者确认是否正确,也欢迎各位读者勘误。
推理的过程本身其实也很过瘾,有点烧脑,有一种刑侦探案的感觉,在现场通过嫌疑人遗留的证据一点一点的抽丝剥茧还原案件真相。33iq 什么的跟这个比起来都弱爆了,建议把这个漏洞改编成剧本杀(不是