
📰 科技要闻
• CNBC:美联储 Goolsbee 表示能源通胀比预期更具持久性,即便油价近期因美伊和谈预期回落,仍显著高于战前。
• Google 一名员工被联邦检方指控欺诈,疑似利用 Search 趋势内部数据在 Polymarket 押注获利约 120 万美元。
• Apple 最新款 iPad Air(M4)首次降价最高 100 美元,11 英寸版本来到历史低位。
前几天有个朋友丢过来一个崩溃日志,一上来就是 INSTALL_PARSE_FAILED_NO_CERTIFICATES。他抓狂的点在于:同一个 APK,他自己的测试机能装,运维同学的 Android 14 Pixel 上死活装不上。我让他把签名信息发我,apksigner verify --verbose 一跑,问题瞬间清楚——那个 APK 只签了 V1,没签 V2/V3,而 Android 11+ 在某些场景下对仅 V1 签名的应用会直接拒装。
说实话,APK 签名这块东西,我自己也是踩了好几年坑才慢慢理顺的。表面看就是 apksigner sign 一行命令的事,背后却是一整套从 JAR 时代延续下来、又被 Google 反复重构的复杂协议。今天我想把 V1/V2/V3/V3.1/V4 这一套拆开讲清楚——不是把官方文档复读一遍,而是从工程视角讲:每一代到底解决了什么问题、攻击者怎么打、防御方怎么接、我们写代码的时候到底该怎么选。
一、先说清楚一件事:签名到底防什么
很多同行一提到"签名"就只想到一件事:防止别人篡改。但这个说法其实太粗了。Android 签名实际上同时承担了三件事:
• 完整性(Integrity):APK 文件没被改过一个字节。
• 身份(Identity):这个 APK 是谁发布的,跟系统里已装的同包名应用是不是同一个开发者。
• 授权(Authorization):基于"同一个签名"才能授予的能力,比如 signature 级别权限、共享 UID、应用更新覆盖安装。
这三件事是有先后的:完整性是技术地基,身份是延伸出来的契约,授权是建在身份之上的策略。后面我们会看到,V1/V2/V3 之间的差异,主要不是在"加密更强",而是在哪一层信任被绑定到了哪个粒度的字节流上。这才是关键。
二、V1:JAR 签名时代的遗产,和它最大的坑
协议本质
V1 签名是 Android 从 Java JAR 那继承下来的东西,逻辑很简单——遍历 APK 里每一个文件(除了 META-INF/ 下的几个特殊文件),逐个算摘要,写到 META-INF/MANIFEST.MF 里;再对 MANIFEST.MF 做摘要,写到 CERT.SF;最后用私钥签 CERT.SF,结果放进 CERT.RSA(或 .DSA/.EC)。
看起来一环扣一环挺严密,对吧?问题恰恰出在它的"粒度"上:V1 签的是每个文件,而不是整个 APK 字节流。
这意味着:只要一个文件没出现在 MANIFEST.MF 里,攻击者就可以悄悄塞进去;ZIP 注释、ZIP 间的填充字节、以及 META-INF/ 里某些特殊路径,都不在 V1 的保护范围内。这就是著名的 Janus 漏洞(CVE-2017-13156)的根源。
Janus 漏洞简述
Janus 的攻击思路是这样的:
原始合法 APK(V1 签名)
↓
在 APK 头部前置一个 DEX 文件
↓
✅ PackageManager 看 → 只校验 ZIP 部分,V1 签名通过
❌ ART/Dalvik 加载 → 看到前置的 DEX 头,加载攻击者代码
说人话就是:APK 既是个合法的 ZIP,又能被 ART 当成 DEX 加载——一份字节流被两套解析器各自看成自己想看到的样子。V1 签名只校验了 ZIP 视图下的内容完整性,根本不管 ZIP 头之前那一段。
Google 在 Android 7.0 给出的应对方案就是 V2 签名——直接对整个 APK 字节流签名,把 ZIP 之外的所有"灰色地带"全部纳入保护范围。
三、V2:把签名从"逐文件"提升到"整块字节"
APK Signing Block 的物理位置
V2 签名引入了一个全新的容器——APK Signing Block,物理位置非常巧妙:紧贴在 ZIP Central Directory 之前。这样既不破坏 ZIP 结构(ZIP 解析器从尾部 EOCD 反向定位 Central Directory,根本看不见 Signing Block),又能把整个 APK 文件作为签名输入。
一个 V2 签名的 APK 物理布局长这样:
① ZIP Entries(实际文件内容)
↓
② APK Signing Block(V2/V3/V3.1 在这里)
↓
③ ZIP Central Directory
↓
④ ZIP End of Central Directory(EOCD)
分块摘要:让校验可并行
V2 签名最聪明的地方是分块摘要。它把 ①③④ 三段(不包括 Signing Block 本身)切成 1MB 的块,对每一块算 SHA-256,再把所有块摘要拼起来再算一次 SHA-256,得到最终摘要。这么做的好处有两个:
• 摘要计算可以并行——这对几百 MB 的大型 APK 是关键,安装速度肉眼可见提升。
• 配合后面的 V4 增量校验,可以做到只对改动的块重新算摘要。
用 apksigner 看一个真实 APK 的 V2 签名结构(演示用,命令实际可跑):
# 查看签名版本与证书指纹
$ apksigner verify --verbose \
--print-certs app.apkVerifies
Verified using v1 scheme: true
Verified using v2 scheme: true
Verified using v3 scheme: true
Verified using v4 scheme: false
Number of signers: 1
Signer #1 certificate DN: ...
Signer #1 certificate SHA-256:
a7b4c2...e9f1
关键的一点:V2 出现后,旧机型还在校验 V1,新机型直接用 V2,两者并存而不是替换。这种"向下兼容 + 渐进升级"是 Android 安全机制演进的一贯思路,也是后面 V3/V3.1 要解决的复杂性来源之一。
四、V3:把"签名轮换"做进了协议
为什么要轮换
在 V3 之前,Android 应用的签名 keystore 一旦泄露或丢失,基本就没救了。要么换包名重新发,老用户全部丢失;要么硬撑着用泄露的 key,安全风险像悬在脑袋上的剑。Google Play 上是真的有团队因为这种事故损失过千万级用户的。
V3 引入的 Key Rotation(签名轮换)解决的就是这个问题:允许应用用旧 key 证明"我有权限把签名切到新 key",之后所有更新都用新 key 签。底层数据结构是一个证书谱系(Proof-of-Rotation lineage),把"新旧 key 之间的继承关系"用一连串签名链起来。
轮换的实际命令
# 1) 用旧 key 给新 key 签发轮换证书
$ apksigner rotate \
--in old-lineage.txt \
--out new-lineage.bin \
--old-signer \
--ks old.keystore \
--ks-key-alias old \
--new-signer \
--ks new.keystore \
--ks-key-alias new# 2) 用新 key + lineage 重新签 APK
$ apksigner sign \
--ks new.keystore \
--ks-key-alias new \
--lineage new-lineage.bin \
app.apk
有一个非常容易踩的坑:lineage 一旦生成就要妥善保存,丢了就等于轮换链断了,下一次想再轮换或者降级回旧 key 都没办法。我建议把 lineage.bin 和 keystore 一起做异地多副本备份。
权限的"按 key 兼容矩阵"
V3 的 lineage 还有一个好玩的设计——可以为每一段轮换分别声明权限继承策略。比如你可以指定:旧 key 不再拥有 signature 权限授予能力,但仍然算作"同一开发者"可以覆盖安装。这给了一种渐进式弃用旧 key 的能力,对企业级应用尤其有用。
五、V3.1 与 V4:被忽略的两块拼图
V3.1:解决 V3 的版本协商坑
V3 在实际推广中暴露了一个尴尬问题:因为 V3 的 ID 在 APK Signing Block 里是固定的,没法在不破坏老机型兼容性的前提下扩展新算法。Android 13 引入 V3.1,本质就是给 V3 加了一个"在新机型上才识别"的扩展槽位。
V3.1 的存在意义不是"更安全",而是给未来的算法升级(比如后量子签名)留出一条不破坏老设备的路径。普通业务开发者基本不需要关心 V3.1,但如果你做加固/混淆工具开发,必须支持读写 V3.1 块,否则 Android 13+ 设备会直接拒绝。
V4:增量安装的基石
V4 是个完全不一样的物种。它不放在 APK Signing Block 里,而是输出一个独立的 .apk.idsig 文件,里面是基于 Merkle Tree 的分块哈希。它的核心目标只有一个:支持 ADB 增量安装和 Google Play 的 App Bundle 增量更新。
为什么要 Merkle Tree?因为增量安装时,APK 是一边传一边装的,系统需要在每个块到达时立刻验证它的完整性,而不是等整包下完。Merkle Tree 让任意一个 4KB 块都能用 O(log n) 个哈希校验出来。
V4 的实际应用场景,对绝大多数业务团队来说是透明的——Google Play 在分发 App Bundle 时会自动生成 V4 签名,开发者基本不需要手动操作。但如果你在做内部分发系统、要支持快速调试安装,了解 V4 的存在能省你很多调试时间。
六、不同 Android 版本的签名要求矩阵
Android 版本 | 最低签名要求 | 备注 |
|---|---|---|
7.0(N)以下 | V1 | 不识别 V2/V3 |
7.0 ~ 8.x | V1 或 V2 | V2 优先校验 |
9.0 ~ 10 | V1+V2 推荐,V3 可选 | 支持轮换 |
11 ~ 12 | 必须 V2 或更高(targetSdk≥30) | 仅 V1 在某些场景被拒 |
13+ | 支持 V3.1 | 为后续扩展预留 |
14+ | 同上 + V4 用于增量 | Google Play 强制 AAB |
实战建议:所有新项目,签 V1+V2+V3 是底线,Android 13+ 加 V3.1。如果你打算用 Google Play 的 App Bundle 分发,V4 由 Google 自动生成。CI 流水线里加一道 apksigner verify --verbose 校验,避免出包时漏签某一版本。
七、攻击视角:现在的 APK 篡改怎么打?
很多同行有个误解:以为 V2/V3 上线后,APK 篡改就死透了。实际不是这样。今天 Android 应用面临的"篡改"威胁,已经不是 Janus 那种针对协议本身的攻击了,而是把战场转移到了几个新方向:
1. 重打包 + 重签名(Re-signing)
攻击者不再尝试绕过签名校验,而是直接重新签名。改完 dex/资源/配置之后,用攻击者自己的 keystore 重新签 V1+V2+V3,然后通过侧载、第三方市场分发。这种攻击对完整性是无解的,因为攻击者有自己的有效签名。防御要靠应用内部的"签名指纹自校验"。
最朴素的实现是这样:
fun checkSignature(
ctx: Context
): Boolean {
val expected = "a7b4c2...e9f1"
val pm = ctx.packageManager
val info = pm.getPackageInfo(
ctx.packageName,
PackageManager
.GET_SIGNING_CERTIFICATES
)
val sigs = info.signingInfo
?.apkContentsSigners
?: return false
return sigs.any {
sha256Hex(it.toByteArray())
== expected
}
}
这段代码本身很容易写,但问题是:这段校验代码本身也会被攻击者改掉。所以现实里,签名校验通常会和加固方案绑在一起——把校验逻辑放到 native 层,配合反调试、反 Hook、字符串混淆。我个人的偏好是:Java/Kotlin 层做"快速拒绝",native 层做"延迟报警 + 异常上报",让攻击者不能简单 NOP 掉一个返回值就绕过。
2. 利用 V1/V2/V3 校验差异(Mismatch Attack)
这是最容易被忽视的一类问题。Android 在校验签名时,只校验该设备认识的最高版本。也就是说,如果你的应用同时签了 V1+V2+V3,攻击者只要修改 V1 部分能保持自洽(比如重算 MANIFEST.MF),同时让 V2/V3 块"看起来无效"——某些老设备的 PackageManager 实现会回退到 V1,于是攻击成立。
这是早期 V2 推广时真实出现过的问题(早期版本的兼容性 bug 已被修复)。今天的实战建议是:要么严格只签 V2+V3,明确放弃 Android 6 及以下用户;要么签 V1+V2+V3,但在应用内部主动获取多版本签名信息做交叉校验。PackageManager.GET_SIGNING_CERTIFICATES 返回的 SigningInfo 在 Android 9+ 上能拿到完整的 lineage,可以用来检测异常。
3. 内存补丁与运行时 Hook
这一类攻击根本不动 APK 本身,而是在运行时用 Frida、Xposed、LSPosed 之类的工具 Hook 掉 PackageManager.getPackageInfo 或 native 层的 dlopen,让你的签名校验函数返回任意结果。这才是当前最主流、也最难防的攻击方式。
面对这种攻击,单纯的签名校验已经不够了。需要的是一整套运行时完整性体系(RASP)——这就是另一个话题了,未来我会单独写一篇讲基于 Frida 检测、Xposed 反制和 native 内存校验的文章。
八、给工程团队的几条具体建议
最后我把自己这些年踩坑攒下来的经验提炼成几条可落地的建议,按重要性排序:
• keystore 备份要异地、要多副本。把 keystore 当成跟生产数据库一样重要的东西。最好用 KMS 或者硬件 HSM 托管,至少也要走加密 + 异地多副本。我见过太多团队 keystore 丢失了,被迫整个换包名重发——损失没法估量。
• CI 强制双签或三签校验。流水线里加一步 apksigner verify --verbose --print-certs,明确要求 V2/V3 都通过。这一步 5 秒不到,能避免事故。
• 应用内做签名指纹自校验。即便有加固方案,自校验也是性价比最高的第一道防线。用 SHA-256 比对,不要用 SHA-1。
• 提前规划轮换策略。哪怕你现在 keystore 安全得一塌糊涂,也建议提前生成 lineage 文件,做一次"演习轮换"。等真出事了再想办法,会非常被动。
• 不要迷信 V3 的"自动轮换"。V3 提供的是协议层支持,但发布渠道(Google Play、自有商店、企业分发)各自有自己的策略。Google Play App Signing 启用后,本地 key 跟 Play key 是分离的,轮换流程更复杂,先在测试包上跑通再上正式版。
• 关注 Android 后续版本的 V3.1 / 后量子签名进展。现在没有后量子算法的事,但 V3.1 的扩展槽位说明 Google 已经在为 PQC 切换做准备。如果你做加固/逆向工具,这块要持续跟进。
九、收尾:从签名看 Android 安全的"层"
回头看这一连串协议——V1 是 Java 时代留下的遗产,V2 修补了 V1 的物理粒度漏洞,V3 把"开发者身份"从一个 keystore 扩展到一个谱系,V3.1 留出未来扩展的口子,V4 配合增量安装。每一代都不是对前一代的彻底替换,而是在保持兼容的前提下加一层。这种层叠式的设计哲学,其实贯穿了整个 Android 安全体系——SELinux、Verified Boot、APK Scoped Storage、SafetyNet/Play Integrity,都是同一种思路。
理解每一层的"边界"和"对方做的事",才能在做架构和选型时不踩坑。比如你做加固工具,就必须知道 V2 是"整块字节签名"——这意味着任何修改 dex/资源后再用脱壳器恢复的方案,都不可能保留原签名;又比如你做安装管理工具,要知道 V4 是 idsig 文件而不是 APK 内部的块。