先看结论与判断条件

  • 先为两个原始 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 不会替你指出某个字节为何变化,但它提供了避免证据断裂的组织边界。没有这些记录时,可以继续做二进制差分,却不能声称已经证明构建过程完整可信。

单个 APK 证据包的最小字段
字段组建议记录用于排除缺失时状态
产物主体文件长度与 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、flavorManifest、资源、代码、签名
解析依赖锁文件、依赖图与校验摘要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 签名或依赖元数据有关。分组后再选择专用解析器,能让调查保持可复核和可停止。

ZIP 比较结果与下一步
观察可以确认仍不能确认下一步
整个摘要和条目内容均相同文件内容高度一致构建来源和运行安全核对证明与签名身份
条目内容同、容器摘要不同业务条目未见内容变化差异只来自时间字段查顺序、元数据、对齐与签名块
仅 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、依赖图、符号与源码变更定位。没有当前候选的运行回归时,只能报告结构差异和待验证影响。

分层差异的责任输入与验证方式
主要输入规范化比较必须保留的原始证据
Manifestsource set、占位符、依赖合并组件、权限、SDK 与元数据二进制条目摘要
资源表aapt2、资源裁剪与依赖资源资源 ID、限定符和来源resources.arsc 摘要
DEX编译器、依赖、R8 规则与 mapping类、方法、字段与指令结构classes*.dex 摘要
NativeNDK、编译和链接参数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
  • 脚本结果绑定工具版本并进入人工发布判断
生成 APK 分层条目摘要并报告首个差异
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、解析依赖、工具链、签名证书摘要、规范化条目差分、加固前后流程和目标设备回归范围,再通过御盾中央平台提交申请。

想用自己的 App 验证?

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

继续阅读: APK、DEX 与签名完整性如何一起验收