吾爱破解 - 52pojie.cn

 找回密码
 注册[Register]

QQ登录

只需一步,快速开始

查看: 267|回复: 2
上一主题 下一主题
收起左侧

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

  [复制链接]
跳转到指定楼层
楼主
rsds0duck 发表于 2026-5-16 19:32 回帖奖励

抖音 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+0x228X19+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...。处理流程:

  1. NULL 检查——加密失败就跳过
  2. 包装成 std::string
  3. \r\n split 成 vector
  4. free 掉原始指针
  5. 成对遍历,写入 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

总结

整个机制拆开看:

  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 之间的关系串起来。

免费评分

参与人数 3吾爱币 +2 热心值 +2 收起 理由
zshlbl + 1 感谢发布原创作品,吾爱破解论坛因你更精彩!
springlu + 1 我很赞同!
uniceguy + 1 + 1 用心讨论,共获提升!

查看全部评分

发帖前要善用论坛搜索功能,那里可能会有你要找的答案或者已经有人发布过相同内容了,请勿重复发帖。

沙发
addis2579 发表于 2026-5-18 17:24
简直了,666
3#
wanghua1986 发表于 2026-5-19 08:54
您需要登录后才可以回帖 登录 | 注册[Register]

本版积分规则

返回列表

RSS订阅|小黑屋|处罚记录|联系我们|吾爱破解 - 52pojie.cn ( 京ICP备16042023号 | 京公网安备 11010502030087号 )

GMT+8, 2026-5-19 09:41

Powered by Discuz!

Copyright © 2001-2020, Tencent Cloud.

快速回复 返回顶部 返回列表