先看结论与判断条件
- 核心函数清单的基本单位是业务能力、调用边界与编译后方法的对应关系,不是源码包、类名或方法数量。
- 每条候选链至少要标出用户入口、决策点、敏感操作、服务端复核和失败出口,避免只看叶子方法而遗漏真正的授权责任。
- 候选方法必须同时记录保护理由与排除理由,副作用、调用频率、线程位置、反射、JNI 和错误恢复都会影响选择。
- R8 映射、Keep 规则和最终候选摘要用于确认方法身份与可达性,但编译优化、名称混淆与 VMP 是不同职责。
- 清单版本要绑定源码提交、构建参数、映射文件、保护配置与产物摘要,才能在回归失败时准确回退单个方法。
- 静态调用关系只能生成候选,最终放行必须依赖同一产物上的业务结果、失败语义、设备矩阵和可观测性证据。
先定义清单要回答的问题,而不是先圈包名
VMP 核心函数清单首先要回答三个商业问题:哪段代码承载不可轻易替代的业务判断,哪条调用路径暴露后会降低软件的商业壁垒,哪种保护失败会直接影响登录、支付、授权或核心交易。若团队从包名、类名或方法总数出发,通常会把界面胶水、网络编排和错误恢复一起纳入,真正的核心逻辑反而淹没在大量低价值方法中。
清单中的一行不应只有方法签名。它还要包含业务能力、外部入口、上游调用者、下游依赖、输入敏感度、输出用途、服务端是否复核、线程与频率、允许的失败语义,以及为什么选择或排除。这样的记录可以让产品、研发、安全和测试围绕同一对象讨论,避免把“代码重要”误写成“所有实现都适合 VMP”。
工程判断上,保护范围越大并不自动等于防护越强。高频循环、生命周期编排、反射框架、序列化桥接和崩溃恢复具有更高的兼容与诊断成本,可能需要保持薄而可观测。本文没有具体候选包和实测回执,因此提供的是建立清单与验证决策的方法,不声称任何方法已经通过保护或达到特定强度。
| 比较项 | 粗放记录 | 可审计清单 | 决策价值 |
|---|---|---|---|
| 选择单位 | 包、类或方法数量 | 业务能力与调用边界 | 能解释为何保护 |
| 方法身份 | 源码名称 | 源码签名加编译后身份 | 能绑定最终候选 |
| 上下文 | 单个叶子函数 | 入口、决策、操作与复核链 | 能看见授权责任 |
| 风险记录 | 只有重要等级 | 副作用、频率与失败出口 | 能提前排除高故障对象 |
| 验收方式 | 构建成功或未崩溃 | 业务语义、设备与证据回执 | 能支持放行和回退 |
把一项业务能力拆成五类调用边界
业务链可以按入口、规范化、决策、敏感操作和服务端复核五类边界拆解。入口负责接收用户动作、Intent、深链或组件调用;规范化层把外部数据转换为受控模型;决策层计算价格、权限、额度或功能状态;敏感操作层执行解密、签名、许可证使用或本地模型推理;服务端复核层完成最终授权和可撤销控制。VMP 候选通常来自决策与敏感操作,但必须保留其前后边界。
入口本身可能公开、频繁且受 Android 生命周期约束,不一定适合强保护;服务端复核也不属于客户端 VMP 的替代对象。清单要明确客户端判断是体验优化、离线兜底还是最终授权。若客户端输出可以直接解锁高价值能力,而服务端没有重新判断,这首先是信任边界问题,不能只靠扩大 VMP 范围来掩盖。
一条完整记录还要写出失败出口。输入非法时是拒绝、降级还是重新认证,服务不可达时能否继续使用离线能力,受保护方法异常时由谁捕获并记录。没有失败出口的调用图只描述了成功路径,无法指导兼容测试。对商业 App 来说,核心函数的价值与故障半径必须同时呈现在清单里。
| 边界 | 典型责任 | VMP 初步选择 | 必须保留的证据 |
|---|---|---|---|
| 外部入口 | 接收界面、组件或深链输入 | 通常保持薄层 | 调用者与输入来源 |
| 输入规范化 | 解析、校验和模型转换 | 按敏感度评估 | 拒绝规则与异常语义 |
| 业务决策 | 权限、价格、额度和状态判断 | 优先识别候选 | 规则所有者与结果用途 |
| 敏感操作 | 密钥使用、签名或专有计算 | 按方法精确评估 | 输入输出和副作用 |
| 服务端复核 | 最终授权、撤销和风控 | 不由客户端 VMP 代替 | 请求绑定与拒绝回执 |
用资产清单、入口表、调用边和构建映射交叉取证
建立初始清单至少需要四份输入。第一份是由业务负责人确认的能力资产表,说明什么结果具有商业价值;第二份是 Android 入口表,列出 Activity、Service、Receiver、Provider、公开 SDK 接口和后台任务;第三份是源码或静态分析得到的调用边;第四份是发布变体的 R8 映射与最终产物摘要。任何单一输入都不足以得出完整结论。
资产表不应写“核心算法”这种无法核查的标签,而要写清场景、输入、输出、最终授权方和泄露后果。例如“离线许可证剩余权益计算”比“LicenseManager 很重要”更可操作。入口表则要覆盖非界面路径,包括通知、快捷方式、Widget、Binder、JNI 回调和定时任务。遗漏入口会让同一决策方法的频率与错误半径被低估。
调用图存在天然限制。反射、依赖注入、JNI、动态特性和生成代码会让静态边不完整,优化器还可能内联、合并或移除方法。因此清单要给每条边标注来源与置信度,并允许人工补边。静态分析结果只用于缩小候选范围,不能被写成运行时真实调用次数,也不能证明方法在所有构建变体中仍独立存在。
| 输入 | 提供的信息 | 常见缺口 | 补证方式 |
|---|---|---|---|
| 业务资产表 | 能力价值、所有者和授权责任 | 标签空泛或无人确认 | 业务评审与场景样例 |
| Android 入口表 | 外部和系统触发路径 | 漏掉后台与组件入口 | 最终 Manifest 与接口清单 |
| 调用边记录 | 调用者、被调用者和路径 | 反射、JNI 与生成代码缺边 | 人工补边和运行日志 |
| R8 映射 | 源码身份与编译后名称 | 变体或产物错配 | 绑定提交、变体与摘要 |
| 设备回执 | 真实生命周期和结果语义 | 矩阵不完整 | 按风险扩展系统与 ABI |
给每个候选同时写入选中理由与排除理由
候选方法可以从商业价值、客户端独占程度、服务端复核强度、输入敏感度和可替代成本几个方面描述保护收益,再从调用频率、线程位置、副作用、反射或 JNI 依赖、异常恢复和跨版本稳定性描述实施风险。评分只用来排序人工评审,不应把多维判断压成一个看似精确却无法解释的总分。
选中理由必须对应具体调用链。例如某方法位于订阅权益决策与离线能力解锁之间,输入来自已验证许可证,输出直接影响功能分支,而且服务端无法在离线场景即时复核,这比“方法名含 security”更有依据。相反,纯 DTO 转换、日志、路由、动画、通用加解码包装和框架生命周期即使位于核心包中,也可能因低商业价值或高故障半径被排除。
排除不是永久放弃保护,而是记录当前不能进入的原因与重新评估条件。某个方法可能因高频主线程调用暂缓,待缓存或批处理完成后再测;也可能因反射入口不清晰而先补 Keep 规则和设备回归。把原因、责任人、证据缺口和复核日期写入清单,能阻止后续版本把同一风险重复引入。
| 字段 | 需要回答的问题 | 倾向纳入信号 | 倾向排除信号 |
|---|---|---|---|
| 商业后果 | 逻辑暴露后影响什么 | 高价值规则或专有计算 | 通用胶水与展示逻辑 |
| 信任位置 | 谁做最终授权 | 离线关键判断 | 服务端已完整复核 |
| 调用特征 | 频率、线程和时序如何 | 低频可隔离叶子 | 主线程高频编排 |
| 副作用 | 是否触碰 I/O、锁或组件状态 | 纯计算且结果确定 | 跨进程、锁和恢复路径 |
| 可验证性 | 能否构造输入输出对照 | 边界明确可自动断言 | 依赖隐式全局状态 |
把源码签名映射到发布产物而不混淆 R8 与 VMP
Enable app optimization with R8 说明 R8 会执行代码和资源缩减、优化与名称混淆。清单中的源码方法在发布构建里可能被重命名、内联、合并或删除,因此保护配置不能只保存 IDE 中看到的签名。每次候选生成都应绑定构建变体、mapping 文件摘要、配置摘要和 APK 或 AAB 摘要,并标记方法在最终产物中的状态。
R8 keep rules best practices 强调 Keep 规则需要精确,反射、JNI 和间接入口要围绕实际需要保留。宽泛 Keep 可能暂时让方法“看起来存在”,却会扩大未优化范围并掩盖边界错误。清单应记录一条方法为什么需要 Keep、由谁间接调用、对应哪个测试。如果无法说明间接入口,先修复可达性证据,再讨论 VMP 命中。
R8 的缩减、优化和名称混淆不等于 VMP,mapping 也不能证明受保护代码的运行语义。工程上要分开保存三件事:源码到编译后身份的映射、VMP 配置实际命中的方法、设备端执行过的业务路径。只有三者绑定同一候选,才能回答“想保护的是否真的被命中”以及“命中后是否仍按预期工作”。
| 阶段 | 关键对象 | 需要保存 | 不能据此声称 |
|---|---|---|---|
| 源码评审 | 业务签名与调用边界 | 提交和资产编号 | 最终产物仍有独立方法 |
| R8 处理 | mapping 与优化配置 | 文件摘要和变体 | 已经获得 VMP 保护 |
| 保护配置 | 方法规则与命中清单 | 工具版本和配置摘要 | 业务语义已经正确 |
| 候选产物 | APK 或 AAB | 主体摘要和签名身份 | 所有设备都兼容 |
| 设备验证 | 路径、断言与结果 | 系统、ABI 和运行回执 | 未覆盖环境同样通过 |
让清单版本能够回答谁构建、保护了什么和如何回退
NIST SP 800-218 SSDF 将安全开发、验证、变更和供应链风险纳入组织流程。应用到核心函数清单时,意味着选择不是一张长期不变的表,而是随候选产物版本化的工程输入。每次修改要记录来源、评审者、变更原因、预期验证和退出条件,避免某个旧版本的保护结论被直接复制到新依赖、新编译器或新业务路径。
SLSA Provenance v1.1 描述了构建证明应绑定产物主体、构建者、构建类型、外部参数和依赖材料。VMP 清单可以引用这些字段来建立身份链,例如把清单摘要作为外部参数或附属材料,把最终 APK 摘要作为主体。provenance 证明记录的构建过程,不证明方法抗逆向强度,也不替代设备运行结果。
回退粒度应与清单粒度一致。若一次加入十几个方法后出现业务异常,团队需要知道每个方法的业务所有者、配置变更和测试覆盖,才能先撤销风险最高的一项,而不是回退整包或关闭全部保护。发布记录还要保留未纳入方法及原因,防止回退后失去原始决策依据。
| 字段 | 示例含义 | 审计目的 | 更新触发 |
|---|---|---|---|
| sourceRevision | 源码提交身份 | 定位业务实现 | 代码合并 |
| mappingDigest | R8 映射摘要 | 确认编译后身份 | 发布构建 |
| policyDigest | 保护配置摘要 | 确认实际范围 | 规则调整 |
| artifactDigest | 候选产物摘要 | 绑定测试与发布 | 重新打包 |
| decisionRecord | 纳入、排除及依据 | 支持复核和回退 | 边界或证据变化 |
用设备端业务断言决定候选是否真正放行
Android instrumented tests 适合验证依赖真实 Android 运行时、组件、线程和系统 API 的语义。核心函数候选至少要覆盖正常输入、边界输入、拒绝路径、离线或服务不可达、进程重建和关键组件入口。断言应检查业务结果、错误类型和状态变化,不能只看应用是否启动或测试进程是否没有崩溃。
测试回执必须绑定同一 APK 摘要、同一保护配置和明确设备环境。若测试使用未优化构建、临时签名包或不同 mapping,结果不能替代发布候选。单一设备通过也不能代表完整 API、ABI 和厂商矩阵;矩阵应围绕最低支持系统、主要架构、关键机型和真实入口选择,并明确尚未覆盖的边界。
OWASP MASVS-RESILIENCE 把抗篡改和抗逆向放在纵深防御语境中。清单验收因此不能只问“方法是否受保护”,还要确认服务端授权、密钥生命周期、更新链和错误恢复仍然独立成立。VMP 可以提高核心代码分析与篡改成本,但不能替代服务端最终判断,也不能把未验证的客户端信任边界变成安全结论。
- 测试包摘要与清单记录完全一致
- 每个候选至少有一条正向和一条拒绝断言
- 入口、决策和敏感操作链可关联到同一运行回执
- 异常由明确边界捕获且不会泄露敏感输入
- 设备、系统、ABI、构建变体和签名身份均被记录
- 未覆盖环境和服务端复核限制写入放行结论
用只读脚本合并构建映射与人工资产清单
下面的 Python 示例读取两份公开安全 JSON:构建映射记录原始签名、编译后名称、调用入口、频率和副作用;人工资产表记录业务能力、入口、决策方法、敏感操作、服务端复核状态和明确排除项。脚本验证结构后按方法身份合并,输出候选、入口、命中理由、排除理由和需要补证的映射缺口。
脚本不会反编译产物、修改保护配置或根据方法名猜测商业价值。候选只来自人工确认的决策与敏感操作集合;若方法存在副作用、属于明确排除项、没有任何入口或缺少编译映射,就生成可读理由并停止自动放行。输出中的 candidate 只表示进入人工评审,并不表示已经实施 VMP 或通过设备验收。
准备软件加固评估时,可整理业务资产表、Android 入口表、调用边、R8 mapping、候选包摘要、保护配置和设备回执,再通过御盾中央平台提交申请。清单与代码示例也可配合本站关于 Application 与 ContentProvider 启动边界的技术文章使用;没有同一候选的真实证据时,不写性能、兼容或攻击阻断结论。
- 输入只含可公开复核的方法身份和工程标签
- 构建映射与资产表分别保存 SHA-256 摘要
- 候选仅来自人工确认的决策和敏感操作
- 副作用、缺失调用者和缺失映射都会显式阻塞
- 脚本输出不代表 VMP 已实施或设备测试通过
- 最终放行仍绑定候选包、配置和设备回执
#!/usr/bin/env python3
import hashlib
import json
import sys
from pathlib import Path
def fail(message, code):
print(message, file=sys.stderr)
raise SystemExit(code)
def load_object(path):
try:
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail("cannot read JSON: " + str(exc), 3)
if not isinstance(value, dict):
fail("root value must be an object", 4)
return value
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def require_string(record, key):
value = record.get(key)
if not isinstance(value, str) or not value.strip():
fail("missing string field: " + key, 5)
return value.strip()
def string_set(value, field):
if not isinstance(value, list) or not all(isinstance(x, str) and x.strip() for x in value):
fail("invalid string list: " + field, 6)
return {x.strip() for x in value}
def index_mapping(document):
methods = document.get("methods")
if not isinstance(methods, list) or not methods:
fail("mapping methods are missing", 7)
index = {}
for item in methods:
if not isinstance(item, dict):
fail("mapping entry must be an object", 8)
original = require_string(item, "original")
if original in index:
fail("duplicate mapping method: " + original, 9)
callers = string_set(item.get("callers", []), "callers")
side_effects = string_set(item.get("sideEffects", []), "sideEffects")
index[original] = {
"obfuscated": require_string(item, "obfuscated"),
"callers": sorted(callers),
"sideEffects": sorted(side_effects),
"frequency": require_string(item, "frequency"),
}
return index
def build_inventory(mapping, assets):
capabilities = assets.get("capabilities")
if not isinstance(capabilities, list) or not capabilities:
fail("capabilities are missing", 10)
records = []
for capability in capabilities:
if not isinstance(capability, dict):
fail("capability must be an object", 11)
name = require_string(capability, "name")
entries = string_set(capability.get("entrypoints"), "entrypoints")
decisions = string_set(capability.get("decisions"), "decisions")
operations = string_set(capability.get("sensitiveOperations"), "sensitiveOperations")
exclusions = string_set(capability.get("exclusions", []), "exclusions")
server_check = capability.get("serverCheck")
if server_check not in {"required", "offline-limited", "not-applicable"}:
fail("invalid serverCheck for " + name, 12)
for method in sorted(decisions | operations | exclusions):
mapped = mapping.get(method)
reasons = []
excluded = method in exclusions
if mapped is None:
reasons.append("missing-build-mapping")
else:
if not mapped["callers"]:
reasons.append("no-recorded-caller")
if mapped["sideEffects"]:
reasons.append("side-effects:" + ",".join(mapped["sideEffects"]))
if excluded:
reasons.append("manually-excluded")
role = "decision" if method in decisions else "sensitive-operation"
candidate = mapped is not None and not excluded and not reasons
records.append({
"capability": name,
"method": method,
"compiledMethod": mapped["obfuscated"] if mapped else None,
"role": role,
"entrypoints": sorted(entries),
"callers": mapped["callers"] if mapped else [],
"serverCheck": server_check,
"candidate": candidate,
"reasons": reasons or ["boundary-complete-review-required"],
})
return records
def main():
if len(sys.argv) != 3:
print("Usage: inventory.py MAPPING_JSON ASSET_JSON", file=sys.stderr)
raise SystemExit(2)
mapping_path = Path(sys.argv[1])
asset_path = Path(sys.argv[2])
if not mapping_path.is_file() or not asset_path.is_file():
print("required input file missing", file=sys.stderr)
raise SystemExit(2)
mapping_doc = load_object(mapping_path)
asset_doc = load_object(asset_path)
inventory = build_inventory(index_mapping(mapping_doc), asset_doc)
result = {
"status": "manual-review-required",
"mappingSha256": digest(mapping_path),
"assetSha256": digest(asset_path),
"records": inventory,
}
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
if __name__ == "__main__":
main()事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 移动端抗逆向与抗篡改应作为纵深防御的一部分,而不是服务端授权的替代物。 | OWASP MASVS-RESILIENCE 将相关控制放在应用韧性与纵深防御语境中。 | 控制目录不证明某个候选包、具体方法或配置已经达到任何防护强度。 |
| 安全开发与发布流程应保留来源、验证、变更和供应链风险处置记录。 | NIST SP 800-218 SSDF 提供组织级安全软件开发实践框架。 | 该框架不定义 VMP 产品功能,也不决定某个 Android 方法是否适合保护。 |
| 发布构建中的方法身份会受到缩减、优化和名称混淆处理影响。 | Enable app optimization with R8 说明 R8 的代码与资源处理职责。 | R8 处理不等于 VMP,也不能证明最终产物具备抗动态分析能力。 |
| 反射、JNI 和间接入口需要围绕实际可达性设计精确的保留规则。 | R8 keep rules best practices 说明 Keep 规则应精确并面向实际入口。 | Keep 规则只影响 R8 可达性与优化边界,不定义 VMP 可保护范围。 |
| 依赖 Android 运行时、组件和系统 API 的业务语义需要设备端验证。 | Android instrumented tests 说明设备测试可访问真实 Android 框架能力。 | 一个设备或一条成功路径不能代表完整 API、ABI、厂商和异常矩阵。 |
| 构建证明可以绑定产物主体、构建者、构建类型、参数和依赖材料。 | SLSA Provenance v1.1 定义了构建来源记录的核心结构。 | provenance 证明记录的构建过程,不单独证明运行时业务正确或防护强度。 |
| 核心函数应按业务调用边界筛选,而不是按包名、类名或方法数量批量圈选。 | 工程判断:商业价值、信任责任、故障半径和可验证性必须在同一条候选记录中解释。 | 具体选择仍需项目资产所有者、最终构建映射和同一候选设备回执共同确认。 |
| 候选记录必须同时保留纳入理由、排除理由与重新评估条件。 | 工程判断:可追踪的双向决策才能支持单方法回退并避免版本迭代重复引入风险。 | 清单本身不是运行证据,不能据此声称兼容、性能或攻击阻断已经通过。 |
工程常见问题
核心包里的所有方法都应该进入 VMP 清单吗?
不应该。包名只能提供组织线索,清单还要确认业务能力、调用入口、信任位置、副作用、频率和失败出口。界面胶水、框架生命周期、通用转换和错误恢复常需要保持薄而可观测。
方法调用次数越少,是否越适合 VMP?
低频通常降低性能和故障放大风险,但不是充分条件。方法还应有明确输入输出、较少副作用、可测试失败语义和足够商业价值,并与服务端授权责任保持一致。
静态调用图能否直接作为最终核心函数清单?
不能。反射、JNI、依赖注入、动态特性和生成代码会造成缺边,优化器也会改变方法身份。静态图用于生成候选,最终要结合人工资产表、R8 映射和设备运行证据。
R8 mapping 文件为什么必须与清单绑定?
因为发布方法可能被重命名、内联、合并或删除。绑定 mapping 摘要、构建变体和产物摘要,才能确认源码候选在最终包中的身份,避免测试与保护规则指向不同产物。
服务端已经复核的客户端决策还需要 VMP 吗?
要看客户端是否仍承载专有计算、离线体验或高价值中间结果。服务端复核降低最终授权风险,但不自动消除代码泄露价值。清单应明确两端责任,再按方法评估保护收益。
提交核心函数保护评估前应准备哪些材料?
准备业务资产表、Android 入口、调用边、R8 mapping、保护候选与排除理由、最终包摘要、测试设备范围和服务端复核说明,再从御盾中央平台提交申请。