先看结论与判断条件
- 重载方法的唯一身份由类、方法名和参数返回描述符共同组成,短方法名不能区分实际目标。
- 源码签名、R8 mapping、最终方法清单和 VMP 规则必须来自同一候选构建,不能跨版本拼接。
- keep 规则控制 R8 可达性与名称约束,VMP 规则控制保护范围,两者共享身份但不能互相替代。
- 反射与间接入口需要精确保留和运行回归,扩大类级规则会掩盖身份绑定缺陷。
- 门禁必须对每条规则执行零命中、唯一命中和多命中检查,并把最终命中方法写入不可变回执。
- mapping 和 retrace 能帮助解释混淆名称关系,但不能修复错候选、错配置或未验证的保护结论。
先给结论:重载匹配必须精确到 descriptor
同一个类可以存在多个同名方法,参数类型、参数顺序或返回类型不同。VMP 规则若只写类名和方法名,可能同时命中多个重载,也可能在 R8 优化后命中完全不同的最终方法。正确的最小身份是 owner、name 和 JVM descriptor,规则生成器必须把三者作为不可拆分键,并记录它来自哪个源码资产。
名称混淆增加的是追踪步骤,不改变唯一身份原则。团队先在源码或编译后未混淆清单中登记目标重载,再使用同一发布构建生成的 R8 mapping 将它绑定到最终 owner、name 和 descriptor。最后从实际进入 VMP 的候选产物枚举方法,证明规则恰好命中一个对象。任何阶段缺失都应保持未确认。
门禁结果分为 unique、missing、ambiguous 和 forbidden-broad。unique 只说明规则精确命中,仍需语义回归;missing 表示目标被删除、内联、改形或清单错配;ambiguous 表示规则命中多个方法;forbidden-broad 表示使用类级或名称通配掩盖歧义。后三类不能靠增加更多通配符自动修复。
| 结果 | 含义 | 常见原因 | 发布动作 |
|---|---|---|---|
| unique | 只命中一个最终方法 | 身份完整且候选一致 | 进入语义回归 |
| missing | 未命中最终方法 | 删除、内联或错 mapping | 阻止并追踪 |
| ambiguous | 命中多个重载 | 缺少 descriptor | 收窄规则 |
| forbidden-broad | 使用类级通配 | 为省事扩大范围 | 拒绝自动放行 |
| stale | 输入属于旧构建 | 跨版本复用 mapping | 重建全部身份 |
| verified | 唯一命中且回归绑定 | 候选证据完整 | 限定范围登记 |
JVM descriptor 才能区分同名重载
Java 和 Kotlin 源码通常以可读类型展示参数,类文件则使用 JVM descriptor 表达参数与返回类型。两个方法即使都叫 verify,只要一个接收字符串、另一个接收字节数组,就拥有不同 descriptor。规则系统若丢掉 descriptor,只能得到名称集合,无法证明选中了业务所有者指定的那个重载。
数组、基本类型、对象类型和多参数顺序都会进入 descriptor。装箱类型与基本类型不是同一身份,可空性通常不会单独改变 JVM descriptor,泛型信息也可能在擦除后收敛到更一般的类型。审查需要面对最终类文件,而不能从源码显示文本猜测。若多个源码声明擦除后发生桥接或生成入口,还要记录 synthetic 与 bridge 关系。
返回类型也应保留在完整 descriptor 中,即使某些规则语法主要靠参数区分。原因不是追求格式复杂,而是让身份记录能与类文件、mapping、调用图和验证工具直接比较。任何转换器若自行省略返回类型,都应有明确规范和往返测试,避免不同工具对同一规则产生不同解释。
| 字段 | 示例语义 | 缺失风险 | 门禁要求 |
|---|---|---|---|
| owner | 方法所属最终类 | 同名类或迁移错配 | 完整内部类名 |
| name | 最终方法名 | 无法定位入口 | 不接受空值 |
| descriptor | 参数与返回类型 | 重载歧义 | 必须精确匹配 |
| sourceIdentity | 源码资产归属 | 命中但不知保护谁 | 绑定业务清单 |
| mappingDigest | 本构建名称关系 | 跨版本误用 | 不可变摘要 |
| candidateDigest | 最终输入产物 | 报告与包错配 | 发布身份绑定 |
重载、桥接与生成方法会叠加歧义
普通重载已经要求 descriptor,泛型擦除和协变返回还可能让编译器增加 bridge 方法。Kotlin 默认参数、扩展函数、suspend 函数和属性访问器也会产生与源码展示不同的类文件形态。规则评审必须区分手写业务实现、稳定调用入口和编译器生成适配层,不能把同名方法数量多理解为都属于同一资产。
目标资产可能经过多个入口到达同一实现,也可能一个源码入口在优化后分散到调用者。只保护短方法名会把旁路重载一起纳入,增加兼容与回归变量;只保护反编译器看起来最复杂的方法,又可能遗漏框架真实调用入口。正确记录允许一项资产关联多个最终方法,同时要求每个关联都有入口责任和排除理由。
规则生成前应先建立候选方法清单,标注 access flags、synthetic、bridge、调用者和源码线索。清单只是静态证据,不能证明运行时必经某个入口。对反射、序列化、JNI 或框架回调,还要从真实业务路径发起测试,确认 keep 规则与保护规则共同保持可发现性和语义。
R8 mapping 必须与候选构建一一对应
R8 的职责包括缩减、优化和名称混淆。源码方法可能被改名、删除、内联或改变最终调用布局,因此旧构建 mapping 不能用于新候选。流水线要保存 mapping 文件摘要、R8 配置摘要、工具版本、构建变体和候选摘要,并在生成 VMP 规则前确认这些输入来自同一次构建。
mapping 是名称关系证据,不是保护命中证明。它可以帮助把源码 owner 与方法名映射到混淆身份,也可以和 retrace 一起解释堆栈,但最终规则仍需对实际字节码方法枚举验证。若 mapping 中存在源方法而候选清单没有最终方法,门禁应调查优化结果,而不是凭 mapping 行直接登记已命中。
同一源码签名在不同变体中可能映射到不同最终身份,依赖和功能开关也会改变可达性。规则缓存键必须包含变体与 input hash,不能只按 slug、类名或日期复用。候选发生重建后,即使源码没有变化,也要重新核对 mapping 与方法清单,防止非确定依赖或工具变化造成静默漂移。
| 阶段 | 输入 | 输出 | 失败时不能做什么 |
|---|---|---|---|
| 资产登记 | 源码签名与业务理由 | 目标身份 | 按包名猜测 |
| R8 构建 | 代码与优化配置 | mapping 和候选 | 跨构建混用 |
| 身份转换 | 目标与 mapping | 最终精确键 | 丢弃 descriptor |
| 规则生成 | 最终键与语法 | VMP 精确规则 | 扩大类级范围 |
| 命中验证 | 规则与最终清单 | 唯一命中回执 | 只看命令成功 |
| 语义回归 | 真实入口与候选 | 结果和异常证据 | 宣称普遍兼容 |
keep 规则与 VMP 规则共享身份但责任不同
R8 keep 规则用于控制可达性、优化和名称约束,VMP 规则用于选择保护对象。一个反射调用的方法可能需要精确 keep 才能保留,同时也可能因业务价值进入 VMP;另一个方法可能必须 keep 以维持框架契约,却不值得纳入高成本保护。把两类规则合并成一个宽泛类级清单,会丢失责任和失败归因。
Android 官方 keep 规则最佳实践强调间接入口需要精确规则,过宽规则会削弱优化并掩盖配置缺陷。这个原则可以支持身份清单设计,但不能直接定义 VMP 范围。团队应从同一方法键派生两份规则,并分别记录 keepReason 与 protectionReason。某一理由为空时,不得自动继承另一份规则。
Kotlin 反射依赖运行时可发现的类、成员和元数据,名称或签名变化可能影响查找。对反射路径,回归需要使用真实查找方式和参数类型,不应为了测试方便直接调用最终混淆方法。若反射失败,先区分 keep、mapping、描述符、元数据和保护变换,不能立即扩大所有重载的保护或保留范围。
规则门禁必须失败关闭并保留原因
零命中不是成功。它可能表示目标被 R8 删除或内联,也可能说明 mapping、descriptor、构建变体或候选文件错配。流水线必须返回 missing 并阻止发布,随后由资产所有者确认逻辑迁移到了哪里。悄悄忽略零命中会让配置文件存在却没有保护对象,形成最危险的假通过。
多命中同样不能通过。若一个规则匹配两个重载,系统不能自行选择第一个,也不能把两个都加入以求保险。报告应列出所有候选的完整身份、flags 和调用者,要求修复 descriptor 或拆分资产映射。扩大保护面会增加运行变量,同时仍无法证明真正目标是其中哪个。
通配规则只在明确定义、数量固定且所有方法责任相同的极少场景下讨论,但本意图默认拒绝类级和名称级通配。每个例外需要规则所有者、业务资产、预期命中数量和回归集合。实际数量变化时立刻失败,不能把新增依赖方法自动纳入旧结论。
| 检查 | 通过条件 | 失败输出 | 修复边界 |
|---|---|---|---|
| 输入绑定 | 摘要全部一致 | stale-input | 重建身份链 |
| descriptor | 语法完整可解析 | invalid-descriptor | 只修签名字段 |
| 最终匹配 | 恰好一个方法 | missing 或 ambiguous | 追踪或收窄 |
| 通配检测 | 没有禁用通配 | forbidden-broad | 生成精确规则 |
| 资产关联 | 存在保护理由 | unowned-target | 补资产评审 |
| 回归绑定 | 入口和候选一致 | unverified-semantics | 执行目标用例 |
用结构化代码检查 mapping、descriptor 与规则命中
自动检查器应使用结构化输入,而不是依赖反编译界面截图。source_targets 保存源码 owner、name、descriptor 和资产标识;mapping 保存源码键到最终键的关系;final_methods 来自候选字节码枚举;rules 保存准备交给 VMP 的精确键。所有输入都需要候选摘要与 mapping 摘要,在外层由流水线验证一致。
下面的 Python 示例读取一个脱敏 JSON 报告,先验证 JVM descriptor 基本格式和禁止通配字符,再把每个源码目标映射到最终身份。它要求映射唯一、最终方法存在且规则恰好命中一次。任何 missing、ambiguous、stale 或 broad 都通过可达失败路径退出,不会修改类文件、APK 或保护配置。
脚本输出 targetBindings 和 ruleMatches,供审核人核对资产、源码签名和最终方法。它没有分析实际调用图,也不判断 VMP 能力、兼容或防护强度。通过只意味着输入报告内部一致;正式发布仍需真实工具命中回执、入口测试、异常回归和最终候选绑定。
from pathlib import Path
import json
import re
import sys
if len(sys.argv) != 2:
raise SystemExit("usage: verify_vmp_method_rules.py report.json")
report_path = Path(sys.argv[1]).resolve()
if not report_path.is_file():
raise SystemExit(f"rule report missing: {report_path.name}")
report = json.loads(report_path.read_text(encoding="utf-8"))
descriptor_pattern = re.compile(r"^\([^)]*\).+$")
for field in ("source_targets", "mapping", "final_methods", "rules"):
if not isinstance(report.get(field), list) or not report[field]:
raise SystemExit(f"{field} must be a non-empty array")
def identity(record, label):
if not isinstance(record, dict):
raise SystemExit(f"{label} entry is not an object")
owner = record.get("owner")
name = record.get("name")
descriptor = record.get("descriptor")
if not all(isinstance(value, str) and value for value in (owner, name, descriptor)):
raise SystemExit(f"{label} entry lacks owner, name, or descriptor")
if any(marker in owner or marker in name or marker in descriptor for marker in ("*", "?")):
raise SystemExit(f"{label} entry uses a forbidden wildcard")
if not descriptor_pattern.fullmatch(descriptor):
raise SystemExit(f"{label} entry has an invalid JVM descriptor")
return owner, name, descriptor
final_methods = {identity(item, "final_methods") for item in report["final_methods"]}
rules = [identity(item, "rules") for item in report["rules"]]
if len(set(rules)) != len(rules):
raise SystemExit("rules contains duplicate method identities")
mapping_by_source = {}
for entry in report["mapping"]:
if not isinstance(entry, dict):
raise SystemExit("mapping entry is not an object")
source = identity(entry.get("source"), "mapping source")
target = identity(entry.get("target"), "mapping target")
mapping_by_source.setdefault(source, []).append(target)
bindings = []
for target in report["source_targets"]:
source = identity(target, "source_targets")
asset = target.get("asset")
if not isinstance(asset, str) or not asset:
raise SystemExit("source target lacks a business asset identifier")
mapped = mapping_by_source.get(source, [])
if len(mapped) == 0:
raise SystemExit(f"source target is missing from mapping: {source}")
if len(mapped) > 1:
raise SystemExit(f"source target has ambiguous mappings: {source}")
final_identity = mapped[0]
if final_identity not in final_methods:
raise SystemExit(f"mapped method is absent from final inventory: {final_identity}")
match_count = sum(1 for rule in rules if rule == final_identity)
if match_count != 1:
raise SystemExit(f"target must have exactly one VMP rule: {final_identity}")
bindings.append({"asset": asset, "source": source, "final": final_identity, "rule_matches": match_count})
unused_rules = [rule for rule in rules if rule not in {tuple(item["final"]) for item in bindings}]
if unused_rules:
raise SystemExit(f"rules contains unowned final methods: {unused_rules}")
print(json.dumps({"status": "unique-method-bindings", "bindings": bindings}, ensure_ascii=False, indent=2))命中回执要与 retrace 和语义回归配对
retrace 用同一构建的 mapping 还原混淆 Java 或 Kotlin 堆栈,能帮助定位异常对应的源码位置。它不能处理 Native 符号,也不能修复错误候选或错误规则。发布证据应把命中回执和 mapping 保存,发生崩溃时再用候选身份选择正确 mapping,避免旧映射生成看似可读但实际错误的诊断。
语义回归必须从真实入口调用目标重载,覆盖正常输入、边界类型、空值约定、异常、并发和反射路径。测试断言关注输出、副作用和异常类型,而不是只确认方法被调用。若规则只保护一个重载,还要验证相邻重载仍按预期工作,防止描述符转换或桥接关系产生意外影响。
命中唯一和回归通过仍不是绝对防护结论。OWASP MASVS 将抗篡改与抗逆向放在纵深防御范围,不能替代服务端授权和完整发布链。VMP 规则可以提高特定客户端方法的分析与修改成本,但项目结论必须绑定真实候选和验证范围,不能根据配置数量宣称攻击必然失败。
发布证据必须让下一次构建可以重算
NIST SSDF 强调来源、构建、验证和变更证据。对应重载规则,证据包至少包含业务资产清单、源码签名、mapping 摘要、最终方法清单、精确规则、命中结果、工具版本、候选摘要和语义回归。每次构建都应从输入重新生成,而不是人工复制上一版本规则。
报告要区分事实、工程判断和限制。可以登记某条精确键在候选中唯一命中、某个入口测试通过、某份 mapping 与候选摘要一致;不能写成所有重载均安全、没有绕过路径、性能无影响或产品已阻断攻击。没有真实项目数据时不提供客户、排名、流量或量化防护结论。
准备实际规则评审时,可先查看[App VMP 保护范围选择指南](/zh-cn/vmp-protection-selection/),整理源码资产、完整 descriptor、R8 mapping、最终方法索引和回归入口。需要提交候选,可从[御盾中央平台申请加固评估](https://www.leonadev.com/console/)。材料应脱敏,登录、价格、购买和控制台操作统一由中央平台承接。
- 所有重载目标使用 owner、name 和完整 descriptor
- 源码目标、mapping、最终清单和候选来自同一构建
- keepReason 与 protectionReason 分开记录
- 每条规则都通过零命中、唯一命中和多命中检查
- 类级和名称级通配默认被门禁拒绝
- 真实入口、反射、异常和相邻重载均进入回归
- 命中、兼容和防护结论保持不同证据边界
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| R8 负责代码缩减、优化和名称混淆,源码身份不一定等于最终候选方法身份。 | Enable app optimization with R8 说明发布构建中的 R8 优化职责。 | R8 不等于 VMP,文档也不证明具体规则已经命中或达到防护强度。 |
| 反射、JNI 和间接入口需要精确 keep 规则,过宽规则会掩盖可达性边界。 | R8 keep rules best practices 说明间接调用场景的精确保留规则原则。 | keep 规则只约束 R8 行为,不能替代 VMP 选择和候选级语义回归。 |
| 混淆后的 Java 或 Kotlin 堆栈需要与同一构建的 mapping 配对还原。 | R8 retrace 说明使用 mapping 还原混淆堆栈的方法。 | retrace 不处理 Native 符号,也不能修复错候选、错 mapping 或错保护规则。 |
| Kotlin 反射依赖运行时可发现的类、成员和元数据,签名变化可能影响查找。 | Kotlin reflection 说明运行时访问类与成员的反射能力。 | 该文档不覆盖所有框架生成代码,也不证明特定反射路径已通过回归。 |
| 移动端抗篡改和抗逆向属于纵深防御控制。 | OWASP MASVS-RESILIENCE 给出移动端韧性控制类别。 | 控制目录不证明某个候选包达到具体强度,也不能替代服务端授权。 |
| 安全发布应保存来源、构建、验证与变更证据,并纳入供应链风险。 | NIST SP 800-218 SSDF 给出组织级安全软件开发与发布实践。 | SSDF 不定义 VMP 规则语法,也不证明某个候选已经通过。 |
| 重载方法规则必须包含完整 JVM descriptor,并在最终候选中唯一命中。 | 工程判断:短方法名无法区分同类重载,唯一键能支持自动失败关闭。 | 唯一命中只证明身份绑定,不证明运行兼容、业务正确或防护效果。 |
| mapping 必须与候选构建一一对应,不能跨版本复用。 | 工程判断:优化、混淆、变体和依赖会改变最终方法身份与可达性。 | 摘要一致仍需验证转换器、规则语法和实际工具命中。 |
| 发布证据应绑定源码资产、mapping、最终清单、规则、命中回执和测试。 | 项目证据尚未接入:本文列出必须保存的字段,不声称当前候选已完成验收。 | 配置存在、脚本通过或堆栈可读不能单独证明保护完成。 |
| 本文不提供客户、性能、排名或攻击阻断结论。 | 项目证据尚未接入:没有真实候选和运行回执时不扩张事实。 | 公开代码只用于身份与规则诊断,不能替代具体产品和候选验证。 |
工程常见问题
只写类名和方法名为什么不能匹配重载?
同一类可有多个同名方法,只有参数和返回 descriptor 才能形成完整身份。短名称规则可能零命中或同时命中多个对象。
把同名重载全部加入 VMP 是否更保险?
不保险。它扩大兼容和回归变量,仍不能证明真正业务目标是哪一个。应精确绑定资产和最终 descriptor。
有 R8 mapping 是否就能证明 VMP 规则命中?
不能。mapping 提供名称关系,还要在实际输入候选中枚举最终方法,并用规则执行唯一命中检查。
keep 规则已经精确,VMP 规则可以复用吗?
可以共享方法身份,但不能直接复用责任。keep 控制 R8 行为,VMP 控制保护范围,两者需要各自理由和验证。
规则零命中时可以忽略并继续发布吗?
不可以。零命中可能表示目标被删除、内联或输入错配,必须追踪资产最终位置,不能把配置存在当成保护完成。
retrace 能修复用错 mapping 的诊断吗?
不能。retrace 依赖与候选匹配的 mapping,输入错误可能生成误导结果。先用候选摘要选择正确映射。
反射调用的重载怎样做回归?
使用真实反射名称、参数类型和元数据从业务入口调用,覆盖成功、缺失、类型错误和异常,而不是直接调用混淆方法。
规则检查脚本通过是否等于保护已经完成?
不等于。脚本只证明报告内部的身份和唯一命中一致,仍需真实工具回执、候选绑定和语义回归。