百趣云 百趣云的博客

hook libssl 抓 HTTPS 明文:SSL_read/SSL_write 拦截实战

为什么抓包工具失效时要 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 加签名前后的两个版本)。

总结

  1. SSL Pinning 防的是中间人,防不住加密端点本身的 hook
  2. SSL_write 在 onEnter 读,SSL_read 必须在 onLeave 读
  3. 静态链接无符号时,用字符串交叉引用在 IDA 定位 + 偏移 hook
  4. QUIC 要单独处理,最简单的办法是封 UDP 443 逼回 TCP

交流微信:run1255

By 百趣云 阅读量:2 On