Appearance
Intent 与解析:匹配算法、隐式风险与 PendingIntent 契约
本页导读:Intent 是 Android 唯一通用的「动作意图」数据结构,也是安全事故的重灾区。本页拆三件事:Intent 的数据结构与解析三测试的真实算法、隐式 Intent 的劫持与重定向风险、以及 PendingIntent 从「可变默认」到「Android 12 强制显式声明」的契约演变。前置:Activity、广播。
一、Intent 是什么:一份可跨进程投递的数据包
抛开教程里的隐喻,Intent 就是一个 Parcelable:
| 字段 | 语义 | 解析时的角色 |
|---|---|---|
| component | 显式目标(类名) | 有它就不走匹配算法 |
| action | 动作字符串 | 三测试其一 |
| data / type | URI 与 MIME 类型 | 三测试其二(二者互斥用 setDataAndType) |
| categories | 类别集合 | 三测试其三(startActivity 自动补 DEFAULT) |
| extras | Bundle 载荷 | 不参与匹配,安全审查重点 |
| flags | 行为修饰 | 任务栈/授权/PendingIntent 语义 |
匹配发生在服务端(PackageManagerService/ComponentResolver 的查询族),应用侧的 resolveActivity/queryIntentActivities 都是它的代理。理解解析,等于理解服务端如何遍历 IntentFilter 打分。
两个细节:type 未显式设置时由 resolveType(resolver) 从 data URI 向 provider 反查 MIME;extras 不参与匹配但会被原样投递——所以「匹配是门面,extras 才是攻击面」。
bash
# 反查一个 content URI 的 MIME(type 匹配的前置知识)
adb shell content read --uri content://media/external/images/media/42
adb shell cmd package query-activities -a android.intent.action.VIEW -d https://example.com/item/9 --components二、解析三测试:action、data、category
对每一个候选 <intent-filter>,服务端依次跑:
- Action 测试:filter 声明的 action 集合含目标 action?filter 不写 action = 永不匹配。
- Data 测试:URI 的 scheme/host/pathPattern(
hostmatcher的 exact/prefix/suffix)与 MIME type(含image/*这类通配)命中;data 与 type 按「URI 能推出 type 则合并」的规则参与。 - Category 测试:目标 Intent 的每一个 category 都必须出现在 filter 里(子集语义!)——
DEFAULT缺失是「找不到 Activity」的第一大原因。
java
// frameworks/base/core/java/android/content/IntentFilter.java(match 逻辑大意)
public int match(String action, String type, ...) {
int act = matchAction(action); // 不中 → 返回负数
if (act < 0) return act;
int dat = matchData(type, ...); // 不中 → 返回负数
if (dat < 0) return dat;
int cat = matchCategories(data); // 注意:filter 里的 category 是「要求集合」
if (cat < 0) return cat;
return act + dat + cat; // 非负分:越小越精确(无 action/data 的泛匹配分高)
}返回分数的用法:服务侧收集所有 ≥0 的候选,分数越低 = filter 越具体,resolveActivity 取「最优」;完全同分时才按历史规则(先安装/系统应用优先等,以 AOSP 为准)裁决——所以「我的 App 能不能抢到某个 VIEW intent」在工程上是玄学,在产品上要靠 App Links 抢(见第四节)。
一个标准的深链 filter 长这样(三个测试全部要过关):
xml
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>
<data android:scheme="https" android:host="example.com"
android:pathPattern="/item/.*"/>
</intent-filter>三、priority 只管广播;打分只管「最具体」
两条常被混起来的排序规则要分开记:
android:priority(IntentFilter 优先级):只在有序广播投递序里生效,Activity 解析与隐式服务选择不看它——写在<activity>的 filter 上等于没写。- match 分数:决定
resolveActivity()的「唯一最佳」;多个候选分差极大时(如一个是泛匹配一个是精确 host)结果稳定,分差在噪声区时结果可被安装顺序影响。 - chooser(应用选择器)出现的本质:无唯一最佳 + 未指定默认 → 交给用户。
Intent.createChooser在 Android 14+ 已不鼓励手写(系统自带选择器的安全策略更完备)。
打分直觉表(以 AOSP IntentFilter 打分权重为参考,具体分值以源码为准):
- 显式 action + 精确 host + 具体 pathPattern:最低分(最优先);
- 通配 MIME(
image/*)+ 无 host:高分(垫底); - 空 filter(只有 DEFAULT):参与兜底但基本抢不到 VIEW。
四、IntentFilter 验证与 App Links 域名验证
「filter 写得对不对」有三层验证:
bash
# 1) 命令行直查解析结果(intent 参数与 am/start 一致)
adb shell cmd package resolve-activity -a android.intent.action.VIEW \
-d https://example.com/item/9 --brief
adb shell cmd package query-activities -a android.intent.action.SEND -t text/plain
# 2) 单测层:直接对 IntentFilter 做 match 断言(Robolectric 或纯 JVM)
# 3) 单元调试神器:
adb shell dumpsys package preferred-activities # 用户默认设置Web 域名场景用 App Links 而不是赌分数:manifest 声明 autoVerify="true" + 域名根路径放 .well-known/assetlinks.json(数字资产标识,含你的包名与证书指纹)。系统安装时会发起验证,成功后该域名链接直达你的 App、绕过选择器。验证失败的排查方向:https 证书、json MIME、指纹是否为上架签名的指纹(常见事故:拿 debug 指纹生成 json 上线)。
assetlinks.json 的最小形态:
json
[{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AA:BB:CC:...上架签名指纹..."]
}
}]字段含义以官方 Digital Asset Links 文档为准。
版本注记:基于 Android 16 / API 36;Android 12 差异:PendingIntent 可变性强制显式(见第六节);Android 12L/13 差异:
IntentFilter新增 Builder 与 canonicalize 增强(用于跨版本持久化比较);Android 13 差异:App Links 验证失败时系统提示入口加强;Android 14 差异:后台 BAL 收紧波及「后台 PendingIntent 拉起界面」的行为。
五、隐式 Intent 的安全面:两个方向都有洞
- 发不出去(劫持):
Intent(ACTION_VIEW)这类隐式目标可被恶意同名 filter 抢走 → 敏感数据(登录态、token、文件)交给假 App。对策:内部跳转一律显式(Intent(ctx, Target::class.java));必须隐式对外时setPackage/resolveActivity先验明目标签名。 - 收进来(混淆代理人/Intent Redirection):你的 exported 组件收到「Intent 套 Intent」(extra 里再装一个 PendingIntent/Intent),原样转发等于攻击者借你的手发起特权动作。对策:转发前重建 Intent(只挑白名单字段)、拒收
getParcelableExtra里未校验的 Intent/PendingIntent。 - PendingIntent 被回填(fill-in):可变 PendingIntent 允许接收方往未填字段里塞内容,与上一条叠加就是提权链。
- uri 授权失控:
FLAG_GRANT_PERSISTABLE+ 大权限的 URI 发给别人 = 长期共享;按最小粒度授权(第四节 content 页机制)。
安全转发包装器(治「Intent 重定向」的样板):
kotlin
// 只转发白名单字段,重建而非透传
fun sanitize(raw: Intent?, pkg: String): Intent? {
val src = raw ?: return null
val cmp = src.component ?: return null
if (cmp.packageName != pkg) return null // 只允许转发自家组件
return Intent(src.action).apply {
component = cmp
data = src.data?.takeIf { it.scheme in SAFE_SCHEMES }
src.getStringExtra("order_id")?.let { putExtra("order_id", it) }
}
}最小动作清单:exported 三思(真需要对外的组件才 exported)、隐式必验目标、套娃必重建、授权必临时。
六、PendingIntent:把「将来发起权」卖给对方
PendingIntent 本质是系统代管的 Intent + 发起者身份的凭证,receiver(如通知栏、系统服务)用它发起动作时沿用创建者的身份与权限——这决定了它天然是个安全面。
| 标志 | 语义 | 事故形态 |
|---|---|---|
| FLAG_IMMUTABLE | 内容不可被接收方修改 | 安全默认 |
| FLAG_MUTABLE | 允许接收方填充未定字段 | 需要显式 action/component 留白才合理 |
| FLAG_ONE_SHOT/CANCEL_CURRENT | 发起权次数/覆盖策略 | 逻辑 bug 多发区 |
Android 12(API 31)起,target 31+ 创建 PendingIntent 必须显式带 FLAG_IMMUTABLE 或 FLAG_MUTABLE,否则抛异常——这是对「可变默认」时代劫持漏洞的总清算。工程口径:
- 通知里点开的 PendingIntent:
getActivity(ctx, req, intent, FLAG_IMMUTABLE); - 只有「让对方补全数据」(如远程回复、Direct Share)才用 MUTABLE,且 Intent 只留 data 空槽、action/component 写死;
- 跨 App 发 PendingIntent 前问自己:对方拿着我的身份能干出什么?答不上来就别发。
kotlin
// 通知的标准姿势(不可变 + 显式目标)
val tap = PendingIntent.getActivity(
context, REQUEST_CODE,
Intent(context, DetailActivity::class.java)
.putExtra("id", id)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)七、常见误区问答
Q1:为什么 startActivity(implicit) 能起、单测里 resolve 返回 null? 检查你构造 Intent 时有没有漏 addCategory(CATEGORY_DEFAULT)——只有 startActivity 系列会替你补,手调 resolveActivity 不会。
Q2:同 action 的 Intent,换一台手机解析结果不同? 正常且被设计如此:候选集是「装了哪些 App」的函数。需要确定性就用 App Links 或显式目标,别赌 queryIntentActivities().first()。
Q3:extras 里的 Parcelable 为什么在新进程里解不出来? 跨进程反序列化要求类在目标进程可见(同一 ClassLoader 或双方都有该类);传 Bundle 基础类型 + 自有数据的原始字段永远比传自定义 Parcelable 稳。
Q4:putExtras(intent.extras) 合并别人的 Bundle 安全吗? 等于开放写入你的字段空间——先白名单 key 再拷贝,尤其警惕 key 撞名带来的逻辑覆盖(历史上多款旗舰 App 的支付参数注入都长这样)。
Q5:FLAG_ACTIVITY_NEW_TASK 和 launchMode 谁生效? flags 是单次请求的显式意志,优先于静态 launchMode;混合使用时以 ActivityStarter 的合并结果为准,排查一律以 dumpsys activity activities 的落栈现场为准。
本页小结:Intent 匹配 = 服务端对每个 filter 跑 action/data/category 三测试并打分,「最具体者胜」,priority 只属于有序广播;隐式 Intent 的风险在发与收两个方向都存在,靠显式化、验目标、重建套娃 Intent 化解;PendingIntent 是身份出借,Android 12 起不可变性必须显式选择——把这四句话背下来,这个主题的事故你避掉九成。
参考链接
- https://developer.android.com/guide/components/intents-filters
- https://developer.android.com/reference/android/content/IntentFilter
- https://developer.android.com/reference/android/os/PendingIntent
- https://developer.android.com/about/versions/12/behavior-changes
- https://cs.android.com/android/platform/frameworks/base/+/main:core/java/android/content/IntentFilter.java
- 站内:Activity、广播、ContentProvider、权限模型