抖音 libsscronet 跨 SO 加密调用机制
作者: 人生导师
日期: 2026 年 5 月 16 日
结论先说:抖音的网络层(libsscronet.so)和加密层(libmetasec_ml.so)之间不是传统的 PLT/GOT 动态链接,而是运行时通过 C++ 虚表注册的函数指针回调。加密 SO 启动时拿到 Cronet_Engine 对象,通过虚表第 29 个槽位把自己的函数地址原子写进网络 SO 的一个全局变量,网络请求时原子读出来直接 BLR 跳过去。设计意图是解耦——网络 SO 不需要链接加密 SO,两者松耦合,支持热插拔。
版本:29.7.0,EncryptEntry 入口 0x415958。
整体架构
┌─────────────────────────────┐ ┌──────────────────────────────┐
│ libsscronet.so │ │ libmetasec_ml.so (加密SO) │
│ │ │ │
│ qword_556958 (.bss) │◄────────│ JNI_OnLoad / init: │
│ [全局函数指针] │ 注册 │ register(my_encrypt_fn) │
│ │ │ │
│ EncryptEntry: │ │ my_encrypt_fn(url, body): │
│ X23 = load(qword_556958) │────────►│ return encrypted_headers │
│ result = BLR X23 │ 调用 │ │
└─────────────────────────────┘ └──────────────────────────────┘
你仔细看这个图就能理解核心思路:网络 SO 在 .bss 段预留一个 8 字节的坑位,加密 SO 初始化时把自己的函数地址填进去,请求时读出来调用。就这么简单。
函数入口与寄存器分配
EncryptEntry 的原型:
int64_t EncryptEntry(int64_t a1, int64_t a2, int64_t a3, int64_t *a4)
入口处的寄存器保存是标准的 ARM64 prologue:
EncryptEntry:
PACIASP ; PAC 指针认证(防 ROP)
SUB SP, SP, #0x1A0 ; 分配 416 字节栈空间
STP X29, X30, [SP,#0x160] ; 保存帧指针和返回地址
STP X28-X19, ... ; 保存 callee-saved 寄存器
ADD X29, SP, #0x140 ; 设置帧指针
MOV X22, X3 ; X22 = a4(输出参数指针)
MOV X20, X2 ; X20 = a3(HTTP headers map 对象)
MOV X19, X1 ; X19 = a2(请求上下文结构体)
MOV X21, X0 ; X21 = a1(连接/会话对象)
关键寄存器用途:
| 寄存器 |
含义 |
来源 |
| X19 |
请求上下文结构体指针 |
函数参数 a2 |
| X20 |
HTTP headers map |
函数参数 a3 |
| X21 |
循环中复用,最终为 body 字符串指针 |
拼接构造 |
| X22 |
最终为 URL 字符串指针 |
从容器提取 |
| X23 |
加密函数指针 |
从全局变量原子读取 |
| X24 |
var_60 栈地址(SSO 短字符串时的 data 指针) |
栈帧计算 |
| X25 |
header 参数元素总数 |
容器 size 计算 |
| X26 |
最后一个元素索引 (X25 - 1) |
用于循环判断是否追加分隔符 |
获取加密函数指针
这是整个机制的核心,代码在 0x415DF0:
0x415DF0: ADRL X8, qword_556958 ; X8 = &g_encrypt_func
0x415DF8: LDAR X23, [X8] ; X23 = atomic_load_acquire(&g_encrypt_func)
0x415DFC: CBZ X23, loc_416100 ; if (X23 == NULL) goto fallback
三条指令干了三件事:取全局变量地址、原子读、空指针保护。LDAR 是 ARM64 的 Load-Acquire 指令,带内存屏障语义。CBZ 做了 NULL 检查——如果加密 SO 还没注册(比如启动时序问题),走 fallback 路径调用 sub_3A79A0,不会崩。
等价 C 代码:
typedef char* (*encrypt_fn_t)(const char*, const char*);
static _Atomic(encrypt_fn_t) g_encrypt_func; // qword_556958
encrypt_fn_t fn = atomic_load_explicit(&g_encrypt_func, memory_order_acquire);
if (!fn) {
return sub_3A79A0(a2, a3); // fallback: 不加密
}
构造参数1:URL 字符串
从请求上下文的容器里取最后一个元素:
0x415E00: LDR X9, [X19,#0x430] ; X9 = request_ctx->container.begin
0x415E08: LDR X8, [X19,#0x438] ; X8 = request_ctx->container.end
0x415E10: CMP X9, X8 ; 容器是否为空
0x415E14: B.EQ abort ; 空则 abort
0x415E18: SUB X1, X8, #0x78 ; X1 = end - 120(最后一个元素)
0x415E20: BL sub_1C744C ; 拷贝构造到栈变量 var_60
这里有个细节:元素大小是 0x78(120 字节),每个元素是一个包含 URL 等信息的结构体。end - 0x78 就是最后一个元素的起始地址。
拷贝到栈上之后,还要从 std::string 里把 data() 指针提出来:
0x415EC8: LDURSB W8, [X29,#var_49] ; 读 SSO 标志位(offset +0x17)
0x415ECC: LDUR X9, [X29,#var_60] ; 读第一个 qword
0x415ED0: CMP W8, #0
0x415ED4: CSEL X22, X9, X24, LT ; X22 = (is_long ? heap_ptr : stack_buf)
这里涉及 libc++ 的 std::string SSO(Small String Optimization)布局:
短字符串 (≤22字节):
[0..21] = 字符数据(直接存在对象内部)
[23] = 长度(最高位为0)
长字符串 (>22字节):
[0..7] = 堆指针 (data)
[8..15] = size
[16..23] = capacity | 0x80(最高位为1标记长字符串)
判断逻辑:读第 23 字节的符号位,小于 0 说明是长字符串,取堆指针;大于等于 0 说明是短字符串,直接用栈上的地址。URL 一般都超过 22 字节,所以大概率走堆指针那条路。
构造参数2:Headers Body 字符串
先调 sub_424FD0 解析 HTTP headers map,把 URL 参数(包括 x-common-params-v2)以 key-value 对形式存入一个 vector<string>:
0x415E34: SUB X0, X8, #0x78 ; 容器最后一个元素
0x415E40: MOV X1, X20 ; headers map
0x415E48: BL sub_424FD0 ; 解析 headers → 输出到 var_130 容器
然后是一个循环,把所有元素用 \r\n 拼起来:
; 初始化
0x415E58: SUBS X8, X9, X10 ; X8 = end - begin(字节差)
0x415E64: MOV W9, #0x18 ; 24 = sizeof(std::string)
0x415E68: MOV X21, XZR ; i = 0
0x415E6C: ADRL X22, "\r\n" ; 分隔符
0x415E74: SDIV X25, X8, X9 ; X25 = 元素总数
0x415E78: SUB X26, X25, #1 ; X26 = last_index
; 循环体
loc_415E7C:
BL sub_3D6D98 ; X0 = container.at(i)
BL sub_1D92EC ; result_string.append(container[i])
CMP X21, X26 ; if (i < last_index)
B.CS skip_separator
BL sub_26E3E4 ; result_string.append("\r\n")
skip_separator:
ADD X21, X21, #1 ; i++
CMP X25, X21
B.NE loc_415E7C ; while (i != total)
等价 C 代码:
std::vector<std::string> params;
parse_headers(request_element, headers_map, ¶ms); // sub_424FD0
std::string body;
for (size_t i = 0; i < params.size(); i++) {
body.append(params[i]);
if (i < params.size() - 1)
body.append("\r\n");
}
// body 内容示例: "key1=value1\r\nkey2=value2\r\nkey3=value3"
拼完之后同样要从 std::string 里提 data() 指针,逻辑和上面 URL 那段一样,最终结果存到 X21。
调用加密函数
参数准备好了,调用前后各记一次时间戳用于性能监控:
0x415ED8: BL sub_2D7A68 ; X0 = get_timestamp_ms()
0x415EDC: STR X0, [X19,#0x228] ; request_ctx->pre_encrypt_ts = now()
0x415EE0: MOV X0, X22 ; 参数1 = URL (const char*)
0x415EE4: MOV X1, X21 ; 参数2 = headers body (const char*)
0x415EE8: BLR X23 ; result = g_encrypt_func(url, body)
0x415EEC: MOV X21, X0 ; X21 = 返回值
0x415EF0: BL sub_2D7A68 ; X0 = get_timestamp_ms()
0x415EF4: STR X0, [X19,#0x230] ; request_ctx->post_encrypt_ts = now()
X19+0x228 和 X19+0x230 的差值就是加密耗时。设计很贼——直接在调用点前后打点,不需要加密函数自己上报。
加密函数原型:
// 位于 libmetasec_ml.so 中
char* encrypt_func(const char* url, const char* headers_body);
// 返回值: 堆分配的字符串,格式为 "key\r\nvalue\r\nkey\r\nvalue..."
// 调用方负责 free
返回值处理
加密函数返回一个 C 字符串,格式是 key\r\nvalue\r\nkey\r\nvalue...。处理流程:
- NULL 检查——加密失败就跳过
- 包装成
std::string
- 按
\r\n split 成 vector
- free 掉原始指针
- 成对遍历,写入 HTTP headers
0x415EF8: CBZ X21, loc_4160E4 ; if (result == NULL) goto cleanup
0x415F00: MOV X1, X21
0x415F08: BL sub_1CC390 ; std::string tmp(result)
0x415F10: ADRL X2, "\r\n" ; 分隔符
0x415F1C: MOV W3, #2 ; 分隔符长度
0x415F3C: BL sub_295540 ; split → vector<string>
0x415F48: MOV X0, X21
0x415F4C: BL sub_2CC464 ; free(result)
; 成对遍历写入 headers
0x415F50: LDP X21, X8, [X29,#var_40] ; begin, end
0x415F54: MOV W10, #0x18 ; sizeof(std::string) = 24
0x415F58: SUB X9, X8, X21
0x415F5C: SDIV X9, X9, X10 ; 元素个数
0x415F60: TBNZ W9, #0, done ; 奇数个则跳过(必须成对)
这里有个坑:TBNZ W9, #0 检查元素个数是否为奇数。如果加密函数返回的格式不对(比如少了一个 value),直接跳过不写入,不会崩。防御性编程做得到位。
等价 C 代码:
char* result = encrypt_func(url, body);
request_ctx->post_encrypt_ts = get_timestamp_ms();
if (result) {
std::string tmp(result);
std::vector<std::string> parts = split(tmp, "\r\n");
free(result);
// 返回格式: ["X-Gorgon", "value1", "X-Khronos", "value2", ...]
if (parts.size() % 2 == 0) {
for (size_t i = 0; i < parts.size(); i += 2) {
headers_map.set(parts[i], parts[i+1]);
}
}
}
注册端:Cronet_Engine 虚表机制
这部分是整个分析里最有意思的。注册函数 sub_2289C8 本身极其简单,就三条指令:
sub_2289C8:
BTI c ; Branch Target Identification
ADRL X8, qword_556958 ; X8 = &g_encrypt_func
STLR X1, [X8] ; atomic_store_release(&g_encrypt_func, X1)
RET
X0 没用(是 this 指针,即 Engine 对象),X1 是要注册的加密函数地址。一条 STLR(Store-Release)就完事了。
但是这个函数不是导出符号,加密 SO 怎么找到它的?答案在 Cronet_Engine 的 C++ 虚表里。IDA 已经把这个函数命名为 injiami("加密"的拼音),它位于虚表 off_5322A8 的第 29 个槽位:
Cronet_Engine vtable (off_5322A8), 46 entries:
[ 0] 0x224978 析构函数 (destructor)
[ 1] 0x224C2C ...
...
[27] 0x227214 sub_227214
[28] 0x2271BC sub_2271BC
[29] 0x2289C8 injiami ◄◄◄ 注册加密函数
[30] 0x2289DC sub_2289DC
[31] 0x228A98 sub_228A98
...
[45] 0x1C4494 sub_1C4494 (thunk)
虚表被谁使用?通过 xref 追到 Cronet_Engine_Create:
__int64 Cronet_Engine_Create() {
void* obj = malloc(0x4C8); // 分配 1224 字节
*(uint64_t*)obj = off_5322A8; // 写入虚表指针(对象头部)
// ... 初始化各字段 ...
return obj;
}
这就是标准的 C++ 虚函数机制——对象头 8 字节是虚表指针,虚表里存着所有虚函数的地址。加密 SO 拿到 Engine 对象后,读虚表第 29 项就能找到注册函数。
完整调用链
┌─ libmetasec_ml.so ─────────────────────────────────────────────────┐
│ │
│ JNI_OnLoad / 初始化: │
│ 1. 获取 Cronet_Engine 对象指针(通过 JNI 回调或全局注册) │
│ 2. 读取对象头部的虚表指针: vtable = *(uint64_t*)engine │
│ 3. 取第29个虚函数: fn = vtable[29] │
│ 4. 调用: fn(engine, my_encrypt_func) │
│ │
└─────────────────────────────────────────────────────────────────────┘
│
▼
┌─ libsscronet.so ───────────────────────────────────────────────────┐
│ │
│ sub_2289C8 / injiami (vtable[29]): │
│ atomic_store_release(&qword_556958, my_encrypt_func) │
│ │
└─────────────────────────────────────────────────────────────────────┘
有意思的是,在 libsscronet.so 内部你找不到任何直接调用 sub_2289C8 的地方——因为调用方在另一个 SO 里,通过虚表间接调用。静态分析只能看到虚表里有这个地址,但谁调了它、什么时候调的,必须动态跑才知道。
等价 C 代码
// ═══════════ libsscronet.so 侧 ═══════════
// Cronet_Engine 类的虚函数 [29]
void CronetEngine::injiami(encrypt_fn_t fn) { // sub_2289C8
atomic_store_explicit(&g_encrypt_func, fn, memory_order_release);
}
// 创建 Engine 对象(导出函数)
CronetEngine* Cronet_Engine_Create() {
auto* engine = new CronetEngine(); // 分配 0x4C8 字节
engine->vtable = off_5322A8; // 设置虚表
return engine;
}
// ═══════════ libmetasec_ml.so 侧 ═══════════
// 初始化时注册加密函数
void init(CronetEngine* engine) {
// 通过虚表调用第29个虚函数
engine->injiami(my_encrypt_function);
// 等价于: engine->vtable[29](engine, my_encrypt_function)
}
运行时验证
由于调用方在另一个 SO,静态分析看不到 caller。用 Frida 可以验证:
Interceptor.attach(Module.findBaseAddress("libsscronet.so").add(0x2289C8), {
onEnter(args) {
console.log("injiami called!");
console.log(" arg0 (this/engine):", args[0]);
console.log(" arg1 (encrypt_func):", args[1]);
console.log(" backtrace:\n" +
Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join("\n"));
}
});
backtrace 里应该能看到 libmetasec_ml.so 的初始化函数。
内存序保证:为什么不会读到脏数据
这里用了经典的 release-acquire 配对:
时间线:
─────────────────────────────────────────────────────────────────────
加密SO线程: 网络请求线程:
1. 初始化加密函数内部状态
2. STLR → g_encrypt_func = fn
(release: 保证1在2之前可见)
3. LDAR ← g_encrypt_func → X23
(acquire: 保证3之后的读不会重排到3之前)
4. BLR X23 (调用时,加密函数内部状态已可见)
─────────────────────────────────────────────────────────────────────
STLR 保证:写入函数指针之前的所有内存操作(包括加密函数自身的初始化)都已完成。LDAR 保证:读到非 NULL 指针后,能看到注册方在 STLR 之前的所有写入。两者配合形成 happens-before 关系,不需要锁。
这个设计在 ARM64 上是零额外开销的——LDAR/STLR 本身就是普通 load/store 加了屏障语义,不像 x86 的 lock cmpxchg 那样要锁总线。
为什么不会定位偏
这个问题我一开始也想了半天,后来想通了:
| 疑问 |
解答 |
| 地址是怎么确定的? |
运行时由加密 SO 主动写入自己函数的绝对虚拟地址,不是编译时算的 |
| ASLR 随机化怎么办? |
注册发生在 SO 加载之后,此时 ASLR 已确定,写入的就是最终地址 |
| 两个 SO 基址不同? |
无关。不是 base+offset 方式,是直接存绝对地址 |
| SO 更新后地址变了? |
每次 app 启动都会重新注册,写入当次加载的真实地址 |
| 会不会写入时机太晚? |
CBZ X23 做了 NULL 检查,未注册时走 fallback 不会崩 |
| 多线程竞争? |
LDAR/STLR 原子语义保证,不会读到半写的 8 字节指针 |
关键地址速查表
| 地址 |
类型 |
含义 |
0x415958 |
函数 |
EncryptEntry 入口 |
0x415DF0 |
代码 |
加载函数指针开始 |
0x415EE8 |
代码 |
BLR X23 调用加密函数 |
0x556958 |
.bss 全局变量 |
存储加密函数指针 (g_encrypt_func) |
0x2289C8 |
函数 |
注册接口 injiami(虚表第29槽) |
0x5322A8 |
.data.rel.ro |
Cronet_Engine 虚表起始地址 |
0x532390 |
.data.rel.ro |
虚表第29项,指向 sub_2289C8 |
0x22AE14 |
函数 |
Cronet_Engine_Create(构造 Engine 对象) |
0x416100 |
代码 |
fallback 路径(未注册时) |
0x2D7A68 |
函数 |
获取时间戳 |
0x424FD0 |
函数 |
解析 HTTP headers 参数 |
0x3D6D98 |
函数 |
vector.at(i) 按索引取元素 |
0x1D92EC |
函数 |
string.append() |
0x295540 |
函数 |
string.split() |
0x3218F8 |
函数 |
headers_map.set(key, value) |
0x2CC464 |
函数 |
operator delete / free |
总结
整个机制拆开看:
libsscronet.so 通过 Cronet_Engine_Create 创建 Engine 对象,对象头部写入虚表指针 off_5322A8
- Engine 对象被传递给
libmetasec_ml.so(通过 JNI 或 Cronet API)
- 加密 SO 通过虚表第 29 个槽位(
injiami)调用注册函数,把自己的加密函数地址原子写入 qword_556958
- 网络请求时,
EncryptEntry 原子读取该指针,构造好 URL 和 headers body 两个参数后调用
- 加密函数返回
key\r\nvalue\r\n... 格式的字符串,调用方解析后写入 HTTP headers(X-Gorgon、X-Khronos 等)
核心设计意图是解耦网络层和安全层。网络 SO 不需要知道加密 SO 的存在,只要有人通过虚表往那个全局变量里填了地址就调用,没填就走 fallback。这样加密 SO 可以独立更新、条件加载,甚至在某些场景下完全不加载也不影响网络功能。
从逆向角度看,这种设计比直接 PLT 调用更难静态分析——你在 IDA 里看到的只是一个 BLR X23,不知道它跳到哪里去;注册函数也没有直接 caller,只有虚表里的一个地址。必须动态跑起来,hook 注册点看 backtrace,才能把两个 SO 之间的关系串起来。