先看结论与判断条件
- 先为两个原始 APK 计算独立摘要并只读归档,所有解析报告都绑定产物摘要;不要先解压、重打包或重签名后再调查。
- 同一源码提交不是完整构建输入,变体、依赖锁定、插件与 JDK、环境参数、生成文件和签名配置均可能改变最终字节。
- 比较顺序应从构建上下文进入 ZIP 容器,再到 Manifest、资源、DEX、Native 库与签名层,避免用一份巨大文本 diff 掩盖首个责任边界。
- ZIP 条目内容一致但整个 APK 摘要不同,仍需检查条目顺序、压缩方式、时间字段、对齐、中央目录与不表现为普通条目的 APK Signing Block。
- 可解释差异只说明能够指出变化来源;可复现构建要求相同的受控输入重复产生相同字节;发布可接受性还要单独核对候选身份和签名。
- 门禁回执应绑定源码提交、构建变体、解析依赖、工具链、构建参数、APK 摘要与签名证书,缺少任何关键输入时只记录未确认。
先把问题拆成字节差异、构建差异与发布身份差异
两个 APK 的 SHA-256 不同,只能确定文件字节不完全相同,不能直接推出源码变化、构建被入侵或签名错误。差异可能来自合法的变体选择、依赖解析、资源生成、编译顺序、ZIP 元数据或签名,也可能来自未受控输入。调查的第一步不是寻找一个看起来合理的解释,而是保护两个原始文件,记录各自摘要、文件长度、取得时间和构建任务,再让每份后续报告引用对应摘要。
归因需要区分三个问题。字节层回答哪些区域不同;构建层回答哪些输入或工具步骤产生这些字节;发布层回答哪个文件才是经过批准、由正确证书签署并允许交付的候选。发现只有签名块不同,不等于可以任选一个发布;发现 DEX 相同,也不等于资源、Native 库或 Manifest 相同。每层都必须形成独立结论,不能用局部一致替代完整候选身份。
调查期间不要对原始 APK 执行会改写文件的操作。解压后重新压缩会改变条目顺序、压缩结果、时间字段、对齐和中央目录,重签名则会替换身份相关材料。需要展开内容时,应把只读解析结果写到独立工作目录,并同时保留工具版本与命令参数。若原始文件已经被覆盖,只能把证据状态标记为不完整,不能把重建文件冒充最初候选。
| 判断层 | 要回答的问题 | 核心证据 | 常见误判 |
|---|---|---|---|
| 字节差异 | 哪些区域或条目不同 | 原始摘要、ZIP 与分层清单 | 摘要不同即源码不同 |
| 构建归因 | 什么输入产生变化 | 变体、依赖、工具链与参数 | 找到一个差异即找到根因 |
| 可复现性 | 受控重建能否得到同一字节 | 独立重复构建与产物摘要 | 差异可解释即可复现 |
| 发布身份 | 哪个候选允许交付和更新 | 审批记录、证书与签名方案 | 内容相近即可互换 |
| 安全判断 | 差异是否引入业务风险 | 代码审查、测试与威胁证据 | 签名有效即代码安全 |
为每个 APK 建立不可混用的构建证据包
SLSA Provenance v1.1 把产物主体、构建者、构建类型、外部参数和解析后的依赖材料纳入构建证明。对 APK 差异排查而言,这些字段不是装饰信息,而是重放构建条件的入口。源码提交相同,但 Gradle 任务、product flavor、build type、版本号注入、环境变量或依赖解析不同,产物自然可能不同。证据包应逐项记录实际值,未知字段保持未知,禁止事后按印象补齐。
in-toto Attestation Statement v1 使用主体摘要与有类型的声明负载建立绑定,这提醒团队把报告与确切 APK 对齐。一个通用的构建日志即使内容真实,如果没有引用产物摘要,也可能被错误附到另一个候选上。工程上可为每次构建生成只读索引,列出 APK SHA-256、源码提交、流水线运行、构建任务、工具链摘要和证明文件摘要,再由后续门禁以这些标识取数。
NIST SP 800-218 SSDF 把来源、构建、验证和变更证据放入安全开发实践。落实到本次调查,需要同时保留源码状态、依赖锁定、构建容器或主机基线、生成步骤、测试回执和审批变更。SSDF 不会替你指出某个字节为何变化,但它提供了避免证据断裂的组织边界。没有这些记录时,可以继续做二进制差分,却不能声称已经证明构建过程完整可信。
| 字段组 | 建议记录 | 用于排除 | 缺失时状态 |
|---|---|---|---|
| 产物主体 | 文件长度与 SHA-256 | 报告错配、文件替换 | 候选身份不完整 |
| 源码 | 提交、子模块与工作区状态 | 未提交修改、子模块漂移 | 源码输入未知 |
| 构建入口 | 任务、variant 与外部参数 | 误用 debug 或其他渠道 | 无法重放 |
| 依赖材料 | 锁文件与解析图摘要 | 动态版本、仓库漂移 | 依赖未固定 |
| 工具链 | Gradle、AGP、JDK、NDK 与镜像 | 编译器和打包器差异 | 环境未固定 |
| 签名身份 | 证书摘要与方案覆盖 | 错误密钥或签名路径 | 不可确认发布身份 |
先核对 build variant,再讨论编译器是否不确定
Android build variants 由 build type、product flavor、source set、applicationId 和签名配置等输入组合形成。同一仓库中的 release、staging、地区渠道或客户 flavor 可以选中不同资源、Manifest 片段、BuildConfig 值和签名配置。排查时要从流水线实际执行的 Gradle task 反推出完整 variant,而不是只看分支名或产物文件名。文件名可以被复制和重命名,variant 只能由构建记录和产物内容共同确认。
依赖解析是第二个高频分叉点。动态版本、变化模块、不同仓库响应、锁文件未提交、插件间接依赖和本地缓存都可能让相同源码得到不同 classpath。应导出与候选对应的解析依赖图,记录仓库与校验信息,并比较最终进入运行时、编译器和打包任务的坐标及摘要。只比较顶层 build.gradle 声明不够,因为实际选择版本可能来自约束、平台依赖或解析规则。
工具链和生成步骤也要进入比较。JDK、Gradle、Android Gradle Plugin、Build Tools、aapt2、D8 或 R8、NDK、CMake 以及代码生成器版本变化,都可能影响资源表、DEX、Native 对象或容器布局。时间、随机标识、绝对路径和不稳定遍历顺序若被写入生成文件,也会破坏重复构建。正确做法是先证明两次构建输入相同,再把剩余差异归入工具不确定性;不能从源码相同直接跳到编译器有问题。
| 优先级 | 检查对象 | 比较材料 | 可能影响层 |
|---|---|---|---|
| 一 | Gradle task 与 variant | 任务日志、applicationId、flavor | Manifest、资源、代码、签名 |
| 二 | 解析依赖 | 锁文件、依赖图与校验摘要 | DEX、Native、资源 |
| 三 | 工具链 | AGP、JDK、Build Tools、NDK | 全部构建层 |
| 四 | 外部参数 | 环境变量、属性与密钥别名 | 生成值、渠道和签名 |
| 五 | 生成文件 | 模板输入、时间与路径来源 | Manifest、资源、DEX |
| 六 | 工作区 | 未跟踪文件与子模块状态 | 任何进入构建的材料 |
ZIP 容器相同与条目内容相同是两种结论
APK 采用 ZIP 容器,但比较不能止于解压目录。两个 APK 可以包含相同条目内容,却因条目顺序、压缩算法或级别、时间字段、额外字段、文件属性、对齐填充和中央目录布局不同而得到不同摘要。规范化条目清单适合先回答内容是否一致:按条目名排序,记录未压缩内容 SHA-256、长度、CRC、压缩方式和容器元数据,再把整个文件摘要作为独立字段保留。
如果规范化内容摘要一致而整个 APK 摘要不同,调查范围应收窄到容器布局和签名相关区域,但不能直接宣布只是时间戳。先比较条目顺序与元数据,再定位第一个字节偏移;随后用 Android 签名工具检查签名方案和证书。APK Signing Block 位于 ZIP 条目之外,普通解压工具不会把它显示成 META-INF 文件,因此条目清单一致仍可能伴随签名块差异。
如果某个条目内容摘要不同,应先按责任层分组,而不是立即反编译全包。AndroidManifest.xml 与 resources.arsc 指向清单和资源合并,classes*.dex 指向 Java 或 Kotlin 编译、压缩与优化,lib/* 指向 Native 构建,assets 与 res 指向打包材料,META-INF 中的部分文件与 v1 签名或依赖元数据有关。分组后再选择专用解析器,能让调查保持可复核和可停止。
| 观察 | 可以确认 | 仍不能确认 | 下一步 |
|---|---|---|---|
| 整个摘要和条目内容均相同 | 文件内容高度一致 | 构建来源和运行安全 | 核对证明与签名身份 |
| 条目内容同、容器摘要不同 | 业务条目未见内容变化 | 差异只来自时间字段 | 查顺序、元数据、对齐与签名块 |
| 仅 META-INF 不同 | 签名或依赖元数据有变化线索 | 证书必然错误 | 运行 apksigner 并核对证书 |
| DEX 不同 | 可执行字节发生变化 | 业务逻辑必然改变 | 比对依赖、R8 配置和方法结构 |
| 资源层不同 | 打包资源或清单变化 | 变化一定可见 | 解析 Manifest 与资源表 |
| Native 库不同 | 至少一个 ABI 的二进制变化 | 漏洞或保护强度变化 | 绑定 NDK、符号与构建输入 |
Manifest、资源、DEX 与 Native 差异要用各自的证据语言
AndroidManifest.xml 是二进制 XML,直接比较原始字节可以发现变化,却不容易解释合并来源。应使用固定版本的解析工具导出规范化结构,比较 applicationId、versionCode、uses-sdk、组件、权限、exported 状态、provider authority 和元数据,同时保留原始条目摘要。若差异来自占位符或 source set,回到 variant 与 Manifest merger 输入;若规范化语义相同但字节不同,再检查属性顺序、字符串池和工具链。
resources.arsc、res 与 assets 需要区分资源身份和内容。资源 ID、压缩策略、语言或密度裁剪、生成图标、渠道配置和依赖资源合并都会改变产物。只比较文件名会漏掉同名内容变化,只比较解码后的图片也会忽略原始编码和资源表引用。调查报告应记录资源表摘要、差异条目、配置限定符和来源模块;无法追溯来源的资源变化必须保持待确认,不能归类为无害。
DEX 与 Native 库属于可执行层,但字节差异不自动等于业务语义变化。D8 或 R8 版本、依赖顺序、混淆映射、优化规则、调试信息和类排序可能改变 DEX;编译器、NDK、链接器、构建标识与符号处理可能改变 .so。应先比较文件摘要和结构清单,再结合 mapping、依赖图、符号与源码变更定位。没有当前候选的运行回归时,只能报告结构差异和待验证影响。
| 层 | 主要输入 | 规范化比较 | 必须保留的原始证据 |
|---|---|---|---|
| Manifest | source set、占位符、依赖合并 | 组件、权限、SDK 与元数据 | 二进制条目摘要 |
| 资源表 | aapt2、资源裁剪与依赖资源 | 资源 ID、限定符和来源 | resources.arsc 摘要 |
| DEX | 编译器、依赖、R8 规则与 mapping | 类、方法、字段与指令结构 | classes*.dex 摘要 |
| Native | NDK、编译和链接参数 | ABI、段、符号与依赖 | 每个 .so 摘要 |
| assets | 业务材料与生成步骤 | 路径、内容类型与来源 | 原始文件摘要 |
| 容器 | zipalign、压缩与打包顺序 | 条目清单和元数据 | 完整 APK 摘要 |
签名差异必须在原始 APK 上独立验证
AOSP app signing 说明 Android 使用应用签名建立更新身份,不同签名方案覆盖不同文件区域与平台版本。两个内容接近的 APK 如果证书不同,可能无法作为同一应用的正常更新;两个证书相同的 APK 仍可能包含不同业务内容。签名因此既是差异来源,也是发布身份门禁,但不是代码安全或加固强度证明。报告应分别记录证书摘要、验证结果和各方案覆盖。
Android apksigner 可以签名并验证 APK 的签名方案和证书,且签名完成后继续修改 APK 会使相关签名失效。调查时应对收到的原始文件运行 verify,并保留工具版本、命令、退出状态、证书摘要与方案结果。不要为了让两包可比较而先用同一密钥重签,也不要从 META-INF 文件存在与否推断全部方案;v2 及更高方案使用的 Signing Block 不等同于普通 ZIP 条目。
签名结果还要回到发布流程。若两个 APK 来自不同阶段,例如一个是加固前输入、一个是渠道重签后的输出,它们摘要不同可能符合流程设计,但仍需一条受批准的转换链把输入、处理步骤、输出和证书绑定。若团队期望同阶段重建可复现,签名时间、密钥选择和签名工具必须进入受控输入。流程解释不存在或与证据冲突时,正确动作是停线调查,而不是把差异标成渠道正常。
| 结果 | 说明 | 发布判断 | 边界 |
|---|---|---|---|
| 验证失败 | 文件或签名材料不一致 | 立即停线 | 不直接说明改动者是谁 |
| 证书不同 | 更新身份可能变化 | 核对轮换与渠道批准 | 不直接说明内容恶意 |
| 证书同、APK 摘要不同 | 同一签名身份下内容不同 | 继续分层差分 | 证书不能证明内容相同 |
| 条目同、签名块不同 | 签名参数或块布局变化 | 核对工具与签名阶段 | 普通解压结果不足 |
| 全部方案通过 | 当前文件签名结构有效 | 继续审批与候选核对 | 不证明业务代码安全 |
| 签名后被重打包 | 签名覆盖可能失效 | 拒绝重建文件 | 必须回到原始候选 |
把可解释差异、可复现构建和可发布候选分开验收
可解释差异的最低标准是指出变化位于哪个层、由哪个已记录输入或步骤产生,并能用当前证据复核。例如渠道 flavor 增加一项资源、签名阶段更换批准证书,属于有来源的解释。可复现构建的标准更严格:在受控且声明相同的源码、依赖、工具链、参数和签名输入下,独立重建得到相同字节。一次解释成功不能替代重复构建,重复构建相同也不能证明源代码没有安全问题。
发布候选还需要单独的审批闭环。最终交付记录应引用唯一 APK 摘要、版本与 variant、证书摘要、构建证明、差异报告、测试回执和批准人。任何后续复制都再次计算摘要,不允许用相同文件名或目录位置继承身份。若调查发现的差异符合预期但当前摘要未在审批记录中,仍应更新记录或重新批准,不能口头把陌生产物纳入发布。
持续集成门禁应优先减少重复劳动。每次构建自动产出规范化 ZIP 清单、分层摘要、variant、解析依赖和签名验证摘要;只有整体摘要变化时才进入差分,并从首个不同责任层开始。相同来源回执和工具版本可以复用,但每个候选的产物摘要与差分结论必须新生成。这样既能快速确认无变化,也能避免每次都重新反编译全部内容。
- 两个原始 APK 已只读保存并分别计算 SHA-256
- 构建任务、variant、依赖与工具链均绑定候选
- ZIP 容器和条目内容使用不同结果字段
- Manifest、资源、DEX 与 Native 差异分别归因
- 原始 APK 已通过 apksigner 检查并记录证书
- 可解释、可复现与可发布形成三个独立结论
用只读脚本生成规范化条目清单并定位首个分层差异
下面的 Python 脚本接收两个 APK 路径,先计算完整文件 SHA-256,再以只读方式遍历 ZIP 条目。它按路径对条目归入签名元数据、Manifest 与资源、DEX、Native、普通资源和其他层,记录未压缩内容摘要、长度、CRC、压缩方式与 ZIP 时间字段,最后输出第一个不同条目或容器级差异。输入缺失、不是有效 ZIP、出现重复条目名或读取失败时返回非零状态。
脚本不能解析 APK Signing Block,也不能判断两个不同 DEX 是否业务等价。若全部普通条目内容一致而完整 APK 摘要不同,输出会提示继续检查 ZIP 布局和 Signing Block;此时应在原始文件上运行 apksigner,而不是据此断言差异来自签名。时间字段被记录为线索,但不能单独成为根因,仍需与打包任务、条目顺序和第一个字节偏移交叉验证。
申请 APK 商业加固评估前,可准备原始候选、源码提交、构建 variant、解析依赖、工具链、签名证书摘要、规范化差异清单和真实设备回归范围,再从御盾中央平台提交申请。评估资料越精确,越容易明确哪些业务代码、Native 模块和发布环节需要保护;没有当前候选和验证回执时,不宣称差异已消除、攻击被阻断或兼容矩阵已经通过。
- 脚本只读取原始 APK,不解压覆盖和不重打包
- 完整文件摘要与条目内容摘要分别保存
- 重复条目、无效 ZIP 和读取错误产生非零退出
- 首个差异按 Manifest、资源、DEX、Native 或签名线索归类
- 普通条目一致时继续检查 ZIP 布局与 Signing Block
- 脚本结果绑定工具版本并进入人工发布判断
from pathlib import Path
from zipfile import ZipFile, BadZipFile
import hashlib
import json
import re
import sys
def sha256_stream(handle):
digest = hashlib.sha256()
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def file_sha256(path):
with path.open("rb") as handle:
return sha256_stream(handle)
def layer_for(name):
if name.startswith("META-INF/"):
return "signature-metadata"
if name == "AndroidManifest.xml" or name == "resources.arsc":
return "manifest-resources"
if re.fullmatch(r"classes(?:[2-9][0-9]*)?\.dex", name):
return "dex"
if name.startswith("lib/") and name.endswith(".so"):
return "native"
if name.startswith(("res/", "assets/")):
return "packaged-resources"
return "other"
def apk_manifest(path):
result = {}
seen = set()
try:
with ZipFile(path, "r") as archive:
for info in archive.infolist():
if info.is_dir():
continue
if info.filename in seen:
raise SystemExit(3)
seen.add(info.filename)
with archive.open(info, "r") as handle:
content_hash = sha256_stream(handle)
result[info.filename] = {
"layer": layer_for(info.filename),
"sha256": content_hash,
"size": info.file_size,
"crc32": f"{info.CRC:08x}",
"compression": info.compress_type,
"zipTime": list(info.date_time),
}
except (BadZipFile, OSError, RuntimeError):
raise SystemExit(2)
return result
def first_difference(left, right):
for name in sorted(set(left) | set(right)):
if name not in left:
return {"entry": name, "kind": "only-right", "layer": right[name]["layer"]}
if name not in right:
return {"entry": name, "kind": "only-left", "layer": left[name]["layer"]}
for field in ("sha256", "size", "crc32", "compression", "zipTime"):
if left[name][field] != right[name][field]:
return {"entry": name, "kind": field, "layer": left[name]["layer"]}
return None
if len(sys.argv) != 3:
raise SystemExit(2)
left_path, right_path = map(Path, sys.argv[1:])
if not left_path.is_file() or not right_path.is_file():
raise SystemExit(2)
left_entries = apk_manifest(left_path)
right_entries = apk_manifest(right_path)
left_apk = file_sha256(left_path)
right_apk = file_sha256(right_path)
difference = first_difference(left_entries, right_entries)
if difference is None and left_apk != right_apk:
difference = {"kind": "container-or-signing-block", "action": "inspect ZIP layout and run apksigner verify"}
print(json.dumps({"leftApkSha256": left_apk, "rightApkSha256": right_apk, "firstDifference": difference}, ensure_ascii=False, indent=2))事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| 构建证明应把产物主体与构建者、构建类型、外部参数和解析依赖材料绑定。 | SLSA Provenance v1.1 定义了构建 provenance 中的主体和构建上下文字段。 | provenance 记录构建过程,不单独证明 APK 运行安全、业务正确或没有恶意源码。 |
| 供应链声明需要使用产物摘要标识主体,并关联有类型的声明负载。 | in-toto Attestation Statement v1 给出 subject 与 predicateType 等声明结构。 | 声明格式本身不保证内容真实,仍需要可信签名者、验证策略和候选绑定。 |
| 安全发布流程应保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 描述组织在安全软件开发中的来源、构建和验证实践。 | SSDF 是组织级框架,不定义某个 APK 差异的具体根因,也不证明御盾产品能力。 |
| Android 应用签名用于建立更新身份,不同方案覆盖的文件区域和平台范围不同。 | AOSP app signing 说明 Android 应用签名与各签名方案的角色。 | 签名有效证明完整性和签名者身份的相关属性,不证明业务代码安全或构建可复现。 |
| apksigner 可以验证 APK 的签名方案与证书,签名完成后继续修改文件会使签名失效。 | Android apksigner 说明签名、验证和签名后文件修改的约束。 | 工具验证通过不证明密钥管理、渠道审批、候选归档或业务测试流程正确。 |
| build type、product flavor、source set、applicationId 和签名配置能够组合出不同构建变体。 | Android build variants 描述 variant 的组成及相关构建配置。 | 同一仓库或提交不代表两个任务选择了相同 variant、依赖、资源和证书。 |
| 规范化 ZIP 条目一致而完整 APK 摘要不同,应把容器布局与 Signing Block 作为独立排查层。 | 工程判断:普通 ZIP 条目清单不能表达全部容器字节,也不能把 APK Signing Block 作为普通条目列出。 | 该观察只收窄调查范围,不能直接断言根因是签名、时间字段或打包工具。 |
| 可解释差异、可复现构建与可发布候选必须形成三个独立结论。 | 工程判断:根因说明、重复构建字节一致和发布审批分别回答来源、重复性与交付身份问题。 | 三个结论均不替代安全代码审查、真机兼容回归或业务风险验证。 |
工程常见问题
两个 APK 只差 SHA-256,能否说明源码被修改?
不能。摘要不同只证明文件字节不同,差异还可能来自 variant、依赖、工具链、生成资源、ZIP 元数据或签名。需要冻结原始文件并按构建上下文和二进制层逐步归因。
把两个 APK 解压后目录内容相同,是否说明它们等价?
不能。条目顺序、压缩方式、时间字段、对齐、中央目录和 APK Signing Block 仍可能不同;解压还会丢失部分容器信息。应同时保留完整文件摘要、规范化条目清单和签名验证结果。
找到 ZIP 时间字段不同,是否可以结束调查?
不应立即结束。时间字段只是一个差异点,还要确认是否存在内容、顺序、压缩、对齐或签名块变化,并证明时间输入来自已批准的构建步骤。首个观察不一定就是唯一根因。
apksigner 验证通过,能否证明两个 APK 都可发布?
不能。验证通过说明当前文件的相关签名结构有效,还需要核对证书是否被批准、候选摘要是否进入发布记录、构建来源是否可信以及测试和审批是否完成。
差异已经能被构建参数解释,是否等于构建可复现?
不等于。可复现要求在相同受控输入下独立重建得到相同字节。参数能够解释差异只证明变化有来源,还需要重复构建与摘要比对才能形成可复现结论。
申请 APK 加固评估前需要提交哪些差异材料?
准备原始 APK 与摘要、源码提交、variant、解析依赖、工具链、签名证书摘要、规范化条目差分、加固前后流程和目标设备回归范围,再通过御盾中央平台提交申请。