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.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_376EDC0(ctx);
sub_376EE1C(ctx, *(this + 352), 256);
sub_376EF88(ctx, len, *(this + 336), in, out);
sub_376EDF0(ctx);
③ 追踪密钥来源:this+352 的 key 由调用者传入。反查 xref,sub_3804BEC(xref 0x3804F08)和 sub_3805E9C(xref 0x3806140)均通过逐字节循环从 byte_400EF18 复制密钥:
do {
v23 = byte_400EF18[i];
key_buf[i++] = v23;
} while (...);
sub_38013D0(fae, &file, &key_vec, 0, 0, &iv);
④ 读取密钥: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 个 .gdc(GDSC magic)和 7 个 .scn(场景文件)。
加密输入: final/assets/(10 个 .gdc + 7 个 .scn)解密产物: solve/decrypted/(仅 .gdc;.scn 在 patch_trigger4_scn.py 中就地解密处理)CFB128 实现: patch_trigger4_scn.py 中 cfb128_decrypt_standard()
二、GDScript 字节码反混淆
2.1 问题发现
标准 gdre_tools 反编译输出乱码——关键字错位(if 变成 match,func 变成 signal 等),说明自定义引擎对 GDScript tokenizer 的 token ID 做了重映射。
对照 Godot 4.5 开源代码 modules/gdscript/gdscript_tokenizer.h 的标准 GDSC 格式,定位到两处自定义修改:
- 标识符 XOR 加密:标准 Godot 的 identifier pool 是明文 UTF-32 LE,解密后字节码的标识符全部乱码。已知明文推断(标识符中必然包含
_ready、extends 等),用预期 ASCII 与实际字节逐字节 XOR,得到固定密钥 0xB6
- Token ID 重映射:标准枚举中
FUNC=74, VAR=77, IF=58 等被打乱为 FUNC=71, VAR=81, IF=51 等
2.2 GDSC 二进制格式
[0..4] "GDSC" magic → [4..8] version (0x65=Godot4.5) → [8..12] decompressed_size
[12..] zstd payload → 解压后: 标识符池 → 常量池 → 行号表 → token 流
标识符 UTF-32 LE 每字节 XOR 0xB6(解密后得到合法 ASCII 变量名)。Token 流每项 4/8 字节,word1[0:7] = token type(重映射 ID),word1[8:] = pool index,word2 = 行号(可选)。
2.3 Token 重映射表恢复
从最简单的脚本(label2.gdc、token.gdc)开始,利用 GDScript 语法结构逐步推断映射:
- IDENTIFIER(12) / LITERAL(13):最先确定——它们携带 pool index,出现频率最高
- 结构关键字:
func name(args): 模式 → 确定 FUNC(71)、PAREN_OPEN(88)、PAREN_CLOSE(89)、COLON(95)
- 控制流:
if ... : / while ... : / for ... in ... → 确定 IF(51)、WHILE(55)、FOR(54)、IN(72)
- 运算符:结合加密代码上下文,
& 0xFF → AMPERSAND(26),^ key → CARET(29),<< 3 → LESS_LESS(30)
共恢复 35+ 个映射(部分摘录):
| 重映射 ID |
含义 |
|
重映射 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 <file.gdc> --reconstruct
python solve/godot/parse_gdc.py <file.gdc> --raw
所有 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,
CSEL W8, W9, W8, EQ // 条件分支:根据结果选 hash
B dispatcher
dispatcher: // 全函数唯一
EOR W8, W8,
ADD W8, W8,
CMP W8,
B.LE lower_half
CMP W8,
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,
STUR W8, [X29,
LDURSW X8, [X29,
LDRSW X8, [X22, X8, LSL
LDR X8, [X23, X8, LSL
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(变体 B:内联双表 state_tab[state] → jpt[idx] → BR):

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

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

四、反调试绕过(10 分)
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:
void sub_99094() {
pthread_create(&t1, 0, sub_9C654, 0);
pthread_create(&t2, 0, sub_9CDC4, 0);
pthread_create(&t3, 0, sub_9B7D8, 0);
}
4.1 Frida 线程检测(sub_9CDC4)
后台线程循环扫描 /proc/self/task/*/status,搜索线程名 gum-js-loop(Frida)/ gmain(GLib),检测到则 exit_group(0)。
void frida_detection_thread() {
sleep(1);
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 , AT_FDCWD, path, O_RDONLY);
if (strstr(line, "gum-js-loop") || strstr(line, "gmain"))
syscall(94 , 0);
}
sub_99418();
}
绕过:Hook openat,当路径含 /proc/self/task 时替换为无效路径。
4.2 注入器检测(sub_99418)
扫描 /proc/self/fd,readlinkat 读取每个 fd 的符号链接目标,搜索 linjector。
void injector_detection() {
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 , AT_FDCWD, path, buf, 256);
if (strstr(buf, "linjector"))
syscall(94 , 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(心跳时间戳):
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++)
val = (val & 1) ? (0xEDB88320 ^ (val >> 1)) : (val >> 1);
crc = val;
if ((i & 0xFFF) == 0) {
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,与当前时间比对:
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)
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 perms
sub_9A470 将段名与目标库名比较(字符串匹配),定位 .text 段基址供 CRC32 校验使用
void* integrity_guard_thread(void* arg) {
sleep(3);
setpriority(PRIO_PROCESS, 0, 19);
if (*(uint32_t*)exit != 0x50000058)
flag_abnormal();
void* exit_page = (void*)(-page_size & (uint64_t)&exit);
mprotect(exit_page, page_size, PROT_READ | PROT_EXEC);
int fd = syscall(SYS_openat, AT_FDCWD,
decrypt_str(data_1682d0), 0, 0);
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);
pos = 0;
} else line[pos++] = c;
}
while (1) { crc32_check(); sleep(3); }
}
void locate_text_segment(char* line) {
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);
}
void crc32_check() {
dl_iterate_phdr(find_text_phdr, &ctx);
uint32_t crc = crc32(ctx.base, ctx.size);
if (crc != expected_crc)
syscall(SYS_exit_group, 0);
}
循环阶段(每 3 秒):
- CRC32 完整性校验:
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 自附加,实现三层反调试:
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;
}
void clear_hw_breakpoints(pid_t pid) {
uint32_t regs[17];
struct iovec iov = {regs, 0x44};
ptrace(PTRACE_GETREGSET, pid, NT_ARM_HW_BREAK, &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;
}
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 注入痕迹:
int fd = syscall(SYS_openat, AT_FDCWD, "/proc/self/maps", 0, 0);
while (syscall(SYS_read, fd, &byte, 1) == 1) {
if (byte == '\n') {
line[pos] = '\0';
sub_98564(line);
pos = 0;
} else {
line[pos++] = byte;
}
}
核心检测在 sub_98564(maps 行解析器):
int sub_98564(const char* maps_line) {
char* frida_str = decrypt("frida");
char* memfd_str = decrypt("/memfd:");
char* fmt = decrypt("%lx-%lx %s");
unsigned long start, end;
char perms[256];
sscanf(maps_line, fmt, &start, &end, perms);
if (strstr(maps_line, "frida")) return 1;
if (strstr(maps_line, "/memfd:")) return 1;
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 段(不是自身)——形成交叉校验,两个库互相守护:
void integrity_check() {
void* ext_init = dlsym(libsec2026, "extension_init");
void* base = ext_init - 0xA4074;
struct iovec local = { heap_buf, 0xCA210 };
struct iovec remote = { base + 0x95C70, 0xCA210 };
syscall(SYS_process_vm_readv, self_pid, &local, 1, &remote, 1, 0);
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: 7→0),交换 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::AddExecuteHandler(base + 0x147D4A8,
[](Hook::VContext* ctx, uintptr_t pc, bool after) -> void {
if (after) return;
uint8_t* buf = (uint8_t*)(ctx->x[20] - 0x10);
if (*(uint32_t*)(buf + 0x1F80) == 0x00003C8C) {
buf[0x1F81] = 0x30;
}
});
运行结果:Token 8dce44a5,输出 flag{sec2026_PART1_154ca922} ✅
真机验证(MuMu 模拟器,Token 8dce44a5,碰撞 Trigger2 后显示 flag):

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);
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);
lo[0] = hi[0]; lo[1] = hi[1];
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) {
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>
六、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。

以下为 CFF 状态机精简后的等价伪代码:
__int64 sub_A936C(__int64 token, __int64 a2, __int64 output) {
__int64 v20 = 0, v21 = 0;
_memcpy_chk(&v20, token, 8, 17);
_memcpy_chk(&v21, token, 8, 9);
v17 = xmmword_58550;
v18 = xmmword_58600;
sub_A7900(v19, &v17, &v18);
sub_A7194(v19, &v20, 0x10);
}
void sub_A7DE8(__int64 ctx, __int64 key) {
sub_A9884();
}
__int64 sub_A7194(__int64 ctx, __int64 pt, unsigned __int64 size) {
}
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_A8D44(下图):
| CFF case |
调用序列 |
对应 AES 操作 |
| L12-13 |
sub_A7944() → sub_AAB64(0, ...) |
InitTransform → AddRoundKey(0) |
| case 1 |
sub_A82C8 → sub_A8F00 → sub_AADE8 |
SubBytes → RoundMix → ShiftRows |
| case 3 |
sub_A6F20 → sub_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。

确认框架是 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 直接可见:

v1 = sub_A7598(a1);
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);
GF(2^8) 多项式(sub_A96F0)— & 0x71 即 reduction poly 0x171:
a1 = ((unsigned int)(char)v3 >> 7) & 0x71 ^ (2 * v3);
MixColumns 系数(sub_A6F20)— [6,3,5,2] 循环矩阵:

v9[0] = gf_mul(v10,6) ^ gf_mul(v11,3) ^ gf_mul(v12,5) ^ gf_mul(v13,2);
v9[1] = gf_mul(v10,2) ^ gf_mul(v11,6) ^ gf_mul(v12,3) ^ gf_mul(v13,5);
v9[2] = gf_mul(v10,5) ^ gf_mul(v11,2) ^ gf_mul(v12,6) ^ gf_mul(v13,3);
v9[3] = gf_mul(v10,3) ^ gf_mul(v11,5) ^ gf_mul(v12,2) ^ gf_mul(v13,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),直接读出字节置换:
v1=r[5]; v2=r[1]; v3=r[13];
r[13]=r[9]; r[9]=v1; r[5]=v2; r[1]=v3;
v4=r[6]; v5=r[10]; v6=r[14]; v7=r[2];
r[2]=v4; r[6]=v5; r[10]=v6; r[14]=v7;
v8=r[11]; v9=r[3]; v10=r[15]; v11=r[7];
r[3]=v8; r[11]=v9; r[7]=v10; r[15]=v11;
RoundMix(sub_A8F00)— CFF 混淆,通过 Unicorn 差分提取:
def roundmix(state, round_num):
v = (71 - 99 * round_num) & 0xFF
prng = []
for _ in range(16):
prng.append(v)
v = (47 - 61 * v) & 0xFF
old = list(state)
for j in range(16):
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_buffer 中 MOV W25, #0xB6(地址 0x147D4A8),在解压后修改 _process() 中的 _m3(_d) 调用为 _w7(_d)。
if (*(uint32_t*)(buf + 0x1CD4) == 0x00003B8C) {
buf[0x1CD5] = 0x29;
}
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):

C++ 核心实现(part2_aes.cpp,~300 行):
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;
b >>= 1;
}
return p;
}
static const uint8_t MIX[4][4] = {
{6,3,5,2}, {2,6,3,5}, {5,2,6,3}, {3,5,2,6}
};
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);
}
}
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];
}
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];
}
static void aes_encrypt_block(uint8_t s[16]) {
init_transform(s);
add_round_key(s, 0);
for (int r = 1; r <= 11; r++) {
sub_bytes(s);
roundmix(s, r);
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>
七、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 无法静态追踪)。

结论: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_md5 且 pt[:4] == b"RSRC"。
重加密部署:
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
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_9F4EC("Process", "Process", 0x4A85115FAA17D83FLL, 8);
sub_9B5E8(v30, sub_97704, 0);
sub_97358("Tick", "Tick", 0x97A36EF7A74F5E4ELL, 5);
sub_9610C(v27, sub_9AD68, 0);
sub_9CB50("input", "input", 0x7B88A8A1801FE656LL, 6);
PART3 完整执行架构
┌──────────────────────────────────────────────────────────┐
│ 后台线程 sub_9B7D8:完整性校验(详见 4.4) │
│ │
│ exit 反 hook + maps 段定位 + CRC32 完整性心跳 │
│ CRC32 不通过 → exit_group 杀进程 │
│ │
│ ★ sub_A9A7C 通过 GDExtension 虚表间接调用 │
│ (静态二进制中 0 xref,IDA 无法追踪) │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 每帧调用:GDScript _process() → _gx.Tick() │
│ │
│ sub_9AD68 (Tick handler) — 仅做反调试计时: │
│ ├─ clock_gettime(CLOCK_MONOTONIC) │
│ ├─ 首次调用:记录初始时间 → data_1834b8 │
│ └─ 后续调用:检查帧间隔 │
│ ├─ > 10秒 → return(反调试:暂停过久则停止) │
│ └─ ≤ 10秒 → 正常返回 │
│ ⚠ Tick 不触发 PART3 计算! │
└──────────────────────────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────┐
│ 碰撞触发(GDExtension 虚表隐藏路径) │
│ │
│ 车碰撞 trigger4 (Area3D) → Godot 物理引擎 │
│ → notification_func / get_virtual_func 回调 │
│ → (虚表间接调用,静态 0 xref) → sub_A9A7C(token) │
│ │
│ sub_A9A7C (35-case CFF 状态机,IDA 反编译见下图): │
│ ├─ mutex_lock │
│ ├─ 检查 qword_183498 != 0(缓冲区已分配?) │
│ ├─ memcpy token(8B) → byte_1836C0 │
│ ├─ socket (port 5414 = 0x1526) │
│ ├─ sub_1371F8: VM 初始化 (malloc 0x2030) │
│ ├─ sub_1374A0 注册 VM handlers: │
│ │ ├─ cmd 101: VMEntry — 提供 token 字节 │
│ │ └─ cmd 102: sub_AA758 — 格式化 %08x%08x │
│ ├─ sub_13CD90: VM dispatcher 执行循环 │
│ │ └─ sub_13C67C: 56-case CFF 线性 PC 解释器 │
│ │ → 执行 TEA 变种算法 │
│ ├─ 清理: sub_13D374, sub_13C5C8, sub_137360 │
│ ├─ mutex_unlock │
│ └─ return &unk_1836E0 (flag string, 16 hex) │
└──────────────────────────────────────────────────────────┘
│
▼
flag{sec2026_PART3_XXXXXXXXXXXXXXXX}
(16 hex, 两个 u32: %08x%08x)
7.4 VM 算法提取:Unicorn 差分 trace
面对 56-case 解释器 + CFF 混淆,直接静态分析不现实。采用 Unicorn 模拟 + 差分 taint 方案:
关键前提验证:5 组不同输入的指令执行数均为 20,019,435 条——控制流完全数据无关,算法是固定顺序的算术操作序列。
差分方法:
- 对两组仅 1 字节不同的输入分别执行,挂
UC_HOOK_MEM_WRITE 记录全部写入
- 对比同一位置的写入值差异,定位输入敏感的内存 slot(约 10 个)
- 追踪关键 slot 的值变化序列,识别轮结构
性能优化:初版用 Python 回调实现 libc 函数,每 token 约 30s。改用 Keystone 生成 ARM64 原生 stub(memcpy/memset/strlen 等),降至 ~0.5s/token(60x 加速)。
产物: vm_fast.py (Unicorn 模拟器), vm_trace.py (差分 trace)
7.5 算法还原过程
Step 1 — 差分 taint 定位输入敏感地址
挂载 UC_HOOK_MEM_WRITE,记录全部写入 (地址, 值)。两组仅首字节不同的输入对比后,约 1400 万次写入中 ~5000 处值不同,聚类后集中在 10 个 slot:
| 分类 |
地址 |
用途(后续确认) |
| 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 + 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 = 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)) & M,hi = v0,token = pack('<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)
for r in range(2, 29):
sum += delta
v1 += ((v0<<4)+key[1]) ^ (v0+sum) ^ ((v0>>7)+key[2])
v0 += ((v1<<6)+key[3]) ^ (v1+sum) ^ ((v1>>5)+0xaabbccdd)
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)
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):

C++ 核心实现(part3_tea.cpp):
命名约定:C++ 中 KEY[0]=delta=0x29e59c9f,MAGIC=0xaabbccdd;VM 反编译器中 key[0]=0xaabbccdd(7.6 表采用 VM 约定)。两套索引不同,常量值一致。
static const uint32_t KEY[4] = {0x29e59c9f, 0xf95d664a, 0x12aa364c, 0x33ad3cee};
static const uint32_t MAGIC = 0xaabbccdd;
static const uint32_t DELTA = KEY[0];
static const int ROUNDS = 28;
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]);
}
static void encrypt(uint32_t& v0, uint32_t& v1) {
uint32_t s = DELTA;
v0 += f_v0(v1, s);
for (int i = 1; i < ROUNDS; i++) {
s += DELTA;
v1 += f_v1(v0, s);
v0 += f_v0(v1, s);
}
}
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);
}
static void token_to_state(const uint8_t token[8], uint32_t& v0, uint32_t& v1) {
v0 = le32(token + 4);
v1 = f_v1(v0, DELTA) + le32(token);
}
产物: part3_tea.cpp (C++ 加密+解密), part3_tea.py (Python)
运行: flag_tool.exe encrypt <token> / flag_tool.exe decrypt 3 <hex>
八、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_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 结果格式化):
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);
loc_1385BC(a1, a2, ctx + 24, &v0, 8);
sub_A9664("%08x%08x", ...);
sub_AA6AC(&unk_1836E0, 32, "%08x%08x", v1, v0);
}
VMEntry(VM cmd 101: 提供 token 字节):
int64_t VMEntry(a1, a2, a3, a4, a5, a6) {
uint32_t idx = *v8;
if (idx >= a6)
*v7 = 0;
else
*v7 = byte_1836C0[idx];
sub_13879C(a1, a2, ctx + 8*idx + 16, &v30, 8);
*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 状态(地址→语义):
| 状态地址 |
功能 |
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 的核心路径:
- 读 opcode:
*arg3 = 0x0100 → switch 到 ADD 分支
- 读源操作数:
reg_read(regfile, op1_slot, &val1), reg_read(regfile, op2_slot, &val2)
- 计算:
result = val1 + val2
- 写目标:
reg_write(regfile, dst_slot, result)
Family3(传送,sub_139D70)的 PUSH/POP 操作 VM 栈指针(reg[0x10]),每次移动 8 字节。LOAD/STORE 通过 mem_read/mem_write 访问 VM 内存。
Family selector — sub_13C430
sub_13C430 读 *(bytecode_ptr + 1)(opcode 高字节),映射到 family:
0 → Family0 (0x138BBC) 5,6,7,8 → Family5 (0x13AC3C)
1 → Family1 (0x139060) 9 → Family6 (0x13BBD4)
2 → Family2 (0x139A44)
3 → Family3 (0x139D70) 调用方式: (*descriptor->handler)(desc, host, bytecode, ctx)
4 → Family4 (0x13A440)
Family handler 表
7 个 Family handler 按 opcode 高字节分派:
| Family |
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_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 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,
.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,
.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,
ADD r0, acc // r0 = r11 + delta
...
MOVALT lr,
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,
; .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 源码说明
9.3 全部交付物索引
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 9小时前
被a'ゞCicada编辑
,原因: