首页
社区
课程
招聘
[原创]尝试分析某海外加固
发表于: 6小时前 132

[原创]尝试分析某海外加固

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_21A69CBIO_new 风格的构造:分配对象、装 method 回调、存上下文。

结论

之前认定的"0x692Dxx 是函数指针校验表"完全错误。这片实际上是:

  1. C++ vtablesub_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 常量:

__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_5ECA08pthread_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 的逻辑完全符合"全局派发"模式:
    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_5E244Cif (*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_62F0C8pthread_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_18D930std::string::append / memcpy(处理源/目标重叠,更新 ptr/len/cap),通用 string 操作。
  • sub_18D7F0 是构造临时 string 后立即析构(RAII 临时缓冲)。
  • sub_550D6Cwrite() 封装sub_682490 是 write syscall,循环处理 EAGAIN(11) / EINTR(4) 重试。a1 是 fd,a2 是数据 buffer。

结论

sub_5512A4 后半的 sub_550D6C(dword_6BF258, v14)把解密结果 write 到 fd,不是 CRC 比较。dword_6BF258sub_550E54 传入的 fd(解密线程关联的输出 fd)。

sub_18D930 / sub_18D7F0 / sub_18D6F8 全是 std::string 操作。到目前为止整条解密链里没找到 CRC 比较


第 7 轮:最后的排除 —— libsupercell 侧没有 CRC

thinking

还剩 sub_47981Csub_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_47981CBase64 编码:3 字节分组 → 4 字节输出,24 位打包后每次取 6 位查表(sub_479CF4 是字母表),末尾 =(61) padding。所有特征铁证。
  • sub_20D574std::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_18E8B0off_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 000x6F97DE88B0

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] = 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):

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_1
  • 0xC2B2AE3D27D4EB4F = XXH64 PRIME64_2
  • 0x165667B19E3779F9 = XXH64 PRIME64_3
  • 0x85EBCA77C2B2AE63 = XXH64 PRIME64_4
  • 0x27D4EB2F165667C5 = 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 完整逻辑:

    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 逐字段拆解(提交重加密任务到任务队列):

    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_5533DCsub_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_2ADF9037 处调用(所有 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 段" —— 不准确。校验就在 libsupercellsub_5533DC + sub_452500),只是藏在三层派发(sub_6826D0sub_18E8B0sub_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-0x2AD9DCsub_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_5533DCexit_group)。模式跟 sub_5533DC 一模一样:记录事件 + 派发 kill。
  • sub_2AD8BC 是第二个常驻线程
    • __noreturn 无限循环,caller 是 sub_550E54(创建线程的函数)
    • 等另一个事件源 unk_6C7BA0cond_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)

[内核课程]《Windows内核攻防实战》!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
免费 0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回