Skip to content

Intent 与解析:匹配算法、隐式风险与 PendingIntent 契约 ​

本页导读:Intent 是 Android 唯一通用的「动作意图」数据结构,也是安全事故的重灾区。本页拆三件事:Intent 的数据结构与解析三测试的真实算法、隐式 Intent 的劫持与重定向风险、以及 PendingIntent 从「可变默认」到「Android 12 强制显式声明」的契约演变。前置:Activity、广播。

一、Intent 是什么:一份可跨进程投递的数据包 ​

抛开教程里的隐喻,Intent 就是一个 Parcelable:

字段语义解析时的角色
component显式目标(类名)有它就不走匹配算法
action动作字符串三测试其一
data / typeURI 与 MIME 类型三测试其二(二者互斥用 setDataAndType)
categories类别集合三测试其三(startActivity 自动补 DEFAULT)
extrasBundle 载荷不参与匹配,安全审查重点
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>,服务端依次跑:

  1. Action 测试:filter 声明的 action 集合含目标 action?filter 不写 action = 永不匹配。
  2. Data 测试:URI 的 scheme/host/pathPattern(hostmatcher 的 exact/prefix/suffix)与 MIME type(含 image/* 这类通配)命中;data 与 type 按「URI 能推出 type 则合并」的规则参与。
  3. 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。

「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 起不可变性必须显式选择——把这四句话背下来,这个主题的事故你避掉九成。

参考链接 ​

基于 Android 16 / API 36 · 内容为原创整理,转载请注明出处