简介:面向移动端安全研究、小程序逆向分析与代理抓包调试的学习者,这份资料围绕支付宝小程序 SSL 校验拦截与 Frida 注入思路展开,适合已具备一定抓包工具和 JavaScript/Python 基础、希望理解证书校验绕过机制的中高级读者。压缩包共 3 个文件,包含 1 个 Python 脚本、1 个 Markdown 说明文档和 1 个 JavaScript 脚本,整体约 2KB,分别对应注入入口、使用说明与前端 Hook 逻辑,目录轻量但模块指向明确。资源已有 3034 人学习,可帮助读者梳理 SSL Pinning 处理流程、认识脚本加载与 Hook 点的组织方式,并借助说明文档与示例代码建立调试思路,适合作为移动端请求分析与安全测试的参考材料。
1. 某跨平台应用的一个接口,在模拟器上稳定返回 200,装到真机后却一直卡在握手阶段,日志里只留下一句“连接已关闭”。排查了半天才发现,它不是网络问题,而是客户端自己把服务端证书锁死了:只要证书链跟内置的那份对不上,就直接断开连接。这类问题靠加日志排,效率低得离谱,真正省时间的做法是把 TLS 流量还原成明文,看一眼请求到底长什么样。
这份工具包做的就是这件事:一套围绕自研应用网络调试的证书管理与注入脚本,把设备上的 HTTPS 流量还原成可读的请求和响应。它适合三类人:需要联调第三方 SDK 的客户端开发、要给测试团队搭一套可重复调试环境的工程负责人,以及在授权范围内做安全自测的同学。前提得说清楚:目标应用必须是自研的,或者你有明确授权,拿去做别人的应用不在讨论范围里。
2. 工具包的三层结构:证书层、注入层、配置层各管什么
2.1 目录划分反映了什么设计意图
拿到压缩包后先不要急着运行脚本,先把目录看一遍。这套东西的模块划分很清楚,基本是按“谁决定什么”来切分的:
sim-debug-kit/ ├── certs/ # 证书层:根 CA 与叶子证书的生成产物 │ ├── gen_ca.sh │ └── leaf.ext ├── tools/ │ └── push_cert.sh # 把根 CA 塞进系统信任区 ├── scripts/ # 注入层:运行时改写校验逻辑 │ ├── hook_trustmanager.js │ ├── hook_pinner.js │ └── hook_native.js ├── config/ │ └── targets.yaml # 配置层:这次调试作用于谁、加载哪些脚本 └── README.md三层各管一件事:证书层决定“系统愿不愿意把流量交出来”,注入层决定“应用内部的二次校验放不放行”,配置层决定“这套东西能不能被团队里其他人重复使用”。很多同类脚本只有中间一层,结果换台机器就得重新摸索一遍参数,这也是我把配置单独拆出来的原因。
证书层是最底层的一环。Android 从 7.0 开始,应用默认不再信任用户安装的 CA 证书,只信任系统区里的那份。所以push_cert.sh干的事不是“安装证书”,而是把自签根证书伪装成系统预置证书塞进/system/etc/security/cacerts/,让系统的信任链把它当成出厂自带的一员。
注入层是第二道关。就算系统放行了,应用自己还会再验一遍:有的用X509TrustManager的默认实现,有的用 OkHttp 的CertificatePinner做公钥锁定,还有的把校验下沉到 so 库里的 BoringSSL。三个脚本分别覆盖这三种场景,按需加载,不必全开。
配置层解决的是“可重复”问题。把包名、注入时机、要加载的脚本、抓包工具监听的地址集中写在一个 yaml 里,新同事拿到仓库改两行就能跑,不用再问“你上次那个参数是什么来着”。
2.2 证书生成:SAN 字段比 CN 重要得多
certs/gen_ca.sh是整个流程的第一步,它做的事看着简单,但有几个参数一旦写错,后面所有步骤都会白费。
#!/usr/bin/env bash # certs/gen_ca.sh —— 生成一套本地调试用的根 CA 与叶子证书 set -euo pipefail OUT=${1:-./out} mkdir -p "$OUT" # 1. 根 CA 私钥。调试环境 2048 位足够,4096 位会让握手明显变慢 openssl genrsa -out "$OUT/rootCA.key" 2048 # 2. 根 CA 自签证书。有效期给 3 年,避免隔几个月就要重装一次 openssl req -x509 -new -nodes -key "$OUT/rootCA.key" \ -sha256 -days 1095 -out "$OUT/rootCA.pem" \ -subj "/C=CN/O=SimDebugKit/CN=SimDebugKit Root CA" \ -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ -addext "keyUsage=critical,keyCertSign,cRLSign" # 3. 叶子证书的私钥与 CSR openssl genrsa -out "$OUT/leaf.key" 2048 openssl req -new -key "$OUT/leaf.key" -out "$OUT/leaf.csr" \ -subj "/C=CN/O=SimDebugKit/CN=*.example.internal" # 4. 关键一步:SAN 写全。现代客户端只看 SAN,CN 已经被忽略 cat > "$OUT/leaf.ext" <<'EOF' subjectAltName=DNS:*.example.internal,DNS:example.internal,IP:127.0.0.1 basicConstraints=CA:FALSE keyUsage=digitalSignature,keyEncipherment extendedKeyUsage=serverAuth EOF # 5. 用根 CA 签发叶子证书 openssl x509 -req -in "$OUT/leaf.csr" -CA "$OUT/rootCA.pem" \ -CAkey "$OUT/rootCA.key" -CAcreateserial -days 825 -sha256 \ -extfile "$OUT/leaf.ext" -out "$OUT/leaf.pem"逐个说下容易出错的地方。第一步的位数不用纠结,调试环境跑 2048 完全够,上 4096 只会让每次握手多花几十毫秒。第二步的basicConstraints=critical,CA:TRUE必须带critical,否则部分系统会拒绝把它当 CA 用;pathlen:0表示这条链下面不允许再有中间 CA,属于收紧权限的写法。
第三步的-subj里我用了*.example.internal,这个域名是自己编的,不对应任何真实服务。它的作用只是让抓包工具的叶子证书看起来“像那么回事”,实际服务端域名由抓包工具在运行时按目标请求动态签发,这一步生成的叶子证书更多是给单元测试和自签服务用的。
第四步是新手最容易忽略的。subjectAltName必须写全所有要用到的域名和 IP,因为 RFC 6125 之后主流客户端已经完全不看CN字段了。我见过有人把 CN 写得漂漂亮亮,SAN 空着,结果抓包工具一发证书过去就被拒绝,排查半天以为是信任链的问题。
第五步的-days 825是个硬性限制,Apple 和部分 Android 版本对叶子证书有效期有上限要求,超过会被判定为不合规。825 天是个安全值。
2.3 配置层:把一次调试会话写成可复现的声明
config/targets.yaml用一份声明描述整次调试会话,这样换人、换机器、换目标应用都只是改几行配置的事:
# config/targets.yaml —— 一次调试会话的完整描述 session: package: com.example.demoapp # 只作用于这个包,避免 hook 到系统进程 spawn: true # true=启动时注入;false=attach 到已运行进程 scripts: - hook_trustmanager.js - hook_pinner.js capture: host: 10.0.2.2 # 模拟器访问宿主机的固定地址;真机填局域网 IP port: 8080 global: true # 是否把整机流量都导入抓包工具 timeout: attach: 15 # attach 超时秒数,冷启动慢的机型要调大package字段是整个配置里最重要的一个。Frida 默认会把脚本注入到所有匹配的进程,如果不加限制,系统进程、输入法、推送服务全都会被 hook 一遍,轻则日志刷屏,重则整机卡死。我一般的做法是只写一个包名,需要测多个应用时开多个配置文件,而不是把包名塞进一个数组。
spawn: true表示由脚本负责拉起应用并在Application初始化之前注入。这个时机很关键——很多应用在onCreate里就把OkHttpClient建好了,CertificatePinner也是那一刻注册进去的。如果等应用跑起来再 attach,hook 只能拦住之后新建的连接,早期建立的连接池里的那条就漏掉了。
capture.host在模拟器场景下固定是10.0.2.2,指向宿主机;真机则要填开发机在局域网里的地址,同时保证防火墙放行8080端口。这一项写错的表现是“证书装好了、脚本也注入了,但抓包工具里一片空白”,属于典型的配置问题而非技术问题。
3. 从零跑通:装证书、注入脚本、验证链路
3.1 环境准备与版本对齐
这套工具对版本比较敏感,frida-server和本地客户端的大版本必须一致,否则 attach 阶段就会报协议不匹配。我一般按下面的组合来准备:
| 组件 | 建议版本 | 说明 |
|---|---|---|
| Android | 8.0 及以上 | 7.0 以下系统信任策略不同,脚本要改 |
| frida-server | 与本地 frida 同版本 | 架构要和设备 CPU 对上,arm64 最常见 |
| 本地 frida | pip install frida-tools | 客户端版本由它决定 |
| openssl | 1.1.1 以上 | 低版本不支持-addext参数 |
| 抓包工具 | 任意支持导入自签 CA 的 | 需要有“按域名动态签发证书”能力 |
先在设备上把frida-server跑起来,这一步是所有注入的前提:
# 把 frida-server 推到设备并赋予执行权限 adb push frida-server-16.x.x-android-arm64 /data/local/tmp/fs adb shell chmod 755 /data/local/tmp/fs # 后台启动,注意要用 root 身份 adb shell "su -c '/data/local/tmp/fs &'" # 本地验证是否连上,能列出进程就说明通了 frida-ps -U | head -20adb push的目标路径建议放/data/local/tmp,这个目录在大多数设备上可写且不需要重新挂载分区。启动时用su -c而不是直接执行,是因为注入系统级进程需要 root 权限;如果只是普通应用,非 root 也能跑,但证书那一步就绕不过去了。
frida-ps -U是验证连通性最快的方式。如果它报unable to connect to remote frida-server,先别怀疑脚本,八成是版本不匹配或者服务没起来。这时候用adb shell ps -A | grep fs看一眼进程在不在,比反复重装有效得多。
3.2 把根 CA 装进系统信任区
这一步是整个流程里最“物理”的一环,也是最容易翻车的一环。系统证书区的文件名不是随便起的,必须是<subject_hash_old>.0这种格式:
#!/usr/bin/env bash # tools/push_cert.sh —— 把自签根 CA 写入系统信任区 set -euo pipefail CA=${1:?用法: push_cert.sh rootCA.pem} # 1. 算出 subject_hash_old,这是系统查找证书时用的索引 HASH=$(openssl x509 -inform PEM -subject_hash_old -in "$CA" | head -n1) echo "证书哈希: ${HASH}" # 2. 重新挂载 /system 为可写(Android 10 之后需要先 disable-verity) adb root adb remount # 3. 推入并修正权限,644 是系统证书的标准权限 adb push "$CA" "/system/etc/security/cacerts/${HASH}.0" adb shell chmod 644 "/system/etc/security/cacerts/${HASH}.0" adb shell chown root:root "/system/etc/security/cacerts/${HASH}.0" # 4. 重启让系统重新加载证书目录 adb rebootsubject_hash_old用的是旧版哈希算法,这一点很关键。OpenSSL 1.0 之后默认的-subject_hash换成了新算法,而 Android 系统认的还是旧的,用错了文件名,系统扫目录时根本不会加载它。这个细节不注意,表现就是“文件明明在里面,应用还是不信任”。
chmod 644也是必须的。系统在加载cacerts目录时会跳过权限不对的文件,而且不会报任何错误。我在这上面栽过一次,重启了三次才发现是权限问题。
adb remount在 Android 10 及以上会因为分区只读而失败,需要先执行adb shell avbctl disable-verification再重启,然后才能 remount。不同厂商的 ROM 又有各自的做法,有的需要解锁 bootloader,有的干脆改不了。如果设备改不动,退而求其次的方案是给自研应用单独配一份networkSecurityConfig,把自签 CA 写进去——但这只对你自己能改源码的应用有效。
重启之后用一个简单的方式验证证书是否被系统接受:
# 从设备上导出系统证书列表,确认我们的证书在里面 adb shell ls /system/etc/security/cacerts/ | grep "${HASH}" # 用 openssl 验证证书链是否完整 openssl verify -CAfile rootCA.pem leaf.pem第一条命令确认文件确实在目录里,第二条确认证书链本身没问题。两条都过了还不生效,问题一般出在设备时间上,下面踩坑章节会细说。
3.3 注入脚本并确认抓到第一个明文请求
证书到位之后,注入脚本这一步反而简单:
# 按配置文件启动:拉起应用、注入脚本、不暂停在启动点 frida -U -f com.example.demoapp \ -l scripts/hook_trustmanager.js \ -l scripts/hook_pinner.js \ --no-pause-f表示由 frida 负责启动目标应用,配合--no-pause让它直接跑起来而不是停在入口等回车。如果只想 attach 到已经运行的进程,把-f 包名换成进程名或 PID 就行,但前面说过,那种方式会漏掉早期建立的连接。
判断链路是否打通,不要只看抓包工具有没有数据,而是看有没有“能读懂的明文”。具体做法是:在应用里触发一个已知的、返回 JSON 的接口,然后在抓包工具里找对应的请求,如果 Response 里能看到完整的 JSON 而不是一堆二进制,说明 TLS 已经被拆开了。
如果只看到CONNECT隧道而看不到具体请求,说明客户端走的是 HTTP 隧道模式,抓包工具没法解密,这时候要检查证书有没有被真正信任。反过来,如果能看到请求行但响应是乱码,那多半是应用对响应体做了额外加密,属于另一个层面的问题,不在证书校验的范围内。
4. 核心代码拆解:三个 hook 点分别拦住了什么
4.1 替换 TrustManager:从 SSLContext.init 下手
Java 层的证书校验最终都收敛到X509TrustManager.checkServerTrusted这一个方法上,所以最直接的思路就是让它什么都不做。但直接改这个方法有个坑:有些库会自己new一个TrustManager实例并长期持有引用,等你 hook 的时候它早就被存起来了。
更稳妥的做法是在SSLContext.init处拦截,把传进去的TrustManager数组整个换掉:
// scripts/hook_trustmanager.js —— 让应用接受调试用的自签证书 Java.perform(function () { var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager'); var SSLContext = Java.use('javax.net.ssl.SSLContext'); // 1. 注册一个"全部放行"的 TrustManager 实现 var TrustAll = Java.registerClass({ name: 'com.simdebug.TrustAll', implements: [X509TrustManager], methods: { checkClientTrusted: function (chain, authType) { // 客户端证书校验,调试环境直接放行 }, checkServerTrusted: function (chain, authType) { // 服务端证书校验,这里是核心,不抛异常就等于通过 console.log('[trust] bypass chain len=' + chain.length); }, getAcceptedIssuers: function () { // 必须返回空数组而不是 null,部分实现会 NPE return []; } } }); // 2. 拦截 SSLContext.init,把调用方传进来的 TrustManager 换掉 var init = SSLContext.init.overload( '[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom' ); init.implementation = function (km, tm, sr) { console.log('[trust] SSLContext.init 被调用,替换 TrustManager'); // 注意:KeyManager 原样传回,双向认证场景还要用到它 init.call(this, km, [TrustAll.$new()], sr); }; });Java.perform是 Frida 的标准入口,它保证代码在 JVM 加载完成之后才执行,不在里面包一层直接调 Java 类会报Java is not defined。
registerClass用来在运行时动态创建一个实现了指定接口的类。implements必须传数组,即使只有一个接口。三个方法的签名要和接口完全一致,getAcceptedIssuers返回空数组而不是null,是因为某些 TLS 实现会直接遍历这个返回值,拿到null就崩。
overload那一段是最容易被跳过的地方。SSLContext.init有三个重载,参数类型必须用 JVM 的内部签名写全([L表示对象数组),否则 Frida 不知道你要拦哪个,会抛ambiguous overload之类的错。
init.implementation里重新调用init.call时,this要原样传回去,km(KeyManager)也要原样传递。很多人图省事把km也换成null,结果在需要客户端证书的场景下直接握手失败——因为服务端在验证客户端身份时,客户端拿不出证书。
4.2 OkHttp 的 CertificatePinner 与原生层兜底
有些应用不满足于系统级的信任链校验,还会在 OkHttp 里再锁一层公钥。CertificatePinner会把服务端证书的公钥哈希写死在代码里,不匹配就抛SSLPeerUnverifiedException。这时候上一节的TrustManager替换就失效了,因为校验发生在更上层。
// scripts/hook_pinner.js —— 跳过 OkHttp 的公钥锁定 Java.perform(function () { try { var Pinner = Java.use('okhttp3.CertificatePinner'); // 参数签名要写全:域名 + 该域名允许的 pin 列表 Pinner.check.overload('java.lang.String', 'java.util.List') .implementation = function (host, pins) { console.log('[pinner] bypass ' + host + ' pinCount=' + pins.size()); return; // 正常实现会在这里抛异常,直接返回等于跳过 }; } catch (e) { // 应用没用 OkHttp 或类被混淆时会走到这里,属于正常情况 console.log('[pinner] 未找到 okhttp3.CertificatePinner: ' + e.message); } });try/catch是必须的。如果应用压根没用 OkHttp,Java.use会直接抛错并中断整个脚本,导致后面的 hook 都不执行。把它包起来,找不到就打印一行跳过,其余脚本继续工作。
pins.size()只是用来打日志确认拦到的是哪次调用,真正的动作是那个空的return。正常实现里这个方法要么返回要么抛异常,直接返回就等价于“校验通过”。
有些应用把校验下沉到了 native 层,用 BoringSSL 自己实现了一套。这种情况 Java 层怎么改都没用,只能从 so 库下手:
// scripts/hook_native.js —— 兜底:把原生层的校验模式改成不校验 var setVerify = Module.findExportByName('libssl.so', 'SSL_CTX_set_verify'); if (setVerify) { Interceptor.attach(setVerify, { onEnter: function (args) { // args[1] 是 verify_mode,0 对应 SSL_VERIFY_NONE args[1] = ptr(0); console.log('[native] SSL_CTX_set_verify -> NONE'); } }); } else { console.log('[native] 未导出该符号,可能是静态链接的 BoringSSL'); }Module.findExportByName找的是动态符号表里的导出函数。很多应用把 BoringSSL 静态编译进 so 里并做了符号裁剪,这时候连libssl.so都不存在,这个方法直接失效。遇到这种情况,要么去内存里扫描特征码,要么换思路——比如让抓包工具不去解密,改用日志埋点的方式拿数据。
4.3 双向认证场景下的额外处理
前面两种都是单向认证,服务端出示证书、客户端校验。还有一种场景是服务端也要求客户端出示证书,这就是双向认证。在这种场景下,就算你把TrustManager全放行了,握手依然会失败,因为服务端不认识抓包工具伪造的客户端证书。
处理思路是保留应用原本的KeyManager,只在信任侧放行:
// scripts/hook_trustmanager.js 里的 init 改写,双向认证版本 init.implementation = function (km, tm, sr) { // 关键差异:km 必须原样传回,它持有客户端证书和私钥 // tm 换成放行版,只影响"客户端如何校验服务端" init.call(this, km, [TrustAll.$new()], sr); };这段代码和 4.1 那版唯一的区别是注释里那句话。KeyManager负责在服务端索要客户端证书时提供证书和私钥,一旦被换成null,客户端就拿不出东西,服务端直接拒绝握手。所以在双向认证场景下,只能动TrustManager那一半。
如果服务端还额外绑定了客户端证书的指纹,那就不是 hook 能解决的问题了,需要拿到测试环境的合法客户端证书才行。这部分超出工具包的范围,得跟服务端同学配合。
5. 踩坑排查:五种“证书装了还是抓不到”的情况
5.1 浏览器能抓到,应用死活握手失败
现象:用系统浏览器访问任意 HTTPS 站点,抓包工具里能看到完整明文;换成目标应用,所有请求都失败,日志里是SSLHandshakeException。
原因:应用的targetSdkVersion大于等于 24,系统按新策略只信任系统区证书,用户手动安装的那份被排除在外。浏览器用的是另一套信任策略,所以表现不一致。
解决:确认证书是不是真的进了/system/etc/security/cacerts/,用adb shell ls看一眼文件名是不是<hash>.0格式、权限是不是 644。如果设备改不了系统分区,退一步给自研应用加networkSecurityConfig,把调试用的 CA 显式列进去,只在 debug 构建里生效。
5.2 脚本注入成功,但应用启动就闪退
现象:frida -f启动后应用白屏一秒然后退出,Frida 控制台没有任何 hook 日志。
原因:大概率是registerClass生成的新类名和应用的混淆规则冲突,或者 hook 的类在目标版本里根本不存在。OkHttp 从 3.x 到 4.x 有过包名调整,混用会直接抛ClassNotFoundException。
解决:先用Java.enumerateLoadedClasses把应用加载的类名打出来,确认okhttp3.CertificatePinner到底存不存在。如果被混淆了,改用特征匹配的方式找——比如遍历所有类,找名字里带Pinner或者实现了同名方法的。脚本里每个Java.use都用try/catch包起来,一个失败不影响其他。
5.3 抓到了请求,但全是乱码或者只有隧道
现象:抓包工具里能看到一堆CONNECT记录,点进去只有二进制的字节流,看不到请求行和 JSON。
原因:两种可能。一是应用走了 HTTP/3(QUIC),基于 UDP,传统的抓包方式看不到;二是应用在 TLS 之上又叠了一层自己的加密,比如用 AES 对请求体做了二次封装。
解决:先在应用里关掉 QUIC 试试——大多数 OkHttp 客户端可以通过配置protocols强制走 HTTP/1.1。如果关掉之后能看到明文,那就是协议问题。如果还是乱码,说明有应用层加密,得结合代码去 hook 加解密函数,这就不是证书层能解决的了。
5.4 证书明明没过期,却提示有效期错误
现象:手机关闭自动时间后手动改过日期,或者设备长时间没联网,握手时报CertificateExpiredException或CertificateNotYetValidException。
原因:TLS 握手时会校验证书的notBefore和notAfter,设备时间和证书签发时间对不上就会失败。生成证书时如果宿主机时间本来就不准,签出来的证书一开始就是错的。
解决:先校准设备时间,用adb shell date看和宿主机差多少,必要时手动设置。重新生成证书时,-days给足,但别超过 825。如果设备长期离线导致时间漂移,考虑把证书有效期往前多留几天缓冲。
5.5 hook 都生效了,个别请求还是失败
现象:大部分接口都能抓到明文,但某几个特定接口始终握手失败,而且这些接口恰好是带签名参数的那些。
原因:不是证书问题,是请求签名。有的服务端在 TLS 之外还要求请求头里带一个基于时间戳和设备信息算出来的签名,抓包工具转发时改动了请求(比如补了Host头),签名就对不上了。
解决:确认失败的是握手阶段还是业务阶段。如果是握手阶段,回到 4.3 检查双向认证;如果是业务阶段返回 401/403,那就是签名问题,需要在抓包工具里配置不改写原始请求头,或者干脆只抓不改,用重放的方式单独调试。
6. 二次开发:把调试能力做成一个可控的开关
真正值得改的地方,不是把 hook 脚本写得更强,而是把“允许被抓包”这件事变成自己应用里一个显式的、可验证的开关。
我的做法是按构建类型分三套信任策略,写进networkSecurityConfig:
| 构建类型 | 信任来源 | 证书校验 | 用途 |
|---|---|---|---|
| debug | 系统 CA + 调试 CA | 关闭 pinning | 本地联调、抓包 |
| staging | 系统 CA | 开启 pinning | 提测、验收 |
| release | 系统 CA | 开启 pinning + 双向认证 | 线上 |
对应的配置大概是这个形状:
<!-- app/src/debug/res/xml/network_security_config.xml --> <network-security-config> <!-- 只在 debug 构建里信任调试用的自签 CA --> <debug-overrides> <trust-anchors> <certificates src="@raw/debug_ca" /> <certificates src="system" /> </trust-anchors> </debug-overrides> </network-security-config>debug-overrides这个标签只在android:debuggable="true"时生效,release 包里它会被系统直接忽略,等于白写。这是它的价值所在——代码不用改,配置天然按构建类型隔离。
配套的验证方法也得有。我一般会写一个脚本,在装好自签 CA 的设备上跑一遍 release 包,确认它必须握手失败:
#!/usr/bin/env bash # 验证 release 包不会被调试证书骗过去 set -e adb install -r app-release.apk # 触发一个必然发起的网络请求,期望结果是失败 adb shell am start -n com.example.demoapp/.MainActivity sleep 5 # 从 logcat 里捞握手异常,捞到才算通过 adb logcat -d | grep -q "SSLHandshakeException" \ && echo "PASS: release 包拒绝了调试证书" \ || echo "FAIL: release 包竟然接受了调试证书,赶紧查"这个脚本我会挂到打包流水线上,每次出 release 包都跑一遍。听起来有点小题大做,但证书锁定这种东西一旦配错,线上表现是所有用户都连不上,回滚都来不及。
还有一个容易被忽略的点:CertificatePinner的 pin 值要留一份备份。OkHttp 支持给同一个域名配多个 pin,只要命中其中一个就通过。我一般会同时放当前证书和下一张备用证书的哈希,这样证书轮换时不会出现“服务端换了证书、客户端全部连不上”的空窗期。这个习惯是从一次半夜紧急发版之后养成的。
从那以后我每次给应用加证书锁定,都会强制走一遍“debug 能抓、release 抓不到”的双向验证,两个方向都过了才算完。希望帮到你。
本文还有配套的精品资源,点击获取