首页 / 资讯中心 / 文章详情

Android签名校验全解析:v1-v4、证书指纹与防重签名实践

Android签名校验全解析:v1-v4、证书指纹与防重签名实践 ★ FEATURED ARTICLE
做 Android 端开发的人几乎都碰过一次“签名校验”相关的崩溃或误判。有的是上线后一批渠道包突然提示签名不一致有的是换了构建机之后本地自校验全部失败还有的是接入了应用商店的分发重签名客户端里写死的指纹一上线就废了。Android 签名校验这件事代码本身只有几十行但要写得稳、写得不容易被绕、写得不给自己挖坑背后牵涉的是 v1 到 v4 四套签名方案、PackageManager 在不同系统版本上的返回差异、证书轮换、多渠道重签名这一整套知识。这篇内容就围绕“Android 签名校验”和“Android 签名验证解析”这两个关键词把我这些年踩过的坑、验证过的写法、以及从签名块结构层面能讲清楚的部分一次讲透。适合正在做防二次打包、做客户端完整性自检、做接口防重放的同学参考也适合只想搞明白“为什么我 debug 包能过、release 包过不了”的新手照着排查。1. Android 签名校验到底在验什么很多人第一次接触签名脑子里会默认它跟 HTTPS 一样是“加密”。这个误解会直接导致后面的方案设计跑偏。搞清楚签名在校验什么才谈得上写得对。1.1 签名不是加密是封条加身份证Android 的 APK 签名本质上做两件事一是证明“这个包是谁打的”二是证明“这个包在打包之后没被人动过”。它不负责保密APK 里的 dex、资源、so 全都是明文任何人都能解开看。所以签名校验的能力边界非常清晰——它能拦住“把别人的包改一改重新签名发出去”这类操作但拦不住“把自己的设备 root 之后在运行时 hook 掉你的校验函数”。我用一个生活化的类比签名就像快递箱上的封条和寄件人印章。封条完整说明路上没被拆过印章说明是谁寄的。但如果你自己在家里就决定不检查封条那它一点用没有。对应到代码上就是——签名校验的价值取决于你把校验结果用在什么地方以及攻击者绕过它需要付出多少成本。这也解释了为什么单纯的“Java 层读一次签名比对字符串”几乎等于没做防护。攻击者反编译你的 APK把判断语句改掉或者直接 hook 掉那个方法返回 true成本也就十分钟。真正有意义的做法是分层Java 层做基础校验拦小打小闹Native 层做二次校验提高逆向成本服务端做最终裁决保证业务安全。1.2 三个必须校验的典型场景第一个场景是客户端完整性自检。某些金融类、工具类应用会在启动时校验自身签名一旦发现被重打包就直接退出或者进入受限模式。这类应用通常还配合 dex 完整性校验、so 校验一起做。第二个场景是防止多渠道包被篡改。做过分发的人都知道渠道包、马甲包、定制包经常会被交给第三方处理中间环节有人塞广告 SDK、塞统计代码甚至塞后门。这时候用签名比对可以快速发现“这个包不是我们签的”。第三个场景是前后端联动校验。客户端把自身签名摘要作为参数传给服务端服务端维护一份白名单不匹配就拒绝关键接口。这个方案的好处是绕过成本被抬高了——你光改客户端没用服务端那一关过不去。但要注意这个摘要本身是可伪造的任何人拿到你的 APK 都能算出同样的值所以它只能作为一种“提高门槛”的手段不能当作身份凭证。我在实际项目里见过一个反例某团队把签名摘要当成用户身份标识传给后端结果被人把 APK 解包后用同样的摘要伪造请求绕过了所有风控。签名摘要的定位应该是“包的标识”不是“人的标识”这两者千万不要混。1.3 v1 到 v4 四套方案的选择逻辑Android 的签名方案是一路演进过来的每一代解决的痛点都不一样方案引入版本校验范围主要解决的问题v1很早逐个文件条目基础完整性兼容老系统v2Android 7.0整个 APK 字节流校验快、防 zip 条目增删v3Android 9.0同 v2 加密钥轮换信息支持换签名密钥v4Android 11.0配合 idsig 文件增量安装校验选择逻辑很简单用 apksigner 默认配置同时开启 v1 和 v2目标 SDK 高的再加 v3。不要只签 v2 就以为万事大吉——虽然新系统都支持但一些定制 ROM 和老设备在安装和校验路径上行为不一致双签是最稳的。这里有个隐藏的坑必须提v2 签名覆盖了整个 APK 的字节流除签名块本身而 v1 是逐条目签名的。这意味着如果有人往 APK 里塞了一个 zip 条目v1 会因为新条目没有在 MANIFEST.MF 里而失败v2 会直接因为整体摘要变化而失败。从防篡改强度看 v2 更强但从“我能不能自己解析出来校验”的角度看v1 反而更容易手写。2. 拆开签名块v1 到 v4 的实现差异想写好校验代码得先知道系统在验什么。这一节不讲 API只讲结构。理解了结构后面遇到“为什么这个包在某个系统上过了、在另一个系统上没过”你自己就能推出来。2.1 v1 签名三层文件链的信任传递v1 也就是 JAR 签名走的是 Java 世界里那套jarsigner的机制。打开一个签过 v1 的 APKMETA-INF/目录下会有三个关键文件MANIFEST.MF记录 APK 内每一个文件条目的名字和它的 SHA-256或 SHA-1摘要Base64 编码。CERT.SF记录两样东西——MANIFEST.MF整个文件的摘要以及MANIFEST.MF中每个条目那一段文本的摘要。后者是为了防止有人替换 MANIFEST.MF 里的某一条。CERT.RSA或.DSA、.EC一个 PKCS#7 签名块里面装着签名值和对签名者证书的引用。校验链条是这样的先用 CERT.RSA 里的公钥验证 CERT.SF 的签名确认 CERT.SF 没被改再用 CERT.SF 里的摘要验证 MANIFEST.MF 没被改最后用 MANIFEST.MF 里的摘要验证 APK 里每个文件没被改。这是典型的链式信任传递任何一环断裂都会导致校验失败。这个结构带来的一个实际影响是v1 签名只管它记录过的文件没记录的文件它不管。安卓系统在安装时会对 APK 里所有文件做一次“有没有被 MANIFEST.MF 覆盖”的检查但如果你自己写代码只解析 MANIFEST.MF 比对就会漏掉“新增了未记录文件”这种情况。这也是为什么我不推荐手写 v1 校验——细节太多很容易漏。2.2 v2/v3APK Signing Block 与分块摘要v2 的思路完全不同。它在 ZIP 的中央目录之前插入了一个叫APK Signing Block的区域里面是一串“ID-值”对ID 为0x7109871a的那一段就是 v2 签名数据0xf05368c0是 v3。签名数据里记录了签名者证书、签名算法、以及一个叫digest的东西。这个 digest 的计算方式是整个方案的核心把 APK 分成三段签名块之前的内容、中央目录、以及把 EOCD中央目录结束记录里“中央目录偏移”字段替换成签名块偏移后的 EOCD。每段按 1MB 切块每块单独算一次摘要用 SHA-256 或 SHA-512。把每块的摘要再拼起来前面加一个“块数”前缀整体再算一次摘要得到顶层 digest。对顶层 digest 做签名。这个设计的巧妙之处在于它把“分块”和“整体”结合了既保证了大文件也能快速校验又保证了任何一块被改都能被发现。同时 EOCD 里那个被替换的偏移字段保证了签名块的位置本身也被覆盖防止有人把整个签名块挪走。v3 在 v2 的基础上增加了 key rotation 的概念。签名数据里多了minSdkVersion、maxSdkVersion字段系统会根据设备版本选择用哪一份签名来校验。这也解释了一个常见的困惑同一个 APK 里可以有多份签名数据系统只挑适合自己的那份用。所以你在代码里拿到的签名可能跟你用apksigner --print-certs看到的不是同一个——这在密钥轮换的场景下会真实发生。2.3 v3 密钥轮换带来的校验陷阱密钥轮换这个东西听名字像是给大厂准备的实际上小项目也可能踩到。它的机制是应用在新版本里用新密钥签名同时在签名块里附带一份“proof-of-rotation”证明用旧密钥给新密钥背书形成一条 lineage 链。系统用旧密钥验证过这条链之后就允许新版本覆盖安装。问题出在客户端读取签名的时候。API 28 引入了SigningInfo它有两个不同的方法getApkContentsSigners()返回的是当前这个 APK 实际的签名者。getSigningCertificateHistory()返回的是这条 lineage 上所有的历史证书最新的在前。如果你用后者做白名单比对那么在轮换之后新包的返回值里会同时包含新旧证书你的白名单如果只配了旧的比对可能还能过因为里面有旧证书但一旦你把旧的从白名单里删掉就出问题。反过来如果你用前者轮换后返回值变成新证书白名单没更新就会直接校验失败。我的建议是白名单里同时保留新旧指纹比对逻辑用“任意一个匹配即通过”并且在轮换完成后至少保留两个大版本再清理旧指纹。这样既不会因为轮换导致线上大面积失败也不会让旧指纹无限期留在代码里。2.4 v4 与增量安装的关系v4 是 Android 11 引入的它把签名信息放到了 APK 旁边的一个.idsig文件里主要目的是支持增量安装——设备只需要下载变化的部分然后用 idsig 校验拼装后的结果。对客户端代码来说v4 基本是透明的PackageManager返回的签名和 v2/v3 一致。但有一个场景要注意通过增量安装方式装上的应用.idsig文件在安装完成后会被删除所以你不要指望在运行时去读这个文件做校验。另外某些设备在安装后sourceDir指向的 APK 路径可能和预期不同比如指向一个拼接后的临时文件如果你在 Native 层用“读 sourceDir 再自己解析 zip”的方式校验可能会失败。这也是我后面推荐用PackageManager而不是自己解析 zip 的原因之一。3. 代码落地Java 层签名校验的三种写法前面讲原理这一节开始写代码。我把实际项目里用过的写法按“能用、好用、抗造”三个层次摆出来你可以根据自己的安全需求选择。3.1 兼容 API 28 前后的取签名代码先看最核心的取签名部分。这里的分支是绕不开的因为GET_SIGNING_CERTIFICATES是 API 28 才有的低于这个版本只能用GET_SIGNATURES。public final class SignatureReader { public static ListString readSha256Digests(Context ctx) { ListString result new ArrayList(4); try { String pkg ctx.getPackageName(); PackageManager pm ctx.getPackageManager(); Signature[] sigs; if (Build.VERSION.SDK_INT Build.VERSION_CODES.P) { PackageInfo pi pm.getPackageInfo(pkg, PackageManager.GET_SIGNING_CERTIFICATES); SigningInfo info pi.signingInfo; if (info null) { return result; } sigs info.hasMultipleSigners() ? info.getApkContentsSigners() : info.getSigningCertificateHistory(); } else { PackageInfo pi pm.getPackageInfo(pkg, PackageManager.GET_SIGNATURES); sigs pi.signatures; } if (sigs null || sigs.length 0) { return result; } MessageDigest md MessageDigest.getInstance(SHA-256); for (Signature s : sigs) { byte[] raw s.toByteArray(); result.add(toHex(md.digest(raw))); } } catch (Exception e) { // 关键这里不要吞掉就完事后面会讲怎么处理 Log.w(SignatureReader, read failed, e); } return result; } private static String toHex(byte[] bytes) { StringBuilder sb new StringBuilder(bytes.length * 2); for (byte b : bytes) { sb.append(Character.forDigit((b 4) 0xF, 16)); sb.append(Character.forDigit(b 0xF, 16)); } return sb.toString(); } }这段代码有几个细节值得单独说。s.toByteArray()拿到的是证书的 DER 编码字节对它算 SHA-256 得到的十六进制串就是通常说的“证书指纹”。注意是证书的指纹不是 APK 文件的指纹这两个经常被搞混——apksigner --print-certs打印出来的也是证书指纹所以两边能对上。toHex里我用了Character.forDigit这样出来的字符串是小写的。而keytool打印的指纹默认是大写冒号分隔。比对时统一转小写、去掉冒号可以省掉一大堆“明明一样却不相等”的问题。3.2 多签名者与历史的取舍上一节代码里那个三元判断hasMultipleSigners() ? getApkContentsSigners() : getSigningCertificateHistory()是我认为最稳妥的写法但它的含义需要解释清楚。hasMultipleSigners()返回 true 的情况是这个 APK 被多个不同的证书同时签名注意不是同一证书的多份是多个人一起签。这种场景在正式应用里很少见一般是联合开发或者特殊的分发要求才会用。这种情况下 lineage 概念不适用只能拿当前的签名者。返回 false 时是单签名者此时getApkContentsSigners()和getSigningCertificateHistory()在没轮换过的情况下返回的内容是一样的。但在轮换过的情况下前者只返回当前的后者返回全部历史。那么到底该用哪个我的判断标准是你要防的是什么。如果你防的是“包被别人重签”用getApkContentsSigners()就够了返回的就是这个包的签名者。如果你还要兼容“我们自己轮换过密钥”那就要用 history并且白名单里放多个值。前者逻辑更简单后者兼容性更好我倾向于后者代价是白名单要多维护几行。还有一个反向的坑某些加固方案或者插件化框架会往 APK 里塞额外的签名信息导致signingInfo返回的数组长度比你预期多。所以比对逻辑必须写成“遍历返回值任意一个命中白名单即通过”不能写成“取第一个比对”。用“取第一个”的写法在多数情况下能跑但一旦遇到多签名或者轮换就翻车。3.3 比对 SHA-256 而不是比对 Signature 对象新手最容易犯的错是直接用Signature.equals()或者Arrays.equals(sig1.toByteArray(), sig2.toByteArray())来比对。前者在部分系统版本上行为不一致Signature没重写equals比的是引用后者在证书重新编码后可能失败。正确做法是先算摘要再比字符串。原因有三一是摘要长度固定比对快且不占内存二是摘要可以安全地放到配置文件、服务端接口、日志里不会泄露完整证书三是白名单配置起来直观60 多个十六进制字符肉眼能看出差异。我当时见过的一个事故就是比对方式的问题某团队用Arrays.equals比对原始字节本地 debug 包和 release 包用的是同一张证书但打出来的包在Signature.toByteArray()上出现了字节序差异跟 zip 对齐和签名方案有关导致 release 包全部校验失败。改成摘要比对之后问题消失——因为摘要比对的是证书本身跟签名块怎么组织无关。3.4 校验时机与失败处理策略代码写对了还有一个更重要的设计问题在哪校验失败了怎么办。时机上我建议分三处Application.onCreate做一次轻量校验只读摘要不做重活关键业务入口做一次以及服务端接口调用前把结果带上。不要在onCreate里做太重的操作因为这里阻塞会直接影响冷启动时间用户感知很明显。实测下来单次读取加 SHA-256 计算在多数设备上不到 2 毫秒可以接受但如果你还要读 so、算 dex 摘要就得挪到子线程。失败处理上最忌讳的是“失败了直接System.exit(0)或者抛异常崩溃”。这么做有两个问题一是误判成本极高一次签名读取失败就闪退用户直接卸载二是给了攻击者一个明确的定位信号——你崩溃了他就知道校验点在这。我更推荐分级处理首次校验失败记录并重试一次连续失败进入受限模式关闭部分功能、不上报敏感数据同时把结果上报服务端由服务端决定是否降级。这样既保证了误判时的用户体验又让真正的攻击者拿不到完整功能。4. 加固Native 层校验与服务端联动Java 层的校验只能算第一道门槛。想让逆向的人多花点时间得往下走。4.1 JNI 里取签名的两条路径Native 层校验有两种做法各有优劣。第一种是在 JNI 里回调 Java 的 PackageManager。用JNIEnv找到Context的getPackageManager方法再调getPackageInfo把拿到的Signature数组转成字节数组回传到 C 侧计算摘要。好处是复用了系统的解析逻辑多签名、轮换、split APK 这些情况系统都帮你处理了你不需要关心。坏处是依赖 Java 层攻击者如果 hook 了PackageManager你这边拿到的也是假数据。第二种是自己在 Native 层读 APK 文件解析。通过/proc/self/maps找到加载的 base.apk 路径或者直接拿ApplicationInfo.sourceDir传进来然后用 zip 解析库读META-INF/下的文件自己走一遍 v1 校验。好处是不依赖 Java 层hook 难度高。坏处非常明显如果 APK 只签了 v2/v3 没签 v1META-INF下根本没有签名文件你的解析会直接失败。而且 split APK 场景下sourceDir只指向 base其余 split 拿不到。我的实际选择是主路径用第一种第二种作为辅助校验。辅助校验不追求解析正确只追求“如果 META-INF 存在内容必须和预期一致”不存在就跳过。这样既拿到了 Native 层的抗 hook 能力又不会因为签名方案差异导致误判。下面是一个简化的 JNI 取签名骨架extern C JNIEXPORT jbyteArray JNICALL Java_com_demo_guard_NativeGuard_readCert(JNIEnv *env, jclass, jobject context) { jclass ctxCls env-GetObjectClass(context); jmethodID getPm env-GetMethodID(ctxCls, getPackageManager, ()Landroid/content/pm/PackageManager;); jobject pm env-CallObjectMethod(context, getPm); jclass pmCls env-GetObjectClass(pm); jmethodID getPkgName env-GetMethodID(ctxCls, getPackageName, ()Ljava/lang/String;); jstring pkg (jstring) env-CallObjectMethod(context, getPkgName); // API 28 走 GET_SIGNING_CERTIFICATES否则走 GET_SIGNATURES jmethodID getInfo env-GetMethodID(pmCls, getPackageInfo, (Ljava/lang/String;I)Landroid/content/pm/PackageInfo;); jobject info env-CallObjectMethod(pm, getInfo, pkg, 0x08000000); jclass infoCls env-GetObjectClass(info); jfieldID signField env-GetFieldID(infoCls, signatures, [Landroid/content/pm/Signature;); jobjectArray sigs (jobjectArray) env-GetObjectField(info, signField); if (sigs nullptr || env-GetArrayLength(sigs) 0) { return nullptr; } jobject sig0 env-GetObjectArrayElement(sigs, 0); jclass sigCls env-GetObjectClass(sig0); jmethodID toBytes env-GetMethodID(sigCls, toByteArray, ()[B); jbyteArray raw (jbyteArray) env-CallObjectMethod(sig0, toBytes); return raw; }这段代码是为了说明调用链路实际写的时候要把 API 28 的分支、异常检查、局部引用释放都补上。JNI 里最容易出问题的是局部引用表溢出——在一个循环里反复GetObjectArrayElement而不DeleteLocalRef跑几次就崩。这个坑我踩过一次排查了大半天。4.2 白名单和校验逻辑该放哪里白名单也就是那些十六进制摘要放在哪里直接影响破解成本。放在 Java 常量里反编译一眼就能看到改起来也就几秒。放在strings.xml里稍微好一点但resources.arsc也能解。放在 so 里加密存储用的时候解密比对成本会高一些。放服务端下发客户端本地不存破解者必须抓包才能拿到。我比较推荐的是混合方案本地 so 里存一份混淆过的摘要比如异或之后再拆成多段运行时分段拼回来服务端再存一份。本地那份用于离线场景的快速判断服务端那份用于关键接口的最终裁决。这样攻击者即使把本地那份挖出来也过不了服务端那关。顺带提一个很实用的小技巧不要把摘要直接写成字符串常量。就算放在 so 里用strings命令也能扫出来。我通常会把每个字节加一个固定偏移或者把整个数组和另一个数组做异或运行时还原。这不能阻止有耐心的人但能把“随手扫一下就能改”的成本抬到“需要真正逆向”的水平。4.3 服务端二次校验与接口签名客户端的任何校验结果都是可伪造的所以真正的裁决必须在服务端。这里有一个设计要点不要只传一个“校验通过”的布尔值。正确的做法是让客户端传一段带有一定信息量的数据比如签名摘要、APK 的首次安装时间、当前版本号、设备指纹加上一个时间戳然后用一个只有服务端和客户端约定好的算法拼起来做一次哈希。服务端收到后校验时间戳是否在有效窗口内、签名摘要是否在白名单内、哈希是否匹配。这里的关键是时间戳窗口。窗口设得太短比如 30 秒用户网络慢一点就会失败设得太长比如 24 小时重放攻击的成本就降下来了。我的经验是 5 分钟左右比较合适同时服务端要维护一个短期的“已用过的 nonce”集合防止同一个请求被重放。还有一个容易被忽略的点服务端不应该因为签名不匹配就立刻封禁账号。因为误判是真实存在的——用户可能装的是某个应用商店重签名的版本很常见或者公司内部测试包。合理的策略是降级而不是封禁比如关闭高风险功能、要求二次验证同时把异常记录下来人工排查。5. 实操流程从提取指纹到上线灰度原理和代码都讲完了这一节是完整的操作流程你可以照着做一遍。5.1 用 apksigner 和 keytool 取指纹拿到一个 APK 之后第一件事是把它的证书指纹取出来。# 推荐方式能同时看到 v1/v2/v3 各方案的校验结果 apksigner verify --print-certs --verbose app-release.apk输出里会有一行Signer #1 certificate SHA-256 digest: xxxxxxxx...这个就是你要的白名单值。注意它是小写的正好和前面 Java 代码算出来的一致。如果你的环境里没有apksigner可以用keytoolkeytool -printcert -jarfile app-release.apk这个命令只对 v1 签名的 APK 有效如果 APK 只签了 v2/v3它会报“没有签名”。这时候就得用apksigner或者先解包再从META-INF里提取证书。还有一种情况是你手上有 keystore 但没打包可以直接从 keystore 里读keytool -list -v -keystore release.jks -alias your_alias -storepass ******输出里的SHA256:那一行就是。这个方法最快适合在配置 CI 的时候批量生成。5.2 生成白名单常量与构建脚本手工抄指纹容易出错我一般会在构建脚本里自动生成。下面是一个 Gradle 的示例思路在打包完成后自动读取签名并生成一个 Java 常量类task generateSignConst { doLast { def apk file(${buildDir}/outputs/apk/release/app-release.apk) if (!apk.exists()) return def proc [apksigner, verify, --print-certs, apk.absolutePath].execute() proc.waitFor() def text proc.text def matcher text ~ /SHA-256 digest: ([0-9a-f])/ if (!matcher.find()) return def digest matcher.group(1) def outFile file(${projectDir}/src/main/java/com/demo/guard/SignConst.java) outFile.text package com.demo.guard; public final class SignConst { public static final String[] ALLOWED { ${digest} }; private SignConst() {} } } }这个脚本很粗糙实际用的时候要处理多指纹、异常、路径这些问题但思路是清楚的白名单是构建产物不是手写常量。这样能避免“换了签名环境忘了更新代码”这类事故。要注意一点如果你的项目有多个 flavor、多个签名配置这个脚本要对每个输出的 APK 都跑一遍生成对应的常量。CI 上更要小心因为不同构建机的 JDK 版本可能影响apksigner的输出格式最好在脚本里做一次容错解析。5.3 上线前的回归清单签名校验这类改动最怕的是“本地全对线上一片红”。我整理了一份自己的回归清单每次改这块都过一遍检查项具体要求常见失误debug 包校验逻辑要能识别 debug 签名并放行忘了 debug 的 fingerprint开发全员崩溃release 包白名单包含当前正式签名换签名后忘了更新多 flavor每个 flavor 的指纹都要覆盖只测了主 flavor应用商店版本商店重签名后的指纹要单独确认直接线上校验失败32/64 位so 里的校验逻辑两条路径都要测只测了 arm64低版本系统API 28 以下走 GET_SIGNATURES 分支只在模拟器新系统测过覆盖安装老版本升级到新版本后校验正常轮换场景没测其中“应用商店版本”这一项是最容易翻车的。很多应用商店在上架时会用自己的证书重新签名这时候用户下载到的 APK 和你本地打的包指纹完全不同。如果你的自校验写死了本地指纹商店版本一上线就是大面积失败。解决办法只有两个要么在商店后台关闭重签名很多平台支持要么把商店的指纹也加进白名单。这两条必须在提审前确认清楚不能等上线了才发现。6. 常见问题排查实录这一节是我这些年遇到过的真实问题的整理都是些文档里不会写的东西。6.1 上线后大面积校验失败的三类原因第一类是分发环节重签名前面已经说过。判断方法很简单让出问题的用户报一下版本号然后你去对应的分发渠道下载同一个版本用apksigner比对指纹如果不一样就是这个问题。第二类是构建环境变更。有些团队的 CI 用 Docker镜像更新后 keystore 路径或者签名配置变了打出来的包用了默认 debug 签名。这种包在本地跑没问题因为开发者本地可能也在用 debug 签名但一到线上就炸。排查方法是把每个发布包的指纹记录下来每次发版前做一次自动比对。第三类是读取接口返回不一致。这个最隐蔽。比如某台设备上getPackageInfo抛了NameNotFoundException多用户、工作资料、应用被冻结等场景都可能出现你的代码 catch 住之后返回空列表比对自然失败。所以异常处理不能只是Log一下要有降级策略。6.2 自定义 ROM、Hook 与模拟器的干扰在定制系统上PackageManager的返回可能被改写。我遇到过一台设备系统预装的某个“优化”模块会拦截getPackageInfo返回的signatures数组长度是 1 但内容是空的。这种环境下的校验失败不应该等同于攻击因为正常用户也可能用到这类设备。判断是否为恶意环境的思路是交叉验证Java 层读一次Native 层读一次两次结果比对再读一下sourceDir指向的 APK 文件大小和修改时间跟自己记录的值比对。三者只要有一项明显异常就可以标记为可疑但不一定要立刻阻断。至于 Hook 框架和模拟器它们的特征检测是另一个话题了而且很容易误伤很多正常用户会用模拟器玩游戏、用多开工具管理账号。我的建议是把环境检测和签名校验分开处理签名校验只管包有没有被改环境检测单独做两者的处置策略也不同。6.3 排查速查表现象最可能的原因排查动作debug 能过 release 不过白名单只有 debug 指纹apksigner --print-certs对比两个包全部设备失败白名单值格式不对大小写/冒号统一转小写去冒号后比对部分设备失败API 28 分支没走对在低版本设备上打断点看返回值商店版本失败分发平台重签名下载商店包比对指纹轮换后失败用了getApkContentsSigners且白名单没更新改用 history 并补白名单偶发失败异常被吞返回空列表加日志检查是否抛异常校验耗时明显在主线程做了重活移到子线程或延迟执行7. 几个我认为最重要的经验写到这里技术上该讲的都讲了。最后分享几条纯个人体会都是踩过坑之后才明白的。关于安全边界客户端签名校验永远只是“提高成本”不是“绝对防护”。我见过太多团队在这上面投入巨大精力做各种花式混淆结果攻击者直接 hook 掉整个校验函数前面所有工作全部白费。真正值得投入的地方是服务端——把关键逻辑和判定放到服务端客户端只负责采集和上报这个投入产出比要高得多。关于误判成本任何安全措施都要算一笔账——拦住一个攻击者带来的收益和误伤一个正常用户带来的损失哪个更大。签名校验的误判率其实不低重签名、定制系统、多用户环境都会触发所以处置策略必须是渐进的不能一上来就闪退。关于可维护性白名单一定要自动化生成不要手工维护。人手抄 64 位十六进制字符串出错概率接近 100%。我现在的做法是构建时就生成常量类并且把每个版本的指纹归档出问题时能快速回溯。最后一个很小的技巧校验失败时不要把具体的期望值和实际值打到日志里。很多人调试时为了方便会这么写结果上线忘了删等于把答案直接告诉了攻击者。日志里只记录“校验失败”和错误码就够了具体的比对细节留在本地调试版本里。
阅读完成 · 觉得有帮助?
咨询建站