APK 签名方案 V1–V4 与加固为何会破坏签名
每一代签名到底保护了什么、Janus 式攻击为何得手,以及加固后的 APK 为什么必须把重签名放在最后一步。
Android 签名不是一套机制,而是四套叠加的方案,每一代都在补上一代的盲区。V1(JAR 签名)把每个文件单独哈希写进 META-INF/MANIFEST.MF,再对这份清单的摘要签名。因为它签的是「文件」而不是「字节流」,清单之外的任何东西——ZIP 注释、填充字节、未列出的文件——都不在保护范围内。CVE-2017-13156(Janus)正是利用这个缺口:在一个合法签名的 APK 头部前置一段 DEX,ZIP 解析器看到的是一个合法包,ART 却会执行攻击者的代码。
V2 用「签整个字节流」修掉了这个问题。它在 ZIP Central Directory 之前插入 APK Signing Block,并把其余段落切成 1MB 的块,每块算 SHA-256,再对块摘要做一次哈希。分块带来两个好处:校验可以并行,对几百 MB 的大包提速明显;同时 ZIP 结构完全不受影响——ZIP 读取器从尾部的 EOCD 反查 Central Directory,根本看不到这个块。V3 保留该结构,并加入轮换谱系(proof-of-rotation lineage),让密钥泄露后仍能平滑换钥。V3.1(Android 13)增加了一个老设备会忽略的扩展槽;V4 则是独立的 .apk.idsig 文件,基于 fs-verity Merkle 树,只服务于增量安装。
版本矩阵在工程上很关键。Android 7.0 以下只认 V1;7.0 起优先 V2;Android 11+ 在某些路径会拒绝仅 V1 的包;Android 13+ 的工具链要求能识别 V3.1。发布包的安全基线仍然是 V1 + V2 + V3,如果你通过 Play 分发 AAB,V4 由平台自动生成。CI 里务必跑一遍 apksigner verify --verbose --print-certs —— 静默丢掉某一档签名的包,是安装期的地雷,而不是构建期的警告。
加固与签名的冲突点很具体:每个壳都会重签名。dpt-shell 会写入自己的调试证书,Abdal-DroidGuard 又写一次,每一段都会留下过期签名材料。在此基础上直接跑 apksigner,要么失败,要么产出一个 V2 块与实际字节早已不匹配的安装包。因此正确顺序是严格固定的:先清旧签名(zip_clean.py)、再对齐(zipalign)、最后 V1+V2+V3 重签并校验。签名永远是最后一步,绝不放在中间。
有一个坑值得单独点名,因为它的表现像随机崩溃:dpt-shell 的 -vs 参数会在壳内开启运行时签名校验,而它校验的是壳自己打上的证书,不是你最终发布的证书。在最后还要重签的前提下开启它,应用会在启动阶段直接中止。这正是 VALLUM 安全模式刻意不加 -vs 的原因。如果你确实需要运行时签名校验,请自己针对发布证书指纹实现,而不是让壳去校验它的调试密钥。
最后要清楚签名买不到什么。签名向系统证明的是完整性与作者身份,但攻击者把你的应用改完,用自己的密钥重签,再从第三方渠道分发——系统完全接受。要发现这种情况,必须在应用内做发布证书指纹自校验,并且要保护它不被 NOP 掉。这个校验应该落在 native 层、藏在防篡改之后,这正是 VALLUM 把 SignSeal 与壳绑定、而不是做成一个普通库调用的原因。