让AI学会逆向-把TikTok SSL绕过思路沉淀成Skill
zsk Lv4

免责声明:本文仅用于授权的安全测试、协议分析与学习研究(自己的设备、自己的流量、CTF、合规渗透)。请遵守当地法律法规,严禁用于任何商业或非法用途,由此产生的一切后果与作者无关。

起因

最近在抓 TikTok 最新版 45.7.1,有证书校验抓不到包。找了一圈教程,发现一个规律:每次大版本更新,教程里的现成脚本就失效了——偏移变了、模块结构变了。但它们的核心思路完全一致:hook 掉 SSL_CTX_set_custom_verify,替换证书校验回调,从而绕过 pinning。差异只是每个版本需要做点小调整。

于是我冒出一个想法:既然思路是通用的,能不能把这些文章喂给 AI,让它掌握这套思路,沉淀成一个 skill?以后换版本,我只要让它重新生成对应脚本就行。

参考的几篇文章:

原理:为什么这套思路是通用的

抖音和 TikTok 用的是同一套协议栈——Google 的 Cronet 网络协议栈,它是从 Chrome 浏览器剥离出来的。证书校验在 libsscronet.so 里,核心检测点就是 SSL_CTX_set_custom_verify

需要注意的是,SSL_CTX_set_custom_verifyBoringSSL 提供的 API,用于自定义 TLS 证书验证逻辑,它不是 OpenSSL 的标准 API——所以你在 OpenSSL 官方文档里是搜不到它的。它常见于以 BoringSSL 为底层的项目:Chromium、Android、Envoy、gRPC、各种 QUIC 实现等。

它的 mode 参数有三种取值,第三个参数 callback 就是应用自定义的证书校验逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// SSL_VERIFY_NONE, on a client, verifies the server certificate but does not
// make errors fatal. The result may be checked with |SSL_get_verify_result|. On
// a server it does not request a client certificate. This is the default.
#define SSL_VERIFY_NONE 0x00

// SSL_VERIFY_PEER, on a client, makes server certificate errors fatal. On a
// server it requests a client certificate and makes errors fatal. However,
// anonymous clients are still allowed. See
// |SSL_VERIFY_FAIL_IF_NO_PEER_CERT|.
#define SSL_VERIFY_PEER 0x01

// SSL_VERIFY_FAIL_IF_NO_PEER_CERT configures a server to reject connections if
// the client declines to send a certificate. This flag must be used together
// with |SSL_VERIFY_PEER|, otherwise it won't work.
#define SSL_VERIFY_FAIL_IF_NO_PEER_CERT 0x02

void SSL_CTX_set_custom_verify(
SSL_CTX *ctx, int mode,
enum ssl_verify_result_t (*callback)(SSL *ssl, uint8_t *out_alert));

所以思路就是:Hook SSL_CTX_set_custom_verify,拦下它的 callback(第三个参数),让校验结果返回成功(ssl_verify_ok),在 Native 层全局绕过 SSL Pinning。

一个关键细节:不能无脑 replace(0)

网上很多现成脚本直接把回调返回值一律改成 0(成功)。这在”同步校验”的版本上能用,但在使用异步校验的新版上会踩坑。原因在于 callback 的返回值不是简单的 bool,而是一个三态枚举:

1
2
3
4
5
enum ssl_verify_result_t {
ssl_verify_ok = 0, // 校验成功
ssl_verify_invalid = 1, // 校验失败
ssl_verify_retry = 2, // 异步校验进行中,"稍后再回调我"
};

2(retry)是异步校验的信号——此时校验还没跑完,Cronet 内部状态尚未就绪。如果你把 2 也强行改成 0,等于骗 TLS 状态机”校验已完成”,而实际结构还没填好,后续就会在别的线程崩溃。

所以正确的做法是:只把 1(失败)翻成 0(成功),保留 2(retry)不动,并且用 Interceptor.attachonLeave 里改返回值,而不是Interceptor.replace 把整个回调换掉(替换会跳过原始校验,导致 Cronet 状态不完整而崩溃)。一句话:观察并微调,而不是替换。

除此之外还有两个容易踩的坑,skill 里都处理了:

  • **别 hook 到系统的 libssl.so**。Android 自带一个系统 BoringSSL(libssl.so),它开机就加载好了,但 Cronet 根本不走它。如果脚本图省事 hook 了它,会发现”hook 上了却一个回调都没触发”。真正要 hook 的是 App 自带的 libttboringssl.so / libsscronet.so
  • 跳过 QUIC 的那个校验回调。QUIC 路径会在同一个 SSL_CTX 上再注册一个 custom_verify,它是异步状态机,翻它的返回值同样会崩。skill 通过记录 SSL_CTX_set_quic_method 用过的 ctx,把这些 QUIC ctx 的回调跳过、保持原生。

而这套思路本身不随版本变化,变的只是符号在哪个模块、偏移是多少——这些恰恰可以让 AI 配合 IDA 每次现查。这也是把它做成 skill 的意义:沉淀不变的方法论,把易变的细节交给 AI 现场分析。

做成 Skill

基于上面的想法,我把这几篇文章喂给 AI,让它提炼出这套方法论,做成了一个 Claude Code skill,并开源到了 GitHub:

👉 tiktok-ssl-bypass-skill

有兴趣的也可以按同样的方法自己调一个出来。

使用演示

用之前记得在 IDA 中加载目标的 libsscronet.so 并打开 IDA MCP(skill 会通过 MCP 让 AI 直接分析这个 so)。

然后只需要给 AI 发一句话:

使用 skills rev-cronet-ssl 配合 mcp idapro 给出绕过 tiktok ssl 校验的 frida 脚本

没一会儿脚本就生成出来了:

image

生成的脚本(TikTok 45.7.1 实测通过):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
/*
* TikTok / Douyin Cronet SSL-Pinning bypass — minimal reference.
*
* Target verified: TikTok com.zhiliaoapp.musically 45.7.1 (arm64-v8a).
* IDA (libsscronet.so v45.7.1): SSL_CTX_set_custom_verify and SSL_CTX_set_quic_method
* are import thunks -> the real functions live in libttboringssl.so. So we hook the
* EXPORT by name (Strategy A): resolve it in the app's BoringSSL, capture arg[2]
* (the verify callback) and flip its result 1 (ssl_verify_invalid) -> 0 (ssl_verify_ok).
*
* MANDATORY method (see rev-cronet-ssl skill):
* - observe-and-nudge, NEVER replace the callback. Let the real verifier run, then in
* onLeave flip ONLY 1 -> 0. Leave 2 (ssl_verify_retry, the async signal) untouched.
* - QUIC registers a second custom_verify on the same SSL_CTX (the one that also calls
* SSL_CTX_set_quic_method). Its callback is an async state machine returning 2; flipping
* it desyncs state and crashes a media thread. We remember QUIC ctxs via
* SSL_CTX_set_quic_method and SKIP installing our hook for those ctxs.
*
* Run at spawn (the SSL_CTX is built early; attaching late misses it):
* frida -U -f com.zhiliaoapp.musically -l tiktok_ssl_bypass.js
* frida -U -f com.ss.android.ugc.aweme -l tiktok_ssl_bypass.js # Douyin
*
* Success signal: "hit | caller=libsscronet.so ..." then "force verify 1 -> 0".
* QUIC/UDP video traffic stays native and won't show in an HTTP/TCP proxy — expected.
*/

'use strict';

var CONFIG = {
// App's own BoringSSL only. NEVER add the system libssl.so: it resolves the symbol but
// no Cronet traffic flows through it, so you'd hook it and see zero callback hits.
modules: [
'libttboringssl.so', // ByteDance BoringSSL (real home of the symbols)
'libsscronet.so' // Cronet (PLT thunks; kept as a fallback resolver)
],
pollMs: 200,
pollTimeoutMs: 60000,
verbose: true
};

var installed = {}; // module -> true (custom_verify export hooked)
var quicWatched = {}; // module -> true (quic_method export hooked)
var hookedCbs = {}; // callback addr string -> true
var quicCtxs = {}; // SSL_CTX addr string -> true (skip these)

function log(m) { if (CONFIG.verbose) console.log('[tt-ssl] ' + m); }

function findModuleByName(name) {
if (Process.findModuleByName) return Process.findModuleByName(name);
try { return Process.getModuleByName(name); } catch (_) { return null; }
}

function fmtAddr(addr) {
var mod = Process.findModuleByAddress(addr);
return mod === null ? addr.toString() : mod.name + '!0x' + addr.sub(mod.base).toString(16);
}

function findExport(moduleName, symbol) {
var mod = findModuleByName(moduleName);
if (mod === null) return null;
try { if (mod.getExportByName) return mod.getExportByName(symbol); } catch (_) {}
try { if (Module.getExportByName) return Module.getExportByName(moduleName, symbol); } catch (_) {}
try { if (Module.findExportByName) return Module.findExportByName(moduleName, symbol); } catch (_) {}
return null;
}

// Hook the verify callback: enum ssl_verify_result_t (*)(SSL*, uint8_t*).
// 0=ok, 1=invalid, 2=retry. Flip ONLY 1 -> 0. Never touch 2.
function hookVerifyCallback(cbPtr, src) {
if (cbPtr === null || cbPtr.isNull()) return;
var key = cbPtr.toString();
if (hookedCbs[key]) return;
hookedCbs[key] = true;

log('hook verify callback ' + cbPtr + ' (' + fmtAddr(cbPtr) + ') from ' + src);
try {
Interceptor.attach(cbPtr, {
onLeave: function (retval) {
if (retval.toInt32() === 1) {
log('force verify 1 -> 0 (ssl_verify_ok) [' + src + ']');
retval.replace(0);
}
}
});
} catch (e) {
console.error('[tt-ssl] failed to hook callback ' + cbPtr + ': ' + e);
}
}

// Remember every SSL_CTX passed to SSL_CTX_set_quic_method so we can skip QUIC ctxs.
function watchQuicMethod(moduleName) {
if (quicWatched[moduleName]) return;
var addr = findExport(moduleName, 'SSL_CTX_set_quic_method');
if (addr === null) return;
quicWatched[moduleName] = true;
log('watch SSL_CTX_set_quic_method @ ' + addr + ' (' + fmtAddr(addr) + ') in ' + moduleName);
Interceptor.attach(addr, {
onEnter: function (args) {
var ctx = args[0];
if (ctx && !ctx.isNull()) {
quicCtxs[ctx.toString()] = true;
log('QUIC ctx registered: ' + ctx + ' (will skip its custom_verify)');
}
}
});
}

// Strategy A: hook exported SSL_CTX_set_custom_verify, capture arg[2], skip QUIC ctxs.
function hookExport(moduleName) {
if (installed[moduleName]) return true;
var addr = findExport(moduleName, 'SSL_CTX_set_custom_verify');
if (addr === null) return false;

installed[moduleName] = true;
log('hook SSL_CTX_set_custom_verify @ ' + addr + ' (' + fmtAddr(addr) + ') in ' + moduleName);

Interceptor.attach(addr, {
onEnter: function (args) {
var ctx = args[0];
var caller = Process.findModuleByAddress(this.returnAddress);
log('hit | caller=' + (caller ? caller.name : 'unknown') +
' | ctx=' + ctx + ' | mode=' + args[1] + ' | cb=' + args[2]);

if (ctx && quicCtxs[ctx.toString()]) {
log('skip: QUIC ctx ' + ctx + ' (async retry callback, leave native)');
return;
}
hookVerifyCallback(args[2], moduleName);
}
});
return true;
}

function tryHookModule(moduleName) {
// Register the QUIC watcher first so ctxs are known before custom_verify fires.
watchQuicMethod(moduleName);
return hookExport(moduleName);
}

function scanLoaded() {
var any = false;
CONFIG.modules.forEach(function (m) {
if (findModuleByName(m) !== null) { if (tryHookModule(m)) any = true; }
});
return any;
}

function watchDlopen() {
['android_dlopen_ext', 'dlopen'].forEach(function (loaderName) {
var loader = null;
try { loader = Module.getExportByName(null, loaderName); } catch (_) {}
if (loader === null) { try { loader = Module.findExportByName(null, loaderName); } catch (_) {} }
if (loader === null) return;
Interceptor.attach(loader, {
onEnter: function (args) {
this.target = null;
var p = args[0];
if (!p || p.isNull()) return;
var path = p.readCString();
if (path === null) return;
for (var i = 0; i < CONFIG.modules.length; i++) {
if (path.indexOf(CONFIG.modules[i]) >= 0) { this.target = CONFIG.modules[i]; break; }
}
},
onLeave: function () {
if (this.target) { log('loaded ' + this.target); tryHookModule(this.target); }
}
});
});
}

// Poll until the app's BoringSSL is hooked. Keep polling for libttboringssl.so specifically;
// do not accept the system libssl.so (it resolves the symbol but carries no Cronet traffic).
function startPolling() {
if (scanLoaded() && installed['libttboringssl.so']) return;
var elapsed = 0;
var timer = setInterval(function () {
elapsed += CONFIG.pollMs;
scanLoaded();
if (installed['libttboringssl.so']) { clearInterval(timer); return; }
if (elapsed >= CONFIG.pollTimeoutMs) {
clearInterval(timer);
log('gave up after ' + (CONFIG.pollTimeoutMs / 1000) + 's; no BoringSSL hooked. ' +
'Confirm the app uses libttboringssl.so / libsscronet.so and run at spawn (-f).');
}
}, CONFIG.pollMs);
}

function main() {
log('starting; modules: ' + CONFIG.modules.join(', ') + ' (poll ' + CONFIG.pollMs + 'ms)');
watchDlopen();
startPolling();
}

setImmediate(main);

一个踩坑:红米设备闪退

我最先用红米 Note 12 Turbo 测试,能抓到包,但播放视频没一会儿就闪退了

image

一开始我以为是脚本的锅,改了好几版(跳过 QUIC、保留 retry……)都没解决。于是抓了崩溃栈来看,关键几帧是:

1
2
3
4
#00 libstagefright.so  getServerConfigurableFlag+504     ← strtoll 解析配置,撞空指针
#01 libstagefright.so VideoRenderQualityTracker::Configuration::getFromServerConfigurableFlags
#02 libstagefright.so MediaCodec::MediaCodec ← 创建视频解码器
#03 libstagefright.so MediaCodec::CreateByComponentName

崩溃点在 libstagefright.so——这是 Android 系统媒体框架,不是 TikTok、更不是我们的 hook。它在创建视频解码器时,读一个服务端下发的配置项(server-configurable flag),解析时撞了空指针。

崩溃的根因是 frida 的 spawn 注入干扰了 MIUI 媒体框架的初始化,加上这台 ROM 的 VideoRenderQualityTracker 对畸形配置不设防,共同触发的一个系统级 bug,和证书绕过没有任何因果关系。

这也是为什么我没有把这个崩溃的”修复”塞进 skill——它是特定 ROM 的问题,偏移和表现都因设备而异,硬编码进去只会破坏 skill 的通用性。能复用的是方法论,设备特有的坑就留在设备那一侧。

换了一台 Pixel 设备测试,就没有这个问题了:

image

Charles 也顺利抓到了包:

image

关于视频画面刷不出来:绕过之后,API、评论、头像、封面这些走 TCP HTTPS 的请求都能正常抓到,但视频流本身走的是 QUIC(UDP),而 Charles/mitmproxy 这类 HTTP 代理默认看不到 UDP 流量——所以视频可能加载不出来。这是预期现象,不是绕过失败。如果确实要抓视频流,得额外做 QUIC 降级(比如禁用 UDP 443 逼它回落到 TCP),但那是另一个话题,不影响接口抓包。

小结

这次实践验证了最初的想法是可行的:把通用的逆向思路(hook SSL_CTX_set_custom_verify、只翻 1→0、跳过 QUIC 异步回调、排除系统 libssl.so)沉淀进一个 skill,配合 IDA MCP,AI 就能在版本更新后自动定位符号、生成对应的 Frida 脚本。

比起收藏一堆很快就过期的现成脚本,把”方法论”交给 AI 显然更划算——下次 TikTok 再更新,我只需要在 IDA 里加载新版 so,再发一句话就行。

再次提醒:本文与仓库仅供学习研究,请在授权范围内使用。

 评论
评论插件加载失败
正在加载评论插件
由 Hexo 驱动 & 主题 Keep
访客数 访问量