输入屏幕左上角 8 位 hex Token,输出 3 个 flag。完整工具:flag_tool.exe encrypt <token>
以 Token a2d576a6 为例:
产物: flag_tool.exe 源码 — 支持 encrypt / decrypt / verify,16 组测试向量全通过
APK 解包后观察到 assets/assets.sparsepck、lib/arm64-v8a/libgodot_android.so,结合 AndroidManifest 中的 com.godot.game 包名,确认引擎为 Godot 4.5(开源引擎)。
关键二进制:
PCK 资源经 AES-256-CFB128 加密。IDA 中追踪密钥的完整链路:
① 定位 open_and_parse:搜索 RTTI 字符串 "19FileAccessEncrypted"(地址 0x945101),经 typeinfo xref 定位到 sub_38013D0(= FileAccessEncrypted::open_and_parse,9 处调用)。
② 确认 AES 调用:sub_38013D0 内部的 AES 初始化序列:
③ 追踪密钥来源:this+352 的 key 由调用者传入。反查 xref,sub_3804BEC(xref 0x3804F08)和 sub_3805E9C(xref 0x3806140)均通过逐字节循环从 byte_400EF18 复制密钥:
④ 读取密钥:IDA 中直接读取 byte_400EF18,32 字节即为 AES-256 密钥:
APK 解包后 assets/ 目录下的 .gdc / .scn 均为加密文件,格式:
用 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()
标准 gdre_tools 反编译输出乱码——关键字错位(if 变成 match,func 变成 signal 等),说明自定义引擎对 GDScript tokenizer 的 token ID 做了重映射。
对照 Godot 4.5 开源代码 modules/gdscript/gdscript_tokenizer.h 的标准 GDSC 格式,定位到两处自定义修改:
标识符 UTF-32 LE 每字节 XOR 0xB6(解密后得到合法 ASCII 变量名)。Token 流每项 4/8 字节,word1[0:7] = token type(重映射 ID),word1[8:] = pool index,word2 = 行号(可选)。
从最简单的脚本(label2.gdc、token.gdc)开始,利用 GDScript 语法结构逐步推断映射:
共恢复 35+ 个映射(部分摘录):
由于 token 枚举表内联在引擎解释器中,未以独立数据结构存在,无法直接从二进制提取,改用上述语义推断法逐一恢复。完整映射表见 parse_gdc.py。
所有 10 个 .gdc 均成功反编译为可读 GDScript。
产物: solve/godot/parse_gdc.py
反编译后可读出完整 flag 生成逻辑。完整反编译源码见 solve/decompiled/Trigger/。
trigger2.gd — PART1(纯 GDScript Feistel):
碰撞回调 _w7 读取 Token,调用 _fe() 做 8 轮 Feistel 加密,密钥 "Sec2026_Godot",输出 flag。算法完全在 GDScript 中,无 native 参与:
trigger3.gd — PART2(调 native Process):
碰撞回调将 Token 的 UTF-8 字节(注意:不是 hex→bytes,是 ASCII 直传)传给 GameExtension.Process(),由 libsec2026.so 的自定义 AES 加密:
trigger4.gd — PART3(完全 native):
没有 body_entered.connect(),碰撞不由 GDScript 处理。GDScript 侧仅有 _gx.Tick() 每帧调用(后续逆向证实 Tick 仅做反剥离计时,见 4.3)。真正的 flag 生成由碰撞触发的 native 隐藏回调完成(见第七章 7.3):
IDA 打开 libsec2026.so 后,搜不到任何明文字符串("Process"、"Tick"、"ClassDB" 等均不存在)。观察到大量函数具有相同的 prologue 特征(FFC300D1 E01700F9 E11300F9 E20F00F9 E31700B9),其反编译结果为 XOR 解密循环,将 .rodata 密文写入 .bss 段:
两种变体:
编写 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)。
libsec2026.so 全部函数均被 CFF(Control Flow Flattening)混淆,存在两种变体。
用于辅助函数(字符串解密、反调试等)。所有基本块共享一个中央 dispatcher,state 是 32-bit 编码 hash,经 EOR+ADD 解码后通过 CMP 二叉搜索树匹配目标块:
IDA 则需手动解析 hash 解码 + CMP 树。
用于核心加密函数(AES sub_A936C、TEA sub_A9A7C 等,共 ~136 个)。没有中央 dispatcher,每个块后内联一套两级查找:
两级查找的反分析效果:
内联派发消除了集中 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 状态机可视化):

直接 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:
后台线程循环扫描 /proc/self/task/*/status,搜索线程名 gum-js-loop(Frida)/ gmain(GLib),检测到则 exit_group(0)。
绕过:Hook openat,当路径含 /proc/self/task 时替换为无效路径。
扫描 /proc/self/fd,readlinkat 读取每个 fd 的符号链接目标,搜索 linjector。
绕过:同上,Hook openat 拦截 /proc/self/fd。
通过逆向 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(心跳时间戳):
校验结果不匹配 → exit_group(0) 杀进程。
读端 — Tick handler(sub_9AD68):
每帧读取 data_1834b8,与当前时间比对:
互锁设计:
绕过:不 kill 检测线程,采用数据层 Patch 不改 .text 段 → CRC32 自然通过 → 心跳持续。
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):
循环阶段(每 3 秒):
绕过:采用数据层 Patch(修改 .scn 场景资源)而非代码层 Patch,完全不改 .text 段。不 hook exit,不改内存权限,CRC32 校验自然通过,心跳持续。
sub_9C654 使用 fork + ptrace 自附加,实现三层反调试:
绕过:Frida spawn 模式不依赖 ptrace 和硬件断点,不受影响。
属于 T3 线程 sub_9B7D8 状态机的一个分支(case 10 → case 8 → case 1)。与 4.1 的 /proc/self/task 线程名扫描不同,这里扫描 /proc/self/maps 寻找 Frida 注入痕迹:
核心检测在 sub_98564(maps 行解析器):
反分析手段:① raw syscall 绕过 LD_PRELOAD/libc hook ② 逐字节读取绕过缓冲区拦截 ③ 关键字 XOR 加密 ④ CFF 状态机 ~19 个 case 混淆控制流
绕过:数据层 Patch 不注入任何 .so,maps 中无异常条目。
沙箱 trace 发现 libgodot_android.so 中的 sub_10B9E4C 每帧检查 libsec2026.so 的 .text 段(不是自身)——形成交叉校验,两个库互相守护:
设计精妙之处:
绕过:数据层 Patch 不修改任何 .text 段,校验和自然通过。若必须 Patch .text,需同步修改 libgodot 中的期望值 dword_400A050。
T1=sub_9C654, T2=sub_9CDC4, T3=sub_9B7D8
统一绕过策略:采用数据层 Patch(修改 .scn 场景资源触发 flag 计算),不修改任何 .text 段代码、不 hook 任何函数、不 kill 任何检测线程。9 项检测全部自然通过。
反编译 trigger2.gdc,_w7 回调中包含完整加密代码(纯 GDScript,不涉及 native)。
8 轮 Feistel 网络,输入 4 字节(Token hex→bytes),分组 2+2 字节:
trigger2 需要碰撞触发(3D 场景中撞到 Trigger2),可通过 Hook GDScriptTokenizerBuffer::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)。
运行结果:Token 8dce44a5,输出 flag{sec2026_PART1_154ca922} ✅
真机验证(MuMu 模拟器,Token 8dce44a5,碰撞 Trigger2 后显示 flag):

C++ 核心实现(part1_feistel.cpp):
产物: part1_feistel.cpp (C++ 加密+解密), flag.py (Python)
运行: flag_tool.exe encrypt <token> / flag_tool.exe decrypt 1 <hex>
反编译 trigger3.gdc,_w7 回调将 Token 的 UTF-8 字节传入 GameExtension.Process(),返回 32 字符 hex 拼为 flag:
CFF 去混淆后追踪 Process() handler(sub_97704)→ sub_A936C。

以下为 CFF 状态机精简后的等价伪代码:
密钥扩展 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 变种后,逐一定位每个非标准参数:
参数提取:CFF 去混淆后从 IDA 伪代码逐一读取,以下为关键证据:
S-box 仿射变换(sub_A8C8C)— 常数 0x8F 和 ROL5 直接可见:

GF(2^8) 多项式(sub_A96F0)— & 0x71 即 reduction poly 0x171:
MixColumns 系数(sub_A6F20)— [6,3,5,2] 循环矩阵:

Rcon 表(byte_652C9,IDA hex dump):
ShiftRows 置换(sub_AADE8)— IDA 完整反编译(无 CFF),直接读出字节置换:
RoundMix(sub_A8F00)— CFF 混淆,通过 Unicorn 差分提取:
InitTransform / FinalTransform — CFF 混淆,但引用的数据地址可直接读取:
常量(从 libsec2026.so 提取):
InvMixColumns:正向矩阵 [6,3,5,2] 在 GF(2^8, 0x171) 上的逆矩阵通过高斯消元法求解(part2_aes.py _compute_inv_mix_matrix()),结果为 [0x80, 0xf3, 0x64, 0xaf] 循环矩阵。可逆性由 GF(2^8) 的域性质保证(非零行列式)。
完整解密流程(严格逆序):
与 PART1 相同方法:Hook set_code_buffer 中 MOV W25, #0xB6(地址 0x147D4A8),在解压后修改 _process() 中的 _m3(_d) 调用为 _w7(_d)。
Patch 对比表:
运行结果:Token 1af763af,输出 flag{sec2026_PART2_52f0ab0970da6f1d7c516d0813acc998} ✅
真机验证(Token 1af763af,通过字节码 Patch 自动触发,右上角显示 flag):

C++ 核心实现(part2_aes.cpp,~300 行):
产物: 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>
反编译 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 在游戏中既不可见也无法通过物理碰撞触发,必须修改场景才能触发。
解密 .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"。
重加密部署:
替换 APK 中的 assets/.godot/exported/133200997/export-...-town_scene.scn,重签名安装。
> 产物: patch_trigger4_scn.py, parse_scene.py
面对 56-case 解释器 + CFF 混淆,直接静态分析不现实。采用 Unicorn 模拟 + 差分 taint 方案:
关键前提验证:5 组不同输入的指令执行数均为 20,019,435 条——控制流完全数据无关,算法是固定顺序的算术操作序列。
差分方法:
性能优化:初版用 Python 回调实现 libc 函数,每 token 约 30s。改用 Keystone 生成 ARM64 原生 stub(memcpy/memset/strlen 等),降至 ~0.5s/token(60x 加速)。
产物: vm_fast.py (Unicorn 模拟器), vm_trace.py (差分 trace)
Step 1 — 差分 taint 定位输入敏感地址
挂载 UC_HOOK_MEM_WRITE,记录全部写入 (地址, 值)。两组仅首字节不同的输入对比后,约 1400 万次写入中 ~5000 处值不同,聚类后集中在 10 个 slot:
Step 2 — 轮数识别
追踪 0x80329e08(v0 输出 slot)的值变化序列:
29 次 transition = 初始值 + 28 轮更新。
Step 3 — 轮函数还原
3a. 初次追踪中间值:slot de8 的每次写入值,Round 1:
这看起来像 (A + K1) ^ (B) ^ (C + K2) 的 TEA 结构。
3b. 确认移位量(对多组输入交叉验证):
确认: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 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:
结论:delta = 0x29e59c9f(非 0xaabbccdd!后者是 v0 右项加数,即 7.6 表中的 key[0])。
Step 5 — 4 个候选模型淘汰赛
Step 6 — 输入映射
用 8 组单字节输入观察 v0/v1 初始值变化:
逆映射:lo = (v1 - f_v1(v0, delta)) & M,hi = v0,token = pack('<II', lo, hi)
常量:
与标准 TEA 差异:
加密/解密:
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 约定)。两套索引不同,常量值一致。
产物: part3_tea.cpp (C++ 加密+解密), part3_tea.py (Python)
运行: flag_tool.exe encrypt <token> / flag_tool.exe decrypt 3 <hex>
算法已在第七章通过 Unicorn 差分 trace 完全还原后,回过头来对 VM 本身进行完整逆向,构建了不依赖模拟器的纯静态反编译器,从原始字节码直接产出可读伪代码。
sub_A9A7C case 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 字节):
host_table (0x2030 字节):
FamilyDescriptor (40 字节),7 个连续存放在 .data:0x163948:
寄存器文件(通过 sub_118340 从 runtime 取出):
sub_AA758(VM cmd 102: PART3 结果格式化):
VMEntry(VM cmd 101: 提供 token 字节):
本质是标准线性 PC 解释器,但整体被 56-case CFF(控制流平坦化)混淆包裹,静态看起来极其复杂。IDA 完全无法反编译(SP 分析失败,仅输出 3 行),成功还原全部 56 个 CFF 状态。去掉 CFF 外壳后,每条指令的执行流程为:
反编译关键 CFF 状态(地址→语义):
handler 分派点在 ARM64 层为 BLR X8 @ 0x139684(动态反编译器 hook 的位置),调用前 X2 指向 112 字节指令描述符,SP+0xF0 为当前 bytecode PC。
Family handler 内部通过以下函数操作 VM 状态:
例如 Family1(算术,sub_139060)执行 ADD 的核心路径:
Family3(传送,sub_139D70)的 PUSH/POP 操作 VM 栈指针(reg[0x10]),每次移动 8 字节。LOAD/STORE 通过 mem_read/mem_write 访问 VM 内存。
sub_13C430 读 *(bytecode_ptr + 1)(opcode 高字节),映射到 family:
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 输出结果。
通过 Unicorn hook sub_13C67C 的 BLR X8(handler 分派点),截获每条指令执行前的 112 字节结构体,逆推编码格式:
示例 ADD acc, acc, tmp(.sbc:100AB):
验证方法:将推测的编码格式直接解码 .rodata:0x63DA0 处的 5414 字节原始数据,逐条与 Unicorn 动态 trace 的指令序列比对——684 条指令全部吻合,证明编码格式正确。
直接按偏移解码字节流,输出 IDA 风格汇编:
问题:684 条原始指令中大量 PUSH/POP/MOV 是寄存器保存/恢复,直接看完全不可读。
改为递归下降解码:从入口 0x10000 跟随控制流,遇到 JMP/条件跳转加入工作队列,跳过不可达代码。
函数识别模式:MOVALT lr, #ret_addr; JMP target = 函数调用(lr 保存返回地址)。以 JMP [lr] 或 HALT 为函数结束。识别出 5 个函数,其中 u32_truncate 被 18 处调用。
反编译器的核心是多模式识别,将固定的指令序列折叠为高级语句:
模式 A — u32 表达式块:7 个 PUSH(保存寄存器上下文)→ 算术/位运算序列 → CALL u32_truncate → 7 个 POP。整个序列折叠为一行 tN = u32(expr),其中 expr 通过栈式符号执行构建:
模式 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 等)。
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!
最后于 5天前
被a'ゞCicada编辑
,原因: