先看结论与判断条件
- 回归契约的对象是一个明确核心函数和业务资产,不是笼统的页面冒烟或整个应用“运行正常”。
- 输入需要覆盖正常、边界、空值、错误格式和状态组合,并保存可公开或受控的数据版本与生成方式。
- 输出比较要先规范化时间、顺序、随机标识和浮点表达,区分业务等价与字节完全相同。
- 异常类型、错误码、事务结果、文件或数据库变化、日志分类和线程要求都属于函数语义,不能只比较返回值。
- 性能边界必须在相同候选、设备、进程和迭代条件下测量,由项目风险和调用频率定义,不能套用通用数字。
- 混淆映射、测试结果和设备证据必须绑定同一 APK 摘要;契约通过也不等于完整兼容矩阵或保护强度得到证明。
先为单个核心函数定义可判定的业务契约
VMP 会改变方法的执行载体和调试形态,但业务方真正关心的是函数语义是否保持。若加固前没有明确契约,团队只能在加固后凭页面是否打开、是否崩溃来判断,细微的错误码、舍入、状态更新或异常变化容易漏掉。契约应以 assetId、方法签名和业务责任为起点。
一个可执行契约至少描述前置状态、输入 schema、测试向量、规范输出、异常、允许副作用、线程条件、资源边界和证据位置。每条用例有稳定 caseId,并引用基线 APK 摘要与保护候选摘要。这样失败能定位到具体输入和语义,而不是把全部问题归为“VMP 不兼容”。
契约不能由实现代码自动复制,因为这样会把当前缺陷固化成预期。业务负责人定义可接受语义,研发提供实现细节,测试负责人把它转换为断言,安全与发布人员确认候选身份。工程判断是让高价值函数优先建立契约,而不是追求所有工具方法都拥有同样深度。
| 字段 | 回答的问题 | 证据来源 | 失败时动作 |
|---|---|---|---|
| assetId 与方法 | 保护哪个业务资产 | 资产清单与编译后符号 | 确认选择器是否漂移 |
| preconditions | 调用前必须满足什么 | 业务状态和环境夹具 | 区分环境与实现失败 |
| input cases | 哪些输入必须覆盖 | 受控数据集与生成规则 | 补缺失边界 |
| expected semantics | 输出、异常和副作用是什么 | 产品规则与基线审查 | 定位语义变化 |
| artifact identity | 结果对应哪个候选 | APK、配置与 mapping 摘要 | 拒绝错配报告 |
输入向量要覆盖边界,不要只保存理想样例
核心函数常在正常输入下表现一致,却在空值、极限长度、编码、状态缺失或并发条件下暴露差异。契约应从输入字段类型、允许域和业务状态推导 case,至少区分有效、边界、无效和依赖状态变化。本文不提供固定用例数量,复杂度应由函数风险和输入空间决定。
测试数据需要可重现。公开安全样例可以直接版本化,涉及客户、账号或专有模型的数据应保存脱敏生成器、内容摘要和受控引用,不能把真实敏感数据放进代码仓库。随机测试必须记录 seed 和生成器版本,否则基线与保护候选可能运行了不同输入而无法比较。
输入序列也可能属于契约。单次纯函数调用与多步状态机不同,授权、缓存、计数器或加密会话可能依赖前序动作。工程判断是把 stateSetup 和 callSequence 明确写入 case,并在每次执行前重置到已知状态。只记录最终请求会遗漏导致结果的历史条件。
| 维度 | 示例责任 | 常见遗漏 | 契约记录 |
|---|---|---|---|
| 正常输入 | 主业务路径 | 只保留一条成功样例 | 字段和预期结果 |
| 边界输入 | 空值、长度和范围边缘 | 边界由实现猜测 | 业务允许域 |
| 错误输入 | 格式、类型和状态不符 | 只断言发生异常 | 异常类型和错误码 |
| 状态组合 | 登录、缓存和数据库版本 | 前置状态不重置 | stateSetup 摘要 |
| 输入序列 | 多步协议和重试 | 只测最后一步 | callSequence 与顺序 |
输出比较要先定义规范化与等价关系
返回对象可能包含时间戳、随机 ID、无序集合、浮点值或设备信息,直接做字符串比较会产生噪声。契约应列出哪些字段必须完全相同,哪些字段按业务规则规范化,哪些字段只验证格式和关联关系。规范化函数本身需要版本化和测试,不能在失败后临时修改以迎合候选。
业务等价不等于字节相等。序列化字段顺序变化可能不影响业务,金额舍入、权限决策或签名输入变化则可能非常关键。产品与研发应为每个输出字段声明 comparator 类型,例如 exact、ordered-set、format、relation 或 project-tolerance,并写清原因。项目容差不能外推成通用 VMP 结论。
输出摘要适合归档大结果,但必须保留规范化算法和原始结果的受控引用。只保存 SHA-256 能确认结果是否与某次执行相同,却无法解释哪个字段变化。工程判断是门禁先给出字段级差异,再用摘要绑定完整记录。公开报告只展示安全字段,不泄露核心算法输入输出。
| 字段类型 | 比较方式 | 需要固定 | 错误做法 |
|---|---|---|---|
| 业务决策 | exact | 输入、状态和策略版本 | 允许任意变化 |
| 有序结果 | 逐项和顺序比较 | 排序规则与 locale | 先排序掩盖顺序语义 |
| 无序集合 | 规范化成员后比较 | 去重与身份字段 | 比较原始序列 |
| 随机或时间字段 | 格式与关联关系 | 时钟、seed 或生成规则 | 要求字节一致 |
| 浮点或模型输出 | 项目容差与数据集 | 指标、基线和后果 | 使用通用阈值 |
异常、副作用和线程条件也是函数语义
很多回归只比较成功返回值,却忽略失败行为。VMP 前后若异常类型、错误码、抛出时机或包装层次变化,上层可能走入不同恢复路径。契约要为预期失败输入记录 exceptionClass、errorCode、messageCategory 和状态结果,并区分业务拒绝与系统故障。错误消息全文通常不适合作稳定断言。
副作用包括数据库写入、文件变化、缓存更新、计数器、网络请求和审计事件。契约应列出允许新增、必须保持和明确禁止的副作用,并在调用前后读取受控状态。事务失败时要检查是否回滚,而不是看到异常就结束。对于外部服务,使用可复现测试环境或记录请求契约,不发送真实客户数据。
线程和重入条件也可能改变语义。某些核心函数要求主线程,另一些必须离开主线程;并发调用可能要求幂等或串行。工程判断是把 dispatcher、thread expectation、timeout ownership 和 reentrancy 写入契约,但不在文章中给出通用超时数字。实际边界应由业务频率、设备和恢复能力决定。
| 语义 | 基线记录 | 保护候选检查 | 失败影响 |
|---|---|---|---|
| 异常类型 | 类、错误码和分类 | 同输入触发相同业务分支 | 上层恢复改变 |
| 数据库状态 | 前后快照或查询摘要 | 写入、回滚和约束一致 | 数据损坏或重复 |
| 文件与缓存 | 允许路径和内容摘要 | 变化范围符合契约 | 状态泄露或陈旧 |
| 外部调用 | 请求 schema 和次数 | 不新增未授权副作用 | 费用或重复动作 |
| 线程与重入 | 调用线程和并发规则 | 满足同一执行契约 | 死锁、ANR 或竞态 |
依赖 Android 的语义要放到设备端验证
Android instrumented tests 说明设备或模拟器上的测试可以访问真实 Android 框架能力。核心函数若依赖 Context、Keystore、Binder、SQLite、文件权限、组件或系统 API,JVM 测试只能覆盖部分逻辑。契约应标记 executionClass,把纯逻辑留在快速测试,把平台语义放进 instrumented test。
设备测试必须安装精确候选,并记录 APK、VMP 配置和测试包摘要。测试运行前建立前置状态,运行后收集断言、日志分类和系统信息。若测试 APK 与应用 APK 使用不同 build variant 或依赖版本,结果不能直接作为发布候选证据。测试通过只覆盖声明的 case 和设备。
单一设备不能代表全部 API、ABI 与厂商矩阵。本文只建立核心函数契约,不替代整机兼容矩阵、冷启动测量或异常专项。项目应根据最低系统、主要 ABI、硬件能力和用户分布分配契约用例;未执行的组合明确标记未验证,不用一个绿色总状态遮盖。
| 依赖 | 首选执行层 | 必须记录 | 边界 |
|---|---|---|---|
| 纯计算 | 本地或 JVM 测试 | 输入、输出和实现版本 | 不证明设备集成 |
| Android framework | instrumented test | 设备、系统与候选摘要 | 单设备不可外推 |
| 组件生命周期 | 真实组件启动路径 | Manifest、进程和状态 | 单元测试不可替代 |
| 硬件或 Keystore | 目标能力设备 | 能力、密钥状态和结果 | 模拟器能力不同 |
| 跨进程与 Binder | 多进程设备用例 | 进程、调用顺序和错误 | 需单独超时诊断 |
性能边界要用可重复基准,不预设加固结论
Android Macrobenchmark 适合在独立测试进程和可重复设备条件下比较启动或关键路径,并记录迭代。核心函数性能契约应先说明调用频率、用户路径和资源预算,再选择微观计时或端到端基准。基线和保护候选要在同一设备状态、构建身份和测试流程下运行。
基准框架不会提供加固前后的预设结论。缓存、编译模式、温度、后台负载和数据规模都会影响结果。文章不提供统一百分比或毫秒阈值;项目应根据业务后果、调用频率和设备分布定义 performanceBoundaryRef,并保留原始迭代数据,而不是只保存平均值。
性能失败需要定位而不是自动取消保护。方法可能调用过于频繁、输入过大、被放在启动主路径或包含外部 I/O。工程判断是先分解调用次数、函数时间和端到端时间,再选择减少频率、移动时机、缓存或缩小保护范围。任何优化都要重新绑定候选并重复语义契约。
| 维度 | 固定内容 | 原因 | 输出 |
|---|---|---|---|
| 候选身份 | APK 与配置摘要 | 防止比较不同构建 | 基线和保护映射 |
| 设备状态 | 型号、系统和运行条件 | 控制环境差异 | 设备记录 |
| 数据与调用路径 | 输入版本和频率 | 解释业务负载 | caseId 与路径 |
| 迭代数据 | 每轮原始结果 | 避免平均值掩盖离散 | 受控结果集 |
| 边界引用 | 项目批准的判定规则 | 不使用通用数字 | 通过、失败或待评审 |
R8、retrace 与证据身份必须一起保存
Enable app optimization with R8 说明 R8 会执行缩减、优化和名称混淆,并需要保留规则与输出。VMP 契约中的源码方法可能在最终候选中被内联、重命名或裁剪,因此测试记录要保存 R8 规则、mapping 摘要、VMP 配置和编译后选择器。只有 assetId 到最终方法的映射闭合,失败才能定位。
R8 retrace 要使用同一构建产生的 mapping 还原混淆后的 Java 或 Kotlin 崩溃。拿错 mapping 会产生误导堆栈,retrace 也不处理 Native 符号,更不能修复候选包身份错配。契约失败若伴随崩溃,报告应先核对 APK 摘要和 mapping 摘要,再进行堆栈还原。
NIST SP 800-218 SSDF 要求保留来源、构建、验证和变更证据。契约台账应连接需求、测试实现、基线结果、保护结果、候选身份、mapping、设备和批准结论。变更测试代码或 comparator 时产生新版本,不能覆盖旧回执。SSDF 不定义 VMP 产品功能。
- assetId 连接源码与编译后方法
- R8 规则和 mapping 摘要绑定候选
- 崩溃只用同构建 mapping retrace
- VMP 配置与 APK 摘要进入结果
- 测试和 comparator 版本不可覆盖
- 失败证据保留原始输入与状态
用结果比较器执行公开安全的契约门禁
下面的 Python 示例读取契约、基线结果和保护候选结果。契约定义 caseId、允许状态、输出摘要、异常类型、允许副作用与 performanceBoundaryRef。比较器检查两份结果都绑定同一 case,候选结果满足契约,并输出字段级差异。它不运行攻击、不读取私钥,也不包含客户数据。
性能字段只要求引用已批准证据,并把基线与候选的 evidenceId 交给独立评审,不在脚本中硬编码通用阈值。大输出使用 canonicalOutputSha256 绑定规范化结果,原始结果保留在受控位置。若契约字段缺失、摘要格式错误或出现未允许副作用,脚本返回非零状态。
准备核心函数的软件加固评估时,可整理资产清单、契约版本、公开或受控输入、基线与保护候选、R8 mapping、设备结果和性能边界,再通过御盾中央平台提交申请。契约通过只能证明已覆盖语义在指定范围一致,不证明完整兼容或保护强度。
- 契约由业务语义而非实现自动复制
- 输入数据和生成器具有版本
- 输出规范化算法固定
- 异常与副作用都有显式断言
- 性能使用项目边界引用
- 结果绑定 APK、配置与 mapping 摘要
- 契约通过不冒充完整兼容
from pathlib import Path
import json
import re
import sys
if len(sys.argv) != 4:
raise SystemExit(2)
contract_path = Path(sys.argv[1])
baseline_path = Path(sys.argv[2])
candidate_path = Path(sys.argv[3])
if not contract_path.is_file() or not baseline_path.is_file() or not candidate_path.is_file():
raise SystemExit(2)
contract = json.loads(contract_path.read_text(encoding="utf-8"))
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
required = ["caseId", "allowedStatus", "expectedOutputSha256", "expectedException", "allowedSideEffects", "performanceBoundaryRef"]
if any(field not in contract for field in required):
raise SystemExit(2)
if baseline.get("caseId") != contract["caseId"] or candidate.get("caseId") != contract["caseId"]:
raise SystemExit(2)
sha256_pattern = re.compile(r"^[0-9a-f]{64}$")
expected_digest = str(contract["expectedOutputSha256"]).lower()
if not sha256_pattern.fullmatch(expected_digest):
raise SystemExit(2)
if candidate.get("status") not in contract["allowedStatus"]:
raise SystemExit(2)
if str(candidate.get("canonicalOutputSha256", "")).lower() != expected_digest:
raise SystemExit(2)
if candidate.get("exception", "") != contract["expectedException"]:
raise SystemExit(2)
allowed_effects = set(contract["allowedSideEffects"])
candidate_effects = set(candidate.get("sideEffects", []))
if not candidate_effects.issubset(allowed_effects):
raise SystemExit(2)
if not baseline.get("performanceEvidenceId") or not candidate.get("performanceEvidenceId"):
raise SystemExit(2)
differences = {}
for field in ["status", "canonicalOutputSha256", "exception", "sideEffects"]:
if baseline.get(field) != candidate.get(field):
differences[field] = {"baseline": baseline.get(field), "candidate": candidate.get(field)}
result = {"status": "contract-pass", "caseId": contract["caseId"], "differencesForReview": differences, "performanceBoundaryRef": contract["performanceBoundaryRef"], "performanceEvidence": {"baseline": baseline["performanceEvidenceId"], "candidate": candidate["performanceEvidenceId"]}}
print(json.dumps(result, ensure_ascii=False, indent=2))事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 依赖 Android 运行时、组件和系统 API 的语义应在设备端验证。 | Android instrumented tests 说明设备测试可以访问真实 Android 框架能力。 | 单一设备通过不能代表完整 API、ABI 和厂商矩阵。 |
| 启动和关键路径比较应在独立测试进程与可重复设备条件下执行并记录迭代。 | Android Macrobenchmark 描述 Android 端到端基准的执行模型。 | 基准框架不提供加固前后的预设性能结论或通用阈值。 |
| 混淆后的 Java 或 Kotlin 崩溃需要使用同一构建的 mapping 进行还原。 | R8 retrace 说明堆栈还原对 mapping 文件的使用。 | retrace 不处理 Native 符号,也不能修复候选包或 mapping 身份错配。 |
| R8 会执行代码和资源缩减、优化与名称混淆并产生发布输出。 | Enable app optimization with R8 说明 R8 的发布构建职责。 | R8 优化不等于 VMP,也不证明抗动态分析或核心函数保护强度。 |
| 安全发布应保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 提供组织级安全软件开发实践。 | SSDF 不定义具体 VMP 产品、测试阈值或项目验收结论。 |
| 移动安全验收需要按不同控制域分别取证。 | OWASP MASVS overview 组织存储、密码学、认证、平台交互、代码质量与韧性等控制域。 | 控制域不提供御盾或任何具体产品的通过结论。 |
| VMP 前后回归契约必须同时覆盖输入、输出、异常和副作用。 | 工程判断:只比较成功返回值会遗漏上层控制流、事务和外部状态变化。 | 具体字段和用例取决于函数业务语义,本文不提供通用数量。 |
| 性能边界必须由项目风险、调用频率和目标设备定义。 | 工程判断:同一执行变化在不同业务路径和设备上的后果不同。 | 本文不提供统一百分比、毫秒阈值或任何项目性能结论。 |
工程常见问题
VMP 前后页面都能打开,为什么还要函数契约?
页面冒烟会漏掉错误码、舍入、异常、副作用和边界输入变化。函数契约能把失败定位到具体资产、case 和语义字段。
回归契约是否只需要保存输入和返回值?
不够。还要记录前置状态、异常、事务、文件或数据库变化、外部调用、线程条件、候选身份和性能证据。
输出包含时间戳或随机 ID 时怎样比较?
先定义版本化规范化规则,固定必须相等字段,对随机或时间字段验证格式和关联关系,不能在失败后临时改 comparator。
JVM 单元测试能否替代 instrumented test?
纯计算可以优先用 JVM 测试;依赖 Context、组件、Keystore、Binder、文件权限或系统 API 的语义需要设备端验证。
契约通过是否证明 VMP 没有性能影响?
不能。语义契约与性能证据应分开,性能要在相同候选、设备和流程下按项目边界评审,不存在通用预设结论。
申请核心函数 VMP 评估前要准备什么?
准备资产清单、契约、输入数据、基线与保护候选、VMP 配置、R8 mapping、设备和性能证据,再从御盾中央平台提交申请。