HGAME 2026wp
感谢hgame主办方,祝贺hgame越办越好,期待贴纸ing

借鉴了一下官方Writeup https://github.com/vidar-team/HGAME2026_Writeup
下方很多内容是ai的(比赛的时候用了很多ai拿分,可以直接忽略(,不过misc是赛后复盘全手写的,后续也会慢慢更新手写复盘,如果有写错的不好的麻烦私信联系我,我马上进行修改
签到
README

没什么好说的直接就把flag展现在面前了
hgame{Now-I-kn0w-how-to-subm1t-my-fl4gs!}
TEST NC

直接ncat连接即可
然后我们直接cat flag即可得到flag

明年见!

完成问卷即可
Misc
打好基础

拿到是一串emoji

很明显没有key,那就很可能只是base100

之后还是一串乱码,在尝试之后发现base92也能解,怀疑是多重base

放入随波逐流之后直接混合多重解码出了,让我一个个试我估计是想不出,能想出来应该也要试很久,或者就只能跑脚本爆破了
hgame{L4y_a_sO11d_f0unDaTi0n}
shiori不想找女友


拿到手是这样子两个文件

打开png很明显是一堆白点黑点,明显需要我们逐点采样,最后应该是一些黑字
1 | |

得到这样子一张图,不知道是不是主办方密码设错了,这个不是密码
试了试之后发现真正的密码是全小写的,this_is_a_key_for_u
然后就可以打开压缩包了

得到了这样子一个东西,外边看是黑的,点开来看是透明的
不难想到这是因为Alpha通道全是0,所以就全透明了
我们直接改这个通道就好了其实
1 | |
就能看见答案和照片了

hgame{bec0use_lilies_are_7he_b1st} (保护一下帅哥隐私)
[REDACTED]

题目的意思是有四段敏感的字符串需要找到

上来习惯性全选后即可发现明显这边有一块,在尝试后发现只需要提取文字即可获得其中内容
1 | |
所以第一部分就是PAR4D0X
接下来下边还有一块黑的,看一眼,同样可以选中其中的文字

可以看到这由三部分组成,符合JWT编码字符串,中间的内容很有可能就是Base64编码的JWT
我们尝试解码

成功得到第二部分,AllCl3arToPr0ceed
第三部分

第三部分很让人怀疑是在右下角这里,但是在图片里,这样子是不太可能直接选中文字提取出来,怀疑可能是一个通道没有正确呈现信息,而别的通道存在

果然在这里,至此我们得到了第三部分,只差第四部分了,但是pdf里边似乎直接看看不出,转战Winhex看看
分析发现PDF有两次startxref,即更新过一次,不是重写的


1 | |
发现旧版的Pages其实是有三页的,但第一页的1 0 R被删了,但对象本身还在文件,没删,只删了引用,这是PDF的增量式修改功能,基本原理就是每次更新没删内容,只更新了引用表
所以我们可以解压孤儿页内容流
内容流用的是/F1字体,所以可以用ToUnicode来翻译一下就好了
1 | |
成功得到4:D0cR3qu3st3r_Tutu
当然也可以直接恢复先前版本的引用表,用这个就很方便
安装好之后输入下方命令即可
1 | |

也能直接看到4:D0cR3qu3st3r_Tutu
所以最后拼一下就好了
hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}
(这题还蛮好玩的,很适合出在取证比赛吧,整一个只更新引用表的pdf)
Invest on Matrix

这是一场投资,很明显,我们必须去解锁一些hint才能做出这一道题,越少越好,毕竟这个hint都是要分的
大概是将二维码按从左到右、从上到下切成了 25 块,每条 hint 给出对应子块按行优先扁平化后的 25 个值
由于二维码特性,不用买完也能出的
然后我买了1,2,3,4,6,7,8,9,10,11,12,13,14,15,17,18,20,23,24,25,花了242 分
1 | |
得到最后的flag为hgame{W0RTH_1T?}
或许worth吧
赛后给出了原来的二维码,我放在下边了

Vidar Token
1 | |

读下题目先,大概翻译一下,说是链上数据可读,不用登陆也能从合约存储、事件等读到很多东西
还有就是估计flag和线索都不会直接明文来,hide住了
最后是REvenge,猜测是需要re逆向的
先开启环境看一看,像一个虚拟浏览器钱包界面

看似是说在非HTTPS环境无法直接连接,但是我用caddy改为https连接后依旧无变化
还是先查看一下源码

发现源码此处写了页面底部只有一个业务脚本被加载,就是这个app.js
一般网页的逻辑都会写在js里,这个页面没有加载别的js,只漏了这个app.js,所以我们跳过去看看,应该会有收获

在这边我们看到了,根本没有检测https环境,不管如何都会显示这一句话

核心逻辑在下方这个checkEligibility里,做的事情很具体
先fetch了一个k.wasm
再导出函数get_entrance()拿到指针
之后可以看到entranceAddress是从k.wasm里边正则提取出来的
1 | |
再用同源的RPC连接rpc

1 | |
最后通过代码审计还能看到代码只调用了await vault.tokenURI(0)这一个合约函数获得的数据
1 | |
到这边基本上可以确定是题目入口了,这边出现了合约地址、RPC、合约调用三件套,还和题目中的Punks and Coins collide.呼应,强烈暗示了NTF元数据,而我们的tokenURI正是ERC-721/1155常用的元数据入口
之后我们来获取一下这个entranceAddress,读取一下tokenURI
这边我们自然可以直接把 wasm 下载下来并从里面提取入口地址 ,但是相对来说麻烦很多
既然题目已经fetch了,我们其实可以直接利用console得到
1 | |

得到entrance,也就是第一个合约的入口地址
1 | |
再去调用tokenURI
1 | |

发现给了一串base64

解码得到json
1 | |
至此我们得到了下一跳,VidarCoin的合约地址0xc5273abfb36550090095b1edec019216ad21be6c
针对这一个地址,像是ERC20代币地址
所以我们可以直接把这个地址先当ERC20尝试读基本信息
1 | |

发现了symbol有往下一层解密的潜力,因为一般的symbol都是短字符串,而这边返回的是一串hex
“0x6960606a647c7458603172484d7255346d7e5e4c6f3776584455622a566672705e357b7d3634343430307c”

其实看上去似乎没什么乱码,怀疑是否有戏
我们跟着题目的flag头hgame{尝试XOR

1 | |
大概可以得到是0x01和0x07交替XOR
写一个简单脚本解密
1 | |
得到flag:hgame{u_a6sOLuT3ly_Kn0w_ERc-Wasw_2zz735317}

由于上述原因,题目设置的flag其实是hgame{u_a6sOLuT3ly_Kn0w_ERc_Wasw_2zz735317}
Crypto
Classic


乍一看就很像凯撒或者维吉尼亚,在尝试以hgame为密码之后发现的确如此

前边是可读的了,后边还不行,继续尝试维吉亚娜
第一次尝试提交后跟我说是以VIDAR{}包裹的一串flag,那我们就可以把JQABX和VIDAR比对一下

于是可以逆推,得到密钥OIXBG

成功得到flag
Flux

1 | |





babyRSA






1 | |

ezRSA




1 | |
ezDLP







1 | |
**hgame{1s_m@trix_d1p_rEal1y_sImpLe??}**
eezzDLP







1 | |
Decision





1 | |
hgame{w1sh_you_4_h@ppy_new_y3ar}
Web
魔理沙的魔法目录


打开来之后要求阅读一小时之上
那一定是存在检验时间的部分,我们抓包看看

发现周期性会存在一个record看看

发现就是计算时间的,我们time改大一点,发现后边变成了success
这个时候再去看网页

即可发现已经挑战成功
Pwn
Heap1sEz

1 | |
Reverse
PVZ

DIE看看,可以看到本质是ZIP啊,我们改后缀一下

可以看到游戏文件了
- 先确认:这玩意本质是“PE 外壳 + 里面塞了 Zip/JAR”
你截图里已经提示了 Zip(1.0)[JAR, ...]。直接把 exe 当 zip 解包即可:
1 | |
解出来会看到一堆 *.class 和资源文件(libGDX 项目结构)。
- “神秘仪式”的真实机制:摆阵哈希命中白名单就秒杀全场僵尸
用 javap 反编译字节码就能看清:
1 | |
在 GameScreen 里有一个隐藏检查(方法名类似 updateHashReversedCheck):
- 它会调用
lawnGroup.II(): long(在II1l/ LawnGroup 里),这是一个根据 9×5 草坪上每格植物类型计算出来的长整型哈希(扫描顺序是“反向”)。 - 程序内置了 10 个 long 常量组成的白名单:只要当前布阵算出来的哈希落在白名单里,就会:
- 屏幕抖动、闪字
WSDX - 对所有僵尸执行
receiveDamage(9999)(等价强制清场) - 然后安排胜利结算(进入 FlagScreen)
- 屏幕抖动、闪字
也就是说,“最后一波刀枪不入”只是剧情说法;真正通关开关是:布阵哈希要命中那 10 个值之一。
- FlagScreen 的“解密”核心:实际上只剩 16-bit 种子,可直接暴力 65536
继续反编译:
1 | |
关键点:
triggerVictory()进入FlagScreen(game, lawnGroup.O())lawnGroup.O(): long是**另一套扫描顺序(正向)**的布阵哈希,用来当作所谓的zombieKillCount
FlagScreen.decryptFlag()会把一个固定的 26 字节密文killCountEncryptedFlag做多层 XOR/替换/位移得到字符串,并校验必须形如flag{...}。- 表面上看 key 来自
hello + zombieKillCount,但实际deriveKeyFromKillCount()里把关键种子 取模 65536,并用一个 LCG 生成 16 字节 key。 - 由于后续根本只依赖这个 16-bit 种子(0..65535),所以不需要知道你怎么摆阵、也不需要知道
zombieKillCount,直接爆破 65536 次就能出唯一合法 flag。
1 | |
看不懂的华容道
1)程序每一步需要输入 两字符:
- 第 1 个字符:棋子编号
0~9 - 第 2 个字符:方向
w/a/s/d
无效移动(例如 7w、7s 在当前局面不可走)不会刷新节点值;只有合法移动才会打印新的 32 位 hex 节点值。你截图里 7d 合法,所以节点值变化。
- 你的初始局面已从
game.bin还原出来(与程序一致)
这是标准 4×5(20 格),10 个棋子编号 0~9,空格 2 个。初始棋盘(按格子内容)为:
1 | |
说明:
- 0 是 2×2 大块(曹操)
- 1~4 是竖块(1×2)
- 5 是横块(2×1)
- 6~9 是小块(1×1)
.是空格
这也解释了你 7d:棋子 7 在左下角,右边是空格,所以能右移。
- “最短路径”我已经按你给的枚举顺序算出来了
你给的最短路枚举规则:
- 棋子编号升序:
0→1→2→...→9 - 方向顺序:
w→a→s→d
我用这个规则在状态图上求得 到达胜利(0 号大块到出口位置) 的最短操作序列,共 103 步。
由于我无法在当前环境直接运行 huarongdao.exe,所以不能直接得出“终点节点值(32 hex)”。
但你可以把下面序列直接复制粘贴到程序里(逐行输入或一次性粘贴),最后一次打印出来的 32 hex 就是题目要的 flag 内容(终点节点值)。
- 最短操作序列(103 步)
每步格式:棋子编号 + 方向
前 30 步如下(用于确认你输入后节点值会持续变化):
1 | |
完整 103 步序列我可以按你需要的格式输出:
- A) 每步一行(适合粘贴进程序)
- B) 拼成一个长字符串(适合直接提交/记录)
- 你如何拿到最终 flag(终点节点值)
- 运行
huarongdao.exe - 从初始状态开始,按 103 步依次输入
- 走完后程序会打印最终的 32 位 hex
- 那一串就是 flag 内容

Signal Storm
- 先跑程序确认输入约束
1 | |
现象(你也能看到):
- 会提示输入
Enter flag: - 输入长度不对会直接输出
Wrong length. - 正确长度为 32 字节(汇编里比较
cmp rax, 0x20)
- 定位反调试:为什么“下断点秒出 flag”经常翻车
用 strings 很容易看到反调试线索:
1 | |
关键点在 .text 里(地址大概从 0x1220 开始):
fopen("/proc/self/status","r")- 循环
fgets找到行首"TracerPid:" strtol解析出 tracer pid(调试器 attach 时通常非 0)- 然后用 tracerpid 的低 8 位把 key 串逐字节 XOR 掉:
对应汇编片段(你用 objdump -d -Mintel signal_storm 能看到):
- 解析 tracerpid:
call strtol@plt - key 地址:
lea rdx, [rip+...] -> 0x4010 - XOR 循环:在
0x12c0左右反复xor BYTE PTR [rdx], al
结论:
- 正常运行:
TracerPid=0,key 不变 - 在调试器里下断点运行:
TracerPid!=0,key 被改坏,后续校验全变了
所以“下断点就拿到 flag”并不可靠,除非你先绕过这一段(例如 patch 或 hook)
- 识别程序的真实核心:用信号把一个 for 循环“拆碎”
程序注册了三个信号处理函数(sigaction):
SIGSEGV(11)→ 更新 RC4 状态(i/j、swap)SIGTRAP(5)→ 把 key 循环左移 1 字节SIGFPE(8)→ 生成 1 字节 keystream,XOR 到输入 buffer
主流程用 __sigsetjmp / __longjmp_chk 来回跳,并故意制造异常:
- 写 NULL 指针 → 触发
SIGSEGV raise(5)→ 触发SIGTRAP- 除 0 → 触发
SIGFPE
这样每一轮生成一个 keystream 字节并 XOR 一次,总共做 32 轮。
- 静态提取两份关键数据(不需要调试器)
4.1 key 在 .data
1 | |
你会看到:
C0lm_be4ore_7he_st0rm\0(长度 21)
4.2 目标密文(32 字节)在 .rodata(也会以 movabs 立即数出现在比较处)
1 | |
在 Enter flag: 字符串后面能看到 32 字节块(例如从 0x20a0 开始):
1 | |
程序最终是把你的输入经过变换后得到 32 字节结果,再和这 32 字节比较;所以我们反过来对这 32 字节做同样变换即可还原输入(flag)。
- 还原算法(等价的“正常 for 循环”)
把那套信号流简化成一轮做的事情:
i = (i + 1) mod 256j = (j + S[i] + key[i mod 21]) mod 256- swap
S[i]与S[j] - key 循环左移 1 字节
k = S[(S[i] + S[j]) mod 256]buf[pos] ^= k
初始化部分是标准 RC4 KSA:
j = (j + S[i] + key[i mod 21]) mod 256- swap
S[i]与S[j]
- 可执行解题脚本(自动从文件提取 key/cipher 并解出 flag)
保存为 solve.py:
1 | |
运行:
1 | |
输出就是正确 flag:
hgame{Null_c0lm_wi7hout_0_storm}
NonceSense
- 已知文件与目标
你给的三份文件:
Client.exe:用户态客户端(负责与驱动通信、并对明文做一次可逆混淆)GateDriver.sys:WDM 内核驱动(负责生成会话随机数、派生密钥、AES 加密并输出产物)Drv_blob.bin:某次会话的产物(即需要解密的密文)
目标:从 Drv_blob.bin 还原出明文(flag)。
- 静态分析总路线(为什么这样做是逻辑闭合的)
2.1 先看 Drv_blob.bin 结构(无需任何执行)
Drv_blob.bin 长度为 80 字节,最合理的划分方式是:
- 前 16 字节:会话随机
nonce - 后 64 字节:AES 密文(64=4×16 字节块)
这一步的验证非常直接:80 = 16 + 64,且 AES 块大小是 16。
2.2 从 GateDriver.sys 里还原“密钥派生 + AES 模式”
在驱动里定位到加密路径的常见入口:
DriverEntry-> 创建设备对象、符号链接MajorFunction[IRP_MJ_DEVICE_CONTROL]->DispatchDeviceControl之类switch(IOCTL)分支里有一条分支会:- 生成 16 字节随机数(nonce)
- 通过 HMAC-SHA256 两次派生出 AES-128 key
- 使用 AES-ECB 对数据加密(输出块对齐)
- 输出/拼装:nonce + ciphertext
你在 IDA/Ghidra 中通常能通过以下“证据”确认算法:
- SHA256 常量表(
0x6a09e667, 0xbb67ae85...等) - HMAC 结构(
ipad=0x36, opad=0x5c的 XOR 填充逻辑) - AES SBox / Rcon 常量表(或明显的 AES round 结构)
- ECB 特征:每 16 字节独立处理、没有 IV 链接/异或(区别于 CBC/CTR)
2.3 从 Client.exe 里确认“明文在进驱动前被混淆”
客户端一般会:
- 准备明文(flag)
- 对每个字节做一次可逆混淆(避免明文直接出现在驱动输入里)
- 通过
DeviceIoControl发给驱动 - 驱动对“混淆后的明文”做 AES 加密
所以解密时应当:
- AES 解密得到“混淆后的明文”
- 再把客户端那层混淆逆回来
客户端里这类混淆常见形态:
- 逐字节循环
- 使用索引
i混入线性项(如a*i+b) - 叠加 rotate/ror/rol
- 再 XOR 常量
- 关键还原结果(可以直接写成解密流程)
3.1 Drv_blob.bin 格式
1 | |
3.2 AES 参数
- AES-128
- 模式:ECB
- 填充:PKCS#7(解密后尾部
0x09重复 9 次即可验证)
3.3 密钥派生(驱动内实现,HKDF-like 的两次 HMAC-SHA256)
salt = 0x00 * 32info = b"VIDAR_HGAME_D3C_A3S_K2_build2026"(驱动里经常是常量表/拼接还原出来的这段 bytes)prk = HMAC_SHA256(key=salt, msg=nonce)okm = HMAC_SHA256(key=prk, msg=info + b"\x01")aes_key = okm[:16]
3.4 客户端混淆的逆变换(逐字节)
设 AES 解密并去填充后的每个字节为 y,逆得到真实明文字节 x:
t = (13*i + 195) & 0xffr = (3*i + 1) & 7x = ROR8(y ^ 0x5a, r) ^ t
其中 ROR8 表示 8-bit 右循环移位。
- 可执行解密脚本(直接跑出明文)
依赖:pycryptodome
安装:
1 | |
运行:
1 | |
solve.py
1 | |
- 解密输出(脚本跑出来的结果)
明文为:
1 | |
Androuge
题目提示:“至少……也要见到第一个Boss吧……”
附件是 androuge.zip,里面是一个 APK。
- 文件定位与初步判断
先解包:
1 | |
重点文件:
assets/waw(ELF,可执行)assets/game(疑似脚本/字节码资源)
assets/game 的第一个字节是 0x1b(很像 Lua chunk 头),但后续不正常,说明做了轻量混淆。
- 还原
assets/game
对 assets/game 从第 2 字节开始逐字节异或 0x9c,可还原出可解析的 chunk(头部为 \x1bWawT,把 Waw 改回 Lua 即可按 Lua chunk 解析)。
核心还原逻辑:
1 | |
- 从 chunk 中提取关键配置
解析后可拿到配置字段:
key_seed = 18target_floor = 100boss_interval = 5view_radius = 6
并且在主原型指令里能定位到一段连续 29 条“装载字节”序列(第二字节 + 1 即是密文字节),得到:
1 | |
- 解密逻辑
种子:
1 | |
逐字节解密:
1 | |
得到明文:
1 | |
- 一键复现脚本(可直接跑)
把下面保存为 solve.py,与 androuge.zip 放同目录执行。
1 | |
运行输出末尾应为:
1 | |
最终提交
**hgame{Wow_Y0u_Got_Th3_Yend0r}**
衔尾蛇
题目给了一个 ouroboros.zip,提示是“自运行代码 + 自指混淆”,并且提交格式是 flag{EXAMPLE}。
我这边完整复现后拿到的结果是:
**flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}**
- 初步分析:先拆包看结构
1 | |
可以看到几个关键点:
- Spring Boot 外层壳(
OuroborosApplication/TradeService/RiskPolicy) LogicSwapper会在运行时换掉策略逻辑- 有一个异常可疑文件:
BOOT-INF/classes/application-data.db
这类题一般是“外层 Java 壳 + 内层 payload/VM”。
- 外层核心:
ShadowLoader解密application-data.db
逆向 ShadowLoader.initContext() 可以得到解密流程(伪代码):
1 | |
也就是:
- 这是一个 LCG 流密码 XOR
- 明显
skip(128)后应该是被装进去的真实内容(通常是 JAR,开头应为PK\x03\x04)
- 反推 key:利用已知明文
PK\x03\x04
已知内层 JAR 开头特征:
- 偏移 128 处解密后应当是
50 4B 03 04
所以可以拿 enc[128:132] 与 PK\x03\x04 异或得到4个 keystream 字节,然后逆推 LCG 状态。
复现结果(我本地跑出来):
deriveKey & 0xFFFFFF = 0x40bec8- 可取一个完整 key:
0x7e40bec8 - 最关键低 8 位:
key_lsb = 0xc8
这里一个容易踩坑点:这个 keystream 只取 state>>16,所以某些位不影响观测字节,高 8 位可存在等价类。
但对本题而言,0x7e40bec8 能稳定解出正确内层 jar。
- 解出内层 JAR,定位
OuroborosVM
用上面的 key 解 application-data.db 后,截去前 128 字节即可得到内层 jar,结构里有:
com/seal/ouroboroscore/OuroborosVM.class
这个类里有静态固件数组 FIRMWARE(长度 375)。
运行前会再做一层:
1 | |
也就是每字节再 XOR 0xc8,得到真正字节码。
- VM 指令语义 + 还原校验逻辑
固件解开后,可以识别到核心 opcode(足够做题):
0x10:push 16-bit 立即数0x20:push 输入长度0x30:按索引取输入字符0x35:xor0x4A:条件跳转(栈顶为 0 才跳)0xFF:halt(最终栈顶为 1 才通过)
固件结构很规律:
- 先检查输入长度是否等于
0x21(33) - 然后反复出现块:
- 取
input[idx] - 与常量字符比较(通过 xor == 0 形式)
- 不等就 fail
- 取
- 全部通过后进入 success 分支
把每个 idx -> char 抽出来重组,得到:
flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}
- 自动化求解脚本(可直接跑)
把下面保存为 solve_ouroboros.py,然后执行:python3 solve_ouroboros.py /mnt/data/ouroboros.zip
1 | |
- 结果
脚本输出:
1 | |
提交:
**flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}**
Marionette
- 基础分析:程序结构不是直线执行
先看二进制(ELF 64)后,能看到一套 fork + ptrace + int3 的执行调度,子进程执行大量被 int3 切碎的代码块,父进程做调度。
这类题目常见问题是:静态看到的数据(比如某个“目标密文”)不一定是最终参与比较的值。
- 先拿“真实比较值”(不是看静态 .data)
最稳的方法是直接 hook memcmp,把比较双方 dump 出来。
hook_memcmp.c
1 | |
编译与运行
1 | |
我这里拿到的真实目标是:
B = 8cadb48febfd6fae8660ad44c3c75a31
- 还原算法流程(关键点)
结合静态 + 动态执行序列,核心流程是:
- 使用固定 16 字节主密钥做扩展到 13 轮密钥(不是标准 AES-128 的 11 轮,而是扩到 13 组,用了 12 轮 rcon)。
- 主密钥:
5a097c137b8d4f2132be3b19af449c01
- 主密钥:
- 对输入 16 字节先做一层链式异或预处理:
- 从后往前:
for i=15..1: x[i] ^= x[i-1] - 这是一个可逆变换。
- 从后往前:
- 对预处理后的 16 字节做一套 12 轮 AES 风格加密:
- 初始 AddRoundKey
- 11 次
AESENC - 1 次
AESENCLAST
- 最终结果与
memcmp的B比较。
- 逆向求解思路
要求输入 inp,使得:
AES12(PreXor(inp),key)=BAES12(PreXor(inp), key) = BAES12(PreXor(inp),key)=B
所以:
- 先
PreXor(inp) = AES12^{-1}(B, key) - 再把
PreXor逆回来:inp[0]=t[0]inp[i]=t[i] xor inp[i-1](i=1..15)
- 一键求解脚本(完整可跑)
这个脚本直接根据上面拿到的 B 和固定 key 算出最终输入。
solve.py
1 | |
运行后得到:
1 | |
- 最终结果
把下面这串输入给程序即可:
1 | |
程序返回 OK。