找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

5348

积分

0

好友

725

主题
发表于 6 小时前 | 查看: 5| 回复: 0

核心成果速览

本文还原了2026腾讯游戏安全技术竞赛安卓决赛的完整技术链路。最终产物为 flag_tool.exe,输入屏幕左上角的8位十六进制Token,可同时输出三个Flag。完整工具调用:

flag_tool.exe encrypt <token>

以 Token a2d576a6 为例,三个部分的算法与Flag对应关系如下:

三部分Flag与算法对应表

该工具源码支持加密/解密/验证功能,16组测试向量全部通过。


01 引擎识别与资源提取

1.1 引擎识别

APK解包后在 assets/ 目录下发现 assets.sparsepck,结合 lib/arm64-v8a/libgodot_android.so 以及 AndroidManifest 中的 com.godot.game 包名,可以确认引擎为 Godot 4.5(开源游戏引擎)。

关键二进制文件及其用途:

关键文件用途表

1.2 AES密钥提取

PCK资源经AES-256-CFB128加密。在IDA中追踪密钥的完整链路如下:

① 定位 open_and_parse
搜索RTTI字符串 "19FileAccessEncrypted"(地址 0x945101),通过typeinfo交叉引用找到 sub_38013D0(即 FileAccessEncrypted::open_and_parse,9处调用)。

② 确认AES调用
sub_38013D0 内部的AES初始化序列:

// sub_38013D0 (open_and_parse) 伪代码摘录
sub_376EDC0(ctx);                                    // mbedtls_aes_init
sub_376EE1C(ctx, *(this + 352), 256);                // mbedtls_aes_setkey_enc(ctx, key, 256)
sub_376EF88(ctx, len, *(this + 336), in, out);       // mbedtls_aes_crypt_cfb128
sub_376EDF0(ctx);                                    // mbedtls_aes_free

③ 追踪密钥来源
this+352 处的密钥由调用者传入。反查交叉引用,sub_3804BEC(xref 0x3804F08)和 sub_3805E9C(xref 0x3806140)均通过逐字节循环从 byte_400EF18 复制密钥:

// sub_3805E9C 伪代码摘录
do {
    v23 = byte_400EF18[i];    // .data 段静态密钥
    key_buf[i++] = v23;
} while (...);
sub_38013D0(fae, &file, &key_vec, 0, 0, &iv);  // open_and_parse

④ 读取密钥
在IDA中直接读取 byte_400EF18,32字节即为AES-256密钥:

地址 0x400EF18:
CE 4D F8 75 3B 59 A5 A3 9A DE 58 AC 07 EF 94 7A
3D A3 9F 2A F7 5E 32 84 D5 12 17 C0 4D 49 A0 61

1.3 CFB128解密

APK解包后 assets/ 目录下的 .gdc/.scn 均为加密文件,格式如下:

[0..16]   MD5(明文校验)
[16..24]  uint64 LE 明文长度
[24..40]  16-byte IV
[40..]    AES-256-CFB128 密文

用1.2节提取的密钥做标准CFB128解密,校验 MD5(明文) == 文件头MD5 通过,确认决赛使用标准CFB128(初赛为position-XOR变体)。解密后得到10个 .gdcGDSC magic)和7个 .scn(场景文件)。

加密输入: final/assets/(10个 .gdc + 7个 .scn

解密产物: solve/decrypted/(仅 .gdc.scnpatch_trigger4_scn.py 中就地解密处理)

CFB128实现在 patch_trigger4_scn.pycfb128_decrypt_standard() 函数中。


02 GDScript字节码反混淆

2.1 问题发现

标准 gdre_tools 反编译输出乱码——关键字错位(if 变成 matchfunc 变成 signal 等),说明自定义引擎对GDScript tokenizer的token ID做了重映射。

对照Godot 4.5开源代码 modules/gdscript/gdscript_tokenizer.h 的标准GDSC格式,定位到两处自定义修改:

标识符 XOR 加密
标准Godot的identifier pool是明文UTF-32 LE,解密后字节码的标识符全部乱码。利用已知明文推断(标识符中必然包含 _readyextends 等),用预期ASCII与实际字节逐字节XOR,得到固定密钥 0xB6

Token ID 重映射
标准枚举中 FUNC=74, VAR=77, IF=58 等被打乱为 FUNC=71, VAR=81, IF=51 等。

2.2 GDSC二进制格式

[0..4]  "GDSC" magic → [4..8] version (0x65=Godot4.5) → [8..12] decompressed_size
[12..]  zstd payload → 解压后: 标识符池 → 常量池 → 行号表 → token 流

标识符UTF-32 LE每字节XOR 0xB6(解密后得到合法ASCII变量名)。Token流每项4/8字节,word1[0:7] = token type(重映射ID),word1[8:] = pool index,word2 = 行号(可选)。

2.3 Token重映射表恢复

从最简单的脚本(label2.gdctoken.gdc)开始,利用GDScript语法结构逐步推断映射:

IDENTIFIER(12) / LITERAL(13)
最先确定——它们携带pool index,出现频率最高。

结构关键字
func name(args): 模式 → 确定 FUNC(71)、PAREN_OPEN(88)、PAREN_CLOSE(89)、COLON(95)。

控制流
if ... : / while ... : / for ... in ... → 确定 IF(51)、WHILE(55)、FOR(54)、IN(72)。

运算符
结合加密代码上下文,& 0xFF → AMPERSAND(26),^ key → CARET(29),<< 3 → LESS_LESS(30)。

共恢复35+个映射(部分摘录):

重映射ID对照表

由于token枚举表内联在引擎解释器中,未以独立数据结构存在,无法直接从二进制提取,改用上述语义推断法逐一恢复。完整映射表见 parse_gdc.py

2.4 自定义解析器

python solve/godot/parse_gdc.py <file.gdc> --reconstruct   # 反编译为 GDScript
python solve/godot/parse_gdc.py <file.gdc> --raw            # 原始 token 流

所有10个 .gdc 均成功反编译为可读GDScript。

产物: solve/godot/parse_gdc.py

2.5 反编译结果——三个计分Trigger

反编译后可读出完整flag生成逻辑。完整反编译源码见 solve/decompiled/Trigger/

trigger2.gd — PART1(纯GDScript Feistel)
碰撞回调 _w7 读取Token,调用 _fe() 做8轮Feistel加密,密钥 "Sec2026_Godot",输出flag。算法完全在GDScript中,无native参与:

func _w7(_ar):
    var _tk := str(_lt.text).substr(7)     # "Token: a1b2c3d4" → "a1b2c3d4"
    var _rs := _fe(_tk)                     # Feistel 加密
    _lb.text = "flag{" + _rs + "}"          # flag{sec2026_PART1_XXXXXXXX}

trigger3.gd — PART2(调native Process)
碰撞回调将Token的UTF-8字节(注意:不是hex→bytes,是ASCII直传)传给 GameExtension.Process(),由 libsec2026.so 的自定义AES加密:

func _w7(_ar):
    var _raw := str(_lt.text).substr(7)     # "a1b2c3d4"
    var _buf := _raw.to_utf8_buffer()       # ASCII 字节,不是 hex decode
    var _rv := _gx.Process(_buf)            # ★ native 加密
    _lb.text = "flag{sec2026_PART2_" + _rv + "}"

trigger4.gd — PART3(完全native)
没有 body_entered.connect(),碰撞不由GDScript处理。GDScript侧仅有 _gx.Tick() 每帧调用(后续逆向证实Tick仅做反剥离计时,见4.3节)。真正的flag生成由碰撞触发的native隐藏回调完成(见第七章7.3节):

func _ready():
    _gx = GameExtension.new()

func _process(_d):
    _gx.Tick()      # 仅反剥离计时,不触发 PART3 计算
    _rv = _d        # delta time
    _tv += _d * 2.0
    _m3()

03 libsec2026.so 逆向基础

3.1 字符串解密

IDA打开 libsec2026.so 后,搜不到任何明文字符串("Process""Tick""ClassDB" 等均不存在)。观察到大量函数具有相同的prologue特征(FFC300D1 E01700F9 E11300F9 E20F00F9 E31700B9),其反编译结果为XOR解密循环,将 .rodata 密文写入 .bss 段:

字符串加密特征 写回.bss

两种变体:

  • XOR_PLAIN: out[i] = cipher[i] ^ key[i % 8]
  • XOR_WITH_INDEX: out[i] = cipher[i] ^ i ^ key[i % 8]

编写IDAPython脚本 ida_decrypt_strings.py:搜索 .text 段中具有上述prologue特征的解密函数(共31个),反编译每个调用者提取 (dest, src, key, len) 四元组,批量解密并在IDA中添加注释。

解密后发现的关键字符串和对应功能:

解密函数与字符串对照表

通过字符串解密,定位到 sub_97B6C 为方法注册入口(注册Process/Tick/input三个GDExtension方法),进而追踪到PART2 handler(sub_97704)和Tick handler(sub_9AD68)。

3.2 CFF去混淆

libsec2026.so 全部函数均被CFF(Control Flow Flattening)混淆,存在两种变体。

变体A — 集中式CMP树

用于辅助函数(字符串解密、反调试等)。所有基本块共享一个中央dispatcher,state是32-bit编码hash,经 EOR+ADD 解码后通过CMP二叉搜索树匹配目标块:

block_i:
    ... 业务代码 ...
    MOV  W8, #0x7DDB08B6           // 下一个 state(编码 hash)
    CSEL W8, W9, W8, EQ            // 条件分支:根据结果选 hash
    B    dispatcher

dispatcher:                         // 全函数唯一
    EOR  W8, W8, #0xC3             // 解码 step 1
    ADD  W8, W8, #0x71             // 解码 step 2
    CMP  W8, #0x1EE0E901           // CMP 二叉搜索树
    B.LE lower_half
    CMP  W8, #0x316195C5
    B.EQ block_17                  // 匹配 → 跳转
    ...

IDA需手动解析hash解码 + CMP树。

变体B — 内联双表派发

用于核心加密函数(AES sub_A936C、TEA sub_A9A7C 等,共约136个)。没有中央dispatcher,每个块后内联一套两级查找:

prologue:
    ADRL  X22, state_tab            // dword 查找表
    ADRL  X23, jpt                  // qword 跳转表

block_i:
    ... 业务代码 ...
    MOV   W8, #next_state           // state = 小整数 (0~127)
    STUR  W8, [X29, #-4]
    LDURSW X8, [X29, #-4]          // ① 读 state
    LDRSW  X8, [X22, X8, LSL#2]    // ② state_tab[state] → jpt 索引
    LDR    X8, [X23, X8, LSL#3]    // ③ jpt[idx] → 代码块地址
    BR     X8                       // ④ 间接跳转(每个块都有完整副本)

两级查找的反分析效果:

state_tab[] (dword):           jpt[] (qword):
┌────────┬──────┐              ┌─────┬────────────┐
│state 0 │→ 10  │              │ [0] │ 0x130644   │
│state 1 │→ 5   │              │ [1] │ 0x130184   │
│state 2 │→ 26  │              │ ... │    ...      │
│  ...   │ ...  │              │[44] │ 0x13031C   │
└────────┴──────┘              └─────┴────────────┘
① 多个 state 可映射到同一 jpt 索引(代码块共享)
② jpt 中混入冗余条目,干扰静态枚举
③ state 常量不直接对应跳转目标,对抗常量传播

内联派发消除了集中dispatcher这个单点突破口;间接表使state常量不再直接对应跳转目标——同时对抗两类自动化攻击。

去混淆方案

三种方案逐步演进,最终IDA工具链覆盖全部变体B函数:

去混淆方案对比表

IDA dispatch tail重写覆盖率 134/136(98.5%),2个跨块寄存器传递需手动处理。花指令NOP(patch_junk_code.py)作为预处理步骤清除干扰指令。

单层CFF(变体A:集中式CMP树dispatcher):

单层CFF示例

双层CFF(变体B:内联双表 state_tab[state]jpt[idx]BR):

双层CFF示例

Patch修复前后对比(IDA函数列表 + 反编译,修复前截断 vs 修复后完整):

CFF Patch对比

还原后的控制流图(CFG状态机可视化):

CFF CFG状态机


04 反调试绕过

4.0 检测发现方法

直接Frida attach会被秒杀(exit_group(0)),静态分析面对全函数CFF混淆也很难枚举所有检测点。这里采用了自研模拟器沙箱完整捕获 libsec2026.so 的运行时行为。

日志规模:单次运行产生约1.5GB trace,包含每条syscall的编号、参数、返回值,以及每个库函数调用的函数名和参数。

沙箱trace日志截图

筛选过程:将trace日志交由AI分析,按syscall编号和参数模式自动分类——标记出 openat("/proc/self/task")openat("/proc/self/fd")openat("/proc/self/maps")ptrace(ATTACH)process_vm_readvclock_gettime 高频调用等异常模式。每个AI标记的检测点再回到IDA中定位对应函数、反编译确认逻辑。最终梳理出3个检测线程共9项检测机制。

检测总入口sub_99094 创建3个pthread:

// sub_99094 — 检测线程总入口
void sub_99094() {
    pthread_create(&t1, 0, sub_9C654, 0);  // ptrace 自保护 + 硬件断点清除
    pthread_create(&t2, 0, sub_9CDC4, 0);  // Frida 线程名 + 注入器检测
    pthread_create(&t3, 0, sub_9B7D8, 0);  // exit 反 hook + CRC32 完整性心跳 + maps 扫描
}

4.1 Frida线程检测(sub_9CDC4)

后台线程循环扫描 /proc/self/task/*/status,搜索线程名 gum-js-loop(Frida)/ gmain(GLib),检测到则 exit_group(0)

// sub_9CDC4 (0x9CDC4 ~ 0x9EA1C) — 约70 case CFF,精简后:
void frida_detection_thread() {
    sleep(1);
    // 解密字符串
    // sub_97AE0 → "/proc/self/task"
    // sub_96848 → "gum-js-loop"
    // sub_9A9A8 → "gmain"

    DIR *dir = opendir("/proc/self/task");
    while ((ent = readdir(dir)) != NULL) {
        snprintf(path, 256, "/proc/self/task/%s/status", ent->d_name);
        int fd = syscall(56 /*openat*/, AT_FDCWD, path, O_RDONLY);
        // 逐行读取,搜索 "gum-js-loop" 或 "gmain"
        if (strstr(line, "gum-js-loop") || strstr(line, "gmain"))
            syscall(94 /*exit_group*/, 0);  // 杀进程
    }
    sub_99418();  // 接着执行注入器检测
}

绕过:Hook openat,当路径含 /proc/self/task 时替换为无效路径。

4.2 注入器检测(sub_99418)

扫描 /proc/self/fdreadlinkat 读取每个fd的符号链接目标,搜索 linjector

// sub_99418 (0x99418 ~ 0x9A1EC) — 精简后:
void injector_detection() {
    // 解密: "/proc/self/fd", "linjector"
    DIR *dir = opendir("/proc/self/fd");
    while ((ent = readdir(dir)) != NULL) {
        snprintf(path, 256, "/proc/self/fd/%s", ent->d_name);
        lstat(path, &st);
        if ((st.st_mode & S_IFMT) == S_IFLNK) {
            syscall(78 /*readlinkat*/, AT_FDCWD, path, buf, 256);
            if (strstr(buf, "linjector"))
                syscall(94 /*exit_group*/, 0);
        }
    }
}

绕过:同上,Hook openat 拦截 /proc/self/fd

4.3 检测线程心跳 + CRC32完整性互锁(sub_9AD68 ↔ sub_9AF98 ↔ sub_96A00)

通过逆向 sub_9AD68(Tick handler)和交叉引用 data_1834b8,发现它并非简单的"帧间隔检测",而是与代码完整性校验构成心跳互锁机制。

写端 — 检测线程(sub_96A00 → sub_9AF98)
sub_96A00 每约14秒通过 dl_iterate_phdr 获取 .text 段,调用 sub_9AF98 计算CRC32(多项式 0xEDB88320)。关键在于 sub_9AF98 的CRC32循环中,每处理4096字节就执行一次 usleep(10μs) + clock_gettime 并更新全局变量 data_1834b8(心跳时间戳)。

// sub_9AF98 — CRC32 + 心跳
uint32_t crc32_with_heartbeat(uint8_t *data, size_t len, uint32_t init) {
    uint32_t crc = init;
    for (size_t i = 0; i < len; i++) {
        uint32_t val = crc ^ data[i];
        for (int j = 0; j < 8; j++)          // 标准 CRC32
            val = (val & 1) ? (0xEDB88320 ^ (val >> 1)) : (val >> 1);
        crc = val;
        if ((i & 0xFFF) == 0) {               // 每 4096 字节
            usleep(10);
            clock_gettime(CLOCK_MONOTONIC, &ts);
            data_1834b8 = ts.tv_sec * 1000000 + ts.tv_nsec / 1000;  // 更新心跳
        }
    }
    return ~crc;
}

校验结果不匹配 → exit_group(0) 杀进程。

读端 — Tick handler(sub_9AD68)
每帧读取 data_1834b8,与当前时间比对:

// sub_9AD68 — Tick handler
void tick_handler() {
    if (data_1834b8 == 0) {                    // 首次调用:初始化
        clock_gettime(CLOCK_MONOTONIC, &ts);
        data_1834b8 = ts.tv_sec * 1000000 + ts.tv_nsec / 1000;
        return;
    }
    clock_gettime(CLOCK_MONOTONIC, &ts);
    int64_t now = ts.tv_sec * 1000000 + ts.tv_nsec / 1000;
    if (llabs(now - data_1834b8) > 10000000)   // > 10 秒
        return;  // 心跳超时 → 停止处理
}

互锁设计

  • 检测线程存活时:CRC32循环不断更新 data_1834b8 → Tick读到新鲜心跳 → 正常
  • 检测线程被kill/freeze:data_1834b8 停止更新 → Tick检测到超时 → 游戏停止处理
  • CRC32与心跳在同一循环中交织,无法单独绕过任何一方

绕过:不kill检测线程,采用数据层Patch不改 .text 段 → CRC32自然通过 → 心跳持续。

4.4 exit反hook + CRC32完整性心跳(sub_9B7D8)

sub_9B7D8 是检测最密集的线程,包含3种校验。

初始化阶段sleep(3)setpriority(nice=19) → 以下检查按顺序执行。

exit() inline hook检测:检查 *(uint32_t*)exit == 0x50000058,即exit函数的首条ARM64指令是否被篡改。如果被inline hook则标记异常。

exit页面写保护mprotect(page_align(&exit), page_size, PROT_READ|PROT_EXEC) — 去掉exit所在页的写权限,防止后续inline hook注入。

/proc/self/maps段定位(sub_98564 + sub_9A470):

  1. raw syscall openat 打开 /proc/self/maps(防Frida hook libc IO)
  2. 逐字节读取每行,sub_98564 用sscanf解析 start-end perms
  3. sub_9A470 将段名与目标库名比较(字符串匹配),定位 .text 段基址供CRC32校验使用
// sub_9B7D8 — exit 反 hook + CRC32 完整性校验(去除 CFF 后还原)
// @ 0x9B7D8, 子函数 0x98564, 0x9A470, 0x96A00
void* integrity_guard_thread(void* arg) {
    sleep(3);
    setpriority(PRIO_PROCESS, 0, 19);

    // Step 1: exit inline hook 检测
    if (*(uint32_t*)exit != 0x50000058)
        flag_abnormal();

    // Step 2: 保护 exit 页面,防 inline hook
    void* exit_page = (void*)(-page_size & (uint64_t)&exit);
    mprotect(exit_page, page_size, PROT_READ | PROT_EXEC);

    // Step 3: raw syscall 打开 maps → 定位 .text 段
    int fd = syscall(SYS_openat, AT_FDCWD,
        decrypt_str(data_1682d0), 0, 0);  // → "/proc/self/maps"
    char line[512]; int pos = 0; char c;
    while (syscall(SYS_read, fd, &c, 1) == 1) {
        if (c == '\n') {
            line[pos] = 0;
            locate_text_segment(line);         // sub_98564
            pos = 0;
        } else line[pos++] = c;
    }

    // Step 4: CRC32 循环(每 3 秒)
    while (1) { crc32_check(); sleep(3); }
}

// sub_98564 — maps 行解析 + 段定位
void locate_text_segment(char* line) {     // @ 0x98564
    uint64_t start, end; char perms[8];
    sscanf(line, "%lx-%lx %s", &start, &end, perms);
    if (perms == "r--p" || perms == "rw-p")
        match_and_store(start, end);       // → sub_9A470(字符串匹配,非解密)
}

// sub_96A00 — CRC32 完整性校验
void crc32_check() {                       // @ 0x96A00
    dl_iterate_phdr(find_text_phdr, &ctx); // sub_9EFB4: 定位 .text PT_LOAD 段
    uint32_t crc = crc32(ctx.base, ctx.size); // sub_9AF98: 多项式 0xEDB88320
    if (crc != expected_crc)               // dword_1682C8
        syscall(SYS_exit_group, 0);        // 杀进程
}

循环阶段(每3秒):
CRC32完整性校验: sub_96A00dl_iterate_phdr(sub_9EFB4)定位 .text 段 → sub_9AF98 计算CRC32(多项式 0xEDB88320)→ 不匹配则 exit_group(0)。CRC32循环中每次更新心跳 data_1834b8(即4.3节的写端)。

绕过:采用数据层Patch(修改 .scn 场景资源)而非代码层Patch,完全不改 .text 段。不hook exit,不改内存权限,CRC32校验自然通过,心跳持续。

4.5 ptrace自保护 + 硬件断点清除(sub_9C654)

sub_9C654 使用fork + ptrace自附加,实现三层反调试:

// sub_9C654 — 逆向还原
void* ptrace_guard_thread(void* arg) {
    pid_t pid = getpid();
    pid_t child = fork();
    if (child == 0) {
        // 子进程
        if (ptrace(PTRACE_ATTACH, pid, 0, 0) == -1)
            exit(1);  // 已有调试器 → 退出
        while (1) {
            waitpid(pid, &status, 0);
            if (WIFEXITED(status)) break;
            if (WIFSTOPPED(status)) {
                int sig = WSTOPSIG(status);
                sub_95CC0(pid);                    // 清除硬件断点
                if (sig == SIGSTOP || sig == SIGTRAP)
                    ptrace(PTRACE_CONT, pid, 0, 0);     // 吞掉调试信号
                else
                    ptrace(PTRACE_CONT, pid, 0, sig);   // 转发其他信号
            }
        }
    }
    return 0;
}

// sub_95CC0 — 硬件断点清除
void clear_hw_breakpoints(pid_t pid) {
    uint32_t regs[17];
    struct iovec iov = {regs, 0x44};
    ptrace(PTRACE_GETREGSET, pid, NT_ARM_HW_BREAK/*0x402*/, &iov);
    int num_brps = min((regs[0] >> 12) & 0xFF, 8);
    for (int i = 0; i < num_brps; i++) {
        regs[1 + i*2] = 0;  // 地址清零
        regs[2 + i*2] = 7;  // 控制位:地址=0 时断点无效
    }
    ptrace(PTRACE_SETREGSET, pid, NT_ARM_HW_BREAK, &iov);
}
  • 第1层: ptrace(ATTACH) 占住调试位,外部调试器无法attach
  • 第2层: 每次父进程被信号暂停时,清除所有ARM64硬件断点寄存器(NT_ARM_HW_BREAK)
  • 第3层: 正确转发信号,使父进程在被跟踪状态下仍正常运行

绕过:Frida spawn模式不依赖ptrace和硬件断点,不受影响。

4.6 /proc/self/maps Frida注入检测(T3 sub_9B7D8状态机)

属于T3线程 sub_9B7D8 状态机的一个分支(case 10 → case 8 → case 1)。与4.1节的 /proc/self/task 线程名扫描不同,这里扫描 /proc/self/maps 寻找Frida注入痕迹:

// sub_9B7D8 case 10: 用 raw syscall 打开 maps(绕过 libc hook)
int fd = syscall(SYS_openat/*56*/, AT_FDCWD, "/proc/self/maps", 0, 0);

// case 8: 逐字节读取(绕过缓冲区级 hook)
while (syscall(SYS_read/*63*/, fd, &byte, 1) == 1) {
    if (byte == '\n') {
        line[pos] = '\0';
        sub_98564(line);  // 解析并检测
        pos = 0;
    } else {
        line[pos++] = byte;  // 最大 510 字节
    }
}

核心检测在 sub_98564(maps行解析器):

int sub_98564(const char* maps_line) {
    // 3 个字符串均为 XOR 加密存储(密钥 byte_1834B0),运行时解密
    char* frida_str  = decrypt("frida");       // data_1682F4
    char* memfd_str  = decrypt("/memfd:");      // data_1682FA
    char* fmt        = decrypt("%lx-%lx %s");   // data_168302

    unsigned long start, end;
    char perms[256];
    sscanf(maps_line, fmt, &start, &end, perms);

    // 检测 1: 路径中包含 "frida"(Frida agent .so)
    if (strstr(maps_line, "frida"))   return 1;  // DETECTED
    // 检测 2: 路径中包含 "/memfd:"(Frida 匿名内存注入)
    if (strstr(maps_line, "/memfd:")) return 1;  // DETECTED
    return 0;
}

反分析手段:① raw syscall绕过LD_PRELOAD/libc hook;② 逐字节读取绕过缓冲区拦截;③ 关键字XOR加密;④ CFF状态机约19个case混淆控制流。

绕过:数据层Patch不注入任何 .so,maps中无异常条目。

4.7 process_vm_readv交叉完整性校验(libgodot_android.so → libsec2026.so)

沙箱trace发现libgodot_android.so中的 sub_10B9E4C 每帧检查libsec2026.so的 .text(不是自身)——形成交叉校验,两个库互相守护:

// sub_10B9E4C (libgodot_android.so) — 每帧调用,跳过前 480 帧(约8秒热身)
void integrity_check() {
    // 定位 libsec2026.so 基址
    void* ext_init = dlsym(libsec2026, "extension_init");  // offset 0xA4074
    void* base = ext_init - 0xA4074;

    // 用 process_vm_readv 读取 libsec2026 的 .text 段
    // 范围: base+0x95C70 ~ base+0x15FE80, 大小 0xCA210 (827920 字节)
    struct iovec local  = { heap_buf, 0xCA210 };
    struct iovec remote = { base + 0x95C70, 0xCA210 };
    syscall(SYS_process_vm_readv/*270*/, self_pid, &local, 1, &remote, 1, 0);

    // 每帧处理 0x19000 字节(分帧校验,约9帧完成一轮)
    for (i = offset; i < offset + 0x19000 && i < 0xCA210; i++)
        sum = (sum + heap_buf[i]) % 0xFFFFFF;

    if (done_full_pass) {
        if (sum != 0x11FFDD) {  // 期望校验和
            // 篡改检出!静默关闭游戏
            pthread_create(&t, 0, Main_Cleanup, 0);  // 新线程执行引擎清理
        }
        sum = 0;  // 重置,开始下一轮
    }
}

设计精妙之处

  • 跨库守护: libgodot校验libsec2026的 .text(本节),libsec2026的T3校验自身 .text(4.4节的CRC32)→ libsec2026的 .text 受双重保护
  • 分帧处理: 每帧仅处理100KB,不影响渲染帧率
  • 静默退出: 不调用 exit_group,而是 pthread_create 新线程执行 Main::Cleanup,游戏"正常关闭",难以定位检测点
  • 热身跳过: 前480帧(约8秒)不检测,等libsec2026完成初始化
参数 说明
.text 偏移 0x95C70 libsec2026.so .text 段起始
.text 大小 0xCA210 (808KB) 完整 .text
校验算法 逐字节累加 mod 0xFFFFFF 简单但覆盖全段
期望值 0x11FFDD 存储在libgodot dword_400A050
每帧处理量 0x19000 (100KB) 约9帧完成一轮

绕过:数据层Patch不修改任何 .text 段,校验和自然通过。若必须Patch .text,需同步修改libgodot中的期望值 dword_400A050

4.8 策略总结

9项检测汇总表

T1=sub_9C654, T2=sub_9CDC4, T3=sub_9B7D8。

统一绕过策略:采用数据层Patch(修改 .scn 场景资源触发flag计算),不修改任何 .text 段代码、不hook任何函数、不kill任何检测线程。9项检测全部自然通过。


05 PART1 — Feistel加密

5.1 算法

反编译 trigger2.gdc_w7 回调中包含完整加密代码(纯GDScript,不涉及native)。

8轮Feistel网络,输入4字节(Token hex→bytes),分组2+2字节:

密钥 K = b"Sec2026_Godot" (13 字节)

轮函数 F(block, K, r):
  对每字节 j:  v = block[j] ^ K[(j+r) % 13]    // XOR 密钥
               v = (v × 7 + r) & 0xFF           // 仿射变换
               v = ROL3(v)                       // 8-bit 左旋 3 位

加密: lo, hi = token[0:2], token[2:4]
      重复 8 轮: lo, hi = hi, lo ⊕ F(hi, K, r)

解密: 轮序反转 (r: 7→0),交换 lo/hi

5.2 验证

Token加密解密验证

5.3 运行时字节码Patch验证

trigger2需要碰撞触发(3D场景中撞到Trigger2),可通过Hook GDScriptTokenizerBuffer::set_code_buffer 在运行时修改字节码,将 _process() 中的动画调用 _m3(_d) 替换为flag回调 _w7(_d),使flag每帧自动生成。

关键地址(libgodot_android.so)

set_code_buffer关键地址

Patch细节:解压后偏移 0x1F80 处为Token[603]:0x00003C8C(IDENTIFIER, pidx=60 → _m3),将 buf[0x1F81]0x3C 改为 0x30,pidx从60(_m3)变为48(_w7)。

// Hook at MOV W25, #0xB6 inside set_code_buffer
Hook::AddExecuteHandler(base + 0x147D4A8,
    [](Hook::VContext* ctx, uintptr_t pc, bool after) -> void {
        if (after) return;
        uint8_t* buf = (uint8_t*)(ctx->x[20] - 0x10);
        // trigger2: offset 0x1F80 = 0x00003C8C (_m3) → _w7
        if (*(uint32_t*)(buf + 0x1F80) == 0x00003C8C) {
            buf[0x1F81] = 0x30;  // _m3(idx=60) → _w7(idx=48)
        }
    });

运行结果:Token 8dce44a5,输出 flag{sec2026_PART1_154ca922}

真机验证(MuMu模拟器,Token 8dce44a5,碰撞Trigger2后显示flag):

PART1真机验证

C++核心实现part1_feistel.cpp):

static void round_fn(const uint8_t* block, int len, int round_num, uint8_t* out) {
    for (int j = 0; j < len; j++) {
        uint8_t v = block[j] ^ KEY[(j + round_num) % KEY_LEN];
        v = (uint8_t)((v * 7 + round_num) & 0xFF);
        v = (uint8_t)(((v << 3) | (v >> 5)) & 0xFF);  // ROL3
        out[j] = v;
    }
}

std::string part1::encrypt(const std::string& token_hex) {
    uint8_t lo[2] = {data[0], data[1]}, hi[2] = {data[2], data[3]};
    for (int rn = 0; rn < ROUNDS; rn++) {
        uint8_t fv[2], new_hi[2];
        round_fn(hi, 2, rn, fv);
        xor_bytes(lo, fv, 2, new_hi);   // new_hi = lo ^ F(hi)
        lo[0] = hi[0]; lo[1] = hi[1];   // lo ← hi
        hi[0] = new_hi[0]; hi[1] = new_hi[1];
    }
    return bytes_to_hex({lo[0], lo[1], hi[0], hi[1]}, 4);
}

std::string part1::decrypt(const std::string& cipher_hex) {
    // 逆向:从最后一轮往回推,交换 lo/hi 角色
    for (int rn = ROUNDS - 1; rn >= 0; rn--) {
        round_fn(lo, 2, rn, fv);
        xor_bytes(hi, fv, 2, new_lo);
        hi = lo; lo = new_lo;
    }
}

产物: part1_feistel.cpp(C++加密+解密), flag.py(Python)
运行: flag_tool.exe encrypt <token> / flag_tool.exe decrypt 1 <hex>


06 PART2 — 自定义AES-128-CBC

6.1 发现过程

反编译 trigger3.gdc_w7 回调将Token的UTF-8字节传入 GameExtension.Process(),返回32字符hex拼为flag:

var _rv := _gx.Process(_buf)
_lb.text = "flag{sec2026_PART2_" + _rv + "}"

6.2 IDA逆向调用链

CFF去混淆后追踪 Process() handler(sub_97704)→ sub_A936C。

sub_A936C反编译

以下为CFF状态机精简后的等价伪代码:

// sub_A936C (0xA936C ~ 0xA9664) — PART2 加密入口
__int64 sub_A936C(__int64 token, __int64 a2, __int64 output) {
    __int64 v20 = 0, v21 = 0;  // 16 字节明文缓冲
    _memcpy_chk(&v20, token, 8, 17);   // buf[0:8]  = token
    _memcpy_chk(&v21, token, 8, 9);    // buf[8:16] = token (重复!)

    v17 = xmmword_58550;               // AES key
    v18 = xmmword_58600;               // AES IV
    sub_A7900(v19, &v17, &v18);        // 密钥扩展
    sub_A7194(v19, &v20, 0x10);        // CBC 加密 16 bytes in-place

    // CFF 状态机: for v15=0..15, snprintf("%02x", buf[v15])
    // → 输出 32 字符 hex 到 output
}
// sub_A7DE8 (0xA7DE8 ~ 0xA7F80) — 密钥扩展(48 words = 11 轮)
void sub_A7DE8(__int64 ctx, __int64 key) {
    sub_A9884();                       // 生成自定义 S-box → byte_183700[256]
    // 复制 4 words 初始密钥 ctx[0..15] = key[0..15]
    // for v2 = 4 .. 47:
    //   if (v2 % 4 == 0): RotWord + SubBytes(byte_183700) + Rcon(byte_652C9)
    //   ctx[4*v2+j] = ctx[4*(v2-4)+j] ^ rotated[j]
}
// sub_A7194 (0xA7194 ~ 0xA7308) — AES-CBC 加密
__int64 sub_A7194(__int64 ctx, __int64 pt, unsigned __int64 size) {
    // for each 16-byte block:
    //   sub_AA9B0(block, iv);   // twisted IV XOR: even→^iv[15-i], odd→^iv[i]
    //   sub_A8D44(block, ctx);  // AES 加密单块 (11 轮, 全部非标准操作)
    //   iv = block;             // CBC: 密文作为下一块 IV
}

6.3 算法识别:为什么是AES

密钥扩展 sub_A7DE8(下图):循环 v2 = 4..47 → 48 words = 12组轮密钥(11轮 + 初始轮),比标准AES-128的44 words多一轮。

密钥扩展CFF case对照

sub_A7DE8密钥扩展

单块加密 sub_A8D44(下图):轮循环 v5 = 1..11,末轮(case 2)无MixColumns。与标准AES的SubBytes → ShiftRows → MixColumns → AddRoundKey四步结构一致,但多了RoundMix、首尾Transform,轮数11而非10。

单块加密CFE对照

sub_A8D44单块加密

确认框架是AES变种后,逐一定位每个非标准参数。

6.4 与标准AES-128的差异

AES变种对比表

参数提取:CFF去混淆后从IDA伪代码逐一读取,以下为关键证据。

S-box仿射变换sub_A8C8C)— 常数 0x8F 和ROL5直接可见:

sub_A8C8C S-box仿射变换

// sub_A8C8C — S-box 仿射变换
v1 = sub_A7598(a1);                           // GF(2^8) 求逆
v2 = sub_A8744(v1, 1);  v3 = sub_A8744(v1, 2);
v4 = sub_A8744(v1, 3);  v5 = sub_A8744(v1, 4);
return sub_A8744(v2 ^ v3 ^ v4 ^ v5 ^ v1 ^ 0x8F, 5);   // ← affine 0x8F + ROL5

GF(2^8)多项式sub_A96F0)— & 0x71 即reduction poly 0x171

// sub_A96F0 — GF 乘法 (gf_mul)
a1 = ((unsigned int)(char)v3 >> 7) & 0x71 ^ (2 * v3);   // ← poly = 0x171

MixColumns系数sub_A6F20)— [6,3,5,2] 循环矩阵:

sub_A6F20 MixColumns

// sub_A6F20 — MixColumns
v9[0] = gf_mul(v10,6) ^ gf_mul(v11,3) ^ gf_mul(v12,5) ^ gf_mul(v13,2);  // [6,3,5,2]
v9[1] = gf_mul(v10,2) ^ gf_mul(v11,6) ^ gf_mul(v12,3) ^ gf_mul(v13,5);  // [2,6,3,5]
v9[2] = gf_mul(v10,5) ^ gf_mul(v11,2) ^ gf_mul(v12,6) ^ gf_mul(v13,3);  // [5,2,6,3]
v9[3] = gf_mul(v10,3) ^ gf_mul(v11,5) ^ gf_mul(v12,2) ^ gf_mul(v13,6);  // [3,5,2,6]

Rcon表byte_652C9,IDA hex dump):

A7 A6 A5 A3 AF B7 87 E7 27 D6 45 FC 25 30 38 78

ShiftRows置换sub_AADE8)— IDA完整反编译(无CFF),直接读出字节置换:

// sub_AADE8 — ShiftRows (固定置换,IDA 完整反编译)
// 追踪每个赋值: out[i] ← in[src[i]]
// 源索引: [0, 13, 6, 11, 4, 1, 10, 15, 8, 5, 14, 3, 12, 9, 2, 7]
v1=r[5]; v2=r[1]; v3=r[13];
r[13]=r[9]; r[9]=v1; r[5]=v2; r[1]=v3;   // 列 1 循环: 1←13←9←5←1
v4=r[6]; v5=r[10]; v6=r[14]; v7=r[2];
r[2]=v4; r[6]=v5; r[10]=v6; r[14]=v7;    // 列 2 循环: 2←6←10←14←2
v8=r[11]; v9=r[3]; v10=r[15]; v11=r[7];
r[3]=v8; r[11]=v9; r[7]=v10; r[15]=v11;  // 列 3 循环: 3←11←3, 7←15←7

RoundMixsub_A8F00)— CFF混淆,通过Unicorn差分提取:

# 可见初始化: var_dc = 0x47 + arg2 * 0xffffff9d
# 即 seed = (71 - 99 * round_num) & 0xFF
def roundmix(state, round_num):
    v = (71 - 99 * round_num) & 0xFF      # PRNG 种子(0x47 + r*0xffffff9d)
    prng = []
    for _ in range(16):
        prng.append(v)
        v = (47 - 61 * v) & 0xFF           # PRNG 步进(IDA case 7: 47 - 61*v19)
    old = list(state)
    for j in range(16):                     # 列反转 + XOR
        col, row = j // 4, j % 4
        state[j] = old[(3 - row) * 4 + col] ^ prng[j]

InitTransform / FinalTransform — CFF混淆,但引用的数据地址可直接读取:

sub_A7944 (InitTransform) 引用 unk_58510:
  DE 4F 8A 37 C1 6B 59 E2 73 AD 1F 94 B8 06 D5 42
→ state[i] ^= INIT_XOR[i]   (加密前)

sub_A84A4 (FinalTransform) 引用 unk_58590:
  7C E3 28 91 A6 5D F0 14 BB 69 07 D8 4A 35 EC 80
→ state[i] ^= FINAL_XOR[i]  (加密后)

常量(从 libsec2026.so 提取):

AES Key/IV/Transform常量

6.5 解密实现

InvMixColumns:正向矩阵 [6,3,5,2] 在GF(2^8, 0x171)上的逆矩阵通过高斯消元法求解(part2_aes.py_compute_inv_mix_matrix()),结果为 [0x80, 0xf3, 0x64, 0xaf] 循环矩阵。可逆性由GF(2^8)的域性质保证(非零行列式)。

完整解密流程(严格逆序):

FinalTransform → AddRoundKey(11) → InvShiftRows → InvRoundMix(11) → InvSubBytes
→ 循环 r=10..1: AddRoundKey(r) → InvMixColumns → InvShiftRows → InvRoundMix(r) → InvSubBytes
→ AddRoundKey(0) → InitTransform → Twisted IV XOR

6.6 验证(5组测试向量)

5组Token测试向量全部通过

6.7 运行时字节码Patch验证

与PART1相同方法:Hook set_code_bufferMOV W25, #0xB6(地址 0x147D4A8),在解压后修改 _process() 中的 _m3(_d) 调用为 _w7(_d)

// trigger3: offset 0x1CD4 = 0x00003B8C (_m3) → _w7
if (*(uint32_t*)(buf + 0x1CD4) == 0x00003B8C) {
    buf[0x1CD5] = 0x29;  // _m3(idx=59) → _w7(idx=41)
}

Patch对比表

trigger2和trigger3的Patch偏移对比

运行结果:Token 1af763af,输出 flag{sec2026_PART2_52f0ab0970da6f1d7c516d0813acc998}

真机验证(Token 1af763af,通过字节码Patch自动触发,右上角显示flag):

PART2真机验证

C++核心实现part2_aes.cpp,约300行):

// GF(2^8) 乘法 — 约化多项式 x^8+x^6+x^5+x^4+1 = 0x171(非标准 0x11B)
static uint8_t gf_mul(uint8_t a, uint8_t b) {
    uint8_t p = 0;
    for (int i = 0; i < 8; i++) {
        if (b & 1) p ^= a;
        uint8_t hi = a & 0x80;
        a = (a << 1) & 0xFF;
        if (hi) a ^= 0x71;  // 0x171 的低 8 位
        b >>= 1;
    }
    return p;
}

// MixColumns 矩阵 [6,3,5,2](标准 AES 为 [2,3,1,1])
static const uint8_t MIX[4][4] = {
    {6,3,5,2}, {2,6,3,5}, {5,2,6,3}, {3,5,2,6}
};

// AddRoundKey — 额外 XOR (row + 91*round_idx) & 0xFF
static void add_round_key(uint8_t s[16], int rk_idx) {
    uint8_t extra_base = (uint8_t)((91 * rk_idx) & 0xFF);
    for (int i = 0; i < 16; i++) {
        uint8_t row = i % 4;
        s[i] ^= ROUND_KEYS[rk_idx][i] ^ ((row + extra_base) & 0xFF);
    }
}

// RoundMix — 置换 + PRNG 异或(每轮不同的伪随机扰动)
static void roundmix(uint8_t s[16], int rn) {
    uint8_t prng[16]; roundmix_prng(rn, prng);
    uint8_t t[16]; memcpy(t, s, 16);
    for (int j = 0; j < 16; j++) s[j] = t[RMIX_FWD[j]] ^ prng[j];
}

// Twisted IV XOR — even 位置倒序、odd 位置正序
static void twisted_xor(uint8_t s[16], const uint8_t iv[16]) {
    for (int i = 0; i < 16; i++)
        s[i] ^= (i % 2 == 0) ? iv[15 - i] : iv[i];
}

// 11 轮加密(标准 AES 为 10 轮)
static void aes_encrypt_block(uint8_t s[16]) {
    init_transform(s);          // INIT_XOR
    add_round_key(s, 0);
    for (int r = 1; r <= 11; r++) {
        sub_bytes(s);           // 自定义 S-box(affine 0x8F + ROL5)
        roundmix(s, r);         // 置换+PRNG(标准 AES 无此步骤)
        shift_rows(s);          // 自定义置换表
        if (r < 11) { mix_columns(s); add_round_key(s, r); }
        else { add_round_key(s, 11); final_transform(s); }
    }
}

产物: part2_aes.cpp(C++加密+解密, 约300行,含完整S-box/GF(2^8)/MixColumns), part2_aes.py(Python)
运行: flag_tool.exe encrypt <token> / flag_tool.exe decrypt 2 <hex>


07 PART3 — VM中的TEA变种分组密码

7.1 发现:Trigger4被刻意禁用

反编译 trigger4.gdc 发现:GDScript侧没有任何flag逻辑,没有 body_entered.connect(),仅有 GameExtension.new()Tick() 调用。

解析 town_scene.scn(Godot PackedScene RSRC v6格式)发现Trigger4被刻意禁用:

Trigger4禁用属性

反编译 trigger4.gd(代码见第二章2.5节):没有 body_entered.connect()signal collided_with 声明但GDScript侧从未emit。

碰撞回调必须由native层实现——通过字符串解密定位到 sub_A07F4(GDExtension类注册,下图),发现PART3碰撞回调注册在虚函数表中,最终指向 sub_A9A7C(静态二进制中0 xref,通过GDExtension虚表间接调用,IDA无法静态追踪)。

sub_A07F4 GDExtension类注册

结论:Trigger4在游戏中既不可见也无法通过物理碰撞触发,必须修改场景才能触发。

7.2 场景Patch

解密 .scn 后,在RSRC的nodes数组中修改variant index指针,将属性指向正确的true/false variant:

场景Patch偏移对照

前4处启用碰撞和可见性,后3处将Trigger4移到玩家出生点(开局即碰撞)。

解密 .scn:加密文件格式 [md5:16][pt_len:8][iv:16][ciphertext:...],AES-256-CFB128解密后验证 md5(pt) == stored_md5pt[:4] == b"RSRC"

重加密部署

# 应用 7 处 patch 后
pt = bytes(patched_plaintext)
ct_new = cfb128_encrypt_standard(KEY, iv, pt + b"\x00" * pad_len)
out = hashlib.md5(pt).digest() + struct.pack("<Q", len(pt)) + iv + ct_new
# Round-trip 验证
assert cfb128_decrypt_standard(KEY, iv, ct_new)[:len(pt)] == pt

替换APK中的 assets/.godot/exported/133200997/export-...-town_scene.scn,重签名安装。

产物: patch_trigger4_scn.py, parse_scene.py

7.3 Native逆向架构

方法注册(sub_97B6C)

// sub_97B6C — GDExtension 方法注册(CFF 去混淆后)
// case 14: 注册 Process
sub_9F4EC("Process", "Process", 0x4A85115FAA17D83FLL, 8);
sub_9B5E8(v30, sub_97704, 0);     // sub_97704 = Process handler → PART2

// case 0: 注册 Tick
sub_97358("Tick", "Tick", 0x97A36EF7A74F5E4ELL, 5);
sub_9610C(v27, sub_9AD68, 0);     // sub_9AD68 = Tick handler → 反调试计时

// case 28: 注册 input
sub_9CB50("input", "input", 0x7B88A8A1801FE656LL, 6);

PART3完整执行架构

┌──────────────────────────────────────────────────────────┐
│ 后台线程 sub_9B7D8:完整性校验(详见 4.4 节)               │
│                                                          │
│  exit 反 hook + maps 段定位 + CRC32 完整性心跳           │
│  CRC32 不通过 → exit_group 杀进程                        │
│                                                          │
│  ★ sub_A9A7C 通过 GDExtension 虚表间接调用              │
│    (静态二进制中 0 xref,IDA 无法追踪)                 │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│ 每帧调用:GDScript _process() → _gx.Tick()              │
│                                                          │
│  sub_9AD68 (Tick handler) — 仅做反调试计时:              │
│  ├─ clock_gettime(CLOCK_MONOTONIC)                       │
│  ├─ 首次调用:记录初始时间 → data_1834b8                 │
│  └─ 后续调用:检查帧间隔                                  │
│      ├─ > 10秒 → return(反调试:暂停过久则停止)         │
│      └─ ≤ 10秒 → 正常返回                                │
│  ⚠ Tick 不触发 PART3 计算!                              │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
┌──────────────────────────────────────────────────────────┐
│ 碰撞触发(GDExtension 虚表隐藏路径)                     │
│                                                          │
│  车碰撞 trigger4 (Area3D) → Godot 物理引擎               │
│  → notification_func / get_virtual_func 回调              │
│  → (虚表间接调用,静态 0 xref) → sub_A9A7C(token)        │
│                                                          │
│  sub_A9A7C (35-case CFF 状态机,IDA 反编译见下图):        │
│  ├─ mutex_lock                                           │
│  ├─ 检查 qword_183498 != 0(缓冲区已分配?)              │
│  ├─ memcpy token(8B) → byte_1836C0                       │
│  ├─ socket (port 5414 = 0x1526)                          │
│  ├─ sub_1371F8: VM 初始化 (malloc 0x2030)                │
│  ├─ sub_1374A0 注册 VM handlers:                         │
│  │   ├─ cmd 101: VMEntry — 提供 token 字节               │
│  │   └─ cmd 102: sub_AA758 — 格式化 %08x%08x            │
│  ├─ sub_13CD90: VM dispatcher 执行循环                   │
│  │   └─ sub_13C67C: 56-case CFF 线性 PC 解释器           │
│  │       → 执行 TEA 变种算法                             │
│  ├─ 清理: sub_13D374, sub_13C5C8, sub_137360            │
│  ├─ mutex_unlock                                         │
│  └─ return &unk_1836E0 (flag string, 16 hex)             │
└──────────────────────────────────────────────────────────┘
                          │
                          ▼
        flag{sec2026_PART3_XXXXXXXXXXXXXXXX}
              (16 hex, 两个 u32: %08x%08x)

7.4 VM算法提取:Unicorn差分trace

面对56-case解释器 + CFF混淆,直接静态分析不现实。采用Unicorn模拟 + 差分taint方案。

关键前提验证:5组不同输入的指令执行数均为 20,019,435 条——控制流完全数据无关,算法是固定顺序的算术操作序列。

差分方法

  • 对两组仅1字节不同的输入分别执行,挂 UC_HOOK_MEM_WRITE 记录全部写入
  • 对比同一位置的写入值差异,定位输入敏感的内存slot(约10个)
  • 追踪关键slot的值变化序列,识别轮结构

性能优化:初版用Python回调实现libc函数,每token约30s。改用Keystone生成ARM64原生stub(memcpy/memset/strlen等),降至约0.5s/token(60x加速)。

产物: vm_fast.py(Unicorn模拟器), vm_trace.py(差分trace)

7.5 算法还原过程

Step 1 — 差分taint定位输入敏感地址
挂载 UC_HOOK_MEM_WRITE,记录全部写入 (地址, 值)。两组仅首字节不同的输入对比后,约1400万次写入中约5000处值不同,聚类后集中在10个slot:

差分trace敏感地址

Step 2 — 轮数识别
追踪 0x80329e08(v0输出slot)的值变化序列:

Transitions for 0x80329e08 (input=0x00*8):
[ 0] 0x00000000  (初始)
[ 1] 0xe3546957
[ 2] 0x9c660d6c
      ...
[27] 0xaf607752
[28] 0x268193dc  (= 最终输出高 32 位)

29次transition = 初始值 + 28轮更新。

Step 3 — 轮函数还原
通过交叉验证移位量、确认da8多用途寄存器、最终识别出两阶段轮结构:

Round 1 仅 v0 update
Round 2~28 先 v1(用v0的 <<4/>>7)再 v0(用新v1的 <<6/>>5)

Step 4 — delta的真实身份
通过trace反推sum,证实 delta = 0x29e59c9f(非 0xaabbccdd,后者是v0右项加数,即7.6表中的 key[0])。

Step 5 — 4个候选模型淘汰赛

模型B全部匹配

7.6 提取结果

常量

TEA常量表

与标准TEA差异

TEA变种差异对比

加密/解密

def encrypt(v0, v1):
    sum = delta
    v0 += ((v1<<6)+key[3]) ^ (v1+sum) ^ ((v1>>5)+0xaabbccdd)   # Round 1
    for r in range(2, 29):
        sum += delta
        v1 += ((v0<<4)+key[1]) ^ (v0+sum) ^ ((v0>>7)+key[2])   # v1 先
        v0 += ((v1<<6)+key[3]) ^ (v1+sum) ^ ((v1>>5)+0xaabbccdd) # v0 后
    return v0 & M, v1 & M

def decrypt(v0, v1):  # 严格逆序
    sum = 28 * delta
    for r in range(28, 1, -1):
        v0 -= ((v1<<6)+key[3]) ^ (v1+sum) ^ ((v1>>5)+0xaabbccdd)
        v1 -= ((v0<<4)+key[1]) ^ (v0+sum) ^ ((v0>>7)+key[2])
        sum -= delta
    v0 -= ((v1<<6)+key[3]) ^ (v1+sum) ^ ((v1>>5)+0xaabbccdd)   # Round 1
    return v0 & M, v1 & M

7.7 验证(7+1组测试向量)

8组Token全部验证通过

7/7正向 + 7/7逆向 + 1组ASCII真实场景,全部匹配Unicorn ground truth。

真机验证(MuMu模拟器,Token a2d576a6,场景Patch后碰撞Trigger4显示flag):

PART3真机验证

C++核心实现part3_tea.cpp):

命名约定:C++中 KEY[0]=delta=0x29e59c9fMAGIC=0xaabbccdd;VM反编译器中 key[0]=0xaabbccdd(7.6表采用VM约定)。两套索引不同,常量值一致。

static const uint32_t KEY[4] = {0x29e59c9f, 0xf95d664a, 0x12aa364c, 0x33ad3cee};
static const uint32_t MAGIC = 0xaabbccdd;  // VM 反编译器中的 key[0]
static const uint32_t DELTA = KEY[0];  // 0x29e59c9f
static const int ROUNDS = 28;

// 轮函数 — v0 用 <<6/>>5,v1 用 <<4/>>7(标准 TEA 均为 <<4/>>5)
static uint32_t f_v0(uint32_t v1, uint32_t s) {
    return ((v1 << 6) + KEY[3]) ^ (v1 + s) ^ ((v1 >> 5) + MAGIC);
}
static uint32_t f_v1(uint32_t v0, uint32_t s) {
    return ((v0 << 4) + KEY[1]) ^ (v0 + s) ^ ((v0 >> 7) + KEY[2]);
}

// 非对称 Feistel — 第 1 轮仅更新 v0,第 2~28 轮先 v1 后 v0
static void encrypt(uint32_t& v0, uint32_t& v1) {
    uint32_t s = DELTA;
    v0 += f_v0(v1, s);              // 第 1 轮:仅 v0
    for (int i = 1; i < ROUNDS; i++) {
        s += DELTA;
        v1 += f_v1(v0, s);          // 第 2~28 轮:先 v1
        v0 += f_v0(v1, s);          // 再 v0
    }
}

static void decrypt(uint32_t& v0, uint32_t& v1) {
    uint32_t s = (uint32_t)((uint64_t)ROUNDS * DELTA);
    for (int i = ROUNDS - 1; i >= 1; i--) {
        v0 -= f_v0(v1, s);
        v1 -= f_v1(v0, s);
        s -= DELTA;
    }
    v0 -= f_v0(v1, s);              // 逆向第 1 轮
}

// 输入映射:token[0:4]→lo, token[4:8]→hi=v0, v1=f_v1(v0,δ)+lo
static void token_to_state(const uint8_t token[8], uint32_t& v0, uint32_t& v1) {
    v0 = le32(token + 4);
    v1 = f_v1(v0, DELTA) + le32(token);
}

产物: part3_tea.cpp(C++加密+解密), part3_tea.py(Python)
运行: flag_tool.exe encrypt <token> / flag_tool.exe decrypt 3 <hex>


08 VM逆向与静态反编译器(附加分析)

算法已在第七章通过Unicorn差分trace完全还原后,回过头来对VM本身进行完整逆向,构建了不依赖模拟器的纯静态反编译器,从原始字节码直接产出可读伪代码。

8.1 VM执行架构

启动链

sub_A9A7C(token)
  ├─ sub_12DE80()                        → 创建 runtime 对象
  ├─ loc_119DA8(rt, 0x63DA0, 5414)       → 加载字节码到 runtime
  ├─ sub_13BFD4(..., &vm_machine)        → 创建 vm_machine (0x110 字节)
  │     └─ 安装 7 个 family descriptor → vm+0x100
  │     └─ 安装 sub_13C430 (selector)  → vm+0xA0
  ├─ sub_1371F8(0, &host_table)          → 创建 host_table (malloc 0x2030)
  ├─ sub_13D278(vm, ht)                  → 绑定: vm+0x98 = host_table
  ├─ sub_1374A0(ht, 101, VMEntry)        → 注册 BRIDGE cmd 101
  ├─ sub_1374A0(ht, 102, sub_AA758)      → 注册 BRIDGE cmd 102
  ├─ sub_13CD90(vm_machine)              → 驱动循环
  │     └─ while (sub_13C67C(vm) == 0x604) {}
  ├─ sub_13D374, sub_13C5C8, loc_137360  → 清理
  └─ return &unk_1836E0                  → flag 字符串

sub_A9A7C case 33(VM调用核心)— 启动链中每个函数调用均可在IDA反编译中一一对应:

sub_A9A7C VM调用链

sub_13CD90 是驱动循环,每次调用 sub_13C67C 执行一条VM指令。返回 0x604(CONTINUE)继续,0(SUCCESS)结束。额外保护:vm[6] >= vm[1] 时强制退出(PC越界)。

双对象模型

VM运行时由两个独立对象组成:

VM核心对象

sub_13D278 绑定两者:*(vm_machine + 0x98) = host_table,使VM执行时能通过BRIDGE指令调用宿主函数。

数据结构布局

vm_machine (0x110字节)

+0x00   runtime_ptr       指向 runtime 对象
+0x08   bytecode_limit    字节码长度(PC 越界检查用)
+0x30   PC counter        当前字节码 PC(= vm[6],每条指令执行后推进)
+0x60   insn_count        指令计数器(= vm[0xC])
+0x98   host_table_ptr    绑定的 host_table
+0xA0   selector_func     sub_13C430(family 选择器函数指针)
+0x100  descriptor_array  指向 7 个 FamilyDescriptor 的指针数组

host_table (0x2030字节)

+0x0000..+0x1FFF  256 个 handler 槽位,每个 0x20 字节:
    +0x00 name_ptr      handler 名字符串
    +0x08 handler_func  回调函数指针
    +0x10 opaque        用户数据
    +0x19 registered    1=已注册
+0x2000  handler_count  已注册数量
+0x2008  allocator      malloc 函数指针
+0x2010  free_func      free 函数指针

FamilyDescriptor (40字节),7个连续存放在 .data:0x163948

+0x00  slot0    共享 RET stub (0x13D4F8,所有 family 相同)
+0x08  slot1    各 family 独立辅助 stub
+0x10  handler  ★ family handler 函数指针(BLR X8 调用目标)
+0x18  (zero)
+0x20  (zero)

寄存器文件(通过 sub_118340 从runtime取出):

寄存器存储在 regfile + 0x28 + reg_idx * 8,最多 32 个
内存上下文在 regfile + 0x128

VM寄存器索引表

BRIDGE handler伪代码

sub_AA758(VM cmd 102: PART3结果格式化):

// sub_AA758 (0xAA758 ~ 0xAA9B0) — VM cmd 102
void sub_AA758(a1, a2, a3, a4, a5) {
    int64_t v0, v1;
    int64_t ctx = *(a3 + 8);
    loc_1385BC(a1, a2, ctx + 16, &v1, 8);  // 读第一个 u32
    loc_1385BC(a1, a2, ctx + 24, &v0, 8);  // 读第二个 u32
    sub_A9664("%08x%08x", ...);             // 解密格式字符串
    sub_AA6AC(&unk_1836E0, 32, "%08x%08x", v1, v0);
}

VMEntry(VM cmd 101: 提供token字节):

// VMEntry (0xA6BEC ~ 0xA6F10) — VM 读取 token 字节
int64_t VMEntry(a1, a2, a3, a4, a5, a6) {
    uint32_t idx = *v8;
    if (idx >= a6)
        *v7 = 0;                     // 超出范围返回 0
    else
        *v7 = byte_1836C0[idx];      // token 第 idx 字节
    sub_13879C(a1, a2, ctx + 8*idx + 16, &v30, 8);  // 写入 VM 上下文
    *v8 = idx + 1;
}

本质上是标准线性PC解释器,但整体被56-case CFF(控制流平坦化)混淆包裹,静态看起来极其复杂。IDA完全无法反编译(SP分析失败,仅输出3行),成功还原全部56个CFF状态。去掉CFF外壳后,每条指令的执行流程为:

sub_13C67C(vm_machine) {
    1. 取指:从 bytecode[PC] 读 4 字节 header → opcode, flags, cls
    2. 解码:按 cls 个数读操作数(fmt=0x04 → 4字节立即数,其他 → 1字节寄存器索引)
    3. 分派:sub_13C430 按 opcode 高字节选 family → descriptor[family].handler
    4. 执行:BLR X8 @ 0x139684 → 调用 family handler
    5. 推进:PC += instruction_size, insn_count++
    6. 返回:0x604 (CONTINUE) 或 0 (HALT)
}

反编译关键CFF状态(地址→语义):

CFF状态-语义对应表

handler分派点在ARM64层为 BLR X8 @ 0x139684(动态反编译器hook的位置),调用前X2指向112字节指令描述符,SP+0xF0为当前bytecode PC。

寄存器/内存访问函数

Family handler内部通过以下函数操作VM状态:

VM底层函数表

例如Family1(算术,sub_139060)执行 ADD 的核心路径:

  • 读opcode:*arg3 = 0x0100 → switch到ADD分支
  • 读源操作数:reg_read(regfile, op1_slot, &val1), reg_read(regfile, op2_slot, &val2)
  • 计算:result = val1 + val2
  • 写目标:reg_write(regfile, dst_slot, result)

Family3(传送,sub_139D70)的PUSH/POP操作VM栈指针(reg[0x10]),每次移动8字节。LOAD/STORE通过 mem_read/mem_write 访问VM内存。

Family selector — sub_13C430

sub_13C430*(bytecode_ptr + 1)(opcode高字节),映射到family:

0 → Family0 (0x138BBC)    5,6,7,8 → Family5 (0x13AC3C)
1 → Family1 (0x139060)    9       → Family6 (0x13BBD4)
2 → Family2 (0x139A44)
3 → Family3 (0x139D70)     调用方式: (*descriptor->handler)(desc, host, bytecode, ctx)
4 → Family4 (0x13A440)

Family handler表

7个Family handler按opcode高字节分派:

Family指令集分类表

opcode高字节5/6/7/8全部路由到Family5(sub_13C430中确认),Family5内部再按完整opcode二次分派。BRIDGE(func, nargs) 是VM↔Native桥接指令:func=0x65调用VMEntry读token,func=0x66调用sub_AA758输出结果。

8.2 SBC0字节码格式还原

指令编码

通过Unicorn hook sub_13C67CBLR X8(handler分派点),截获每条指令执行前的112字节结构体,逆推编码格式:

Header (4 bytes):
  [opcode_lo] [opcode_hi] [flags] [cls]
   └────────────────┘      └───┘   └─┘
  16-bit opcode           标志   操作数个数

Operand × cls:
  [fmt] (1 byte)
  fmt == 0x04 → [imm32_le] (4 bytes, 小端立即数)
  fmt == 0x01 → [reg]      (1 byte, 寄存器索引)

示例 ADD acc, acc, tmp.sbc:100AB):

Raw: 00 01 00 03 01 0E 01 0E 01 0F
      ^^^^       ^^^^^ ^^^^^ ^^^^^
      opcode     op0   op1   op2
0x0100     acc   acc   tmp
      =ADD       r14   r14   r15

Header: opcode=0x0100(ADD), flags=0x00, cls=0x03(3 operands)
Op0: fmt=0x01(reg), idx=0x0E(acc)  → 目标
Op1: fmt=0x01(reg), idx=0x0E(acc)  → 源1
Op2: fmt=0x01(reg), idx=0x0F(tmp)  → 源2
长度: 4 + 3×2 = 10 字节

验证方法:将推测的编码格式直接解码 .rodata:0x63DA0 处的5414字节原始数据,逐条与Unicorn动态trace的指令序列比对——684条指令全部吻合,证明编码格式正确。

指令集总表(60+条)

VM完整指令集

8.3 从反汇编到反编译

Step 1 — 线性反汇编

直接按偏移解码字节流,输出IDA风格汇编:

.sbc:10892  06 03 00 02 01 0E 04 9F 9C E5 29    MOVALT  acc, #0x29E59C9F  ; delta
.sbc:1089D  03 03 00 01 01 00                   PUSH    r0
.sbc:108A3  00 03 00 02 01 00 01 0B             MOV     r0, r11
.sbc:108AB  03 03 00 01 01 01                   PUSH    r1
...
.sbc:108E7  04 03 00 01 01 01                   POP     r1
.sbc:108ED  06 03 00 02 01 0F 04 09 00 01 00    MOVALT  lr, #0x10009
.sbc:108F8  00 04 00 01 04 09 00 01 00          JMP     u32_truncate

问题:684条原始指令中大量PUSH/POP/MOV是寄存器保存/恢复,直接看完全不可读。

Step 2 — 递归下降 + 函数识别

改为递归下降解码:从入口0x10000跟随控制流,遇到JMP/条件跳转加入工作队列,跳过不可达代码。

函数识别模式MOVALT lr, #ret_addr; JMP target = 函数调用(lr保存返回地址)。以 JMP [lr]HALT 为函数结束。识别出5个函数,其中 u32_truncate 被18处调用。

Step 3 — 模式匹配提升为伪代码

反编译器的核心是多模式识别,将固定的指令序列折叠为高级语句。

模式A — u32表达式块:7个PUSH(保存寄存器上下文)→ 算术/位运算序列 → CALL u32_truncate → 7个POP。整个序列折叠为一行 tN = u32(expr),其中expr通过栈式符号执行构建。

模式B — 向量操作PUSH r0; MOVALT acc, #imm; POP r0; VECPUSH r0, accvec.push(imm)。连续多个合并为 vec.push(0) x 10

模式C — 常数赋值MOVALT r0, #val; MOV rX, r0rX = val(消除r0中转)。

模式D — 内存操作MOVALT tmp, #addr; STORE/LOAD r0, [tmp]mem[addr] = r0/r0 = mem[addr],已知地址替换为符号名(v0, v1, sum等)。

模式E — 自赋值消除:MOV链传播后产生 r0 = r0 的无效赋值,直接删除。

Step 4 — 常量识别与标注

预定义TEA相关常量表,出现时自动标注:

KNOWN_CONSTANTS = {
    0x29e59c9f: "delta",    0xf95d664a: "key[1]",
    0x12aa364c: "key[2]",   0x33ad3cee: "key[3]",
    0xaabbccdd: "key[0]",
}

Step 5 — 交错输出

最终产出三种视图,其中交错视图将每行伪代码与其对应的原始汇编以注释形式交织,便于逐行验证:

; .sbc:10892  06 03 00 02 01 0E 04 9F 9C E5 29  MOVALT  acc, #0x29E59C9F  ; delta
; .sbc:1089D  03 03 00 01 01 00                   PUSH    r0
; .sbc:108A3  00 03 00 02 01 00 01 0B             MOV     r0, r11
;  ... (共 ~30 条)
; .sbc:108F8  00 04 00 01 04 09 00 01 00          JMP     u32_truncate
r13 = u32(r11 + delta)

8.4 反编译结果

684条指令 → 约40行伪代码,TEA循环体完整可读(注意:VM内部v0/v1命名与算法相反,输出时 push(v1,v0) 交换回来):

func main():
    vec.push(0) x 10             // 分配 10 个 slot
    bridge(func=0x65, nargs=1)  // 读 token 字节
    ...
    counter = 0x1
  loop:
    if (counter >= 0x1C) goto done    // 28 轮

    // Phase 1: 算法的 v1 更新(VM 变量名 v0,<<4, >>7, key[1], key[2])
    r13 = u32(r11 + delta)            // sum += delta
    t0 = u32(r12 << 0x4)
    t1 = u32(t0 + key[1])
    t2 = u32(r12 + r13)
    t3 = u32(t1 ^ t2)
    t4 = u32(r12 >> 0x7)
    t5 = u32(t4 + key[2])
    t6 = u32(t3 ^ t5)
    v0 = u32(sum + t6)               // VM 的 v0 = 算法的 v1_new

    // Phase 2: 算法的 v0 更新(VM 变量名 v1,<<6, >>5, key[3], key[0]=0xAABBCCDD)
    t8  = u32(v0 << 0x6)
    t9  = u32(t8 + key[3])
    t10 = u32(v0 + r13)
    t11 = u32(t9 ^ t10)
    t12 = u32(v0 >> 0x5)
    t13 = u32(t12 + key[0])          // + 0xAABBCCDD
    t14 = u32(t11 ^ t13)
    v1  = u32(r12 + t14)             // VM 的 v1 = 算法的 v0_new

    counter++; goto loop

  done:
    vec.push(v1); vec.push(v0)
    bridge(func=0x66, nargs=1)  // 输出 %08x%08x
    halt

反编译结果与第七章Unicorn trace还原的算法完全吻合:移位量、密钥、delta、轮数、轮结构全部一致,形成交叉验证。

产物: vm_static_disasm.py(静态反汇编+反编译), vm_decompile.py(Unicorn动态反编译)
输出: vm_static_output.txt(交错视图), vm_disasm.txt(纯汇编), vm_decompiled.txt(纯伪代码)

8.5 关键地址表

函数

PART3核心函数地址

VM基础设施

VM创建与执行函数

VM寄存器/内存访问

VM底层访问函数

Family handler

Family handler地址表

静态数据

VM字节码与常量地址

运行时缓冲区

运行时缓冲区地址


09 C++实现与交付物

9.1 flag_tool(C++17)

三个PART的加密和解密算法均用C++实现,支持Token→Flag和Flag→Token双向转换。

> flag_tool.exe encrypt a2d576a6

Token: a2d576a6
PART1: flag{sec2026_PART1_2d55e927}
PART2: flag{sec2026_PART2_be088bdac626fff5c3eb0e12265ab9d4}
PART3: flag{sec2026_PART3_21441a664225fa06}

> flag_tool.exe decrypt 2 be088bdac626fff5c3eb0e12265ab9d4
Token: a2d576a6
Verify: encrypt(a2d576a6) = be088bdac626fff5c3eb0e12265ab9d4 OK

> flag_tool.exe verify
=== 16 passed, 0 failed ===

编译:cl /EHsc /O2 /std:c++17 /Fe:flag_tool.exe main.cpp part1_feistel.cpp part2_aes.cpp part3_tea.cpp

9.2 源码说明

C++源码结构

9.3 全部交付物索引

完整交付物清单


以上就是针对2026腾讯游戏安全技术竞赛安卓决赛的完整VM分析与还原过程。从引擎识别、资源提取、GDScript反混淆、反调试绕过,到三个加密算法的逆向还原,再到自研VM静态反编译器的构建,形成了一套完整的分析工具链。最终产物 flag_tool.exe 可双向转换Token与Flag,16组测试向量全部通过验证。

若对文中涉及的Reverse EngineeringComputer Arch相关技术有更深入的兴趣,欢迎来云栈社区与更多同好交流探讨。




上一篇:昇腾NPU如何驱动生成式推荐落地?从FuXi模型到Performance Law的全栈实践
下一篇:百万级组件告急:AntV生态npm包遭批量投毒,窃取凭证蠕虫持续扩散
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-5-20 11:02 , Processed in 0.710858 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

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