抖音抓包LSPosed插件多版本可用的技术刨析
如果只需要插件,不想看技术实现,可以关注公众号,发送“抖音插件”
1. 落点选 custom_verify
libsscronet.so 会引用 BoringSSL 的 SSL API,比如 SSL_CTX_set_custom_verify 和 SSL_do_handshake;真实实现位于 libttboringssl.so。所以这里 hook 的不是 Cronet 的导入符号,而是 BoringSSL 里的 custom_verify setter。
直接 hook Cronet 的 CertVerify 语义上最直观,但稳定性不如 BoringSSL setter。
一方面,Cronet 的 C++ wrapper、虚函数入口、对象方法里经常能看到 paciasp。在 Android 15+ 的 arm64 设备上,这类函数和 PAC/TBI 关系很近。inline hook 一旦破坏认证上下文,崩溃不一定发生在 hook 点,而可能延迟出现在后续逻辑里。
另一方面,Cronet 的证书校验不只是一个 bool。它还会填充证书链、host、OCSP、SCT、verify flags、SSLInfo 等状态。高层粗暴截断,很容易让后续逻辑拿到不完整状态。
custom_verify setter 更干净:


这两个函数的特点很适合 hook:
函数体短没有 paciaspx2 就是 verify callback语义稳定,和上层业务混淆关系不大
2. 注册阶段:保存原 callback,再替换
Cronet:SSL_CTX_set_custom_verify(ctx, mode, cronet_cb)Detour:保存 cronet_cb调原 setter,把 MyVerifyCb 写入 BoringSSLTLS handshake:BoringSSL 调 MyVerifyCbMyVerifyCb 再调 cronet_cbMyVerifyCb 覆盖最终结果
核心 detour:
using VerifyCb = int (*)(void *ssl, uint8_t *out_alert);using SetVerifyFn = void (*)(void *obj, int mode, VerifyCb cb);voidCtxSetVerifyDetour(void *ctx, int mode, VerifyCb cb){if (cb && cb != MyVerifyCb) {std::lock_guard<std::mutex> lk(g_mtx);g_ctx_cb[ctx] = cb;}g_orig_ctx_set(ctx, mode, MyVerifyCb);}
BoringSSL 的 verify callback 有 per-CTX 和 per-SSL 两种注册粒度。抖音 Cronet 的主流量都通过 SSL_CTX_set_custom_verify 在 CTX 级注册,所以只 hook 这一个 setter 就能覆盖。
不要丢掉原始 callback。原 callback 后面还要被调用,用来维持 Cronet / BoringSSL 内部状态。
3. 为什么要 per-CTX 保存
不能只用一个全局变量保存“最后一次看到的 callback”。
同一个进程里可能有多套 TLS 使用者:
主 Cronet媒体下载PCDN / ODLWebView / sandbox其他 native 网络组件
它们各自创建自己的 SSL_CTX,注册各自的 verify callback。如果只保存一个全局 callback,后注册的组件会覆盖前面的组件。结果就是:
SSL 对象 A(属于 CTX a)-> 被喂给组件 B 的 callback-> callback 按 B 的上下文解析 A-> 状态错乱或崩溃
所以要按 CTX 对象保存:
std::unordered_map<void *, VerifyCb> g_ctx_cb; // SSL_CTX* -> 这个 CTX 自己的原始 callback但握手时 BoringSSL 交给回调的是 SSL*,不是 SSL_CTX*。要回到这张表,还得先从 SSL* 反查出它的 SSL_CTX*——这就是下一节的事。
4. 从 SSL* 找回 SSL_CTX*
主流量通常走 SSL_CTX_set_custom_verify。注册时我们保存的是 ctx -> callback,但握手时 MyVerifyCb 收到的是 SSL* ssl,所以需要从 ssl 反查 ctx。
SSL_get_SSL_CTX 很短:

代码里直接按偏移读取:
constexpr uintptr_t kSslCtxOff = 0x68;void *ctx = *reinterpret_cast<void *const *>(reinterpret_cast<const char *>(ssl) + kSslCtxOff);
然后用这个 ctx 去 g_ctx_cb 里找原始 callback,这个值目前看到抖音39.1.0-39.3.0目前最新版本没变化,其余版本我就不一一测试了,需要自行测试。
5. 握手阶段:MyVerifyCb
MyVerifyCb 做三件事:
1. 从 SSL* 反查 SSL_CTX*,再到 g_ctx_cb 找回 Cronet 原始 callback2. 调用原始 callback,让原始校验链路继续执行3. 根据返回值语义决定是否覆盖
核心逻辑:
intMyVerifyCb(void *ssl, uint8_t *out_alert){VerifyCb orig = nullptr;{std::lock_guard<std::mutex> lk(g_mtx);void *ctx = *reinterpret_cast<void *const *>(reinterpret_cast<const char *>(ssl) + kSslCtxOff);auto it = g_ctx_cb.find(ctx);if (it != g_ctx_cb.end()) orig = it->second;}int r = orig ? orig(ssl, out_alert) : kSslVerifyOk;return r == kSslVerifyRetry ? kSslVerifyRetry : kSslVerifyOk;}
查找路径只有一条:ssl -> ctx(按 §4 的偏移)-> g_ctx_cb。如果没找到(orig == nullptr),通常意味着 hook 晚于 CTX 创建,或偏移变了;这种情况应当打诊断暴露出来,而不是悄悄放行。
这一步的重点是先调原 callback。它会继续执行 Cronet 的证书校验逻辑,填充后续还会使用的状态。我们只是在它返回之后改最终结果。
custom verify callback 的返回值不是普通 bool:
0 = ssl_verify_ok1 = ssl_verify_invalid2 = ssl_verify_retry
同步失败可以覆盖:
origret=1 -> 0但异步校验不能提前放行:
origret=2 -> 2retry(2) 表示证书校验还在异步流程里。这个时候如果直接改成 ok(0),握手会被提前推进,但 Cronet 内部状态还没准备好,后面很容易触发断言或状态不一致。
6. 为什么还要 hook SSL_get_verify_result
只 hook custom verify setter,可以处理同步失败;但更严格的异步路径里,最终错误码还会被后续读取。
所以补一层:
using GetVerifyResultFn = long (*)(void *ssl);longGetVerifyResultDetour(void *ssl) {// 仍调原函数让内部状态走完,只把最终错误码覆盖成 0if (g_orig_get_verify_result) g_orig_get_verify_result(ssl);return 0;}
这层的作用是把覆盖动作放到更晚的位置:
custom_verify 阶段:尊重 retry,不提前推进握手verify_result 读取阶段:把最终错误码覆盖成 0
这也是整套方案稳定的关键:握手状态按真实流程走,最终结果在读取点再改。

7. 总结

这套方案的核心不是直接改 Cronet 的证书校验结果,而是接管 BoringSSL 的 custom_verify callback 注册点。最终只剩两个 hook 点、一张 map:
注册阶段:Cronet 调 SSL_CTX_set_custom_verify 注册 verify callbackhook 这个 setter,把原 callback 存进 g_ctx_cb[ctx]把真正写入 BoringSSL 的 callback 换成 MyVerifyCb握手阶段:BoringSSL 调 MyVerifyCbMyVerifyCb 用 ssl->ctx 偏移反查 g_ctx_cb,找回并调用 Cronet 原 callbackretry(2) 原样返回同步失败(1) 覆盖为 ok(0)结果读取阶段:SSL_get_verify_result 覆盖为 0
不要跳过原始校验流程;让它把状态喂完整,再在返回值边界上做最小改动。
夜雨聆风