签名指纹能认出「这不是我发的包」,却认不出会改校验逻辑的人。
拿到一个 APK,反编译、改几行、重新打个包、重新签个名,装到手机上照样能跑------这件事在多数没做防护的应用上,成本低到只需要一个下午。更要命的是,改完之后它还能换上你的图标、顶着你的名字分发出去,用户装的根本分不清来源。
很多人把希望寄托在混淆上。混淆确实让反编译结果难读了一些,但它从头到尾没改过一件事:被改过的程序依然是一个合法可安装的包。名字换成 a、b、c,逻辑还是那段逻辑;改一行代码,重签名,系统照收不误。混淆抬高的是「看懂的成本」,不是「能不能装」。
真正能卡住攻击者的地方不在「难读」,而在「认人」。系统从安装到运行都要验签,而一次重打包必然导致签名发生改变。只要应用在启动时能确认「当前这个包的身份,是不是我自己」,二次打包就从暗处被拽到了明面上。这篇要做的,就是围绕这个身份锚点建起第一道锁,并且把这道锁到底能挡到哪一步说清楚------它不是万能墙,但它是投入产出比最高的一层。
一、把锚点钉死:为什么签名指纹能当身份标识
上一篇把二次打包的链路走了一遍,落点是一个绕不开的结论:重打包必然触发重签名。原包内容一旦被修改,原来的签名就失效了,攻击者必须用自己的密钥重新签一次名,系统才肯让它安装。
把这个结论反过来用,就得到了一条闭合的推理链:
- 有人改了函数,就一定改了 dex;
- dex 改了,原来对内容计算的签名就失效了;
- 签名失效,攻击者必须重新签名;
- 重签名用的是攻击者自己的密钥,签名证书随之改变;
- 证书一变,我们内置的「身份指纹」就对不上了。
这条链的关键在于「闭合」:只要攻击者动了包,就一定会走到最后一步。除非他能拿到你的私钥、用原密钥去签(那已经是另一个量级的攻击,不在本文讨论范围),否则签名指纹的改变无法避免。于是运行时只要问一句「当前签名指纹和我的官方指纹是否一致」,就能识别出被动过的包。
不过这里藏着一个必须先厘清的细节,很多人第一次写校验都会踩:我们要比对的是签名证书的指纹,不是整个签名文件、也不是签名值。 一个签名结构里同时躺着两类东西------一类是「证书」,也就是 X.509 里描述身份信息的那段,它是稳定的;另一类是「对内容计算的签名值」,它会随着被签内容的变化而变化。如果你拿后者、或者拿整个签名文件的哈希去比对,那么只要内容合法地发生一点变化(比如正常发了个新版),这个值就变了,你根本没法把它当成一个长期稳定的身份标识。证书是身份,签名值是按内容算的凭据,两者不能混用,这是后面所有实现的地基。
二、签名方案三代演进:v1、v2、v3 到底签了什么
要理解「为什么光有签名校验还不够、还得补一道完整性校验」,就必须先搞清楚 Android 的签名方案一共迭代了三代,而每一代「签的东西」范围完全不同。很多人只知道要「开 v2」,却说不出 v1 到底漏在哪------而那道漏洞,恰恰是完整性校验存在的理由。
v1,也就是 JAR 签名 ,是 Android 最早的签名方式,它沿用 Java 世界的归档签名套路。它的做法是「逐条目签名」:压缩包里每一个文件条目,都会在 META-INF/MANIFEST.MF 里登记一条它的内容摘要;再把这些摘要汇总成 CERT.SF;最后用私钥对 CERT.SF 签名,产出 .RSA 或 .DSA 文件。你可以粗略理解成一张「收货清单」:清单逐行列出了每个条目的指纹,签名保证的是「这张清单没被改」。
text
META-INF/MANIFEST.MF # 逐条目内容摘要,一张清单
META-INF/CERT.SF # 对清单自身再算摘要
META-INF/CERT.RSA # 用私钥对 CERT.SF 的签名,内含证书
问题正出在「逐条目」这三个字上。v1 保护的是清单里列出的那些条目的内容 ,但它不保护压缩包这个容器整体:压缩包的中央目录、条目的排列顺序、条目上附加的额外字段、以及整个归档的结构,全都不在签名的覆盖范围内。换句话说,v1 给每个抽屉里的东西贴了封条,却没给整个柜子拍照。于是就有了一个在归档格式与代码格式之间存在「双重解释空间」的经典问题------在部分低版本系统上,存在一种构造方式,能让「验签时系统读到的内容」和「运行时真正加载执行的内容」不是同一份。v1 只校验清单内条目,对这类归档层面的差异无能为力。这就是 v1 时代留下的缺口类型。
对照着看 v2。v2 是整包签名 :它不再逐个条目算摘要,而是对整个 APK 的字节流 签名。签名块被插在压缩包中央目录之前、条目数据之后(即 APK Signing Block),覆盖了包内所有条目的内容、条目的元数据以及中央目录结构。任何字节被改动,验证都会直接失败。它带来两个直接好处:一是「整个归档都在保护范围内」,上面那类双重解释的攻击面被堵死;二是验证时不必逐条目解压比对,速度更快。v1 是清单式保护,v2 是整体式保护,这是量级上的差别。
v3 在 v2 的基础上又加了一样东西:签名谱系。 它的签名块结构和 v2 一致、覆盖范围也和 v2 一样是整包,区别在于它允许「密钥轮换」------当你需要更换签名密钥时,新密钥可以携带一条由旧密钥背书的继承谱系,证明「新身份是旧身份的合法延续」。这样系统既能接受新签名的包,又能沿着谱系回溯确认真实来源。它从 Android 9(API 28)开始完整支持。三代的关系可以用一张覆盖范围图看明白。
把三代的差异拉成一张表,覆盖范围的区别一眼可见:
| 方案 | 引入时机 | 签名对象 | 覆盖范围 | 是否覆盖 dex 整体 | 密钥轮换 |
|---|---|---|---|---|---|
| v1(JAR 签名) | 最早 | 压缩包内每个条目 | 仅清单里列出的条目内容 | 否 | 不支持 |
| v2(整包签名) | Android 7.0(API 24) | 整个 APK 字节流 | 全部条目内容与中央目录结构 | 是 | 不支持 |
| v3(整包签名 + 谱系) | Android 9(API 28) | 整个 APK 字节流 | 同 v2 | 是 | 支持 |
还有一条工程事实要记住:从 Android 11 起,targetSdkVersion 达到 30 及以上的应用,必须使用 v2 或更高的签名方案 ,只带 v1 的包在安装时会被直接拒绝。这意味着新应用基本都天然获得整包保护,v1 的缺口主要残留在一些仍在低版本系统上运行、或签名配置陈旧的包上。但恰恰是这些包最容易被忽略,所以才需要一层兜底。到这里可以得出结论:v2/v3 覆盖了 dex 整体,签名校验才足够硬;而 v1 覆盖不到,才需要完整性校验补位。 顺序不能反------先把整包签名打开,再谈补充手段。
三、读签名的 API 兼容:旧接口、新接口与多签名者
从系统里把「当前签名证书」读出来,看起来是一行调用的事,实际上有两个坑:接口随版本变化,以及一个包里可能不止一个签名者。
旧的接口是 PackageManager.GET_SIGNATURES,它返回一个 Signature[] 数组。它从 API 28 起被标记废弃,原因是它表达能力太弱:它表达不了签名谱系,也表达不清「多签名者」到底意味着什么,只给一个数组,让调用方自己猜该取哪个。
新的接口是 PackageManager.GET_SIGNING_CERTIFICATES,它返回一个 SigningInfo,能同时表达两种语义:apkContentsSigners 是「实际对包内容签名的签名者集合」;signingCertificateHistory 是「历史证书链」,用于密钥轮换的场景。取哪个,取决于包处于哪种状态。正确的分支写法大致如下:
kotlin
@Suppress("DEPRECATION")
fun readSigners(context: Context): Array<ByteArray> {
val pm = context.packageManager
val pkg = context.packageName
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES)
val si = info.signingInfo
when {
si == null -> emptyArray()
// 多签名者:多渠道重签名等场景会同时存在多个签名者
si.hasMultipleSigners() -> si.apkContentsSigners.map { it.toByteArray() }.toTypedArray()
// 单签名者:可能正处于密钥轮换期,历史链条一并纳入判定
else -> si.signingCertificateHistory.map { it.toByteArray() }.toTypedArray()
}
} else {
val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES)
info.signatures.map { it.toByteArray() }.toTypedArray()
}
}
旧版本的回退分支单独拎出来,其语义就是一次朴素取值,没有「多签名者」的概念:
kotlin
// API 28 以下只能走旧接口,注意它无法区分「内容签名者」与「历史证书」
val legacyInfo = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES)
val firstOnly = legacyInfo.signatures.firstOrNull()?.toByteArray()
这里有两个必须点出来的问题。
第一,「取哪个签名者」不是随意挑一个就行 。如果你只取 apkContentsSigners.firstOrNull(),在存在多渠道重签名的包里就可能取错那个真正的签名者;而如果你的应用做过密钥轮换、改朝换代过签名,那么老用户机器上装的仍是旧证书签的包,此时历史链里既有旧证书也有新证书,你的白名单必须能同时接纳链条上的身份。判定逻辑应当从「取第一个」升级为「在白名单里做集合匹配」------只要当前实际签名者或历史链中任一身份命中内置白名单,就算通过。
第二,回退分支决定了你要准备两套判定 。低版本系统拿不到 SigningInfo,只有裸的 Signature[],因此校验逻辑要能同时吃下「新接口的结构化结果」和「旧接口的数组」,否则一旦在某类机型上走了回退分支而判定没写全,就会出现莫名其妙的放行或误杀。这类兼容问题在只测主流新机型时几乎发现不了。
四、比对的为什么是证书指纹,而不是签名文件
第一节埋下的那个细节,值得单独用一节讲透,因为它是最容易被写错、又最难排查的一处。
一个 .RSA 签名文件(或 v2/v3 的签名块)里,至少有两类字节:一类是证书 ,一段 X.509 结构的二进制,记录着公钥、颁发者、有效期这些身份信息;另一类是签名值,是「拿私钥对内容摘要加密」得到的凭据。前者是身份,相对稳定;后者是凭据,随内容变化。你的官方指纹如果在开发机上算的是「整个签名文件的哈希」,那么只要内容合法地变一次,值就变了,白名单立刻失效------你会发现自己每次发版都要重刷指纹,还搞不清为什么。
正确做法是:只对证书本身计算哈希。 证书只要私钥不变就不会变,因此它是天然稳定的身份锚点;而内容怎么变,都不影响证书指纹。这个区分不搞清楚,后面的自动化、多签名白名单全都建在沙子上。
再来看指纹算法和格式这块,也有一段演进史。早期常见两种做法,现在都不推荐:
kotlin
// 不建议:toCharsString 返回的是内部编码,不是标准指纹,跨系统/跨工具对不上
val legacy = signature.toCharsString()
// 不建议:MD5 已不适合新的安全用途,且各家对「怎么格式化成字符串」理解不一
val md5 = MessageDigest.getInstance("MD5").digest(signature.toByteArray())
// 建议:对证书的 DER 字节求 SHA-256,统一输出十六进制小写
fun fingerprint(cert: ByteArray): String =
MessageDigest.getInstance("SHA-256")
.digest(cert)
.joinToString("") { "%02x".format(it) }
toCharsString() 拿到的是平台内部的字符串编码,带着长度前缀之类的私有信息,它既不是标准指纹,也无法和构建脚本、后端、第三方工具产出的值对齐;MD5 则因为碰撞问题,早已退出安全用途,而且「要不要加冒号、大小写怎么定、字节序怎么排」这些格式细节,往往是线上误判的真正源头。统一到 SHA-256 + 十六进制小写,是让「构建期回填的指纹」和「运行时算出的指纹」能对上号的前提。
最后一条容易被忽略的实践:官方指纹的来源应该是 keystore 里的证书,而不是从一个已经签好名的 APK 里读出来的值。 前者是「源头」,与打包产物无关;后者是「结果」,你等于拿自己证明自己,一旦读取逻辑或当前包被动过,你根本发现不了。这也为第 4 篇的「构建期自动注入」埋下伏笔------指纹从密钥库来,才能干净地自动化。
五、启动时校验的完整时序
指纹怎么算清楚了,接下来是「什么时候校验」。位置选得不对,要么留出绕过窗口,要么拖垮冷启动。
理想的位置是越早越好 。Application.onCreate() 已经算早了,但它之前还有一个更早的入口:一个自定义的 ContentProvider,它的 onCreate() 会在 Application.onCreate() 之前被系统调用。把校验的触发点挂在这里,可以在业务代码跑起来之前就完成判定。但要克制------这个阶段尽量只做「触发与判定」,把耗时的上报、网络请求丢到异步里,别在启动首帧里做重活。
kotlin
// 用 ContentProvider 抢一个比 Application 更早的初始化时机
class SecurityCheck : ContentProvider() {
override fun onCreate(): Boolean {
val ctx = context ?: return true
SecurityGate.initAsync(ctx) // 只触发校验,重活丢异步,不阻塞启动
return true
}
override fun query(/* ... */): Cursor? = null
override fun insert(/* ... */): Uri? = null
override fun update(/* ... */): Int = 0
override fun delete(/* ... */): Int = 0
override fun getType(/* ... */): String? = null
}
真正做判定的门面,把签名校验与完整性校验的结果汇总,交给统一的失败策略:
kotlin
object SecurityGate {
fun initAsync(context: Context) {
// 判定与处置分离:这里只产出「可信 / 完整」两个信号
val trusted = SignatureGuard.isTrusted(context)
val intact = DexIntegrityGuard.check(context)
if (!trusted || !intact) {
FailureHandler.onTampered(context, trusted, intact)
}
}
}
用一张时序图把整个过程串起来,各角色各司其职:
下面是签名校验主体的实现,也是这一层的核心:读到当前证书、算出指纹、和内置值比对。
kotlin
object SignatureGuard {
// 官方签名指纹(构建期回填,十六进制小写)
private const val OFFICIAL_FINGERPRINT = "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
fun isTrusted(context: Context): Boolean {
val current = readFingerprint(context)
// 读不到时不能当作通过,交由失败策略统一处置
if (current.isEmpty()) return false
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()
}
}
六、补充锁:dex 完整性校验(CRC 从哪来)
签名校验看起来已经是主力了,为什么还要补一道完整性校验?答案就在第二节的结论里:v1 覆盖不到归档整体,在残留的低版本场景下,仅靠签名校验存在盲区。 给 dex 加一道校验和(CRC)比对,就是覆盖这个盲区的手段。
先把最关键的一个问题讲清楚:这个 CRC 值从哪来? 答案是不用自己算------APK 本质是一个 zip 压缩包,zip 的每个条目在中央目录里都带着一个 CRC32 字段,这是压缩规范的一部分,打包工具在写入时就已经算好并存进了中央目录。我们只需要解析中央目录、把每个 dex 条目对应的 CRC 读出来即可,既不必解压也不必重新计算。
kotlin
private fun readDexCrcs(context: Context): Map<String, Long> {
val apk = context.applicationInfo.sourceDir ?: return emptyMap()
val result = mutableMapOf<String, Long>()
// APK 就是 zip,用 ZipFile 遍历中央目录,CRC 字段直接可得
ZipFile(apk).use { zip ->
val entries = zip.entries()
while (entries.hasMoreElements()) {
val entry = entries.nextElement()
if (entry.name.endsWith(".dex")) {
result[entry.name] = entry.crc // 中央目录自带的 CRC32,无需解压
}
}
}
return result
}
校验的范围就是包里所有 dex 条目,classes.dex、classes2.dex 直到 classesN.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 }
}
}
但必须把它的定位说白:签名校验才是主力,dex 校验值只是补充,而不是并列的第二道主力。 三个理由。其一,它只有 32 位,抗碰撞能力弱,它不是密码学哈希 ,攻击者如果专门针对它,碰撞成本并不高,所以它挡不住有备而来的人。其二,在 v2/v3 整包签名已经开启的包上,dex 的任何改动都会被整包签名直接覆盖发现,CRC 的意义进一步被压缩------它真正有额外价值的场景,是私钥泄露、或系统只认 v1 的低版本环境。其三,它有一个绕不开的工程代价:每次打包,dex 都会变,CRC 就会变,白名单必须重新回填。 这个「因」与「果」纠缠在同一个打包产物上的循环依赖,让它很难像签名指纹那样干净地自动化,这一点第 4 篇会专门展开。结论是务实的一句话:签名指纹全自动,dex 校验值按需手动。
七、失败了怎么办:静默上报 + 延迟退出
校验跑起来之后,真正难的不是「判定是否通过」,而是**「判定不通过时该做什么」**。这一节里的每个决定,都直接关系到会不会误伤正常用户。
核心原则有两条,看起来矛盾,其实必须同时满足:读不到指纹不能当作通过(安全上要 fail-closed),但也别立刻崩溃(可用性上不能 fail-loud)。 如果你 fail-open------读不到就当通过,那攻击者只要想办法让你的读取失败,防线就整个作废。如果你 fail-loud------一发现不对就闪退,那第一次上线的误判就会让所有正常用户打不开 App,舆情比被破解还严重。
误杀的来源比想象中多。渠道重签名 会让一个包里出现多个签名者;灰度发布 期间新签名的包正在放量、而白名单还没更新;不同 ROM 的 PackageManager 实现差异可能导致某类机型上读取异常。任何一个环节没兜住,都会把「正常用户」判成「被篡改」。
所以处理思路要拆成四步。第一,判定与处置分离 ------判定只负责产出「是否可信、是否完整、是否读到了」这几个信号,怎么处理交给一个统一的策略模块,不要让判定逻辑里到处散落 finish()。第二,静默上报 ------把异常先上报到自有通道,带上包名、版本、指纹前后缀这类脱敏后的现场信息,先拿到真实分布再动手,别拍脑袋处置。第三,延迟退出 ------不立刻杀进程,让应用先进入受限模式或延迟到下一次冷启动再退出,留出取证和灰度观察的窗口。第四,分级处置------单信号可疑和多重信号一致,处置力度必须不同。
| 场景 | 判定信号 | 处置等级 | 动作 |
|---|---|---|---|
| 指纹不匹配且 CRC 也不符 | 多信号一致 | 重度 | 静默上报后延迟退出 |
| 仅指纹不匹配 | 单信号 | 中 | 上报并限制核心功能 |
| 仅 CRC 不符 | 单信号且弱信号 | 轻 | 上报并观察 |
| 读不到指纹或签名信息 | 异常 | 中 | 上报并限制,不直接放行 |
把这张策略表翻译成代码,就是「判定已由上游完成,这里只做分级处置」:
kotlin
object FailureHandler {
fun onTampered(context: Context, trusted: Boolean, intact: Boolean) {
// 第一步:先静默上报,保留现场,不打扰用户
SecurityReporter.report(
context,
trusted = trusted,
intact = intact,
fingerprint = SignatureGuard.currentPrefix(context) // 仅上报前后缀,注意脱敏
)
// 第二步:分级处置,不直接崩溃,交由统一策略决定
when {
!trusted && !intact -> degrade(context, Level.BLOCK) // 多信号一致,明确被改
!trusted -> degrade(context, Level.RESTRICT) // 单信号,先限制
else -> degrade(context, Level.MONITOR) // 仅 CRC 异常,先观察
}
}
private fun degrade(context: Context, level: Level) {
// 延迟退出:不在当前帧直接杀进程,避免误杀时表现为闪退,留出灰度与取证窗口
SecurityGate.applyPolicy(context, level)
}
}
再配一张分支流程图,把「读到了没有」和「一致不一致」两条判断线的走向画清楚:
八、这道防线的边界
到此为止,第一道锁已经建好:读签名、算指纹、比对,再加一道 CRC 兜底,失败时静默上报、分级处置、延迟退出。它拦得住什么、拦不住什么,必须一开始就摆在台面上说清楚,否则很容易产生「做完就安全了」的错觉。
一张对照表把边界框出来:
| 攻击者类型 | 典型手段 | 能否拦住 | 原因 |
|---|---|---|---|
| 拿来就改的人 | 反编译后改代码、重打包、重签名 | 拦得住 | 重签名必然改变证书指纹,启动即被识别 |
| 会改校验逻辑的人 | 反编译定位校验函数并改写其返回值 | 拦不住 | 校验逻辑就在应用层,可读、可改 |
| 直接在运行时动手的人 | 运行时注入或调试篡改 | 拦不住 | 应用层没有反调试、反注入能力 |
这张表里第二行就是这层防线的天花板。校验逻辑本身是用应用层可读的形式写成的 ------攻击者反编译之后,完全可以把那个「签名校验」方法改成永远返回通过,你的指纹比对再严谨也白搭,因为判定被执行的结果已经被人换掉了。这也是为什么混淆只能提高门槛却当不了防线:它让函数名难认,但改一个 return 关键字并不需要看懂它。
所以它的作用边界其实非常清晰:挡住「拿来就改」的大多数人,挡不住「会改逻辑」的少数人。 而对绝大多数应用来说,第一类人占了攻击者的绝大多数------把门槛抬到「需要反编译定位校验、再手工 patch」这一层,就已经劝退了大部分低成本的盗版和二开。用最低的实现成本换掉最大比例的攻击,这正是它被称为「第一道锁」的原因。
要突破第二行这个天花板,只有一条路:把真正的判断从应用层挪走,沉到更难下手的地方去。 应用层留下一个空壳门禁,真正的校验编译成机器码放进动态库,攻击者面对的就不再是可读的字节码,而是汇编级的二进制。这是下一篇的主题。
九、小结
- 重签名是二次打包绕不开的环节,因此「运行时校验签名指纹」是成本最低、收益最高的第一道防线,应用层即可实现。
- 比对的对象是签名证书的指纹 ,不是整个签名文件,也不是内容签名值------证书稳定,才能当身份标识;指纹统一用 SHA-256 + 十六进制小写,来源应是 keystore 而非已签名的包。
- v1 逐条目保护、v2 整包保护、v3 加密钥轮换;v1 覆盖不到归档整体,是完整性校验存在的理由,v2/v3 才是真正覆盖 dex 整体的一代。
- 内容完整性校验是补充而非主力:dex 的 CRC 直接取自 zip 中央目录条目,无需自算,但它只有 32 位、且每次打包都要重新回填,只在私钥泄露等极端场景才有额外价值。
- 失败处理要同时满足「读不到不算通过」和「不直接崩溃」------判定与处置分离,静默上报、分级处置、延迟退出,把误杀风险压到最低。
- 应用层校验可被 patch:它挡得住「拿来就改」,挡不住「会改逻辑」;要更进一步,必须把校验下沉到原生层。
系列导航:Android 安全加固系列第 2 篇(共 4 篇)。上一篇《Android 混淆不等于安全:二次打包链路完整走一遍》,下一篇《把校验沉到 Android 原生层:让 patch 下不去手》。
你们现在的包,签名指纹是手填在代码里,还是构建期自动注入的?如果同时跑多渠道重签名、又做过密钥轮换,白名单是怎么维护的------欢迎在评论区聊聊你们踩过的坑。