返回文章列表

学弟の考核赛絮絮叨叨

万物皆可逆系列复现教程


目录

  1. 导言
  2. 其一 · 医院导诊系统故障啦
  3. 其二 · 打开 ZGH 被网络封闭的内心
  4. 其三 · 货币の战争

一、导言

我说过,万物皆可逆。所以这一次给学弟出题,本质是想让他大大滴打开脑洞;当然,我的脑洞也是大大滴打开了。

image.png

题目要求是:做不出来我也不鸭梨,能做出来我就大大地夸。
不说多了,下面开始这三道题的复现。


二、其一:医院导诊系统故障啦

1. 题目信息

项目 内容
难度 中等+
出题人 0
形式 容器题 + ELF(很像 pwn,但不是)

题面简述:

前段时间,0 的朋友脚崴了,ta 发 vx 给 0 酱说医院的导诊系统坏了。热心的 0 酱去修,结果那边的人只说要 xor 一个 0x91,0 酱当场宕机:这素?

2. 出题思路

出题任务下来时脑子是空的,于是无聊和朋友 gossip。朋友外面玩得正开心把脚崴了,去医院一肚子怨气,闲谈中吐槽医院导诊——灵感就来了。

附件一开始其实只想给个日志文件让小学弟逆;后面想了一下还是做个人,做成了容器 + ELF。

3. 运行表现

进入容器后,能看到类似医院导诊的系统界面(为什么叫 Genshin 你别管):

image.png

操作很直观:需要什么服务,输入对应数字即可。

问题是:输入哪个数字,才能进入题面里说的「故障修复」界面?

4. 逆向分析

把 ELF 拖进 IDA:

image.png

main 很长。普通 Shift+F12 看字符串不一定够用,可以按下面这种方式过滤/定位:

image.png

方法繁琐,但主要拼耐心,迟早能锁定关键位置:

image.png

哦灭得多!

基本可以以这里为核心看逻辑。觉得代码难看的话,也可以继续「翻牌」定位:

image.png

这里可以看到「排队叫号查询」,对应主页 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 打进去。进入后大致长这样:

image.png

窗口/选项仍可能很多。关键点在于:

image.png

只有进入序号 7 解 XOR 才会有答案。
为什么是 7?自己去查 ASCII。

5. 最后一关:XOR 0x91 + 3 秒时限

image.png

解密算法很直接:对校验码做 xor 0x91。同时还要注意:

image.png

意思是:只给你三秒,算完还要输进去,逾期不候。

两条路:

  1. 绝对相信自己的手速
  2. 绝对相信计算机的速度

年纪上来了,手速跟不上,只能上脚本。直接 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 逆向也不是不行。

这一题直接挂服务器,没有附件——想要附件,有本事自己搞出来。

image.png

  • 在线地址:HYNU Console
  • 会 web 的也许能走越权等思路自己试
  • 这里按 reverse 路线讲理想做法

2. 看网页源码

image.png

跳到前端脚本:

image.png

核心 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

这不是普通图片:

avatar-index

(图很小:1×1 像素壳 + 尾部载荷。)

关键 marker:

1
const markerText = "HYNU-WASM-PAYLOAD\0";  // 注意结尾 \0,共 18 字节

WinHex 可见 IEND 后附加数据:

image.png

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

算法:

  1. 对 seed 做 FNV-1a(32-bit)
  2. xorshift 扩展为 32 字节 key
  3. 解密:
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 == Binput[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,才会输出:

1
HYNUCTF{M0N3YG0D}

喜欢这设计的原因:不是单纯字符串比较,也不是单纯 AES/XOR,而是把 状态路径解密 key 绑在一起——你不是在找一个密码,而是在找一条路。

想表达的是:

有些系统的秘密不一定藏在某个变量里,而是藏在某条不该再存在的流程里。

6. 附件策略

最终只放:

1
2
payment_server.exe
sample.payment

不放:

  • wallet.exe(作者生成交易用)
  • secret.payment(作者验题用的隐藏交易)

选手拿到的 sample.payment 跑起来只会清算成功。真正要做的是:

  1. 逆向服务器
  2. 分析文件格式
  3. 找到隐藏事件序列
  4. 重新构造 payment
  5. 让服务器走旧版 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 保存。