ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

告别Charles证书地狱:用Frida Hook直取App网络请求与响应

告别Charles证书地狱:用Frida Hook直取App网络请求与响应 你有没有经历过这种夜晚手机连着Fiddler圈内常称FD或CharlesSSL代理配了证书也装了信任开关也打开了结果目标App一启动Charles里弹出一排No required SSL certificate was sent所有请求全是红叉或unknown。你搜遍教程装justtrustme把CA证书塞进系统证书目录折腾到凌晨两点总算抓到几个包结果目标App更新了一个版本校验逻辑一变又全废了。我后来做接口分析时基本不再走代理抓包这条路了。原因很简单HTTP代理 SSL证书 绕过证书校验是一条处处被动的链路。我更推荐换一个思路——直接在目标App的进程里做Hook在网络库的函数内部把Request对象和Response对象直接拿出来。对淘宝这类安全级别较高的应用“直达函数内部”能绕开绝大多数代理层和证书层的麻烦这套方法也适用于其他加固过的App接口调试。这篇文章会完整讲清楚原理、环境准备、核心Hook脚本以及从Charles/Fiddler切换过来最容易踩的坑适合做Android逆向调试、接口分析、App兼容性开发的读者参考。1. 传统抓包工具的三座大山代理、证书、证书校验绕过先别急着写代码我们得先搞清楚一个核心问题为什么Charles/Fiddler这套方案用起来这么难受因为它的链路是一条完整的中转链路任何一个环节出问题结果都是“抓不到包”。1.1 代理方案的天然缺陷Charles和Fiddler的工作原理本质上都是HTTP中间人代理。手机把Wifi代理设置为电脑的IP和端口所有HTTP请求先到代理工具代理工具再转发给真实服务器。要看到明文请求和响应代理工具还必须做一次TLS中间人解密。这个模型有几个绕不过去的痛点。第一代理是“全局”的。一旦设置了Wifi代理App里所有网络请求都会走代理通道包括一些长连接、WebSocket、推送通道。很多App的网络库对代理状态很敏感检测到系统代理存在时会直接拒绝工作或者切换到直连通道于是你看到的永远是空列表。第二代理模式对非HTTP流量基本无效。比如QUIC、UDP、自研二进制协议走了代理也没法被Charles正常解析。你在Charles里看到一堆“CONNECT”请求那些往往是TLS隧道根本看不到里面的内容。第三代理层存在性能损耗。手机所有流量绕行到电脑遇到大文件传输、视频流播放时Charles经常卡死崩溃实测体验很差。所以代理方案的问题是结构性的不是你配置得不够仔细而是这条路本身就脆弱。1.2 SSL证书信任链条带来的连环坑代理要做TLS解密就必须让App信任代理工具生成的根证书。这一环是传统抓包最折磨人的地方。Android这边7.0之后系统默认不信任用户安装的CA证书只信任系统证书。你把Charles的证书装成“用户证书”App里很多流量依然报SSL连接错误或trust anchor for certification path not found。想彻底解决要么root后把证书文件改名为hash值塞进/system/etc/security/cacerts/要么走Magisk模块方案。每次系统更新、证书过期都要重新来一遍。iOS这边也是类似。描述文件装好之后还要在“设置-通用-关于本机-证书信任设置”里手动打开完全信任开关。很多人漏了这一步结果抓包工具显示证书已安装实际请求全部Handshake失败。更麻烦的是很多App并不信任系统CA。它们内置了自己的证书校验逻辑这种做法通常叫SSL Pinning。服务端下发的公钥或证书指纹被写死在App代码里代理工具伪造的证书在那一瞬间就被否决了。你看到的报错五花八门但根源都是同一个App根本不认你的CA。1.3 justtrustme们的宿命于是社区里出现了justtrustme经典思路用Xposed模块Hook掉系统的TrustManager、OkHttp的CertificatePinner这些校验点让App无条件信任用户CA。理论上这确实能打通SSL Pinning。但这条路也有尽头。justtrustme这类工具强烈依赖Xposed框架而新版本Android对Xposed越来越不友好不少App也加入了Xposed检测检测到你开了框架直接闪退或功能降级。就算Xposed能用App只要发一个版本把网络库换成自研的Native层实现不再走Java层的TrustManager这些模块瞬间失效。总结一下就是Charles链路是“代理—证书—绕过校验”三位一体你在跟App的开发团队打地鼠他们每加固一次你就要重新折腾一遍。这就是我决定换方案的根本原因。2. 为什么“在函数里取数据”比“在链路上截流量”更稳既然代理链路上到处是雷那就干脆别在链路上截了。换个思路代码里的网络请求最终一定会落到某个Java对象上比如OkHttp的Request、Response或者HttpURLConnection的某个方法。我们直接把Hook点放在这些对象产生的函数内部在源头把数据读出来。2.1 一句话说清Hook原理Frida是一个动态插桩工具它能把一段JavaScript脚本注入到目标App的进程里运行时修改Java方法的实现、读取方法的参数和返回值。对网络调试来说你要做的事情很简单找到网络中某个关键的构建函数Hook它在函数执行前后拿到Request和Response对象。用生活化的类比Charles这种代理抓包相当于你在快递转运中心偷偷拆包裹看完再原样封好而Frida Hook相当于你直接跟发货仓库的打包员串通好了货物一出库他就把快递单和货品清单复印一份给你。后者当然更直接也更不容易被发现。2.2 它天然免疫的几类问题不再需要中间人代理所以App的代理检测策略直接失效。不再需要安装和信任任何证书所以Android 7系统证书限制、iOS证书信任开关全部与你无关。不需要绕过SSL Pinning因为你压根不去解密TLS流量你拿的是App代码内部已经解密好的业务对象。不影响长连接和UDP流量因为你不是在网络层转发只是在业务对象创建时看一眼。这套方案本质上把“网络抓包”变成了“内存对象观测”规避了传统方案里最难缠的几个环节。2.3 一个必须提前知道的技术边界但我也得泼一盆冷水。Hook方案有一个重要的适用边界它只能覆盖Java层的网络栈。如果目标App的网络请求是经过OkHttp、HttpURLConnection这些Java层框架发送的那么Hook效果非常好。但像淘宝这类大型App很多核心接口跑在自研的Native网络栈上底层是长连接、私有协议根本没走Java层的Request/Response对象这种请求用Hook方案也拿不到。实际使用中我通常把它当作一个“Java层网络请求的显微镜”而不是“万能抓包器”。抓H5页面接口、小程序容器请求、部分普通业务接口这套方案非常好使少数核心Native接口抓不到那就换别的专业方案这是工具边界不必神话。3. 环境准备一台能用Frida的设备不用装任何证书Hook方案最大的成本不是安装配置而是准备一台合适的调试设备。好消息是你不需要再折腾证书了。3.1 设备选择与基础要求建议首选Android模拟器雷电、夜神、MuMu都行版本选Android 7到Android 12之间。模拟器最大的优势是root简单frida-server注入几乎不会遇到权限问题快照功能还能随时还原系统状态。真机也可以但要求设备已root并且不建议拿主力机试因为目标App可能会对运行环境做检测而且注入操作有一定概率导致App崩溃最好准备一台专用调试机。USB连接电脑后先用adb devices确认设备在线。模拟器一般通过adb connect 127.0.0.1:端口连接真机用USB线并开启USB调试。3.2 安装frida-server与客户端工具电脑端需要Python环境安装frida工具链pip install frida-tools然后从Frida官方GitHub Release页面下载对应架构的frida-server。模拟器通常是x86或x86_64真机一般是arm64。下载后推送到设备adb push frida-server /data/local/tmp/ adb shell chmod 755 /data/local/tmp/frida-server adb shell /data/local/tmp/frida-server 另开一个终端窗口验证frida-ps -U如果能看到设备上的进程列表说明frida-server运行正常。注意frida-server的版本必须和电脑端frida核心库版本一致pip install frida-tools装的是最新版那就下载对应最新版的server文件。版本不一致会出现unable to communicate with frida-server之类的报错。3.3 两套方案的环境清单对比项目Charles/Fiddler 方案Hook 方案代理服务器需要不需要用户CA证书需要不需要系统证书目录修改通常需要不需要Xposed模块可能需要不需要代理检测规避困难天然免疫对Java层网络栈通用但不稳定稳定且直观核心工具Charles/Fiddler 证书 模块frida-server JS脚本这张表只是我的个人体验。传统方案可能两小时配置用起来依然战战兢兢Hook方案首次配置半小时之后每次换目标App只需要改包名和Hook点基本能做到开箱即用。4. Hook脚本实战拿到OkHttp的Request与Response完整内容环境准备好之后最核心的就是写Hook脚本了。大多数Android App的网络请求都基于OkHttp所以我的默认Hook点是OkHttp的Request$Builder.build()和Response$Builder.build()。这两个方法一个是请求对象的构建出口一个是响应对象的构建出口只要网络库走OkHttp请求和响应100%会经过它们。4.1 先跑通最小脚本只看URL和Method不要一上来就写大而全的脚本先跑通最简版本验证Hook链路是否通畅。新建hook_req.jsJava.perform(function () { var RequestBuilder Java.use(okhttp3.Request$Builder); RequestBuilder.build.implement(function () { var req this.build(); console.log([REQ] req.method() req.url().toString()); return req; }); });启动目标App时用spawn模式让App从启动开始就注入frida -U -f com.example.app -l hook_req.js-f表示启动一个新的App进程-l指定脚本路径。如果看到控制台不断输出[REQ] GET/POST xxx说明Hook成功。这里有个小坑很多教程用frida -U 包名直接附加已运行的进程但这样会错过App启动阶段的早期网络请求。建议调试网络时优先用-f模式从冷启动开始抓取。4.2 完整脚本把Header、Response Code和Body全部打出来最小脚本跑通后再升级到完整版。完整脚本要解决两个问题看清楚请求的完整信息以及拿到响应体。响应体的读取是重点直接resp.body().string()会把body流消费掉导致App后续拿到空body所以必须用source().buffer().clone()先复制一份再读取复制出来的缓冲区。Java.perform(function () { var Long Java.use(java.lang.Long); var RequestBuilder Java.use(okhttp3.Request$Builder); RequestBuilder.build.implement(function () { var req this.build(); try { console.log(\n REQUEST ); console.log([REQ] req.method() req.url().toString()); console.log([REQ-HEADER]\n req.headers().toString()); } catch (e) { console.log([REQ-ERR] e); } return req; }); var ResponseBuilder Java.use(okhttp3.Response$Builder); ResponseBuilder.build.implement(function () { var resp this.build(); try { var req resp.request(); if (req null) return resp; console.log(\n RESPONSE ); console.log([RESP] resp.code() req.method() req.url().toString()); console.log([RESP-HEADER]\n resp.headers().toString()); var body resp.body(); if (body ! null) { var source body.source(); source.request(Long.MAX_VALUE.value); var buffer source.buffer().clone(); var content buffer.readUtf8(); if (content.length 0) { console.log([RESP-BODY] content); } } } catch (e) { console.log([RESP-ERR] e); } return resp; }); });这个脚本覆盖了请求方法、URL、请求头、响应状态码、响应头和响应体。对于日常接口调试这些信息已经足够定位绝大多数问题。4.3 请求体为什么不能无脑打印细心的读者会发现我上面的脚本里没有打印Request的Body。这不是疏漏而是有意为之。RequestBody.writeTo()是OkHttp真正发送请求内容时才会调用的方法。如果你在Hook里提前调用body.writeTo(buffer)把请求体读出来某些类型的RequestBody内部状态会被消耗掉之后OkHttp真正发送时请求体可能变成空内容或者直接抛异常。这种问题极其隐蔽你不容易联想到是自己Hook脚本把请求搞坏了。我的经验做法是优先打印URL、Header和响应体这些已经能覆盖90%的调试需求。如果确实要看请求体先判断body类型对于okio.Buffer这类可重复读取的可以安全复制读取对于流式body不要轻易尝试。你可以额外用下面的代码段单独打印常见Buffer类型的请求体var body req.body(); if (body ! null) { var contentType body.contentType(); var length body.contentLength(); console.log([REQ-BODY-META] type contentType len length); }先看类型和长度再决定要不要冒险读取这是长期实践中比较稳妥的策略。4.4 实测中的输出样式跑起来之后控制台大概是这种感觉 REQUEST [REQ] POST http://192.168.1.10:8080/api/order/list [REQ-HEADER] Content-Type: application/json User-Agent: okhttp/3.12.1 X-Sign: abc123 RESPONSE [RESP] 200 POST http://192.168.1.10:8080/api/order/list [RESP-HEADER] Content-Type: application/json Date: Tue, 08 Apr 2025 10:00:00 GMT [RESP-BODY] {code:0,data:[{id:1,name:test}]}拿这个输出和Charles的报文对比会发现信息完整度并不差而且不用处理那一堆证书告警。特别是遇到400 Bad Request或者request header is too large这种服务器直接拒绝的响应时Hook方案能清楚看到服务器返回的原始内容而不像代理方案那样只给你一个笼统的错误页。5. 适配“安全级别较高的App”时的真实细节现在方案跑通了但现实世界比干净Demo复杂得多。目标App不会老老实实让你用标准包名找到okhttp3.Request$Builder。混淆、加固、多ClassLoader、网络库版本差异这些都是绕不开的细节。5.1 类名被混淆时怎么找Hook点大型App通常开启ProGuard混淆网络库类名可能被改成a.b.c这种短名直接Java.use(okhttp3.Request$Builder)会直接抛ClassNotFoundException。这时候不要硬写类名而是先枚举已加载的类看看目标环境里网络库的真实类长什么样。可以在脚本里加一段扫描逻辑Java.perform(function () { Java.enumerateLoadedClassesSync().forEach(function (clsName) { if (clsName.indexOf(okhttp) ! -1 clsName.indexOf(Request) ! -1) { console.log(clsName); } }); });如果目标App启动后没找到可能是类还没加载。这时候可以先用frida -U -f 包名启动在App里多触发几个界面再执行扫描。找到真实类名后把脚本里的Java.use参数替换成扫描结果即可。如果被混淆得面目全非连okhttp关键词都搜不到那大概率是多ClassLoader搞的鬼继续看下一节。5.2 多ClassLoader导致ClassNotFoundException很多大型App使用插件化架构或自研容器不同的业务模块由不同的ClassLoader加载。你在默认ClassLoader下找不到类但类其实已经被某个插件ClassLoader加载了。解决办法是遍历所有ClassLoader逐个尝试解析目标类。使用Java.ClassFactory.get(loader)为每个ClassLoader创建独立的类工厂再从工厂里解析Hook点Java.enumerateClassLoaders({ onMatch: function (loader) { try { var factory Java.ClassFactory.get(loader); var RequestBuilder factory.use(okhttp3.Request$Builder); RequestBuilder.build.implement(function () { var req this.build(); console.log([REQ] req.method() req.url().toString()); return req; }); } catch (e) { // 这个loader里没有目标类跳过 } }, onComplete: function () { console.log([*] classloader scan done); } });注意这段脚本要在所有ClassLoader都加载完毕后执行才有效。最简单的方式是在Java.perform里包一个setTimeout延迟几秒再扫描。虽不优雅但实际调试中够用。5.3 不同OkHttp版本和不同网络库的差异常规App用的OkHttp版本五花八门有3.x、4.x但包名一直都是okhttp3主要类的Hook点基本稳定。少数老App还在用com.squareup.okhttpOkHttp 2.x这时候类名要改成com.squareup.okhttp.Request$Builder脚本逻辑不用变。如果目标App压根不用OkHttp而是用系统自带的HttpURLConnection那可以Hook它的核心实现类。Android系统内部把HttpURLConnection也实现成了一套OkHttp常用Hook点有com.android.okhttp.internal.huc.HttpURLConnectionImpl.getInputStream()com.android.okhttp.internal.huc.HttpURLConnectionImpl.getOutputStream()com.android.okhttp.internal.huc.HttpURLConnectionImpl.getResponseCode()不回原始Java类的问题最笨也最有效的办法还是枚举已加载类按关键词过滤再根据扫描结果定Hook点。5.4 非Java网络层Hook方案也有边界再强调一遍本章开头提到的边界Android App如果走的是自研Native网络栈比如基于Chromium net库、QUIC、自研长连接协议那么Java层Hook方案完全无能为力。以淘宝为例很多核心接口并不是走OkHttp。你Hookokhttp3.Request$Builder能看到的往往是H5页面里发出去的Ajax请求、部分小程序容器的业务请求以及一些基础REST接口。真正核心的Native请求还是得借助其他更底层的调试手段或者干脆使用目标App自身的调试接口、日志开关。这不是Hook方案的问题而是这个场景本身就超出了Java层Hook的能力范围。6. 从Charles/Fiddler切过来容易踩的错误与排查最后讲几个我在切换方案过程中实际踩过的坑。这些错误在Charles时代也经常遇到但在Hook方案下会以不同形式出现容易让人误判。6.1request header is too large不一定是Hook脚本的问题很多读者第一次跑脚本看到控制台刷出request header is too large第一反应是脚本把Header打太多次导致异常。其实这个错误是服务器返回的意思是请求头的体积超过了服务器/网关的容量限制。通常是因为Cookie、Authorization、自定义Header顺带塞了一堆元数据整体超过8KB甚至16KB。Charles时代这个问题不明显因为代理工具可能帮你裁剪或你压根看不到原始Header大小。Hook方案直连服务器原始Header原样发送问题反而更容易暴露。排查时先看[REQ-HEADER]输出统计Header总长度如果确实过大精简业务侧的冗余Header或者调整服务端网关限额。6.2connection failed: error sending request与代理残留这个错误在Charles时代很常见原因往往是你手机Wifi代理还开着但Charles已经退出或者代理端口被占用。切换到Hook方案后系统代理理论上不再需要设置但如果你之前在代码里、或者通过某些网络库显式设置了Proxy请求依然会尝试走代理然后报error sending request。我的排查顺序是确认系统Wifi代理已关闭手动设置过代理的改为“无”。在App代码的OkHttpClient初始化处HookProxy相关方法看看是否有代码显式设置了代理。用Hook脚本查看请求真正连接的IP和端口判断是直连还是走了某个代理。Hook方案下你拿到的Request对象里带着完整的URL和Header有没有走代理其实很容易看出来。6.3400 Bad Request、stream disconnected before completion这类服务器报错用Charles抓包时这类错误信息往往被工具自身的错误页遮挡真实响应体丢失。用Hook方案Response$Builder.build()会在服务器响应到达的第一时间把Response对象交给你[RESP] 400之后紧跟着的就是服务器返回的错误详情body。这其实是我最推荐的定位方式一旦看到非2xx状态码立刻从[RESP-BODY]读取错误信息大多数情况下服务器会把失败原因写在响应体里。错误类型可能原因排查建议400 Bad Request请求参数或Header格式错误重点看参数签名和Content-Typestream disconnected before completion上游服务器提前断开连接看是否超时、body过大、网关配置request header is too largeHeader体积超限精简Header或调整网关限额connection failed代理残留或网络不通关闭代理检查DNS和IP连通性6.4 从“黑盒抓包”到“白盒定位”的思维转变用Charles久了很容易形成一种思维定式抓不到就是证书问题报错就是代理问题。Hook方案逼着你换个角度思考——你不再是一个站在门外偷听的旁观者而是直接坐在网络库的构造函数旁边看数据的人。这意味着你的调试习惯也要跟着变。以前是“开代理—看列表—筛选域名—看报文”现在是“启动App—看控制台—按关键词过滤—定位到具体方法调用”。刚开始可能不太习惯但用顺手之后你会发现定位一个接口参数问题的速度比之前快很多因为你不只看到了流量还看到了这个流量是从哪个函数、哪条业务逻辑发出来的。我自己现在保留了一台旧Android手机专门做调试机系统里干干净净没有任何证书也不设代理只跑一个frida-server。日常给App做Java层接口分析绝大部分需求都能在这台机器上完成。如果你正被Charles的证书问题折腾得头疼不用硬刚代理层了把这篇文章里的脚本改成你自己的目标包名先跑通一次你会回来感谢这个思路的。
返回列表