Flag 速查表
输入屏幕左上角 8 位 hex Token,输出 3 个 flag。
完整工具:flag_tool.exe encrypt <token>
以 Tokena2d576a6为例:

产物:flag_tool.exe 源码— 支持 encrypt / decrypt / verify,16 组测试向量全通过。
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 xref 定位到sub_38013D0(=FileAccessEncrypted::open_and_parse,9 处调用)。
② 确认 AES 调用:sub_38013D0内部的 AES 初始化序列:
// sub_38013D0 (open_and_parse) 伪代码摘录sub_376EDC0(ctx); // mbedtls_aes_initsub_376EE1C(ctx, *(this + 352), 256); // mbedtls_aes_setkey_enc(ctx, key, 256)sub_376EF88(ctx, len, *(this + 336), in, out); // mbedtls_aes_crypt_cfb128sub_376EDF0(ctx); // mbedtls_aes_free③ 追踪密钥来源:this+352的 key 由调用者传入。反查 xref,sub_3804BEC(xref0x3804F08)和sub_3805E9C(xref0x3806140)均通过逐字节循环从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 7A3D A3 9F 2A F7 5E 32 84 D5 12 17 C0 4D 49 A0 611.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 个.gdc(GDSCmagic)和 7 个.scn(场景文件)。
加密输入:final/assets/(10 个 .gdc + 7 个 .scn)
解密产物:solve/decrypted/(仅 .gdc;.scn 在patch_trigger4_scn.py中就地解密处理)
CFB128 实现:patch_trigger4_scn.py中cfb128_decrypt_standard()
2.1 问题发现
标准gdre_tools反编译输出乱码——关键字错位(if变成match,func变成signal等),说明自定义引擎对 GDScript tokenizer 的 token ID 做了重映射。
对照 Godot 4.5 开源代码modules/gdscript/gdscript_tokenizer.h的标准 GDSC 格式,定位到两处自定义修改:
_ready、extends等),用预期 ASCII 与实际字节逐字节 XOR,得到固定密钥0xB6。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 每字节 XOR0xB6(解密后得到合法 ASCII 变量名)。Token 流每项 4/8 字节,word1[0:7]= token type(重映射 ID),word1[8:]= pool index,word2= 行号(可选)。
2.3 Token 重映射表恢复
从最简单的脚本(label2.gdc、token.gdc)开始,利用 GDScript 语法结构逐步推断映射:
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+ 个映射(部分摘录):

由于 token 枚举表内联在引擎解释器中,未以独立数据结构存在,无法直接从二进制提取,改用上述语义推断法逐一恢复。完整映射表见parse_gdc.py。
2.4 自定义解析器
python solve/godot/parse_gdc.py <file.gdc> --reconstruct # 反编译为 GDScriptpython 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 decodevar _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()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 方法),进而追踪到 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 dispatcherdispatcher: // 全函数唯一 EOR W8, W8, #0xC3 // 解码 step 1 ADD W8, W8, #0x71 // 解码 step 2CMP W8, #0x1EE0E901 // CMP 二叉搜索树 B.LE lower_halfCMP W8, #0x316195C5 B.EQ block_17 // 匹配 → 跳转 ...IDA 则需手动解析 hash 解码 + CMP 树。
变体 B — 内联双表派发
用于核心加密函数(AESsub_A936C、TEAsub_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):┌────────┬──────┐ ┌─────┬────────────┐│state0 │→ 10 │ │ [0] │ 0x130644 ││state1 │→ 5 │ │ [1] │ 0x130184 ││state2 │→ 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(变体 B:内联双表state_tab[state]→jpt[idx]→BR):

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

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

4.0 检测发现方法
直接 Frida attach 会被秒杀(exit_group(0)),静态分析面对全函数 CFF 混淆也很难枚举所有检测点。采用自研模拟器沙箱完整捕获 libsec2026.so 的运行时行为。
日志规模:单次运行产生 ~1.5GB trace,包含每条 syscall 的编号、参数、返回值,以及每个库函数调用的函数名和参数。

筛选过程:将 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,精简后:voidfrida_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(); // 接着执行注入器检测}绕过:Hookopenat,当路径含/proc/self/task时替换为无效路径。
4.2 注入器检测(sub_99418)
扫描/proc/self/fd,readlinkat读取每个 fd 的符号链接目标,搜索linjector。
// sub_99418 (0x99418 ~ 0x9A1EC) — 精简后:voidinjector_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); } }}绕过:同上,Hookopenat拦截/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++) // 标准 CRC32val = (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 handlervoidtick_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)→ 以下检查按顺序执行:
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):
raw syscall openat打开/proc/self/maps(防 Frida hook libc IO)逐字节读取每行, sub_98564用 sscanf 解析start-end permssub_9A470将段名与目标库名比较(字符串匹配),定位 .text 段基址供 CRC32 校验使用
// sub_9B7D8 — exit 反 hook + CRC32 完整性校验(去除 CFF 后还原)// @ 0x9B7D8, 子函数 0x98564, 0x9A470, 0x96A00void* 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 hookvoid* 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 行解析 + 段定位voidlocate_text_segment(char* line) { // @ 0x98564uint64_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 完整性校验voidcrc32_check() { // @ 0x96A00dl_iterate_phdr(find_text_phdr, &ctx); // sub_9EFB4: 定位 .text PT_LOAD 段uint32_t crc = crc32(ctx.base, ctx.size); // sub_9AF98: 多项式 0xEDB88320if (crc != expected_crc) // dword_1682C8syscall(SYS_exit_group, 0); // 杀进程}循环阶段(每 3 秒):
sub_96A00→dl_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); // 吞掉调试信号elseptrace(PTRACE_CONT, pid, 0, sig); // 转发其他信号 } } }return 0;}// sub_95CC0 — 硬件断点清除voidclear_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 行解析器):
intsub_98564(constchar* maps_line) {// 3 个字符串均为 XOR 加密存储(密钥 byte_1834B0),运行时解密char* frida_str = decrypt("frida"); // data_1682F4char* memfd_str = decrypt("/memfd:"); // data_1682FAchar* fmt = decrypt("%lx-%lx %s"); // data_168302unsignedlong 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; // DETECTEDreturn 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 0xA4074void* 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 完成初始化
0x95C70 | ||
0xCA210 | ||
0x11FFDD | dword_400A050 | |
0x19000 |
绕过:数据层 Patch 不修改任何 .text 段,校验和自然通过。若必须 Patch .text,需同步修改 libgodot 中的期望值dword_400A050。
4.8 策略总结

T1=sub_9C654, T2=sub_9CDC4, T3=sub_9B7D8
统一绕过策略:采用数据层 Patch(修改 .scn 场景资源触发 flag 计算),不修改任何 .text 段代码、不 hook 任何函数、不 kill 任何检测线程。9 项检测全部自然通过。
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/hi5.2 验证

5.3 运行时字节码 Patch 验证
trigger2 需要碰撞触发(3D 场景中撞到 Trigger2),可通过 HookGDScriptTokenizerBuffer::set_code_buffer在运行时修改字节码,将_process()中的动画调用_m3(_d)替换为 flag 回调_w7(_d),使 flag 每帧自动生成。
关键地址(libgodot_android.so):

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_bufferHook::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) → _w7if (*(uint32_t*)(buf + 0x1F80) == 0x00003C8C) { buf[0x1F81] = 0x30; // _m3(idx=60) → _w7(idx=48) }});运行结果:Token8dce44a5,输出flag{sec2026_PART1_154ca922}✅
真机验证(MuMu 模拟器,Token8dce44a5,碰撞 Trigger2 后显示 flag):

C++ 核心实现(part1_feistel.cpp):
staticvoidround_fn(constuint8_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>
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。

以下为 CFF 状态机精简后的等价伪代码:
// sub_A936C (0xA936C ~ 0xA9664) — PART2 加密入口__int64 sub_A936C(__int64 token, __int64 a2, __int64 output) {__int64v20 =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 多一轮。

单块加密 sub_A8D44(下图):

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

确认框架是 AES 变种后,逐一定位每个非标准参数:
6.4 与标准 AES-128 的差异

参数提取:CFF 去混淆后从 IDA 伪代码逐一读取,以下为关键证据。
S-box 仿射变换(sub_A8C8C)— 常数0x8F和 ROL5 直接可见:

// 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 + ROL5GF(2^8) 多项式(sub_A96F0)—& 0x71即 reduction poly0x171:
// sub_A96F0 — GF 乘法 (gf_mul)a1 = ((unsignedint)(char)v3 >> 7) & 0x71 ^ (2 * v3); // ← poly = 0x171MixColumns 系数(sub_A6F20)—[6,3,5,2]循环矩阵:

// sub_A6F20 — MixColumnsv9[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 78ShiftRows 置换(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←1v4=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←2v8=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←7RoundMix(sub_A8F00)— CFF 混淆,通过 Unicorn 差分提取:
# 可见初始化: var_dc = 0x47 + arg2 * 0xffffff9d# 即 seed = (71 - 99 * round_num) & 0xFFdef 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 提取):

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 XOR6.6 验证(5 组测试向量)

6.7 运行时字节码 Patch 验证
与 PART1 相同方法:Hookset_code_buffer中MOV W25, #0xB6(地址0x147D4A8),在解压后修改_process()中的_m3(_d)调用为_w7(_d)。
// trigger3: offset 0x1CD4 = 0x00003B8C (_m3) → _w7if (*(uint32_t*)(buf + 0x1CD4) == 0x00003B8C) { buf[0x1CD5] = 0x29; // _m3(idx=59) → _w7(idx=41)}Patch 对比表:

运行结果:Token1af763af,输出flag{sec2026_PART2_52f0ab0970da6f1d7c516d0813acc998}✅
真机验证(Token1af763af,通过字节码 Patch 自动触发,右上角显示 flag):

C++ 核心实现(part2_aes.cpp,~300 行):
// GF(2^8) 乘法 — 约化多项式 x^8+x^6+x^5+x^4+1 = 0x171(非标准 0x11B)staticuint8_tgf_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])staticconstuint8_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) & 0xFFstaticvoidadd_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 异或(每轮不同的伪随机扰动)staticvoidroundmix(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 位置正序staticvoidtwisted_xor(uint8_t s[16], constuint8_t iv[16]) {for (int i = 0; i < 16; i++) s[i] ^= (i % 2 == 0) ? iv[15 - i] : iv[i];}// 11 轮加密(标准 AES 为 10 轮)staticvoidaes_encrypt_block(uint8_t s[16]) {init_transform(s); // INIT_XORadd_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>
7.1 发现:Trigger4 被刻意禁用
反编译trigger4.gdc发现:GDScript 侧没有任何 flag 逻辑,没有body_entered.connect(),仅有GameExtension.new()和Tick()调用。
解析town_scene.scn(Godot PackedScene RSRC v6 格式)发现 Trigger4 被刻意禁用:

反编译trigger4.gd(代码见第二章 2.5):没有body_entered.connect(),signal collided_with声明但 GDScript 侧从未 emit。
碰撞回调必须由 native 层实现——通过字符串解密定位到sub_A07F4(GDExtension 类注册,下图),发现 PART3 碰撞回调注册在虚函数表中,最终指向sub_A9A7C(静态二进制中 0 xref,通过 GDExtension 虚表间接调用,IDA 无法静态追踪)。

结论:Trigger4 在游戏中既不可见也无法通过物理碰撞触发,必须修改场景才能触发。
7.2 场景 Patch
解密.scn后,在 RSRC 的 nodes 数组中修改 variant index 指针,将属性指向正确的 true/false variant:

前 4 处启用碰撞和可见性,后 3 处将 Trigger4 移到玩家出生点(开局即碰撞)。
解密 .scn:加密文件格式[md5:16][pt_len:8][iv:16][ciphertext:...],AES-256-CFB128 解密后验证md5(pt) == stored_md5且pt[: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: 注册 Processsub_9F4EC("Process", "Process", 0x4A85115FAA17D83FLL, 8);sub_9B5E8(v30, sub_97704, 0); // sub_97704 = Process handler → PART2// case 0: 注册 Ticksub_97358("Tick", "Tick", 0x97A36EF7A74F5E4ELL, 5);sub_9610C(v27, sub_9AD68, 0); // sub_9AD68 = Tick handler → 反调试计时// case 28: 注册 inputsub_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:

Step 2 — 轮数识别
追踪 0x80329e08(v0 输出 slot)的值变化序列:
Transitionsfor0x80329e08 (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 + sum ④ 0x53980a16 → ② XOR ③ ⑤ 0x06109664 → v1 >> 5 ⑥ 0xb0cc6341 → ⑤ + 0xaabbccdd ⑦ 0xe3546957 → ④ 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 = 0x29e59c9fRound 2: sum = 0x53cb393e = 2 × 0x29e59c9fRound 3: sum = 0x7db0d5dd = 3 × 0x29e59c9f结论:delta = 0x29e59c9f(非0xaabbccdd!后者是 v0 右项加数,即 7.6 表中的 key[0])。
Step 5 — 4 个候选模型淘汰赛

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)) & M,hi = v0,token = pack('<II', lo, hi)
7.6 提取结果
常量:

与标准 TEA 差异:

加密/解密:
def encrypt(v0, v1):sum = delta v0 += ((v1<<6)+key[3]) ^ (v1+sum) ^ ((v1>>5)+0xaabbccdd) # Round 1for 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 & Mdef decrypt(v0, v1): # 严格逆序sum = 28 * deltafor 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 1return v0 & M, v1 & M7.7 验证(7 + 1 组测试向量)

7/7 正向 + 7/7 逆向 + 1 组 ASCII 真实场景,全部匹配 Unicorn ground truth。
真机验证(MuMu 模拟器,Tokena2d576a6,场景 Patch 后碰撞 Trigger4 显示 flag):

C++ 核心实现(part3_tea.cpp):
命名约定:C++ 中
KEY[0]=delta=0x29e59c9f,MAGIC=0xaabbccdd;VM 反编译器中key[0]=0xaabbccdd(7.6 表采用 VM 约定)。两套索引不同,常量值一致。
staticconstuint32_t KEY[4] = {0x29e59c9f, 0xf95d664a, 0x12aa364c, 0x33ad3cee};staticconstuint32_t MAGIC = 0xaabbccdd; // VM 反编译器中的 key[0]staticconstuint32_t DELTA = KEY[0]; // 0x29e59c9fstaticconstint ROUNDS = 28;// 轮函数 — v0 用 <<6/>>5,v1 用 <<4/>>7(标准 TEA 均为 <<4/>>5)staticuint32_tf_v0(uint32_t v1, uint32_t s) {return ((v1 << 6) + KEY[3]) ^ (v1 + s) ^ ((v1 >> 5) + MAGIC);}staticuint32_tf_v1(uint32_t v0, uint32_t s) {return ((v0 << 4) + KEY[1]) ^ (v0 + s) ^ ((v0 >> 7) + KEY[2]);}// 非对称 Feistel — 第 1 轮仅更新 v0,第 2~28 轮先 v1 后 v0staticvoidencrypt(uint32_t& v0, uint32_t& v1) {uint32_t s = DELTA; v0 += f_v0(v1, s); // 第 1 轮:仅 v0for (int i = 1; i < ROUNDS; i++) { s += DELTA; v1 += f_v1(v0, s); // 第 2~28 轮:先 v1 v0 += f_v0(v1, s); // 再 v0 }}staticvoiddecrypt(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,δ)+lostaticvoidtoken_to_state(constuint8_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>
算法已在第七章通过 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_A9A7Ccase 33(VM 调用核心)— 启动链中每个函数调用均可在 IDA 反编译中一一对应:

sub_13CD90是驱动循环,每次调用sub_13C67C执行一条 VM 指令。返回0x604(CONTINUE)继续,0(SUCCESS)结束。额外保护:vm[6] >= vm[1]时强制退出(PC 越界)。
双对象模型
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
BRIDGE handler 伪代码
sub_AA758(VM cmd 102: PART3 结果格式化):
// sub_AA758 (0xAA758 ~ 0xAA9B0) — VM cmd 102voidsub_AA758(a1, a2, a3, a4, a5) {int64_t v0, v1;int64_t ctx = *(a3 + 8);loc_1385BC(a1, a2, ctx + 16, &v1, 8); // 读第一个 u32loc_1385BC(a1, a2, ctx + 24, &v0, 8); // 读第二个 u32sub_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_tVMEntry(a1, a2, a3, a4, a5, a6) {uint32_t idx = *v8;if (idx >= a6) *v7 = 0; // 超出范围返回 0else *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 状态(地址→语义):

handler 分派点在 ARM64 层为BLR X8 @ 0x139684(动态反编译器 hook 的位置),调用前 X2 指向 112 字节指令描述符,SP+0xF0 为当前 bytecode PC。
寄存器/内存访问函数
Family handler 内部通过以下函数操作 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 高字节分派:

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 hooksub_13C67C的BLR 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 op20x0100 acc acc tmp =ADD r14 r14 r15Header: opcode=0x0100(ADD), flags=0x00, cls=0x03(3 operands)Op0: fmt=0x01(reg), idx=0x0E(acc) → 目标Op1: fmt=0x01(reg), idx=0x0E(acc) → 源1Op2: fmt=0x01(reg), idx=0x0F(tmp) → 源2长度: 4 + 3×2 = 10 字节验证方法:将推测的编码格式直接解码.rodata:0x63DA0处的 5414 字节原始数据,逐条与 Unicorn 动态 trace 的指令序列比对——684 条指令全部吻合,证明编码格式正确。
指令集总表(60+ 条)

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, acc→vec.push(imm)。连续多个合并为vec.push(0) x 10。
模式 C — 常数赋值:MOVALT r0, #val; MOV rX, r0→rX = 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_truncater13 = u32(r11 + delta)8.4 反编译结果
684 条指令 →~40 行伪代码,TEA 循环体完整可读(注意:VM 内部 v0/v1 命名与算法相反,输出时push(v1,v0)交换回来):
func main(): vec.push(0) x 10 // 分配 10 个 slotbridge(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 关键地址表
函数:

VM 基础设施:

VM 寄存器/内存访问:

Family handler:

静态数据:

运行时缓冲区:

9.1 flag_tool(C++17)
三个 PART 的加密和解密算法均用 C++ 实现,支持 Token→Flag 和 Flag→Token 双向转换。
> flag_tool.exe encrypt a2d576a6Token: a2d576a6PART1: flag{sec2026_PART1_2d55e927}PART2: flag{sec2026_PART2_be088bdac626fff5c3eb0e12265ab9d4}PART3: flag{sec2026_PART3_21441a664225fa06}> flag_tool.exe decrypt 2 be088bdac626fff5c3eb0e12265ab9d4Token: a2d576a6Verify: 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 源码说明

9.3 全部交付物索引


看雪ID:XCicada
https://bbs.kanxue.com/user-home-927003.htm

# 往期推荐


球分享

球点赞

球在看

点击阅读原文查看更多
夜雨聆风