先看结论与判断条件
- PendingIntent 是由系统在进程外持有、可代表创建者执行预定操作的令牌;得到令牌不等于得到业务账户、订单或权益的最终授权。
- 身份比较依赖 PendingIntent 类型、requestCode 和底层 Intent 的特定字段,extras 通常不能承担唯一身份职责,必须另有不可混用的业务交易标识。
- 默认选择显式目标与 FLAG_IMMUTABLE;只有 RemoteInput 等确需发送方补充字段的场景才评估 FLAG_MUTABLE,并严格限制可变输入。
- FLAG_ONE_SHOT 可以收窄令牌使用方式,但不能替代服务端幂等、交易状态和重放拒绝;FLAG_UPDATE_CURRENT 也不应被当作授权更新机制。
- 更适合 VMP 的函数是一次性业务状态消费、当前账户绑定、服务端回执映射和高价值动作决策,不是 PendingIntent 创建器、组件生命周期或 Bundle 解析。
- 发布门禁应同时检查静态策略、最终 Manifest、真实设备分发与回调、异常 extras、进程恢复和保护前后等价性。
把系统委托令牌和业务授权拆成两条链
PendingIntent API 描述的是一种由系统代表创建应用执行预定 Intent 的令牌。通知、快捷操作、桌面组件或系统服务可以在应用进程不存在时持有并触发它。这个能力解决跨进程和跨时点调用,不代表接收处理器可以跳过用户、账户、订单、会话或服务端权限校验。令牌合法只说明系统接受该委托,不说明当前业务状态仍允许动作。
选择 VMP 边界前,应把链路拆成创建、系统持有、发送、组件接收、输入规范化、业务状态恢复、服务端确认和最终动作。创建与接收层主要受 Android API、Manifest 和生命周期约束,业务恢复与授权决策才包含项目特有价值。把整个 Receiver、Activity 或 Service 做 VMP,会把框架胶水、序列化和状态恢复一起卷入,兼容回归面远大于保护目标。
门禁要给出两份结论。平台结论检查 PendingIntent 类型、flags、目标组件、requestCode、Manifest 与设备回调;业务结论检查 transactionId、当前账户、对象状态、幂等键、服务端回执和最终权限。VMP 只能作为业务窄函数的附加防护,不能把平台通过转换为业务授权,也不能让业务回执掩盖错误可变性或隐式目标。
| 层 | 核心对象 | 主要证据 | VMP 关系 |
|---|---|---|---|
| 创建 | 类型、flags、requestCode、Intent | 代码清单与静态门禁 | 通常不保护 |
| 系统委托 | PendingIntent 令牌 | 平台行为和设备回执 | 不可由 VMP 改变 |
| 组件接收 | Activity、Receiver 或 Service | Manifest 与生命周期测试 | 保持薄入口 |
| 输入规范化 | 允许 extras 和类型 | 拒绝用例 | 保持可审计 |
| 业务状态 | 交易、账户和幂等 | 服务端与本地状态机 | 优先候选 |
| 最终授权 | 权益和动作许可 | 服务端回执 | 客户端 VMP 不替代 |
PendingIntent 身份不包含所有业务 extras
PendingIntent API 的身份规则与普通对象相等不同。类型由 getActivity、getBroadcast、getService 等创建方式区分,requestCode 和底层 Intent 用于匹配;Intent 的 action、data、type、component 和 categories 等字段参与过滤式比较,而 extras 不应被当作可靠的唯一身份。两个业务动作如果只更换 extras,却复用相同类型、requestCode 和 Intent 身份字段,可能得到同一系统令牌语义。
requestCode 应来自稳定且可审计的动作身份设计,而不是随手填零或用容易碰撞的截断值。通知列表中的多条记录、多个账户、同类交易和不同目的必须明确是否需要独立 PendingIntent。业务 transactionId 仍应放在受校验输入中,并由本地或服务端状态表判断归属;requestCode 只帮助区分系统级令牌,不能承担最终授权。
创建策略还要说明 FLAG_UPDATE_CURRENT、FLAG_CANCEL_CURRENT 或其他复用行为。更新 extras 可能改变后来触发时看到的数据,取消并重建则改变旧引用的有效性。设计者需要记录每种语义对应的业务目的,并测试多个令牌并存、旧通知迟到点击和应用升级后的行为。不能把运行结果看起来正确当成身份规则已经无碰撞。
| 字段 | 系统身份作用 | 业务作用 | 错误用法 |
|---|---|---|---|
| 创建类型 | 区分 activity、broadcast、service | 选择接收通道 | 不同类型混作同一入口 |
| requestCode | 参与令牌匹配 | 动作实例映射线索 | 全部固定为零 |
| action/data/type | 参与 Intent 匹配 | 协议路由 | 仅靠 extras 区分 |
| component | 限定目标 | 责任入口 | 使用隐式目标 |
| categories | 参与匹配 | 路由约束 | 动态拼接未审计 |
| extras | 不作为完整身份依据 | 传递最小业务引用 | 放入最终授权 |
可变性必须由发送方需要修改什么来证明
PendingIntent security risks 指出可变性、目标组件和一次性标志会改变重放与重定向风险。默认应选择 FLAG_IMMUTABLE,让持有者只能触发创建时确定的核心 Intent。需要可变 PendingIntent 的情况必须列出发送方需要补充的字段、平台功能和允许类型,例如特定 RemoteInput 场景;不能因为某个旧版本能运行就统一加 FLAG_MUTABLE。
可变不等于所有 extras 都可信。接收入口应读取白名单字段,检查类型、长度、枚举和对象引用,并拒绝重复、未知或冲突值。用于账户、订单、金额、角色或权益的最终值不能只来自可变 extras,而应使用短期交易引用到受控状态或服务端重新取得。即使使用 FLAG_IMMUTABLE,创建者自身仍可能通过更新语义改变 extras,因此接收端仍需做业务校验。
FLAG_ONE_SHOT 适合确实只能使用一次的委托,但一次性由系统令牌语义和业务交易语义共同决定。令牌被发送一次不等于服务端动作只执行一次,网络重试、并发进程或另一路径仍可能到达业务 API。重要动作需要幂等键、服务端状态转换和重复请求回执;客户端的 consumed 标记可以提前拒绝明显重放,却不能成为唯一证据。
| 标志 | 适用理由 | 额外门禁 | 不能替代 |
|---|---|---|---|
| FLAG_IMMUTABLE | 发送方无需补充字段 | 仍校验业务状态 | 服务端授权 |
| FLAG_MUTABLE | 平台功能确需填充 | 记录理由和字段白名单 | 任意 extras 可信 |
| FLAG_ONE_SHOT | 令牌只应触发一次 | 幂等与重放测试 | 业务一次性 |
| FLAG_UPDATE_CURRENT | 创建者更新现有数据 | 核对旧通知行为 | 权限升级 |
| FLAG_CANCEL_CURRENT | 旧引用需失效 | 测试迟到点击 | 服务端撤销 |
| 组合策略 | 对应明确场景 | 静态与设备回执 | 通用安全模板 |
显式组件和 Manifest 共同限制入口,但不完成授权
Android app manifest 定义组件、权限、intent filter、SDK 约束和应用元数据。PendingIntent 创建时应优先指定明确 component 或限定包,使系统委托进入预期 Activity、Receiver 或 Service。隐式 Intent 会扩大解析结果的不确定性,也让审计者难以证明令牌只触达一个处理入口。目标明确后仍需核对最终 APK Manifest,而不是只看源码声明。
Android exported component risk 说明导出组件和 intent filter 会扩大跨应用调用面,需要显式权限与输入校验。一个仅供应用内部 PendingIntent 使用的接收器,如果没有直接外部调用需求,应评估设为不导出并减少 intent filter。若业务必须导出,则要说明调用者范围、权限、动作 allowlist 和拒绝逻辑。exported 值只是入口条件,不能把令牌或 extras 自动变成可信。
PendingIntent 可以让持有者以创建者授权的方式触发预定动作,因此即使目标组件不导出,也不能忽视令牌传播与业务校验。通知、Widget 或系统表面会把令牌交给应用进程之外的主体;设计应把可触发动作限制为一个安全中间状态,例如打开确认页或恢复待处理交易,而不是仅凭点击直接完成转账、变更权益或切换高权限账户。
| 对象 | 安全基线 | 需要证据 | 常见误判 |
|---|---|---|---|
| component | 显式类名或严格包 | 最终 Intent 清单 | action 唯一即可 |
| exported | 按直接调用需求最小化 | 最终 Manifest | false 即无需校验 |
| permission | 导出入口使用合适权限 | 调用者和设备测试 | 有权限即业务授权 |
| intent filter | 只保留必要动作 | 允许和拒绝矩阵 | 源码值等于最终值 |
| 接收入口 | 薄组件和输入投影 | 调用图与异常测试 | 整段做 VMP |
| 最终动作 | 服务端或确认页复核 | 状态和回执 | 令牌点击即批准 |
extras 只携带最小引用,业务状态必须重新绑定
extras 校验从 schema 开始。每个 PendingIntent 类型应有固定版本、允许键、类型、长度和是否必填,解析后生成不可变数据投影。未知键默认拒绝还是忽略要由协议决定,但账户 ID、金额、角色、权益、回调地址或内部文件路径不能因为存在于 Bundle 就被信任。日志只记录事件类型、交易摘要和错误码,不输出完整个人数据或认证材料。
进程可能在创建令牌后被系统终止,用户也可能切换账户、退出登录、删除对象或撤销操作。回调到达时应以 transactionId 查询当前状态,确认对象属于当前账户、动作仍为 pending、版本未变化且没有被消费。找不到状态、账户错配、超时或已经完成都应安全拒绝,并给出可恢复的用户路径。将序列化的完整业务对象塞入 extras 会让旧状态长期滞留。
真正影响权益或资金的动作应由服务端执行幂等与授权。客户端可以在 VMP 保护的状态函数中验证服务端回执与本地 transactionId、accountId 和 operationType 一致,再更新界面或本地缓存;但不能在离线回调中凭一个布尔字段自行授予服务端权益。若产品支持离线动作,需要单独的签名授权和过期模型,不能由 PendingIntent 令牌隐式推导。
| 输入 | 信任级别 | 校验动作 | 最终用途 |
|---|---|---|---|
| operationType | 外部输入 | 枚举 allowlist | 选择状态机 |
| transactionId | 引用线索 | 格式、存在和归属 | 查询当前状态 |
| accountId | 不可单独信任 | 与当前会话和服务端回执比对 | 防止错绑 |
| displayText | 展示数据 | 长度和转义 | 不可驱动授权 |
| serverReceipt | 需验证的回执 | 真实性、绑定和时效 | 更新业务状态 |
| entitlement | 服务端权威 | 从可信响应读取 | 不得由 extras 授予 |
VMP 只包住窄业务状态机,不包住框架胶水
更适合 VMP 的第一类函数是一次性业务状态消费:输入为规范化 transactionId、operationType 和当前状态摘要,输出为 reject、confirm 或 request-server 三种明确决定。第二类是账户绑定:把可信服务端回执与当前账户和交易关联。第三类是高价值结果映射:根据服务端签发的有限字段决定本地展示或后续步骤,而不产生新的服务器权限。
应排除 PendingIntent.getActivity 等创建器、Notification 构建、Bundle 解析、Parcelable、Activity 或 Receiver 生命周期、Navigation 生成代码、反射注入和第三方 SDK。它们要么价值低、要么平台耦合强、要么需要保持可审计。把这些对象并入 VMP 会扩大崩溃、恢复和版本升级风险,也会让真实保护函数淹没在大量框架指令中。
候选函数必须精确到方法并记录调用方、输入投影、异常、线程、幂等、性能预算和回滚方案。保护前先建立单元测试与设备回调回执,保护后使用同一候选身份和用例重跑。若工具链对协程状态机、反射或特定字节码没有项目级证据,应把边界移到更稳定的纯业务函数,而不是扩大 keep 规则后直接宣称通过。
| 函数类型 | 业务价值 | 框架耦合 | 建议 |
|---|---|---|---|
| 一次性状态消费 | 高 | 低 | 优先评估 |
| 账户与交易绑定 | 高 | 低到中 | 优先评估 |
| 服务端回执映射 | 中到高 | 低 | 选择性保护 |
| PendingIntent 创建器 | 低 | 高 | 不保护 |
| Bundle/Parcelable 解析 | 低 | 高 | 保持透明 |
| Activity/Receiver 生命周期 | 低 | 高 | 薄入口排除 |
设备回归必须覆盖发送、迟到、恢复和异常输入
Android instrumented tests 适合验证依赖真实 Android 运行时、组件和系统 API 的行为。PendingIntent 测试应在当前 APK 上创建令牌,由通知或对应系统表面触发,并记录目标组件、requestCode、flags、进程状态、设备和结果。静态扫描能够发现明显策略错误,但无法证明系统持有、进程死亡恢复和返回栈语义正确。
用例至少覆盖正常点击、重复点击、FLAG_ONE_SHOT、旧通知迟到、账户切换、退出登录、对象删除、应用升级、进程被杀、冷启动、多个令牌并存、错误类型、缺失字段、超长字段和可变字段注入。每个失败应在业务动作之前发生,并生成可区分的错误码。若测试为了通过而跳过服务端复核或放宽 extras 白名单,门禁应直接拒绝。
OWASP MASVS overview 将存储、密码学、认证、平台交互、代码质量和韧性分成不同控制域。PendingIntent flags 与组件入口属于平台交互,业务会话属于认证与授权,VMP 属于韧性的一部分。验收报告要分别取证,不能因为 VMP 回归通过就把 token 存储、服务端授权或组件导出自动标为通过。
- 每个 PendingIntent 类型都有唯一身份和 flags 记录
- 正常、重复、迟到和一次性发送分别验证
- 账户切换、退出登录和对象删除后安全拒绝
- 进程死亡、冷启动和升级后的状态恢复有回执
- 可变 extras 的缺失、错型和注入用例被拒绝
- VMP 前后测试与平台、认证和服务端证据分开登记
用策略校验器阻止可变、隐式或越权 PendingIntent
下面的 Python 示例读取 PendingIntent 策略 JSON。每个条目声明创建类型、requestCode、action、data、显式 component、flags、允许 extras、业务状态来源和最终处理函数。脚本检查系统身份元组唯一、FLAG_IMMUTABLE 与 FLAG_MUTABLE 恰有一个、可变场景有批准理由、一次性业务使用 FLAG_ONE_SHOT、目标组件显式、敏感授权字段不进入 extras,并限制 VMP 只用于三个窄业务角色。
脚本只检查清单,不读取最终 APK、Manifest 或设备中真实 PendingIntent,也不证明 requestCode 计算实现和 JSON 一致。生产流程应从当前源码、构建变体和最终 Manifest 生成清单,使用静态分析确认每个创建点,再把通过结果与 instrumented test、服务端幂等回执和保护前后等价性绑定。任何人工补录字段都要标记证据来源。
申请 PendingIntent 链路的商业加固评估时,可准备最终 APK、创建点清单、flags、显式组件、requestCode 规则、extras schema、业务状态机、服务端幂等协议、候选方法和设备回归计划,再从御盾中央平台提交申请。真实候选和项目回执到位前,不宣称令牌重放被阻断、权限提升被防止或兼容矩阵已经通过。
- 策略清单由当前构建的所有创建点自动生成
- 系统身份元组唯一且 extras 不承担令牌身份
- 显式组件和最终 Manifest 状态逐项一致
- 可变理由、字段白名单和一次性语义可复核
- 业务状态来自服务端或受控幂等账本
- VMP 只覆盖窄业务函数并继续执行真机验证
from pathlib import Path
import json
import sys
CREATION_TYPES = {"activity", "broadcast", "service", "foreground-service"}
MUTABLE_REASONS = {"remote-input", "platform-fill-in"}
VMP_ROLES = {"state-consumption", "account-binding", "server-receipt-mapping"}
FORBIDDEN_EXTRAS = {"role", "entitlement", "isAdmin", "accessToken", "refreshToken", "amountApproved"}
def load_policy(path_text):
path = Path(path_text)
if not path.is_file():
raise SystemExit(2)
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
raise SystemExit(2)
if not isinstance(value, dict) or not isinstance(value.get("pendingIntents"), list):
raise SystemExit(2)
return value["pendingIntents"]
def require_text(value):
return isinstance(value, str) and bool(value.strip())
def require_string_list(value):
return isinstance(value, list) and all(require_text(item) for item in value)
def identity(item):
categories = tuple(sorted(item.get("categories", [])))
return (item["type"], item["requestCode"], item["action"], item["data"], item["mimeType"], item["component"], categories)
if len(sys.argv) != 2:
raise SystemExit(2)
items = load_policy(sys.argv[1])
if not items:
raise SystemExit(2)
seen_names = set()
seen_identities = set()
protected = []
for item in items:
required = ("name", "type", "requestCode", "action", "data", "mimeType", "component", "flags", "allowedExtras", "businessStateSource", "handlerRole", "useVmp")
if not isinstance(item, dict) or any(field not in item for field in required):
raise SystemExit(3)
if not require_text(item["name"]) or item["name"] in seen_names:
raise SystemExit(3)
seen_names.add(item["name"])
if item["type"] not in CREATION_TYPES or not isinstance(item["requestCode"], int) or item["requestCode"] < 0:
raise SystemExit(3)
if not require_text(item["component"]) or not require_string_list(item["flags"]):
raise SystemExit(3)
if not require_string_list(item["allowedExtras"]) or FORBIDDEN_EXTRAS.intersection(item["allowedExtras"]):
raise SystemExit(4)
flags = set(item["flags"])
if ("FLAG_IMMUTABLE" in flags) == ("FLAG_MUTABLE" in flags):
raise SystemExit(4)
if "FLAG_MUTABLE" in flags and item.get("mutableReason") not in MUTABLE_REASONS:
raise SystemExit(4)
if item.get("businessOneShot") is True and "FLAG_ONE_SHOT" not in flags:
raise SystemExit(4)
token_identity = identity(item)
if token_identity in seen_identities:
raise SystemExit(5)
seen_identities.add(token_identity)
if item["businessStateSource"] not in {"server", "server-and-local-ledger"}:
raise SystemExit(5)
if item["useVmp"] is True:
if item["handlerRole"] not in VMP_ROLES or item.get("inputProjection") is not True:
raise SystemExit(5)
protected.append(item["name"])
if not protected:
raise SystemExit(5)
print(json.dumps({"status": "eligible-for-device-review", "pendingIntentCount": len(items), "vmpCandidates": protected}, ensure_ascii=False))事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| PendingIntent 的可变性、目标组件和一次性标志会改变重放与重定向风险。 | PendingIntent security risks 描述可变性、显式 Intent 和 FLAG_ONE_SHOT 等安全注意事项。 | 正确 flags 不保证通知返回栈、账户绑定、服务端幂等和业务状态恢复正确。 |
| PendingIntent 身份由创建类型、requestCode 与底层 Intent 的特定字段共同决定,并由系统在进程外持有。 | PendingIntent API 描述令牌语义、身份匹配和系统持有方式。 | API 身份规则不替代应用对 extras、当前用户、对象状态和最终权限的验证。 |
| Manifest 定义组件、权限、intent filter、SDK 约束和应用元数据。 | Android app manifest 描述应用静态组件与平台声明范围。 | 静态清单不能证明运行时输入校验、业务授权或 PendingIntent 传播范围正确。 |
| 导出组件与 intent filter 会扩大跨应用调用面,需要显式权限和输入校验。 | Android exported component risk 描述导出组件的风险和缓解方向。 | exported 值只是入口条件之一,不等于完整威胁分析,也不让 extras 自动可信。 |
| 依赖真实 Android 组件和系统 API 的 PendingIntent 行为需要设备端测试。 | Android instrumented tests 说明设备端测试用于验证依赖 Android 运行环境的行为。 | 单一设备通过不能代表完整 API、ABI、厂商、通知表面和生命周期矩阵。 |
| 移动安全需要按平台交互、认证、存储、代码质量和韧性等控制域分别取证。 | OWASP MASVS overview 划分移动应用安全控制域和验收方向。 | 标准不提供御盾或任何具体产品的通过结论,也不替代项目威胁模型。 |
| 一次性状态消费、账户绑定和服务端回执映射比 PendingIntent 创建器更适合作为 VMP 候选。 | 工程判断:这些函数承载窄业务决策,可使用稳定输入投影并独立做保护前后回归。 | 最终适配性仍由当前工具链、调用图、异常、线程、性能和真机证据确认。 |
| PendingIntent 令牌、FLAG_ONE_SHOT 和本地 consumed 标记都不能替代服务端最终授权与幂等。 | 工程判断:系统委托、客户端进程状态和业务服务器状态属于不同信任边界。 | 没有当前候选与服务端回执时,不宣称重放、重复动作或权限提升已经被阻断。 |
工程常见问题
PendingIntent 由系统持有,为什么接收端还要校验 extras?
系统持有只维持委托令牌语义。可变 PendingIntent 可能接收发送方填充值,创建者也可能更新数据,业务对象还可能过期或换账号,因此仍要执行 schema、归属和状态校验。
所有 PendingIntent 都使用 FLAG_IMMUTABLE 是否最好?
默认应优先不可变,但 RemoteInput 等平台功能可能确需发送方补充字段。可变场景要有明确理由、严格输入白名单和设备回归,不能全局套用。
FLAG_ONE_SHOT 能否防止重要业务被重复执行?
不能单独保证。它限制系统令牌的发送语义,但网络重试、并发或其他业务入口仍可能重复到达服务端,重要动作还需要幂等键和权威状态转换。
组件设置 exported=false 后是否无需验证业务状态?
仍需验证。非导出限制直接外部调用,但 PendingIntent 本身是一种委托能力,令牌持有者可以触发预定入口。当前账户、对象状态和服务端权限不能省略。
哪些 PendingIntent 处理代码值得优先做 VMP?
优先评估一次性交易状态消费、当前账户与交易绑定、可信服务端回执映射等窄函数。创建器、Bundle 解析和 Activity 或 Receiver 生命周期通常保持透明。
提交 PendingIntent 加固评估需要哪些资料?
准备最终 APK、所有创建点、flags、显式组件、requestCode 规则、extras schema、业务状态机、服务端幂等协议、候选方法和设备回归计划,再通过御盾中央平台提交申请。