零音以林曦格(SigAurelia)的名义在 HGAME 2026 中完成了部分题目。由于时间仓促,零音并未做完所有题目,下面的 writeup 仅包括零音成功完成的题目。
签到 / SignIn
README
hgame{Now-I-kn0w-how-to-subm1t-my-fl4gs!}
由题目所给的内容,显然。
1 | I am the flag! |
TEST NC
hgame{y0UR-CAN-ConNEct_T0_thE_remOTE_eNVIr0nm3nt_t0_GeT-f1Ag0}
使用 nc 连接到题目给出的目标服务器即可。使用 ls 列举服务器下的文件,使用 cat 获取 flag 的内容。
二进制漏洞审计 / Pwn
Heap1sEz
hgame{R3Ady_F0R_MOre_DIfF1cU1T-M4Iloc?52f20e}
程序的 malloc.c 代码完全替代了 glibc 的逻辑,自实现了一套 malloc 来管理通过 sbrk 得到的内存。其逻辑比现代 glibc 的 malloc 简单得多,且包含了一些早期的漏洞。
在 main.c 的 delete() 函数中,程序从笔记本删除一页(即 free() 掉这一页)后,并没有将对应的 notes[index] 置 NULL,从而给了我们 Use After Free 的可能。我们可以在 delete 掉一页后 edit 或 show 这个已经释放的 chunk。从而利用 UAF,我们可以获得 fd 指针的内容,进一步计算出程序的基址。
1 | void delete() |
为达成这一点,考察 malloc.c 的 free() 代码片段,倘使我们仅申请 1 个 chunk 并释放,则因为它紧挨 Top Chunk 从而造成 Top Chunk Consolidation。
1 | if (nextchunk != main_arena.top) |
为了避免这一点并确保 chunk 进入 Unsorted Bin 双向链表,我们建立 Guard Chunk,即申请 2 个 chunk 并释放先申请的 chunk,使得后申请的 chunk 隔开 Top Chunk 和先申请的 chunk。此时即可打印出 fd 指针的值。下面的截图展示了与程序交互以获得该值的最后一步操作。
在 malloc.c 的 unlink_chunk() 函数中,现代 glibc 中用于防御 Unlink Attack 的核心检查被注释,即,我们可以使用 Unlink Attack 实现任意地址写。
1 | static void unlink_chunk(mchunkptr p) |
在 main.c 中存在一个 gift() 函数:
1 | void gift() |
而 malloc.c 的 free() 函数开头,有
1 | if (__builtin_expect(hook != NULL, 0)) |
在获得 libc 基址后,我们可以确定 system() 的地址。若可调用 gift() 函数,则可将 hook 设置为 system(),随后释放一个内容为 /bin/sh 的 chunk,即可调用 (*hook)(mem); 成功 getshell。从而我们确定了大致的攻击步骤:
- 申请两个 chunk,记作 chunk 0 和 chunk 1。释放 chunk 0 后查询其内容,以获得
fd指针的值——它指向main_arena结构体中特定的偏移位置(即 Unsorted Bin 的链表头地址),考虑到bck = bin_at(&main_arena, 1);从而&main_arena = leak_addr + 8;,由此计算出 PIE。 - 使用 PIE 计算出
notes数组本身的地址。使用 UAF 修改 chunk 0 的fd和bk,从而使得notes[0]存储的指针指向notes[0]向前0x18字节的位置。考察栈上内存空间的分布,我们 padding 24 个字节后写入puts(先前已被调用)的 GOT 地址,随后show该 chunk 的内容以获得puts的 libc 地址,以获得 libc 基址。 - 由 libc 基址计算出
system()的地址,调用菜单的gift选项以设置hook为system()的地址。申请一个新的 chunk,更改其内容为/bin/sh并释放,以触发 hook。
下面给出一个可行的 Exploit。
Exploit 脚本
1 | """ |
逆向工程 / Reverse
PVZ
hgame{BECAUSE_I_AM_CRAAAZY}
使用 Detect It Easy 工具查看,发现 gpvz.exe 文件是 Java 程序的封装壳,其 .jar 位于文件后侧。
使用 dd 提取出该 .jar 文件,并拖入 JADX GUI 分析。
1 | dd if=gpvz.exe of=extracted.jar bs=1 skip=$((0x10a00)) count=$((0x032a389b)) |
注意到 addScreen(GameScreen.class, new GameScreen(this)); 易得主逻辑位于 GameScreen,跟踪进入。注意到 spawnZombie() 方法中的逻辑:
1 | if (TOTAL_GAME_DURATION - this.gameTime < 30.0f) { |
即,当游戏时间少于 30 秒时,新生成的僵尸血量极高,从而常规攻击无法成功击败僵尸。不难发现,在每次 placePlant 和 removePlant 时,总会调用一个名为 updateHashReversedCheck() 的私有方法:
1 | /* JADX INFO: Access modifiers changed from: private */ |
即,它检查 lawnGroup.II() 的返回值是否匹配一组特定的 long 型哈希值,如果是,则循环为僵尸施加 9,999 的伤害,使得玩家胜利。考虑到分析 lawnGroup.II() 所在的 defpackage.II1l 类过于复杂,我们直接跳转到游戏胜利后的 triggerVictory() 函数,不难发现它将进入 FlagScreen。
1 | /* JADX INFO: Access modifiers changed from: private */ |
分析后者代码,可以发现它大致进行了这些操作:
- 将输入的种子(
O()的结果)与常量hello相加,取低 32 位作为int输入,通过一个 LCG 生成 16 字节的密钥; - 与派生密钥 XOR 并加上基于索引的偏移后,将 26 字节的数组分为两半,分别与
xorKey1和xorKey2XOR; - 使用预设的
aesEncryptedKey进行循环 XOR 解密后,进行 Rotate Decrypt 和 Substitution Decrypt。
考虑到 FlagScreen 中解密所需的密钥空间仅为 16 位(即 0 到 65,535),我们直接通过暴力破解的方式得到某个格式为 flag{...} 的字符串,并将 flag 替换为 hgame 即可。下面给出一个暴力破解脚本。
暴力破解脚本
1 | def solve(): |
看不懂的华容道
hgame{c4a8ae149d34f8552875b87bb317ffa}
使用 IDA Pro 跟踪 start(),逐步跟踪后找到 main 函数 sub_1400116F4()。经过简要分析,程序中存在一个虚拟机。下面的代码进行了函数重命名以便于分析。
1 | // Hidden C++ exception states: #wind=1 |
其中 init_vm_context() 函数将传入的 a1 的 65,704 字节写零后,在偏移 136 处写入 FF00,在偏移 65,696 处写入 1。
1 | __int64 __fastcall init_vm_context(__int64 a1, __int64 a2, __int64 a3) |
load_arg_file 函数从传入的文件(这里,即 game.bin)中读取最多 32,768 字节的内容(事实上远大于 game.bin),并写入 a1 的 160 偏移处。
1 | __int64 __fastcall load_arg_file(__int64 a1, __int64 a2, __int64 a3) |
基于此,我们推测 a1 即为虚拟机上下文 VM_context。阅读 vm_main_logic 代码后,我们可以推测出该上下文的具体结构。
1 | struct VM_context { |
在 IDA Pro 的 Local Types 中加入该结构体,确定代码逻辑后,对每个 case 分析,即可得到 Opcode 对照表。
| Opcode | 助记符和声明 | 逻辑描述 |
|---|---|---|
0x15 |
INPUT n |
获取用户的输入,并存入 regs[n] |
0x16 |
RENDER |
取 mem[0:9] 为棋子坐标,渲染得到棋盘,存入 mem[80:100] |
0x17 |
PRINT |
打印 regs[9] 和 regs[8] 的值 |
0x18 |
CALC_MD5_R8_R9 |
读取内存中的数据,进行加盐的哈希运算 |
0xA0 |
MOV dst, src |
regs[dst] = regs[src] |
0xA1 |
LDI dst, src(low, high) |
加载 16 位立即数 regs[dst] = regs[src] |
0xB0 |
LDM dst, src |
从内存加载字节 regs[dst] = mem[LOBYTE(regs[src])] |
0xB1 |
STM dst, src |
存储字节到内存 mem[LOBYTE(regs[dst])] = regs[src] |
0xC0 |
NAND dst, src1, src2 |
regs[dst] = ~(regs[src1] & regs[src2]) |
0xC1 |
SHL dst, src1, src2 |
regs[dst] = src1 << src2 |
0xC2 |
SHR dst, src1, src2 |
regs[dst] = src1 >> src2 |
0xD0 |
ADD dst, src1, src2 |
regs[dst] = src1 + src2 |
0xD1 |
XOR dst, src1, src2 |
regs[dst] = src1 ^ src2 |
0xD2 |
SUB dst, src1, src2 |
regs[dst] = src1 - src2 |
0xE0 |
JMP offset |
PC += offset(PC 自增 2 后) |
0xE1 |
JE offset |
(flags == 1)相等时跳转 PC += offset(PC 自增 2 后) |
0xE2 |
JNE offset |
(flags == 0)不相等时跳转 PC += offset(PC 自增 2 后) |
0xE3 |
CMP src_1, src_2 |
flags = src_1 == src_2,这里的 src_1 和 src_2 各占半字节 |
0xFF |
HALT |
停止虚拟机 |
从而,我们将目光转向 game.bin 文件。该文件共 820 字节。根据对照表,不难写出 disassembler.py 进行反汇编。下面给出反编译后的结果。
反编译结果
使用 Gemini 3.0 Pro 为该反汇编结果加入了注释,以方便理解。
1 | ; ============================================================= |
事实上,该 game.bin 实现了一个华容道模拟器,循环请求用户输入 nx 格式的字符串,表示将编号为 n 的棋子向 x 方向移动(x 为 w、a、s 或 d)。模拟器进行边界检测以判定移动合理性,若可以移动,则计算移动后棋盘对应的哈希值(可能是 MD5 值)并输出。
根据题目描述:
flag内容为最短路径下的终点对应的节点值
操作路径顺序按照棋子编号从小到大 操作顺序wasd
我们推测,我们需要使用 BFS 寻找一条华容道路径,它遵循这样的优先级顺序:
- 按照编号升序的顺序移动棋子,即,移动
0优先于移动1; - 对一个棋子按照
w,a,s,d的顺序移动,即,上移优先于左移。
从而编写 BFS 脚本即可得到移动序列。下面给出一个可行的脚本:
BFS 脚本
1 | from collections import deque |
下面给出运算结果:
1 | 4s 5d 1s 7d 6s 1s 0a 2a 3w 3w 9w 9w 4d 7d 6d 1s 5a 2s 2s 3a 9w 4w 7d 2s 3s 9w 4w 7w 9a 4w 7w 2d 3s 3s 7a 7w 5d 1w 5d 6a 8s 1d 6w 8a 1s 5a 4s 5a 3w 9d 7w 3w 1d 6d 6s 5s 0s 7a 7a 9a 4w 2w 9a 3w 1w 6d 6d 8d 8d 5s 0s 7s 9a 3a 1w 1w 0d 7s 7s 9s 9s 3a 1a 4a 2w 2w 0d 7d 7w 5w 8a 6a 8a 6a 0s 7d 7d 9d 9d 5w 6w 6a 0a |
与程序交互后,将移动序列中的 编号-方向 组合逐个输入,即可得到最终的值。为便捷,可以使用 PowerShell:
1 | $moves = "4s 5d 1s 7d 6s 1s 0a 2a 3w 3w 9w 9w 4d 7d 6d 1s 5a 2s 2s 3a 9w 4w 7d 2s 3s 9w 4w 7w 9a 4w 7w 2d 3s 3s 7a 7w 5d 1w 5d 6a 8s 1d 6w 8a 1s 5a 4s 5a 3w 9d 7w 3w 1d 6d 6s 5s 0s 7a 7a 9a 4w 2w 9a 3w 1w 6d 6d 8d 8d 5s 0s 7s 9a 3a 1w 1w 0d 7s 7s 9s 9s 3a 1a 4a 2w 2w 0d 7d 7w 5w 8a 6a 8a 6a 0s 7d 7d 9d 9d 5w 6w 6a 0a" |
NonceSense
Client.exe 读取用户的输入,并对输入的每一个字符进行了变换,随后调用 CreateFileW 打开驱动设备 \\.\GATE_Driver,发送 IOCTL 0x222000,从驱动获取 16 个字节的数据 OutBuffer(称为 Nonce),最终将变换后的用户输入和 Nonce 打包,并发送 IOCTL 0x222004 给驱动,将返回的数据写入 Drv_blob.bin。由此我们推测,附件给出的 Drv_blob.bin 是用户输入为正确 Flag 时的最终产物。
处理用户输入的变换逻辑使用了一个虚拟机,对用户的输入逐字节地依照 unk_1400043F3 的内容变换。仿照上题,我们不加说明地给出该虚拟机的构造:
1 | 00000000 struct VM_Instruction // sizeof=0x3 |
在 unk_1400043F3 固件中定义的 Opcode 是程序运行时实际处理的 Opcode 加 1 的结果。下表的 Opcode 均为 1 字节,取固件中定义的 Opcode。
| Opcode | 助记符和声明 | 逻辑描述 |
|---|---|---|
0x1 |
MOV dst, src |
Reg[dst] = Reg[src] |
0x2 |
XOR_reg dst, src |
Reg[dst] ^= Reg[src] |
0x3 |
XOR dst, imm |
Reg[dst] ^= imm |
0x4 |
ADD dst, imm |
Reg[dst] += imm |
0x5 |
MUL dst, imm |
Reg[dst] *= imm |
0x6 |
AND dst, imm |
Reg[dst] &= imm |
0x7 |
ROL dst, src |
Reg[dst] = ROL(Reg[dst], Reg[src] & 7) |
在 main 函数中,对用户输入的加密逻辑如下:
1 | for ( i = 0; i < input_size; ++i ) |
从而 Reg[0] 为 Buffer[i] 待处理的字符,Reg[1] 为下标 i,Reg[2] 和 Reg[3] 是临时寄存器。
考虑进入固件前,代码中的隐藏操作 n6 = 0; n2 = 2; v9 = 1; 使得 MOV Reg[2], Reg[1] 即 Reg[2] = i。随后进入由固件定义的虚拟机逻辑。
MUL Reg[2], 0x0D从而Reg[2] = i * 13;ADD Reg[2], 0xC3从而Reg[2] = (i * 13) + 195;XOR_reg Reg[0], Reg[2]从而Char = Char ^ ((i * 13) + 195);MOV Reg[3], 1从而Reg[3] = Reg[1] = 1;MUL Reg[3], 3从而Reg[3] = i * 3;ADD Reg[3], 1从而Reg[3] = (i * 3) + 1;AND Reg[3], 7从而Reg[3] = ((i * 3) + 1) & 7(取低 3 位);ROL Reg[0], Reg[3]从而Char = ROL(Char, ((i * 3) + 1) & 7)(循环左移);XOR Reg[0], 0x5A从而Char = Char ^ 0x5A。
总结:对于第 i 个字符 C,虚拟机进行了:
$$
\text{Result} = \operatorname{ROL}(C \oplus ((i \times 13) + 195), ((i \times 3 + 1) \& 7)) \oplus 0x5A
$$
使用 IDA Pro 进入 GateDriver.sys 的 DriverEntry,并寻找主函数 sub_14000154C。根据
1 | if ( v3 >= 0 ) |
寻找到 IRP_MJ_DEVICE_CONTROL 的处理函数 sub_1400010C0()。注意到大致逻辑后,分别查看驱动对两个 LowPart 的处理方法。当程序发送 IOCTL 0x222000 时:
1 | if ( LowPart != 0x222000 ) |
驱动返回 16 字节的内容,这即为前文提到的 Nonce。当程序发送 IOCTL 0x222004 时:
1 | n32 = 0; v17 = 0; |
驱动确保已向程序给出 Nonce 后(类似于检测验证码是否已发送),使用一个硬编码的表 byte_140003250 计算出一个 32 字节的数据 v39。下面的脚本模拟了该计算过程。
1 | def ror(val, r_bits, max_bits=8): |
计算得出的结果为
1 | 56494441525f4847414d455f4433435f4133535f4b325f6275696c6432303236 |
注意到其转为 ASCII 码后的结果是
1 | VIDAR_HGAME_D3C_A3S_K2_build2026 |
随后继续初始化加密上下文。驱动调用 sub_1400019B8(FsContext, v39, 32, v36); 函数进行 HKDF (HMAC-based Extract-and-Expand Key Derivation Function),这含有 2 个阶段:
- Extract 阶段。
PRK = HMAC-Hash(Salt, IKM),其中Salt是 32 字节全 0 的数据,IKM是传入的FsContext(这即之前驱动生成的Nonce)。得到的p_Source1作下一阶段的 PRK。 - Expand 阶段。
OKM = HMAC-Hash(PRK, info + 0x01),其中PRK即上一轮计算出的p_Source1,info为在末尾追加了字节0x01的v39。得到的Source1为最终派生的密钥。
其中 HMAC-Hash 指 HMAC-SHA256 算法。完成后,计算结果被拷贝,使得 v36 指向计算结果的前 16 个字节。
随后驱动继续以 sub_140001908(v36, v40, n16_3); 对 v36 进行 AES-128 算法的密钥扩展,将 16 字节的密钥扩展为 176 字节的轮密钥。最终调用 sub_1400016B0 函数,进行 ECB 模式的 AES-128 加密。
1 | for ( NumberOfBytes_4 = 0; NumberOfBytes_4 < NumberOfBytes_2; NumberOfBytes_4 += 16 ) |
驱动将 16 个字节的 Nonce 和由上述加密方法得到的最终密文拼接并返回给程序,程序接收并保存为 Drv_blob.bin。
我们遵循这样的方法解密:从 Drv_blob.bin 中提取 Nonce 和 Ciphertext 后,参照驱动的 HKDF 方法得到 AES Key,并使用该 Key 和虚拟机的逆逻辑还原 Ciphertext。下面给出最终解密脚本。
解密脚本
1 | import hmac |




