ARTICLE DETAIL

资讯详情

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

Frida 拦截 SSL_read/SSL_write 抓 HTTPS 明文:ApiResolver 定位与配置骨架

Frida 拦截 SSL_read/SSL_write 抓 HTTPS 明文:ApiResolver 定位与配置骨架 1. 为什么 SSL_read/SSL_write 是 HTTPS 抓包的“命门”做移动端逆向或者接口调试时最头疼的场景之一就是App 走的是 HTTPS证书校验还做了 pinning抓包工具挂上去要么直接断连要么只能看到一堆加密后的二进制。这时候与其跟证书校验死磕不如换个思路——不去解密网络包而是直接在进程内存里截住明文。SSL_read和SSL_write就是干这个的。无论上层用的是 OkHttp、Cronet 还是自研网络库只要最终走 OpenSSL/BoringSSL 的 TLS 栈加解密前后的明文一定会经过这两个函数SSL_write(SSL *ssl, const void *buf, int num)应用把明文交给 TLS 层去加密发送buf就是请求明文。SSL_read(SSL *ssl, void *buf, int num)TLS 层解密完把明文交给应用buf就是响应明文。所以只要用 Frida 把这两个函数 hook 住在onEnter里读args[1]指向的内存就能拿到 HTTP 请求行、Header、Body 的原始字节。这比装系统证书、绕 pinning 稳定得多也不依赖具体网络库。这篇要解决的核心问题是怎么用ApiResolver动态定位这两个符号而不是把地址写死。因为不同 App 用的 so 名字不一样libssl.so、libboringssl.so、libflutter.so里内嵌的都有写死模块名和偏移换个版本就废。ApiResolver(module)配合exports:*!SSL_*通配匹配能自动把当前进程里所有导出符号扫一遍命中即挂适配性高很多。适合谁看做 Android/iOS 接口分析、安全测试、协议逆向的同学已经会基本 Frida 注入但被“符号找不到/地址写死失效”卡住的人。下面从环境准备到脚本骨架、再到验证和排障一步步给可复制的配置。2. 前置准备Frida 环境与 TaoToken 凭证通道2.1 Frida 侧要装什么PC 端装frida-tools设备端跑frida-server这是老生常谈但每次都要确认的两件事pip install frida-tools frida --version设备端以 Android 为例需 root 或可调试环境把对应架构的frida-serverpush 进去并赋权启动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能列出进程就说明 USB 通道正常。iOS 侧如果是越狱设备走 Cydia/Sileo 装 frida 即可frida-ps -U同样能验证。2.2 为什么这里要提 TaoToken抓包只是手段最终目的是把请求/响应明文拿去分析、复现、做自动化调用。当你把抓到的接口整理成脚本批量跑时绕不开一个现实问题调用凭证散落在各个脚本里Key 一多就乱轮换和额度也没法统一看。我的做法是把模型调用、编码 Agent 这类需要 Key 的通道统一收口到 TaoToken 管理。它提供统一的 API 入口和 Key 管理抓包分析完接口后需要接模型或跑自动化时直接复用同一套凭证不用在每个脚本里硬编码。官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基址https://taotoken.net/api拿 Key 的地方https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content注意抓包脚本本身不依赖 TaoToken它只是你后续做接口复现/自动化时的凭证通道。两者解耦别混在一起。3. 可复制配置ApiResolver 定位 拦截脚本骨架3.1 核心思路用 ApiResolver 扫导出符号ApiResolver(module)的enumerateMatches支持通配符格式是exports:模块模式!符号模式。几个常用写法匹配表达式含义exports:*libssl*!SSL_*模块名含 libssl 的所有 SSL_ 导出exports:*!SSL_read任意模块里的 SSL_readexports:*libboringssl*!SSL_*BoringSSL 命名exports:*libflutter*!SSL_*Flutter 内嵌 TLS实际写脚本时我一般用exports:*!SSL_*全扫再用 JS 过滤出SSL_read/SSL_write这样不管 so 叫什么都能命中。3.2 精简版脚本 capture_ssl.js先给能直接跑的版本保存为capture_ssl.js// capture_ssl.js function hookSSL() { const targets [SSL_read, SSL_write]; const resolver new ApiResolver(module); resolver .enumerateMatches(exports:*!SSL_*) .filter(function (m) { return targets.some(function (t) { return m.name.indexOf(t) 0; }); }) .forEach(function (m) { console.log([] hook -, m.name, m.address); Interceptor.attach(m.address, { onEnter: function (args) { // int SSL_read(SSL *ssl, void *buf, int num); // int SSL_write(SSL *ssl, const void *buf, int num); this.fn m.name; this.ssl args[0]; this.buf ptr(args[1]); this.num args[2].toInt32(); if (this.num 0) return; let data; try { data this.buf.readByteArray(this.num); } catch (e) { console.log([-] readByteArray failed:, e); return; } send( { code: this.fn.indexOf(write) 0 ? 100 : 200, ssl: this.ssl.toString(), fn: this.fn, len: this.num }, data ); }, onLeave: function (retval) { // 返回实际读写的字节数负数表示出错 const n retval.toInt32(); if (n 0) { console.log([-], this.fn, retval , n); } } }); }); } if (typeof Java ! undefined Java.available) { Java.perform(hookSSL); } else { hookSSL(); }关键点说明args[1]是buf指针args[2]是长度num。必须在onEnter里读因为onLeave时缓冲区可能已被复用或释放。readByteArray(num)读原始字节比readCString安全——HTTP Body 里可能有\0用字符串读会截断。send(payload, data)第二个参数是二进制数据Python 侧通过on_message(message, data)的data拿到避免二进制在 JSON 里被破坏。3.3 完整版Python 侧做请求-响应配对精简版只打印实际分析需要把请求和响应按 SSL 句柄配对。用SSL *ssl指针作为 keySSL_write存请求SSL_read存响应# host.py import frida import zlib device frida.get_usb_device() pid device.get_frontmost_application().pid # 或手动指定 session device.attach(pid) with open(capture_ssl.js, r, encodingutf-8) as f: script session.create_script(f.read()) pairs {} def show_data(headers, payload): try: headers headers.decode(utf-8) except Exception: pass try: payload payload.decode(utf-8) except Exception: pass print(headers, \n\n, payload) def on_message(message, data): if message[type] ! send: return p message[payload] ssl p[ssl] if p[code] 100: # SSL_write - 请求 pairs[ssl] {w: bytearray(data)} elif p[code] 200: # SSL_read - 响应 if ssl not in pairs: return if r not in pairs[ssl]: pairs[ssl][r] bytearray(data) else: pairs[ssl][r].extend(data) raw bytes(pairs[ssl][r]) parts raw.split(b\r\n\r\n, 1) if len(parts) ! 2: return r_headers, r_body parts if bContent-Encoding: gzip in r_headers: try: r_body zlib.decompress(r_body, 16 zlib.MAX_WBITS) except Exception: return # 响应还没收完等下一段 w_raw bytes(pairs[ssl][w]) w_parts w_raw.split(b\r\n\r\n, 1) if len(w_parts) ! 2: del pairs[ssl] return print(* * 100) show_data(w_parts[0], w_parts[1]) print(\n) show_data(r_headers, r_body) print(* * 100) del pairs[ssl] script.on(message, on_message) script.load() input(按回车退出...\n) session.detach()这里处理了两个容易踩的坑响应分段大响应会多次SSL_read需要按SSL句柄累积和gzip 解压Content-Encoding: gzip时手动解解压失败说明还没收完直接 return 等下一段。4. 验证请求注入、触发、比对明文4.1 启动注入两种方式按场景选# 方式一attach 已运行进程 frida-ps -U | grep 目标App frida -U -p pid -l capture_ssl.js # 方式二spawn 冷启动能抓到启动阶段的请求 frida -U -f com.example.app -l capture_ssl.js --no-pause用 Python 脚本跑就是python host.py。4.2 触发请求并观察注入成功后控制台会先打印命中的符号[] hook - SSL_read 0x7f8a1c2d30 [] hook - SSL_write 0x7f8a1c2d80然后你在 App 里点一下刷新、登录、下拉列表触发网络请求。正常的话会看到成对的输出GET /api/v1/user/profile HTTP/1.1 Host: api.example.com Authorization: Bearer eyJ... ... {code:0,data:{uid:10086,name:test}}4.3 怎么判断“抓对了”三个比对动作和抓包工具对照如果同时开了 Charles/mitmproxyURL、Header 应该一致但 Frida 这边能看到解密后的 Body即使证书 pinning 让抓包工具失效。看长度onEnter里num和onLeave的retval应该相等正常读写。如果retval 0说明这次 TLS 操作失败数据不可信。看请求行明文一定以GET/POST/HTTP/1.1开头。如果开头是乱码说明 hook 到了非 TLS 的SSL_*符号或者读的偏移不对。提示如果只看到SSL_write没有SSL_read多半是响应走了别的路径比如 HTTP/2 的SSL_read被内联优化可以试试把匹配放宽到exports:*!*SSL*。5. 本篇常见错排查5.1 ApiResolver 匹配不到符号现象脚本加载后没有任何[] hook -输出。原因通常是模块名或符号名对不上。排查步骤// 先列出所有含 SSL 的导出确认符号真实存在 const r new ApiResolver(module); r.enumerateMatches(exports:*!*SSL*).forEach(function (m) { console.log(m.name, m.address); });如果这条也空说明 TLS 实现是静态链接进主 so 的符号没导出。这种情况ApiResolver无能为力得改用Module.enumerateSymbols扫内部符号或者直接 hookSSL_read的调用方。5.2 读到的数据是乱码readByteArray读的是原始字节如果打印出来是乱码先确认是不是二进制比如 protobuf、图片。用十六进制看console.log(hexdump(this.buf, { length: this.num, ansi: false }));如果 hexdump 开头是16 03 01或17 03 03那是 TLS 记录层数据说明你 hook 的位置在加密之后——检查是不是把args[1]和args[2]搞反了或者 hook 到了SSL_write的内部实现而非导出符号。5.3 响应被截断大响应会分多次SSL_read单次send只拿到一段。这就是完整版脚本里用pairs[ssl][r].extend(payload)累积的原因。判断是否收完看 Header 里的Content-Length或者等\r\n\r\n之后 body 长度够了再解析。5.4 脚本注入后 App 崩溃最常见的是在onLeave里访问this.buf——此时缓冲区可能已释放。所有内存读取都放在onEnter。另外readByteArray的 length 别超过num越界读会触发 SIGSEGV。5.5 抓不到 Flutter/RN 的请求Flutter 用的是自己打包的 BoringSSL符号可能被 strip。试试new ApiResolver(module).enumerateMatches(exports:*libflutter*!*SSL*)如果还是没有考虑 hook Dart 层的HttpClient或者用SSL_read的上层调用点。6. 抓包之后把接口复现接入统一 Key 通道抓到明文只是第一步。真正的工作量在于把接口整理成可复现的请求然后批量跑、做回归、接自动化。这时候你会需要模型能力来做接口语义分析、参数推断、异常归类——而这些调用都需要 Key。我的习惯是把这类凭证统一放在 TaoToken 管理抓包脚本和调用脚本各管各的Key 只在一处维护需要跑模型对话分析接口语义https://taotoken.net/model-chat?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content长期做编码/Agent 自动化https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content管理 Key 和额度https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基址固定为https://taotoken.net/api脚本里配置一次后续换 Key 不用改代码。最后给个实操建议capture_ssl.js里的send数据量很大时Frida 的 JSON 序列化会成为瓶颈。如果只关心特定域名在onEnter里先做一次字符串预判命中目标 Host 再send能显著降低开销const preview this.buf.readUtf8String(Math.min(this.num, 200)); if (preview.indexOf(api.example.com) 0) return;这样过滤后日志干净也不会因为高频请求把 Frida 通道打满。
返回列表