首页
社区
课程
招聘
[原创] 2026腾讯游戏安全技术竞赛-安卓决赛VM分析与还原
发表于: 8小时前 519

[原创] 2026腾讯游戏安全技术竞赛-安卓决赛VM分析与还原

8小时前
519

2026 腾讯游戏安全技术竞赛 — 安卓客户端安全(决赛)

Flag 速查表

输入屏幕左上角 8 位 hex Token,输出 3 个 flag。完整工具:flag_tool.exe encrypt <token>

以 Token a2d576a6 为例:

Part 算法 Flag
PART1 8轮 Feistel flag{sec2026_PART1_2d55e927}
PART2 自定义 AES-128-CBC (11轮) flag{sec2026_PART2_be088bdac626fff5c3eb0e12265ab9d4}
PART3 28轮 TEA 变种 (VM内) flag{sec2026_PART3_21441a664225fa06}

产物: flag_tool.exe 源码 — 支持 encrypt / decrypt / verify,16 组测试向量全通过


一、引擎识别与资源提取

1.1 引擎识别

APK 解包后观察到 assets/assets.sparsepcklib/arm64-v8a/libgodot_android.so,结合 AndroidManifest 中的 com.godot.game 包名,确认引擎为 Godot 4.5(开源引擎)。

关键二进制:

文件 用途
libgodot_android.so 自定义 Godot 引擎(含 AES 密钥 + token 重映射)
libsec2026.so 原生加密库(PART2/PART3 flag 逻辑 + 反调试)

1.2 AES 密钥提取

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

① 定位 open_and_parse:搜索 RTTI 字符串 "19FileAccessEncrypted"(地址 0x945101),经 typeinfo xref 定位到 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 的 key 由调用者传入。反查 xref,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 提取的 key 做标准 CFB128 解密,校验 MD5(明文) == 文件头 MD5 通过,确认决赛使用标准 CFB128(初赛为 position-XOR 变体)。解密后得到 10 个 .gdcGDSC magic)和 7 个 .scn(场景文件)。

加密输入: final/assets/(10 个 .gdc + 7 个 .scn)解密产物: solve/decrypted/(仅 .gdc;.scn 在 patch_trigger4_scn.py 中就地解密处理)CFB128 实现: patch_trigger4_scn.pycfb128_decrypt_standard()

二、GDScript 字节码反混淆

2.1 问题发现

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

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

  1. 标识符 XOR 加密:标准 Godot 的 identifier pool 是明文 UTF-32 LE,解密后字节码的标识符全部乱码。已知明文推断(标识符中必然包含 _readyextends 等),用预期 ASCII 与实际字节逐字节 XOR,得到固定密钥 0xB6
  2. 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 语法结构逐步推断映射:

  1. IDENTIFIER(12) / LITERAL(13):最先确定——它们携带 pool index,出现频率最高
  2. 结构关键字func name(args): 模式 → 确定 FUNC(71)、PAREN_OPEN(88)、PAREN_CLOSE(89)、COLON(95)
  3. 控制流if ... : / while ... : / for ... in ... → 确定 IF(51)、WHILE(55)、FOR(54)、IN(72)
  4. 运算符:结合加密代码上下文,& 0xFF → AMPERSAND(26),^ key → CARET(29),<< 3 → LESS_LESS(30)

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

重映射 ID 含义 重映射 ID 含义
12 IDENTIFIER 29 ^ (XOR)
13 LITERAL 30/31 <</>>
32/33/34 +/-/* 51 if
71 func 81 var
86/87 INDENT/DEDENT 59 NEWLINE

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

2.4 自定义解析器

python solve/godot/parse_gdc.py &lt;file.gdc&gt; --reconstruct   # 反编译为 GDScript
python solve/godot/parse_gdc.py &lt;file.gdc&gt; --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()

三、libsec2026.so 逆向基础

3.1 字符串解密

IDA 打开 libsec2026.so 后,搜不到任何明文字符串("Process"、"Tick"、"ClassDB" 等均不存在)。观察到大量函数具有相同的 prologue 特征(FFC300D1 E01700F9 E11300F9 E20F00F9 E31700B9),其反编译结果为 XOR 解密循环,将 .rodata 密文写入 .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 方法注册
sub_95FA8 "ClassDB" 引擎 API 调用
sub_9650C "oOoo0Oo0." 混淆类名
sub_9CDC4 "/proc/self/task" / "gum-js-loop" Frida 检测
sub_A9538 "%02x" PART2 hex 格式化
sub_AA758 "%08x%08x" PART3 flag 格式化

通过字符串解密,定位到 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 表重排 state_tab[i]=i,重排 jpt 补偿 → IDA 识别为 switch state_tab + jpt 数据 简单 CFF(无条件跳转为主) patch_cff_tables.py
IDA dispatch tail 重写 LDRSW+LDR+BR → B/B.cond 直接跳转 二进制指令 全部变体 B(含条件/嵌套) deobf_cff.py

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


四、反调试绕过(10 分)

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_readv, clock_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 检测到 > 10 秒 → 游戏停止处理
  • CRC32 与心跳在同一循环中交织,无法单独绕过任何一方

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

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

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

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

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

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

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

    • raw syscall openat 打开 /proc/self/maps(防 Frida hook libc IO)
    • 逐字节读取每行,sub_98564 用 sscanf 解析 start-end perms
    • 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 秒):

  1. 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 策略总结

# 检测 函数 线程 syscall 绕过
1 Frida 线程名 sub_9CDC4 T2 openat(56) Hook openat 拦截 /proc/self/task
2 注入器 fd sub_99418 T2 readlinkat(78) Hook openat 拦截 /proc/self/fd
3 CRC32 心跳互锁 sub_9AF98↔sub_9AD68 T3↔渲染 clock_gettime(113) 不 kill 检测线程
4 exit inline hook sub_9B7D8 T3 不 hook exit
5 CRC32 .text 校验 sub_96A00→sub_9AF98 T3 dl_iterate_phdr 数据层 Patch,不改 .text
6 ptrace 占位 sub_9C654 T1 ptrace(117) Frida spawn 不依赖 ptrace
7 硬件断点清除 sub_95CC0 T1 ptrace(GETREGSET) 不用硬件断点
8 maps Frida/memfd 检测 sub_9B7D8 T3 raw syscall(56,63) 不注入额外 .so
9 交叉 .text 校验 sub_10B9E4C (libgodot) 渲染帧 process_vm_readv(270) 数据层 Patch,不改 .text

T1=sub_9C654, T2=sub_9CDC4, T3=sub_9B7D8

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


五、PART1 — Feistel 加密(15 分)

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: 70),交换 lo/hi

5.2 验证

Token 加密输出 解密还原
8dce44a5 154ca922 8dce44a5
a2d576a6 2d55e927 a2d576a6

5.3 运行时字节码 Patch 验证

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

关键地址(libgodot_android.so)

地址 函数 说明
0x147D2A8 set_code_buffer 入口(RTTI: 23GDScriptTokenizerBuffer
0x147D3AC Compression::decompress zstd 解压 GDSC body
0x147D4A8 MOV W25, #0xB6 XOR 密钥加载,最佳 Hook 点

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 &lt;token&gt; / flag_tool.exe decrypt 1 &lt;hex&gt;


六、PART2 — 自定义 AES-128-CBC(25 分)

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(下图):

CFF case 伪代码 对应 AES 操作
case 6 a1[4*v30+j] = a2[4*v30+j] 复制 4 words 初始密钥
case 3 byte_183700[v31..v34]byte_652C9[v2>>2] RotWord → SubBytes(S-box) → XOR Rcon
case 4 a1[4*v2+j] ^= a1[4*(v2-4)+j] 轮密钥 XOR 链

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

sub_A7DE8 密钥扩展

单块加密 sub_A8D44(下图):

CFF case 调用序列 对应 AES 操作
L12-13 sub_A7944()sub_AAB64(0, ...) InitTransform → AddRoundKey(0)
case 1 sub_A82C8sub_A8F00sub_AADE8 SubBytes → RoundMix → ShiftRows
case 3 sub_A6F20sub_AAB64(v2, ...), v5++ MixColumns → AddRoundKey(r)
case 2 sub_AAB64(0xB, ...)sub_A84A4 AddRoundKey(11) → FinalTransform

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

sub_A8D44 单块加密

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

6.4 与标准 AES-128 的差异

属性 标准 AES-128 本题变种
轮数 10 (9+1) 11 (10+1)
S-box 标准 Rijndael 自定义(GF inverse + affine 0x8F + ROL5)
GF(2^8) 多项式 0x11B 0x171
Rcon 01,02,04,... 自定义 a7,a6,...
MixColumns [2,3,1,1] [6,3,5,2]
ShiftRows [0,5,10,15,4,9,...] 自定义置换 [0,13,6,11,4,1,...]
AddRoundKey state[i] ^= rk[i] state[i] ^= rk[i] ^ ((i%4 + 91×round) & 0xFF)
IV XOR 标准 CBC Twisted(偶数位 ^= iv[15-i],奇数位 ^= iv[i]
额外操作 InitTransform / FinalTransform / RoundMix(见下)

参数提取: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 提取):

名称 地址
Key xmmword_58550 2c7e151618aec2a1abf7158809cf4f3c
IV xmmword_58600 2c7e15161a2de471ccff11cf04882f1d
INIT_XOR unk_58510 de4f8a37c16b59e273ad1f94b806d542
FINAL_XOR unk_58590 7ce32891a65df014bb6907d84a35ec80

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 组测试向量)

Token 加密输出 解密还原
1af763af 52f0ab0970da6f1d7c516d0813acc998
8dce44a5 1451b21d6b4dcb03358a11f4bfe4fb9e
00000000 be75f176a587d29af20b445dc2c0f3a8
deadbeef 6ae003283e97f2606f3e668bb5a6be05
abcdef01 3ae1bf70a54c8016e26fdcc79fac65d0

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 (PART1) trigger3 (PART2)
_w7 索引 48 (0x30) 41 (0x29)
_m3 索引 60 (0x3C) 59 (0x3B)
Patch 偏移 0x1F81 0x1CD5
原始字节 0x3C → 0x30 0x3B → 0x29

运行结果: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 &lt;token&gt; / flag_tool.exe decrypt 2 &lt;hex&gt;


七、PART3 — VM 中的 TEA 变种分组密码(50 分)

7.1 发现:Trigger4 被刻意禁用

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

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

属性 效果
monitoring false 碰撞检测关闭
monitorable false 不可被检测
CollisionShape3D.disabled true 碰撞体禁用
MeshInstance3D.visible false 不可见

反编译 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:

偏移 属性 原值 新值
0x858A monitoring variant[10]=false variant[3]=true
0x8592 monitorable variant[10]=false variant[3]=true
0x85D6 disabled variant[3]=true variant[10]=false
0x8602 visible variant[10]=false variant[3]=true
0x7689 origin.x 3.750 8.000 (出生点)
0x768D origin.y 4.593 3.364
0x7691 origin.z -16.560 -16.000

前 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("&lt;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. 对两组仅 1 字节不同的输入分别执行,挂 UC_HOOK_MEM_WRITE 记录全部写入
  2. 对比同一位置的写入值差异,定位输入敏感的内存 slot(约 10 个)
  3. 追踪关键 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:

分类 地址 用途(后续确认)
HEAP 0x80329da8 通用计算寄存器(v1 初始值 + 中间值)
HEAP 0x80329de8 中间运算缓冲(三项 XOR 逐步计算)
HEAP 0x80329e08 v0 输出(每轮更新后写入)
HEAP 0x80329e18 控制/索引寄存器
STACK 0x7f3efb00/58/60/78/80 临时计算(与 heap slot 同步)

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 — 轮函数还原

3a. 初次追踪中间值:slot de8 的每次写入值,Round 1:

Round 1 中间值(零输入,da8 初始 = 0xc212cc99):
  ① 0x3084b32640  → v1 << 6 的完整 64 位结果
  ② 0xb860632e    → (v1<<6)&0xFFFFFFFF + key[3]
  ③ 0xebf86938    → v1 + sum0x53980a16    → ② XOR ③
  ⑤ 0x06109664    → v1 >> 50xb0cc6341    → ⑤ + 0xaabbccdd0xe3546957    → ④ XOR ⑥ = Round 1 输出

这看起来像 (A + K1) ^ (B) ^ (C + K2) 的 TEA 结构。

3b. 确认移位量(对多组输入交叉验证):

  • v1=0xc212cc99 时,v1<<6 = 0x3084B32640 → ✅ 匹配 trace ①
  • v1<<4 = 0xC212CC990 → ❌ 不匹配

确认:v0 update 的左移量是 6(标准 TEA 为 4)。同理 ⑤ = v1 >> 5(标准)。

3c. 错误假设:v1 不变

观察 da8 slot 在全零输入时的前几十次写入,初始值 0xc212cc99 反复出现。一度假设 v1 在 28 轮中保持不变(单侧 Feistel)。在全零输入上"恰好"多轮生效,但非零输入上完全对不上。

3d. 关键发现:da8 是多用途寄存器

用输入 0x4142434445464748 运行,da8 在 Round 2 出现完全不同的序列——da8 不仅存储 v1,还用作三项 XOR 计算的临时缓冲。v1 在 Round 2 就被更新了。

3e. 识别两阶段轮结构

Round 2 的 de8 中间值序列比 Round 1 长一倍——两组 TEA 计算:

Round 2 de8 中间值:
  -- Phase 1 (v1 update) --
  d[0] = (v0<<4)            ← 注意:v0 的移位!
  d[1] = (v0<<4)&M + key[1] ← 用 key[1]
  d[2] = v0 + sum_new
  d[3] = d[1] XOR d[2]
  d[4] = v0 >> 7            ← >>7 而非 >>5!
  d[5] = (v0>>7) + key[2]
  d[6] = d[3] XOR d[5]
  d[7] = v1_old + d[6]      ← v1 更新!

  -- Phase 2 (v0 update) --
  d[8] = (v1_new<<6)        ← 用更新后的 v1
  ...和 Round 1 相同的结构

至此轮结构清晰:Round 1 仅 v0 update;Round 2~28 先 v1(用 v0 的 <<4/>>7)再 v0(用新 v1 的 <<6/>>5)。

Step 4 — delta 的真实身份

4a. 初始错误:VM 字节码中最显眼的常数 0xaabbccdd,以为是 delta。

4b. trace 推翻:追踪中项 (v1 + sum) 反推 sum:

Round 1: sum = 0xebf86938 - 0xc212cc99 = 0x29e59c9f
Round 2: sum = 0x53cb393e = 2 × 0x29e59c9f
Round 3: sum = 0x7db0d5dd = 3 × 0x29e59c9f

结论:delta = 0x29e59c9f(非 0xaabbccdd!后者是 v0 右项加数,即 7.6 表中的 key[0])。

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

模型 描述 结果
A 每轮先 v0 后 v1,共享 sum ❌ Round 2 起偏离
B Round 1 仅 v0;Round 2+ 先 v1 后 v0 ✅ 全部匹配
C 同 B + 最后额外一轮 v1 update ❌ 输出不匹配
D sum 每半轮累加 ❌ Round 3 起偏离

Step 6 — 输入映射

用 8 组单字节输入观察 v0/v1 初始值变化:

token = 01 00 00 00 00 00 00 00  →  v0_init = 0x00000000, v1_init 变化
token = 00 00 00 00 01 00 00 00  →  v0_init = 0x00000001, v1_init 也变化
  • v0_init = le32(token[4:8])
  • v1_init = f_v1(v0_init, delta) + le32(token[0:4])(预处理步骤)

逆映射:lo = (v1 - f_v1(v0, delta)) & Mhi = v0token = pack('&lt;II', lo, hi)

7.6 提取结果

常量

名称 用途
delta 0x29e59c9f sum 累加步长
key[0] 0xaabbccdd v0 右项 (>>5 加数)
key[1] 0xf95d664a v1 左项 (<<4 加数)
key[2] 0x12aa364c v1 右项 (>>7 加数)
key[3] 0x33ad3cee v0 左项 (<<6 加数)

与标准 TEA 差异

属性 标准 TEA 本题
轮数 32 28
v0 左移/右移 4/5 6/5
v1 左移/右移 4/5 4/7
delta 0x9E3779B9 0x29e59c9f
轮结构 对称 Round 1 仅 v0

加密/解密

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 组测试向量)

Token 加密输出 解密还原
0000000000000000 268193dc97031647
4142434445464748 43615381b28f288a
6162636465666768 2ba72e7d9b6dad2f
3132333435363738 7b84d2118e34500f
ffffffffffffffff c65e91327d2c8687
0001020304050607 51b616d6e907ea14
0100000000000000 8ee7c47f6b5308f6
ASCII a2d576a6 21441a664225fa06

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 &lt;token&gt; / flag_tool.exe decrypt 3 &lt;hex&gt;


八、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_machine 0x110 字节 sub_13BFD4 VM 执行状态:PC、指令计数器、descriptor array、selector 函数指针
host_table 0x2030 字节 sub_1371F8 BRIDGE 回调注册表:256 个 handler 槽位(每个 32 字节)+ 管理区

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
索引 名称 偏移
0x00-0x0D r0-r13 +0x28..+0x90
0x0E acc +0x98
0x0F tmp +0xA0
0x10 (sp) +0xA8
0x14 lr +0xC8

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);  // IDA 命名与 C++ 实现相反: 此处 v1 = C++ 的 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=0x044字节立即数,其他 → 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 状态(地址→语义):

状态地址 功能
0x139970 初始化:清零所有工作变量
0x13936c 取指:var_200 = *arg1(读 runtime)
0x13ab68 解码 flags:sub_12e2a0(runtime)
0x13b0b8 读操作数数据(4 字节):sub_11b090(mem, offset, &buf, 4)
0x13cb6c 取 family index:var_174 = var_84(opcode 高字节映射)
0x13d134 查 descriptor:arg1[var_174 * 2 + 0x14] → handler 地址
0x13a3c0 调用 handler:var_168(runtime, &descriptor, aux) = BLR X8
0x13901c 返回 CONTINUE:var_210 = 0x604,推进 arg1[6]++, arg1[0xC]++
0x13ae78 返回 HALT:var_210 = 0

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

寄存器/内存访问函数

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

地址 函数 功能
0x11A154 reg_read *out = *(regfile + 0x28 + reg_idx * 8),读寄存器
0x11A2CC reg_write *(regfile + 0x28 + reg_idx * 8) = value,写寄存器
0x11A3CC get_mem_ctx return *(regfile + 0x128),取内存上下文
0x11B090 mem_read 遍历 region 表,memcpy(out, region.data + offset - base, size)
0x11B39C mem_write 同上逻辑,反向写入

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

  1. 读 opcode:*arg3 = 0x0100 → switch 到 ADD 分支
  2. 读源操作数:reg_read(regfile, op1_slot, &val1), reg_read(regfile, op2_slot, &val2)
  3. 计算:result = val1 + val2
  4. 写目标: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 Handler 地址 操作码范围 功能 代表指令
Family0 0x138BBC 0x00xx 控制/宿主桥接 HALT, BRIDGE, NOP
Family1 0x139060 0x01xx 算术运算 ADD, SUB, MUL, DIV, MOD, NEG
Family2 0x139A44 0x02xx 位运算/移位 AND, OR, XOR, SHL, SHR32
Family3 0x139D70 0x03xx 传送/栈 MOV, LOAD, STORE, PUSH, POP, MOVALT
Family4 0x13A440 0x04xx 控制流 JMP, Jcc, CALL, RET, JDYN
Family5 0x13AC3C 0x05xx-0x08xx 标志/字节/帧/槽 SETF, CLRF, DISPATCH, OBJSTORE
Family6 0x13BBD4 0x09xx 向量操作 VECNEW, VECGET, VECPUSH

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+ 条)

高字节 类别 指令
0x00 控制 NOP HALT BRIDGE(func,nargs)
0x01 算术 ADD SUB MUL DIV MOD NEG
0x02 位运算 AND OR XOR NOT SHL SHR32 SHR64 SAR64
0x03 传送 MOV dst,src LOAD dst,[addr] STORE src,[addr] PUSH POP MOVALT dst,#imm
0x04 跳转 JMP JF1A JF1B JF8 JF9 CALL RET JDYN
0x05 标志 SETF CLRF SYNCZ ROWMGR ROWLNK
0x06 字节 WR8 RD8 DISPATCH ADVP1 ADVM1
0x07 ADVNEXT CURPREV APPLY2 POPFRM GETIDX
0x08 SLOT17A PROV70 PROV12 OBJSTORE
0x09 向量 VECNEW(type,cap) VECGET VECSET VECPUSH LDSLOT

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 通过栈式符号执行构建:

原始(~30 条指令):
  PUSH r0; PUSH r1; ... PUSH r7
  MOV r0, r11          // 操作数 1
  MOVALT acc, #delta   // 操作数 2
  ADD r0, acc          // r0 = r11 + delta
  ...
  MOVALT lr, #ret; JMP u32_truncate
  POP r7; ... POP r0
  MOV r13, acc         // 结果存入 r13

反编译结果(1 行):
  r13 = u32(r11 + delta)

模式 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 关键地址表

函数

地址 函数 用途
0x97B6C 方法注册 ClassDB 注册 Process/Tick/input
0x97704 Process handler PART2 入口
0x9AD68 Tick handler 反调试计时
0x9B7D8 完整性校验 exit 反 hook + CRC32 心跳
0xA07F4 GDExtension 注册 类注册 + 虚函数回调表
0xA9A7C PART3 主逻辑 35-case CFF:创建 VM → 执行 → 清理 → 返回 flag
0xA6BEC VMEntry VM cmd 101:提供 token 字节
0xAA758 sub_AA758 VM cmd 102:%08x%08x 格式化输出
0xA936C PART2 加密 加密 + %02x 格式化

VM 基础设施

地址 函数 用途
0x13BFD4 sub_13BFD4 创建 vm_machine(0x110 字节),安装 7 个 descriptor + selector
0x1371F8 sub_1371F8 创建 host_table(malloc(0x2030)),256 个 handler 槽位
0x13D278 sub_13D278 绑定:*(vm_machine + 0x98) = host_table
0x1374A0 sub_1374A0 注册 BRIDGE handler:host_table + cmd*32 处写入函数指针
0x13CD90 sub_13CD90 驱动循环:while (sub_13C67C(vm) == 0x604) {} + PC 越界保护
0x13C67C sub_13C67C 解释器主体:56-case CFF,取指→解码→分派→执行→PC 推进
0x13C430 sub_13C430 Family 选择器:opcode 高字节 → family index(5/6/7/8 合并为 Family5)
0x139684 BLR X8 handler 分派点(动态反编译器 hook 位置)

VM 寄存器/内存访问

地址 函数 用途
0x11A154 reg_read *(regfile + 0x28 + idx*8) → 读 VM 寄存器
0x11A2CC reg_write *(regfile + 0x28 + idx*8) = val → 写 VM 寄存器
0x11A3CC get_mem_ctx *(regfile + 0x128) → 取内存上下文
0x11B090 mem_read 遍历 region 表,memcpy 读 VM 内存
0x11B39C mem_write 反向 memcpy 写 VM 内存
0x118340 get_regfile 从 runtime context 取出寄存器文件对象
0x12DE80 create_runtime 创建 runtime 对象
0x119DA8 load_bytecode 加载字节码到 runtime

Family handler

地址 Family 覆盖指令
0x138BBC Family0 NOP, HALT, BRIDGE
0x139060 Family1 ADD, SUB, MUL, DIV, MOD, NEG
0x139A44 Family2 AND, OR, XOR, NOT, SHL, SHR
0x139D70 Family3 MOV, LOAD, STORE, PUSH, POP, MOVALT
0x13A440 Family4 JMP, Jcc, CALL, RET, JDYN
0x13AC3C Family5 SETF, CLRF, DISPATCH, OBJSTORE(0x05-0x08xx)
0x13BBD4 Family6 VECNEW, VECGET, VECSET, VECPUSH

静态数据

地址 内容 用途
0x163948 280 字节 7 个 FamilyDescriptor(每个 40 字节)
0x63DA0 5414 字节 SBC0 字节码(684 条指令,线性编码)
0x63D98 0x1526 = 5414 字节码大小(同时用作 socket 端口号)
xmmword_58550 静态 PART2 AES key
xmmword_58600 静态 PART2 AES IV

运行时缓冲区

地址 内容 用途
qword_183498 指针 malloc(0x100000) 共享缓冲区(0=未分配)
byte_1836C0 8 字节 token 输入缓冲
unk_1836E0 32 字节 flag 输出缓冲(%08x%08x 结果)
byte_1834B0 运行时 字符串解密 XOR key

九、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 源码说明

文件 功能 行数
main.cpp CLI 入口 + 16 组测试向量 ~170
part1_feistel.cpp PART1 Feistel 加密/解密 ~80
part2_aes.cpp PART2 自定义 AES(S-box, GF(2^8), MixColumns, RoundMix 全部实现) ~300
part3_tea.cpp PART3 TEA 变种加密/解密 ~100

9.3 全部交付物索引

分类 文件 用途
C++ 实现 flag_tool.exe Token↔Flag 双向转换(16 组自检通过)
C++ 源码 solve/cpp/ 3 个 PART 的完整加密/解密
Python 参考 flag.py 全 PART flag 生成器
Python 参考 part1_feistel.py PART1 Feistel 纯 Python 实现
Python 参考 part2_aes.py PART2 AES 纯 Python 实现
Python 参考 part3_tea.py PART3 TEA 纯 Python 实现
反编译产物 solve/decompiled/Trigger/ 4 个 trigger 完整 GDScript 源码(flag 生成逻辑)
VM 静态分析 vm_static_disasm.py SBC0 字节码静态反汇编 + 反编译
VM 动态分析 vm_decompile.py Unicorn 驱动的动态反编译
字节码解析 parse_gdc.py GDScript 反混淆解析器
场景 Patch patch_trigger4_scn.py Trigger4 启用 + 坐标移动
场景解析 parse_scene.py PackedScene RSRC 格式解析
Unicorn 模拟 vm_fast.py ARM64 模拟器(原生 stub 加速)
差分 trace vm_trace.py VM 算法提取辅助
IDA 脚本 deobf_cff.py CFF 去混淆(dispatch tail 重写)
IDA 脚本 patch_cff_tables.py CFF 间接表重排
IDA 脚本 patch_junk_code.py 花指令去除
IDA 脚本 ida_decrypt_strings.py 字符串解密(31 个函数)

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

最后于 8小时前 被a'ゞCicada编辑 ,原因:
上传的附件:
收藏
免费 5
支持
分享
最新回复 (4)
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
8小时前
0
雪    币: 8264
活跃值: (8887)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3

文件没上传成功,全变成错误的链接了

最后于 8小时前 被mb_rjdrqvpa编辑 ,原因:
8小时前
0
雪    币: 1873
活跃值: (1945)
能力值: ( LV4,RANK:40 )
在线值:
发帖
回帖
粉丝
4
mb_rjdrqvpa 文件没上传成功,全变成错误的链接了
上传附件了,主要是觉得没有用半成品吧,以后都不需要学习了。
8小时前
0
雪    币: 1730
活跃值: (2267)
能力值: ( LV5,RANK:60 )
在线值:
发帖
回帖
粉丝
5
婵老师nb
7小时前
0
游客
登录 | 注册 方可回帖
返回