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-INFAPK 里还有一份你看不见的签名。它藏在 ZIP 中央目录之前的一个特殊区块里叫作 APK Signing Block位置在文件末尾那块 EOCDEnd 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文件里主要服务于增量安装场景。为了方便对照我把四代方案的关键差异整理成一张表方案存放位置保护范围最低支持版本轮换支持v1META-INF/下三个文件逐个文件内容全版本不支持v2APK Signing BlockID0x7109871a整个 APK 字节流Android 7.0不支持v3APK Signing BlockID0xf05368c0整个 APK 字节流Android 9.0支持v4APK 同目录的.idsig文件整个 APK 字节流Android 11支持系统的验证顺序是这样的Android 7.0 及以上如果 APK 带 v2 签名块就只按 v2 验证不会退回 v1Android 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): ListString { 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 文件——比如应用市场类产品要扫描本机下载的安装包、比如做包管理或者安全检测的工具、比如补丁下发前要验证补丁包的来源。这时候用getPackageArchiveInfofun apkFileSignerSha256(context: Context, apkPath: String): ListString { 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 重新签名得到的分发版证书跟你本地的上传证书完全不同。这时候如果你在本地的构建产物上取了指纹