ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

小程序抓包抓不到明文:SSL校验分层与绕过验证

小程序抓包抓不到明文:SSL校验分层与绕过验证

简介:该资源聚焦于支付宝小程序 SSL 验证机制的研究与调试场景,面向从事移动端抓包分析、接口调试及安全测试的开发者与进阶学习者,提供借助 Frida 动态注入与代理工具绕过证书校验、恢复小程序网络流量可见性的实践素材。压缩包共 3 个文件,约 2KB,体量轻巧,包含 1 个 Python 脚本负责注入与钩子逻辑,1 个 JavaScript 脚本承担运行时 Hook 与证书校验替换,另有 1 份 Markdown 说明文档交代环境准备、依赖安装与执行流程,三类文件互为配套,形成从脚本运行到结果验证的闭环。资源已被 3043 人学习下载,具有一定实践参考基础。读者可从中获得可复用的注入脚本骨架、证书校验拦截思路与代理联调方法,并借助说明文档快速复现抓包环境,理解脚本各阶段的执行顺序与调试要点,为后续自定义 Hook 与问题排查提供可改造的起点。

1. 抓不到明文的真正原因:小程序 SSL 校验不住在系统信任链里

设备上装好了 CA,浏览器打开 HTTPS 站点明文一目了然,同一个抓包工具挂到小程序上,日志里只剩一条 CONNECT 记录,后面什么都没有。多数人第一反应是工具版本旧了、证书过期了,折腾半天换工具换机器,问题照旧。真正的原因通常跟工具无关:宿主 App 把服务端证书的校验写进了自己的代码里,系统信任区里多放一张 CA 对它完全不起作用。小程序发出的网络请求最终由宿主 App 的原生网络库兜底,校验收敛在 OkHttp 的 CertificatePinner、自研网络库里的公钥指纹比对,或者容器自己封的一层 TLS 检查上。这篇笔记面向正在做移动端接口联调、自有 App 安全测试、以及要排查小程序链路问题的工程师,把校验位置、环境搭建、绕过路径和验证方法按可复现的顺序拆开讲。

2. 证书校验的三个落点:TrustManager、CertificatePinner 与容器双份校验

要做好这件事,先得知道对手在哪。移动端的证书校验不是单点,它至少分布在三个层次上:系统信任链、应用层 Pinning、以及小程序容器自带的网络栈。搞不清层次,就会出现“证书装了、Hook 也打了、就是没效果”的情况。下面按从底层到上层的顺序,把每一层会在哪里拦你、拦的方式是什么讲清楚。

2.1 从 Android 7 的分水岭说起:用户证书为什么默认不生效

Android 7.0(API 24)之后,应用的默认信任锚只包含系统 CA 目录/system/etc/security/cacerts,用户通过设置界面安装的证书落在/data/misc/user/0/cacerts-added,默认不被应用信任。这是很多人第一次抓包翻车的地方:证书明明在“受信任的凭据”里能看到,握手却直接失败。

三个变量决定这一步能不能过:

变量取值影响
targetSdkVersion< 24默认信任用户证书,直接可抓
targetSdkVersion>= 24默认不信任用户证书,需额外处理
network_security_config未声明 trust-anchors走系统默认策略
network_security_config声明了 user 证书用户证书进入信任链

查 targetSdkVersion 最直接的办法是看安装包:

# 从 APK 里读 targetSdkVersion,aapt 在 build-tools 目录下 aapt dump badging target.apk | grep -i sdkVersion

aapt dump badging会输出sdkVersion:'xx'和targetSdkVersion:'xx'两行,前者是 minSdk,后者才是决定证书策略的那个。如果目标包 targetSdk 是 24 以上且没有配置network_security_config,那你把 CA 装到用户区是白装,必须走系统区或者改配置。

判断证书到底有没有被信任,比看设置界面靠得住的办法是直接验证握手:

# 用 openssl 直连目标域名,看服务端证书链和校验结果 openssl s_client -connect api.example.com:443 -servername api.example.com -CAfile test_ca.pem -verify_return_error

-CAfile指定你自签的根证书,-verify_return_error让校验失败时直接报错而不是继续。如果这里能过、App 里过不了,说明问题不在系统信任链,往上一层找。这条命令我基本每次搭完环境都会先跑一遍,比在 App 里盲试快得多。

2.2 应用层 Pinning 的三种常见写法

系统信任链过了之后,第二道关卡是应用自己写的 Pinning。它绕开了系统的 CA 判断,直接比对服务端证书的某个特征值,常见有三种写法。

第一种是 OkHttp 的CertificatePinner,最典型:

// 应用侧的典型写法,把服务端公钥的 SHA-256 钉死在代码里 CertificatePinner pinner = new CertificatePinner.Builder() .add("api.example.com", "sha256/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=") .build();

add的第一个参数是域名,第二个是sha256/前缀加公钥摘要的 Base64。这种写法的特点是:即使你换了 CA、装了系统证书,只要服务端公钥没变,比对就照样通过;反过来说,你用自己的抓包工具签发的证书公钥跟它不一样,就会被拒。它挂在okhttp3.CertificatePinner.check(String, List<Certificate>)上,Hook 点非常明确。

第二种是自研比对。有些团队不用 OkHttp 的封装,自己拿X509Certificate.getPublicKey().getEncoded()算摘要,和常量数组比。这种代码的位置不固定,可能在任何网络工具类里,静态特征是在MessageDigest.getInstance("SHA-256")附近出现证书对象。找它得靠反编译搜关键字,光靠通用 Hook 脚本覆盖不到。

第三种是双向认证。服务端要求客户端出示证书,客户端在SSLContext.init时传入KeyManager,同时用自定义的TrustManager校验服务端。这种场景下,光有服务端的 CA 不够,你还得拿到客户端证书和私钥,否则握手在 CertificateRequest 阶段就断了。遇到双向认证,讨论“绕过”意义不大,正确做法是找服务端要测试环境的客户端证书。

2.3 小程序容器里的一笔流量两套网络栈

小程序不是独立进程,它跑在宿主 App 的容器里。这就带来一个容易被忽略的事实:同一个小程序发出的请求,可能走两条完全不同的链路。

一条是 JS 层调用容器 API(比如request这类封装),容器把参数交给宿主 App 的原生网络库发出,走的是 OkHttp 或自研库,校验在 Java/Native 层。另一条是页面里直接用 XHR/fetch 发请求,走 WebView 的网络栈,校验可能落在WebViewClient.onReceivedSslError这个回调上——默认实现是handler.cancel(),你要让它proceed()。

这意味着你在 Java 层把 OkHttp 的 Pinning 挂掉了,WebView 那条链路可能还在拦;反过来也一样。判断一笔请求走哪条链路的方法很朴素:看抓包工具里这条连接的 User-Agent 和 TLS 指纹特征,WebView 发出的请求 UA 里通常带; wv标记,原生库发出的则是 App 自定义的 UA。分不清的时候,两条链路都处理一遍,成本比反复试错低。

另外,容器往往有独立进程。多进程 App 里,网络请求可能在:container之类的子进程里发出,而你的 Hook 脚本只 attach 了主进程,日志干干净净,你会以为 Hook 没生效。这一点在后面的排查章节还会展开。

3. 把抓包环境搭起来:CA 签发、系统证书区与可调试测试包

原理清楚之后,环境搭建是决定成败的一半。这一步的目标很明确:让目标设备信任你签发的 CA,并且这个信任要发生在系统层而不是用户层。下面三节分别解决证书怎么来、往哪放、测试包怎么配。

3.1 抓包工具的 CA 生成与证书链整理

抓包工具首次启动时会在配置目录下生成一套 CA,包含私钥和证书。以 mitmproxy 为例,默认在~/.mitmproxy/下,mitmproxy-ca-cert.pem是给设备用的证书,mitmproxy-ca.pem是带私钥的完整文件,别把后者拷到任何共享位置。

拿到证书后先看清它的结构:

# 查看证书主题、有效期和基本约束 openssl x509 -in mitmproxy-ca-cert.pem -noout -subject -issuer -dates -text | head -30

输出里重点看三处:Subject和Issuer是否一致(自签根证书应该一致)、Not After有效期、以及X509v3 Basic Constraints是否为CA:TRUE。有些工具生成的中间证书不带 CA 标记,装到系统区也不会被当作信任锚,握手照样失败。

如果目标应用校验证书链深度,还需要把中间 CA 一起准备好。抓包工具签发的是“根 CA → 叶子证书”两段式,正常够用;个别应用会检查chain.length,遇到这种情况在工具里配置上游证书链就行,不必自己造。

设备侧如果只是临时验证,可以用工具自带的二维码或者http://mitm.it页面下载安装,但这种方式装的是用户证书,仅对 targetSdk < 24 的包有效。要长期用,直接走系统区。

3.2 把 CA 放进系统信任区的三条路径

系统证书区的文件名不是随便起的,必须按subject_hash_old的前八位十六进制命名,后缀.0。算错一位,系统就认不出来。

# 1. 计算证书的旧版 subject hash,Android 系统证书区按这个命名 HASH=$(openssl x509 -inform PEM -subject_hash_old -in mitmproxy-ca-cert.pem | head -1) echo "证书哈希: $HASH" # 2. 按哈希重命名,后缀必须是 .0 cp mitmproxy-ca-cert.pem ${HASH}.0 # 3. 推入系统证书区(要求设备 system 分区可写) adb root adb remount adb push ${HASH}.0 /system/etc/security/cacerts/ adb shell chmod 644 /system/etc/security/cacerts/${HASH}.0 # 4. 重启后生效 adb reboot

-subject_hash_old用的是 OpenSSL 旧版哈希算法,这一点不能省,用新版-subject_hash算出来的名字 Android 不认。chmod 644也不能漏,权限不对的话系统读不到,表现为“文件在那儿但证书列表里没有”。

三条可行路径按侵入性从低到高排:

第一条,用可写 system 镜像的模拟器。这是最省事的方式,adb remount直接成功,重启后证书常驻,适合反复调试。缺点是模拟器上跑不了带硬件校验或加固的包。

第二条,真机 + 系统级证书挂载模块。需要设备已解锁并具备 root 能力,模块把证书以 overlay 方式挂到系统目录,不改动原始分区,卸载方便。这类模块的原理是把证书放到/data下的某个目录,启动时 bind mount 到/system/etc/security/cacerts。Android 10 之后系统分区默认只读,直接remount基本都会失败,overlay 是主流做法。

第三条,改测试包的network_security_config,让应用显式信任你的 CA。这条不需要 root,但需要你能重新签名安装包,只适用于自有包或允许改包的测试场景,下一节细讲。

提示:证书有效期别设太长。抓包工具的 CA 默认有效期往往好几年,一旦私钥泄露影响面很大;调试环境建议每次重新生成,用完即删。

3.3 测试包的构建开关:debuggable 与 network_security_config

如果你手上有源码或者能改包,最干净的做法是让测试包自己信任调试 CA,而不是去动系统分区。核心是一个 XML 配置:

<!-- res/xml/network_security_config.xml --> <network-security-config> <!-- 仅 debug 构建生效,release 包不受影响 --> <debug-overrides> <trust-anchors> <certificates src="@raw/debug_ca" /> </trust-anchors> </debug-overrides> <!-- 针对特定域名放宽,避免全局放开 --> <domain-config> <domain includeSubdomains="true">api.example.com</domain> <trust-anchors> <certificates src="@raw/debug_ca" /> <certificates src="system" /> </trust-anchors> </domain-config> </network-security-config>

三个参数的含义:debug-overrides只在android:debuggable="true"时生效,是给调试包开的后门,release 包自动失效;@raw/debug_ca指向放在res/raw下的证书文件,文件名不带扩展名;domain-config限定只对指定域名放宽,比全局信任安全得多。

在AndroidManifest.xml的<application>上挂这个配置:

<application android:networkSecurityConfig="@xml/network_security_config" android:debuggable="true">

构建时用 debug 变体,装到设备上就能直接抓到明文。这套配置的价值在于可复现:换一台机器、换一个同事,只要拿同一份源码构建,环境就是一致的,不用每台设备都去折腾证书。

注意:debuggable="true"会打开大量调试能力,任何情况下都不要出现在对外发布的包里。CI 里加一条检查,扫描 release 产物的 manifest,发现 debuggable 为 true 直接让流水线失败。

4. 绕过校验的四条路径与选型:改包、Hook、系统证书与 Native 打桩

环境就绪之后,剩下的事就是针对具体校验点下手。四条路径各有适用边界,选错了会浪费大量时间:改包看着简单,但重签名会触发完整性校验;Hook 灵活,但多进程和 Native 层会漏;系统证书最通用,但依赖设备权限。逐条拆开讲。

4.1 静态改包:改配置的收益与重签名的代价

能拿到安装包并且允许改动时,静态改包是最彻底的方案:把上一节的network_security_config塞进去,让应用自己信任你的 CA,不依赖任何运行时工具。流程用 apktool 走一遍:

# 1. 反编译,保留资源文件 apktool d -f -s target.apk -o target_src # 2. 放入证书与配置文件(假设已准备好 res/raw/debug_ca.pem 和 res/xml/network_security_config.xml) cp debug_ca.pem target_src/res/raw/ cp network_security_config.xml target_src/res/xml/ # 3. 在 AndroidManifest.xml 的 application 节点加上 networkSecurityConfig 属性 # 用编辑器或 sed 修改,注意不要破坏原有属性顺序 # 4. 回编译 apktool b target_src -o target_patched.apk # 5. 重新签名(v1 + v2 都要签) apksigner sign --ks debug.keystore --ks-pass pass:android \ --v1-signing-enabled true --v2-signing-enabled true \ --out target_signed.apk target_patched.apk # 6. 校验签名是否有效 apksigner verify --verbose target_signed.apk

-s参数表示不反编译 dex,只处理资源,速度快很多;如果 Manifest 改动涉及代码逻辑,就得去掉-s。apksigner的--v1-signing-enabled和--v2-signing-enabled建议都开,Android 7 以下只认 v1,7 以上优先 v2 但部分场景两者都会检查。

改包的最大代价是签名变了。应用启动时会检查自身签名摘要,和目标值对不上就直接退出;有些包还会校验 APK 的 CRC 或 DEX 的哈希。这类完整性校验的代码位置不固定,可能是PackageManager.getPackageInfo拿signatures比对,也可能是 Native 层直接读/proc/self/maps算段哈希。遇到闪退先看 logcat 里的异常栈,能定位到具体类就直接改;定位不到就换动态 Hook 方案,别在改包上死磕。

还有一层:改了签名之后,应用内依赖签名一致性的功能会失效,比如某些 SDK 的初始化校验、分享回调、支付回调。做接口联调时不涉及这些还好,涉及的话得提前准备。

4.2 Java 层动态 Hook:三个必须挂上的校验点

动态 Hook 的优势是不改包、不重签名,随时开关。用 Frida 挂 Java 层,有几个点是必挂的,先做一次类扫描把目标找出来:

// scan.js —— 只做侦察,输出所有可能和证书校验相关的已加载类 Java.perform(function () { Java.enumerateLoadedClasses({ onMatch: function (name) { // 关键字覆盖 TrustManager、Pinning、HostnameVerifier 三类 if (/TrustManager|CertificatePinner|HostnameVerifier|X509/.test(name)) { console.log(name); } }, onComplete: function () { console.log('[*] class scan done'); } }); });

enumerateLoadedClasses列出的是当前进程已加载的类,所以脚本必须在 App 启动之后再 attach,或者用-f参数在启动时就注入。扫描结果里,除了javax.net.ssl.X509TrustManager这类标准接口,往往还能看到应用自己写的XXTrustManager,后者才是真正要处理的目标。

有了目标类,再挂实际的方法:

// bypass.js —— 仅用于自有测试包或已获书面授权的目标 Java.perform(function () { // 1. 自定义 TrustManager:把 checkServerTrusted 置空 var TrustManagerImpl = Java.use('com.android.org.conscrypt.TrustManagerImpl'); TrustManagerImpl.checkTrustedRecursive.implementation = function () { console.log('[+] checkTrustedRecursive bypassed'); return Java.use('java.util.ArrayList').$new(); // 返回空链,等价于信任 }; // 2. OkHttp CertificatePinner:直接返回,跳过比对 try { var Pinner = Java.use('okhttp3.CertificatePinner'); Pinner.check.overload('java.lang.String', 'java.util.List').implementation = function (host, pins) { console.log('[+] CertificatePinner.check bypassed: ' + host); return; }; } catch (e) { console.log('[-] CertificatePinner not present: ' + e.message); } // 3. HostnameVerifier:域名不匹配时放行 try { var HNV = Java.use('javax.net.ssl.HostnameVerifier'); HNV.verify.implementation = function (host, session) { console.log('[+] HostnameVerifier bypassed: ' + host); return true; }; } catch (e) { console.log('[-] HostnameVerifier hook failed: ' + e.message); } });

三段逻辑分别对应三类校验。checkTrustedRecursive是 Conscrypt 里真正做证书链校验的方法,返回空列表等于告诉调用方“链是合法的”;CertificatePinner.check的overload必须写清楚参数签名,OkHttp 不同版本可能有多个重载,签名对不上会抛异常;HostnameVerifier.verify返回true是最粗暴的放行,但只在自定义了 Verifier 的场景下有效。

启动方式有两种,效果差别很大:

# 方式一:attach 到已运行的进程(App 已经启动,可能错过早期初始化) frida -U -n com.example.app -l bypass.js --no-pause # 方式二:从启动开始注入(推荐,能覆盖首次网络请求) frida -U -f com.example.app -l bypass.js --no-pause

-f会先启动应用再注入,--no-pause让应用继续运行。多进程应用要额外注意,-f默认只注入主进程,子进程需要用frida -U -f com.example.app -l bypass.js配合脚本里的Java.perform在子进程运行时再触发,或者在脚本里用Process.enumerateThreads判断当前进程名,再决定是否执行 Hook 逻辑。实际项目里更省事的做法是写一个启动脚本,先frida-ps -U列出所有相关进程,逐个 attach。

4.3 Native 层:BoringSSL 的校验入口怎么找

当校验逻辑下沉到 Native 层,Java Hook 就完全无效了。这类实现通常是把 BoringSSL 静态链接进自己的 so,或者用SSL_CTX_set_custom_verify注册自定义校验回调。前者导出符号可能被裁剪,后者干脆没有符号。

先尝试用导出符号定位:

// native_bypass.js —— 在 libssl.so 里拦截证书链校验 Java.perform(function () { // 部分应用把 BoringSSL 编成独立 so,导出 SSL_verify_cert_chain var addr = Module.findExportByName('libssl.so', 'SSL_verify_cert_chain'); if (addr === null) { console.log('[-] SSL_verify_cert_chain not exported, 需要走特征码定位'); return; } Interceptor.replace(addr, new NativeCallback(function (ssl, store_ctx) { console.log('[+] SSL_verify_cert_chain forced to success'); return 1; // BoringSSL 约定:返回 1 表示校验通过 }, 'int', ['pointer', 'pointer'])); });

SSL_verify_cert_chain的返回约定是 1 成功、0 失败,替换成常量 1 就等于永远通过。Module.findExportByName找不到时返回null,所以必须先判空再替换,否则脚本会直接崩。

找不到导出符号的情况更常见。这时候的定位手段是搜特征:BoringSSL 的校验失败路径上通常会有固定的错误码字符串,比如certificate verify failed、X509_V_ERR_前缀的日志;用strings扫 so 文件找到字符串偏移,再用 IDA 反查引用它的函数,拿到函数起始地址后,用 Frida 的Module.base.add(offset)计算运行地址。

# 在 so 文件里搜证书校验相关的错误字符串,定位可能的失败分支 strings libnative-lib.so | grep -iE "certificate|verify|ssl_"

这条命令的输出能给出一批候选字符串,配合反汇编工具交叉引用,通常能在半天内定位到校验函数。这活儿不轻松,但如果目标是加固过的包,这是绕不开的一步。

注意:Native 层替换函数时,参数列表必须和原函数完全一致。参数个数或类型对不上,Frida 不会报错,但原逻辑会拿到错误的寄存器值,表现为随机崩溃,排查成本极高。拿不准就先只做Interceptor.attach打日志,确认签名对了再替换返回值。

4.4 四条路径的对照与选型

把前面几条路径摊开对比,选型时按“有没有源码 / 有没有 root / 校验在哪层”三个问题走:

路径前置条件适用校验层主要副作用可复现性
改 network_security_config能改包、能重签名系统信任链签名变更触发完整性校验高,配置随包走
系统证书区挂载设备可 root 或可写 system系统信任链部分应用检测 root 环境中,依赖设备
Java 层 Hook可注入进程Java Pinning、TrustManager多进程需逐个注入中,脚本可版本化
Native 层打桩可注入 + 能定位函数自研 SSL 校验符号定位成本高低,依赖包版本

实际项目里很少只用一条。常见组合是:先挂系统证书解决系统信任链,再用 Java Hook 解决 OkHttp 的 Pinning,最后看日志里还有没有 TLS 层报错,有的话再上 Native。按这个顺序推进,每步都有明确的验证信号,比一上来就堆全套脚本更容易定位问题。

5. 常见问题排查:五条踩过才信的记录

下面五条是这类调试里出现频率最高的问题,每条按现象、原因、解决三段写。遇到问题先按这个清单对一遍,能省下大量试错时间。

5.1 证书装上了,握手还是失败

现象:证书已经推进/system/etc/security/cacerts,重启后在系统证书列表里能看到,但抓包工具里依然只有 CONNECT 记录,没有任何解密后的请求。

原因:三种可能。一是文件名算错,用了新版subject_hash而不是subject_hash_old,系统按哈希查找时匹配不到;二是文件权限不是 644,系统进程读不到;三是目标应用根本不看系统信任链,它用的是自己打包进 so 的 CA 列表做 Pinning,系统区放什么它都不认。

解决:先验证证书本身。用openssl s_client直连目标域名并指定-CAfile,能过说明证书没问题;把证书文件从设备上拉回来,用openssl x509 -in确认内容完整、不是零字节;检查文件名和权限。这三步都过了还失败,直接判定为应用层 Pinning,跳到动态 Hook。

5.2 Hook 打上了,流量还是密文

现象:Frida 脚本输出[+] CertificatePinner.check bypassed,日志里确实打出了这行,但抓包工具里依然看不到明文。

原因:最常见的是多进程。日志里那行是在主进程打出来的,真正发请求的是:container或者:push子进程,脚本压根没注入进去。第二种可能是流量走了 WebView 而不是原生库,onReceivedSslError这条链路没处理。第三种是 UDP 上的 QUIC,抓包工具默认不解密 QUIC,只能看到密文 UDP 包。

解决:用frida-ps -U列出目标应用的所有进程,确认请求来自哪个。多进程就针对子进程单独跑脚本,或者用frida -U -f 包名加脚本内的进程判断逻辑统一处理。怀疑 WebView 的话,在 Java 层挂android.webkit.WebViewClient的onReceivedSslError,让它调handler.proceed()。怀疑 QUIC 的话,在设备上临时丢弃 UDP 443,强制客户端回落到 TCP:

# 在测试设备上丢弃去往 443 的 UDP 包,迫使 QUIC 回落到 TCP 上的 TLS adb shell su -c "iptables -A OUTPUT -p udp --dport 443 -j DROP" # 调试结束后记得清理规则 adb shell su -c "iptables -D OUTPUT -p udp --dport 443 -j DROP"

这条规则只对当前设备生效,重启后自动失效。iptables需要 root,非 root 设备可以改抓包工具配置或者直接在测试包里禁用 QUIC。

5.3 明文抓到了,接口却返回签名错误

现象:请求体明文可见,重放或者稍微改一个参数,服务端返回签名校验失败,错误码通常是 401 或者自定义的业务码。

原因:移动端接口普遍带请求签名,签名的输入通常包括方法、路径、参数、时间戳、随机数,有些还会把设备指纹或者客户端证书摘要算进去。你改了参数但没重算签名,服务端一比对就露馅。时间戳偏差超过阈值也会失败,重放几分钟前的请求同样会被拒。

解决:调试阶段只读不改。查看请求结构和字段含义不需要修改任何参数,抓下来看就行。确实需要改参数验证业务逻辑时,把签名算法从客户端还原出来——搜代码里的hmac、sha256、sign关键字,找到拼接顺序和密钥来源。这一步的工作量视实现而定,密钥硬编码在包里的话还能拿到,走服务端下发或者白盒加密的就得另想办法。日常联调优先用测试环境的固定 token,绕开签名环节。

5.4 重打包后的应用启动即闪退

现象:apktool 回编译、重新签名、安装,点图标秒退,logcat 里一行SecurityException或者干脆没有任何异常栈。

原因:签名校验。应用启动时读自己的签名摘要和目标值比对,不一致直接System.exit。有些包不在 Java 层退出,而是在 Native 层检测/data/app下的 APK 路径,发现签名文件变了就触发保护逻辑。还有一种情况是 v2 签名缺失,Android 7 以上安装时直接拒绝。

解决:先确认签名完整,用apksigner verify --verbose检查 v1/v2/v3 是否都通过。签名没问题还闪退,看 logcat 里的完整异常栈,grep -i "signature\|SecurityException\|verify"。定位到具体类就在那个类的方法上挂 Hook 返回正确值;定位不到就放弃改包路线,改用动态 Hook,成本更低。

5.5 抓到的响应是乱码或者被截断

现象:响应体看起来是二进制,或者长度明显小于预期,部分字段显示为乱码。

原因:三种。一是响应经过压缩(gzip/br),抓包工具没有自动解压;二是业务层自己做了加密,返回的是密文,需要客户端解密后才可读;三是分块传输(chunked)中间被截断,工具配置了最大 body 大小。

解决:先看响应头里的Content-Encoding和Content-Type,gzip的话在工具里开自动解压。响应体是密文的话,找客户端解密函数,通常在网络回调里紧接着解析 JSON 之前,Hook 那个函数的入参就能拿到明文,比逆向加密算法省事得多。body 截断就调大工具的 body 大小限制,mitmproxy 里对应--set body_size_limit=10m这样的配置项。

6. 验证绕过是否生效:三组对照与一份自检清单

绕过做完不等于生效。我见过太多次“日志显示 Hook 成功,实际流量还是密文”的情况,所以每次收尾都要做对照验证。三组对照,缺一不可。

第一组,开关对照。同一个请求,Hook 关闭时发一次,Hook 打开时再发一次,对比抓包工具里的记录。关闭时应该只有 CONNECT,打开时应该能看到完整的 HTTP 报文。如果两次表现一样,说明 Hook 根本没作用到这条链路上,别急着往下走。

第二组,链路对照。同一个小程序页面,触发一次原生库请求、一次 WebView 请求,分别确认两条链路都能抓到。判断方法看 UA 里的; wv标记或者 TLS 指纹差异。只抓到一条,说明另一条链路的校验还没处理。

第三组,时间对照。App 冷启动后立刻发一次请求,再在运行五分钟后发一次。有些应用把证书校验放在首次初始化里,启动时注入的 Hook 能覆盖;有些放在懒加载的模块里,五分钟后的请求走的是另一条代码路径,这时候才暴露问题。

三组都过了,再跑一段自检脚本确认运行时状态:

// selfcheck.js —— 确认关键 Hook 点当前是否处于被替换状态 Java.perform(function () { var checks = [ ['okhttp3.CertificatePinner', 'check'], ['javax.net.ssl.HostnameVerifier', 'verify'] ]; checks.forEach(function (item) { try { var Cls = Java.use(item[0]); // hasOwnProperty 为 true 说明该方法已被脚本接管 var hooked = Cls[item[1]].hasOwnProperty('implementation'); console.log((hooked ? '[+] ' : '[-] ') + item[0] + '.' + item[1]); } catch (e) { console.log('[!] ' + item[0] + ' 未加载'); } }); });

这段脚本只做状态确认,不做任何绕过动作。hasOwnProperty('implementation')检查的是 Frida 是否为该方法挂上了替换实现,比看控制台日志可信,因为日志可能来自上一次运行的残留输出。

还有一件事值得单独说:把整套流程脚本化。环境搭建的每一步——算哈希、推证书、重启、注入、验证——都写成 shell 脚本,参数化包名和域名。我最初嫌麻烦,每次手动敲,结果有次在测试机上漏了chmod 644,排查了两个小时才发现是权限问题。从那以后我每次搭环境都强制走一遍脚本,跑完自动执行三组对照,输出一份结果清单存档。后来团队里其他人接手同样的调试任务,直接改两个变量就能跑,省下的沟通成本远超写脚本的时间。

希望帮到你。

本文还有配套的精品资源,点击获取

返回列表