首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >APK签名V1/V2/V3/V3.1/V4深度解析:从Janus漏洞到现代加固体系

APK签名V1/V2/V3/V3.1/V4深度解析:从Janus漏洞到现代加固体系

作者头像
陆业聪
发布2026-06-24 19:15:14
发布2026-06-24 19:15:14
820
举报

📰 科技要闻

• 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 签名结构(演示用,命令实际可跑):

代码语言:javascript
复制
# 查看签名版本与证书指纹
$ 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 之间的继承关系"用一连串签名链起来。

轮换的实际命令

代码语言:javascript
复制
# 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,然后通过侧载、第三方市场分发。这种攻击对完整性是无解的,因为攻击者有自己的有效签名。防御要靠应用内部的"签名指纹自校验"。

最朴素的实现是这样:

代码语言:javascript
复制
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 内部的块。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-06-13,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 陆业聪 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档