首页
社区
课程
招聘
[原创]抖音 libsscronet 跨 SO 加密调用机制
发表于: 1天前 806

[原创]抖音 libsscronet 跨 SO 加密调用机制

1天前
806

# 抖音 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, &params);  // 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内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 8
支持
分享
最新回复 (4)
雪    币: 211
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
6
1天前
0
雪    币: 1707
活跃值: (6299)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
6
2小时前
0
雪    币: 3
活跃值: (1565)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
6
2小时前
0
雪    币: 104
活跃值: (8527)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
6
2分钟前
0
游客
登录 | 注册 方可回帖
返回