Android APK签名校验:证书指纹、防二次打包与排错
打包、上传、等待审核,第二天收到一条驳回意见:安装包签名与后台登记的指纹不一致。这种时候最难受的不是被拒,而是你根本不知道是打包环境用错了 key,还是发布链路上有人动过包。我做 SDK 分发的几年里,因为 Android 签名校验这件事,至少被坑过三次:一次是宿主 App 换了签名方式导致接入方的校验全部失效,一次是某条渠道流水线在最后一步重新签名,还有一次是自己写的比对逻辑把证书的整体哈希当成了指纹,结果只有部分机型通过。这篇文章就把 Android 签名校验这件事从头捋一遍——APK 里的签名信息到底存在哪、代码怎么取、取到之后怎么比、比完之后放在哪里才有意义,最后把踩过的坑整理成可以直接对照排查的清单。适合正在写校验代码的应用开发者,也适合需要防止 SDK 被随意二次集成的库作者,基础薄弱的同学可以从第一章顺着往下看,有经验的可以直接跳到第三章和第五章。
1. 先把签名这件事讲透:APK 里到底存着几份"身份证明"
很多人对 Android 签名的理解停留在一句话:用 keystore 签一下,装得上就行。但只要你要写校验代码,这个理解就远远不够。因为签名不是一个开关,而是一套嵌套的证明链,同一个 APK 里可能同时存在三四份不同格式、不同算法、不同覆盖范围的签名信息,而且系统在不同版本上读的是不同的那一份。你写的校验代码如果没搞清楚自己到底在读哪一份,就会出现"我明明改了签名它还说通过"或者"包没动过它却报失败"这两种典型事故。
1.1 一个装好的应用,签名信息藏在哪几个角落
APK 本质是个 ZIP 包,最容易被找到的签名信息在META-INF/目录下:MANIFEST.MF、CERT.SF(文件名可能带前缀)、CERT.RSA(或CERT.DSA、CERT.EC)。这一套就是所谓的 v1 签名(JAR 签名)。打开META-INF看一眼,你就能直观感受到它的结构,很多排查工作到这一步就能定位问题——比如拿到一个包发现META-INF里只有MANIFEST.MF而没有CERT.RSA,那基本可以判定这个包要么签名缺失,要么被某个环节剥离了。
但真正让新手困惑的是:除了META-INF,APK 里还有一份你看不见的签名。它藏在 ZIP 中央目录之前的一个特殊区块里,叫作 APK Signing Block,位置在文件末尾那块 EOCD(End of Central Directory)往前推、中央目录再往前推的夹缝中。用普通的解压工具打开 APK 是看不到它的,因为解压工具只认 ZIP 的条目结构,而这块数据不属于任何条目,它是一段"夹带"在 ZIP 结构缝隙里的独立数据。v2 和 v3 签名都住在这里,未来的签名方案大概率也会继续往这里塞。
这就带来一个非常关键的认知:同一个 APK 可以同时拥有 v1 和 v2 签名,而且两者的证书可以完全不同。技术上你完全可以用 A 证书做 v1、B 证书做 v2,然后打包出一个"能装上但签名混乱"的包。这种情况在正常业务里不该出现,但它是排查问题时必须知道的可能性——如果你只检查了META-INF就下结论说"签名没变",很可能会漏掉签名块里的那一份。
1.2 v1 的三层摘要:为什么改一个字节就会全盘失败
v1 签名的结构值得完整讲一遍,因为它解释了很多人遇到过的一个现象:明明只改了一张图片,为什么整包签名校验就挂了。v1 的流程分三步,每一步都在给上一步的结果"盖章"。
第一步,对 APK 内除META-INF/之外的每一个文件,计算内容摘要(SHA-1 或 SHA-256,取决于签名时的配置),把"文件名 → 摘要"的映射写进MANIFEST.MF。注意这里算的是文件解压后的内容,跟 ZIP 的压缩方式、时间戳这些元数据无关。
第二步,对MANIFEST.MF这个文件本身计算一个整体摘要,写入CERT.SF的SHA-256-Digest-Manifest字段;同时,对MANIFEST.MF里每一个条目的三段内容(Name:行、SHA-256-Digest:行、空行)单独再算一次摘要,写入CERT.SF中对应的SHA-256-Digest字段。这一步是为了防止有人往MANIFEST.MF里偷偷追加条目。
第三步,用私钥对整个CERT.SF文件做签名,把签名结果和证书一起打包成 PKCS#7 结构,存成CERT.RSA。校验时,系统先用CERT.RSA里的证书公钥验证CERT.SF没被动过,再用CERT.SF验证MANIFEST.MF没被动过,最后用MANIFEST.MF验证每个文件的内容没被动过。三层环环相扣,改任何一个字节,链条就在某一层断掉。
这套机制设计的初衷是好的,但它有个天然缺陷:MANIFEST.MF只覆盖文件内容,不覆盖 ZIP 的元数据——文件顺序、压缩方法、额外的 ZIP 注释这些都不在保护范围内。历史上围绕这个缝隙出过不止一次问题,这也是 v2 出现的直接动因。所以现在做校验,如果你的目标只是"确认这个包是我签的",用 v1 检查证书是够的;但如果目标是"确认这个包一个字节都没被改过",必须依赖 v2 及以上。
1.3 v2/v3 的分块摘要:换了一套完全不同的算法
v2 的思路跟 v1 完全不同,它不是逐文件算摘要,而是把整个 APK 当成一个字节流来保护。具体做法是把 APK 分成三段:内容区、中央目录、EOCD;然后把 EOCD 里的"中央目录偏移"字段临时替换成签名块的起始位置(因为原文那个值在插入签名块之后就不准了),接着把这三段拼起来,按 1MB 为单位切成块。每一块算摘要时,先写一个字节0xa5、再写 4 字节的块长度(小端)、再写块数据,然后做 SHA-256 得到该块的摘要。最后把所有这些块摘要拼起来,前面加一个字节0x5a、4 字节的块数量,再算一次 SHA-256,得到顶层摘要。
顶层摘要被写进签名块的 signed data 里,然后用私钥对 signed data 签名。校验时重新算一遍,跟签名块里的值对比。因为是按字节流整体保护的,ZIP 元数据被改、文件被重新排列、甚至只是压缩级别变了,摘要都会对不上。这就是为什么用 v2 签名的包,改动之后几乎不可能"悄悄"通过校验——你动了任何东西都会被发现。
v3 在 v2 的基础上增加了两个东西:一是签名块里记录了minSdkVersion和maxSdkVersion,允许同一个 APK 针对不同 Android 版本使用不同的签名(配合平台版本的兼容策略);二是引入了密钥轮换的证明链(proof-of-rotation),允许应用在不改变包名的情况下更换签名密钥,新证书里携带一条由旧密钥签名的证明,系统可以顺着链验证"这个新证书确实是原来那个应用的后继者"。v4 则把签名信息挪到了 APK 外部的.idsig文件里,主要服务于增量安装场景。
为了方便对照,我把四代方案的关键差异整理成一张表:
| 方案 | 存放位置 | 保护范围 | 最低支持版本 | 轮换支持 |
|---|---|---|---|---|
| v1 | META-INF/下三个文件 | 逐个文件内容 | 全版本 | 不支持 |
| v2 | APK Signing Block(ID0x7109871a) | 整个 APK 字节流 | Android 7.0 | 不支持 |
| v3 | APK Signing Block(ID0xf05368c0) | 整个 APK 字节流 | Android 9.0 | 支持 |
| v4 | APK 同目录的.idsig文件 | 整个 APK 字节流 | Android 11 | 支持 |
系统的验证顺序是这样的:Android 7.0 及以上,如果 APK 带 v2 签名块,就只按 v2 验证,不会退回 v1;Android 9.0 及以上如果有 v3,优先按 v3 验证;Android 11 起增量安装会用到 v4。这意味着一个同时带 v1 和 v2 的包,在 7.0 以上的设备上实际生效的是 v2。你在代码里读到的证书,最终来自哪一层,取决于系统的实现,这也是后面讲兼容性问题的伏笔。
2. 取证书的四种姿势:运行态、安装包、构建期、服务端
搞清楚签名存在哪之后,下一个问题是:校验代码从哪里拿这个值。这个问题看似简单,实际上不同场景下的取法差异极大,而且每一路都有自己专属的坑。我按使用频率从高到低讲四种:应用自己运行时取、读取一个未安装的 APK 文件、构建阶段在流水线上验、服务端独立核验。
2.1 运行态取值:GET_SIGNATURES 已经过时,但你还得兼容它
应用自己校验自己的签名,最直接的入口是PackageManager.getPackageInfo。老写法传GET_SIGNATURES,拿到一个Signature[];Android 9.0 之后这个 flag 被标记废弃,改用GET_SIGNING_CERTIFICATES,返回的是SigningInfo对象。两者拿到的证书内容在正常情况下是一致的,但SigningInfo多了轮换相关的信息,这是新代码应该用的接口。
实际项目里我的写法是分版本处理,封装成一个统一函数:
private fun sha256Hex(bytes: ByteArray): String = MessageDigest.getInstance("SHA-256") .digest(bytes) .joinToString("") { "%02x".format(it) } fun currentSignerSha256(context: Context): List<String> { val pm = context.packageManager val pkg = context.packageName return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES) val signers = info.signingInfo?.apkContentsSigners ?: return emptyList() signers.map { sha256Hex(it.toByteArray()) } } else { @Suppress("DEPRECATION") val info = pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES) info.signatures?.map { sha256Hex(it.toByteArray()) } ?: emptyList() } } catch (e: Exception) { emptyList() } }这里第一个要点是返回列表而不是单个字符串。绝大多数应用的签名者只有一个,但多签名确实存在(早期一些应用为了平滑过渡会同时用新旧两个证书签),如果你的代码写死了只取signatures[0],遇到多签名包就会误判——而这在排查时非常难定位,因为日志里打印出来的那个指纹看起来"就是对的"。
第二个要点是signingInfo可能为 null。这不是理论上的情况,在一些定制 ROM 上、在应用被特殊处理过的情况下,getPackageInfo返回的signingInfo就是空的。所有取值路径都必须在外面兜一层 try-catch 加空判断,出错时按"校验失败"处理,绝不能按"校验通过"处理,这个默认方向不能错。
第三个要点是sha256Hex(it.toByteArray())里的it.toByteArray()。Signature.toByteArray()返回的是 X.509 证书的 DER 编码,对这串字节做 SHA-256,得到的十六进制串跟apksigner verify --print-certs打印出来的 "certificate SHA-256 digest" 是同一个值。这一点极其重要:很多人拿Signature.hashCode()去比,或者对Signature.toString()的字符串做哈希,这些都是错的,换个签名算法或者换个解析实现结果就变了。必须落在 DER 字节上。
还有一个隐蔽的坑:getPackageInfo取的是 PackageManager 缓存里的信息,理论上在应用运行期间不会变,但极少数 ROM 上存在Signature对象被替换成占位数据的情况。所以更稳的做法不是判断"签名不为空",而是直接把拿到的指纹跟期望值比,比不中就走失败分支。判断非空是最容易失效的校验方式,因为它连"签名是不是真的"都没验证。
2.2 读未安装的 APK:为什么这条路最容易翻车
很多业务需要校验的不是当前安装的这个应用,而是一个磁盘上的 APK 文件——比如应用市场类产品要扫描本机下载的安装包、比如做包管理或者安全检测的工具、比如补丁下发前要验证补丁包的来源。这时候用getPackageArchiveInfo:
fun apkFileSignerSha256(context: Context, apkPath: String): List<String> { val pm = context.packageManager val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { PackageManager.GET_SIGNING_CERTIFICATES } else { @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES } val info = pm.getPackageArchiveInfo(apkPath, flags) ?: return emptyList() return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { info.signingInfo?.apkContentsSigners?.map { sha256Hex(it.toByteArray()) } ?: emptyList() } else { @Suppress("DEPRECATION") info.signatures?.map { sha256Hex(it.toByteArray()) } ?: emptyList() } }这段代码在多数情况下能跑通,但它有三个明确的失败条件,必须提前在脑子里过一遍。第一,Android 7.0 以下的系统解析器只认 v1 签名,如果你给它的 APK 是"只有 v2 签名"的包,会拿不到签名——虽然这种包在那些设备上本来也装不上,但做包扫描类工具时这会表现为"这个包没有签名",用户看到的结果就是错的。第二,Android 11 之后对外部存储的访问收紧,apkPath不可读时会直接返回 null,看起来像是包损坏。第三,传入的 flags 如果没有包含签名相关的位,返回的info里签名就是空数组,这种低级错误在 code review 里极其常见。
真正可靠的方案是自己解析 ZIP 结构去读签名块。这不是炫技,而是当你需要校验"任意一个 APK 文件"并且要求结果可复现时,唯一不依赖系统实现细节的做法。核心就是三步:从文件尾部找到 EOCD,读出中央目录偏移量,然后从中央目录偏移往前 24 字节处读出签名块的 footer,验证魔数是不是APK Sig Block 42,再往前定位整个块的起点,遍历 ID-value 对。
private static final byte[] SIG_BLOCK_MAGIC = "APK Sig Block 42".getBytes(StandardCharsets.US_ASCII); static byte[] readV2Certs(RandomAccessFile raf) throws IOException { long fileLen = raf.length(); int tailLen = (int) Math.min(fileLen, 0xFFFF + 22); byte[] tail = new byte[tailLen]; raf.seek(fileLen - tailLen); raf.readFully(tail); int eocd = -1; for (int i = tailLen - 22; i >= 0; i--) { if (tail[i] == 0x50 && tail[i + 1] == 0x4b && tail[i + 2] == 0x05 && tail[i + 3] == 0x06) { eocd = i; break; } } if (eocd < 0) return null; ByteBuffer eocdBuf = ByteBuffer.wrap(tail, eocd, 22).order(ByteOrder.LITTLE_ENDIAN); eocdBuf.getInt(); eocdBuf.getShort(); eocdBuf.getShort(); eocdBuf.getShort(); eocdBuf.getShort(); eocdBuf.getInt(); int cdOffset = eocdBuf.getInt(); raf.seek(cdOffset - 24); byte[] footer = new byte[24]; raf.readFully(footer); long blockSize = ByteBuffer.wrap(footer, 0, 8).order(ByteOrder.LITTLE_ENDIAN).getLong(); byte[] magic = Arrays.copyOfRange(footer, 8, 24); if (!Arrays.equals(magic, SIG_BLOCK_MAGIC)) return null; long blockStart = cdOffset - blockSize - 8; int payloadLen = (int) blockSize - 8; byte[] payload = new byte[payloadLen]; raf.seek(blockStart + 8); raf.readFully(payload); ByteBuffer buf = ByteBuffer.wrap(payload).order(ByteOrder.LITTLE_ENDIAN); while (buf.remaining() >= 12) { long pairLen = buf.getLong(); int id = buf.getInt(); if (pairLen < 4 || pairLen > buf.remaining() + 4L) break; if (id == 0x7109871a) { byte[] value = new byte[(int) pairLen - 4]; buf.get(value); return value; } buf.position((int) (buf.position() + pairLen - 4)); } return null; }返回的value是 v2 签名块的内容,它是一层套一层的长度前缀结构:先是一个 4 字节的 signers 序列长度,然后每个 signer 里依次是 signed data、signatures、公钥,而 signed data 里又依次是 digests、certificates、additional attributes。要取出第一张证书,就在 signed data 里跳过 digests,读 certificates 的第一段。这个解析过程不建议手写完整版,因为一旦格式理解偏了就会出现"有时候能读出来有时候读不出来"的诡异现象。实际项目里我一般只用这段代码做"这个包有没有 v2 签名"的快速判断,真要拿证书内容还是优先走系统 API。
2.3 构建期与服务端核验:最省事的交叉验证
上面两条都是运行时的路径,但在工程实践里,我强烈建议在构建阶段就做一次独立核验。理由很简单:运行时的校验逻辑本身可能被改,而构建流水线的日志和产物是要归档的。每次发版前跑一遍apksigner verify --print-certs,把输出贴进发版记录,事后追查时这是最有力的证据。
apksigner verify --print-certs --verbose app-release.apk这条命令会打印出每个签名者的证书 DN、证书 SHA-256 和 SHA-1 指纹,以及 v1/v2/v3 各自的验证结果。看到Verified using v2 scheme (APK Signature Scheme v2): true就说明 v2 有效。老一点的包如果只有 v1,就用keytool -printcert -jarfile app.apk,它能直接读出META-INF里那张证书的信息。如果想看逐条目的签名状态,jarsigner -verify -verbose -certs app.apk会把每个文件是否通过校验列出来,排查"某个文件被改动"这类问题时特别好用。
服务端的核验是另一条独立的线。思路是把"签名指纹"当成应用的一个标识属性,在客户端首次启动或者登录时上报,服务端跟白名单比对,通过之后签发一个有时效的凭证,后续敏感接口都要求携带这个凭证。这样做的好处是把校验从"可以被逆向的客户端代码"搬到了"攻击者够不着的服务端"。这一步很重要,因为它把签名校验从一次性判断变成了会话级的持续约束,后面第四章还会展开讲。
3. 校验代码怎么写才经得起推敲
到这一步,你手上已经有一个或者多个证书的 SHA-256 指纹了。接下来的问题是怎么比、期望值放哪、比不中之后做什么。这三点决定了你的校验代码是"意思意思"还是"真的有用"。我见过太多项目的校验函数是这样写的:取指纹、跟常量比、不等就return false,调用方拿到 false 之后弹个 Toast 继续跑。这种代码除了自我安慰没有别的作用。
3.1 比什么:证书 DER 的 SHA-256,别自作聪明
比对的对象必须是证书 DER 字节的 SHA-256 十六进制字符串,这一点没有商量的余地。为什么强调这个?因为我见过至少三种"聪明"的变体,每一种都有问题。第一种是比Signature.hashCode(),这个值在不同 Android 版本、不同 ROM 上完全可能不同,而且它只是对象的哈希,跟证书内容没有稳定的映射关系。第二种是比签名的 Base64 字符串,这个在大多数情况下能用,但一旦涉及空白字符、换行处理就会出现莫名其妙的比对失败。第三种是拿整个 APK 文件的哈希当指纹,这就更离谱了——APK 内容一变哈希就变,多语言分包、资源对齐调整都会让它失效。
正确的做法就是取 DER 字节、算 SHA-256、转成小写十六进制、定长比较。比较时用MessageDigest.isEqual而不是String.equals,前者对相同长度的输入是常量时间比较,能规避掉基于字符串比较时间的旁路分析。虽然在实际业务里这个风险很小,但既然是安全相关的代码,顺手写上不亏:
fun matchesSigner(context: Context, expected: String): Boolean { val actual = currentSignerSha256(context) if (actual.isEmpty()) return false val expectedBytes = expected.lowercase().toByteArray(Charsets.US_ASCII) return actual.any { fp -> MessageDigest.isEqual(fp.lowercase().toByteArray(Charsets.US_ASCII), expectedBytes) } }这里any { }的语义要注意:只要有一个签名者的指纹命中就返回 true。这在多签名包的场景下是合理的——你认可其中一张证书的身份就行。但如果你的业务要求"必须是唯一的那张证书",就要改成先判断签名者数量,数量不等于预期直接判失败。这两种策略没有对错,取决于你的威胁模型,但一定要想清楚再写,别稀里糊涂地用any。
顺带说一句signingCertificateHistory。在 v3 轮换机制下,apkContentsSigners返回的是当前生效的新证书,而signingCertificateHistory返回的是包含历史证书的数组。如果你的应用经历过密钥轮换,用户在升级过程中可能会横跨新旧两个版本,此时如果只认新证书,老版本用户会全部校验失败。稳妥的做法是:主比对走apkContentsSigners,如果失败再退一步检查signingCertificateHistory里是否包含受信任的历史证书,同时结合应用版本号做限制,避免老证书被无限期信任。
3.2 期望值放哪:让指纹不再是躺在代码里的明文常量
常量放在 Java 层是最容易被改的形态。任何一个人拿到 APK,反编译之后全文搜索那个 64 位十六进制串,几分钟就能定位并替换。所以指纹的存放位置,直接决定了你的校验能挡多久。
第一层做法是把指纹从代码常量挪到 native 层,通过 JNI 返回。这提高了门槛,但没有本质区别——native 代码同样可以被分析。第二层做法是把指纹拆成几段,分别放在不同位置(资源文件、assets、native、BuildConfig),运行时再拼起来,这样静态搜索搜不到完整的 64 位串。第三层做法是不存指纹本身,而是存一个能推出指纹的量,比如把指纹跟包名、版本号做一次哈希,运行时重新算出来比对。这三种可以叠加使用。
不过要坦白说,这些都是提高成本的措施,不是绝对防护。真正的判断标准是:攻击者破解你的成本,是否高于他破解之后能获得的收益。对于一般的防篡改场景,把指纹挪到 native 层加拆分存储,已经能过滤掉绝大部分脚本化的改包行为。
还有一点值得注意:不要把期望指纹写进BuildConfig然后在日志里打印出来。我见过有项目在 Debug 分支把校验结果和期望值一起打到 Logcat,结果线上包也带着这段代码。校验相关的日志要么不打,要么只在BuildConfig.DEBUG为 true 时打,并且发布构建里用代码裁剪把这个分支删掉。
3.3 校验失败之后:别只会 return false
这是整个签名校验里最容易被忽视、但价值最高的部分。绝大部分项目的校验失败处理就是:返回 false,调用方弹个提示,用户点确定,应用继续跑。这种情况下校验代码的存在感约等于零——攻击者只需要改一个跳转指令就绕过去了。
我的经验是,校验结果应该"参与到业务里去",而不是作为一个独立的布尔开关。举几个实际可行的思路。一是让校验结果参与某个关键数据的解密密钥派生:校验通过时密钥是对的,数据能正常解开;校验失败时密钥就错一位,解出来的数据是垃圾,界面表现为各种诡异的异常,而不是一个明确的"校验失败"提示。二是延迟触发:不在校验的当下就报错,而是等到某个业务节点(比如发起支付、上传数据)才表现出异常,让攻击者很难定位是哪一次判断导致的。三是多点分散:不要只在一个地方判断,也不要用同一个函数名,把判断拆到三四个不同的调用点,让"改一处"变成"改十处"。
需要强调的是,这些手段的目的是提高篡改成本,而不是制造"永远不会被破解"的幻觉。任何纯客户端的校验最终都是可以被绕过的,所以最后一层永远是服务端。客户端做的事是把攻击门槛抬高到"不值得",服务端做的事才是真正的兜底。
4. 单点校验为什么形同虚设:多层防御的落点
写到这里必须正面回答一个问题:签名校验到底能防住什么,防不住什么。很多团队在这件事上的预期是错位的,以为加了签名校验就安全了,结果被改包之后发现校验代码根本没执行,或者被执行了但结果被忽略。搞清楚边界,比多写十行校验代码更有价值。
4.1 二次打包的链路长什么样,哪一步动了签名
一个典型的改包流程是这样的:拿到原始 APK,用反编译工具拆出资源和 smali 代码,往里塞入自己的代码或者删掉某些逻辑,然后重新打包,最后用自己的证书重新签名。注意最后这一步是必然的,因为原来的私钥在开发者手里,改包的人不可能拿到。所以从签名指纹的角度看,改包的痕迹是极其明显的——证书完全变了。
但这只是最粗暴的一种。还有几类情况更麻烦:一是不改包体,而是通过动态加载的方式在运行时加载外部代码,原 APK 的签名完全没变,但实际执行的逻辑已经不是你的了。二是使用整体加固或者壳方案,壳会对原始 APK 做加密,运行时解密加载,这种情况下你读到的签名可能来自壳而不是原始包,需要特别处理。三是直接修改运行环境,让PackageManager返回伪造的签名信息,这种情况下你的校验代码拿到的数据源本身就是假的。
第三类情况说明了一个硬道理:签名校验只能证明"磁盘上的这个 APK 文件是原始签名的",不能证明"运行时内存里的代码没被动过"。这是它的能力边界,必须清楚。所以签名校验的定位是"防二次打包的第一道筛子",用来过滤掉低成本的改包行为,而不是"终极防护"。真正对抗高级篡改,需要结合完整性校验、代码混淆、关键逻辑下沉到 native、以及服务端的异常检测,多管齐下。
理解了这个边界,你就不会在"为什么我的校验被绕过了"这个问题上纠结太久——它本来就不是用来对抗那类攻击的。
4.2 多点埋设与交叉校验:把单点判断变成网状结构
单点校验的问题不只是"被绕过一次就全盘失守",更麻烦的是"绕过了你也不知道"。所以我在项目里的做法通常是三个层次同时布置。
第一个层次是启动时的基础校验,放在Application.onCreate或者首个 Activity 的onCreate里,检查当前签名是否在允许列表内。这个点的作用是快速失败,把明显的改包挡在门外。第二个层次是业务入口的校验,比如登录、支付、数据上报这些关键路径开始之前,再校验一次,并且用不同的实现方式——第一个点用PackageManager取,第二个点用 native 层通过getppid之外的其他途径取,或者用保存下来的指纹快照做交叉比对。第三个层次是服务端的周期性校验,客户端把当前签名指纹随请求上报,服务端跟白名单比对,不一致就拒绝服务或者降级。
这三个层次之间还要做交叉校验。所谓交叉校验,就是让 A 点的结果被 B 点使用,且 B 点用不同的方式验证 A 的结果是否可信。举个具体的做法:启动时算出的指纹不要直接用于判断,而是存进一个只有 native 层能读写的变量,业务层需要判断时通过 JNI 去问 native "签名对不对",而不是自己再取一次。这样一来,攻击者想绕过,就必须同时处理 Java 层和 native 层的逻辑,成本明显上升。
再补充一个 SDK 场景下的实际用法。做 SDK 分发的团队经常需要限制"这个 SDK 只能被授权的宿主应用集成",这时候可以用PackageManager.checkSignatures(pkg1, pkg2)判断两个包是否使用相同签名:
fun sameSigner(context: Context, otherPkg: String): Boolean { return try { context.packageManager.checkSignatures(context.packageName, otherPkg) == PackageManager.SIGNATURE_MATCH } catch (e: Exception) { false } }这个 API 比较的是两个包的签名证书集合,返回SIGNATURE_MATCH表示完全一致,SIGNATURE_NO_MATCH表示都有签名但不匹配,SIGNATURE_UNKNOWN_PACKAGE表示包不存在(这在 Android 11 的包可见性限制下会经常遇到,需要在 manifest 里声明queries)。用它来判断"宿主是不是我们自己家的应用"很方便,但要注意它只能判断"是否相同",不能判断"是否等于某个特定值",所以通常还要配合一次指纹比对。另外,跨进程、跨用户(工作资料)场景下这个判断的行为会有差异,多用户设备上要单独测试。
4.3 服务端票据绑定:把签名变成一次可验证的会话凭证
这一层是我认为最有价值的,也是最容易被跳过的。思路不复杂:客户端在首次启动或者登录时,把签名指纹(或者指纹的哈希)作为参数上报给服务端;服务端跟后台配置的合法指纹比对,一致就签发一个带时效的 token;后续所有敏感接口都要求带上这个 token,服务端在验 token 的时候顺带校验里面的指纹跟当前登记的是否一致。
这个设计的巧妙之处在于,它把"客户端可以改的判断"变成了"服务端不可改的约束"。攻击者即使把客户端的校验代码全部删掉,他拿到的 token 里绑定的仍然是原始指纹——除非他能伪造一个合法的 token,而这需要服务端的密钥。同时,这个机制还带来了一个额外的好处:你可以通过服务端的异常统计发现"有一批请求的指纹不对",从而感知到改包行为的存在,而不是被动等待。
实施时有几个细节要注意。一是上报的指纹不要用明文,做一次带盐的哈希,防止被中间环节截获后直接复用。二是 token 要有明确的有效期,不要做成永久有效。三是服务端要有灰度能力,万一某次发版换了签名而白名单没更新,能快速放开而不是全量故障——这个坑我下面还会细说。四是降级策略要提前设计好:指纹不匹配时是直接拒绝,还是只限制部分功能,取决于业务对可用性的要求。
5. 实战排错:签名校验失效的典型现场
前面讲的都是"应该怎么做",这一章讲"实际会出什么问题"。我把这些年遇到过的、以及同行反馈过的失效场景做了归类,按排查的先后顺序整理出来,遇到问题时可以顺着往下走。
5.1 排查顺序:先确认签名本身,再看比对逻辑
遇到"校验失败"的反馈时,最容易犯的错误是直接去读校验代码。正确的顺序应该是从外往里:先确认这个包的签名到底是什么,再确认代码取的签名对不对,最后才看比对逻辑。
第一步,用apksigner verify --print-certs打印出这个包实际的证书指纹,跟你期望的值对一遍。这一步能解决掉大部分问题——很多时候是指纹本身就变了,比如发布流水线用错了 keystore、某个渠道包被重新签过、或者打包脚本在最后一步做了对齐处理导致重新签名。这一步不需要看任何代码,纯粹是数据核对。
第二步,在设备上把currentSignerSha256的返回值打出来,跟第一步的值对比。如果两者不一致,问题就出在取值环节,常见原因包括:系统版本导致的 API 差异、多签名包只取了第一个、signingInfo为空时的兜底逻辑走错了分支。如果两者一致但校验还是失败,问题就在比对环节,常见原因是大小写没统一、多了空格、期望值里混入了不可见字符。
第三步,如果前两步都正常,但业务上还是表现为"校验失败",那就要怀疑是不是校验逻辑压根没被执行到。这种情况在混淆之后特别常见:某个类被裁掉了、某个方法被内联了、某个分支被优化掉了。可以临时加一行日志确认执行路径,但记得发布前删掉。
| 现象 | 最可能的原因 | 快速验证方式 |
|---|---|---|
| 全部设备校验失败 | 打包用错证书 / 期望值没更新 | apksigner verify --print-certs对比后台登记值 |
| 只有部分机型失败 | ROM 对GET_SIGNATURES的返回被替换 | 换成GET_SIGNING_CERTIFICATES再测 |
| 某些渠道包失败 | 渠道流水线重新签名 | 单独验证该渠道包 |
| 升级后老用户失败 | 密钥轮换后只认了新证书 | 检查signingCertificateHistory |
| 分流测试包失败 | 测试包用 debug 证书签的 | 构建脚本里检查 signingConfig |
| 加固包失败 | 加固后重新签名 | 加固流程的签名配置 |
| 校验代码不执行 | 混淆裁剪 / 分支被优化 | 加临时日志确认调用链 |
5.2 debug 与 release、密钥轮换、分包带来的连锁反应
debug 和 release 签名不一致是最经典的坑。开发期用 debug keystore 调试,签名指纹跟线上完全不一样,如果期望值只配了一份,那本地调试就永远失败。解决办法是在构建配置里按 buildType 注入不同的期望值,而不是在代码里写死一份。同时要确保发布构建里不会把 debug 的那份配置打进去——我见过有项目把三份指纹全写进代码,用版本号判断,结果判断逻辑写反了。
密钥轮换是这两年越来越常见的问题。很多团队早期用的是一个自签证书,后来想换,于是启用了 v3 的轮换机制。轮换之后新版本用的是新证书,但老版本用户还在用旧证书,如果服务端的白名单没同步更新,就会出现"老用户能登录新用户不能"或者反过来的诡异现象。我的建议是把新旧证书都放进白名单,并且记录每个证书对应的版本范围,服务端根据客户端上报的版本号选择对应的校验策略,等老版本用户占比降到足够低再下线旧证书。
分包与 App Bundle是另一个高发区。用应用商店的托管签名服务之后,商店会用它的密钥对你的 bundle 重新签名,得到的分发版证书跟你本地的上传证书完全不同。这时候如果你在本地的构建产物上取了指纹