# 抖音 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` 的原型:
```c
int64_t EncryptEntry(int64_t a1, int64_t a2, int64_t a3, int64_t *a4)
```
入口处的寄存器保存是标准的 ARM64 prologue:
```arm64
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`:
```arm64
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 代码:
```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 字符串
从请求上下文的容器里取最后一个元素:
```arm64
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()` 指针提出来:
```arm64
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>`:
```arm64
0x415E34: SUB X0, X8, #0x78 ; 容器最后一个元素
0x415E40: MOV X1, X20 ; headers map
0x415E48: BL sub_424FD0 ; 解析 headers → 输出到 var_130 容器
```
然后是一个循环,把所有元素用 `\r\n` 拼起来:
```arm64
; 初始化
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 代码:
```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。
---
## 调用加密函数
参数准备好了,调用前后各记一次时间戳用于性能监控:
```arm64
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` 的差值就是加密耗时。设计很贼——直接在调用点前后打点,不需要加密函数自己上报。
加密函数原型:
```c
// 位于 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...`。处理流程:
1. NULL 检查——加密失败就跳过
2. 包装成 `std::string`
3. 按 `\r\n` split 成 vector
4. free 掉原始指针
5. 成对遍历,写入 HTTP headers
```arm64
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 代码:
```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` 本身极其简单,就三条指令:
```arm64
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`:
```c
__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 代码
```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 可以验证:
```javascript
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 |
---
## 总结
整个机制拆开看:
1. `libsscronet.so` 通过 `Cronet_Engine_Create` 创建 Engine 对象,对象头部写入虚表指针 `off_5322A8`
2. Engine 对象被传递给 `libmetasec_ml.so`(通过 JNI 或 Cronet API)
3. 加密 SO 通过虚表第 29 个槽位(`injiami`)调用注册函数,把自己的加密函数地址原子写入 `qword_556958`
4. 网络请求时,`EncryptEntry` 原子读取该指针,构造好 URL 和 headers body 两个参数后调用
5. 加密函数返回 `key\r\nvalue\r\n...` 格式的字符串,调用方解析后写入 HTTP headers(X-Gorgon、X-Khronos 等)
核心设计意图是解耦网络层和安全层。网络 SO 不需要知道加密 SO 的存在,只要有人通过虚表往那个全局变量里填了地址就调用,没填就走 fallback。这样加密 SO 可以独立更新、条件加载,甚至在某些场景下完全不加载也不影响网络功能。
从逆向角度看,这种设计比直接 PLT 调用更难静态分析——你在 IDA 里看到的只是一个 `BLR X23`,不知道它跳到哪里去;注册函数也没有直接 caller,只有虚表里的一个地址。必须动态跑起来,hook 注册点看 backtrace,才能把两个 SO 之间的关系串起来。
[培训]《冰与火的战歌:Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。