首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
Android安全
发新帖
6
6
[原创]尝试分析某海外加固
发表于: 2026-6-21 22:57
13651
[原创]尝试分析某海外加固
孤木落
1
2026-6-21 22:57
13651
样本是ClashRoyale,本文由AI生成,就当笔记了,请见谅( 有一说一,ai的算法敏感度和复杂逻辑抽取关键部分的能力真的吊 # libsupercell_clashroyale.so 加固机制逆向 —— 源码分析逐轮记录 > 本文逐轮记录对 Shield 加固层的源码分析过程。每一轮按 **thinking → 源码 → 分析 → 结论** 结构组织,函数地址均为相对 SO 基址的 RVA。 --- ## 第 1 轮:建立整体认知,推翻"LibreSSL = Shield"误判 ### thinking 经过方法论纠正后,第一件事是把几个看似关键的函数放在一起读,看逻辑连贯性,而不是孤立地"这个函数调了 tgkill 所以它是校验"。选的切入点是:一个注册回调的函数(`sub_21A69C`)、一个被大量引用的数据表首项(`sub_45129C`)、一个看起来像"空校验"的桩(`nullsub_103`)、一个走 BIO 风格回调的函数(`sub_5E1AB8`)。目的是先判断之前认定的"函数指针校验表 / CRC 校验"是不是真的存在。 ### 源码 **`sub_5E1AB8` (RVA 0x5E1AB8)** —— 一个多分支回调,按操作码 `n2` 分发: ```c __int64 sub_5E1AB8(_QWORD *a1, int *a2, __int64 a3, unsigned __int64 n7, signed int n2) { ... if (n2 <= 1) { if (!n2) { ... return; } if (n2 != 1) return sub_5614E8(...); v11 = sub_5215A8((__int64)a1, a3, n7); // read if ((v11 & 0x8000000000000000LL) == 0) { ... do { ... LODWORD(v15) = crc32(v15, a3 + v14, (unsigned int)v16); // 流式 CRC ... } while (v14 < v10); ... } } if (n2 == 7) return *((_QWORD *)a2 + 4); // tell if (n2 == 8) { ... return v9 | ...; } // size ... } ``` **`sub_21A69C` (RVA 0x21A69C)** —— 构造对象并装入回调表: ```c _QWORD *sub_21A69C(__int64 a1, __int64 (__fastcall *a2)(...), __int64 a3, __int64 a4) { v11[0] = sub_59ECE0(a1); v8 = a2(a1, a3, v11, 8, 8); // 用回调初始化 ... result = (_QWORD *)sub_19492C(a4); if (result) { *result = a1; result[1] = a2; // 存回调表 result[2] = a3; result[5] = v9; } return result; } ``` **`sub_45129C` (RVA 0x45129C)** —— 设置 vtable: ```c __int64 sub_45129C(_QWORD *a1) { *a1 = off_692D10; // 把 0x692D10 装到对象头 return sub_324B20((__int64)(a1 + 2)); } ``` **`nullsub_103` (RVA 0x453834)** —— 空桩: ```c void nullsub_103() { ; } ``` **`sub_612A7C` (RVA 0x612A7C)** —— 字符串常量: ```c void __noreturn sub_612A7C() { sub_612DAC("Pure virtual function called!"); } ``` ### 分析 把这几个函数放一起看,逻辑高度自洽: - `sub_45129C` 干的事是 `*a1 = off_692D10` —— 这是 **C++ 构造函数设置 vptr** 的标准动作。0x692D10 是某个类的 vtable。 - `0x692E20` 处存放 `0x8C328`,正好位于 vtable 前面一格 —— 这是 C++ 标准布局里的 **type_info 指针**(vtable[-1])。 - `sub_612A7C` 抛 `"Pure virtual function called!"` —— 这是 vtable 里**纯虚函数的占位符**。 - `nullsub_103` 是 vtable 里"无操作"的默认项(空桩)。 - `sub_5E1AB8` 是一个 **BIO method 回调表**实现:`n2` 是操作码(1=read, 7=tell, 8=size),read 分支对读到的字节流**滚动算 crc32**,累积到 `a2[12]`。读完后 `v29 != a2[12]` 比较校验。 - `sub_21A69C` 是 **`BIO_new` 风格的构造**:分配对象、装 method 回调、存上下文。 ### 结论 之前认定的"0x692Dxx 是函数指针校验表"**完全错误**。这片实际上是: 1. **C++ vtable**(`sub_45129C` 设 vptr + type_info + 纯虚占位 + 空桩 = 标准 vtable 布局) 2. **LibreSSL BIO 文件读取层**(`sub_5E1AB8` 的 crc32 是对**文件数据流**算校验,验证证书/配置文件没被改,**不是代码段校验**) `sub_453898`(之前认定的"Shield kill 入口")只是某个 C++ 类的虚方法(析构类),跟检测无关。libsupercell_clashroyale.so 的**绝大部分体积是静态链接的 LibreSSL**,Shield 保护逻辑只占一小部分混在其中。抓到的 crc32 / tgkill 全是 LibreSSL 正常功能,不能作为 Shield 入口。 --- ## 第 2 轮:ChaCha20 调用链 —— 定位真正的 Shield 解密核心 ### thinking 既然 0x692Dxx 是 vtable,那 Shield 真正的保护层得重新找。方法论给的线索是三条:调 ChaCha20 解密代码段、读写 rwxp 段、mprotect 改权限。先从 ChaCha20 入手 —— 之前 agent 报告说 ChaCha20 在 `sub_249D94`,但那是 agent 猜的,需要自己读代码确认,然后沿 caller 链往上追,看它解密的是 TLS 数据还是代码段。 ### 源码 **`sub_249D94` (RVA 0x249D94)** —— 含 ChaCha20 常量: ```c __int64 sub_249D94(_OWORD *a1, _OWORD *a2, unsigned __int64 n0x3F_1, int *a4, __int64 a5, unsigned int a6) { ... int n1797285236; // 1797285236 int n2036477234; // 2036477234 int n857760878; // 857760878 int n1634760805; // 1634760805 ... } ``` **`sub_24A228` (RVA 0x24A228)** —— caller,ChaCha20 封装: ```c __int64 sub_24A228(_OWORD *a1, _OWORD *a2, unsigned __int64 n0x3F, __int64 a4, __int64 a5) { if (*(_QWORD *)(a4 + 16) != 8 || *(_QWORD *)(a5 + 16) != 32) return 0; v5 = *(int **)a4; // nonce if (!v5) { v5 = &byte_6C6540; byte_6C6540 = 0; } v6 = *(char **)a5; // key if (!v6) { v6 = &byte_6C6540; byte_6C6540 = 0; } return sub_249D94(a1, a2, n0x3F, v5, (__int64)v6, 0x14u); // 0x14 = 20 rounds } ``` **`sub_551630` (RVA 0x551630)** —— 上一层 caller: ```c __int64 sub_551630(__int64 a1, __int64 x1_0) { ... sub_240BF8(*(_QWORD *)a1, a2); sub_19C758(25, v5); sub_5A5868(&n4, a2, v5); ... sub_24A228(*(_OWORD **)(a1 + 4136), *(_OWORD **)(a1 + 4136), a1 - *(_QWORD *)(a1 + 4136) + 4128, x1_0, (__int64)v8); return sub_19BF40((__int64)v8); } ``` **`sub_551750` (RVA 0x551750)** —— 再上一层,函数体充满页对齐常量: ```c __int64 sub_551750(__int64 a1, __int64 a2) { ... char n20; // w8 unsigned __int64 n0xFFF; // x9 __int64 n4096; // x9 ... __int64 n32; // x10 ... unsigned __int64 n0x1000; // x9 unsigned __int64 n0xFFF_1; // x8 __int64 n4096_1; // x8 ... } ``` ### 分析 - `sub_249D94` 含 ChaCha20 四个初始化常量(`1634760805`="expa", `857760878`="nd 3", `2036477234`="2-by", `1797285236`="te k" —— 即 `"expand 32-byte k"`),**确认是 ChaCha20 core**。 - `sub_24A228` 是标准封装:校验 nonce 长度 8、key 长度 32,调 core 时传 `0x14`(20 轮)。注意 nonce 是 **8 字节** —— 这是原始 Bernstein 版 ChaCha20(8B nonce + 8B counter),不是 IETF(12B nonce + 4B counter)。 - `sub_551630` 的关键:ChaCha20 的 key 和 data 参数**都是 `*(a1+4136)`**,长度是 `a1 - *(a1+4136) + 4128`。也就是说在 **就地解密** `a1+4136` 指向的内存。这种"对象内偏移指针指向的连续区域就地解密"模式,常见于代码段解密,而非 TLS record。 - `sub_551750` 的局部变量名全是 `n4096` / `n0xFFF` / `n0x1000` / `n4096_1` —— 这是 **4KB 页对齐遍历**的特征。TLS record 处理不会做 4096 对齐。 ### 结论 这条链(`sub_551750 → sub_551630 → sub_24A228 → sub_249D94`)是**按页(4096 字节)解密代码段**的 ChaCha20 解密器,不是 TLS 加密。agent 之前给的 ChaCha20 地址(`0x68192C`)是错的,真正的 core 在 `0x249D94`。 但要判断它是 Shield 解密还是 LibreSSL 内部用,还得继续往上追 caller —— 看它服务于什么。 --- ## 第 3 轮:发现解密线程 —— 推翻"周期性轮询"假设 ### thinking `sub_551750` 的 caller 是 `sub_5512A4`,继续往上追。如果 caller 链通往 `ssl_pkt` / `ssl_lib` 范围,那是 TLS;如果通往一个独立线程循环,那是 Shield 解密服务。重点看 `sub_5512A4` 的栈分配(页大小缓冲?)和它的 caller 是不是一个 `__noreturn` 循环。 ### 源码 **`sub_5512A4` (RVA 0x5512A4)** —— 单页解密入口: ```c __int64 sub_5512A4(__int64 *a1) { ... char v25[4112]; // ⭐ 4112 = 4096 + 16,一页 + 元数据 ... sub_682420(v25, 0, 4112); // memset 4112 字节 v3 = sub_551750(v22, &v15); // 调页解密器 n1274593961 = *off_6AF320; ... } ``` **`sub_550F20` (RVA 0x550F20)** —— `sub_5512A4` 的调用者,`__noreturn`: ```c void __noreturn sub_550F20() { while (1) { sub_682420(v4, 0, 1040); for (i = 0; i != 1024; i += 16) { v1 = &v4[i]; *((_DWORD *)v1 + 4) = 2; *((_QWORD *)v1 + 3) = 0; } v2 = &unk_6CBA88; v3 = 1; sub_5ECFEC(&unk_6CBA88); while (!dword_6BEE44) // ⭐ 等待解密请求标志 sub_5ECA08(&unk_6CBA58, &v2); // cond_wait sub_682330(v4, &dword_6BEE40, 1048); // 取请求结构 if (v3 == 1) sub_5ED040(v2); sub_2ADB1C(); sub_5512A4(v4); // ⭐ ChaCha20 解密一页 sub_5EC9E8(&unk_6CBAB0); } } ``` **`sub_5ECA08` (RVA 0x5ECA08)** —— 等待原语: ```c __int64 sub_5ECA08(pthread_cond_t *cond, __int64 a2) { if (*(_BYTE *)(a2 + 8) != 1) { ... "condition_variable::wait: mutex not locked" } result = pthread_cond_wait(cond, *(pthread_mutex_t **)a2); ... } ``` **`sub_550E54` (RVA 0x550E54)** —— 创建该线程: ```c __int64 sub_550E54(int a1, __int64 a2, __int64 a3) { dword_6BF258 = a1; qword_6CBB08 = a2; sub_551EEC(&unk_6CBB10, a3); sub_551004(v4, sub_550F20); // ⭐ pthread_create(start=sub_550F20) sub_5ED1DC(v4); sub_5ED170(v4); ... } ``` ### 分析 - `sub_5512A4` 在栈上分配 `v25[4112]`(正好一页 4096 + 16 字节元数据),memset 清零后调 `sub_551750` —— 这是**单页解密入口**。 - `sub_550F20` 是 `__noreturn` 的**无限循环**:memset 请求缓冲 → `while (!dword_6BEE44) cond_wait` 阻塞等待 → 收到信号后从 `dword_6BEE40` 拷贝 1048 字节请求 → 调 `sub_5512A4` 解密 → 循环。 - `sub_5ECA08` 是 `pthread_cond_wait` 封装(错误信息 `"condition_variable::wait: mutex not locked"` 是铁证)。 - `sub_550E54` 通过 `sub_551004(v4, sub_550F20)` 启动线程(`sub_550F20` 作为 start_routine)。 - `0x550E94` 处有 `sub_550F20` 的函数指针引用 —— 印证它是线程入口。 ### 结论 Shield 用的是 **生产者-消费者 RPC 线程模型**做代码段按需解密(lazy decryption): - **解密线程** `sub_550F20` 常驻,`cond_wait(dword_6BEE44)` 等请求 - 业务线程要执行某段加密代码时,发请求(`dword_6BEE44 = api_id`),`signal`,然后 `cond_wait` 阻塞等结果 - 解密线程收到信号,ChaCha20 解密该页,回写共享内存,signal 业务线程 - 业务线程醒来,执行已解密代码 **这彻底推翻了之前脑补的"周期性轮询 CRC"** —— 实际是**按需解密**,事件驱动。 --- ## 第 4 轮:定位全局派发咽喉 —— 验证可信执行模型 ### thinking 解密线程找到了,但要理解整体调度,得找到"业务代码 → 提交解密请求"这一环。`sub_551110` 是请求提交函数(写 `dword_6BEE44`),它的 caller 就是业务侧调用点。再往上 `sub_551110 ← sub_5E244C`,而 `sub_5E244C` 看起来是按 `api_id` 派发 —— 这可能就是用户洞察的"全局统一的调用派发函数"。重点读它的查表、解密触发、调用逻辑。 ### 源码 **`sub_5E244C` (RVA 0x5E244C)** —— 全局派发咽喉: ```c _BYTE *sub_5E244C(unsigned int a1, __int64 a2, __int64 a3) { result = (_BYTE *)sub_62F0C8(&unk_6C3348); // 查 TLS 缓冲 if (*result != 1) { // 【校验点 A: 1字节解密状态标记】 sub_551110(a1, a2); // 提交解密请求 goto LABEL_11; } v7 = a1 - 4; if (a1 - 4 >= 0xD || ((0x129Fu >> v7) & 1) == 0) // 位图校验合法 api_id LABEL_11: sub_6826D0(1); v8 = (__int64)*(&off_6A6F98 + v7); // ⭐ 查函数地址表 v9 = *(__int64 (__fastcall **)(_QWORD))(v8 + 8); // 取真实函数指针 if ((*(_BYTE *)v8 & 4) != 0) return (_BYTE *)v9(a1, a2, a3); // 派发调用 if (v9 != 1) { if (v9) return (_BYTE *)v9(a1); ... } return result; } ``` **`sub_551110` (RVA 0x551110)** —— 解密请求提交: ```c __int64 sub_551110(int a1, __int64 a2) { v4 = sub_5ECFEC(&unk_6CBA88); v5 = sub_682410(v4); dword_6BEE40 = v5; // 请求序号 dword_6BEE44 = a1; // ⭐ 设置 api_id qword_6BEE48 = a2; // 请求数据 do { // 填充请求表 (6BEE58 起, 1024字节) n1024 -= 16; *((_DWORD *)v7 - 2) = 2; *v7 = 0; v7 += 2; } while (n1024); qword_6BF250 = 0; sub_439028((__int64)&unk_6BEE50); sub_5ED040(&unk_6CBA88); // signal 解密线程 sub_5EC9E8(&unk_6CBA58); // unlock sub_5ECFEC(&unk_6CBAE0); result = sub_5ECA08(&unk_6CBAB0, &v9); // ⭐ cond_wait 等结果 (同步 RPC) ... } ``` ### 分析 - `sub_5E244C` 的逻辑完全符合"全局派发"模式: 1. `sub_62F0C8` 查 TLS 缓冲,`*result != 1` 判断该代码页是否已解密 2. 未解密 → `sub_551110` 提交请求(异步线程解密) 3. 位图 `0x129Fu >> (api_id-4) & 1` 校验 api_id 合法性(0x129F = `0001 0010 1001 1111`,合法 api_id ∈ {4,5,6,7,8,11,13,16}) 4. `off_6A6F98[api_id-4]` 查函数地址表,每项结构 `+0 flags, +8 函数指针` 5. 按 flags 派发调用 `v9(api_id, args)` - `sub_551110` 是同步 RPC 客户端:写共享结构(`dword_6BEE44 = api_id`)→ signal 解密线程 → 自己 `cond_wait` 阻塞等结果。 - `off_6A6F98` 就是用户洞察的"**stub 持有含目标函数绝对地址的对象**"。 ### 结论 完整可信执行调度模型串起来了,用户洞察全部命中: | 用户洞察 | 代码证据 | |---|---| | "stub 持有含目标函数绝对地址的对象" | `off_6A6F98` 函数地址表 ✅ | | "全局统一的调用派发函数" | `sub_5E244C` ✅ | | "在合适时机 hook 解密的函数" | `sub_551110` (请求) / `sub_5512A4` (执行) ✅ | | "周期性轮询"(我脑补) | **错误**,实际是按需解密 ✅ | 关键 RVA: - `sub_5E244C` (0x5E244C) — 全局派发咽喉 - `sub_551110` (0x551110) — 解密请求提交(同步 RPC) - `off_6A6F98` (0x6A6F98) — 受保护函数地址表 - `dword_6BEE44` (0x6BEE44) — 解密请求标志 --- ## 第 5 轮:CRC 位置排查(第一轮排除) ### thinking 模型建立后,下一个问题是 CRC/完整性校验在哪一层。`sub_5E244C` 的 `if (*result != 1)` 是状态检查不是 CRC。候选点:`sub_62F0C8`(状态检查函数内部)和 `sub_5512A4`(解密入口内部)。`sub_5512A4` 的 callees 里有 `sub_18D6F8` / `sub_18D7F0` / `sub_18D930` —— 在 0x18Dxxx 区域,跟之前看到的 `sub_18D17C`(crc32)同区域,可能是 CRC helper。挨个读确认。 ### 源码 **`sub_62F0C8` (RVA 0x62F0C8)** —— 状态检查函数: ```c __int64 sub_62F0C8(_QWORD *a1) { v2 = atomic_load(a1 + 2); if (!v2) { sub_1E9BE4(dword_6D2C08, sub_62F2F0); pthread_mutex_lock(&mutex__8); v2 = a1[2]; if (!v2) { v2 = ++qword_6D2C10; atomic_store(qword_6D2C10, a1 + 2); } pthread_mutex_unlock(&mutex__8); pointer_2 = pthread_getspecific(key_3); // ⭐ TLS 查询 ... pointer[1] = v6; pthread_setspecific(key, pointer); // 存回 TLS } ... *pointer = 1; // 标记初始化 return result; } ``` **`sub_5512A4` 后半段** —— 解密后的处理: ```c LODWORD(v19[0]) = v6 & 0xFF00FFFF; memset(v14, 0, sizeof(v14)); sub_18D7F0(); sub_18D930(v14, 0, (char *)v19, 0); // 0x18Dxxx 区域调用 ... sub_18D930(&v11, 0, v25, v7); sub_47981C((__int64)&v11, v19); sub_20D574(v14, 1u, _1, v19[2]); sub_18D6F8(v19); sub_18D6F8(&v11); if (dword_6BF258 != -1) { if ((char *)sub_550D6C(dword_6BF258, (__int64)v14) != &byte_88020) { // ⭐ 比较 v10 = (_QWORD *)sub_6826D0(1); sub_18D6F8(v14); sub_33BC80(v10); } sub_682560((unsigned int)dword_6BF258); sub_6824D0(10); } return sub_18D6F8(v14); ``` **`sub_18D6F8` (RVA 0x18D6F8)** —— 被"上千"函数调用: ```c __int64 sub_18D6F8(_QWORD *a1) { v1 = *a1; result = sub_18D784(*a1, a1[2]); // 释放 if (v1) result = sub_62D93C(v1); // free *a1 = 0; a1[1] = 0; a1[2] = 0; // 清零三字段 return result; } ``` ### 分析 - `sub_62F0C8` 用 `pthread_getspecific(key_3)` / `pthread_setspecific` —— 这是 **TLS(线程局部存储)缓冲区管理**。被上百个函数调用(caller 列表巨大),是通用 C++ 基础设施。`*pointer = 1` 是初始化标记。不是 CRC。 - `sub_18D6F8` 的模式(释放 → free → 清零 ptr/len/cap 三字段)是 **std::string 析构**。被上千函数调用,通用基础设施。不是 CRC。 - `sub_5512A4` 后半的 `sub_550D6C(dword_6BF258, v14)` 看起来像比较,但需要读 `sub_550D6C` 确认。 ### 结论 `sub_62F0C8` 是 TLS 缓冲区管理,`sub_18D6F8` 是 string 析构 —— 都不是 CRC。`sub_5E244C` 里的 `*result != 1` 检查的是 TLS 缓冲项首字节(解密状态标记),不是完整性比较。`sub_5512A4` 后半的 `sub_550D6C` 是候选比较点,下一轮确认。 --- ## 第 6 轮:CRC 位置排查(第二轮排除) ### thinking 继续排除。读 `sub_18D930`(0x18Dxxx 区域,可能是 hash)、`sub_18D7F0`(同区域)、`sub_550D6C`(比较候选)。这三个放一起看,判断 `sub_5512A4` 后半是不是解密后的完整性校验。 ### 源码 **`sub_18D930` (RVA 0x18D930)** —— 被上千函数调用: ```c __int64 *sub_18D930(__int64 *result, unsigned __int64 a2, char *a3, unsigned __int64 a4) { v4 = *result; if (*result) { v5 = result[1]; // 长度 v7 = v5 - a2; // 偏移 if (v5 >= a2) { v8 = (char *)(v4 + a2); // 目标 if (a4 < v7) v7 = a4; if (a3 >= v8 || &a3[v7] <= v8) { // 正向拷贝 do { *v8++ = *a3++; --v11; } while (v11); } else { // 重叠,反向拷贝 do { *(_BYTE *)(a2 + v4 - 1 + v9) = a3[v9 - 1]; --v9; } while (!v10); } if (result[2] < v7 + a2) return sub_18D9BC(); // 扩容 } } return result; } ``` **`sub_18D7F0` (RVA 0x18D7F0)** —— 构造+析构临时对象: ```c __int64 sub_18D7F0() { _QWORD v1[4]; v1[3] = *(_QWORD *)(_ReadStatusReg(TPIDR_EL0) + 40); sub_18D844(v1); // 构造临时 string return sub_18D6F8(v1); // 立即析构 } ``` **`sub_550D6C` (RVA 0x550D6C)** —— "比较候选": ```c __int64 sub_550D6C(unsigned int a1, __int64 a2) { _1 = *(char **)a2; // 数据指针 if (!*(_QWORD *)a2) { _1 = 1; ... } v4 = *(_QWORD *)(a2 + 16); // 长度 if (!v4) return n4; n4 = n4_0; do { v7 = sub_682490(a1, _1, v4); // ⭐ syscall 封装 if (v7 < 1 || v4 < v7) { if (!v7) return n4; n11 = *(_DWORD *)_errno(v7, v8); if (n11 != 11 && n11 != 4) return n4; // 11=EAGAIN, 4=EINTR } else { _1 += v7; v4 -= v7; } } while (v4); return n4_1; } ``` ### 分析 - `sub_18D930` 是 **`std::string::append` / memcpy**(处理源/目标重叠,更新 ptr/len/cap),通用 string 操作。 - `sub_18D7F0` 是构造临时 string 后立即析构(RAII 临时缓冲)。 - `sub_550D6C` 是 **`write()` 封装**:`sub_682490` 是 write syscall,循环处理 `EAGAIN`(11) / `EINTR`(4) 重试。`a1` 是 fd,`a2` 是数据 buffer。 ### 结论 `sub_5512A4` 后半的 `sub_550D6C(dword_6BF258, v14)` 是**把解密结果 write 到 fd**,不是 CRC 比较。`dword_6BF258` 是 `sub_550E54` 传入的 fd(解密线程关联的输出 fd)。 `sub_18D930` / `sub_18D7F0` / `sub_18D6F8` 全是 std::string 操作。**到目前为止整条解密链里没找到 CRC 比较**。 --- ## 第 7 轮:最后的排除 —— libsupercell 侧没有 CRC ### thinking 还剩 `sub_47981C` 和 `sub_20D574`(都在 `sub_5512A4` 解密后调用)。读这两个,如果还不是 CRC,就能下结论:libsupercell 侧根本没有独立的 CRC 校验代码,完整性靠别的机制。 ### 源码 **`sub_47981C` (RVA 0x47981C)** —— 经典编码特征: ```c __int64 *sub_47981C(__int64 a1, __int64 *a2) { n3 = *(_QWORD *)(a1 + 16); v5 = n3 / 3; // ⭐ 3 字节分组 n3_2 = 3 * (n3 / 3); v27 = n3 % 3; if (n3 % 3) v7 = v5 + 1; else v7 = n3 / 3; result = sub_220388(4 * v7, a2); // ⭐ 输出 4 字节 ... do { v15 = ((unsigned __int8)_1[3 * v11] << 16) | ((unsigned __int8)_1[3 * v11 + 1] << 8) | (unsigned __int8)_1[3 * v11 + 2]; // 24 位打包 do { result = sub_479CF4((v15 >> (n24 - 6)) & 0x3F); // ⭐ 取 6 位 + 查表 n24 -= 6; *_1_3++ = (char)result; } while (n24); ++v11; _1_2 += 4; } while (v11 != v5); ... return sub_682420(&v16[n3_1], 61, 3 - v27); // ⭐ 61 = '=' padding } ``` **`sub_20D574` (RVA 0x20D574)** —— 字符串操作: ```c __int64 *sub_20D574(__int64 *a1, unsigned __int64 a2, char *a3, unsigned __int64 a4) { v5 = a1[2]; if (a2 <= v5) { ... sub_18DAD8(a1, n0x7F, v14); // 临时 v12 = a1[2]; if (a1[1] - v12 < v8) v8 = a1[1] - v12; sub_18D930(a1, v8 + a2, (char *)(*a1 + a2), v12 - a2); // 移动后半 sub_18D930(a1, a2, a3, v8); // 拷贝新数据 sub_18D6F8(v14); // 析构临时 } return a1; } ``` ### 分析 - `sub_47981C` 是 **Base64 编码**:3 字节分组 → 4 字节输出,24 位打包后每次取 6 位查表(`sub_479CF4` 是字母表),末尾 `=`(61) padding。所有特征铁证。 - `sub_20D574` 是 **`std::string::replace`**:移动后半 + 拷贝新数据 + 析构临时,标准 replace 实现。 ### 结论 **libsupercell 侧整条解密链里没有 CRC 校验**。 所有候选都排除了: | 候选 | 实际功能 | |---|---| | sub_18D17C | crc32(但只用于 BIO 文件读取流式校验,非代码段) | | sub_18D6F8 | std::string 析构 | | sub_18D7F0 | 临时 string 构造+析构 | | sub_18D930 | std::string::append (memcpy) | | sub_20D574 | std::string::replace | | sub_47981C | Base64 编码 | | sub_62F0C8 | TLS 缓冲区管理 | | sub_550D6C | write() 封装 | 初步推断:libsupercell 只是**解密服务**(ChaCha20 按需解密代码页),完整性校验 + tgkill + 重加密逻辑不在 libsupercell 的反编译里,可能在 libg.so 的 rwxp 段(Shield 解密出的保护代码本身)。(这个推断在第 10 轮被修正 —— 校验其实也在 libsupercell,只是藏在多层派发后面。) --- ## 第 8 轮:dump off_6A6F98 表 —— 发现 entry 加密存储 ### thinking 回到第 1 点任务:dump `off_6A6F98` 表,对比 maps + libc decompile 回注函数名。先看表结构 —— `sub_5E244C` 里 `*(&off_6A6F98 + v7)` 取的是指针(QWORD),指向 entry 结构(+0 flags, +8 函数指针)。先 dump 指针数组本身,再 dump 它指向的 entry 内容。 ### 源码 **`off_6A6F98` 指针数组(内存 dump)**: ``` 0x6A6F90 | 68 2D 5E 00 00 00 00 00 28 D1 6C 00 00 00 00 00 (表前一项) 0x6A6FA0 | A8 D1 6C 00 00 00 00 00 C8 D0 6C 00 00 00 00 00 0x6A6FB0 | E8 D0 6C 00 00 00 00 00 08 D1 6C 00 00 00 00 00 0x6A6FC0 | 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x6A6FD0 | 68 D1 6C 00 00 00 00 00 ... ``` 整理(`off_6A6F98[v7]`,v7 = api_id - 4): | v7 | api_id | 值(指向 entry) | |---|---|---| | 0 | 4 | 0x6CD128 | | 1 | 5 | 0x6CD1A8 | | 2 | 6 | 0x6CD0C8 | | 3 | 7 | 0x6CD0E8 | | 4 | 8 | 0x6CD108 | | 7 | 11 | 0x6CD168 | **entry 内容(0x6CD0C8 - 0x6CD1FF,内存 dump)**: ``` 0x6CD0C8 | FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF 0x6CD0D8 | FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ... (全部 0xFF) 0x6CD1FF | FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF FF ``` ### 分析 - `off_6A6F98` 指针数组**已初始化**(值都是 `0x6CD0xx` 范围的有效指针)。 - 但所有 entry 指向的内容(0x6CD0C8 - 0x6CD1FF)**全是 0xFF**。 - 如果直接按 `sub_5E244C` 逻辑读 entry:`v8 = 0x6CD128`,`*v8 = 0xFF`,`*(v8+8) = 0xFFFFFFFFFFFFFFFF`,调用会崩。说明 dump 时刻这些 entry **不可直接用**。 ### 结论 entry 表运行时**按需解密**。静态 dump 抓的时机 entry 还未被解密(全 0xFF = 加密态/未初始化态)。 这印证了第 3-4 轮的模型:`sub_5E244C` 触发时,先经 `sub_551110` 解密对应 entry → 读 flags + 函数指针 → 调用 → (可能)重新加密回去。所以**静态无法回注函数名** —— 抓到的 entry 全是 0xFF。 要拿真实函数指针,必须在运行时 hook `sub_5E244C`,在 `v8 = off_6A6F98[v7]` 之后、调用 `v9` 之前 dump entry。但 hook 本身可能触发检测 —— 鸡生蛋问题,需要先理解检测在哪。 --- ## 第 9 轮:GOT 真身追踪 —— 多级派发浮现 ### thinking 第 4 轮看到 `sub_5E244C` 的 LABEL_11 有一行 `sub_6826D0(1)`(返回值被忽略),当时不知道它干啥。现在回头追。 **第一步**:读 `sub_6826D0`,发现是 thunk —— `return qword_6C5288()`,即读 GOT[0x6C5288] 跳转。这是 Shield 的第一层混淆:通过 GOT 间接调用,让静态 caller 关系断裂(`sub_6826D0` 的 callers 显示 none)。 **第二步**:读 GOT `0x6C5288` 的值。**这里我读错了** —— dump 字节是 `B0 88 DE 97 6F 00 00 00`,我丢了一位读成 `0x6FDE88B0`(32 位,不像合法 ARM64 指针,查 maps 也查不到,卡住了)。**用户纠正为 `0x6F97DE88B0`**(48 位,合法指针)。 **第三步**:重新算 RVA:`0x6F97DE88B0 - 0x6F97C5A000 (libsupercell base) = 0x18E8B0`,对照 maps 落在 `0x6f97de7000-0x6f982b7000 r-xp 0x18d000` 段内。所以 `sub_6826D0` **不是 libc 函数,是 Shield 自家代码**,故意用 GOT 间接调用来抗分析。 **第四步**:读 `sub_18E8B0`(RVA 0x18E8B0),发现它又是派发:`sub_5533DC(ctx, 4, off_6ACCE8[0])`。`off_6ACCE8` 这张新表浮现。 **关键转折是用户纠正地址读错。** 没这一步,会一直卡在"0x6FDE88B0 是什么",看不到 `sub_18E8B0` 和 `off_6ACCE8`。教训:dump 字节要逐位核对,ARM64 指针是 48 位,丢了高位就会变成无效地址。 ### 源码 **`sub_6826D0` (RVA 0x6826D0)** —— thunk: ```c __int64 sub_6826D0() { return qword_6C5288(); // GOT 间接调用 } ``` GOT `0x6C5288` 的值(内存 dump): ``` 0x6C5288 | B0 88 DE 97 6F 00 00 00 → 0x6F97DE88B0 ``` `0x6F97DE88B0 - 0x6F97C5A000 = 0x18E8B0`(RVA,在 libsupercell 内) **`sub_18E8B0` (RVA 0x18E8B0)** —— callers: none(通过 GOT 间接调用): ```c __int64 sub_18E8B0(unsigned int a1) { v2 = sub_553218(); // 获取上下文 v3 = sub_5533DC(v2, 4, off_6ACCE8[0]); // ⭐ 按 key=4 查 off_6ACCE8 表 return v3(a1); // 调用返回的函数指针 } ``` **`off_6ACCE8` 表(内存 dump)**: ``` 0x6ACCE0 | 64 42 63 00 00 00 00 00 F8 2B 64 00 00 00 00 00 0x6ACCF0 | 78 AA 65 00 00 00 00 00 8C 41 63 00 00 00 00 00 ``` 整理: - `off_6ACCE8[0]` = 0x642BF8 - `off_6ACCE8[1]` = 0x65AA78 - `off_6ACCE8[2]` = 0x63418C ### 分析 **追踪链**(从 LABEL_11 到校验核心): ``` sub_5E244C LABEL_11 → sub_6826D0 (thunk) → GOT[0x6C5288] = 0x6F97DE88B0 → sub_18E8B0 (RVA 0x18E8B0) → sub_5533DC(ctx, key=4, off_6ACCE8[0]=0x642BF8) ``` 三层间接,每层都用不同手段混淆:GOT 间接(断裂 caller 关系)+ thunk 中转 + 表驱动派发。 **`off_6ACCE8` 的角色 —— 校验目标表** 看 `sub_5533DC` 怎么用它(第三个参数 `a3`): ```c v8 = sub_62F8A0(0, a3, ctx + 168 + 8*key); // 用 a3 在 ctx 缓存里查 ... if (sub_452500(v8, 0x98u) != stored_hash) { // XXH64(v8, 0x98) kill } ``` `a3 = off_6ACCE8[0] = 0x642BF8` 是一个**代码段地址**。`sub_5533DC` 拿它对应的缓存项 `v8`,对 `v8` 处 0x98 字节算 XXH64。所以 `off_6ACCE8` 每一项 = **一段需要 XXH64 完整性校验的代码的起始地址**: ``` off_6ACCE8[0] = 0x642BF8 → XXH64(0x642BF8, 0x98) 校验 152 字节 off_6ACCE8[1] = 0x65AA78 → XXH64(0x65AA78, 0x98) 校验 152 字节 off_6ACCE8[2] = 0x63418C → XXH64(0x63418C, 0x98) 校验 152 字节 ``` **`sub_18E8B0` 不是孤例 —— 它是一群 thunk 中的一个** `sub_5533DC` 的 callers 有 22 个,全是 `0x18Exxx`: ``` 0x18E788, 0x18E7D8, 0x18E810, 0x18E860, 0x18E8B0, 0x18E8E8, 0x18E90C, 0x18E930, 0x18E998, 0x18E9BC, 0x18E9FC, 0x18EA4C, 0x18EA9C, 0x18EB0C, 0x18EB7C, 0x18EBCC, 0x18EC0C, 0x18ED10, 0x18ED68, 0x18EDD8, 0x18EE30, 0x18EE94 ``` 每个都是 `sub_18E8B0` 的同胞(同样的小 thunk 结构),区别只是用 `off_6ACCxx` 系列里**不同的表 + 不同的 key**。每个 thunk 对应一个受保护 API 的校验入口。 **两层表的角色对比**(容易混) | 表 | 角色 | 内容 | |---|---|---| | `off_6A6F98` | **函数派发表**(sub_5E244C 用) | entry 槽 → 真实函数指针(运行时解密) | | `off_6ACCE8` | **校验目标表**(sub_5533DC 用) | 代码段 RVA → 被校验的 152 字节起点 | `off_6A6F98` 回答"调哪个函数",`off_6ACCE8` 回答"校验哪段代码"。两张表服务于派发的两个不同阶段。 ### 结论 **多级派发的完整图景浮现**: ``` [业务侧] sub_5E244C (派发咽喉) │ LABEL_11 (每次派发都走) ▼ [GOT 混淆层] 断裂静态 caller 关系 sub_6826D0 群 ▼ [校验入口层] 0x18Exxx thunk 群 (22 个) sub_18E8B0: sub_5533DC(ctx, key=4, off_6ACCE8[0]) ← 校验 0x642BF8 段 sub_18E788: sub_5533DC(ctx, key=?, off_6ACCxx[?]) ← 校验另一段 ... ▼ [校验核心] sub_5533DC ├─ sub_62F8A0 查 ctx 缓存 ├─ XXH64(code_ptr, 0x98) vs stored └─ 不等 → 重加密 + SVC kill ``` `off_6ACCE8` 是**校验目标表**(不是函数表),列出所有需要完整性校验的代码段地址。`sub_5533DC` 是真正的校验+派发核心。下一轮深读 `sub_5533DC` + `sub_452500`,定位校验算法和 kill 路径。 --- ## 第 10 轮:定位 XXH64 校验链 —— 检测+kill 完整曝光 ### thinking `sub_5533DC` 是多级派发的最后一层,参数 `(ctx, key, code_ptr)` 强烈暗示"校验 code_ptr 处的代码然后派发"。读它完整逻辑,重点找:查表、比较、不等时的处理。同时读它调用的 `sub_452500`(可能是校验算法)和 `sub_2ADF90`(不等时调用,可能是重加密)。 ### 源码 **`sub_5533DC` (RVA 0x5533DC)** —— 校验+派发核心: ```c __int64 *sub_5533DC(__int64 a1, unsigned int n12_1, __int64 *a3) { ... v8 = (__int64 *)sub_62F8A0(0, a3, a1 + 168 + 8LL * n12_1); // 查 ctx 缓存 if (!v8) { // 懒加载:虚函数调用取函数指针,缓存到 ctx ... } v19 = v8; if (v4 != v8) { v20 = (unsigned __int128 *)(a1 + 16 * n12_2 + 320); do v21 = __ldaxp(v20); // ⭐ 原子加载 128 位 (hash + 标记) while (__stxp(v21, v20)); v22 = atomic_load((unsigned __int64 *)(a1 + 312)); if (v22 - 1 != (_QWORD)v21 // 标记/版本不符 && sub_452500(v8, 0x98u) != *((_QWORD *)&v21 + 1) // ⭐ XXH64 != 存储 hash && v21 != 0) { sub_2ADF90(); // ⭐ 提交重加密任务 LODWORD(v34) = -451920092; __asm { SVC 0x80 } // ⭐⭐⭐ 直接系统调用 (绕过 libc!) v32 = (_QWORD *)sub_6826D0(1); sub_5ED040(a1 + 124); sub_33BC80(v32); } } return v19; } ``` **`sub_452500` (RVA 0x452500)** —— 校验算法(常量铁证): ```c unsigned __int64 sub_452500(__int64 *a1, unsigned __int64 n0x20) { if (n0x20 < 0x20) { v16 = 0x27D4EB2FC0B1E9CALL; } else { v2 = (unsigned __int64)a1 + n0x20 - 32; v3 = 0x61C8864F246FB77ELL; v4 = 0xC2B2AE3DD2306D54LL; v5 = 0x60EA27EF581C37DBLL; v6 = 2858123781LL; do { v7 = v5 - 0x3D4D51C2D82B14B1LL * *a1; ... v11 = __ROR8__(v7, 33); ... v5 = 0x9E3779B185EBCA87LL * v11; ... } while ((unsigned __int64)a1 <= v2); ... } ... return v22 ^ HIDWORD(v22); } ``` 常量识别: - `0x9E3779B185EBCA87` = XXH64 PRIME64_1 - `0xC2B2AE3D27D4EB4F` = XXH64 PRIME64_2 - `0x165667B19E3779F9` = XXH64 PRIME64_3 - `0x85EBCA77C2B2AE63` = XXH64 PRIME64_4 - `0x27D4EB2F165667C5` = XXH64 PRIME64_5 **`sub_2ADF90` (RVA 0x2ADF90)** —— 不等时调用,被 37 处调用: ```c __int64 sub_2ADF90() { sub_5ECFEC(&unk_6C7BE8); // lock v0 = sub_682168(72); // 分配 72 字节任务节点 *(_BYTE *)(v0 + 56) = 0; *(_DWORD *)(v0 + 16) = -1; *(_QWORD *)(v0 + 24) = -1; *(_QWORD *)(v0 + 32) = v0 + 32; *(_QWORD *)(v0 + 40) = v0 + 32; *(_QWORD *)(v0 + 48) = 0; ... *(_QWORD *)v0 = &qword_6C7C10; // ⭐ 挂到链表 qword_6C7C10 ... qword_6C7C20 = v2 + 1; return sub_5ED040(&unk_6C7BE8); // unlock } ``` ### 分析 - `sub_5533DC` 完整逻辑: 1. `sub_62F8A0` 查 ctx 内缓存(避免每次都校验) 2. 没缓存则懒加载(通过虚函数 `(*(v23+48))(...)` 动态解析函数指针,缓存) 3. **校验**:`__ldaxp` 原子加载存储的 128 位(低 64 位 = 版本/标记 `v21`,高 64 位 = 存储 hash) 4. 三条件比较: - `v22 - 1 != v21`(标记/版本不符) - `sub_452500(v8, 0x98u) != *(v21+8)`(**当前 XXH64 != 存储 hash**) - `v21 != 0`(标记非零) 5. 三条件全满足 → 校验失败 → `sub_2ADF90`(提交重加密)+ **`SVC 0x80`**(直接 syscall)+ `sub_6826D0(1)`(兜底) - `sub_452500` 的 5 个常量是 **XXH64 的素数定义**(Yann Collet xxHash 库的 `XXH_PRIME64_1..5`)。算法是 **XXH64**,不是 CRC。输入 `code_ptr` 处 0x98(152)字节,输出 64 位 hash。这正是用户说的"直接比较" —— 算 hash 然后直接和存储值比。 - **`sub_2ADF90` 逐字段拆解**(提交重加密任务到任务队列): ```c sub_5ECFEC(&unk_6C7BE8); // ① 加锁(保护链表的互斥量) v0 = sub_682168(72); // ② 分配 72 字节任务节点 (malloc 封装) // ③ 节点字段初始化: *(_BYTE *)(v0 + 56) = 0; // +56: 标记字节 *(_DWORD *)(v0 + 16) = -1; // +16: 状态/序号 (-1 = 待处理) *(_QWORD *)(v0 + 24) = -1; // +24: 请求 ID (-1 = 未指定) *(_QWORD *)(v0 + 32) = v0+32; // +32: 自引用 (链表哨兵) *(_QWORD *)(v0 + 40) = v0+32; // +40: 同上 *(_QWORD *)(v0 + 48) = 0; // +48: 清零 v1 = (__int64 *)qword_6C7C18; // 取当前链表头 v2 = qword_6C7C20; // 取计数 *(_QWORD *)(v0 + 64) = 0; // +64: 清零 *(_QWORD *)v0 = &qword_6C7C10; // +0: 指向链表根 (qword_6C7C10) *(_QWORD *)(v0 + 8) = v1; // +8: 前驱 = 旧链表头 *v1 = v0; // ④ 头插法: 旧头的 back 指向新节点 qword_6C7C18 = v0; // 新节点成为链表头 qword_6C7C20 = v2 + 1; // 计数 +1 return sub_5ED040(&unk_6C7BE8); // ⑤ 解锁 ``` 结构是典型的**生产者-消费者任务队列提交**: - `qword_6C7C10` = 链表根,`qword_6C7C18` = 头指针,`qword_6C7C20` = 节点计数 - 加锁 → 分配节点 → 头插法入队 → 计数+1 → 解锁 - 检测点只负责"提交",不负责"执行" **疑点已解开**(汇编确认):`sub_5533DC` 里 `sub_2ADF90()` 紧跟 `SVC 0x80`,而 SVC 解码结果是 **`exit_group(1)`**(见下 SVC 解码)—— 进程直接终止。所以 `sub_2ADF90` 提交的任务**根本来不及被消费执行**。 那 `sub_2ADF90` 的真实意义是 **取证/记录**: - 把"哪个校验失败、什么时间、上下文"记到链表节点 - 配合 `libsentry.so`(进程里确实有这个崩溃上报库)做 crash report - 或者链表节点带持久化标记,供下次启动 forensic 分析 - **不是为了重加密后继续运行** —— 进程马上 exit_group,没有"继续" **修正之前的说法**:之前笼统说"由后台线程处理重加密"完全错误。准确说 —— `sub_2ADF90` 是**异常取证入队**,进程随后 `exit_group(1)` 直接死,队列不会被消费。`sub_2ADF90` 被 **37 处**调用(所有 XXH64 校验失败点 + 其他检测点),是统一的"异常记录口"。 **关于"重新加密代码段"的观察**:如果动态调试时确实看到代码段被重新加密,那不是 `sub_5533DC` 这条路径干的(它直接 exit)。可能是: 1. 其他检测点(37 个 caller 中的别的路径)在 exit 前做了重加密 2. 或者 `atexit` / 信号处理器在 exit_group 实际终止前抢跑重加密 3. 或者观察到的"重加密"是 ChaCha20 解密线程的正常活动被误判 需要进一步动态确认 "重加密" 到底发生在哪条路径。 - **`SVC 0x80` 汇编解码 —— 确认是 `exit_group(1)`** 从 IDA 反汇编(`loc_553630`,RVA 0x553630)解码: ``` loc_553630: BL sub_2ADF90 ; 提交重加密任务 MOV W8, #0x3923 MOVK W8, #0xE511,LSL#16 ; W8 = 0xE5113923 MOV W10, #0x3F24 MOVK W10, #0xE510,LSL#16 ; W10 = 0xE5103F24 ADD W9, W8, #0x5A ; W9 = 0xE5113923 + 0x5A = 0xE511397D STR/LDR (栈中转混淆) ORR W10, W10, #1 ; W10 = 0xE5103F24 | 1 = 0xE5103F25 EOR W9, W9, W8 ; W9 = 0xE511397D ^ 0xE5113923 = 0x5E ⭐ syscall 号 EOR W10, W11, W10 ; W10 = 0xE5103F24 ^ 0xE5103F25 = 0x1 ⭐ 参数 MOV X0, X10 ; X0 = 1 (exit status) MOV X8, X9 ; X8 = 0x5E (syscall number) SVC 0x80 ; → 系统调用 ``` - **X8 = 0x5E = 94 = `exit_group`**,X0 = 1 = exit status - **确认 SVC 0x80 = `exit_group(1)`** —— 进程直接终止,状态码 1 - **常量混淆**:`0xE5113923` / `0xE5103F24` 是魔法数,通过 ADD/ORR/EOR 运算还原出 syscall 号 `0x5E` 和参数 `1`。抗静态搜索 —— 直接 grep `0x5E` / `94` / `exit_group` 都找不到,必须解码指令才能确认 - **这就是为什么 hook libc 的 `tgkill` / `exit` / `exit_group` 完全无效** —— Shield 不走 libc PLT,直接 `SVC 0x80` 进内核;而且 syscall 号通过常量混淆动态算出,连 syscall hook 都要小心 **修正之前的判断**:之前推测"可能是 tgkill/exit_group/rt_tgsigqueueinfo 之一" —— 现在汇编确认是 **`exit_group(1)`**。这同时解开了 `sub_2ADF90` 的疑点(见下)。 ### 结论 **完整检测 + kill 链最终定位**: ``` sub_5E244C (派发咽喉, 每次 API 调用都走) └─ LABEL_11 (无条件) → sub_6826D0(1) [GOT 间接] └─ sub_18E8B0 → sub_5533DC(ctx, key=4, off_6ACCE8[i]) │ ├─ sub_452500(code_ptr, 0x98) ⭐ XXH64 校验 152 字节 │ ├─ vs 存储的 hash (__ldaxp 取 128 位) │ └─ 不等 → sub_2ADF90() 提交重加密任务到链表 SVC 0x80 直接 syscall (绕过 libc) sub_6826D0(1) 兜底 kill ``` | 组件 | RVA | 真身 | |---|---|---| | 校验算法 | sub_452500 | **XXH64**(PRIME1-5 常量铁证) | | 校验+派发 | sub_5533DC | 比较 XXH64 → 不等 kill | | 重加密提交 | sub_2ADF90 | 挂链表 qword_6C7C10,37 处调用 | | kill | SVC 0x80 | **直接 syscall**(绕过 libc) | | 受保护代码表 | off_6ACCE8 | 代码段 RVA 列表(0x642BF8 等),每段 0x98 字节被校验 | **修正第 7 轮的初步推断**:之前说"libsupercell 侧没有 CRC,校验在 libg rwxp 段" —— 不准确。校验**就在 libsupercell**(`sub_5533DC` + `sub_452500`),只是藏在三层派发(`sub_6826D0` → `sub_18E8B0` → `sub_5533DC`)后面,且算法是 XXH64 不是 CRC。 --- ## 第 11 轮:追"重加密"路径 —— 发现事件聚合线程,推翻重加密假设 ### thinking 第 10 轮确认 `sub_5533DC` 路径是 `sub_2ADF90` + `exit_group(1)`,没有重加密。但 `sub_2ADF90` 有 37 个 caller,且用户动态观察到过"重新加密代码段"。两条线查重加密到底在哪: 1. 挑代表性 caller(`sub_5D34BC`)看它 `sub_2ADF90` 之后做什么 2. 找 `qword_6C7C10` 链表的**消费者**(谁读这个队列 = 真正的执行者) 线索:`pointers.txt` 显示 `qword_6C7C10/18/20` 的引用**全部集中在 0x2AD8F0-0x2AD9DC**(`sub_2ADF90` 之前),说明生产者和消费者都在 0x2ADxxx 区域。0x2AD948/974/9C4/9DC 这些地址读 `qword_6C7C18`(头指针) 和 `qword_6C7C20`(计数) —— 这是遍历/消费特征。这些地址都落在 `sub_2AD8BC`(0x2AD8BC-0x2ADAE4)内部。 ### 源码 **`sub_5D34BC` (RVA 0x5D34BC)** —— 代表性 caller(37 个之一): ```c sub_2ADF90(); // 提交事件 if ((unsigned int)sub_682500(v24, 0, off_6ADBB0[0], 0)) { // 某个检查 sub_6826D0(1); // ⭐ 派发到 sub_5533DC (校验+exit) v17 = (_QWORD *)sub_18D738(); sub_339628(&v18); sub_33BC80(v17); } ``` **`sub_2AD8BC` (RVA 0x2AD8BC)** —— 链表消费者,`__noreturn`,caller = **`sub_550E54`**(创建解密线程那个函数!): ```c void __noreturn sub_2AD8BC() { while (1) { sub_5ECFEC(&unk_6C7BE8); // lock sub_5ECA08(&unk_6C7BA0, &v22); // cond_wait 另一个事件源 (unk_6C7BA0) while (qword_6C7BE0) { // 处理事件队列 v3 = *(_DWORD *)(qword_6C7BD8 + 16); // 事件类型 n5 = *(_DWORD *)(qword_6C7BD8 + 20); // 操作码 v9 = *(_QWORD *)(qword_6C7BD8 + 24); // 事件数据 if (n5 == 5) { // 新建 qword_6C7C10 节点 (跟 sub_2ADF90 完全一样的入队逻辑) v4 = sub_682168(72); ... 挂到 qword_6C7C10 链表 } else { // 遍历 qword_6C7C18 链表找匹配项 v10 = qword_6C7C18; while (*(_DWORD *)(v10 + 16) != v3) { v10 = *(_QWORD *)(v10 + 8); if (v10 == &qword_6C7C10) goto LABEL_10; // 没找到 } if ((unsigned int)(n5 - 3) > 1) { // 更新节点内子链表的计数 v16[3]++ ++v16[3]; *(_QWORD *)(v10 + 64) = v21; // 更新统计 } else { *(_BYTE *)(v10 + 56) = (n5 == 3); // 标记 } } } } } ``` ### 分析 - **`sub_5D34BC` 不是重加密路径**:它 `sub_2ADF90` 之后调 `sub_6826D0(1)`(派发到 `sub_5533DC` → `exit_group`)。模式跟 `sub_5533DC` 一模一样:记录事件 + 派发 kill。 - **`sub_2AD8BC` 是第二个常驻线程**: - `__noreturn` 无限循环,caller 是 `sub_550E54`(创建线程的函数) - 等另一个事件源 `unk_6C7BA0`(`cond_wait`) - 从 `qword_6C7BD8` 取事件,按操作码 `n5` 处理: - `n5==5`:新建 `qword_6C7C10` 节点(入队逻辑跟 `sub_2ADF90` 完全一样) - 其他:遍历 `qword_6C7C18` 链表,**找匹配节点更新计数**(`v16[3]++`)或标记 - 这是**事件聚合/统计**逻辑,不是重加密 - **`qword_6C7C10` 真实角色**:**检测事件记录表**(带计数统计),不是重加密任务队列。节点结构 `+16` 类型 / `+32..48` 子链表(存重复事件的引用计数)/ `+56` 标记 / `+64` 统计值。 - **Shield 有两个常驻线程**(都由 `sub_550E54` 启动): 1. `sub_550F20` —— 按需解密线程(ChaCha20 解密代码页) 2. `sub_2AD8BC` —— 事件聚合线程(统计检测事件) ### 结论 **libsupercell 里根本没有"重加密代码段"的执行路径。** 检测响应链统一是: ``` 校验失败 (XXH64 不等) → sub_2ADF90 直接入队 qword_6C7C10 (37 处检测点) → 或 sub_2AD8BC 从 qword_6C7BD8 聚合事件到 qword_6C7C10 → exit_group(1) (sub_5533DC 路径) ``` `qword_6C7C10` 是**检测事件记录表**(供 crash report / forensic),不是重加密队列。没有任何函数从 `qword_6C7C10` 取节点做 ChaCha20 重加密。 **那用户观察到的"重新加密代码段"是什么?** 三种可能: 1. **误判**:`sub_550F20` 解密线程按需解密代码页(ChaCha20),观察者看到 ChaCha20 调用 + 代码段变化,误以为是"重加密"。实际是首次解密。 2. **libg rwxp 段**:真正的重加密逻辑可能在 `libg.so` 的 rwxp 段(`0x6f61a64000`,Shield 解密出的保护代码本身),不在 libsupercell 的反编译里。 3. **exit 前的 atexit / 信号处理**:`exit_group` 触发的 `atexit` handler 抢跑重加密。但 `exit_group` 通常跳过 atexit,可能性低。 **最可能是第 1 种**(误判)。按需解密模型下,代码段平时加密存储,首次执行时解密成明文并保持。不存在"解密→执行→重加密"的循环。观察到的"代码段变化"是首次解密,不是重加密。 **修正第 10 轮的说法**:之前推测"`sub_2ADF90` 提交重加密任务"完全错误。准确说 —— `sub_2ADF90` 是**检测事件入队**,`sub_2AD8BC` 是**事件聚合统计**,两者都写 `qword_6C7C10` 记录表,无消费者做重加密。检测失败的最终响应就是 `exit_group(1)`。 --- ## 汇总:Shield 可信执行完整模型 ``` 业务代码调 Shield API (api_id) │ ▼ sub_5E244C (0x5E244C) [全局派发咽喉] │ ├─① sub_62F0C8 查 TLS 缓冲 │ if (*result != 1) 【校验点 A: 1字节解密状态】 │ └─ sub_551110 (0x551110) 提交解密请求 (同步 RPC) │ ├─ dword_6BEE44 = api_id │ ├─ signal 解密线程 │ └─ cond_wait 等结果 │ │ │ ▼ (唤醒解密线程) │ sub_550F20 (0x550F20) [__noreturn 循环] │ └─ sub_5512A4 (0x5512A4) 单页解密入口 │ └─ sub_551750 → sub_551630 │ └─ ChaCha20 (sub_249D94, 0x249D94) │ 就地解密 *(ctx+4136) 处一页 (4096B) │ ├─② 位图校验: (0x129Fu >> (api_id-4)) & 1 【API ID 合法性】 │ ├─③ LABEL_11 → sub_6826D0(1) 无条件 (每次派发都走) │ └─ GOT[0x6C5288] → 0x18E8B0 │ └─ sub_18E8B0: sub_5533DC(ctx, key=4, off_6ACCE8[i]) │ │ │ ├─ sub_62F8A0 查 ctx 缓存 │ ├─ stored = __ldaxp(ctx + 16*key + 320) 取 128 位 │ │ 【校验点 B: XXH64 完整性校验】 │ │ if (标记有效 && XXH64(code_ptr, 0x98) != stored.hash): │ │ sub_2ADF90() 记录检测事件入队 qword_6C7C10 │ │ SVC 0x80 exit_group(1) 直接退出 (常量混淆) │ │ sub_6826D0(1) 兜底 │ └─ 通过 → 返回函数指针 (缓存到 ctx) │ ├─④ v8 = off_6A6F98[api_id-4] 取 entry 指针 │ v9 = *(v8 + 8) 取真实函数指针 │ (entry 运行时按需解密,静态全 0xFF) │ └─⑤ return v9(api_id, args) 【实际调用】 ``` ### 两种校验 | 校验点 | 位置 | 类型 | 作用 | |---|---|---|---| | A | sub_5E244C `*result != 1` | **1 字节**状态标记 | 判断代码页是否已解密,未解密则触发 ChaCha20 解密 | | B | sub_5533DC `XXH64 != stored` | **64 位** XXH64 | 完整性校验,每次派发都走,不等则 kill | ### 为什么 hook libc 无效 Shield kill 走 **`SVC 0x80`**(直接系统调用),不经过 libc PLT。汇编解码确认是 **`exit_group(1)`**(X8=0x5E=94,X0=1),且 syscall 号通过常量混淆(`0xE5113923 ^ 0xE511397D = 0x5E`)动态算出。所以 hook libc 的 `tgkill` / `exit` / `exit_group` 完全拦不到,连 syscall 层 hook 都要小心处理常量混淆。 ### 两个常驻线程(都由 sub_550E54 启动) | 线程 | RVA | 职责 | |---|---|---| | sub_550F20 | 0x550F20 | **按需解密** —— cond_wait(dword_6BEE44) 等请求,ChaCha20 解密代码页 | | sub_2AD8BC | 0x2AD8BC | **事件聚合** —— cond_wait(unk_6C7BA0) 等事件,聚合统计到 qword_6C7C10 | `qword_6C7C10` 是**检测事件记录表**(不是重加密队列):`sub_2ADF90`(37 处检测点)和 `sub_2AD8BC`(聚合器)都向它写,供 crash report / forensic 用,**无消费者做重加密**。libsupercell 里没有"解密→执行→重加密"循环 —— 代码段首次解密后保持明文。 ### Patch 点 最干净:**patch `sub_5533DC` 的检测分支条件**,让 `if (标记有效 && XXH64 != stored && 标记非0)` 永远 false。需 IDA 定位 RVA 0x5533DC 处对应的条件跳转指令(`B.NE` / `CBNZ`),改成 `B`(跳过 kill 块)或 NOP 掉比较+跳转。 ### 关键 RVA 速查 | 符号 | RVA | 角色 | |---|---|---| | sub_5E244C | 0x5E244C | 全局派发咽喉 | | sub_551110 | 0x551110 | 解密请求提交(同步 RPC) | | sub_550F20 | 0x550F20 | 解密线程主循环 | | sub_550E54 | 0x550E54 | 创建解密线程 | | sub_5512A4 | 0x5512A4 | 单页解密入口 | | sub_249D94 | 0x249D94 | ChaCha20 core | | sub_6826D0 | 0x6826D0 | thunk → GOT → sub_18E8B0 | | sub_18E8B0 | 0x18E8B0 | 第二级派发 | | sub_5533DC | 0x5533DC | **校验+kill 核心** | | sub_452500 | 0x452500 | **XXH64 校验算法** | | sub_2ADF90 | 0x2ADF90 | 检测事件入队(37 处检测点写 qword_6C7C10) | | sub_2AD8BC | 0x2AD8BC | 事件聚合线程(第二常驻线程,sub_550E54 启动) | | off_6A6F98 | 0x6A6F98 | 受保护函数地址表(entry 加密存储) | | off_6ACCE8 | 0x6ACCE8 | 受保护代码段地址表(每段 0x98 字节被 XXH64) | | dword_6BEE44 | 0x6BEE44 | 解密请求标志 | | dword_6BEE40 | 0x6BEE40 | 请求结构起点(1048B) |
登录后可查看完整内容
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
收藏
・
6
点赞
・
6
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
iCookiie
你的帖子非常有用,感谢分享!
2026-7-27 16:53
pyth0nDev
感谢你分享这么好的资源!
2026-7-25 20:36
mb_hsscwjpz
谢谢你的细致分析,受益匪浅!
2026-7-9 13:23
New对象处
感谢你的贡献,论坛因你而更加精彩!
2026-6-27 00:52
mb_iesvmzqc
非常支持你的观点!
2026-6-25 18:29
sinker_
为你点赞!
2026-6-25 12:39
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
3
)
bluegatar
雪 币:
5
活跃值:
(5764)
能力值:
( LV2,RANK:10 )
在线值:
发帖
4
回帖
237
粉丝
6
关注
私信
bluegatar
2
楼
好文章啊,顶你一下
2026-6-26 22:10
0
New对象处
雪 币:
361
活跃值:
(8070)
能力值:
( LV3,RANK:30 )
在线值:
发帖
6
回帖
182
粉丝
32
关注
私信
New对象处
3
楼
记得这个玩意有一个超大的函数
2026-6-29 10:22
0
孤木落
雪 币:
1791
活跃值:
(561)
能力值:
( LV5,RANK:70 )
在线值:
发帖
7
回帖
13
粉丝
28
关注
私信
孤木落
1
4
楼
New对象处
记得这个玩意有一个超大的函数
init_proc入口函数都挺大
最后rustfrida秒过,换个工具发现以前都是没苦硬吃
2026-7-23 22:43
0
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
孤木落
1
7
发帖
13
回帖
70
RANK
关注
私信
他的文章
[原创]仅使用NDK构建内核模块的实验
3418
[原创]尝试分析某海外加固
13651
[原创]AndProxy 升级版:基于 Seccomp 的无 Hook Binder 服务代理
47647
[原创]把 .o 变成 .ko(三):自己写 Loader 才知道的事
40165
[原创]把 .o 变成 .ko(二):GKI 安全特性的铁幕
36347
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部