先看结论与判断条件
- 最小样本的衡量标准是能否稳定复现目标链路并给出可判定结果,不是源码目录、页面或 APK 体积越少越好。
- 样本必须绑定精确 candidate SHA-256、variantId、applicationId、versionCode、artifactKind 和来源版本,文件名不能代替身份。
- 删除模块时要保留触发问题所需的类加载、Native 库、资源、组件、反射入口、第三方 SDK 和状态转换,避免修剪掉真正原因。
- AAB、通用 APK 与设备派生 APK 是不同对象;提交 AAB 时要说明模块结构和用于复现的设备 APK 集来源。
- 签名材料应说明阶段、渠道和证书公钥摘要,生产私钥、keystore 密码、服务账号与真实令牌不得进入样本包。
- 样本可以证明指定候选和环境内的问题可复现或已修复,不能外推全部变体、设备、性能、防护强度或生产发布结果。
最小可用的标准是保留因果链,不是尽可能删代码
一个有用的加固样本必须让接收方在没有口头补充的情况下完成同一组步骤,观察到与原项目相同的成功或失败判据。样本可以只保留一个入口、一个业务状态和少量依赖,但类加载顺序、组件声明、资源、Native 库、反射规则或 SDK 回调如果参与问题,就不能为了“更小”而删除。删除后不再复现,只能说明样本失真。
准备工作先写复现契约:目标是什么、从什么状态开始、执行哪些动作、期望结果是什么、当前偏差是什么、失败怎样被记录、环境怎样恢复。契约要在原始项目候选上先运行并保存回执,再逐步移除无关内容。每次删减后重复同一契约,只有目标现象和判定口径保持不变,删减才算有效。
最小样本与演示 App 不同。演示 App 可以重新实现相似功能,最小样本则要尽量保留原问题的构建和运行语义。若必须用替代接口、模拟服务或测试账号,应在清单中明确差异,以及这种差异可能掩盖哪些结论。不能因为样本能启动,就认为它仍代表原项目的保护范围或兼容风险。
| 判断项 | 合格条件 | 失败信号 | 处理 |
|---|---|---|---|
| 可复现 | 固定步骤稳定触发目标现象 | 只能偶尔由人工触发 | 补充状态和环境 |
| 有代表性 | 保留目标变体和关键依赖 | 换成无关空壳实现 | 恢复真实调用边界 |
| 可判定 | 成功和失败均有明确断言 | 只写运行正常 | 定义状态契约 |
| 可回退 | 测试数据可重置且操作可重复 | 一次执行后状态污染 | 增加夹具与清理 |
| 身份固定 | 候选文件摘要可重算 | 只给文件名和版本 | 补充 SHA-256 |
| 数据安全 | 不含生产秘密和个人数据 | 依赖真实账号或密钥 | 替换为受控测试材料 |
从失败路径反向裁剪,并为每次删减保留回执
有效裁剪从调用路径反向进行。先标出入口组件、关键业务方法、加固保护对象、异常出口和外部依赖,再把与路径无关的页面、资源和功能逐批移除。每批只改变一个有解释的范围,重新构建原始版本和加固版本,并运行相同复现契约。这样才能判断现象消失是因为修复、样本改变还是依赖被误删。
容易被误删的内容包括通过字符串反射加载的类、JNI 注册入口、ContentProvider 初始化、动态 Feature、资源名称查找、ProGuard 或 R8 规则、Manifest meta-data、证书校验配置和第三方 SDK 自动发现代码。这些对象在源码引用图上可能看似未使用,却可能决定真实运行路径。清单应说明保留理由,而不是通过宽泛 keep 规则把所有代码重新塞回去。
裁剪记录至少包含 revision、removedScopes、retainedReasons、candidateSha256 和 reproductionResult。若某次删除使问题不再出现,就回到上一份仍能复现的候选,再细分该范围。不要继续删除后把新现象当成原问题。最小化过程本身是诊断证据,也能帮助双方确定真正需要进入评估的模块和责任人。
| 对象 | 为什么容易漏 | 保留证据 | 错误做法 |
|---|---|---|---|
| 反射入口 | 静态调用图未必可见 | 字符串、配置与运行断言 | 删除后补全量 keep |
| Native 库 | 按 ABI 和加载路径选择 | 库清单、Build ID 与调用回执 | 只保留一个无关 ABI |
| 启动组件 | Provider 可能早于 Application | Manifest 与事件顺序 | 只测主 Activity |
| 资源查找 | 名称或配置动态决定 | 资源键与目标配置 | 换成硬编码常量 |
| 第三方 SDK | 自动初始化和回调隐蔽 | 精确版本与入口 | 用自制空接口替代 |
| 状态数据 | 前置状态决定错误分支 | 合成夹具和 before 摘要 | 每次手工临时准备 |
代表变体必须真实包含目标资源、SDK 和签名阶段
Android build variants 文档说明 build type、product flavor、source set、applicationId 与签名配置共同形成变体。同一仓库的不同渠道包可能拥有不同 SDK、Manifest、资源、Native 库和证书配置。如果问题只出现在某个 flavor,提交默认 debug 样本很可能无法复现,接收方也无法判断差异来自加固还是样本身份错误。
样本清单要写 variantId、applicationId、versionCode、artifactKind、配置摘要和构建来源,并让候选文件 SHA-256 成为证据主键。variantId 是业务可读标签,实际解析出的包身份和文件摘要才是接收端可以独立观察的事实。若样本为了安全改用测试 applicationId 或测试证书,应同时说明与原变体的差异边界。
多 Flavor 的系统性选样方法可参考[多渠道多 Flavor App 的 PoC 样本选择](/zh-cn/articles/choose-representative-poc-variant/)。该页面处理多个候选之间怎样选代表样本;本篇处理单个样本怎样最小化和安全提交。一个代表变体不能自动覆盖其他渠道,后续结论仍要限制在实际候选与差异接受范围内。
| 字段 | 用途 | 验证方式 | 不能替代 |
|---|---|---|---|
| variantId | 说明业务构建组合 | 与构建任务和配置对应 | 实际文件摘要 |
| applicationId | 说明应用安装身份 | 从候选解析 | 账号和服务端身份 |
| versionCode | 说明升级与渠道顺序 | 从候选解析 | 源码 revision |
| artifactKind | 区分 APK 与 AAB | 检查文件格式 | 设备派生对象 |
| configDigest | 锁定构建与加固配置 | 规范化后计算摘要 | 配置语义审查 |
| candidateSha256 | 锁定实际提交字节 | 接收端重算 | 兼容或安全结论 |
提交 AAB 时要说明模块结构和设备派生边界
Android App Bundle format 描述 AAB 中的 base、feature、配置和资产模块。AAB 是发布格式,设备实际安装的是依据设备配置派生的 APK 集。因此只提交一个 AAB 并写“无法安装”,不足以复现设备问题;还要说明目标设备配置、动态模块状态和用于执行的派生方式。
如果目标问题发生在加固转换或上传 AAB 阶段,AAB 本身可以作为主候选;如果发生在特定设备的安装、启动或 Native 加载,样本还应保存生成派生 APK 集的工具版本、device spec、模块集合与派生对象摘要。通用 APK、本地 bundletool 输出和商店派生 APK 不能混为同一个文件身份。
为了最小化,可以移除与复现无关的 Feature 和资产包,但必须保留触发路径实际依赖的模块关系。若 Feature 安装状态、按需下载或资源配置本身属于问题,就应把它列入复现契约。一个静态 base 模块能够启动,不代表原来的动态交付路径仍然存在。
| 产物 | 适用问题 | 必须补充 | 结论边界 |
|---|---|---|---|
| APK | 直接安装与运行问题 | 摘要、签名阶段和设备环境 | 只覆盖该 APK |
| AAB | 构建、转换与上传问题 | 模块清单和 bundle 配置 | 不是设备最终 APK |
| 本地 APK 集 | 特定 device spec 复现 | 派生工具、spec 与集合摘要 | 不代表商店对象 |
| 动态 Feature | 按需模块安装或加载 | 模块状态与触发步骤 | base 通过不能替代 |
| 资产包 | 资源交付和读取 | 交付模式与测试资产 | 不能携带客户内容 |
| 商店样本 | 真实渠道派生验证 | 受控下载和渠道回执 | 不公开生产账号 |
签名信息要足够复核,但生产私钥绝不能随样本提交
Sign your Android app 区分应用签名密钥、上传密钥、证书与 Play App Signing 的责任。样本需要说明处于 unsigned、test-signed、upload-signed 或 channel-derived 哪个阶段,并可提供证书公钥摘要用于身份核对。生产私钥、keystore 文件、密码、服务账号和签名服务凭据不属于复现材料。
多数兼容问题可以使用受控测试证书复现,但签名相关行为可能依赖证书、公钥摘要、权限共享、升级连续性或第三方平台配置。此时不能偷偷把生产密钥放进样本,也不能把测试证书结果外推生产。应把签名依赖写成明确边界,由授权人员在隔离环境执行所需验证,只输出最小回执。
样本清单应包含 signingStage、certificateSha256 和 expectedSigningResponsibility。若提交的是未签名 AAB,就说明后续由哪个受控阶段签名;若提交测试签名 APK,就说明它不能作为商业候选。平台文档只能解释一般责任,不能确认某个实际包使用了正确证书,接收方仍需对候选独立解析。
| 材料 | 是否进入样本 | 安全替代 | 说明 |
|---|---|---|---|
| 证书 SHA-256 | 可以 | 公钥身份摘要 | 标注渠道和阶段 |
| 测试签名 APK | 按需 | 隔离测试候选 | 不得冒充商业发布 |
| 生产 keystore | 禁止 | 受控签名服务回执 | 不复制私钥容器 |
| keystore 密码 | 禁止 | 无 | 不进入代码和工单 |
| 服务账号凭据 | 禁止 | 短期受控执行回执 | 不随压缩包发送 |
| 签名责任说明 | 需要 | 阶段、执行方和期望证书 | 便于判断缺口 |
使用合成数据和测试服务保留路径,同时最小化隐私暴露
OWASP MASVS-PRIVACY-1 强调最小化敏感数据访问,并把第三方 SDK 的数据行为纳入责任边界。样本包不应包含真实用户数据库、通讯录、定位历史、支付信息、客户文档、生产日志或可恢复身份的数据。即使这些数据能够快速复现,也应先构造具有相同 schema、边界值和状态关系的合成夹具。
生产 API 密钥、访问凭据、真实域名路由和内部基础设施地址也要剥离。替代方案可以是本地假服务、隔离测试环境、固定响应文件或由项目控制的短期测试账号,但要说明它与生产行为的差异。若问题依赖真实第三方环境而无法安全替代,应把该路径标为受控协作,而不是把生产秘密打包发送。
数据清单至少记录 dataClasses、syntheticFixtures、excludedSensitiveClasses、retentionOwner 和 cleanupProcedure。删除敏感内容后要重新执行复现契约,确认问题仍存在。隐私控制不能只依赖文件扩展名黑名单,因为秘密可能出现在资源、源码常量、配置、日志、数据库和构建缓存中;正式提交前仍需项目自己的秘密与数据审查。
| 类别 | 不得提交 | 可用替代 | 复核重点 |
|---|---|---|---|
| 生产凭据 | API key、会话和服务账号 | 隔离测试身份 | 权限和有效期最小 |
| 签名私钥 | keystore 与密码 | 证书摘要和签名回执 | 私钥不离开受控系统 |
| 个人数据 | 真实账号、位置和设备标识 | 合成但结构一致的数据 | 不可回溯真实个人 |
| 客户内容 | 文档、媒体和业务记录 | 自制边界夹具 | 保持格式与状态关系 |
| 生产日志 | 含身份和内部地址的原始日志 | 脱敏最小事件序列 | 保留时间和因果顺序 |
| 第三方数据 | 未授权 SDK 数据样本 | 沙箱响应或契约模拟 | 记录行为差异 |
用只读清单校验器在提交前拒绝身份和数据缺口
下面的 Python 脚本读取 sample-manifest JSON,要求存在构建说明、候选文件、候选 SHA-256、代表变体、复现步骤、成功与失败判据以及敏感数据排除项。它把候选相对路径限制在 manifest 所在目录内,拒绝符号链接和目录逃逸,并对实际文件分块计算摘要。
清单必须声明 dependenciesLocked、usesSyntheticData 为真,containsProductionCredentials、containsPrivateSigningKeys、containsPersonalData 和 containsCustomerContent 为假。代码只验证提交包内部一致性,不扫描所有文件内容,也不执行 buildInstructions。项目仍需用独立秘密扫描、数据分类和人工复核确认没有敏感材料。
任何字段缺失、候选摘要不匹配、复现步骤过少或敏感标志不安全都会返回非零状态。把它放在压缩和上传前,可以拦住最常见的错包和清单缺口;但脚本通过不代表样本一定能复现,更不代表加固兼容、安全或生产数据治理已经完成。
- 候选相对路径不能逃逸样本目录
- 实际文件摘要等于清单 candidateSha256
- 构建说明、锁定依赖与代表变体齐全
- 复现步骤、期望结果和失败判据可执行
- 四类敏感数据明确排除
- 只使用合成数据或隔离测试材料
- 脚本通过后仍执行秘密扫描和人工复核
#!/usr/bin/env python3
import hashlib
import json
import os
import re
import sys
from pathlib import Path
SHA256 = re.compile(r"^[0-9a-f]{64}$")
EXCLUSIONS = {"productionCredentials", "privateSigningKeys", "personalData", "customerContent"}
def fail(message):
print(message, file=sys.stderr)
raise SystemExit(3)
def require_text(record, field):
value = record.get(field)
if not isinstance(value, str) or not value.strip():
fail(f"missing {field}")
return value.strip()
def sha256_file(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
return digest.hexdigest()
def resolve_candidate(root, relative):
if not isinstance(relative, str) or not relative or os.path.isabs(relative):
fail("candidate path must be relative")
unresolved = root / relative
resolved = unresolved.resolve()
if root not in resolved.parents or unresolved.is_symlink() or not resolved.is_file():
fail("candidate path is unsafe or missing")
return resolved
def validate(manifest_path):
data = json.loads(manifest_path.read_text(encoding="utf-8"))
build = data.get("build")
candidate = data.get("candidate")
reproduction = data.get("reproduction")
privacy = data.get("privacy")
if not all(isinstance(item, dict) for item in (build, candidate, reproduction, privacy)):
fail("manifest sections are incomplete")
instructions = build.get("buildInstructions")
if not isinstance(instructions, list) or len(instructions) < 2 or not all(isinstance(item, str) and item.strip() for item in instructions):
fail("build instructions are incomplete")
if build.get("dependenciesLocked") is not True:
fail("dependencies must be locked")
require_text(candidate, "variantId")
require_text(candidate, "applicationId")
require_text(candidate, "artifactKind")
expected = require_text(candidate, "sha256").lower()
if SHA256.fullmatch(expected) is None:
fail("candidate sha256 is invalid")
path = resolve_candidate(manifest_path.parent.resolve(), require_text(candidate, "path"))
if sha256_file(path) != expected:
fail("candidate sha256 does not match the file")
steps = reproduction.get("steps")
if not isinstance(steps, list) or len(steps) < 3 or not all(isinstance(item, str) and item.strip() for item in steps):
fail("reproduction steps are incomplete")
require_text(reproduction, "expectedResult")
require_text(reproduction, "failureCriterion")
excluded = privacy.get("excludedSensitiveClasses")
if privacy.get("usesSyntheticData") is not True or set(excluded or []) != EXCLUSIONS:
fail("sensitive data exclusions are incomplete")
for field in ("containsProductionCredentials", "containsPrivateSigningKeys", "containsPersonalData", "containsCustomerContent"):
if privacy.get(field) is not False:
fail(f"unsafe privacy declaration: {field}")
return {"status": "sample-manifest-valid", "candidateSha256": expected, "stepCount": len(steps)}
if len(sys.argv) != 2:
raise SystemExit(2)
try:
result = validate(Path(sys.argv[1]).resolve())
except (OSError, UnicodeError, json.JSONDecodeError) as error:
print(str(error), file=sys.stderr)
raise SystemExit(3)
print(json.dumps(result, ensure_ascii=False, indent=2))用来源、构建和接收回执完成提交,不扩大样本结论
NIST SP 800-218 SSDF 把来源、构建、验证、变更证据和供应链风险纳入安全软件开发。样本提交可以据此保留 sourceRevision、依赖锁文件、构建说明、删减记录、候选摘要、复现回执和责任人。材料不必公开组织内部细节,但接收方必须能判断样本从何而来、怎样重建、与哪个真实候选有关。
SLSA Provenance v1.1 可以把样本候选 subject 与 builder、build type、外部参数和依赖材料绑定。provenance 适合减少“源码和二进制不是同一次构建”的争议,但仍要验证签发身份和实际文件摘要。它不能证明样本已覆盖原 App 全部代码,也不能代替设备复现、隐私审查或签名责任确认。
准备提交时,可将样本源码或受控构建输入、候选文件、清单、复现契约、合成夹具、差异说明和接收校验结果整理为独立包,再通过御盾中央平台提交申请。最终评估范围以实际样本和项目回执为准;若关键路径必须依赖生产秘密或无法复现,应先安排受控协作,不以不安全压缩包换取进度。
| 门禁 | 需要的材料 | 通过后可说明 | 不能说明 |
|---|---|---|---|
| 身份门禁 | 候选 SHA-256 和变体字段 | 提交对象被锁定 | 对象兼容或安全 |
| 复现门禁 | 步骤、状态和失败判据 | 目标现象可被检查 | 全部问题已覆盖 |
| 构建门禁 | revision、锁定依赖和说明 | 来源可追溯 | 生产构建完全等价 |
| 隐私门禁 | 排除清单和合成夹具 | 声明满足提交策略 | 所有秘密均被自动发现 |
| 签名门禁 | 阶段、证书摘要和责任 | 签名边界清楚 | 生产私钥应被提交 |
| 接收回执 | 重算摘要和清单结果 | 双方观察同一文件 | 已经完成加固或发布 |
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| build type、product flavor、source set、applicationId 与签名配置会共同形成不同构建变体。 | Android build variants 文档说明 Android 构建变体的组合与配置。 | 同一仓库不能证明不同 variant 具有相同 SDK、资源、证书和运行行为。 |
| Android 发布责任需要区分应用签名密钥、上传密钥、证书和 Play App Signing。 | Sign your Android app 说明 Android 应用签名身份和 Play App Signing 流程。 | 平台文档不能确认某个实际样本使用了正确证书,也不能证明私钥保管安全。 |
| AAB 包含 base、feature、配置和资产模块,设备安装的是从 Bundle 派生的 APK 集。 | Android App Bundle format 描述 App Bundle 的模块结构与交付格式。 | AAB 本身不是设备直接运行的最终 APK,派生身份和设备配置需要另行记录。 |
| 移动样本应最小化敏感数据访问,并把第三方 SDK 的数据行为纳入责任边界。 | OWASP MASVS-PRIVACY-1 规定敏感数据访问最小化和第三方数据行为责任。 | 该控制不能代替项目的数据分类、同意记录、地区法律和具体合规审查。 |
| 安全软件发布应保留来源、构建、验证和变更证据,并管理供应链风险。 | NIST SP 800-218 SSDF 给出组织级安全开发和发布实践。 | SSDF 不定义某个 App 加固产品功能,也不证明样本已满足组织全部要求。 |
| 构建证明可以绑定产物 subject、构建者、构建类型、外部参数和依赖材料。 | SLSA Provenance v1.1 定义构建来源证明及其核心字段。 | provenance 只支持记录的构建事实,不能单独证明运行时兼容、安全或保护强度。 |
| 最小可用样本应保留稳定复现目标现象所需的真实调用和状态链,而不是追求最少文件。 | 工程判断:删除反射、Native、资源、组件、SDK 或前置状态可能使样本失去原问题因果链。 | 哪些对象必须保留取决于项目复现契约和逐步裁剪回执。 |
| 样本候选应由接收端重算 SHA-256,并与构建、复现和删减记录共同归档。 | 工程判断:摘要锁定提交字节,配合来源和回执才能避免错包与不可复现争议。 | 摘要不证明文件安全、无秘密、可兼容或已由任何平台发布。 |
工程常见问题
最小 App 样本是不是只保留一个启动页面?
不是。它要保留能稳定触发目标现象的真实入口、依赖、状态变化与失败判据;如果只剩启动页后问题消失,样本就不再有效。
可以重新写一个相似 Demo 代替原项目样本吗?
可以用于初步说明,但不能默认等价。若反射、Native、资源、组件或第三方 SDK 语义不同,Demo 结果不能代表原项目候选。
只提交 AAB 能否复现设备安装问题?
通常还不够。需要目标 device spec、模块状态、派生工具和实际 APK 集身份,因为 AAB 是发布格式,不是设备直接运行的最终 APK。
为复现签名问题能否把生产 keystore 一起提交?
不能。应提供证书公钥摘要、签名阶段与受控签名回执,必要验证在隔离环境执行,生产私钥和密码不离开受控系统。
使用合成数据会不会让问题无法复现?
合成数据应保持原 schema、边界值和状态关系,替换后重新跑复现契约;若确实依赖生产环境,应改为受控协作而不是提交真实个人或客户数据。
申请软件加固评估前最少要提交哪些材料?
准备代表变体、候选摘要、构建说明、锁定依赖、复现步骤、成功失败判据、合成夹具、敏感排除清单和签名责任,再通过御盾中央平台提交申请。