先看结论与判断条件
- 默认工程顺序是编译输入进入 R8,冻结 R8 输出与 mapping 后解析 VMP 目标,再做 VMP、最终打包和签名;工具明确支持其他插入点时才按其契约例外处理。
- R8 负责代码和资源缩减、优化与名称混淆;VMP 负责当前工具支持范围内的选定方法变换,二者不应重复声明对方职责。
- 源码方法名单不能直接充当 VMP 最终目标,因为 R8 可能删除、内联、合并或重命名方法;必须保存源码意图到 post-R8 身份的映射。
- 反射、JNI、序列化和间接入口使用精确 keep 规则维持可达性,不能为了让 VMP 方便而全局 keep,也不能把 keep 规则当作保护名单。
- mapping、R8 规则、post-R8 清单、VMP 目标、变换报告、最终 APK 摘要和签名身份必须来自同一条不可混用的构建链。
- 测试应分为未优化基线、R8-only、R8-plus-VMP 和最终签名候选四层,使裁剪错误、保护变换错误和打包签名错误可以独立定位。
默认先完成 R8,再对稳定的 post-R8 方法做 VMP
当 VMP 以 R8 输出的 DEX 或等价中间产物为输入时,最清晰的顺序是:先编译并由 R8 完成可达性裁剪、优化和名称混淆,保存 mapping 与规则,再从该确切输出解析 VMP 方法,完成变换后进入最终打包、对齐和签名。这样 VMP 看到的是将要交付的真实方法集合,不会保护已被 R8 删除的方法,也不会依赖尚未稳定的名称。
这不是不考虑工具差异的绝对法则。有些 VMP 以 Gradle 插件、字节码变换或编译器阶段接入,可能要求在 R8 之前标记或处理方法。此时应严格遵循当前版本的支持契约,并证明标记如何穿过 R8、生成代码如何被 R8 对待、最终方法如何追溯。不能把其他产品或旧版本的顺序直接复制到当前流水线。
无论插入点如何,责任边界都不变:R8 决定哪些代码可达、如何优化和命名;VMP 只变换被选中的方法;打包与签名确定最终候选身份。门禁应让每个阶段消费上一阶段的摘要并产生新的摘要,任何报告无法绑定该链时都保持未确认。看到任务执行成功或 APK 可安装,不足以证明目标方法确实进入最终候选。
| 阶段 | 默认输入 | 主要输出 | 例外条件 |
|---|---|---|---|
| 编译 | 源码与依赖 | 未优化类或字节码 | 按项目工具链 |
| R8 | 编译产物与规则 | 优化代码、mapping、usage | 不能被 VMP 隐式替代 |
| 目标解析 | post-R8 产物与映射 | VMP 目标清单 | 编译期插件需提供等价追踪 |
| VMP | 稳定目标与输入摘要 | 变换产物和报告 | 只按受支持插入点 |
| 打包签名 | 全部最终变换产物 | 发布候选 | 签名后不得再改候选 |
| 验证 | 确切最终候选 | 静态与设备回执 | 中间产物不能替代 |
R8 的工作是缩减、优化和混淆,不是方法虚拟化
Enable app optimization with R8 将 R8 的职责描述为代码和资源缩减、优化与名称混淆。缩减移除不可达代码和未使用资源,优化改变可执行结构,混淆缩短或改变类、字段和方法名称。发布构建需要保存规则与输出,使团队能够解释某个入口为何被保留、某个方法为何消失以及崩溃名称如何还原。
R8 的通过标准不是构建成功,而是优化后的候选仍满足应用语义。反射、JNI、序列化、依赖注入、布局引用和其他间接入口可能不被静态分析自动发现,需要精确规则和真实测试。若为了消除崩溃直接 keep 整个包,虽然短期恢复运行,却会掩盖真实可达性边界并削弱缩减与优化价值。
R8 也不提供 VMP 的职责。名称混淆改变标识符,可达性优化改变结构,但它们不等于把选定业务方法转换为某个虚拟指令解释模型。反过来,VMP 也不应负责推断全局可达性或替代 R8 的资源缩减。把两者写成两层独立控制,才能分别配置、测试和回滚。
| 输出 | 回答的问题 | VMP 如何使用 | 不能推出 |
|---|---|---|---|
| 优化代码 | 最终可达结构是什么 | 作为默认变换输入 | 所有语义已回归 |
| mapping | 原始名称如何映射 | 追踪源码意图和崩溃 | Native 符号可还原 |
| usage/seed 线索 | 删除和保留情况 | 发现目标漏选原因 | VMP 保护强度 |
| 规则集合 | 为何保留间接入口 | 验证目标依赖边界 | 规则越宽越安全 |
| 资源缩减结果 | 未使用资源如何处理 | 通常不属于 VMP | 资源运行语义通过 |
| 工具版本 | 哪个 R8 产生输出 | 构建链身份字段 | 版本相同即字节相同 |
VMP 只处理选定方法,不能补救错误的可达性和授权
VMP 在本文中指当前工具对选定 Java、Kotlin 或可支持方法进行虚拟化或等价保护变换。它的输入必须是明确的方法身份、字节码前提和支持边界,输出必须列出成功、跳过和失败目标。VMP 不负责判断整个应用哪些类应该存在,也不能凭保护名单恢复已经被 R8 删除或内联的方法。
选定方法应承载高价值业务决策,并具有窄输入、少反射、少框架回调和可独立测试等特征。Activity 生命周期、序列化生成代码、协程状态机、JNI 桥、反射入口或大型第三方库需要谨慎,除非当前项目拥有工具级支持与真机证据。把全包方法都送入 VMP 会扩大兼容和性能风险,也让真正的保护重点难以审计。
OWASP MASVS-RESILIENCE 把抗逆向与抗篡改放在纵深防御控制域。VMP 可以是其中一种措施,却不能替代服务端授权、签名发布、密钥管理、输入验证或完整测试链。没有当前候选的攻防和设备证据时,只能陈述方法被变换及其边界,不能量化逆向难度或声称攻击已经被阻断。
| 问题 | R8 责任 | VMP 责任 | 其他责任 |
|---|---|---|---|
| 不可达代码 | 分析并缩减 | 不恢复 | 测试证明语义 |
| 名称变化 | 生成混淆映射 | 消费映射后的身份 | 归档 mapping |
| 方法变换 | 优化但非 VMP 契约 | 保护选定方法 | 报告成功与跳过 |
| 反射/JNI | 精确 keep 保持入口 | 按支持边界选择 | 运行测试 |
| 最终授权 | 不负责 | 不负责 | 服务端与业务系统 |
| 发布身份 | 提供中间输出 | 提供变换输出 | 打包签名和候选归档 |
源码意图必须映射到 post-R8 身份,再形成 VMP 清单
产品或安全团队通常以源码方法描述保护意图,例如某个许可证判断、风控状态机或权益映射。R8 之后,这个方法可能被重命名、内联、合并、移动或移除。直接把源码全限定名交给 post-R8 VMP 工具,可能得到未找到、保护错误目标或静默漏选。目标解析必须读取同一构建的 mapping 和 post-R8 方法清单。
推荐保存三层身份:sourceIntentId 表示稳定业务意图,sourceSymbol 记录源码签名,postR8Symbol 与 codeDigest 记录优化后的实际对象。若方法被内联或移除,解析器应返回明确状态,并要求重新选择可保护边界;不能自动选择名字相近的其他方法。若工具支持注解或规则跨阶段传播,也要验证最终报告能够回指 sourceIntentId。
VMP 目标清单本身应不可变,包含 post-R8 输入摘要、方法身份、选择理由、支持前提和期望处理方式。变换完成后,报告逐条给出 protected、skipped 或 failed,并产生输出摘要。只有清单与报告一一闭合,才能确认没有因 R8 变化漏掉高价值方法,也没有把调试、框架或错误重载纳入保护。
| 层 | 标识 | 来源 | 失败处理 |
|---|---|---|---|
| 业务意图 | sourceIntentId | 内部保护台账 | 不得自动替代 |
| 源码方法 | 类、方法、描述符 | 源码与编译输入 | 记录重载精确签名 |
| R8 输出 | postR8Symbol、codeDigest | 同批 mapping 与产物 | 内联或删除需重选 |
| VMP 目标 | targetId 与期望动作 | 不可变目标清单 | 未解析即停线 |
| 变换结果 | 状态与输出定位 | VMP 报告 | skip 和 fail 不算通过 |
| 最终候选 | APK 摘要与签名 | 打包发布阶段 | 无法绑定则拒绝 |
keep 规则只维持可达性,不负责决定保护范围
R8 keep rules best practices 强调为反射、JNI 和其他间接入口编写精确规则。规则应说明哪个框架或调用路径需要保留哪些类、成员或属性,并尽量缩小范围。VMP 候选依赖的构造器、接口、反射入口或 Native 注册点需要单独分析,但不能因为候选方法重要就 keep 整个业务包。
keep 与 protect 是两个不同集合。keep 表示 R8 不得移除或必须保留某些优化或命名属性,protect 表示 VMP 要对 post-R8 目标执行变换。一个方法可能需要 keep 但不值得 VMP,例如序列化构造器;也可能值得保护但不需要保留原名。配置文件应分开维护,门禁阻止把同一通配规则同时解释成两个集合。
JNI 还需要绑定 Native 侧方法查找或注册方式。若 Native 通过字符串寻找 Java 方法,R8 规则必须维持契约,VMP 也要证明变换后的入口与调用约定仍然兼容。R8 mapping 不能替代 Native 符号和注册证据。没有目标 ABI 的真实设备回执时,只能确认静态规则和清单一致,不能登记 JNI 路径通过。
| 场景 | keep 决定 | VMP 决定 | 验证 |
|---|---|---|---|
| 普通直接调用 | 通常无需特殊规则 | 按业务价值选择 | 单元与设备测试 |
| 反射入口 | 保留被查找对象 | 谨慎选择窄函数 | 反射回归 |
| JNI 注册 | 保持 Java/Native 契约 | 仅按工具支持 | 目标 ABI 真机 |
| 序列化模型 | 保留构造和字段契约 | 通常排除 | 编解码回归 |
| 框架生命周期 | 按框架要求 | 通常排除 | 启动与恢复 |
| 高价值纯函数 | 由可达性决定 | 优先评估 | 保护前后等价 |
mapping、变换报告和候选摘要必须同批归档
R8 retrace 使用 mapping 将混淆后的 Java 或 Kotlin 堆栈还原,因此 mapping 必须与产生崩溃的同一构建配对。VMP 加入后,崩溃定位还可能需要变换报告、目标映射和工具日志。若 mapping 来自另一构建,即使源码提交相同,也可能因为依赖、R8 版本或规则差异还原出错误结果。
建议为每个阶段保存输入摘要、配置摘要、工具版本、输出摘要和时间,并使用上一步输出摘要作为下一步输入。R8 阶段附 mapping 与规则摘要,目标解析附 post-R8 清单与 target list 摘要,VMP 阶段附逐方法状态和输出摘要,打包签名阶段附最终 APK 摘要与证书摘要。报告只能引用这些不可变标识。
NIST SP 800-218 SSDF 强调来源、构建、验证与变更证据。落实到这条流水线,任何重新运行都会产生新的 release identity,不能把旧 mapping、旧 VMP 报告或旧设备回执复制到新 APK。若签名后又修改包内容,最终候选身份改变,必须重新进入相应门禁,不能只补一份摘要文件。
| 阶段 | 输入绑定 | 不可缺少输出 | 错配后果 |
|---|---|---|---|
| R8 | 编译产物与规则 | 优化产物、mapping、规则摘要 | 目标和 retrace 错误 |
| 目标解析 | R8 输出与 mapping | post-R8 清单、目标清单 | 漏选或错选 |
| VMP | 目标与输入摘要 | 逐方法报告、输出摘要 | 无法证明保护对象 |
| 打包 | 全部变换产物 | 未签名候选摘要 | 内容来源不清 |
| 签名 | 确切未签名候选 | APK 与证书摘要 | 发布身份错配 |
| 测试 | 最终 APK 摘要 | 设备、用例与结果 | 中间产物冒充上线包 |
四层回归把 R8 错误与 VMP 错误分开定位
第一层是未优化或可诊断基线,用于确认业务用例本身可运行;第二层是 R8-only 候选,用于发现 keep、优化、反射和 JNI 问题;第三层是 R8-plus-VMP 产物,用同一用例检查选定方法变换;第四层是最终打包签名候选,覆盖安装、升级、启动、恢复和真实分发入口。四层的摘要与配置必须分别保存。
Android instrumented tests 适合验证依赖真实 Android 运行时、组件和系统 API 的语义。回归应覆盖正常路径、异常输入、进程恢复、反射、序列化、JNI、并发和目标设备矩阵,并记录保护前后输出与异常类型。单一设备通过不能代表全部 API、ABI 和厂商环境,静态方法出现在 DEX 也不能代表运行路径正确。
故障定位按最早失败层处理。R8-only 已失败就先修 keep 或优化边界,不应通过取消 VMP 掩盖;R8-only 通过而 VMP 层失败,则检查目标适配、异常语义和工具支持;只有最终签名层失败时,再查打包、签名或安装流程。任何修复都会生成新候选并重跑后续层,禁止给旧结果改日期。
- 未优化、R8-only、R8-plus-VMP 和最终候选分别有摘要
- 每层使用相同核心业务与异常用例
- 反射、序列化、JNI 和恢复路径单独覆盖
- 失败归因到最早出现差异的阶段
- 修复后生成新候选并重跑所有后续门禁
- 最终线上结论只绑定签名 APK 的设备回执
用构建链校验器阻止顺序错误和证据错配
下面的 Python 示例读取一份构建证据 JSON,要求阶段严格为 compile、r8、target-resolution、vmp、package、sign。每一阶段的 inputSha256 必须等于上一阶段 outputSha256;R8 必须提供 mapping 与规则摘要,目标解析必须声明 post-r8 基础并提供目标清单,VMP 必须引用该清单并提供逐方法报告,最终声明的 APK 摘要必须等于签名阶段输出。
脚本只验证证据链内部一致,不执行 R8、VMP、打包或签名,也不能证明摘要对应的文件真实存在。生产流水线应在每阶段由受信任务重新计算文件摘要,并将工具版本、命令参数和不可变配置一同签入构建记录。若当前 VMP 工具要求编译期插入,应使用该工具专用 schema 表达顺序,不能为了通过示例而伪造 post-R8 输入。
申请 R8 与 VMP 的商业加固评估时,可准备最终 APK、构建任务、R8 规则与 mapping、源码意图清单、post-R8 方法清单、VMP 报告、签名身份和四层回归结果,再从御盾中央平台提交申请。真实候选和回执齐备前,不宣称优化兼容、方法保护完成、逆向成本提高或攻击已被阻断。
- 阶段顺序由当前工具链契约明确记录
- 每一阶段输入摘要等于上一阶段输出摘要
- R8 mapping、规则与输出来自同一构建
- VMP 目标基于 post-R8 身份并逐条闭合
- 最终 APK、签名证书和测试回执绑定一致
- 静态链通过后继续执行四层运行回归
from pathlib import Path
import json
import re
import sys
DIGEST = re.compile(r"^[0-9a-f]{64}$")
EXPECTED_STAGES = ["compile", "r8", "target-resolution", "vmp", "package", "sign"]
def load_record(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("stages"), list):
raise SystemExit(2)
return value
def valid_digest(value):
return isinstance(value, str) and DIGEST.fullmatch(value.lower()) is not None
def require_digest(stage, field):
value = stage.get(field)
if not valid_digest(value):
raise SystemExit(3)
return value.lower()
if len(sys.argv) != 2:
raise SystemExit(2)
record = load_record(sys.argv[1])
stages = record["stages"]
if [stage.get("name") for stage in stages] != EXPECTED_STAGES:
raise SystemExit(3)
previous_output = None
for index, stage in enumerate(stages):
if not isinstance(stage, dict) or not isinstance(stage.get("toolVersion"), str):
raise SystemExit(3)
current_input = require_digest(stage, "inputSha256")
current_output = require_digest(stage, "outputSha256")
if index > 0 and current_input != previous_output:
raise SystemExit(4)
previous_output = current_output
r8 = stages[1]
targets = stages[2]
vmp = stages[3]
for field in ("mappingSha256", "rulesSha256"):
require_digest(r8, field)
if targets.get("selectionBasis") != "post-r8":
raise SystemExit(4)
target_list = require_digest(targets, "targetListSha256")
require_digest(targets, "sourceIntentMapSha256")
if vmp.get("targetListSha256", "").lower() != target_list:
raise SystemExit(4)
require_digest(vmp, "methodReportSha256")
if not isinstance(vmp.get("protectedCount"), int) or vmp["protectedCount"] <= 0:
raise SystemExit(4)
if not isinstance(vmp.get("skippedCount"), int) or vmp["skippedCount"] != 0:
raise SystemExit(4)
final_apk = record.get("finalApkSha256")
if not valid_digest(final_apk) or final_apk.lower() != stages[-1]["outputSha256"].lower():
raise SystemExit(5)
if not valid_digest(record.get("signingCertificateSha256")):
raise SystemExit(5)
print(json.dumps({"status": "eligible-for-device-gates", "finalApkSha256": final_apk.lower(), "protectedCount": vmp["protectedCount"]}, ensure_ascii=False))事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| R8 的职责包括代码和资源缩减、优化与名称混淆,发布构建需要保存相关规则和输出。 | Enable app optimization with R8 描述 R8 优化、缩减与混淆能力及发布使用方式。 | R8 编译优化不等于 VMP,也不证明抗动态分析、业务授权或运行兼容。 |
| 反射、JNI 和间接入口需要精确 keep 规则,宽泛规则会削弱优化并掩盖边界问题。 | R8 keep rules best practices 给出 keep 规则的精确化方向和常见入口考虑。 | keep 规则只描述 R8 可达性与优化约束,不定义 VMP 保护范围或强度。 |
| 混淆后的 Java 与 Kotlin 崩溃需要使用同一构建产生的 mapping 进行还原。 | R8 retrace 描述使用 mapping 文件还原混淆堆栈的方法。 | retrace 不处理 Native 符号,也不能修复 mapping、VMP 报告或 APK 候选身份错配。 |
| 依赖真实 Android 运行时、组件和系统 API 的语义需要设备端测试。 | Android instrumented tests 说明设备测试用于验证依赖 Android 运行环境的行为。 | 单一设备通过不能代表完整 API、ABI、厂商和保护目标矩阵。 |
| 抗逆向和抗篡改属于移动端纵深防御,不能替代服务端授权和完整发布链。 | OWASP MASVS-RESILIENCE 描述移动应用韧性控制域。 | 控制目录不证明当前候选达到任何防护强度,也不提供御盾产品验收结论。 |
| 安全发布流程应保存来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 描述安全软件开发中的构建、来源、验证和变更实践。 | SSDF 是组织级框架,不定义 R8 与 VMP 的具体插入点或某个加固产品功能。 |
| 当 VMP 消费 R8 输出时,应先冻结 R8 产物和 mapping,再解析 VMP 方法清单。 | 工程判断:R8 可能删除、内联或重命名源码方法,post-R8 身份才能对应真实变换输入。 | 编译期插件可能使用其他受支持插入点,必须以当前工具契约和最终映射证据为准。 |
| R8 输出、目标清单、VMP 报告和最终 APK 必须形成连续摘要链。 | 工程判断:连续输入输出绑定可以阻止旧 mapping、旧报告或中间产物冒充当前发布候选。 | 摘要链证明记录一致性,不单独证明工具可信、运行语义正确或保护强度达标。 |
工程常见问题
R8 和 VMP 到底谁先执行?
若 VMP 以 R8 输出为输入,默认先 R8,再按 post-R8 身份解析目标并执行 VMP,最后打包签名。若工具明确要求编译期插入,则按当前版本契约执行并证明最终映射。
R8 已经混淆名称,为什么还需要 VMP?
两者职责不同。R8 负责缩减、优化和名称混淆;VMP 对选定方法做工具支持的保护变换。是否采用 VMP 取决于业务价值、威胁模型和兼容证据。
能否直接用源码方法名作为 VMP 目标?
不能假设可以。R8 可能重命名、内联、合并或删除方法。应保存源码意图,并用同一构建的 mapping 和 post-R8 清单解析最终目标。
为了避免漏选,把整个业务包都 keep 是否更稳?
通常不合适。宽泛 keep 会掩盖反射或 JNI 的真实可达性需求并削弱优化。keep 与 protect 应分别维护,用精确规则保留入口,用独立目标清单选择 VMP。
VMP 报告显示方法成功,是否说明发布完成?
不能。还要把报告绑定最终 APK 与签名身份,并完成 R8-only、R8-plus-VMP 和最终候选的静态与设备回归。中间阶段成功不是线上结论。
提交 R8 与 VMP 加固评估需要哪些资料?
准备最终 APK、构建任务、R8 规则与 mapping、源码保护意图、post-R8 方法清单、VMP 报告、签名身份和四层回归结果,再通过御盾中央平台提交申请。