先看结论与判断条件

  • 先证明同一已知良好基线稳定通过、完整加固配置稳定失败,再开始二分;不稳定回归应先处理环境和测试噪声。
  • 固定的是源码、依赖、构建工具、变体、签名、设备、数据与测试步骤,不是每轮产物摘要;配置变化必然产生需要单独登记的新候选。
  • 配置项必须先规范化并按依赖关系组成不可拆原子组,互斥、前置、顺序敏感或共同生效的配置不能被机械切开。
  • 每轮只改变一个受控配置集合,记录两半的配置摘要、构建 provenance、APK 摘要、测试回执和不确定结果,禁止同时换设备或修业务代码。
  • 简单二分找到的是与失败相关的触发集合;遇到交互效应时,应转为最小失败集搜索,并用单项、组合项和恢复性对照验证。
  • 根因关闭需要最小复现、可解释机制、修复候选和同路径回归证据;退出原因、崩溃栈或某个半集失败都只是诊断线索。

先证明回归可重复,再谈配置二分

二分实验的起点不是把几十个开关随手分成两组,而是建立两个可重复端点:已知良好配置在目标路径稳定通过,完整待排配置在相同路径稳定失败。两端必须来自同一源码提交、依赖锁文件、构建工具链、发布变体、签名身份、设备状态、测试账号和输入数据。若端点本身时好时坏,二分会把环境噪声伪装成配置影响,轮数越多反而越难解释。

所谓同一候选身份,需要拆成固定身份和变化身份。source SHA、依赖摘要、build type、product flavor、applicationId、签名证书摘要以及测试用例版本应固定;受控配置集合是唯一允许变化的外部参数。配置一变,最终 APK 字节和摘要通常也会变化,因此每轮都要登记新的 artifact digest,而不能沿用完整配置候选的摘要。把旧摘要贴到新实验上,会让测试回执失去对象。

开始前还要定义可观察失败。启动闪退、特定页面异常、JNI 返回码变化、数据库迁移中断和后台任务不再完成,需要各自对应明确断言、采集时间窗与失败分类。测试人员不能只写“打不开”或“兼容性不好”,也不能在不同轮次临时更换点击路径。一个可用断言应能说明执行了哪一步、期望什么、实际观察是什么,以及不确定时为什么不能判定。

二分实验开始前必须固定或单独登记的对象
对象处理方式记录内容偏离后的动作
源码与依赖所有轮次固定提交摘要、锁文件摘要、子模块版本停止比较并重建端点
构建变体与签名所有轮次固定build type、flavor、applicationId、证书摘要标记为不可比候选
受控配置集合每轮按计划改变规范化列表、集合摘要、父实验编号拒绝计划外配置
APK 产物每次构建新登记文件摘要、构建编号、证明声明禁止复用旧回执
设备与测试输入同一实验链固定型号、系统、ABI、账号状态、数据快照重置后重新验证端点
失败断言预先版本化步骤、期望、实际、退出或日志证据不允许事后改判定标准

把加固开关变成可审计的配置集合

配置二分首先需要一个稳定清单。不要直接比较两份界面截图或两段顺序不明的文本,而应把配置导出为规范化记录:使用公开安全的抽象标签、确定排序、统一布尔值与枚举表示,并把继承值和默认值展开。列表还应注明配置来自项目默认、模块覆盖还是临时实验。只有序列化结果稳定,集合摘要才能真实表示本轮改变了什么。

配置清单不能暴露密钥、内部策略名称、客户包名或可被滥用的防护细节。公开记录可使用 cfg-control-flow、cfg-resource-check、cfg-native-transform 这类抽象标签,私有工程台账再把标签映射到真实配置。抽象不等于模糊,标签仍要一对一、版本化并可回查;同一标签在实验中不能悄悄代表多个实现选项,否则集合比较只剩形式。

构建系统中的默认值尤其容易破坏二分。某项配置未写出,不一定等于关闭,它可能由插件版本、环境变量、远端模板或模块级规则启用。因此,实验输入应该保存解析后的有效配置,而非仅保存用户编辑的差异文件。若解析器升级或默认值改变,即使表面配置清单相同,也必须视为新的实验链,重新建立良好与失败端点。

  • 为每个配置建立稳定且不含敏感信息的唯一标签
  • 按固定顺序序列化布尔值、枚举、列表和作用范围
  • 展开项目默认、模块覆盖与工具默认后的有效值
  • 记录配置解析器、加固工具和构建插件版本
  • 对规范化配置集合计算摘要并绑定父实验编号
  • 把抽象标签与真实配置的映射保留在受控工程记录中

先识别不能拆开的依赖与原子组

机械地按数量对半切分,隐含了各配置相互独立的假设,但加固配置经常存在前置关系、互斥关系和共同生效关系。某个变换可能要求先启用符号分析,某个运行时检查可能只在特定注入模式下生效,两个配置也可能共同改变同一生成阶段。若把依赖项拆到不同半集,构建失败或行为变化只能说明组合无效,不能说明其中某项造成兼容回归。

解决办法是先构造原子组。把必须同时启用或同时关闭的配置视为一个实验单位,并为每组写出 dependency、conflict、scope 和 order 属性。原子组的大小可以不同,分组目标也不再是严格数量相等,而是让两边的可疑范围和构建成本大致可管理。若某个大组无法再拆,二分到该组时应转向组件级诊断,不能伪造更细的独立开关。

还要区分控制项和可疑项。用于保持产物可安装、签名连续或运行时协议成立的配置属于控制项,应固定在所有实验中;只有与回归时间线和影响路径相关的配置进入可疑集合。把全部配置都放进二分,会让必要控制项被关闭,得到一个无法安装或完全不执行目标路径的包,这种结果不能用于判断兼容性。

决定配置能否独立进入二分的依赖检查
关系类型识别问题实验处理可接受结论
前置依赖关闭 A 后 B 是否失去语义A 与 B 组成原子组或固定 A只判断组合,不归因单项
互斥关系A 与 B 是否不能同时启用分成合法方案而非同组对照比较方案,不比较开关数量
顺序敏感变换次序是否改变输出把顺序作为显式参数固定当前次序下的触发关系
作用域重叠多个配置是否改写同一类或资源先按模块或阶段聚合定位到作用域组
必要控制项关闭后是否无法安装或到达路径所有轮次保持启用不参与嫌疑排序
独立可疑项单独切换是否仍生成合法候选允许进入半集可继续做单项与组合验证

每轮只让一个配置集合成为变量

第一轮把可疑原子组分成 A、B 两半,控制项保持不变,然后分别构建候选。更稳妥的执行方式是从已知失败的完整集合出发,先禁用 A、保留 B;若失败消失,A 中存在必要触发因素,下一轮在 A 内继续缩小。若仍失败,再禁用 B、恢复 A 进行对照,确认失败是否随 B 的移除而变化。只构建一个半集而不做互补或恢复实验,容易被偶发状态误导。

每轮实验记录需要包含父节点、启用集合、禁用集合、控制集合、规范化摘要、构建输入摘要、产物摘要和测试回执。SLSA provenance 可表达构建者、构建类型、外部参数与材料,in-toto statement 可把有类型声明绑定到产物 subject 摘要。它们适合防止报告与包错配,但不会替你判断运行时是否通过,也不会证明记录内容本身一定真实。

测试结果应允许 pass、fail 和 inconclusive 三种状态。设备离线、账号状态污染、日志缺失、步骤未执行完或观察与断言不匹配,都应记为 inconclusive,而不是勉强归入通过或失败。出现不确定结果时,先按预先定义的重置和重复规则恢复同一条件;若仍不稳定,暂停该分支并回到端点验证,不能通过多换几台设备挑选想要的结论。

  • 父实验与当前实验使用同一源码、依赖和工具链
  • 每个新候选都生成独立 APK 摘要与 provenance
  • 控制集合在所有子实验中保持完全一致
  • 设备、账号、数据快照和测试步骤未随半集改变
  • 结果明确区分 pass、fail 与 inconclusive
  • 互补半集和恢复性对照均保留可回查回执

配置交互会让简单二分给出假答案

简单二分假设失败具有单调性:包含触发项时失败,移除触发项后通过。真实配置可能不满足这个条件。例如 A 单独启用通过,B 单独启用也通过,只有 A 与 B 同时启用才失败;或者关闭 C 后暴露另一条失败路径。此时两个半集都可能通过,而完整集合失败。结论不应写成问题已消失,而应标记存在跨半集交互。

发现交互后,可以保留一个失败的完整集合,使用最小失败集方法逐步移除子集。每次移除都要确认其余配置仍构成合法候选,并记录通过、失败或不确定。若移除任何单项都会通过,得到的是当前条件下的最小触发集合,而不是唯一根因。另一个不同集合仍可能触发同一症状,尤其当多个变换汇聚到相同生成代码或运行时路径时。

组合验证至少包含单项 A、单项 B、组合 A+B、移除组合以及恢复完整集合。若只有 A+B 失败,可以说该组合与回归相关;若修复某一实现后 A+B 通过,同时旧实现恢复后再次失败,因果证据才更强。即便如此,还需要解释生成代码、资源、控制流或运行时协议为何发生错误,不能把统计相关直接写成技术根因。

二分结果与下一步实验决策
观察结果允许判断下一步禁止结论
A 失败、B 通过A 含有当前触发因素在 A 内继续分组并恢复对照A 中每项都是根因
A 通过、B 失败B 含有当前触发因素在 B 内继续分组并恢复对照B 中第一项必然有错
A 与 B 都失败可能有多个触发集或共享控制异常复核控制项并分别缩小二分方法无效
A 与 B 都通过、完整集失败可能存在跨半集交互执行组合和最小失败集搜索问题已自然消失
结果不稳定当前证据不可判定重置环境并复验端点选择出现次数更多的一侧
最小集合稳定失败得到当前条件下最小触发集解释机制、修复并做恢复实验已经证明唯一根因

设备状态、测试数据和退出证据必须同链绑定

兼容性回归往往依赖真实 Android 运行时、组件生命周期、系统服务或 ABI,因此关键路径应使用设备端 instrumented test 执行。测试记录要把用例版本、设备型号、系统版本、ABI、安装方式、权限、区域、账号状态和数据快照绑定到实验编号。单一设备通过只能说明该条件下未复现,不能外推到完整 API、ABI 与厂商范围。

每轮开始前应执行相同的安装、清数据、登录、数据准备和网络模拟步骤。如果目标是升级路径,就不能一轮覆盖安装、一轮全新安装;如果目标是离线授权,就不能在失败轮次改变服务端响应。设备矩阵可以在定位触发集合后扩展,但二分主链应先保持一个稳定复现环境,否则配置变量与平台变量会交叉,无法知道哪一个导致变化。

进程退出时,ApplicationExitInfo 可补充退出原因、ANR trace,并在新版本提供 Native tombstone。回执应关联实验 APK 摘要、安装时间、测试路径和观察时间窗。退出 reason、ANR trace 或 tombstone 都是诊断证据,不会自动指出某个配置就是根因;符号化、线程状态、业务阶段和恢复性对照仍然必需。没有同一候选绑定时,历史退出记录不能挪给当前实验。

  • instrumented test 断言与人工复现步骤使用同一业务语义
  • 设备型号、系统版本、ABI 和安装状态绑定实验编号
  • 权限、账号、区域、网络和数据快照按规则重置
  • 升级场景不会与全新安装场景混在同一二分链
  • 退出记录关联当前 APK 摘要、时间窗和用户路径
  • 单一设备的通过或失败不被外推为全矩阵结论

用只读脚本生成并校验二分实验批次

下面的 Python 脚本读取一个脱敏 JSON 计划,校验产物身份字段、固定测试维度、控制集合和可疑集合,再把可疑项分成两个实验。脚本只处理抽象标签,不读取真实加固策略,也不执行构建、安装或修改文件。输出包含输入摘要、父轮次、固定维度和两份启用集合,适合作为构建系统的只读前置门禁。

输入中的 artifact_identity 保存源码、依赖、变体、applicationId 和签名证书摘要,这些字段在当前二分链保持不变;baseline_enabled 保存必要控制项;suspects 保存经过依赖分组后的可疑原子组。脚本拒绝缺失字段、重复标签、控制项与可疑项重叠,以及少于两个可疑项的计划。错误直接返回非零状态,避免生成看似有效但无法比较的实验。

脚本输出的 enabled 集合不同,因此后续构建结果必须分别记录新的 APK 摘要。真正的流水线还应把规范化配置作为 provenance 外部参数,把 APK 摘要作为 statement subject,并在设备测试完成后追加只读回执。该示例不声称配置能够成功构建,也不判断哪一半会失败;它只保证拆分之前的输入结构完整、两半互补且固定维度一致。

为抽象加固配置生成可比较的二分计划
import hashlib
import json
import sys
from pathlib import Path

REQUIRED_IDENTITY = (
    "source_sha256",
    "dependency_lock_sha256",
    "variant",
    "application_id",
    "signing_cert_sha256",
)

def stable_digest(value):
    encoded = json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(encoded.encode("utf-8")).hexdigest()

def require_string_map(value, name):
    if not isinstance(value, dict) or not value:
        raise SystemExit(f"{name} must be a non-empty object")
    if any(not isinstance(key, str) or not isinstance(item, str) or not item for key, item in value.items()):
        raise SystemExit(f"{name} must contain non-empty string pairs")
    return dict(sorted(value.items()))

def require_labels(value, name):
    if not isinstance(value, list) or any(not isinstance(item, str) or not item for item in value):
        raise SystemExit(f"{name} must be a list of non-empty labels")
    if len(value) != len(set(value)):
        raise SystemExit(f"{name} contains duplicate labels")
    return sorted(value)

if len(sys.argv) != 2:
    raise SystemExit("usage: python bisect_plan.py PLAN_JSON")
plan_path = Path(sys.argv[1]).resolve()
if not plan_path.is_file():
    raise SystemExit(f"plan file not found: {plan_path}")
plan = json.loads(plan_path.read_text(encoding="utf-8"))
identity = require_string_map(plan.get("artifact_identity"), "artifact_identity")
missing = [field for field in REQUIRED_IDENTITY if field not in identity]
if missing:
    raise SystemExit(f"artifact_identity missing fields: {missing}")
fixed = require_string_map(plan.get("fixed_test_dimensions"), "fixed_test_dimensions")
baseline = require_labels(plan.get("baseline_enabled"), "baseline_enabled")
suspects = require_labels(plan.get("suspects"), "suspects")
if len(suspects) < 2:
    raise SystemExit("suspects must contain at least two atomic groups")
if set(baseline).intersection(suspects):
    raise SystemExit("baseline_enabled and suspects must not overlap")
round_number = plan.get("round")
if not isinstance(round_number, int) or round_number < 1:
    raise SystemExit("round must be a positive integer")
split_at = (len(suspects) + 1) // 2
left = suspects[:split_at]
right = suspects[split_at:]
if not left or not right or sorted(left + right) != suspects:
    raise SystemExit("bisect split is incomplete")
common = {
    "parent": plan.get("parent", "root"),
    "round": round_number,
    "artifact_identity": identity,
    "fixed_test_dimensions": fixed,
}
experiments = []
for label, selected in (("A", left), ("B", right)):
    enabled = sorted(baseline + selected)
    experiments.append({**common, "experiment": label, "enabled": enabled, "controlled_delta": selected})
output = {
    "input_sha256": stable_digest(plan),
    "identity_sha256": stable_digest(identity),
    "experiments": experiments,
}
print(json.dumps(output, ensure_ascii=False, indent=2, sort_keys=True))

用最小复现、修复候选和恢复性实验关闭问题

二分结束时,理想输出不是一个孤立的开关名称,而是一份最小复现包:固定的源码与依赖身份、合法变体、最小触发配置集合、确定设备条件、测试数据、逐步操作、预期与实际断言、APK 摘要、构建证明和退出证据。复现包应能让另一个工程人员在相同条件下得到同类结果,并明确哪些条件尚未覆盖。

根因分析要从触发集合继续进入机制。若可疑项影响代码变换,应比较生成方法、类初始化、异常语义或 JNI 边界;若影响资源或清单,应比较最终合并结果和运行时解析;若影响运行时注入,应检查加载顺序、线程、权限和进程模型。只有症状、触发配置与技术机制能够互相解释,才有资格提出修复,而不是简单把配置永久关闭。

修复后至少保留三类候选:旧实现加最小触发集应复现失败,新实现加同一触发集应通过,恢复旧实现后应再次出现同类失败。随后再把完整生产配置恢复,执行目标设备与业务矩阵。需要协助评估实际回归时,可通过御盾中央平台提交脱敏配置标签、候选摘要、构建证明、最小复现步骤和退出回执,先确认实验可比性,再讨论保护范围调整。

  • 最小复现包含固定身份、触发集合、设备条件和测试断言
  • 每份测试回执都绑定唯一 APK 摘要与构建证明
  • 技术机制能够解释为什么该配置组合触发当前症状
  • 旧实现、新实现和恢复旧实现形成因果对照
  • 完整生产配置恢复后重新执行目标业务与设备矩阵
  • 未覆盖的 API、ABI、厂商和数据状态写入发布边界

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
安全发布应保留来源、构建、验证与变更证据,并把供应链风险纳入开发流程。NIST SP 800-218 SSDF 把来源追溯、构建完整性、验证和持续维护纳入安全软件开发实践。SSDF 是组织级实践框架,不定义某个 App 加固产品的配置含义,也不能证明特定回归已经定位。
每轮配置变化构建出的产物应绑定构建者、构建类型、外部参数与依赖材料。SLSA Provenance v1.1 定义了 subject、builder、buildType、externalParameters 和 resolvedDependencies 等构建证明字段。provenance 证明记录的构建关系,不证明候选在设备上兼容、安全或性能满足要求。
实验回执必须绑定当前候选的产物摘要,不能把上一轮报告挪给新 APK。in-toto Attestation Statement v1 使用 subject 摘要与有类型 predicate 组织证明声明,可降低报告与产物错配风险。声明格式不保证签发者可信或内容真实,仍需签名验证、访问控制与门禁核对。
build type、product flavor、source set、applicationId 与签名配置会形成不同 Android 构建变体。Android build variants 说明构建类型、产品风味、源集、应用身份和签名配置共同参与 variant 生成。同一仓库或同一版本名不代表两个候选具有相同资源、SDK、证书或运行行为。
依赖真实 Android 运行时、组件和系统 API 的兼容行为应通过设备端测试验证。Android instrumented tests 说明 instrumented test 在 Android 设备或模拟环境中运行,可访问真实框架与组件语义。单一设备或单一用例通过不能代表完整 API、ABI、厂商和业务路径矩阵。
进程退出原因、ANR trace 与部分版本的 Native tombstone 可以为配置回归提供诊断线索。Android ApplicationExitInfo 描述历史进程退出原因、相关 trace 以及支持版本中的 Native tombstone 获取能力。退出信息必须绑定当前候选、时间窗和用户路径,且不能单独证明某个配置是技术根因。
加固配置应先按依赖、互斥和顺序关系组成原子组,再进入二分。工程判断:机械拆分相互依赖的配置会产生非法候选或改变多个语义变量,使通过与失败无法归因。原子组划分依赖项目配置语义与构建日志,抽象方法不能替代具体工具版本的工程核对。
二分得到的最小触发集合仍需恢复性对照和机制解释,才能支持根因结论。工程判断:配置与症状共同出现只建立相关性,恢复旧实现、应用修复并复验同一路径可加强因果证据。即使当前最小集合稳定复现,也可能存在其他触发集合,未覆盖设备和数据条件仍应明确保留。

工程常见问题

几十个加固配置能直接按数量一半一半关闭吗?

不能先按数量机械切分。应先展开有效值,识别前置、互斥、顺序与共同生效关系,把不可拆配置组成原子组,再对合法组进行二分。

配置变化后还能说是在测试同一个候选包吗?

不能说 APK 字节相同。固定的是源码、依赖、变体、签名和测试条件;每个配置集合都会产生新候选,必须登记自己的 APK 摘要与构建证明。

某一半配置失败,是否说明这一半里的配置都有问题?

不说明。它只表明当前条件下触发因素仍在该集合,下一轮需要继续缩小,并通过单项、组合项和恢复性实验排除交互与噪声。

两个半集都通过,但完整配置失败,下一步怎么办?

这通常提示跨半集交互或测试不稳定。先复验完整失败端点,再做组合实验和最小失败集搜索,不应把结果写成回归已经消失。

ApplicationExitInfo 能直接告诉我是哪项加固配置导致崩溃吗?

不能。它提供退出原因和可用 trace,仍要关联当前 APK 摘要、符号、时间窗、路径与配置对照,之后才能形成机制解释。

提交兼容性回归排查材料时最少要准备什么?

准备源码与依赖摘要、变体和签名身份、规范化配置标签、良好与失败端点、每轮 APK 摘要、设备条件、测试断言、构建证明和退出回执。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: App 加固 PoC 如何形成发布结论