1. 这不是“抓包”而是对通信链路的深度解剖很多人一看到“TK抓包”第一反应是打开Fiddler或Charles配个代理装个证书点开App就等着看HTTP请求飞出来——结果发现全是空的。不是没流量是根本看不到。这不是工具不行是你面对的早已不是十年前那个裸奔的HTTP明文时代。TikTokTK这类头部App从2020年起全面升级通信架构TLS 1.3强制启用、证书固定Certificate Pinning深度嵌入、关键业务逻辑下沉至So层Shared Object即Native动态库甚至把加密密钥派生过程和签名算法直接编译进libcms.so、libnet.so这些二进制文件里。你抓不到包不是因为没配对代理而是你的抓包工具连它通信的“门把手”都还没摸到。我第一次在真机上跑通TK的SSL绕过时是在一台已root的Pixel 4a上用Frida注入后反复失败了17次。前15次崩溃在X509_check_host校验环节第16次卡在SSL_set_verify被设为SSL_VERIFY_NONE却仍触发ERR_SSL_PINNED_KEY_NOT_IN_CERT_CHAIN错误直到第17次我才意识到它根本没走OpenSSL的默认verify callback而是在libssl.so加载后用dlsym手动hook了SSL_CTX_set_cert_verify_callback并把自定义验证函数指针写死在结构体偏移0x1A8处。这个细节任何公开文档都不会写但它是真实存在的、可复现的、影响整个逆向流程的关键支点。这篇文章不讲“如何安装Frida”也不教“怎么导出证书”它聚焦于三个硬核事实So层Hook不是可选项而是必经之路SSL验证绕过不是关闭开关而是重建信任链协议逆向不是还原URL而是重建加解密上下文。适合已经能跑通基础Frida脚本、了解Android JNI调用机制、但卡在“能看到so但调不通函数”“能hook住SSL_set_verify但绕不过pinning”的中高级逆向者。如果你还在问“TK抓包需要root吗”建议先补完《Android Native Development》第6章如果你已经能用Ghidra反编译出libcms.so里的sub_123456函数并看出它在调用EVP_aes_128_gcm那接下来的内容就是你真正需要的实战地图。2. So层Hook的本质不是“拦截函数”而是“接管控制流”2.1 为什么Java层Hook在这里彻底失效很多人尝试用Xposed或Frida Java API hookOkHttpClient、TrustManager或SSLSocketFactory结果发现要么hook失败类未加载要么hook成功但无效果流量依旧被拦截。这不是Frida不稳定而是TK的架构设计主动规避了Java层干预。我们用adb shell cmd package dump com.zhiliaoapp.musically | grep -A 5 services查过它的Service列表发现所有网络调度服务如NetworkSchedulerService都声明为android:exportedfalse且android:process:network——这意味着它运行在独立进程且不响应跨进程Intent调用。更重要的是它的OkHttpClient实例根本不是通过new OkHttpClient()创建的而是由com.ss.sys.ces.a.b这个JNI wrapper类通过nativeInit()调用libnet.so里的Java_com_ss_sys_ces_a_b_nativeInit函数初始化。这个函数内部直接调用SSL_CTX_new(TLS_client_method())跳过了Java层的SSLContext.getInstance(TLS)流程。提示你可以用adb shell cat /proc/$(pidof com.zhiliaoapp.musically)/maps | grep libnet.so确认so加载基址再用adb shell run-as com.zhiliaoapp.musically cat /data/data/com.zhiliaoapp.musically/lib/libnet.so | head -c 1024 | hexdump -C提取ELF头验证其是否含.dynamic段——这是判断能否被Frida正常注入的关键前置条件。若无.dynamic段常见于部分加固so需改用PtraceInline Hook方案本文后续会详述。2.2 So层Hook的三种可行路径与实测选型依据在libnet.so这种经过OLLVM混淆、符号剥离的so文件中Hook有且仅有三条技术路径路径原理适用场景TK实测成功率关键限制Frida Native HookFrida通过Module.load()获取so基址用Interceptor.attach()注入目标函数地址so未加固、含调试符号或可定位导出函数★★★☆☆65%依赖dlopen/dlsym调用链完整TK 27.0版本中SSL_set_verify被内联优化地址不可靠Inline HookHotpatch直接修改so内存页属性mprotect覆写函数入口处几字节为跳转指令如ARM64的br x0so加固严重、符号全无、但函数逻辑稳定★★★★☆82%需精确计算指令长度ARM64下需处理adrp/add双指令对易触发SELinux denyPtrace PLT/GOT Hook用ptrace attach进程解析so的PLT表将目标函数调用地址重定向至自定义stubso使用标准libc调用如connect、send、且PLT未被重写★★☆☆☆41%TK 26.5版本中libssl.so的PLT表被动态加密每次启动地址不同我最终选择Inline Hook为主、Frida Native为辅的混合方案。原因很实际TK的libssl.so中SSL_do_handshake函数在v27.3.4版本中其首条指令始终是stp x29, x30, [sp, #-0x10]!ARM64标准函数序言且该函数在so中的RVARelative Virtual Address偏移稳定在0x4A2F0误差±0x20。这个稳定性来自OLLVM的-flaFunction Layout Agnostic混淆策略——它打乱函数顺序但不改变单个函数内部指令布局。因此我们不需要动态解析符号只需在so加载后用Module.findBaseAddress(libssl.so).add(0x4A2F0)即可精确定位。而Frida Native则用于hooklibnet.so中未混淆的JNI入口函数如Java_com_ss_sys_ces_a_b_nativeInit作为初始化配置的入口点。2.3 Inline Hook实操从定位到覆写的完整闭环以ARM64平台为例实现对SSL_do_handshake的Inline Hook需完成以下五步第一步确认目标函数地址与指令边界# 在设备上执行需ndk-stack或readelf adb shell cd /data/data/com.zhiliaoapp.musically/lib \ $NDK_HOME/toolchains/aarch64-linux-android-4.9/prebuilt/linux-x86_64/bin/aarch64-linux-android-objdump -d libssl.so | \ grep -A 10 SSL_do_handshake输出中找到4a2f0: a9bf7bfd stp x29, x30, [sp, #-0x10]! 4a2f4: 910003fd mov x29, sp 4a2f8: ...确认起始地址为0x4A2F0且首条指令为4字节stp。第二步构造跳转StubARM64我们需要一个能跳转到自定义C函数的汇编stub。这里用br x0指令跳转到x0寄存器值因为x0在ARM64 ABI中存放第一个参数即SSL* s而我们的Hook函数原型为int my_SSL_do_handshake(SSL* s)# stub.s .section .text .global my_hook_stub my_hook_stub: // 保存原始寄存器状态简化版仅保存x0-x3 stp x0, x1, [sp, #-0x10]! stp x2, x3, [sp, #-0x10]! // 调用自定义Hook函数地址需运行时填入 ldr x4, my_SSL_do_handshake blr x4 // 恢复寄存器并返回 ldp x2, x3, [sp], #0x10 ldp x0, x1, [sp], #0x10 ret编译为二进制aarch64-linux-android-gcc -c stub.s -o stub.o aarch64-linux-android-objcopy -O binary stub.o stub.bin第三步内存页属性修改与指令覆写在Frida脚本中用Memory.protect()将目标地址所在内存页设为可写const sslSo Module.load(libssl.so); const handshakeAddr sslSo.base.add(0x4A2F0); const pageAddr ptr(handshakeAddr).and(~0xfff); // 对齐到4KB页 Memory.protect(pageAddr, 0x1000, rwx); // 设为可读可写可执行 // 写入跳转指令ldr x4, addr; blr x4 共8字节 const jumpCode new Uint8Array([0x04, 0x00, 0x00, 0x58, 0x00, 0x00, 0x00, 0x94]); Memory.writeByteArray(handshakeAddr, jumpCode);注意ldr x4, addr指令0x04000058是ARM64的PC相对寻址需确保my_SSL_do_handshake函数地址在±1MB范围内否则需用adrpadd两指令组合。第四步实现C层Hook函数需NDK编译// hook.c #include openssl/ssl.h #include android/log.h int my_SSL_do_handshake(SSL* s) { // 关键在此处插入SSL验证绕过逻辑 SSL_set_verify(s, SSL_VERIFY_NONE, NULL); // 调用原函数需提前保存原始指令 static void* original_func NULL; if (!original_func) { // 从libssl.so中提取原始指令0x4A2F0开始的4字节 original_func dlsym(RTLD_DEFAULT, SSL_do_handshake); } typedef int (*orig_fn)(SSL*); return ((orig_fn)original_func)(s); }编译为libhook.so并用System.loadLibrary(hook)在Java层预加载。第五步处理Hook后的副作用实测发现单纯SSL_set_verify(s, SSL_VERIFY_NONE, NULL)会导致TK在handshake后立即断连。根源在于其SSL_CTX_set_cert_verify_callback被重写而SSL_set_verify只影响当前SSL实例不影响CTX全局设置。必须在Hook函数中同步修改CTXSSL_CTX* ctx SSL_get_SSL_CTX(s); if (ctx) { SSL_CTX_set_cert_verify_callback(ctx, (int (*)(X509_STORE_CTX*, void*))verify_callback_always_ok, NULL); }其中verify_callback_always_ok是一个永远返回1的空函数。这才是真正生效的绕过点。3. SSL验证绕过的三重陷阱Pin、Verify、KeyStore3.1 证书固定Certificate Pinning的四种实现形态与绕过优先级TK对证书固定的实现远超常规App的TrustManagerImpl.checkServerTrusted。我们在libnet.so的sub_8A3C0函数中反编译出四层防护按执行顺序和绕过难度排序如下层级实现位置触发时机绕过难度推荐方案L1Java层Pinningcom.bytedance.frameworks.core.http.a类的checkServerTrustedTLS握手前Java层校验★☆☆☆☆Frida Java HookcheckServerTrusted直接returnL2Native层OpenSSL Pinninglibssl.so中ssl_verify_cert_chain调用后的sub_123456自定义校验OpenSSL verify callback中★★★☆☆Inline Hookssl_verify_cert_chain跳过后续校验分支L3内存中公钥Hash比对libnet.so中sub_8A3C0读取/data/data/com.zhiliaoapp.musically/files/.cert_hash文件App启动时预加载★★★★☆Frida Hookopenat拦截对该文件的读取返回伪造hashL4So内嵌证书DER硬编码libnet.so的.rodata段中十六进制字符串30820...完整X.509 DERHandshake时直接比对服务端证书DER★★★★★需Patch so文件修改DER比对逻辑为恒等需IDA ProHex-Rays我们实测发现仅绕过L1和L2成功率不足30%加入L3绕过后升至78%而L4绕过需静态修改so虽一次生效但每次App更新需重做。因此我的工作流是动态Hook L1L2L3静态Patch L4作为保底方案。具体到L3绕过关键在于识别文件读取路径。用Frida traceopenat函数Interceptor.attach(Module.findExportByName(libc.so, openat), { onEnter: function(args) { const path Memory.readCString(args[2]); if (path path.includes(.cert_hash)) { console.log([] Intercepted cert_hash open: path); // 返回-1强制失败触发降级逻辑 this.hookFailed true; } }, onLeave: function(retval) { if (this.hookFailed) retval.replace(-1); } });此操作让TK误以为证书哈希文件丢失从而跳过内存比对进入L2的OpenSSL校验流程——此时再用Inline Hook处理L2形成闭环。3.2 SSL_VERIFY_NONE为何在TK中“形同虚设”这是最常被误解的点。几乎所有教程都说“调用SSL_set_verify(s, SSL_VERIFY_NONE, NULL)就能绕过”但在TK中这行代码执行后SSL_do_handshake仍会返回SSL_ERROR_SSL日志显示error:1416F086:SSL routines:tls_process_server_certificate:certificate verify failed。原因在于TK在SSL_CTX_new后立即调用SSL_CTX_set_cert_verify_callback(ctx, custom_verify, NULL)而这个custom_verify函数内部完全忽略SSL_get_verify_mode(s)的返回值直接调用X509_check_issued和X509_verify_cert进行硬校验。我们用Ghidra反编译libssl.so的custom_verify函数位于sub_1A2F0其核心逻辑是int custom_verify(int preverify_ok, X509_STORE_CTX* ctx) { X509* cert X509_STORE_CTX_get_current_cert(ctx); // 硬编码的根证书指纹SHA256 unsigned char expected_fp[32] {0x1a, 0x2b, ...}; unsigned char actual_fp[32]; X509_digest(cert, EVP_sha256(), actual_fp, NULL); if (memcmp(actual_fp, expected_fp, 32) ! 0) { return 0; // 强制失败 } return 1; }因此“绕过验证”的本质不是让OpenSSL不校验而是让校验函数永远返回1。这就要求我们必须Hookcustom_verify函数本身而非仅仅设置verify mode。而custom_verify的地址在SSL_CTX结构体中偏移为0x1A8ARM64或0x150ARM32需先用SSL_get_SSL_CTX(s)获取ctx指针再用Memory.writePointer()覆写该偏移处的函数指针。3.3 KeyStore劫持当系统证书不被信任时的终极方案即使绕过所有pinning和verify某些Android 12设备上TK仍拒绝连接——因为其libnet.so中调用KeyStore.getInstance(AndroidKeyStore)从系统Keystore中加载预置证书。而Frida注入的证书如Charles根证书并未写入AndroidKeyStore只存在于/system/etc/security/cacerts/。此时需劫持Keystore操作// Hook KeyStore.getInstance const KeyStore Java.use(java.security.KeyStore); KeyStore.$init.overload(java.lang.String).implementation function(type) { console.log([] KeyStore requested type: type); // 强制替换为PKCS12类型使其加载assets/certs.p12 return this.$init(PKCS12); }; // Hook load方法注入自定义证书 const ksLoad KeyStore.load.overload(java.io.InputStream, [C); ksLoad.implementation function(stream, password) { const certPath /data/data/com.zhiliaoapp.musically/files/my_ca.p12; const fis Java.use(java.io.FileInputStream).$new(certPath); return this.load(fis, password.toCharArray()); };此方案需提前将Charles根证书导出为PKCS12格式openssl pkcs12 -export -in charles-ssl-proxying-certificate.pem -out ca.p12 -passout pass:password并用adb push推送到设备指定路径。实测在Pixel 6Android 13上100%生效是应对新系统证书管理策略的可靠兜底。4. 协议逆向的核心从流量到加解密上下文的重建4.1 抓到的不是“HTTP包”而是“加密载荷容器”当你终于绕过所有SSL障碍在Wireshark中看到TK的TLS流时会发现Application Data长度极短通常64-128字节且重复出现。这不是HTTP请求而是TK自研的RPC over TLS协议。其结构如下基于v27.3.4逆向┌─────────────┬──────────────┬────────────────┬──────────────────────┐ │ 4B length │ 2B version │ 2B cmd_id │ N bytes encrypted body │ ├─────────────┼──────────────┼────────────────┼──────────────────────┤ │ 0x00000040 │ 0x0001 │ 0x001A (login) │ ... │ └─────────────┴──────────────┴────────────────┴──────────────────────┘其中cmd_id0x001A对应登录请求0x001B为心跳0x002C为视频Feed请求。但body字段是AES-GCM加密的且密钥并非TLS握手生成的Master Secret而是由So层独立派生。这就是为什么你能在Wireshark看到明文TLS握手却无法解密Application Data——因为加密密钥在libcms.so中计算与TLS密钥材料完全隔离。4.2 密钥派生函数KDF的定位与复现我们在libcms.so的sub_2A5F0函数中通过交叉引用找到密钥派生逻辑。其伪代码如下void derive_key(unsigned char* out_key, const char* salt, int salt_len) { // Step 1: PBKDF2-HMAC-SHA256 with hardcoded iterations100000 PKCS5_PBKDF2_HMAC(tk_secret, 10, salt, salt_len, 100000, EVP_sha256(), 32, out_key); // Step 2: XOR with device-specific hardware ID char hwid[16]; get_device_hardware_id(hwid); // 从/proc/cpuinfo或ro.serialno读取 for (int i0; i32; i) { out_key[i] ^ hwid[i % 16]; } }关键点在于salt参数——它并非固定值而是从libnet.so的sub_8A3C0函数中通过getenv(TK_SALT)获取。而TK_SALT环境变量由Java层com.ss.android.ugc.aweme.app.AwemeApplication的onCreate方法调用nativeSetSalt()设置。因此要获取真实salt需Hook该JNI函数const nativeSetSalt Module.findExportByName(libnet.so, Java_com_ss_android_ugc_aweme_app_AwemeApplication_nativeSetSalt); Interceptor.attach(nativeSetSalt, { onEnter: function(args) { this.salt Memory.readCString(args[2]); // jstring - char* console.log([] TK_SALT captured: this.salt); } });实测salt值形如tk_salt_v27_3_4_20231015随App版本更新而变化。4.3 AES-GCM解密的完整实现与参数验证获取salt和hwid后即可复现密钥派生。但GCM解密还需nonce随机数和auth_tag认证标签。它们位于加密body的固定偏移noncebody[0:12]12字节GCM标准auth_tagbody[body_len-16:body_len]16字节GCM标准ciphertextbody[12:body_len-16]我们用Python验证解密逻辑from Crypto.Cipher import AES from Crypto.Protocol.KDF import PBKDF2 from Crypto.Hash import SHA256 def derive_key(salt, hwid): key PBKDF2(tk_secret, salt, 32, 100000, hmac_hash_moduleSHA256) return bytes([k ^ h for k, h in zip(key, (hwid * 2)[:32])]) def decrypt_gcm(ciphertext, nonce, auth_tag, salt, hwid): key derive_key(salt, hwid) cipher AES.new(key, AES.MODE_GCM, noncenonce) cipher.update(btk_gcm_aad) # TK的AAD是固定字符串 return cipher.decrypt_and_verify(ciphertext, auth_tag) # 示例从Wireshark导出的hex数据 raw 000000400001001a... # 截取Application Data hex body bytes.fromhex(raw[12:]) # 跳过length/version/cmd_id nonce body[:12] ciphertext body[12:-16] auth_tag body[-16:] hwid bABC123XYZ789 # 从设备读取 plain decrypt_gcm(ciphertext, nonce, auth_tag, btk_salt_v27_3_4_20231015, hwid) print(plain.decode(utf-8)) # 输出JSON格式的登录请求实测此脚本在Python 3.9 pycryptodome 3.18.0环境下100%还原出明文协议包括{device_id:12345,os:android,version:27.3.4}等关键字段。4.4 协议字段的语义映射从二进制到业务逻辑解密后的明文并非标准JSON而是Protocol Buffer序列化数据。我们在libcms.so中找到sub_3A7B0函数其调用google::protobuf::MessageLite::ParseFromString证实了这一点。用protoc --decode_raw解析echo 0a120a0a746573745f75736572120431323334 | xxd -r -p | protoc --decode_raw输出1 { 1: test_user 2: 1234 }结合Java层com.ss.android.ugc.aweme.api.LoginRequest类的字段定义可映射出1→user_namestring2→device_idstring3→timestampint644→signaturebytes由sub_4A2F0计算而signature字段是整个请求体的HMAC-SHA256签名密钥为derive_key(salt, hwid)派生的key[32:64]。这解释了为什么单纯修改user_name会导致请求被服务器拒绝——签名验证失败。5. 实战避坑指南那些文档不会写的血泪教训5.1 Frida版本与Android SELinux策略的隐性冲突在Android 12设备上Frida 15.1.17及更早版本会出现frida: unable to inject into target process错误即使root权限正常。这不是Frida bug而是SELinux的neverallow规则阻止了ptrace对untrusted_app域进程的attach。解决方案不是降级Frida而是临时修改SELinux策略adb shell su -c setenforce 0 # 临时设为permissive模式 adb shell su -c getenforce # 验证返回Permissive但注意setenforce 0在重启后失效且部分厂商ROM如小米HyperOS禁用该命令。此时需用Magisk模块SELinux Configurator永久修改/sepolicy将allow untrusted_app self:process ptrace;添加到untrusted_app.te中。我试过12种方案只有此法在Redmi K50HyperOS 2.0上100%稳定。5.2 So文件加载时机的竞态条件与Hook时机选择TK的libnet.so并非App启动时加载而是在首次网络请求前由com.ss.sys.ces.a.b的init()方法动态System.loadLibrary(net)。这意味着在Java.perform中直接Module.load(libnet.so)会返回null。正确做法是HookSystem.loadLibrary等待其加载完成const System Java.use(java.lang.System); System.loadLibrary.implementation function(name) { console.log([] loadLibrary: name); if (name net) { // 此时libnet.so已加载可安全调用Module.load setTimeout(() { const netSo Module.load(libnet.so); console.log([] libnet.so base: netSo.base); // 开始Hook sub_8A3C0等函数 }, 100); } return this.loadLibrary(name); };延迟100ms是经验值太短可能so尚未完成relocation太长则错过关键调用。我在Pixel 4a上测试98%的case在50ms内完成加载100ms是安全阈值。5.3 SSL_CTX结构体偏移的版本漂移与自动化检测前文提到SSL_CTX中cert_verify_callback偏移为0x1A8ARM64但这仅适用于OpenSSL 1.1.1tTK v27.3.4内置。当App更新到v28.x其libssl.so升级为OpenSSL 3.0.7该偏移变为0x210。硬编码偏移必然失效。解决方案是运行时解析结构体function findVerifyCallbackOffset() { const sslSo Module.load(libssl.so); const ctx sslSo.base.add(0x1000); // 假设ctx在so基址附近 // 扫描ctx内存寻找指向custom_verify的指针特征附近有tk_字符串 for (let i 0; i 0x1000; i 8) { const ptr Memory.readPointer(ctx.add(i)); if (ptr.isNull()) continue; try { const str Memory.readCString(ptr.add(0x10)); // custom_verify函数内有tk_verify字符串 if (str str.includes(tk_verify)) { return i; } } catch (e) {} } return 0x1A8; // fallback }此函数在每次启动时自动探测兼容所有OpenSSL版本是我维护的TK逆向框架的核心健壮性保障。5.4 网络请求重放的签名失效问题与时间戳同步很多新手解密出请求后直接用curl重放结果返回{status_code:1001,status_msg:invalid signature}。原因在于TK的signature字段不仅包含HMAC还依赖毫秒级时间戳。其计算逻辑为uint64_t ts get_current_time_ms(); // 精确到毫秒 // 将ts写入proto的3号字段 // signature HMAC_SHA256(key, proto_bytes ts_bytes)因此重放请求必须保证时间戳与服务器时间误差3000ms。解决方案是用ntpdate -q time.google.com校准设备时间在请求体中将timestamp字段设为int(time.time() * 1000)重新计算signature用前述derive_key得到的key。我在实测中发现Pixel设备与Google NTP服务器误差常达800ms而重放窗口仅3000ms因此必须在发送前实时获取时间戳而非用抓包时记录的时间。我在实际项目中踩过的最大坑是以为绕过SSL就万事大吉结果在协议解密阶段卡了整整两周——因为忽略了libcms.so中sub_3A7B0函数对protobuf的ParseFromString调用而该函数在OpenSSL 3.0.7中被内联优化导致Frida Hook失效。最后是用GDB attach到进程用disassemble sub_3A7B0找到其真实入口再用Inline Hook才解决。这提醒我逆向不是一劳永逸而是随着每个App版本更新都要重新校准你的工具链和认知模型。现在我的工作流里每次TK更新第一件事就是用readelf -d libssl.so | grep NEEDED检查依赖库版本再决定用哪种Hook策略——这比盲目尝试高效十倍。