简介:围绕支付宝小程序 SSL 校验与代理抓包调试场景整理的轻量技术资料包,面向具备一定移动端逆向与安全测试基础的研究者、开发者,用于理解小程序网络请求分析中证书校验、代理转发与脚本注入的基本流程。压缩包共 3 个文件,约 2KB,包含 1 个 Python 脚本、1 个 Markdown 说明文档和 1 个 JavaScript 脚本:Python 文件承担 Frida 入口与运行控制,Markdown 文档提供使用说明与背景备注,JavaScript 脚本对应注入逻辑。已有 3043 人学习下载,说明该主题在移动端安全测试与抓包调试中有一定关注度。读者可据此梳理 SSL 校验处理思路、脚本组织方式与代理抓包调试的排错方向,适合作为相关实验环境的参考材料。
1. 抓不到包先别换工具:九成是证书固定没绕对方向
一次移动端测试,抓包端就绪、证书也装进了系统信任区,结果被测应用里每一个请求都返回 SSLHandshakeException。换抓包工具、换测试机、重装证书,来来回回折腾了大半天,报错一个字都没变。最后把安装包里的 so 拉出来跑了一遍字符串匹配,才发现证书固定写死在 native 层,前面那些操作全是无用功。
这事之后我把 SSL Pinning 的验证流程重做了一遍:先自己写三个测试应用,分别用网络安全配置文件、OkHttp 的 CertificatePinner、自定义 TrustManager 实现固定,再逐个验证钩子能不能过、过不去具体卡在哪一层。下面这套流程就是那次整理出来的,适合正在做移动端抓包测试的同学,也适合写应用的开发同学用来检查自己的证书固定是不是纸糊的。
2. 证书固定到底固定了什么:三层实现在测试端的对应关系
2.1 从 TLS 握手往回看,固定点其实只有三个
普通 HTTPS 的信任链是这样的:客户端拿到服务端证书链,沿着链往上找到系统信任区里的根证书,验证签名、有效期、域名,全部通过就建连。证书固定是在这条链上额外加一道判断——不是“这个证书能不能被信任”,而是“这个证书必须是我认识的那一个”。
按固定对象拆,只有三个层次:
第一层固定整张叶子证书,比的是 DER 编码的完整字节。这种最严格,但证书一续期或者换 CA 就全线失效,运维成本极高,实际项目里很少这么干。
第二层固定公钥,也就是 SPKI。做法是把证书里的 SubjectPublicKeyInfo 取出来算 SHA-256,再做 Base64。这一层的好处是证书可以换、CA 可以换,只要密钥对不变,pin 就一直有效。这是主流做法,OkHttp 的sha256/xxx和网络安全配置里的<pin>都是这个东西。
第三层固定 CA 或中间证书,只信任指定的签发者。这种方式粒度粗一些,但对整个域名下的服务都有效,常见于对第三方 SDK 做白名单的场景。
测试端理解这三层的意义在于:固定位置决定了钩子要打在哪里。固定整证书和固定公钥,通常在 Java 层的 TrustManager 或者 OkHttp 里做;固定 CA 更多落到配置文件和系统层;如果真的下沉到 native,就得靠 so 分析。搞不清哪一层就乱试钩子,最后只能得到“我钩了但没用”这种没法推进的结论。
顺带说一个容易被忽略的点:pin 一定要有备份。线上做密钥轮换的时候如果只有一条 pin,新证书一上,所有老版本客户端立刻连不上。做测试的时候也一样,你会发现某些应用的 pin 列表里有多条,钩子脚本如果只处理第一条,切到备用 pin 的场景就验证不出来。
2.2 Android 上四种常见实现的指纹特征
真打起来,遇到的固定实现基本是这四类,每类都有可以快速识别的特征。
第一种是network_security_config.xml里的<pin-set>。把 APK 用 jadx 打开,res/xml/下一般能找到,特征是<pin digest="SHA-256">加一串 Base64。这类固定的执行点在系统 TLS 栈,Java 层钩子完全够不着,这是最容易让人误判的一类——脚本打印了“hook 成功”,请求照样失败。
第二种是 OkHttp 的 CertificatePinner。异常堆栈里会明确出现okhttp3.CertificatePinner,报错类型是SSLPeerUnverifiedException,消息里带Certificate pinning failure。这是最好处理的一种,Java 层直接替换方法即可。
第三种是自定义 X509TrustManager,在checkServerTrusted里手工比对公钥指纹或者直接return。这种代码通常是混淆过的,类名看不出来,但可以通过 hookSSLContext.init拿到所有注册进去的 TrustManager 实例,再看具体类型。
第四种是 native 层校验。可能是 BoringSSL 的自定义 verify callback,也可能是加固厂商塞进去的独立校验模块。特征是:Java 层所有钩子都打上了,日志也正常,但握手依然失败,而且失败得非常快,几乎在 TCP 建连后立刻断开。
提示:识别顺序建议从异常堆栈开始,再翻 xml,最后才动 so。从便宜的手段往贵的手段排,能省掉大量时间。
2.3 自建测试应用复现四种,才有对照基线
直接拿别人的应用练手,最大的问题是不知道预期结果长什么样。钩子生效了但请求还是失败,你分不清是钩子写错了还是对方用了别的方式。所以我一般会先自己写一个测试应用,把四种实现各做一个构建变体,形成对照基线。
先准备一个测试域名,这里用test.example.internal这种内部保留域名代替真实域名,避免指向任何线上服务。然后是 OkHttp 版本的固定:
// 仅用于验证钩子脚本,指向自建测试服务 private fun buildPinnedClient(): OkHttpClient { val pinner = CertificatePinner.Builder() // 主 pin:当前测试服务公钥的 SPKI SHA-256 .add("test.example.internal", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") // 备用 pin:轮换后的公钥,验证多 pin 场景 .add("test.example.internal", "sha256/BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=") .build() return OkHttpClient.Builder() .certificatePinner(pinner) // 超时调短,失败时能快速拿到结论 .connectTimeout(5, TimeUnit.SECONDS) .build() }add的第一个参数是主机名,必须和实际请求的 host 完全一致,includeSubdomains类的通配在 OkHttp 里是不支持的。第二个参数是sha256/前缀加 Base64,注意末尾的等号不能省。多条 pin 之间是“或”的关系,只要有一条匹配就放行,这也是备份 pin 能生效的原因。
配置文件版本:
<!-- res/xml/network_security_config.xml --> <network-security-config> <domain-config cleartextTrafficPermitted="false"> <domain includeSubdomains="false">test.example.internal</domain> <pin-set expiration="2030-01-01"> <!-- 主公钥 --> <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin> <!-- 备份公钥,密钥轮换时顶上 --> <pin digest="SHA-256">BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB=</pin> </pin-set> </domain-config> </network-security-config>expiration这个属性很容易被漏掉,一旦过了这个日期,pin 会直接失效、退回普通信任链校验,而不是直接拒绝连接。做测试时如果发现某个版本的应用突然不 pin 了,先去看看这个字段。
自定义 TrustManager 版本就更直白,在checkServerTrusted里拿到链,取chain[0].publicKey.encoded算 SHA-256 比对,不匹配就抛CertificateException。
四个变体跑起来,你就有一张清晰的对照表:哪个变体在哪种钩子下能通、哪个不能通。后面遇到真实目标,先跑一遍钩子,看它的行为和哪个变体一致,就能大致定位实现方式。
3. 复现环境搭建:从零到能抓到第一个包
3.1 测试机与工具链的选型对照
环境这块最容易在一开始就走弯路。我踩过的坑是:拿着一台没 root 的零售机折腾了一下午,最后发现根本没法往系统信任区写证书,前面所有配置全部作废。所以选型要放在最前面。
| 维度 | 已 root 真机 | 模拟器 | 未 root 真机 |
|---|---|---|---|
| 系统 CA 写入 | 可写,稳定 | 可写,需可写镜像 | 不可写 |
| native 层 hook | 可行 | 部分 ABI 受限 | 不可行 |
| 机型与系统版本覆盖 | 单机型 | 单版本 | 覆盖面广 |
| 反调试与完整性检测 | 较少 | 少 | 较多 |
| 适合的阶段 | 联调验证 | 快速跑通流程 | 只做初步观察 |
结论比较明确:先用模拟器把脚本和流程跑通,确认每一步的预期结果,再上已 root 的真机做最终验证。未 root 的真机不要指望能搞定证书固定,最多用来确认“这个应用到底有没有做固定”这件事——看它报不报证书错误就够了。
工具链按用途分四块:抓包端负责拦截和转发,钩子框架负责运行时改写,反编译工具负责静态看代码和资源,so 分析工具负责看 native 层。四块里最容易被低估的是反编译,很多人上来就 hook,其实先花十分钟翻一遍 xml 和网络层代码,能省掉后面两个小时。
3.2 证书落位:系统信任区和用户信任区的差别
从 Android 7.0 开始,应用默认不信任用户安装的 CA 证书,只信任系统信任区里的。这条策略直接决定了证书固定的验证能不能做下去——你把抓包端的根证书装到“用户证书”里,除了浏览器,绝大多数应用都不会认。
系统信任区的路径是/system/etc/security/cacerts/,里面的文件名不是随便起的,必须是<subject_hash_old>.0的形式。哈希算错,文件放进去也不会被加载,而且不会有任何提示,属于典型的“静默失败”。
# 从抓包端导出根证书,转成 PEM openssl x509 -in mitm-root.crt -out mitm-root.pem -outform PEM # 关键:用旧版 subject hash,不是新版 HASH=$(openssl x509 -inform PEM -subject_hash_old -in mitm-root.pem | head -n 1) echo "目标文件名: ${HASH}.0" # 生成待推送的文件 cp mitm-root.pem "${HASH}.0" # 推入系统信任区(需测试机具备系统分区写入能力) adb push "${HASH}.0" /system/etc/security/cacerts/ adb shell chmod 644 /system/etc/security/cacerts/${HASH}.0-subject_hash_old里的 old 是重点。用新版-subject_hash算出来的值,文件名对不上,证书就是加载不进去,而且系统日志里也不一定有明显报错。这个坑我在不同设备上踩过不止一次。
推完之后必须重启,或者至少重启netd让信任区重新加载。验证方式是打开系统设置里的信任证书列表,看能不能找到刚装的那张,找不到就是没生效,别急着往下走。
还有一种情况:测试机拿不到系统分区写权限,但应用本身在network_security_config里声明了信任用户证书。这种应用反而好办,直接把证书装到用户区就行。判断方法很简单,反编译看 xml 里有没有<certificates src="user" />。
3.3 基线验证:抓不到包先确认这四件事
环境搭好后不要立刻去碰目标,先用一个普通的 HTTPS 请求验证整条链路通不通。这一步能排掉八成的假故障。
第一件事,网络本身通不通。用命令行直接请求目标域名,确认能拿到响应。这一步排除 DNS 和路由问题。
第二件事,中间人有没有生效。用系统浏览器访问任意 HTTPS 站点,抓包端应该能看到明文请求。看不到就说明证书没被信任,或者抓包端没有正确转发。
第三件事,测试应用能不能过。拿 2.3 节里没开 pinning 的那个变体发请求,能通说明证书链没问题。这一步是把“证书问题”和“pinning 问题”切开的关键。
第四件事,开了 pinning 的变体报什么错。这一步的报错类型基本就决定了后续方向:
| 报错或现象 | 大概率原因 | 下一步 |
|---|---|---|
Trust anchor for certification path not found | 证书没装进被信任的区域 | 回 3.2 检查哈希和路径 |
SSLPeerUnverifiedException+ pinning 字样 | Java 层 pinning | 上第 4 章的钩子 |
| 握手瞬间断开,无 Java 异常 | native 层校验或双向认证 | 分析 so,检查客户端证书 |
| 抓包端完全无记录,但应用正常 | 走了 UDP 或本地缓存 | 关掉 HTTP/3,清缓存重试 |
最后一行的场景值得单独说。现在不少应用默认开启 HTTP/3,走 UDP 443,抓包端如果只监听 TCP,就会完全看不到请求,但应用本身工作正常,非常迷惑。处理方式是先关掉抓包端的 HTTP/3 支持,或者在测试构建里强制走 TCP。
4. 逐层验证钩子:四种固定实现分别怎么打
4.1 TrustManager 层的通用钩子
这一层是覆盖面最广的。所有走 Java 标准 TLS 栈的连接,最终都会调用X509TrustManager.checkServerTrusted,只要把这个方法换成空实现,证书校验就被摘掉了。但直接 hook 接口方法没用,得从SSLContext.init这个入口下手,把传进去的 TrustManager 数组整个替换掉。
// 目的:验证被测应用是否依赖标准 TrustManager 做校验 Java.perform(function () { var X509TrustManager = Java.use('javax.net.ssl.X509TrustManager'); // 构造一个什么都不校验的实现 var BypassTrustManager = Java.registerClass({ name: 'com.check.BypassTrustManager', implements: [X509TrustManager], methods: { checkClientTrusted: function (chain, authType) { }, checkServerTrusted: function (chain, authType) { console.log('[+] checkServerTrusted 被调用,已放行'); }, getAcceptedIssuers: function () { return []; } } }); var SSLContext = Java.use('javax.net.ssl.SSLContext'); // 三个参数的重载才是真正被应用调用的那个 var init = SSLContext.init.overload( '[Ljavax.net.ssl.KeyManager;', '[Ljavax.net.ssl.TrustManager;', 'java.security.SecureRandom' ); init.implementation = function (km, tm, sr) { console.log('[+] SSLContext.init 被调用,替换 TrustManager'); // 注意:KeyManager 必须原样传回,否则双向认证场景会直接失败 init.call(this, km, [BypassTrustManager.$new()], sr); }; });几个参数的含义要点清楚。km是 KeyManager 数组,管的是客户端证书,双向认证场景下必须保留原值,替换掉就握手不了了。tm是 TrustManager 数组,是我们要换的目标。sr是随机源,原样传回即可。
这个脚本只能解决“通过 SSLContext.init 注册 TrustManager”的场景。如果应用用的是 OkHttp 默认配置,OkHttp 内部也会调 SSLContext.init,一样能拦到。但如果应用自己 new 了一个 TrustManager 直接塞给 SSLSocketFactory,绕过了 SSLContext,这个脚本就无效。
另一条路径是 Conscrypt 的优化实现。Android 上com.android.org.conscrypt.TrustManagerImpl有个checkServerTrusted的扩展版本,绕过标准接口直接调用。稳妥的做法是两手都上,先 hookSSLContext.init,再 hookTrustManagerImpl.verifyChain,看日志里哪个先打印,就能判断应用走的是哪条路。
4.2 OkHttp CertificatePinner 的验证
OkHttp 的 pinning 是在标准链校验之后再加一道,所以只换 TrustManager 不够,必须单独把 CertificatePinner 摘掉。它的判断入口在check方法里,只要让它直接返回,后续就正常建连。
// 目的:摘掉 OkHttp 的证书固定判断 Java.perform(function () { try { var CertificatePinner = Java.use('okhttp3.CertificatePinner'); // OkHttp 3.x:check(String, List) CertificatePinner.check.overload('java.lang.String', 'java.util.List') .implementation = function (host, peerCertificates) { console.log('[+] CertificatePinner.check 命中, host=' + host); return; // 不抛异常即视为匹配成功 }; } catch (e) { console.log('[-] 3.x 签名不存在,尝试 4.x 的 check$okhttp'); } try { // OkHttp 4.x 是 Kotlin 实现,方法名被改写成 check$okhttp var Pinner4 = Java.use('okhttp3.CertificatePinner'); Pinner4['check$okhttp'].overload('java.lang.String', 'kotlin.jvm.functions.Function0') .implementation = function (host, cleaner) { console.log('[+] check$okhttp 命中, host=' + host); return; }; } catch (e) { console.log('[-] 4.x 签名也不存在,目标可能没用 OkHttp'); } });这里有两处要说明。第一处是版本差异,OkHttp 3.x 用 Java 写,方法就叫check;4.x 改成 Kotlin 之后,内部方法名带$,Frida 里访问带$的成员必须用方括号写法,点名会直接报语法错误。第二处是两个try块分别处理,不要合并——同一个进程里可能同时存在两个版本的 OkHttp,尤其是接了多个三方 SDK 的应用。
还有一个更彻底的做法:hookCertificatePinner.Builder.add,在构造阶段就把所有 pin 记下来并清空。这个方式的好处是能看到应用到底固定了哪些主机和哪些 pin,对分析有用;坏处是如果应用在别处已经构建好了 client 实例,Builder 阶段就错过了。实际验证时我一般两个都打,看哪个先命中。
判断脚本是否真的生效,不能只看有没有打印。正确的验证顺序是:先确认脚本注入成功,再确认方法被调用,最后确认请求成功。三个都对上,才算这一次验证通过。只有打印、请求仍失败,说明固定的执行点不在这一层。
4.3 配置文件与 Flutter 场景的处理
网络安全配置文件里的<pin-set>是最容易被误判的一类。它的执行点在系统的 TLS 栈里,Java 层的任何钩子都碰不到。表现特征很典型:钩子日志显示SSLContext.init被调用了,checkServerTrusted也放行了,但握手仍然失败,而且报错信息里没有 pinning 字样,只有一句干巴巴的证书验证失败。
识别方法:把 2.2 节的 jadx 步骤走一遍,看res/xml/下有没有 pin-set。有,且钩子无效,就可以确定是这一类。处理方式只有两条——修改应用的 xml 后重新打包,或者在 native 层钩住系统 TLS 的校验函数。前者对签名有影响,后者成本高。做防护评估时我一般直接判定这一类为“有效”,因为它确实拦住了绝大多数只会 Java 层钩子的测试者。
Flutter 场景又是另一个样子。Flutter 的网络请求走 Dart 层,底层的 TLS 实现是随libflutter.so一起打包的 BoringSSL,跟系统的 TrustManager 完全没有关系。所以你会看到:Java 层钩子打得好好的,应用里的 Flutter 页面该失败还是失败,而同一应用的原生页面却能通。
识别特征也很明显:钩子脚本注入后,SSLContext.init的调用次数明显偏少,但应用确实在发 HTTPS 请求。进一步确认可以看 APK 里有没有lib/*/libflutter.so。
处理思路上,Dart 层的badCertificateCallback是个可下手的点,但这需要能改 Dart 代码;对于闭源场景,只能转向 so 层,去定位 BoringSSL 里负责验证证书链的那个回调。常见的做法是找SSL_CTX_set_custom_verify这类函数并观察它是否被调用了自定义回调,但 Android 上的 BoringSSL 符号经常被裁剪,只能靠特征字符串定位——这一步的成本已经明显高于前面几种。
React Native 的情况相对简单,它的网络请求最终落到原生的 OkHttp 或系统网络栈,按 4.1 和 4.2 处理就行,不需要额外手段。
4.4 native 层校验为什么钩子失效,以及怎么判断
native 层校验难处理的原因是它完全绕开了 Java 层的标准接口。BoringSSL 允许在SSL_CTX上设置自定义的 verify callback,这个回调直接拿到证书链,做任意比对,比对失败就返回 0,握手终止。整个过程不会产生任何 Java 异常,日志也不会说什么,就是连接断了。
判断是不是这一类,最快的方法是看 so。
# 从 APK 中提取所有 so,仅解压 lib 目录 unzip -o target.apk 'lib/*' -d extracted # 在 so 里找证书校验相关的字符串特征 strings extracted/lib/arm64-v8a/*.so \ | grep -i -E "pinning|spki|sha256/|x509_verify|cert.*mismatch" \ | sort -u | head -n 20关注两类特征。一类是sha256/开头的 Base64 常量,如果 so 里直接出现了,基本可以确定是 native 层硬编码了 pin。另一类是错误文案,比如“certificate mismatch”这种,出现在 so 里说明校验逻辑就在这里,而且失败时会走到这条分支。
符号被 strip 掉是常态,所以不要指望能看到函数名。这时候的思路变成:先定位校验函数在哪个 so、大概哪个偏移,再考虑要不要继续。实际做防护评估时,如果确认校验下沉到了 native,我一般就把结论写成“绕过成本高,需要专门投入”,不再往下钻,因为再往下的收益和投入不成比例。
顺带说一个判断双向认证的窍门。如果抓包端在收到 ClientHello 之后回证书,客户端直接断开,而且 so 里能搜到客户端证书相关的字符串,那大概率是 mTLS。这类场景下光绕过 pinning 没用,抓包端还得带上合法的客户端证书,属于另一个维度的门槛。
5. 排查记录:五次翻车,五种现象对应五种原因
5.1 钩子打印了但请求还是失败
现象:脚本注入成功,SSLContext.init的日志正常输出,checkServerTrusted也放行了,但应用里的请求依然报证书错误。
原因:钩子打在了接口上,而应用实际用的是具体实现类。比如 hook 了javax.net.ssl.X509TrustManager,但应用的信任逻辑写在com.android.org.conscrypt.TrustManagerImpl里,走的是扩展方法,根本没经过接口。
解决:用类枚举反查实际类型。在脚本里调Java.enumerateLoadedClassesSync()过滤出所有含TrustManager的类,看哪个是应用自己的包名,然后针对具体类下手。这一步比猜快得多。
5.2 装了系统证书,高版本系统上仍然不生效
现象:证书按流程推到了/system/etc/security/cacerts/,权限也改了,重启之后浏览器的 HTTPS 能抓,但应用不行。
原因:文件名哈希算错了。用新版-subject_hash算出来的值和系统要求的不一致,系统会静默忽略这个文件,不报错也不加载。
解决:回到 3.2 节,用-subject_hash_old。验证方式是装完之后打开系统的信任证书列表,肉眼确认能找到。找不到就别往下走,后面所有结论都不可信。
5.3 抓包端一条记录都没有,应用却工作正常
现象:抓包端界面干干净净,应用该加载的加载、该刷新的刷新,完全不像是出了问题。
原因:走了 UDP 上的 HTTP/3。抓包端默认只监听 TCP,自然什么都看不到。另一种可能是应用有本地缓存,某些页面根本没发请求。
解决:先在抓包端关掉 HTTP/3 相关的处理,强制回落 TCP;再清一次应用数据重进。两件事做完还是没记录,才考虑是不是走了别的通道。
5.4 部分接口能抓,部分接口抓不到
现象:主流程的接口都能看到明文,但某个特定功能死活抓不到,返回的数据看起来又是正常的。
原因:应用里有多个 HTTP 客户端实例。有的走统一配置,有的由三方 SDK 自己 new 了一个,pinning 配置不统一。或者那部分页面用的是 WebView,走的是另一套网络栈。
解决:在主流程和异常流程各打一次日志,对比SSLContext.init的调用来源。WebView 的情况单独处理,开启 WebView 的调试开关后用浏览器内核的调试协议看请求,不要硬套原生那套钩子。
5.5 钩子注入后应用直接闪退
现象:脚本一跑应用就崩,日志里看不到任何业务相关的堆栈。
原因:三种可能——钩子框架版本和 ABI 不匹配;应用有反调试检测,发现注入进程后主动退出;注入时机太晚,类已经加载完了,注册的替换被拒。
解决:先换 spawn 模式启动,把注入时机提前到进程刚起来时;再确认框架版本和应用架构一致(arm64 还是 arm32);如果还崩,就先只注入一个空脚本测试,排除是脚本本身的问题。反调试检测这条不好确认,但特征是“空脚本也崩”,可以用这个来区分。
6. 加固侧的清单:验完把洞补回去
验证做完,最有价值的产出不是“我绕过了”,而是“哪些防护真的起了作用”。我自己评估了一圈,把这几类固定方式按实际拦截效果排了个序,顺手整理成一张可以直接对着改的清单。
| 防护手段 | 实现成本 | 被绕过的难度 | 适用场景 |
|---|---|---|---|
| 仅网络安全配置 pin-set | 低 | 中(挡住 Java 层钩子) | 快速上线,作为基础门槛 |
| OkHttp CertificatePinner | 低 | 低(一条脚本) | 已有 OkHttp 的项目,聊胜于无 |
| 自定义 TrustManager | 中 | 低到中 | 需要额外做业务判断时 |
| native 层校验 | 高 | 高 | 对安全性有要求的核心接口 |
| 双向认证 mTLS | 中高 | 高 | 客户端可唯一标识的场景 |
| 请求签名 + 时间戳 + 随机数 | 中 | 高(即使解了 TLS 也改不了包) | 所有涉及金额和状态变更的接口 |
这张表里最值得说的是最后一行。前面五项全都是“防止别人看到明文”,但只要对方拿到了合法的客户端证书、绕过了所有 pinning,明文照样能看。而请求签名不一样——它保护的不是传输层,而是业务层。对方能看到请求,但改了参数就验签失败,改了时间戳就过期,重放会被随机数挡住。真正要防住篡改,这一层是绕不过去的。
另外两个容易漏的点。一是 pin 的备份,主 pin 和备用 pin 必须同时在线,并且备用 pin 对应的密钥要在冷存储里保管,不能和主密钥放在同一台机器上,否则一次事故两个一起丢。二是证书固定的作用范围,不要只在网络层封装的地方做,三方 SDK 自己发起的请求经常是独立的客户端实例,配置漏了就等于没做。
回过头看那次折腾大半天的经历,最大的教训不是“该怎么绕”,而是“该先确认哪一层”。从那以后我每次开新的移动端测试,都会强制走一遍顺序:先用无 pinning 的测试应用确认链路通,再用三个变体确认钩子各自的预期表现,最后才碰目标。这套动作花不到二十分钟,能省掉的却是几个小时的无效试错。我把这几段脚本和配置文件样例整理进了一个压缩包,需要拿去做对照验证的,按资源页的说明取用就行。
希望帮到你。
本文还有配套的精品资源,点击获取