乐于分享
好东西不私藏

抖音 App 抓包实战:从 SSL Pinning 到 Frida Gadget 注入

抖音 App 抓包实战:从 SSL Pinning 到 Frida Gadget 注入

抖音 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.apk

APK 基本信息:

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 `  androiddebugkey

Android 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$pid

root 读取 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:9090

Proxyman 侧确认:

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 构造函数是否真的执行 loadLibrary

3. 有 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 先观察,不急着 patch

5. 代理设置了但手机连不上 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 请求

这才算一个完整的抓包闭环。