抖音 App 抓包实战:从 SSL Pinning 到 Frida Gadget 注入
本文记录一次在授权测试环境中,对抖音 Android
39.2.0做 HTTPS 抓包的完整过程。重点不是“装个代理就抓包”,而是解释为什么大型 App 会出现抓包失败、SSL Pinning 在链路里做了什么,以及如何用 Frida Gadget 把 hook 脚本内置进 APK,让 App 启动时自动处理证书校验逻辑。
本文只讨论自有设备、授权 App 安全测试和协议分析场景。请不要把这里的流程用于未授权目标。
一、问题背景:为什么抖音不能直接抓包
移动端 HTTPS 抓包的常规链路很简单:
Android 手机 -> 设置 Wi-Fi HTTP 代理 -> Proxyman / Charles / Burp -> 安装代理 CA 证书 -> App HTTPS 请求被代理解密展示普通 App 这样基本就够了。但抖音这类大型 App 通常不会只依赖 Android 系统证书链,而会在网络库内部做额外校验,也就是常说的 SSL Pinning。
典型现象是:
浏览器能抓包普通 App 能抓包抖音只出现 CONNECTProxyman 显示 HTTPS_CLIENT_ERRORApp 页面显示网络错误这说明代理链路不一定断了。更常见的原因是:App 发现当前 TLS 连接里的服务端证书不是它预期的证书或证书链,于是主动中断握手。
二、SSL Pinning 做了什么
正常 HTTPS 信任链大致是:
服务端证书 -> 中间 CA -> 根 CA -> 系统信任库抓包代理的原理是中间人代理:
App <-> Proxyman 伪造的站点证书 <-> Proxyman <-> 真实服务端证书只要手机信任 Proxyman 的根证书,普通 HTTPS 就能被解开。
但 SSL Pinning 会额外校验:
服务端证书公钥 hash 是否匹配证书链是否匹配内置 CA证书 subject / issuer 是否符合预期native TLS callback 返回值是否成功Cronet / TTNet 内部证书校验结果是否通过所以即使系统已经安装了 Proxyman CA,App 仍然可能拒绝连接。
抖音 Android 这一版里,网络栈重点在 native 侧:
libsscronet.solibttboringssl.solibttcrypto.so这意味着只改 Java 层 TrustManager 往往不够。要稳定处理 HTTPS 抓包,需要关注 native TLS 和 Cronet/TTNet 的证书校验路径。
三、应对 SSL Pinning 的几种路线
抓包前先明确目标:我们不是要“关闭 HTTPS”,而是在测试设备上让 App 接受代理生成的证书,从而观察协议内容。
常见路线有四类。
1. 安装用户 CA / 系统 CA
这是最基础的方法。
安装 Proxyman CA设置手机 Wi-Fi 代理开启 SSL Proxying优点是无侵入。缺点是只能处理没有强 pinning 的 App。抖音这类 App 通常不够。
Android 7 以后,App 默认不一定信任用户证书。如果 App 没有显式允许 user CA,即使安装了证书也可能失败。
2. 改 network security config
对普通 App,可以 patch network_security_config,让 App 信任用户证书。
但抖音这种网络栈主要走 TTNet/Cronet native 校验,改 XML 通常不是关键点。
3. Frida 动态注入
常规方式是手机启动 frida-server:
frida -U -f com.ss.android.ugc.aweme -l hook.js优点是快,适合调试。缺点是每次都要启动 server、spawn/attach 进程,App 重启后还要重新处理;有些环境也不方便暴露 Frida 服务。
4. Frida Gadget 注入 APK
Frida Gadget 的思路是把 Frida runtime 放进 APK:
App 启动 -> Application 主动 loadLibrary("frida-gadget") -> Gadget 读取配置 -> Gadget 加载内置脚本 -> 脚本自动 hook SSL/Cronet优点:
不需要单独启动 frida-serverApp 启动即自动加载 hook适合重复抓包和交付测试包hook 脚本可以随 APK 一起固化这就是本文采用的方案。
四、本次实验环境
样本:
douyin_39.2.0.apkAPK 基本信息:
package : com.ss.android.ugc.awemeversionName : 39.2.0versionCode : 390201launcher : com.ss.android.ugc.aweme.splash.SplashActivityapplication : com.ss.android.ugc.aweme.app.host.AwemeHostApplicationABI : arm64-v8a测试设备:
root Android 设备USB 已连接可通过 su 获取 root验证 root:
adb shell su -c id返回类似:
uid=0(root) gid=0(root)本机工具:
adbapktooljava / jarsignerFrida 16.0.11Proxyman代理环境:
Proxyman 监听 9090SSL Proxying 开启手机全局代理指向 Windows 主机 IP:9090五、完整抓包流程概览
最终流程如下:
1. 准备 Frida Gadget arm642. apktool 解包抖音 APK3. 放入 libfrida-gadget.so4. 放入 Gadget config 和 hook script5. patch AwemeHostApplication.smali6. apktool 重打包7. 签名并安装8. 配置手机代理和 Proxyman SSL Proxying9. 启动 App,观察 Gadget 日志10. 在 Proxyman 中确认 HTTPS 请求被解密六、准备 Frida Gadget
先确认本机 Frida 版本:
frida --version本次是:
16.0.11下载对应版本:
frida-gadget-16.0.11-android-arm64.so.xz解压得到:
libfrida-gadget.so由于抖音 39.2.0 这个包只有 arm64-v8a,所以必须使用 Android arm64 Gadget。ABI 不匹配会直接导致加载失败或启动崩溃。
本次本地路径:
out\gadget\libfrida-gadget.so七、apktool 解包 APK
常规解包命令:
apktool d douyin_39.2.0.apk ` -o out\douyin_39.2.0_apktool_gadget这次完整资源解包遇到资源解析问题,所以改用:
apktool d --no-res douyin_39.2.0.apk ` -o out\douyin_39.2.0_apktool_gadget这里用 --no-res 是可接受的,因为本次只改:
lib/arm64-v8a/smali_classes40/.../AwemeHostApplication.smali不修改布局、图片、values 等资源。
八、放入 Gadget 文件
目标目录:
out\douyin_39.2.0_apktool_gadget\lib\arm64-v8a\复制 Gadget:
Copy-Item out\gadget\libfrida-gadget.so ` out\douyin_39.2.0_apktool_gadget\lib\arm64-v8a\libfrida-gadget.so新增 Gadget 配置:
out\douyin_39.2.0_apktool_gadget\lib\arm64-v8a\libfrida-gadget.config.so内容:
{ "interaction": { "type": "script", "path": "libfrida-gadget.script.so" }}新增 hook 脚本:
out\douyin_39.2.0_apktool_gadget\lib\arm64-v8a\libfrida-gadget.script.so三个文件最终是:
libfrida-gadget.solibfrida-gadget.config.solibfrida-gadget.script.so配置文件和脚本文件也用 .so 后缀,是为了让它们一起进入 APK 的 native lib 安装路径。
九、patch Application 主动加载 Gadget
抖音的 Application:
com.ss.android.ugc.aweme.app.host.AwemeHostApplication对应 smali:
out\douyin_39.2.0_apktool_gadget\smali_classes40\com\ss\android\ugc\aweme\app\host\AwemeHostApplication.smali找到构造函数:
.method public constructor <init>()V插入:
const-string v0, "frida-gadget"invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V完整效果:
.method public constructor <init>()V .locals 1 .prologue .line 0 invoke-direct {p0}, Landroid/app/Application;-><init>()V const-string v0, "frida-gadget" invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V .line 1 return-void.end method注意 .locals:
.locals 0要改成:
.locals 1因为新增了 v0。这是 smali patch 里最容易漏掉的细节之一。
为什么选择 Application 构造函数?
足够早:大多数网络库初始化前能加载 Gadget足够稳:比 hook init_array 或 linker constructor 风险低定位明确:manifest 里能直接确认 application 类十、SSL Pinning hook 思路
抖音这一版里,抓包失败的关键不在普通 Java TrustManager,而在 TTNet/Cronet native 网络栈。
重点库:
libsscronet.solibttboringssl.solibttcrypto.so重点符号:
SSL_CTX_set_custom_verifySSL_set_custom_verifyX509_verify_certCronet_CertVerify_DoVerifyV2处理策略:
1. 等目标 so 加载2. hook SSL_CTX_set_custom_verify / SSL_set_custom_verify3. 从参数里拿到 custom verify callback4. 不替换 callback 指针,只 attach 原 callback5. callback 返回时把失败值改成成功值6. hook X509_verify_cert,把失败返回改成 17. Cronet_CertVerify_DoVerifyV2 先只打日志,不直接 patch这里最关键的是第 4 点。
一开始直接用 Interceptor.replace 替换 verify callback,能看到 hook 生效,但 Chromium 网络线程会出现 SIGTRAP。更稳定的做法是保留原 callback,只在返回值上做修正。这样调用约定、上下文、内部副作用都保留下来,App 更不容易崩。
十一、Gadget 脚本核心代码
本次脚本保存为:
out\hook_douyin39_ssl.js写回 APK 工程:
Copy-Item out\hook_douyin39_ssl.js ` out\douyin_39.2.0_apktool_gadget\lib\arm64-v8a\libfrida-gadget.script.so ` -Force核心逻辑如下:
'use strict';const TAG = '[dy39-ssl]';const installed = {};function log(message, androidLog) { console.log(TAG + ' ' + message); if (androidLog !== false && Java.available) { try { Java.perform(function () { Java.use('android.util.Log').i('DYFRIDA', TAG + ' ' + message); }); } catch (_) { } }}function findGlobalExport(name) { if (typeof Module.findGlobalExportByName === 'function') { return Module.findGlobalExportByName(name); } return Module.findExportByName(null, name);}function findModuleExport(mod, exportName) { try { if (mod && typeof mod.getExportByName === 'function') { return mod.getExportByName(exportName); } } catch (_) { } try { return Module.getExportByName(mod.name, exportName); } catch (_) { return null; }}function hookExport(mod, exportName, callbacks) { const key = mod.name + '!' + exportName; if (installed[key]) { return; } const target = findModuleExport(mod, exportName); if (target === null) { return; } installed[key] = true; log('hook ' + key + ' @ ' + target); Interceptor.attach(target, callbacks);}function hookVerifyCallback(callbackPtr) { if (callbackPtr.isNull()) { return; } const key = 'verify-cb!' + callbackPtr; if (installed[key]) { return; } installed[key] = true; log('hook verify callback @ ' + callbackPtr); Interceptor.attach(callbackPtr, { onLeave(retval) { if (retval.toInt32() !== 0) { retval.replace(0); } } });}function hookSslModule(mod) { hookExport(mod, 'SSL_CTX_set_custom_verify', { onEnter(args) { log(mod.name + ' SSL_CTX_set_custom_verify mode=' + args[1] + ' cb=' + args[2]); hookVerifyCallback(args[2]); } }); hookExport(mod, 'SSL_set_custom_verify', { onEnter(args) { log(mod.name + ' SSL_set_custom_verify mode=' + args[1] + ' cb=' + args[2]); hookVerifyCallback(args[2]); } }); hookExport(mod, 'X509_verify_cert', { onLeave(retval) { if (retval.toInt32() !== 1) { log(mod.name + ' X509_verify_cert ' + retval + ' -> 1'); retval.replace(1); } } });}function hookCronetModule(mod) { hookSslModule(mod); hookExport(mod, 'Cronet_CertVerify_DoVerifyV2', { onEnter(args) { log(mod.name + ' Cronet_CertVerify_DoVerifyV2'); }, onLeave(retval) { log(mod.name + ' Cronet_CertVerify_DoVerifyV2 ret=' + retval); } });}function hookNowOrOnLoad(moduleName, callback) { const loaded = Process.findModuleByName(moduleName); if (loaded) { callback(loaded); } const dlopen = findGlobalExport('android_dlopen_ext') || findGlobalExport('dlopen'); if (!dlopen) { log('dlopen export not found'); return; } Interceptor.attach(dlopen, { onEnter(args) { this.path = args[0].isNull() ? '' : args[0].readCString(); this.match = this.path.indexOf(moduleName) !== -1; }, onLeave(retval) { if (!this.match || retval.isNull()) { return; } const mod = Process.findModuleByName(moduleName); if (mod) { callback(mod); } } });}function install() { try { log('install start'); hookNowOrOnLoad('libttboringssl.so', hookSslModule); hookNowOrOnLoad('libttcrypto.so', hookSslModule); hookNowOrOnLoad('libsscronet.so', hookCronetModule); log('install done'); } catch (e) { log('install error: ' + e.stack); }}rpc.exports = { init(stage, parameters) { log('init stage=' + stage + ' params=' + JSON.stringify(parameters || {})); install(); }, dispose() { log('dispose'); }};install();两个实现细节值得注意。
第一,脚本同时写 Frida console 和 Android log:
Java.use('android.util.Log').i('DYFRIDA', TAG + ' ' + message);这样即使用 Gadget script 模式,也可以直接用 adb logcat -s DYFRIDA 观察脚本执行状态。
第二,符号查找做了 Frida API 兼容:
Module.findGlobalExportByNameModule.findExportByNamemod.getExportByNameModule.getExportByName这是因为不同 Frida 版本的 API 行为会有差异。Gadget 模式下脚本一旦早期异常,App 可能继续运行,但 hook 完全没有装上,排查会很浪费时间。
十二、重打包和签名
重打包:
apktool b out\douyin_39.2.0_apktool_gadget ` -o out\douyin_39.2.0_frida_gadget_ssl_unsigned.apk推荐使用 Android Build Tools:
zipalign -p -f 4 ` out\douyin_39.2.0_frida_gadget_ssl_unsigned.apk ` out\douyin_39.2.0_frida_gadget_ssl_aligned.apk签名:
apksigner sign ` --ks out\debug.keystore ` --ks-key-alias androiddebugkey ` --ks-pass pass:android ` --key-pass pass:android ` --out out\douyin_39.2.0_frida_gadget_ssl_signed.apk ` out\douyin_39.2.0_frida_gadget_ssl_aligned.apk验签:
apksigner verify --verbose out\douyin_39.2.0_frida_gadget_ssl_signed.apk本机当时没有 apksigner / zipalign,临时使用 JDK jarsigner 做 debug 签名:
jarsigner -verbose ` -keystore out\debug.keystore ` -storepass android ` -keypass android ` -signedjar out\douyin_39.2.0_frida_gadget_signed.apk ` out\douyin_39.2.0_frida_gadget_unsigned.apk ` androiddebugkeyAndroid 8 测试设备可以安装这类 v1/JAR debug 签名包。但如果要做更通用的测试包,建议补齐 zipalign + apksigner。
十三、安装和启动
安装:
adb install -r out\douyin_39.2.0_frida_gadget_signed.apk启动:
adb shell monkey -p com.ss.android.ugc.aweme ` -c android.intent.category.LAUNCHER 1确认包版本:
adb shell dumpsys package com.ss.android.ugc.aweme | findstr /i "versionName versionCode"确认前台 Activity:
adb shell dumpsys window | findstr /i "mCurrentFocus mFocusedApp"正常会看到:
com.ss.android.ugc.aweme/.splash.SplashActivity十四、验证 Gadget 是否加载成功
看进程:
$pid = adb shell pidof com.ss.android.ugc.aweme$pidroot 读取 maps:
adb shell "su -c 'cat /proc/$pid/maps | grep frida'"能看到:
/data/app/com.ss.android.ugc.aweme-.../lib/arm64/libfrida-gadget.so继续确认目标网络库:
adb shell "su -c 'cat /proc/$pid/maps | grep -E ""sscronet|ttboringssl|ttcrypto""'"如果能看到:
libsscronet.solibttboringssl.solibttcrypto.so说明 Gadget 和网络库都在进程内。
看 hook 日志:
adb logcat -s DYFRIDA正常输出类似:
[dy39-ssl] install start[dy39-ssl] hook libttboringssl.so!SSL_CTX_set_custom_verify[dy39-ssl] hook libttboringssl.so!SSL_set_custom_verify[dy39-ssl] hook libttboringssl.so!X509_verify_cert[dy39-ssl] hook libsscronet.so!Cronet_CertVerify_DoVerifyV2[dy39-ssl] SSL_CTX_set_custom_verify mode=0x1 cb=0x...[dy39-ssl] hook verify callback @ 0x...这一步非常关键。不要只用“App 没崩”判断成功。能看到 hook 安装日志,才说明 Gadget 脚本确实运行了。
十五、配置 Proxyman 抓包
Windows 上 Proxyman 监听:
0.0.0.0:9090手机设置全局代理:
adb shell settings put global http_proxy 172.18.22.23:9090adb shell settings get global http_proxy返回:
172.18.22.23:9090Proxyman 侧确认:
Recording: onProxy port: 9090SSL Proxying: onInclude: *如果只看到:
CONNECT api5-core.amemv.com:443HTTPS_CLIENT_ERROR说明代理入口通了,但 HTTPS 没解开。
真正成功时,应该看到完整 HTTPS 请求,例如:
POST https://api5-core.amemv.com/aweme/v2/feed/ 200GET https://api5-normal.amemv.com/service/settings/v3/ 200POST https://security.snssdk.com/passport/ticket_guard/get_client_cert/ 200GET wss://frontier-aweme-*.amemv.com/ws/v2 101POST https://applog.snssdk.com/service/2/app_log/ 200并且 Proxyman 里能看到:
isSSL=trueisIntercepted=truestatusCode=200 或 101本次最终 App 进入首页信息流,Proxyman 能解出 feed、settings、passport、app_log、monitor、WebSocket 等请求,说明链路成立。
十六、抓包成功后能看到什么
抓到核心接口后,可以先做分层:
内容数据层 /aweme/v2/feed/ /aweme/v1/search/... /aweme/v1/danmaku/...账号安全层 /passport/... /passport/ticket_guard/get_client_cert/配置资源层 /service/settings/v3/ /gkx/api/settings/v3 /gkx/api/resource/v7/... /check_version/v7/日志监控层 /service/2/app_log/ /monitor/collect/batch/ /monitor/appmonitor/v5/batch_settings长连接层 /ws/v2静态资源层 douyinpic.com bytegecko.com bytetos.com以首页推荐为例:
Host : api5-core.amemv.comPath : /aweme/v2/feed/Method : POSTStatus : 200Response : application/x-protobuf请求头里可以看到多组安全参数:
X-ArgusX-GorgonX-KhronosX-LadonX-MedusaX-SS-STUBX-SS-REQ-TICKETx-common-params-v2这说明“能抓包”只是第一步。后续如果要复现接口,还要继续处理签名、设备参数、protobuf、压缩编码和会话态。
十七、常见问题和排查
1. App 启动就崩
优先看:
adb logcat | findstr /i "FATAL AndroidRuntime frida DYFRIDA SIG"重点检查:
Gadget ABI 是否匹配smali .locals 是否正确loadLibrary 名字是否是 frida-gadgetconfig JSON 是否合法脚本是否早期异常2. 没有 DYFRIDA 日志
说明脚本可能没执行。
检查:
libfrida-gadget.so 是否进入 lib/arm64-v8alibfrida-gadget.config.so 是否同目录libfrida-gadget.script.so 路径是否和 config 对上Application 构造函数是否真的执行 loadLibrary3. 有 CONNECT,但没有 HTTPS 明文
检查:
Proxyman SSL Proxying 是否开启Include 是否覆盖目标域名hook 是否命中 SSL_CTX_set_custom_verify是否还有其他证书校验分支4. hook 后 ChromiumNet 线程 SIGTRAP
这次遇到过。原因是直接替换 verify callback 风险较高。
更稳策略:
attach 原 callback只在 onLeave 改返回值不要 replace callback 指针Cronet_CertVerify_DoVerifyV2 先观察,不急着 patch5. 代理设置了但手机连不上 Proxyman
检查 Windows 主机 IP:
ipconfig检查手机代理:
adb shell settings get global http_proxy检查 Proxyman 是否监听:
netstat -ano | findstr :9090有时 ping 不通不代表 TCP 不通,Windows 防火墙可能拦 ICMP。以 Proxyman 是否出现连接、netstat 是否有手机 IP 连接为准。
结语
抖音 App 抓包的关键不在代理软件本身,而在 App 的证书校验模型。普通安装 CA 的方式能解决基础 HTTPS,但遇到 TTNet/Cronet 和 native SSL Pinning 时,需要进入 App 进程内部处理校验结果。
Frida Gadget 的价值在于把这件事工程化:把 Frida runtime、配置和 hook 脚本固化进 APK,让 App 启动时自动处理 SSL Pinning。相比每次手动启动 frida-server 再 attach,Gadget 更适合重复测试、设备切换和抓包环境交付。
本次实践最终确认了三件事:
Gadget 已被 App 主动加载native SSL/Cronet hook 已命中Proxyman 能解出抖音核心 HTTPS 请求这才算一个完整的抓包闭环。
夜雨聆风