先看结论与判断条件

  • 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 只能作为业务窄函数的附加防护,不能把平台通过转换为业务授权,也不能让业务回执掩盖错误可变性或隐式目标。

PendingIntent 链路的责任分层
核心对象主要证据VMP 关系
创建类型、flags、requestCode、Intent代码清单与静态门禁通常不保护
系统委托PendingIntent 令牌平台行为和设备回执不可由 VMP 改变
组件接收Activity、Receiver 或 ServiceManifest 与生命周期测试保持薄入口
输入规范化允许 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 标记可以提前拒绝明显重放,却不能成为唯一证据。

PendingIntent flags 的工程判断
标志适用理由额外门禁不能替代
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按直接调用需求最小化最终 Manifestfalse 即无需校验
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 链路的 VMP 选择矩阵
函数类型业务价值框架耦合建议
一次性状态消费优先评估
账户与交易绑定低到中优先评估
服务端回执映射中到高选择性保护
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 只覆盖窄业务函数并继续执行真机验证
校验 PendingIntent 身份、flags、输入与 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、业务状态机、服务端幂等协议、候选方法和设备回归计划,再通过御盾中央平台提交申请。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: VMP 加固是什么,如何选择保护范围