-
-
[原创]尝试分析某海外加固
-
发表于: 6小时前 132
-
样本是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 分发:
__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) —— 构造对象并装入回调表:
_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:
__int64 sub_45129C(_QWORD *a1) {
*a1 = off_692D10; // 把 0x692D10 装到对象头
return sub_324B20((__int64)(a1 + 2));
}
nullsub_103 (RVA 0x453834) —— 空桩:
void nullsub_103() { ; }
sub_612A7C (RVA 0x612A7C) —— 字符串常量:
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 是函数指针校验表"完全错误。这片实际上是:
- C++ vtable(
sub_45129C设 vptr + type_info + 纯虚占位 + 空桩 = 标准 vtable 布局) - 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 常量:
__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 封装:
__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:
__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) —— 再上一层,函数体充满页对齐常量:
__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) —— 单页解密入口:
__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:
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) —— 等待原语:
__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) —— 创建该线程:
__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) —— 全局派发咽喉:
_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) —— 解密请求提交:
__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的逻辑完全符合"全局派发"模式:sub_62F0C8查 TLS 缓冲,*result != 1判断该代码页是否已解密- 未解密 →
sub_551110提交请求(异步线程解密) - 位图
0x129Fu >> (api_id-4) & 1校验 api_id 合法性(0x129F =0001 0010 1001 1111,合法 api_id ∈ {4,5,6,7,8,11,13,16}) off_6A6F98[api_id-4]查函数地址表,每项结构+0 flags, +8 函数指针- 按 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) —— 状态检查函数:
__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 后半段 —— 解密后的处理:
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) —— 被"上千"函数调用:
__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) —— 被上千函数调用:
__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) —— 构造+析构临时对象:
__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) —— "比较候选":
__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) —— 经典编码特征:
__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) —— 字符串操作:
__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:
__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 间接调用):
__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]= 0x642BF8off_6ACCE8[1]= 0x65AA78off_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):
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) —— 校验+派发核心:
__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) —— 校验算法(常量铁证):
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_10xC2B2AE3D27D4EB4F= XXH64 PRIME64_20x165667B19E3779F9= XXH64 PRIME64_30x85EBCA77C2B2AE63= XXH64 PRIME64_40x27D4EB2F165667C5= XXH64 PRIME64_5
sub_2ADF90 (RVA 0x2ADF90) —— 不等时调用,被 37 处调用:
__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完整逻辑:sub_62F8A0查 ctx 内缓存(避免每次都校验)- 没缓存则懒加载(通过虚函数
(*(v23+48))(...)动态解析函数指针,缓存) - 校验:
__ldaxp原子加载存储的 128 位(低 64 位 = 版本/标记v21,高 64 位 = 存储 hash) - 三条件比较:
v22 - 1 != v21(标记/版本不符)sub_452500(v8, 0x98u) != *(v21+8)(当前 XXH64 != 存储 hash)v21 != 0(标记非零)
- 三条件全满足 → 校验失败 →
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逐字段拆解(提交重加密任务到任务队列):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)。可能是:- 其他检测点(37 个 caller 中的别的路径)在 exit 前做了重加密
- 或者
atexit/ 信号处理器在 exit_group 实际终止前抢跑重加密 - 或者观察到的"重加密"是 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。抗静态搜索 —— 直接 grep0x5E/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的疑点(见下)。- X8 = 0x5E = 94 =
结论
完整检测 + 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,且用户动态观察到过"重新加密代码段"。两条线查重加密到底在哪:
- 挑代表性 caller(
sub_5D34BC)看它sub_2ADF90之后做什么 - 找
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 个之一):
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(创建解密线程那个函数!):
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启动):sub_550F20—— 按需解密线程(ChaCha20 解密代码页)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 重加密。
那用户观察到的"重新加密代码段"是什么? 三种可能:
- 误判:
sub_550F20解密线程按需解密代码页(ChaCha20),观察者看到 ChaCha20 调用 + 代码段变化,误以为是"重加密"。实际是首次解密。 - libg rwxp 段:真正的重加密逻辑可能在
libg.so的 rwxp 段(0x6f61a64000,Shield 解密出的保护代码本身),不在 libsupercell 的反编译里。 - exit 前的 atexit / 信号处理:
exit_group触发的atexithandler 抢跑重加密。但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) |
[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。