APK 签名方案¶
REA 以静态方式读取所选本地 Android APK(或 ZIP/AAB)的签名信息,
因此分析人员可以在信任或分类处理之前回答以下问题:谁签署了此文件、采用了哪些方案,
以及声明的内容摘要是否与这些字节匹配。该读取器在进程内运行,无需外部引擎:
它解析 ZIP 尾部与 APK 签名块,使用平台 X.509 解析器读取证书,
并重新计算内容摘要。不会执行任何 Android 代码,也无需设备或 adb。
CLI 与 MCP 共享同一工作流,并以内联方式返回绑定到证据的 Evidence。
选定事实与保留的 Evidence¶
MCP 默认返回摘要,包含方案存在情况、签名者证书指纹、
行数、姿态与显式覆盖范围。detail: "complete" 返回完整报告。
在获取调用中,选定页面可以回答更具体的问题:
{
"path": "/path/app.apk",
"view": {
"collection": "signers",
"schemes": ["v3"],
"fields": ["certificate_sha256"],
"limit": 32
}
}
返回的 parent_evidence_id 标识了保留的完整报告。
后续读取使用 inspect_analysis_view,即使在 APK 被删除之后也可以:
{
"source": {
"kind": "retained-evidence",
"evidence_id": "<returned parent_evidence_id>"
},
"view": {
"kind": "apk-signature",
"collection": "digests",
"signer_ids": ["<returned signer id>"],
"fields": ["signature_algorithm_id", "algorithm", "matches_apk"],
"offset": 0,
"limit": 32
}
}
集合包括 signers、digests 以及声明的 lineage 节点。
ids、signer_ids 与 schemes 过滤器取交集;省略过滤器表示选择所有可用记录,
[] 表示不选择任何记录。省略 fields 返回所有集合列;
fields: [] 仅保留身份与关联链接。ID 包含完整的 APK 摘要、
方案与来源序号;子记录保留 signer_id 与 signer_index,
声明的证书保留其原始 DER 形式的 SHA-256 身份。
Lineage 页面在选择时会暴露 previous_id。
证书指纹可以跨版本组合,而不必将声明视为已验证身份。
页面覆盖范围描述已检查的声明记录。一个页面读取完毕并不代表 证书真实性、平台接受度、v1 条目覆盖范围或已验证的轮换已确立。 即使 lineage 页面中没有行,部分解码及其原因仍然可见。 弱缓存会复用已验证的不可变报告事实;每次读取都会检查其父主题, 新的文件检查会再次读取。
CLI 默认交付完整报告。紧凑的一次性响应需要完整的 Evidence 导出:
rea inspect-apk-signature /path/app.apk --detail summary \
--view '{"collection":"signers","fields":["certificate_sha256"]}' \
--evidence_output /path/app-signing-evidence.json --json
导出内容与 MCP 使用的完整父报告相同。历史 CLI 页面使用
inspect-analysis-view,其来源记录为 evidence-file。
CLI 与 MCP 共享应用程序工作流、投影、保留与发布所有者。
报告内容¶
- 存在的方案: v1(JAR 签名,来自中央目录),以及
APK 签名块中的 v2、v3 与 v3.1。v1 遵循 apksig 的选择逻辑:
META-INF/下任意深度以.SF结尾的条目,或以.RSA/.DSA/.EC(区分大小写)结尾的条目。仅当签名块有其匹配的.SF文件时 (META-INF/x/CERT.RSA与META-INF/x/CERT.SF配对), v1 才标记为present;signature_files与block_files在两种情况下都会列出所有候选文件。 - 每个 v1 签名者(每个块与其
.SF配对):REA 解压该块并读取其 PKCS#7SignedData。certificate是该块中唯一SignerInfo通过issuerAndSerialNumber或subjectKeyIdentifier命名的证书 (而非按位置),其事实与原始 DER SHA-256 与 v2/v3 签名者相同, 因此仅使用 v1 签名的 APK 也可以被指纹化,并与 v2/v3 签名的 APK 聚类。 apksig 仅解析issuerAndSerialNumber,因此对于SignerInfo使用subjectKeyIdentifier的块,其 v1 验证会失败: REA 仍会报告声明的证书(identified_by: subject-key-identifier)、 添加限制条件,并在v1_signers_match_block_signers中将该签名者视为未知。certificates列出该块携带的所有证书(SHA-256 与主题),signer_infos给出每个SignerInfo的证书与摘要/签名算法 OID。 PKCS#7SignedData信封以 BER 读取,包括构造型签名 OCTET STRING; 每个嵌入的证书仍由 X.509 所有者按 DER 解释。 颁发者匹配保留编码后的 Name,并允许 CN、C、L、ST、O 与 OU 在 ASCII PrintableString/UTF8String 之间等价:大小写与重复/边缘空格会被归一化, RDN 序列顺序保留,单个多值 RDN 内部的属性顺序忽略。 其他名称形式要求精确的编码身份;语义等价性无法确立。 当块包含多个SignerInfo(此时没有已验证的唯一签名者) 或命名的证书不在块中时,certificate为null。 无法读取的块(ASN.1 格式错误、不支持的压缩方法、Zip64 大小、 本地头与中央目录在名称、数据描述符标志、CRC-32 或大小上不一致(apksig 会拒绝), 或超出上限)会保留其名称并带有decode_error;绝不会被报告为缺失签名者。 apksig 忽略 ZIP 加密标志(位 0),因此设置该标志的块会按存储方式读取: 由 PKCS#7 解码决定(真正加密的字节必然解码失败), 并有一条限制条件指明该块。 对于中央目录中存在任何重复名称("Duplicate entry")的归档, apksig 会拒绝其 v1 签名。REA 检查每个条目: 限制条件会指明前几个重复项(以 JSON 引号包裹;超过 256 字节的名称 会在该处截断并给出其存储长度),其余则计数; 其块、.SF或META-INF/MANIFEST.MF名称重复的签名者 (每个块名称一个签名者)会携带decode_errorduplicate entry: <name>,因此重复的 manifest 会使每个签名者都被标记。 重复名称位于这些名称之外时,签名者保持声明状态不变。 名称按 apksig 的比较方式比较,即从 UTF-8 解码出的 Java 字符串: 格式错误的字节变成 U+FFFD,因此dup\xff与dup\xfe被视为同一个名称 (apksigner 输出:Duplicate entry: dup�)。REA 复现 Java 的替换行为, 而非 WHATWGTextDecoder的行为;两者在编码后的代理项 (ED A0..BF)上有所不同,Java 将其作为一个单元替换。 这一点已在 OpenJDK 25 的new String(bytes, UTF_8)上 针对约 30 万个格式错误与格式良好的序列做了检查,包括前导字节顺序标记, Java 会保留该标记;其他 Java 运行时未经验证。 相同的解码规则用于命名 v1 条目,并用于将本地头的名称与中央目录中的名称比较; 与 apksig 一致,如果本地名称的字节数长于目录名称,则会在解码之前拒绝。 apksig 从经典 end-of-central-directory 记录的 16 位字段获取记录数, 并忽略 Zip64 的数量,因此对于更大的目录只读取前 65,535 条记录: 此时限制条件会如此说明,对于 apksig 而言,只有当重复名称的第二个副本 位于这些记录之内时才计入重复;仅在这些记录之后重复的名称会单独列出。 REA 仍然从每条记录读取 v1 签名条目,并标记重复的签名名称, 因此在第 65,535 条记录之后,它会报告 apksig 看不到的签名者。 块ContentInfo之后的字节会被忽略(与 apksig 的 BER 解析器一致), 并有一条限制条件指明该块及其字节数。 - 每个签名者: 证书的 原始嵌入 DER 的 SHA-256 指纹
(与
apksigner --print-certs打印的值相同), 外加主题、颁发者、序列号、ISO 8601 有效期与公钥算法 (当 X.509 解析器能够读取所携带的确切 DER 时;否则各项为null)。 解析器返回的 DER 必须等于这些字节,才能将可读事实与其指纹组合; 嵌入的 PEM 文本与尾部数据不能选择另一个证书。 原始签名算法 ID、声明的摘要十六进制与摘要算法、 签名长度与公钥长度;以及 v3/v3.1 签名者声明的 min/max SDK。 - 密钥轮换(v3/v3.1): 声明的轮换证明谱系,按从旧到新排列。
每个节点携带其证书,以及轮换后由该节点自身证书保留的能力标志
(已安装数据、共享 UID、权限、回滚、auth)。
无法解码的谱系属性会以
decode_error报告,绝不会静默清空。 - 来源戳: 是否存在来源戳块及其声明的戳证书
(V2 戳块优先于 V1,与 apksig 一致)。无戳时
certificate为null, 或块无法解码时decode_error说明原因。戳签名不进行验证。 - 声明摘要一致性: 对于 SHA-256/SHA-512 摘要,REA 重新计算
v2/v3 内容摘要,并为每个摘要报告
matches_apk和posture.self_consistent。这仅表明声明的摘要 等于当前字节的摘要。这不是签名验证: 任何更改字节的人都可以重新计算摘要并将其写入签名数据, REA 仍会报告true,而apksigner verify会拒绝该 APK。 字节更改且声明摘要不变时读取为false。 - 姿态(由上述事实推导):
v1_only:v1 存在且没有 v2/v3/v3.1 块。 不读取 v1X-Android-APK-Signed标记,因此无法区分 仅以 v1 签名的 APK 与被移除 v2/v3 块的 APK。schemes_agree_on_signer(观察结果):每个存在的块方案 呈现相同的签名者证书_集合_。一个方案中多出的签名者即为不一致; 签名者没有证书时结果为null(未知)。 Build-tools 33+ 默认通过 v3.1 轮换,因此已轮换的应用通常 此项为false而下一项为true。signer_change_declared_by_rotation(声明结果):签名者不同, 但每个签名者都出现在 APK 声明的谱系中。请将两者一起解读: 已轮换的 APK 显示false/true;签名者不同且没有 包含它们的声明谱系时显示false/false。 当签名者身份未知,或签名者不同且声明的谱系无法解码时,该项为null。v1_signers_match_block_signers(观察结果):v1 签名者证书 等于具有签名者的最低块方案(v2,否则 v3,否则 v3.1) 所期望 v1 携带的内容。即其签名者集合,但声明轮换谱系的 v3/v3.1 签名者期望谱系中的第一个(原始)证书,与 apksig 的要求一致。false值得注意:JAR 签名命名的密钥与块签名不同。 密钥轮换使 v1(以及 v2)保留在原始密钥上,因此此项读取为true而schemes_agree_on_signer读取为false,无论是否带 v2。 当 v1 或某个块方案缺失、任何签名者身份未知 (decode_error、null证书或subjectKeyIdentifierSignerInfo), 或谱系的原始证书无法识别时,该项为null;此时会有限制条件说明原因。strongest_scheme、rotation_present、source_stamp_present、self_consistent。
它不声称的内容¶
这是声明身份与摘要一致性,而非信任。没有签名经过密码学验证(包括 v1 PKCS#7 签名和 .SF/MANIFEST.MF 摘要,因此即使 keytool 或 apksigner 拒绝该归档,也会报告 v1 签名者),证书链和吊销状态未予评估,此处任何内容都不表示应用是真实的。self_consistent: true 表示声明的摘要等于当前字节的摘要;这不是签名验证,也不证明字节由谁生成。轮换谱系的节点签名同样未被验证,因此谱系只是 APK 所_声明_的内容:攻击者重新签名 APK 后可以声明任意谱系,这正是 signer_change_declared_by_rotation 与观察到的匹配情况分开记录的原因。SHA-256 标识的是确切字节,而非真实性。
尚未读取:v4 .apk.idsig(同级文件)、verity(0x0421/0x0423/0x0425)摘要(以 matches_apk: null 报告),以及 v1 X-Android-APK-Signed 标记。
边界与错误¶
归档只读取一次,限制在 512 MiB,不跟随符号链接,并带有变更检测。读取器使用的 ZIP 尾部偏移、中央目录大小和签名块跨度会被对照其所在部分进行检查,包括 ZIP 结束记录之前的完整中央目录范围。进程级队列允许一个活动的整文件检查以及跨 provider 实例的八个待处理扫描;待处理扫描不保留 APK 字节,并可以按其原始原因取消。进一步的请求会收到容量错误。活动槽位在解析和清理期间一直被持有。每个方案的签名者、v1 签名者和谱系节点各以 32 为上限,作为资源准入限制,而非格式最大值(apksig 单独最多接受 10 个 v1 签名者)。每个 v1 块最多解压到 1 MiB,其 BER 在 32 层嵌套和 65,536 个元素内读取,最多 32 个证书和 32 个 SignerInfo;接受 BER 不定长度和构造式 OCTET STRING 字段。内嵌 X.509 证书保留其 DER 要求。报告大小在物化任何内容之前即由上述计数确定上限,随后会将报告及其限制(其中会重复归档名)的精确序列化大小对照 8 MiB 输出预算进行检查(精确检查具有权威性);超出预算的报告会被拒绝,而不会被截断。
整文件读取器分配选定的快照和有界读取暂存空间;进程总内存会超过所选文件的大小。它还会计算完整文件 SHA-256,因此检查并非仅读取头部。在一次检查中,每个 SHA-256/SHA-512 算法的内容摘要最多重新计算一次。输入、解压、解码记录和序列化输出的边界确保工作量有限;资源拒绝与格式错误是不同类型的错误。
如果稳定读取完成,但其描述符关闭失败或被取消,错误会保留 details.partial_observation,包含所选路径、完整文件 SHA-256 和字节数。这些事实描述的是所获取的文件;它们不确立任何已解码的签名事实。失败的清理保持可见,直到 provider 在清理重试时释放描述符。
非 ZIP 文件会被拒绝为不支持的目标。损坏的 ZIP(有局部头但没有中央目录结尾记录)、无法遍历的中央目录、或其声明记录数无法解释其全部字节的中央目录,以及格式错误的签名块或签名者,都属于输入错误,并指明偏移量或方案及原因。Zip64 尾部损坏包括缺少必需的定位器、不可能的定位器磁盘/计数对、在定位器之前无法容纳的固定结尾记录,或错误的结尾记录签名。这些是输入错误,而非不支持格式的证据。一致的多磁盘定位器元数据,以及超出读取器安全整数范围的 64 位偏移/计数/大小,仍不受支持。定位器偏移量是相对于磁盘的,因此在应用本地文件边界之前会先检查多磁盘元数据。这遵循 PKWARE APPNOTE 6.3.10,第 4.3.14–4.3.15 节 中的记录字段;它并不确立对 Zip64 版本或可扩展数据的完整验证。
签名者框架包括签名记录、公钥字段以及匹配的已签名/外部 v3 SDK 范围。已知字段之外的尾缀字节被视为未解释字节,因为 Android 读取器可能忽略扩展;它们的存在并不表明签名格式错误。当中央目录之后不是紧跟着中央目录结尾记录(声明大小不匹配或存在 Zip64 记录)时,不会重新计算内容摘要,并会有一条限制说明这一点。无法解码的轮换属性仍会计为 rotation_present(该属性存在),并携带其 decode_error。
由 verify:apk-signature 真实通道验证。它使用 aapt2 构建一个真实的最小 APK,用 SDK 的 apksigner 签名(默认 v2+v3,使用 RSA 和 EC 密钥时仅 v1,在旧版目标上 v1+v2+v3,带和不带 v1 的轮换密钥谱系,由 v3.1 承载的轮换,源戳,以及一个更改了一个字节的副本),并通过 CLI 和 MCP 将 REA 与 apksigner verify --print-certs、apksigner lineage --print-certs 和 keytool -printcert -jarfile 进行交叉核对。
真实工具链 CI 通道在其作业工作区中使用固定版本、经过校验和验证的 Build Tools 36.1.0 与 platform 36 oracle 文件。它需要现有的宿主 JDK,并运行 npm run verify:apk-signature -- --require-tools;缺少 oracle 会导致失败,而不是静默建立 provider 覆盖率。REA 本身不需要这些外部工具。
本页原文 Markdown:在 AtomGit 查看·内容源自开源项目 rea2/rea