万物皆可逆系列复现教程
目录
导言
其一 · 医院导诊系统故障啦
其二 · 打开 ZGH 被网络封闭的内心
其三 · 货币の战争
一、导言 我说过,万物皆可逆。所以这一次给学弟出题,本质是想让他大大滴打开脑洞;当然,我的脑洞也是大大滴打开了。
题目要求是:做不出来我也不鸭梨,能做出来我就大大地夸。 不说多了,下面开始这三道题的复现。
二、其一:医院导诊系统故障啦 1. 题目信息
项目
内容
难度
中等+
出题人
0
形式
容器题 + ELF(很像 pwn,但不是)
题面简述:
前段时间,0 的朋友脚崴了,ta 发 vx 给 0 酱说医院的导诊系统坏了。热心的 0 酱去修,结果那边的人只说要 xor 一个 0x91,0 酱当场宕机:这素?
2. 出题思路 出题任务下来时脑子是空的,于是无聊和朋友 gossip。朋友外面玩得正开心把脚崴了,去医院一肚子怨气,闲谈中吐槽医院导诊——灵感就来了。
附件一开始其实只想给个日志文件让小学弟逆;后面想了一下还是做个人,做成了容器 + ELF。
3. 运行表现 进入容器后,能看到类似医院导诊的系统界面(为什么叫 Genshin 你别管):
操作很直观:需要什么服务,输入对应数字即可。
问题是:输入哪个数字,才能进入题面里说的「故障修复」界面?
4. 逆向分析 把 ELF 拖进 IDA:
main 很长。普通 Shift+F12 看字符串不一定够用,可以按下面这种方式过滤/定位:
方法繁琐,但主要拼耐心,迟早能锁定关键位置:
哦灭得多!
基本可以以这里为核心看逻辑。觉得代码难看的话,也可以继续「翻牌」定位:
这里可以看到「排队叫号查询」,对应主页 3 号 服务。相关逻辑大致如下:
1 2 3 4 5 6 7 8 9 puts(asc_40321D); // 排队叫号查询 __printf_chk(); // 输入你的卡号 fflush(stdout); if ( (unsigned int)sub_402540(s, 60) ) // 读取输入进行比对 goto LABEL_59; strcpy(v49, "MNT-2419"); // 秘密位置 if ( *(_QWORD *)s == 0x393134322D544E4DLL && !v53 ) { puts(&byte_403C90); // 输入正确即可进入
也就是说:进入 3 号服务后,把卡号 MNT-2419 打进去。进入后大致长这样:
窗口/选项仍可能很多。关键点在于:
只有进入序号 7 解 XOR 才会有答案。 为什么是 7?自己去查 ASCII。
5. 最后一关:XOR 0x91 + 3 秒时限
解密算法很直接:对校验码做 xor 0x91。同时还要注意:
意思是:只给你三秒 ,算完还要输进去,逾期不候。
两条路:
绝对相信自己的手速
绝对相信计算机的速度
年纪上来了,手速跟不上,只能上脚本。直接 Python 自动化,别说 3 秒,1 秒就破。
6. 自动化脚本 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 import re import socket import sys HOST = "" PORT = 0 # 按实际环境填写 def recv_until(sock, markers, timeout=8): sock.settimeout(timeout) buf = b"" while True: try: chunk = sock.recv(4096) if not chunk: break buf += chunk text = buf.decode("utf-8", "replace") for m in markers: if m in text: return text except socket.timeout: break return buf.decode("utf-8", "replace") def sendline(sock, s): sock.sendall((s + "\n").encode()) def main(): host = sys.argv[1] if len(sys.argv) > 1 else HOST port = int(sys.argv[2]) if len(sys.argv) > 2 else PORT s = socket.create_connection((host, port), timeout=10) print(recv_until(s, ["> "]), end="") sendline(s, "3") print(recv_until(s, [": ", ":"]), end="") sendline(s, "MNT-2419") print(recv_until(s, ["> "]), end="") sendline(s, "7") text = recv_until(s, [": ", "结果"]) print(text, end="") m = re.search(r"校验码:\s*([0-9a-fA-F]{8})", text) if not m: m = re.search(r"([0-9a-fA-F]{8})", text) if not m: print("challenge not found", file=sys.stderr) s.close() sys.exit(1) challenge = m.group(1) password = bytes(b ^ 0x91 for b in bytes.fromhex(challenge)).hex() print(f"\n[+] challenge={challenge} password={password}") sendline(s, password) try: out = s.recv(8192).decode("utf-8", "replace") print(out, end="") fm = re.search(r"HYNUCTF\{[^}]+\}", out) if fm: print(f"\n[+] FLAG: {fm.group(0)}") except Exception as e: print(e, file=sys.stderr) finally: s.close() if __name__ == "__main__": main()
7. 小结 出完之后才发现这题还是偏简单,不太需要太重的静态分析。算了,就这样吧。
解题路径速记:
1 2 3 4 5 菜单 3(排队叫号) → 卡号 MNT-2419 → 选项 7(XOR 修复) → 读校验码,逐字节 xor 0x91 → 3 秒内回传 → flag
三、其二:打开 ZGH 被网络封闭的内心 1. 出题背景 有天晚上不记得谁说了一嘴「逆向 ZGH」,我就出了。刚好琢磨 web 逆向挺久(源自之前逆向 4399),觉得 web 逆向也不是不行。
这一题直接挂服务器,没有附件 ——想要附件,有本事自己搞出来。
2. 看网页源码
跳到前端脚本:
核心 JS(app.min.js 逻辑):
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 "use strict"; (() => { const form = document.getElementById("gate-form"); const status = document.getElementById("status"); const submit = document.getElementById("submit"); const screen = document.getElementById("screen"); const markerText = "HYNU-WASM-PAYLOAD\0"; const marker = new TextEncoder().encode(markerText); const assetPath = ["", "assets", "media", "cache", "2026", "07", "31", "r", "e", "v", "avatar-index.png"].join("/"); let wasmReady; function deriveKey(seed) { let x = 0x811c9dc5; for (let i = 0; i < seed.length; i += 1) { x ^= seed.charCodeAt(i); x = Math.imul(x, 0x01000193) >>> 0; } const key = new Uint8Array(32); for (let i = 0; i < key.length; i += 1) { x ^= x << 13; x ^= x >>> 17; x ^= x << 5; key[i] = x & 255; } return key; } function findMarker(bytes) { outer: for (let i = bytes.length - marker.length - 4; i >= 0; i -= 1) { for (let j = 0; j < marker.length; j += 1) { if (bytes[i + j] !== marker[j]) continue outer; } return i; } return -1; } function decrypt(bytes) { const seed = [ document.documentElement.dataset.cacheSeed || "", document.body.dataset.profile || "", document.querySelector("meta[name='asset-profile']")?.content || "" ].join("|"); const key = deriveKey(seed); const out = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; i += 1) { out[i] = bytes[i] ^ key[i & 31] ^ ((i * 31 + 17) & 255); } return out; } async function loadGate() { const response = await fetch(assetPath, { cache: "no-store" }); if (!response.ok) throw new Error("asset"); const carrier = new Uint8Array(await response.arrayBuffer()); const offset = findMarker(carrier); if (offset < 0) throw new Error("marker"); const view = new DataView(carrier.buffer, carrier.byteOffset + offset + marker.length, 4); const length = view.getUint32(0, false); const start = offset + marker.length + 4; const encrypted = carrier.slice(start, start + length); if (encrypted.length !== length) throw new Error("length"); const wasmBytes = decrypt(encrypted); const module = await WebAssembly.instantiate(wasmBytes, {}); return module.instance.exports; } function writeString(memory, pointer, value) { const bytes = new TextEncoder().encode(value); new Uint8Array(memory.buffer).set(bytes, pointer); return bytes.length; } function renderConsole(payload) { screen.innerHTML = ""; const box = document.createElement("section"); box.className = "dashboard"; const title = document.createElement("h1"); title.textContent = payload.title || "Console"; const flag = document.createElement("code"); flag.className = "flag"; flag.textContent = payload.flag; box.append(title, flag); screen.append(box); } async function unlock(username, password) { const res = await fetch("/api/unlock", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ username, password }) }); const payload = await res.json().catch(() => ({ ok: false })); if (!res.ok || !payload.ok) throw new Error(payload.message || "Access denied."); return payload; } wasmReady = loadGate(); form.addEventListener("submit", async event => { event.preventDefault(); submit.disabled = true; status.className = "status"; status.textContent = "Checking identity."; try { const wasm = await wasmReady; const username = document.getElementById("username").value; const password = document.getElementById("password").value; const uPtr = 1024; const pPtr = 1536; const uLen = writeString(wasm.memory, uPtr, username); const pLen = writeString(wasm.memory, pPtr, password); if (wasm.check(uPtr, uLen, pPtr, pLen) !== 1) { throw new Error("Access denied."); } const payload = await unlock(username, password); renderConsole(payload); } catch (error) { status.className = "status bad"; status.textContent = error.message === "Access denied." ? "Access denied." : "Gate resource is not ready."; submit.disabled = false; } }); })();
3. 从 PNG 中提取 WASM JS 指代资源路径:
1 /assets/media/cache/2026/07/31/r/e/v/avatar-index.png
这不是普通图片:
(图很小:1×1 像素壳 + 尾部载荷。)
关键 marker:
1 const markerText = "HYNU-WASM-PAYLOAD\0"; // 注意结尾 \0,共 18 字节
WinHex 可见 IEND 后附加数据:
3.1 文件布局 1 2 3 4 [合法 PNG ... IEND] [HYNU-WASM-PAYLOAD\0] // 18 字节 [length: u32 BE] // 本题 0x00000317 = 791 [encrypted WASM] // 791 字节密文
3.2 截取密文 1 2 3 offset = index("HYNU-WASM-PAYLOAD\0") length = BE_u32(file[offset+18 : offset+22]) cipher = file[offset+22 : offset+22+length]
密文不以 \0asm 开头 → 不是明文 WASM,需解密 。
3.3 构造 key 并解密 seed 由 HTML 三个字段用 | 拼接:
1 data-cache-seed | data-profile | meta[name=asset-profile]
本题:
1 theme://cdn.hynu.internal/cache/31/rev|hynuctf-quiet-gate|archive:quiet-blueprint:v1
算法:
对 seed 做 FNV-1a(32-bit)
xorshift 扩展为 32 字节 key
解密:
1 plain[i] = cipher[i] ^ key[i & 31] ^ ((i * 31 + 17) & 0xFF)
解密后文件头:
1 00 61 73 6D 01 00 00 00 → \0asm + version 1
得到 gate.wasm,再用 wasm2c 等工具分析。
4. 分析 check:还原用户名 / 密码 4.1 函数原型 前端调用:
1 wasm.check(uPtr, uLen, pPtr, pLen)
wasm2c 导出:
1 2 3 /* export: 'check' */ u32 w2c_gate_check(w2c_gate* instance, u32 var_p0, u32 var_p1, u32 var_p2, u32 var_p3); // uPtr uLen pPtr pLen
真正逻辑在 w2c_gate_check_0:
参数
含义
var_p0
用户名在 linear memory 中的指针
var_p1
用户名长度
var_p2
密码指针
var_p3
密码长度
返回值
1 通过,0 失败
4.2 长度校验 1 2 3 4 5 // 用户名长度必须为 16 if (var_p1 != 16u) return 0; // 密码长度必须为 17 if (var_p3 != 17u) return 0;
4.3 逐字节 XOR 比较 以用户名第 0 字节为例:
1 2 3 4 // username[0] var_i0 = i32_load8_u(... memory[uPtr + 0] ...); var_i0 ^= 53u; if (var_i0 != 71u) return 0;
等价:
1 2 3 if ((memory[uPtr + i] ^ A) != B) return 0; // 因 XOR 可逆: // memory[uPtr + i] == A ^ B
密码段把 var_p0 换成 var_p2,偏移 0..16,同一公式。全部通过返回 1。
4.4 常量表与结果 模式:input[i] ^ A == B → input[i] = A ^ B
用户名(16 字节)
i
A
B
A^B
char
0
53
71
114
r
1
101
0
101
e
2
149
248
109
m
3
197
154
95
_
4
245
134
115
s
5
238
135
105
i
6
30
114
108
l
7
78
43
101
e
8
126
16
110
n
9
174
218
116
t
10
167
248
95
_
11
215
180
99
c
12
7
102
97
a
13
55
84
99
c
14
103
15
104
h
15
96
5
101
e
→ username = rem_silent_cache
密码(17 字节)
i
A
B
A^B
char
0
138
221
87
W
1
186
142
52
4
2
234
153
115
s
3
26
119
109
m
4
74
21
95
_
5
67
114
49
1
6
115
0
115
s
7
163
252
95
_
8
211
157
78
N
9
3
51
48
0
10
252
136
116
t
11
44
115
95
_
12
92
104
52
4
13
140
211
95
_
14
188
236
80
P
15
181
219
110
n
16
229
130
103
g
→ password = W4sm_1s_N0t_4_Png
4.5 还原脚本 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 # input[i] ^ A == B => input[i] = A ^ B user = [ (53, 71), (101, 0), (149, 248), (197, 154), (245, 134), (238, 135), (30, 114), (78, 43), (126, 16), (174, 218), (167, 248), (215, 180), (7, 102), (55, 84), (103, 15), (96, 5), ] pwd = [ (138, 221), (186, 142), (234, 153), (26, 119), (74, 21), (67, 114), (115, 0), (163, 252), (211, 157), (3, 51), (252, 136), (44, 115), (92, 104), (140, 211), (188, 236), (181, 219), (229, 130), ] username = "".join(chr(a ^ b) for a, b in user) password = "".join(chr(a ^ b) for a, b in pwd) print(username) # rem_silent_cache print(password) # W4sm_1s_N0t_4_Png
5. 流程小结 1 2 3 1. 长度:用户名 16、密码 17 2. 逐字节 XOR 校验 username / password 3. check 返回 1 后,POST /api/unlock 换 flag
账号:
1 2 username: rem_silent_cache password: W4sm_1s_N0t_4_Png
四、其三:货币の战争
我的大菜,也是最近学自动机搞的小东西的实现。
1. 出题动机 这题最初不是奔着「出一道逆向」去的,而是那段时间一直在看状态机、后量子密码。理论看到后面真的想吐:交易状态、银行验证、账本锁定、回滚、防双花、密钥交换……纸面上都挺有道理,停在纸面上就很虚。
于是干脆自己搓一个小的。完整金融系统当然不现实;出题心态是:先实现一套货币交易系统,再顺带把 flag 塞进去。
2. 表面行为 1 payment_server.exe sample.payment
跑起来服务器说交易已正常清算:
1 2 3 [bank] digital payment server started [bank] transaction received [bank] transaction settled
这是故意留的「假正常」:
sample.payment 确实是合法支付文件
服务器会解析、验证、清算
正常路径没有 flag
真正要看的不是「这笔交易成没成」,而是「系统里有没有别的交易路径」。
3. 状态机设计 拆了几个状态机:
1 2 3 4 TransactionFSM BankFSM LedgerFSM WalletFSM
简单说:
1 2 3 4 钱包负责生成交易 交易状态机负责走流程 银行状态机负责审核 账本状态机负责锁钱和清算
放到 reverse 里好玩的点:选手不是只看一个 if,而是要还原一整条流程。
正常路径 1 2 3 4 5 6 7 SIGN → KYBER_EXCHANGE → AES_ENCRYPT → SEND → VERIFY_SUCCESS → SETTLE → SETTLE
终点:transaction settled。
隐藏路径(旧版 recovery) 1 2 3 4 5 6 7 SIGN → KYBER_EXCHANGE → AES_ENCRYPT → SEND → VERIFY_FAIL → REFUND → LEGACY_RETRY
表面:失败 → 退款 → 结束。 继续往下会发现 LEGACY_RETRY 会进隐藏状态。
核心:flag 不在正常支付里,而在「失败后被旧系统重新接管」的那条路里。
4. payment 文件结构 不止改事件编号就能出 flag,文件里还有多层校验:
1 DPAY | version | amount | kyber_capsule | event_count | events | signature | checksum
字段
作用
DPAY
文件头,提示不是普通文本
amount
金额
events
状态机事件序列
signature / checksum
防止随便改事件
kyber_capsule
后量子支付的「小玩意」(伪 Kyber 胶囊校验)
说明:题里的 Kyber 不是 完整 Kyber768。工程量太大,对 reverse 主线帮助有限,更像伪胶囊校验——它不是 flag,也不是最终解密密钥。
形象一点:
1 2 3 flag = 银行服务器里锁起来的秘密回执 pathHash = 真正开锁的钥匙 Kyber capsule = 门口保安要看的通行证
光有通行证不行
光知道门在哪里也不行
必须让交易走到隐藏 recovery,算出特殊 pathHash
pathHash 才是解密 receipt 的 key
正常路径的 pathHash 不对,解不开;隐藏路径对了,才解出加密 receipt。
5. Flag 在哪
不在 sample.payment 明文里
不在 程序字符串明文里
加密后放在 payment_server.exe 里
只有构造正确的隐藏交易,让服务器进入 legacy recovery,才会输出:
喜欢这设计的原因:不是单纯字符串比较,也不是单纯 AES/XOR,而是把 状态路径 和 解密 key 绑在一起——你不是在找一个密码,而是在找一条路。
想表达的是:
有些系统的秘密不一定藏在某个变量里,而是藏在某条不该再存在的流程里。
6. 附件策略 最终只放:
1 2 payment_server.exe sample.payment
不放:
wallet.exe(作者生成交易用)
secret.payment(作者验题用的隐藏交易)
选手拿到的 sample.payment 跑起来只会清算成功。真正要做的是:
逆向服务器
分析文件格式
找到隐藏事件序列
重新构造 payment
让服务器走旧版 recovery
7. 碎碎念 这题不算特别难,但比单纯藏字符串或套壳舒服一点:至少是从系统设计角度做 reverse——钱包、交易、银行、账本、胶囊校验、隐藏回执拼起来,有一点「它真的像个东西」的感觉。
虽然还是挺手搓,但能把学得想吐的理论变成能跑的题,已经挺值了。
附录:三题速查
题号
题名
关键点
备注
其一
医院导诊系统故障啦
菜单 3 → MNT-2419 → 选项 7 → xor 0x91 + 3 秒
适合脚本自动化
其二
打开 ZGH 被网络封闭的内心
PNG 尾部加密 WASM + HTML seed 解密 + check 逐字节 XOR
rem_silent_cache / W4sm_1s_N0t_4_Png
其三
货币の战争
状态机隐藏 recovery 路径 → pathHash 解密 receipt
Flag:HYNUCTF{M0N3YG0D}
留言
欢迎留下想法。留言将由 GitHub Discussions 保存。