先看结论与判断条件
- 优先保护最终业务决策、幂等判断和关键状态变更,不按 Lambda 类名或 synthetic 标记批量选择。
- 源码 Lambda 可能编译为生成类、静态桥接或 invokedynamic 形态,规则必须基于最终候选与方法签名。
- 线程调度、生命周期和回调顺序属于可观察语义,VMP 前后需要在真实 Android 运行时逐路径比较。
- 协程取消是协作式的,CancellationException、finally 清理和挂起点都不能被通用异常分支吞掉。
- launch、async、父子 Job 与 WorkManager 拥有不同失败和重复语义,保护范围不能把它们压成统一回调。
- R8 keep 规则只保证可达性,不定义 VMP 适用性;宽泛保留会掩盖规则漂移并扩大包体与攻击面。
直接答案:保护业务动作,默认不保护短生命周期适配器
Lambda 和回调适配器常用于把框架事件转成业务调用,它们可能只捕获参数、切换线程或转发结果。默认保护生成类会得到大量短方法,收益有限,却增加重命名、内联、调用约定和异常语义的回归面。范围评审应从最终写库、发起交易、更新权限或提交任务的方法向上回溯。
优先候选是业务字段校验、幂等键计算、状态转换和不可替代的失败关闭。若适配器只执行 target.accept(value) 或把监听器参数搬到另一个接口,保留在外更易测试。只有它组合敏感字段、选择关键分支或阻止重复提交,而且无法安全迁移到显式方法时,才按准确签名纳入。
VMP 只能提高实现分析和修改成本,不改变 Android 生命周期、线程或服务端授权。客户端即使隐藏回调路径,服务器仍要验证主体、对象、幂等和业务状态。报告不能把保护一个 adapter 写成攻击已被阻断,也不能从静态规则外推所有回调在设备上语义一致。
| 方法角色 | 默认建议 | 原因 | 必测语义 |
|---|---|---|---|
| 业务状态转换 | 优先纳入 | 承载核心决策 | 输入、输出、失败 |
| 幂等与去重 | 优先纳入 | 影响重复动作 | 并发与重试 |
| 线程切换胶水 | 通常不纳入 | 平台耦合 | 执行线程与顺序 |
| 参数转发 adapter | 不纳入 | 无独立决策 | 调用目标一致 |
| 异常路由 | 谨慎纳入 | 传播语义敏感 | 异常类型与父子关系 |
| 资源清理回调 | 通常不纳入 | 取消与 finally 敏感 | 清理必达 |
先在最终字节码中确认 Lambda 的真实形态
源码中的一个 Lambda 不等于最终候选中的一个稳定类。Kotlin 与 Java 编译器、desugaring、R8 优化和目标 API 会让它表现为生成类、静态 synthetic 方法、桥接方法、内联代码或 invokedynamic 关联。保护规则如果只匹配源码行号或类名模式,很容易在版本升级后命中不同对象。
审计应保存最终 APK 或 AAB 候选、mapping、method inventory 和调用边。对每个候选记录 owner、方法名、描述符、访问标记、synthetic 或 bridge 属性、捕获字段、调用目标和线程来源。类名中的 Lambda、ExternalSyntheticLambda 或美元编号只作为发现线索,不作为自动保护依据。
内联后的源码 Lambda 可能已经没有独立方法,此时不应为了满足规则重新禁止优化。应定位被内联进入的业务函数,若关键决策仍在可测试显式方法中就保护该方法。若优化把边界完全抹平,先重构源码或调整精确 keep,再重新构建和评审,不能在成品上猜测。
| 形态 | 识别线索 | 主要风险 | 处理 |
|---|---|---|---|
| 生成类 invoke | 接口与捕获字段 | 类名漂移 | 绑定描述符和目标 |
| 静态 synthetic | 访问标记与调用边 | 被合并或删除 | 只作线索 |
| bridge adapter | 桥接签名 | 类型转换差异 | 验证参数与异常 |
| inline body | 无独立方法 | 规则失配 | 保护显式业务函数 |
| invokedynamic | bootstrap 关联 | 工具支持差异 | 候选级验证 |
| R8 合并方法 | mapping 与调用图 | 多个语义混合 | 缩小或重构 |
线程切换和回调顺序必须保持可观察一致
回调可能来自主线程、Binder、WorkManager executor、网络线程或自定义 dispatcher。适配器中的 Handler.post、Executor.execute、withContext 和 lifecycle 检查通常是平台编排,不应为了隐藏而整体虚拟化。保护前后要记录回调执行线程、顺序、重复次数和宿主销毁后的行为。
线程改变会影响 UI 访问、锁、数据库事务和竞态。一个同步 adapter 被变成异步,或异步回调被错误内联,都可能导致提交顺序变化。测试要使用可控 barrier 和事件序列,不用固定 sleep 猜测时序;同一输入应产生同一状态变更次数与先后关系。
生命周期边界同样重要。Activity 或 Fragment 销毁后,回调可能应该取消、忽略或仅更新持久状态。VMP 方法不得长期持有 View、Context 或匿名宿主引用,也不能因异常包装绕过 lifecycle 检查。泄漏与过期 UI 更新应使用设备测试和内存工具独立验证。
| 观察项 | 保护前基线 | 危险变化 | 断言 |
|---|---|---|---|
| 执行线程 | main 或指定 executor | 线程漂移 | 线程名与 Looper |
| 调用次数 | 一次或幂等多次 | 重复提交 | 业务计数 |
| 先后顺序 | 验证后写入 | 先写后验 | 事件序列 |
| 宿主销毁 | 取消或忽略 | 继续触达 UI | lifecycle 状态 |
| 并发输入 | 串行或锁保护 | 竞态覆盖 | 最终状态 |
| 超时 | 明确失败 | 回调永不返回 | 期限与清理 |
取消传播要保留挂起点、finally 和 CancellationException
Kotlin coroutine cancellation 是协作式的。挂起函数会检查取消,计算循环需要显式协作;finally 承担资源清理,CancellationException 通常用于正常取消传播。把协程入口或 adapter 纳入 VMP 后,必须逐路径确认取消仍能到达、清理仍执行、取消没有被当成普通错误重试。
最常见的边界错误是 catch Exception 后统一返回成功或再次调度,吞掉取消;另一个错误是在 finally 中调用可挂起逻辑却没有明确 NonCancellable 边界。范围选择应让通用取消与资源管理代码保持透明,只保护清理前后的小型业务判断,避免改变编译器状态机。
测试覆盖启动前取消、第一次挂起时取消、业务动作前取消、动作完成后取消和超时。每条记录 Job 状态、异常类型、清理标记、业务提交次数和父任务状态。单纯任务停止或日志出现 cancelled 不足以证明没有重复副作用。
异常传播要区分 launch、async 和父子 Job
Kotlin coroutine exception handling 说明 launch 与 async 对异常暴露方式不同,父子 Job、SupervisorJob 和 CancellationException 也有不同传播规则。一个通用 callback adapter 若把 Throwable 转成布尔值,可能丢失结构化并发语义,让父任务无法取消或让 deferred 异常延迟到错误位置。
保护候选需要列出正常返回、业务拒绝、可重试异常、不可重试异常和取消五类出口。VMP 前后比较异常具体类型、cause、捕获层和父子状态,而不是只比较消息字符串。禁止为了兼容门禁把所有异常包成 RuntimeException,也禁止在敏感操作失败后返回默认成功。
日志和遥测只记录错误枚举、候选版本和脱敏上下文,不输出 token、用户数据或完整堆栈到公开页面。调试构建可以保留更详细诊断,发布构建要验证混淆后堆栈映射可用。异常证据只能证明被测路径,不代表所有并发组合已覆盖。
| 出口 | 预期传播 | 禁止变化 | 证据 |
|---|---|---|---|
| 正常 | 返回业务结果 | 重复回调 | 结果与次数 |
| 业务拒绝 | 类型化失败 | 默认成功 | 错误枚举 |
| 可重试异常 | 上交重试层 | 适配器无限重试 | 预算计数 |
| 不可重试异常 | 终止当前动作 | 吞掉异常 | 父子 Job 状态 |
| 取消 | 传播 CancellationException | 转普通失败 | 清理与取消标记 |
| 超时 | 取消并清理 | 后台继续提交 | 副作用计数 |
WorkManager 回调还要保留唯一任务和停止语义
WorkManager 的唯一任务名、ExistingWorkPolicy、取消与停止决定重复任务和版本替换行为。若 Lambda 只是组装 WorkRequest 或监听状态,整体保护它不会替代唯一性策略。真正值得保护的可能是业务唯一键、输入约束和成功前的幂等提交函数。
后台任务可能被系统停止并在以后重试。adapter 必须区分 stop、retry、failure 和 success,不在 onStopped 后继续提交,也不把进程重启当成首次执行。服务端仍需要幂等键和业务去重,因为 WorkManager 的本地唯一性不能覆盖多设备、清数据或直接 API 调用。
设备测试要覆盖相同 uniqueWorkName 的 KEEP、REPLACE 或 APPEND 策略,约束变化时重新取证。记录任务 ID、runAttemptCount、停止原因、输出和服务器提交次数。只看到任务最终成功,不能证明中间没有重复副作用。
R8 keep 与 VMP 规则要分工,不用宽泛规则固定胶水
R8 keep rules best practices 强调反射、JNI 和间接入口使用精确规则,宽泛 keep 会阻止优化并掩盖真实可达性。VMP 规则也应绑定稳定业务方法,而不是为了抓住 Lambda 使用整个包或所有 synthetic 类的通配。两套规则职责不同,但都需要候选级审计。
先让 R8 按正常优化生成 mapping 和 usage,再识别关键入口是否保留。若回调由反射、XML、JNI 或框架间接调用,添加最小 keep 并记录原因。随后用 mapping 后 owner、name、descriptor 生成 VMP 清单,保护后再次核对命中数量和最终调用目标。
依赖或编译器升级会改变生成 adapter,规则必须检测 drift。门禁比较基线与候选的目标方法集合,新增、消失或描述符变化都需人工确认。不能自动扩大通配范围来恢复命中数,也不能因为构建通过就忽略关键业务函数不再受保护。
用调用映射把 adapter 关联到线程、取消和最终业务动作
下面的 Python 示例读取公开 call-map.json,节点包含方法、kind、thread、cancellation、exceptionPolicy 和 calls。它从 lambda、callback-adapter 与 coroutine-entry 出发向下遍历,要求最终到达 business-action,检查未知线程、吞取消和 default-success 异常策略,并输出候选链。
脚本不反编译生产 APK、不执行代码、不包含攻击链。实际项目可由类文件分析器生成脱敏调用映射,再用这段逻辑做范围审查。输入派生的未知目标、循环超限、缺少业务动作或危险策略会非零退出,避免只凭类名生成保护清单。
通过表示调用投影满足结构规则,不证明动态派发完整、VMP 变换正确或设备语义通过。下游仍要绑定候选摘要、mapping、保护规则和 instrumented test 回执;反射与 JNI 入口另用精确 keep 和运行证据补齐。
import json
import sys
from pathlib import Path
if len(sys.argv) != 2:
raise SystemExit("usage: audit_callback_map.py call-map.json")
map_path = Path(sys.argv[1]).resolve()
if not map_path.is_file():
raise SystemExit(f"call map missing: {map_path.name}")
with map_path.open("r", encoding="utf-8") as stream:
payload = json.load(stream)
if not isinstance(payload, dict) or not isinstance(payload.get("methods"), list):
raise SystemExit("methods must be an array")
methods = payload["methods"]
by_id = {item.get("id"): item for item in methods if isinstance(item, dict)}
if len(by_id) != len(methods) or None in by_id:
raise SystemExit("method ids must be present and unique")
entry_kinds = {"lambda", "callback-adapter", "coroutine-entry"}
entries = sorted(key for key, value in by_id.items() if value.get("kind") in entry_kinds)
if not entries:
raise SystemExit("no callback or lambda entries were found")
errors = []
chains = []
for entry in entries:
stack = [(entry, [entry])]
reached_business = False
while stack:
current, chain = stack.pop()
node = by_id[current]
if node.get("thread") not in {"main", "worker", "inherited"}:
errors.append(f"{current} has an unknown thread policy")
if node.get("cancellation") == "swallowed":
errors.append(f"{current} swallows cancellation")
if node.get("exceptionPolicy") == "default-success":
errors.append(f"{current} converts failure to success")
if node.get("kind") == "business-action":
reached_business = True
chains.append(chain)
continue
for target in node.get("calls", []):
if target not in by_id:
errors.append(f"{current} calls unknown target {target}")
elif target in chain:
errors.append(f"cycle detected at {target}")
elif len(chain) < 20:
stack.append((target, chain + [target]))
if not reached_business:
errors.append(f"{entry} never reaches a business-action")
if errors:
print(json.dumps({"status": "failed", "errors": sorted(set(errors))}, indent=2))
raise SystemExit("callback map contains review-blocking differences")
print(json.dumps({
"status": "passed",
"entry_count": len(entries),
"business_chains": chains,
"boundary": "static chains passed; device semantics remain separate gates",
}, indent=2))发布证据必须证明保护前后语义,而不是只证明规则命中
证据包包含最终候选摘要、mapping、方法 inventory、调用映射、R8 keep、VMP 精确规则、命中差异、线程序列、取消矩阵、异常矩阵、WorkManager 重试和设备 API 范围。所有回执绑定同一候选,重构建或规则变化后不能沿用。
Android instrumented tests 适合验证依赖真实运行时、组件和系统 API 的语义。至少覆盖主线程、后台线程、宿主销毁、取消、超时、父子异常和重复任务。单一设备通过不能外推完整 API、ABI 与厂商矩阵,也不能产生性能或攻击阻断结论。
跨 WebView 的桥接回调可另看[WebView 原生桥 VMP 边界](/zh-cn/articles/webview-native-bridge-vmp-boundary/),本篇不重复回答入口暴露。需要提交候选和调用映射,可从[御盾中央平台申请加固服务](https://www.leonadev.com/console/)。登录、注册、价格、购买和控制台统一由中央平台承接。
- 从最终业务动作反向定位 Lambda 与 adapter
- 规则绑定 mapping 后 owner、name 和 descriptor
- 线程、顺序、重复次数和 lifecycle 前后一致
- CancellationException、finally 与挂起点逐路径验证
- launch、async 与父子 Job 异常语义分开测试
- WorkManager 唯一任务、停止和重试保持一致
- R8 keep 和 VMP 规则各自最小且理由可追溯
- 设备回执与静态调用图绑定同一候选摘要
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 协程语义由挂起、调度、结构化并发、取消和异常传播共同构成。 | Kotlin coroutines guide 说明协程基础语义与结构化并发。 | 语言指南不证明变换后的状态机仍保持原语义。 |
| 协程取消是协作式的,取消异常和挂起点影响清理与传播。 | Kotlin coroutine cancellation 说明取消、超时、finally 与协作检查。 | 取消规则不能替代目标函数逐路径回归。 |
| launch、async、父子 Job 和 CancellationException 的传播规则不同。 | Kotlin coroutine exception handling 说明异常处理差异。 | 示例语义不能证明某一 VMP 变换实现正确。 |
| 唯一任务名、ExistingWorkPolicy、取消和停止影响重复任务与版本替换。 | Manage WorkManager work 说明唯一任务与管理语义。 | WorkManager 唯一性不替代服务端幂等和业务去重。 |
| 反射、JNI 和间接入口需要精确 keep,宽泛规则会掩盖边界错误。 | R8 keep rules best practices 说明规则最小化原则。 | keep 规则只描述 R8 可达性,不定义 VMP 保护范围。 |
| 依赖 Android 运行时与系统 API 的语义应通过设备 instrumented test。 | Android instrumented tests 说明设备端测试职责。 | 单一设备通过不能代表完整 API、ABI 和厂商矩阵。 |
| 最终业务动作、幂等判断和关键校验优先于短 adapter 进入 VMP。 | 工程判断:这些方法承载核心决策且具有更稳定输入输出。 | 仍需候选调用图、性能、异常和设备回执确认。 |
| 生成类名与 synthetic 标记只能用于发现,不能自动决定保护。 | 工程判断:编译器、desugaring 和 R8 会改变生成形态。 | 静态投影可能遗漏反射与动态派发,需额外证据。 |
| 真实候选必须验证线程、取消、异常和 WorkManager 路径。 | 项目证据尚未接入:这是发布门禁,不表示当前候选已通过。 | 构建成功、规则命中和脚本 exit 0 不能登记运行语义通过。 |
| 本文不提供客户、性能、兼容、排名、收录或攻击阻断结论。 | 项目证据尚未接入:缺少真实候选与设备矩阵。 | 文章只提供可复核范围和测试方法。 |
工程常见问题
Lambda 生成类是否都应该做 VMP?
不应该。优先保护最终业务决策和状态变更,生成类名只用于发现,不能批量决定范围。
只保护 callback adapter 有什么问题?
它可能只是转发参数,而真正敏感的幂等、校验和写入仍在下游,收益与回归风险不匹配。
协程入口做 VMP 后只测正常返回够吗?
不够。还要覆盖取消、超时、finally、launch、async、父子 Job 和异常类型传播。
线程切换方法适合整体保护吗?
通常不适合。平台调度保持透明更易验证,保护小型业务决策并对线程与顺序做设备回归。
R8 keep 规则能否代替 VMP 范围规则?
不能。keep 只控制可达性与优化,VMP 范围还要根据业务价值、语义稳定和候选证据确定。
WorkManager unique work 能否替代服务端幂等?
不能。它只约束本地任务,多设备、清数据、重装和直接 API 请求仍需服务端幂等。
调用映射脚本通过能否证明运行语义正确?
不能。它只检查静态链,真实线程、生命周期、取消、异常与系统组件必须设备验证。
依赖升级后怎样发现规则漂移?
比较 mapping 后目标方法集合、描述符、调用边和命中数量,任何新增消失都人工确认,不扩大通配。