为什么抓包工具失效时要 hook libssl
App 做了 SSL Pinning 后,Charles/mitmproxy 这类中间人抓包全部失效——证书校验在客户端代码里写死,代理证书不被信任。但有一个地方永远是明文:加密前和解密后的数据。所有 HTTPS 流量最终都要经过 SSL 库的 SSL_write(加密前)和 SSL_read(解密后),在这里 hook,Pinning 形同虚设。
Android 上绝大多数 App 用 BoringSSL(Google 的 OpenSSL 分支),SO 名字是 libssl.so,或静态链接进自己的 SO(比如抖音的 libttboringssl)。
核心 hook 脚本
function hookSSL(soName) {
var SSL_read = Module.findExportByName(soName, 'SSL_read');
var SSL_write = Module.findExportByName(soName, 'SSL_write');
if (!SSL_read || !SSL_write) {
console.log(soName + ' 无导出符号(可能静态链接),换特征扫描');
return;
}
// SSL_write(SSL* ssl, const void* buf, int num)
Interceptor.attach(SSL_write, {
onEnter: function(args) {
var buf = args[1], len = args[2].toInt32();
try {
var data = buf.readByteArray(Math.min(len, 4096));
var text = String.fromCharCode.apply(null, new Uint8Array(data));
if (text.match(/^(GET|POST|PUT|DELETE|PATCH) /)) {
console.log('\n[SSL_write] ' + text.substring(0, 2000));
}
} catch(e) {}
}
});
// SSL_read(SSL* ssl, void* buf, int num) —— 返回后 buf 里才是明文
Interceptor.attach(SSL_read, {
onEnter: function(args) { this.buf = args[1]; },
onLeave: function(retval) {
var len = retval.toInt32();
if (len <= 0) return;
try {
var data = this.buf.readByteArray(Math.min(len, 4096));
var text = String.fromCharCode.apply(null, new Uint8Array(data));
if (text.indexOf('HTTP/') === 0 || text.indexOf('{') === 0) {
console.log('\n[SSL_read] ' + text.substring(0, 2000));
}
} catch(e) {}
}
});
}
hookSSL('libssl.so');精髓一:SSL_read 必须在 onLeave 读
新手最常犯的错:在 onEnter 里读 buf——那时 buf 是空 buffer,数据还没写进去。SSL_read 的语义是"把解密后的数据填进 buf",所以必须等函数返回(onLeave)后读,且读取长度用返回值(实际读到的字节数),不是传入的 buf 大小。
精髓二:静态链接的 BoringSSL 没有导出符号
大厂 App 常把 BoringSSL 静态编译进自己的 SO(libttboringssl、libsscronet 等),findExportByName 返回 null。对策是特征码扫描:
function findSSLFunctions(soName) {
var module = Process.findModuleByName(soName);
// SSL_write 的特征:函数开头有特定的指令序列
// 实战做法:从同版本未 strip 的 libssl.so 提取函数开头字节 pattern
var pattern = 'ff 03 01 d1 fd 7b 01 a9'; // 示例,实际 pattern 要按版本提取
Memory.scan(module.base, module.size, pattern, {
onMatch: function(addr) { console.log('疑似 SSL_write @ ' + addr); },
onComplete: function() {}
});
}更稳的办法:SSL 函数必然引用 "SSL" 相关的错误字符串(ssl3_write_bytes 等符号名常量),在 IDA 里按字符串交叉引用定位函数,算出偏移后 Frida 里 base.add(offset) 直接 hook。
精髓三:HTTP/2 和 QUIC 的坑
- HTTP/2:头部是 HPACK 压缩的二进制帧,hook 到的明文不是可读的 HTTP 文本。过滤条件改成匹配
:method、:path的二进制特征,或干脆只看 body 帧 - QUIC/HTTP3:走 UDP,用的是 QUIC 自己的加密(虽然底层也是 BoringSSL 的加密函数,但调用路径不同)。SSL_read/SSL_write 可能根本不被调用。抖音系 App 默认走 QUIC 时,要么 hook QUIC 层的读写函数,要么禁用 QUIC 逼它回退 TCP(抓包时配合
--disable-quic类参数或防火墙封 UDP 443)
配套:拿到明文之后
hook 到明文只是开始。建议把数据通过 send() 发到 Python 侧落盘,配合时间戳和调用栈(Thread.backtrace)做请求-响应配对,就是一个完整的"应用层抓包器"——比中间人方案信息更全(能看到 App 加签名前后的两个版本)。
总结
- SSL Pinning 防的是中间人,防不住加密端点本身的 hook
- SSL_write 在 onEnter 读,SSL_read 必须在 onLeave 读
- 静态链接无符号时,用字符串交叉引用在 IDA 定位 + 偏移 hook
- QUIC 要单独处理,最简单的办法是封 UDP 443 逼回 TCP
交流微信:run1255