Android 签名校验为什么能拦住二次打包:指纹与完整性校验

重签名绕不开,那就在运行时问一句:这个包,是不是我发的?

前言

做 Android 加固的人,大多经历过这样一个阶段:混淆开关打开、把 APK 发出去,以为「代码看不清就安全了」。直到渠道反馈,市面上出现了同一个图标、同一个界面,但内购和弹窗都不对的包,才发现混淆挡住的只是阅读难度,没有挡住任何一次修改。

真正让人心里没底的,是这条链几乎每一步都有现成工具:反编译、改中间码、重新打包、重新签名,装上就能跑,没有任何一环需要攻击者「重新发明轮子」。

我在实际项目里梳理这条链路时,注意到了一个关键的不对称:攻击者可以改任何东西,唯独不能省略「重签名」这一步------因为系统在安装时会验签,包一旦被改动,原签名就失效。这个绕不开的环节,就是防御可以借力的支点。 这篇就讲怎么围绕它,在运行时建起第一道防线:签名指纹校验,以及作为补充的内容完整性校验,同时把这道防线的天花板讲清楚。

一、为什么「签名变了」就等于「包被动过」

1.1 攻击链的闭合性

先回答一个前提问题:我们凭什么能靠签名识别二次打包?

答案藏在攻击链的闭合性里。假设攻击者的目标是改掉某个函数的行为,那么:

改函数 → 必然改 dex → 改 dex 后原签名失效 → 攻击者必须重签名 → 签名指纹改变。

只要攻击者动了包里的代码,这条链就会一路走到「签名指纹改变」这一步。于是防御动作变得非常简单:在启动时读一下「当前签名指纹」,和内置的「官方签名指纹」比对,不一致就说明这个包被动过手脚。

反过来看也成立:想避开这一步,唯一的办法是拿到官方私钥;而在私钥安全的前提下,这条链在「签名校验」这里就是断的。

1.2 比对的是证书指纹,不是整个签名文件

这里有个必须先厘清的细节:我们比对的是签名证书的指纹(对证书本身算一个哈希值),而不是整个签名文件。

原因在于:签名文件大致等于「证书 + 用私钥对内容算出的签名值」两部分,签名值会随内容变化,证书本身才是稳定的,才能当「身份标识」。所以身份标识要取证书的哈希,而不是整个文件。

工程上这个哈希一般取 SHA-256,输出 32 字节,即 64 个十六进制字符,发布前回填进代码即可。

二、签名方案 v1 / v2 / v3:它们到底保护了什么

要理解「为什么有些场景下签名校验不够用」,得先知道不同签名方案的覆盖范围差别有多大。Android 的签名方案是逐步演进的,同一份 APK 可以同时携带多套签名。

方案 起始版本 保护粒度 关键特征
v1 最早 ZIP 内每个条目分别签名 又叫 JAR 签名,产物是 META-INF/ 下的三个文件
v2 Android 7.0(API 24) 整个 APK(除签名块本身) 插入独立的签名块,改动任意字节即验签失败
v3 Android 9(API 28) 整个 APK(除签名块本身) 在 v2 基础上支持密钥轮换
v4 Android 11(API 30) 整个 APK 面向流式安装的增量签名,产物含 .idsig 文件

2.1 v1:签的是条目,不签容器

v1 的本质是 JAR 签名,它会对 ZIP 包里的每一个条目单独算摘要,并把摘要和证书一起放进 META-INF/ 目录下的三个文件里:MANIFEST.MF、CERT.SF、CERT.RSA。

它的局限不在「没给 dex 算摘要」------条目摘要它是算了------而在于它的粒度是条目,不是容器。ZIP 除了条目内容,还有一套描述条目位置和属性的结构(本地头、中央目录、结尾记录),v1 并不保护这些。历史上就出现过利用这一点的典型问题:让系统实际加载的代码,与「已签名的那份内容」不一致(Android 8.0 起系统层面对此做了修补)。

对我们的防御来说,结论是:在只认 v1 的场景下,「签名有效」不完全等于「内容没被动过」,需要额外一道内容层的校验来兜底。

2.2 v2 / v3:改一个字节就失效

v2 和 v3 换了个思路:不再逐条目签名,而是把整个 APK 当成一个整体来签。签名信息被放进一个独立的「APK 签名块」,插在条目区和中央目录之间,覆盖范围是 APK 里除签名块本身以外的所有字节。

这个设计带来一个非常干脆的性质:任何一处字节被改动,都无法通过验签。攻击者想改一行代码再重新打包,就必然破坏 v2/v3 签名,只能回到「重新签名」这条路上------而这正是我们第一章抓住的那个环节。

2.3 这对防御意味着什么

把 v1 和 v2/v3 放在一起看,防御策略就清楚了:

  • 面向新系统(API 24 及以上)的包,v2/v3 已覆盖整包,签名校验本身就足够强;
  • 面向只认 v1 的旧系统,可能出现「签名有效但内容存疑」,需要内容完整性校验补一刀;
  • 所以「签名校验 + 完整性校验」是一组,前者是主力,后者是补充,而非二选一。

三、第一道锁:签名指纹校验

3.1 核心逻辑

在应用层,读取签名证书、计算指纹、与内置值比对,逻辑很直接:

kotlin 复制代码
object SignatureGuard {

    // 官方签名指纹(发布前回填,十六进制小写)
    private const val OFFICIAL_FINGERPRINT = "0000000000000000000000000000000000000000000000000000000000000000"

    fun isTrusted(context: Context): Boolean {
        val current = readFingerprint(context)
        return OFFICIAL_FINGERPRINT.equals(current, ignoreCase = true)
    }

    private fun readFingerprint(context: Context): String {
        val pm = context.packageManager
        val info = pm.getPackageInfo(
            context.packageName,
            PackageManager.GET_SIGNING_CERTIFICATES
        )
        val cert = info.signingInfo?.apkContentsSigners?.firstOrNull()?.toByteArray()
        return cert?.let { sha256Hex(it) }.orEmpty()
    }
}

这里的 OFFICIAL_FINGERPRINT 是一串全 0 的占位值,发布前必须回填成自己的签名指纹,否则校验等于没做------把正式签名包装到真机或模拟器上,跑一次上面的读指纹逻辑,把打印出的 64 位十六进制串抄进来即可。

3.2 取签名的几种方式与版本兼容

上面用的是 API 28(Android 9)起才有的 GET_SIGNING_CERTIFICATES 与 signingInfo。更早的系统上,这套接口不存在,得回退到已被标记为废弃的 GET_SIGNATURES。两者的差异如下:

取法 最低版本 返回值 说明
GET_SIGNATURES 最早 PackageInfo.signatures(Signature[]) API 28 起废弃,仅返回当前签名
GET_SIGNING_CERTIFICATES API 28 PackageInfo.signingInfo(SigningInfo) 推荐,能区分「当前签名」与「历史签名」

SigningInfo 里有两个容易混淆的方法:

  • apkContentsSigners:当前实际签署这个 APK 的证书,判断「是不是我发的包」要用它;
  • signingCertificateHistory:该应用历史上用过的签名证书列表(配合密钥轮换),用它可以判断「是不是我一脉相承签出来的包」;
  • 另外还有 hasMultipleSigners() 与 hasPastSigningCertificates(),分别回答「是否是多人联合签名」和「是否发生过密钥轮换」。

所以一个兼容新旧系统的读法大致是这样:

kotlin 复制代码
private fun readFingerprint(context: Context): String {
    val pm = context.packageManager
    val certBytes: ByteArray? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
        pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNING_CERTIFICATES)
            .signingInfo
            ?.apkContentsSigners
            ?.firstOrNull()
            ?.toByteArray()
    } else {
        @Suppress("DEPRECATION")
        pm.getPackageInfo(context.packageName, PackageManager.GET_SIGNATURES)
            .signatures
            ?.firstOrNull()
            ?.toByteArray()
    }
    return certBytes?.let { sha256Hex(it) }.orEmpty()
}

分界线是 Build.VERSION_CODES.P(API 28),比硬写 28 更不容易写错。

3.3 失败要「保守」,但不能一刀切

有两个工程注意点:

  • 系统版本差异:读取签名证书的接口在不同系统版本上不一样,要做版本分支兼容(新接口优先,低版本回退到旧接口),也就是上面 3.2 的写法。
  • 失败要「保守」:读不到指纹时,不能当作「通过」,但也别直接崩溃------先把结果记录下来,由失败处理策略统一决定怎么处置。

第二点值得展开说。签名校验的结果其实有三种,而不是两种:

结果 含义 处置建议
通过 指纹与内置值一致 正常继续
不通过 读到了指纹,但与内置值不一致 明确的篡改信号,按策略拦截
无法判定 读不到指纹(异常、权限、系统差异) 记录并上报,不直接崩溃

把第二、三种混为一谈是很常见的坑:某些定制系统或异常环境下读不到证书,就会把大量正常用户误判成攻击者。安全是为了抬高攻击成本,不是连可用性一起牺牲。

四、第二道锁:内容完整性校验

4.1 先搞清楚对象:APK 是个 ZIP 包

做完整性校验之前,得回到一个基本事实:APK 本质上是一个 ZIP 压缩包。

它由三部分构成:一个个条目 (classes.dex、resources.arsc、AndroidManifest.xml、res/ 下的资源等)、描述这些条目位置和属性的中央目录 ,以及末尾的结尾记录 。每个条目在本地头和中央目录里都带有一个 CRC32 字段,是打包时算好的。

这带来一个便利:要拿 dex 的校验值,不必自己重算,直接读 ZIP 里现成的 CRC 字段即可。

4.2 用 dex 的 CRC 做比对

为覆盖 v1 场景下「签名有效但内容存疑」的情况,可以加一道 dex 校验和的比对:

kotlin 复制代码
object DexIntegrityGuard {

    // 各 dex 的校验值白名单,key 为 dex 文件名,value 为 CRC 值
    private val expected: Map<String, Long> = mapOf(
        "classes.dex" to 1234567890L
    )

    fun check(context: Context): Boolean {
        if (expected.isEmpty()) return true
        val current = readDexCrcs(context)
        return expected.all { (name, crc) -> current[name] == crc }
    }
}

这里的 1234567890L 同样是占位值,真实值要等打包完成后从 APK 里读出回填。expected 为空时直接返回 true,是为了未启用这道校验时不误伤------它默认不介入。

4.3 CRC32 与 SHA-256:别混用

这两个都是「算一个值来比对」,但用途完全不同,混用会踩坑:

维度 CRC32 SHA-256
长度 32 位 256 位
用途 检测传输/存储的错误(意外损坏) 检测刻意篡改、做身份标识
抗碰撞 弱,构造碰撞很容易 强,抗碰撞是设计目标
速度 极快 相对慢
典型位置 ZIP 每个条目自带 证书指纹、文件摘要

关键区别在威胁模型 :CRC32 的设计目标是「发现无意的比特翻转」,不是「防御有意构造的碰撞」。所以它不适合当防篡改的唯一手段------能挡住意外,挡不住有心人;它在这里的价值只是「顺带读到、不用额外计算」的低成本信号。要强校验,就该用 SHA-256 这类密码学摘要。

4.4 multidex 下怎么枚举各个 dex

方法数超过 65536 之后,工程会启用 multidex,产物里的 dex 就不止一个了:classes.dex、classes2.dex、classes3.dex......依次存在,且都放在 APK 的根目录下。

枚举它们,用 ZipFile 读条目名即可:

kotlin 复制代码
private fun readDexCrcs(context: Context): Map<String, Long> {
    val result = mutableMapOf<String, Long>()
    ZipFile(context.applicationInfo.sourceDir).use { zip ->
        zip.entries().asSequence()
            .filter { it.name.matches(Regex("classes\\d*\\.dex")) }
            .forEach { entry -> result[entry.name] = entry.crc }
    }
    return result
}

这里用 context.applicationInfo.sourceDir 拿到当前 APK 的路径(即安装到设备上的那份包),交给 ZipFile 逐条目读取;正则 classes\d*\.dex 同时覆盖 classes.dex 与 classes2.dex 这类名字。

4.5 一个绕不开的取舍:校验值难以自动化

到这里,签名校验和完整性校验都能跑了,但两者在「维护成本」上不是一回事。

签名指纹是干净的:它来自密钥库里的证书,和打包产物无关,所以可以在构建期自动算出来、自动回填,人不需要介入。

dex 校验值不行,因为它存在一个循环依赖:

  • dex 校验值是打包产物,要等 dex 合并完成后才确定;
  • 这个值要编进校验逻辑里,作为预期值;
  • 而校验用的代码本身又被打进同一个 APK,反过来影响 dex 的最终布局。

也就是说,校验值的「因」和「果」纠缠在同一个产物上,单次构建内无法自举 。务实的选择是:签名指纹全自动,dex 校验值按需手动回填,或者走两遍构建。而回到第一节的结论------改 dex 必然导致重签名、重签名必然改变指纹------签名校验已经足够拦截绝大多数二次打包,dex 校验值只在私钥泄露这类极端场景下才派得上额外用场,属于可选的保险。

五、这道防线的天花板:应用层逻辑可被改

5.1 它拦得住谁,拦不住谁

坦白说,这一层防线有个明确的天花板:校验逻辑本身写在应用层,攻击者反编译后完全可以把它改掉------比如把校验函数改成「永远返回通过」。

所以它的作用边界很清楚:

  • 拦得住「拿来就改」的人------他们改完包、重签名,一启动就被签名校验拦下;
  • 拦不住「会改校验逻辑」的人------他们不碰你的代码逻辑,直接绕过校验函数本身。

这两类人的成本差了一个量级,而防线只覆盖了较低的那一类。但这道锁依然值得上:绝大多数实际的二次打包都停在第一类,上锁的成本又低。

5.2 往哪儿走:把判断挪出应用层

要抵抗第二类人,只有一条路:把真正的判断从应用层挪走,沉到更难下手的地方去。

应用层代码反编译后几乎等价于源码,改一行、重打包,门槛很低;而校验一旦编译成机器码的动态库,攻击者面对的就不再是可读逻辑,而是汇编级二进制,改造成本完全不同量级。这也是下一篇要展开的主题:把校验逻辑下沉到原生层。

在那之前,本篇这两道锁该上还是要上:它们拦住了成本最低的一大批攻击者,而它们暴露的问题(指纹维护、校验值回填、失败降级),在下沉之后还会以更复杂的形式重现。

结论

  • 重签名是二次打包绕不开的环节,「运行时校验签名指纹」是成本最低、收益最高的第一道防线,应用层即可实现。
  • 比对的是签名证书的指纹,不是整个签名文件------证书稳定,才能当身份标识。
  • v1 只签条目、不签容器 ,因此旧系统上可能「签名有效但内容存疑」;v2/v3 覆盖整个 APK,改动任意字节即验签失败。
  • 取签名区分 GET_SIGNATURES(旧)与 GET_SIGNING_CERTIFICATES(API 28+),前者只给当前签名,后者能区分当前签名与历史签名。
  • 内容完整性校验是补充而非主力:它用 ZIP 里现成的 dex CRC 兜底,只在私钥泄露等极端场景才有额外价值。
  • CRC32 与 SHA-256 不能混用:前者防意外损坏,后者才抗刻意碰撞。
  • 签名指纹可全自动注入,dex 校验值受循环依赖制约,只能手动回填或两遍构建;但签名校验已足够,不必为此付出复杂度的代价。
  • 应用层校验可被 patch,它挡得住「拿来就改」,挡不住「会改逻辑」;要更进一步,必须把校验下沉到原生层。

如果这套「签名 + 完整性」的组合在你的工程里也踩过坑------比如换签名忘了回填指纹、或者 multidex 漏了某个 dex------欢迎在评论区聊聊你的处置方式。

相关推荐
jason.zeng@15022071 小时前
使用vscode+flutter开发android程序(入门)
android·vscode·flutter
事圆则缓2 小时前
Kotlin学习导航:从知识树走到项目与面试
android·开发语言·kotlin
天空之城--2 小时前
近一周Android开发动态:AI编码、Compose与自适应实践
android
mmsx2 小时前
Android 混淆挡不住二次打包:一条完整的攻击链与突破口
android·混淆
聚美智数11 小时前
智能结构化识别-图片结构解析-图像结构化解析-照片结构化解析
android·java·数据库
mmsx17 小时前
Android 插件化架构:40 个 Gradle 模块的拆分、通信与共存
android·kotlin
mmsx17 小时前
Android 不改一行源码:用 AspectJ 做三类拦截
android·kotlin
TST-魚爷17 小时前
易视讯会议摄像头 + 全向麦克风实操讲解稿
android·电脑·钉钉·腾讯会议·zoom会议