前言:题目总览

题目类型 题目名称 难度 解出情况
Misc (Multi) Constructed Dataline Medium
Misc Constructed Dataline Easy
Misc Feedback Trivial
Misc SignIn Trivial
Misc 优质 Flag 募集中! Medium
Misc 心理念写 Hard
Misc 红芯铸魂・码践初心 Trivial
Crypto Broken AES Expert
Crypto Hyper Substitution Easy
Crypto Leak CKKS Normal
Crypto Noisy LCG Medium
Crypto Scale CKKS Medium
Crypto Weird RSA Hard
Crypto 手撕平方根 Normal
Pwn 御坂网络 Expert
Pwn 御坂美琴 Hard
Pwn 相互置换 Medium
Pwn 等长条 Normal
Pwn 能力开发・缺陷电气 Normal
Pwn 能力开发・路径穿过 Easy
Pwn 能力开发・鸟瞰把握 Medium
Pwn 超电磁炮 Medium
Web Fail Open Medium
Web Safe Store Normal
Web Terraria 百科 Hard
Web ez blog Medium
Web ez xss Hard
Web 不明意味 Medium
Web 乌萨奇的硬币 Easy
Reverse Bluntstone Medium
Reverse FunSokoban Medium
Reverse Strange puts Hard
Reverse Vigorcheck Easy
Reverse 开始电力运输 Normal
Forensics 损坏的 Sysprep Normal
PPC 10 秒不顶号就毁 flag Medium
PPC 日历 Expert
PPC 日历・序章 Normal
Pentest 内网迷踪 Medium
OSINT Find Me Easy
OSINT Journey across Medium
OSINT 无论到哪都爱吃麦麦 Easy

1 Misc

1.2 Constructed Dataline

我翻开附件一查,这附件没有后缀,歪歪斜斜的每叶上都写着 Dataline 几个字。我横竖睡不着,仔细看了半夜,才从字缝里看出字来,整个文件都写着几个字是 flag!

打开附件,发现由大量重复格式的 Dataline 数据行构成:

1
2
3
4
5
6
Dataline(32202,32204,0.80,1.26,0.00,0.00);
Dataline(32202,32216,0.80,0.80,0.00,0.00);
Dataline(32204,32221,1.26,1.26,0.00,0.00);
...
Dataline(36317,36320,1.02,0.85,0.00,0.00);
Dataline(36322,36322,1.10,1.06,0.00,0.00);

根据提示,对所有数据行进行解析,还原每条线段并做适当的变换,最终可在生成的图片中看出 flag

观察数据特征可知,每行最后两项数值恒为 0.00,属于无效信息;前四项为有效参数,可分别解析为线段端点坐标 (x1, x2)(y1, y2),其中 x1x2 为整数类型,y1y2 为小数类型。

由于 y 方向的数值很小,直接画出来会很扁,需要放大。尝试放大 100 倍:

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

import matplotlib.pyplot as plt

Y_SCALE = 100 # 纵轴放大倍数


def main() -> None:
with open("flag", "r", encoding="utf-8", errors="ignore") as f:
text = f.read()

fig, ax = plt.subplots(figsize=(16, 4), dpi=400)

# 正则提取所有 Dataline 坐标
for x1, x2, y1, y2 in re.findall(
r"Dataline\((\d+),(\d+),([0-9.]+),([0-9.]+),[0-9.]+,[0-9.]+\);", text
):
ax.plot([int(x1), int(x2)], [float(y1) * Y_SCALE, float(y2) * Y_SCALE], "k-", lw=0.25)

ax.axis("off")
fig.savefig("yup_100.png", bbox_inches="tight", pad_inches=0)


if __name__ == "__main__":
main()

可以看到图片本身的比例不合适,需要对整体纵向压缩。同时,首字母类似 M 而不是 W,说明还需要上下翻转图像。

发现当压缩系数设置为 0.1 时,可以输出较为清晰的图案:

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
import io
import re

import matplotlib.pyplot as plt
from PIL import Image

Y_SCALE = 100.0 # 纵轴放大倍数
Y_SQUISH = 0.1 # 垂直压缩系数


def main() -> None:
with open("flag", "r", encoding="utf-8", errors="ignore") as f:
text = f.read()

segs = re.findall(
r"Dataline\((\d+),(\d+),([0-9.]+),([0-9.]+),[0-9.]+,[0-9.]+\);", text
)

# 计算最大纵坐标,用于上下翻转图像
max_y = max(max(float(y1), float(y2)) for _, _, y1, y2 in segs)

fig, ax = plt.subplots(figsize=(16, 4), dpi=400)
for x1, x2, y1, y2 in segs:
ax.plot(
[int(x1), int(x2)],
[(max_y - float(y1)) * Y_SCALE, (max_y - float(y2)) * Y_SCALE],
"k-",
lw=0.25,
)

ax.axis("off")

# 将图片保存到内存缓冲区
buf = io.BytesIO()
fig.savefig(buf, format="png", bbox_inches="tight", pad_inches=0)
plt.close(fig)

# 从内存区读取图片
buf.seek(0)
img = Image.open(buf)
w, h = img.size

# 垂直压缩图片
img = img.resize((w, max(1, int(h * Y_SQUISH))), Image.Resampling.LANCZOS)
img.save("inv_squish_10.png")


if __name__ == "__main__":
main()

从图片中读出 flag

1
W4terCTF{Ev3ryb0dy_plz_V_Hachiwa0_50_bcUz_He_i5_soOoOoOoOo_hUn9rY_on_KFCCrazyThurs}

1.3 Feedback

感谢参与 W4terCTF 2026!我们准备了一份问卷,期待你来填写,给我们你的反馈,帮助我们做得更好。

填写问卷,完成赛事反馈后获得 flag

1
W4terCTF{4_w33k_70_r3m3mb3r_4nd_533_y0u_n3x7_71m3}

1.4 SignIn

欢迎关注中山大学网络空间安全协会,祝你比赛顺利。

网页内嵌简易终端,在内置 shell 中浏览工作目录,发现并读取 flag 文件。

1
W4terCTF{Welc0me_t0_W4terCTF2026!!!}

1.7 红芯铸魂・码践初心

社会主义核心价值观正是凝聚民族力量、引领青年前行的精神旗帜,为传承红色基因、践行社会主义核心价值观,现核心价值观关键词进行安全编码传输,请解出其中蕴含的秘密。

附件为「核心价值观」编码这一常见的趣味编码形式,借助在线解码器还原明文得到 flag

1
W4terCTF{Br41nW4sh1n9_5uck5}

2 Crypto

2.2 Hyper Substitution

破译密文得 flag

首先,将题目给出的密文保存为 ciphertext。每一次创建实例时生成的密文不相同,以下是解题所用密文:

1
z#D!+; h#e>GCC< :cG v!g>/c /y>G;:>/:< c#/ /EG;: c>/ g>CG:>BG /:+dS>;" #y:>\G \!gy#;!G/ #;d dGGE y#\G/ >; #gg E#D:/ !C :cG ?!DgdT }; ;>;G:GG; C!D:S'G>"c:< cG ?G;: :! *#$G `>\+ >; :cG -!;"! :! !A/GD\G # ;G? \!gy#;! ?c>yc cG g#:GD ;#BGd `>:+D!T h#e>GCC ?#/ #AgG :! /G: +E c>/ y#BE \GDS yg!/G :! :cG \!gy#;! ?c>gG >: ?#/ GD+E:>;" \>!gG;:gST hc!+"c cG B#;#"Gd :! :#$G # ;+BAGD !C AD>gg>#;: Ec!:!"D#Ec/< cG y!+gd ;!: /:#S ;G#D :cG \!gy#;! C!D \GDS g!;"T zG ;!:>yGd :c#: # D>\GD !C g>Q+>d D!y$ ?#/ y!B>;" :!?#Dd/ c>BT }: :cDG#:G;Gd :! /+DD!+;d c>B y!BEgG:GgS< A+: h#e>GCC B#;#"Gd :! G/y#EG F+/: >; :>BGT zG ?#>:Gd +;:>g :cG \!gy#;! AGy#BG Q+>G: #;d cG ?#/ #AgG :! DG:+D; :?! d#S/ g#:GDT hc>/ :>BG< cG B#;#"Gd :! yg>BA >;:! :cG B!+:c !C `>:+D! /! :c#: cG y!+gd :#$G Ec!:!"D#Ec/ #;d BG#/+DG :GBEGD#:+DG/T h#e>GCC c#/ !C:G; D>/$Gd c>/ g>CG >; :c>/ ?#ST zG c#/ AGG; #AgG :! :Ggg +/ B!DG #A!+: #y:>\G \!gy#;!G/ :c#; #;S B#; #g>\GT

将明文候选字符集固定为:空格 + a-z + .,'\";:!?-(共 36 个符号),与密文字符集建立一一映射即可建模本题。映射总数对应 36 阶排列,精确枚举不可行,故采用脚本在启发式框架下搜索较优结果。

实现要点如下:

  1. 初始化:依据密文字符频率构造初始映射 —— 高频符号优先对应空格,其余位置参考英文常用字母频率顺序 etaoin... 排列字母与标点;将映射修正为合法排列,并对空格位置施加约束以保证密文空格与明文空格对齐。
  2. 评分:对解密结果中提取的长度不小于 2 的连续小写字母串,使用 wordfreq.zipf_frequency 累加词频得分;对「字母 – 标点 – 字母」这类不合理片段施加惩罚,并对连续空格、重复标点等现象扣分;对 " the "" and "" to "" of " 等常见片段给予小幅加分以稳定搜索。
  3. 搜索:在保持双射的前提下,每一步随机交换映射中的两个位置;评分上升则接受,下降则以模拟退火准则按温度接受扰动解,温度由约 5.0 按指数衰减至约 0.05。脚本采用多组随机种子重启(默认 12 轮,每轮约 120_000 次迭代),取全局最优映射及对应明文。
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
from __future__ import annotations

import math
import os
import random
import re
import sys
from collections import Counter
from typing import Dict, List

from wordfreq import zipf_frequency


PUNCT = ".,'\";:!?-" # 标点符号集合
PLAINTEXT_ALPHABET = " " + "abcdefghijklmnopqrstuvwxyz" + PUNCT # 明文字符集


def read_cipher(path: str) -> str:
"""读取密文文件"""
return open(path, "r", encoding="utf-8").read()


def initial_mapping(cipher_alpha: str, plain_alpha: str, cipher: str) -> Dict[str, str]:
""" 根据字符频率初始化映射表(模拟退火初始解)"""

# 统计密文字符出现频率,从高到低排序
cipher_counts = Counter(cipher)
cipher_sorted = [c for c, _ in cipher_counts.most_common()]

# 英文最高频字母顺序(用于初始映射)
target_order = (
[" "]
+ list("etaoinshrdlucmfwypvbgkjqxz")
+ list(".,'\";:!?-")
)

# 过滤掉不在明文字符集中的字符
target_order = [c for c in target_order if c in plain_alpha]
if len(target_order) != len(plain_alpha) or len(set(target_order)) != len(plain_alpha):
target_order = list(plain_alpha)

# 密文与明文字符集长度必须一致
if len(cipher_alpha) != len(plain_alpha):
raise ValueError(f"alphabet size mismatch: cipher={len(cipher_alpha)} plain={len(plain_alpha)}")

# 拼接完整的目标映射顺序
remaining = [c for c in plain_alpha if c not in target_order]
perm = target_order + remaining
perm = perm[: len(plain_alpha)]

# 构建初始映射表
mapping = {c: perm[idx] for idx, c in enumerate(cipher_sorted)}
# 未匹配到的字符随机补充
for c in cipher_alpha:
mapping.setdefault(c, random.choice(plain_alpha))
return mapping


# 正则表达式:提取连续的英文单词(至少两个字母)
WORD_RE = re.compile(r"[a-z]{2,}")


def score_plaintext(pt: str) -> float:
"""文本评分函数"""
pt_low = pt.lower()
words = WORD_RE.findall(pt_low)

# 如果没有有效单词,直接返回极低分
if not words:
return -1e9

s = 0.0
# 根据英文真实词频加分,越常见的单词分数越高
for w in words:
z = zipf_frequency(w, "en")
s += max(-5.0, min(7.0, z))

# 惩罚:单词中间出现标点(不符合英文语法)
inside_word = re.findall(rf"[a-z][{re.escape(PUNCT)}][a-z]", pt_low)
s -= 25.0 * len(inside_word)

# 惩罚:连续空格、重复符号
s -= 5.0 * pt_low.count(" ")
s -= 1.5 * pt_low.count("..")
s -= 1.5 * pt_low.count(",,")
s -= 1.0 * pt_low.count("''")
s -= 1.0 * pt_low.count('""')
s -= 1.0 * pt_low.count("--")
s -= 2.0 * pt_low.count("?")

# 加分:正常英文标点
s += 1.0 * (pt_low.count(".") + pt_low.count(","))
s += 0.5 * (pt_low.count("'") + pt_low.count("!"))

# 强力加分:英文高频词组
for tri in (" the ", " and ", " to ", " of "):
s += 6.0 * pt_low.count(tri)

return s


def decrypt_with_mapping(cipher: str, mapping: Dict[str, str]) -> str:
"""根据当前映射表,将密文转换为候选明文"""
return "".join(mapping.get(ch, ch) for ch in cipher)


def anneal(
cipher: str,
cipher_alpha: str,
plain_alpha: str,
*,
iters: int,
seed: int,
) -> tuple[float, Dict[str, str], str]:
"""模拟退火主算法"""
rnd = random.Random(seed)

# 根据频率初始化映射排列
plain_perm = [initial_mapping(cipher_alpha, plain_alpha, cipher)[c] for c in cipher_alpha]

# 空格必须映射为空格,固定不变
if " " in cipher_alpha and " " in plain_alpha:
space_idx = cipher_alpha.index(" ")
plain_perm[space_idx] = " "

# 确保映射表无重复、无遗漏字符
used = set()
missing = [c for c in plain_alpha if c not in plain_perm]
mi = 0
for idx, ch in enumerate(plain_perm):
if ch in used:
plain_perm[idx] = missing[mi]
mi += 1
used.add(plain_perm[idx])

# 将排列转为映射字典
def perm_to_map(pp: List[str]) -> Dict[str, str]:
return dict(zip(cipher_alpha, pp))

# 初始化解密状态
mapping = perm_to_map(plain_perm)
cur_pt = decrypt_with_mapping(cipher, mapping)
cur_score = score_plaintext(cur_pt)

# 记录当前最优解
best_score = cur_score
best_perm = plain_perm[:]
best_pt = cur_pt

# 模拟退火温度参数,温度随迭代指数下降
t0 = 5.0
t1 = 0.05

for step in range(1, iters + 1):
# 计算当前温度
frac = step / iters
t = t0 * ((t1 / t0) ** frac)

# 随机选择两个位置交换映射(跳过空格)
a = rnd.randrange(len(plain_perm))
b = rnd.randrange(len(plain_perm))
if " " in cipher_alpha:
fixed = cipher_alpha.index(" ")
if a == fixed or b == fixed:
continue
if a == b:
continue

# 执行交换
plain_perm[a], plain_perm[b] = plain_perm[b], plain_perm[a]
mapping2 = perm_to_map(plain_perm)
pt2 = decrypt_with_mapping(cipher, mapping2)
sc2 = score_plaintext(pt2)

# 计算分数差
d = sc2 - cur_score
# 模拟退火接受准则:更优 或 按概率接受
accept = d >= 0 or rnd.random() < math.exp(d / max(1e-9, t))

if accept:
# 接受新解
cur_score = sc2
cur_pt = pt2
if sc2 > best_score:
best_score = sc2
best_perm = plain_perm[:]
best_pt = pt2
else:
# 不接受,撤销交换
plain_perm[a], plain_perm[b] = plain_perm[b], plain_perm[a]

# 定期重启,避免陷入局部最优
if step % (iters // 5) == 0 and step != iters:
plain_perm = best_perm[:]
for _ in range(20):
i = rnd.randrange(len(plain_perm))
j = rnd.randrange(len(plain_perm))
plain_perm[i], plain_perm[j] = plain_perm[j], plain_perm[i]
mapping = perm_to_map(plain_perm)
cur_pt = decrypt_with_mapping(cipher, mapping)
cur_score = score_plaintext(cur_pt)

return best_score, perm_to_map(best_perm), best_pt


def main() -> int:
# 读取密文
cipher_path = sys.argv[1] if len(sys.argv) > 1 else "ciphertext"
cipher = read_cipher(cipher_path)

# 构建密文字符集
cipher_alpha = "".join(sorted(set(cipher)))
if len(cipher_alpha) != len(PLAINTEXT_ALPHABET):
print("[-] Cipher alphabet size:", len(cipher_alpha), "expected:", len(PLAINTEXT_ALPHABET))
print(" cipher alphabet repr:", repr(cipher_alpha))
return 2

print("[*] Cipher length:", len(cipher), flush=True)
print("[*] Alphabet:", repr(cipher_alpha), flush=True)

# 多起点搜索,避免局部最优,共 12 轮
best_score = float("-inf")
best_map: Dict[str, str] = {}
best_pt = ""
starts = 12
iters = 120_000

for s in range(starts):
seed = 1337 + s * 99991
sc, mp, pt = anneal(cipher, cipher_alpha, PLAINTEXT_ALPHABET, iters=iters, seed=seed)
if sc > best_score:
best_score, best_map, best_pt = sc, mp, pt
print(f"[+] New best score {best_score:.2f} at start {s}", flush=True)
preview = pt[:300].replace("\n", " ")
print(" preview:", preview, flush=True)

# 保存最优明文
out_txt = os.path.join(os.path.dirname(cipher_path) or ".", "decrypted_best.txt")
with open(out_txt, "w", encoding="utf-8") as f:
f.write(best_pt)

# 保存最优替换表
out_map = os.path.join(os.path.dirname(cipher_path) or ".", "mapping_best.txt")
with open(out_map, "w", encoding="utf-8") as f:
for c in cipher_alpha:
f.write(f"{repr(c)} -> {repr(best_map[c])}\n")

print("[*] Wrote:", out_txt, flush=True)
print("[*] Wrote:", out_map, flush=True)
return 0


if __name__ == "__main__":
raise SystemExit(main())

运行上述脚本得到的较优明文:

1
jaroun .axieff! the ;olish scientist! has spent his lifetime studying active volcanoes and deep caves in all parts of the world, -n nineteen fortyzeight! he went to ?ake 'ivu in the :ongo to observe a new volcano which he later named 'ituro, .axieff was able to set up his camp very close to the volcano while it was erupting violently, .hough he managed to take a number of brilliant photographs! he could not stay near the volcano for very long, je noticed that a river of liquid rock was coming towards him, -t threatened to surround him completely! but .axieff managed to escape "ust in time, je waited until the volcano became quiet and he was able to return two days later, .his time! he managed to climb into the mouth of 'ituro so that he could take photographs and measure temperatures, .axieff has often risked his life in this way, je has been able to tell us more about active volcanoes than any man alive,

脚本不能保证还原出正确明文,在处理完毕后可能差几个字符,需要人工校正(因为大写字母无法还原)。

密文符号 求解器给出 正确明文 典型出现
z j H Haroun, He
h . T Tazieff, Though, This
v ; P Polish
} - I In, It
' z - forty-eight
* ? L Lake
` ' K Kivu, Kituro
- : C Congo
e x z Tazieff, axieff, zeight
F " j just
T , . 句号
< ! , 逗号

可以根据英语经验,搜索专有名词反复试验得出最终正确的明文:

1
Haroun Tazieff, the Polish scientist, has spent his lifetime studying active volcanoes and deep caves in all parts of the world. In nineteen forty-eight, he went to Lake Kivu in the Congo to observe a new volcano which he later named Kituro. Tazieff was able to set up his camp very close to the volcano while it was erupting violently. Though he managed to take a number of brilliant photographs, he could not stay near the volcano for very long. He noticed that a river of liquid rock was coming towards him. It threatened to surround him completely, but Tazieff managed to escape just in time. He waited until the volcano became quiet and he was able to return two days later. This time, he managed to climb into the mouth of Kituro so that he could take photographs and measure temperatures. Tazieff has often risked his life in this way. He has been able to tell us more about active volcanoes than any man alive.

1
W4terCTF{sTa7ls7ICS_15_7h3_BIg6esT_EnEmy_OF_ClaSs1CA1_cryPtO6raPHY}

2.3 Leak CKKS

CKKS 是一种可以使用浮点数运算的全同态加密算法,a1wAys 在上现代密码学课程的时候不小心泄漏了一些明密文对,你能据此得到他的私钥吗 🤓?

(1) 题目分析

本题基于 TenSEAL 的 CKKS 方案。服务端对全零明文做对称加密后执行 multiply,在 rescale 之前 把乘法密文的系数数组 ct 以及 decrypt 得到的明文系数 dec 一并输出。同时用派生密钥对 flag 做 AES-ECB 加密。

关键代码如下:

1
2
sk_active = dump_dyn_array(sk.data())[: len(active_moduli) * N]
key = sha256(",".join(map(str, sk_active)).encode()).hexdigest()[:32]

AES 密钥由 sk_active 决定:sk.data() 在 NTT 域下,对每个当前 RNS 模数 $q_j$ 取一段长度为 $N$ 的系数。只要恢复这 $L \times N$ 个整数,就能复现 key 并解密 flag

generate_instance 中循环 NUM_SAMPLES = 2 次,每次独立加密全零、平方、解密,得到两组 (ct, dec)。两组样本的私钥相同,但密文随机性不同,因而得到两条不同的代数方程。

(2) 建立方程

乘法后密文为 3 分量 $(c_0, c_1, c_2)$。在模素数 $q$、系数位置 $i$ 上,解密满足:

$$
\text{dec} \equiv c_0 + c_1 \cdot s + c_2 \cdot s^2 \pmod q
$$

其中 $s$ 为私钥在该模数、该位置上的 NTT 系数。移项:

$$
c_2 s^2 + c_1 s + (c_0 - \text{dec}) \equiv 0 \pmod q
$$

记 $A = c_0 - \text{dec}$,$B = c_1$,$C = c_2$,则

$$
C s^2 + B s + A \equiv 0 \pmod q
$$

这是 $\mathbb{F}_q$ 上的二次方程。单独解它会有两个根,无法唯一确定 $s$。题目提供两组样本,正是为了在 不单独求二次根 的前提下,用联立方程直接解出 $s$。

(3) 联立消元

设两组样本对应系数 $(A_0, B_0, C_0)$ 与 $(A_1, B_1, C_1)$,共享未知数 $s$:

$$
C_0 s^2 + B_0 s + A_0 \equiv 0 \pmod q
$$

$$
C_1 s^2 + B_1 s + A_1 \equiv 0 \pmod q
$$

第一式乘 $C_1$、第二式乘 $C_0$ 后相减,消去 $s^2$:

$$
(B_0 C_1 - B_1 C_0), s + (A_0 C_1 - A_1 C_0) \equiv 0 \pmod q
$$

记 $\Delta = B_0 C_1 - B_1 C_0$。当 $\Delta \not\equiv 0 \pmod q$ 时,

$$
s \equiv \Delta^{-1} (A_1 C_0 - A_0 C_1) \pmod q
$$

active_moduli 中每个模数 $q_j$、每个 $i \in [0, N)$ 各算一次,拼接结果即为 sk_active。模数由 CoeffModulus.Create 生成,均为奇素数;随机实例下 $\Delta \equiv 0$ 的概率极低。

(4) 数据格式与下标

程序打印两行:

1
2
[+] ciphertext: <hex>
[+] challenge data: <json>

challenge data 中含 poly_modulus_degreeactive_modulisamples 等字段。每个 sample 有 ctdec,以及 ciphertext_size = 3coeff_modulus_size = L

ct 按 SEAL 动态数组顺序展开:先密文分量,再 RNS 模数,再多项式系数。令 total = L * N

1
2
ct[comp * total + j * N + i]   分量 comp ∈ {0, 1, 2},模数 j,位置 i
dec[j * N + i] 模数 j,位置 i

运行以下脚本拉取远程输出并保存为 output.txt

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
param(
[string]$HostName = "127.0.0.1",
[int]$Port = xxxxx,
[string]$OutFile = "output.txt"
)

$client = $null
$stream = $null

try {
$client = New-Object System.Net.Sockets.TcpClient($HostName, $Port)
$stream = $client.GetStream()

$req = [Text.Encoding]::ASCII.GetBytes("GET / HTTP/1.0`r`nHost: $HostName`r`n`r`n")
$stream.Write($req, 0, $req.Length)

$out = New-Object System.IO.MemoryStream
$buf = New-Object byte[] 65536
while (($n = $stream.Read($buf, 0, $buf.Length)) -gt 0) {
$out.Write($buf, 0, $n)
}

[IO.File]::WriteAllBytes($OutFile, $out.ToArray())
"saved $($out.Length) bytes -> $OutFile"
} catch {
Write-Error "connect/read failed: $($_.Exception.Message)"
exit 1
} finally {
if ($stream) { $stream.Close() }
if ($client) { $client.Close() }
}

(5) 完整解题脚本

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
import json
import re
from hashlib import sha256

from Crypto.Cipher import AES
from Crypto.Util.number import inverse
from Crypto.Util.Padding import unpad


def derive_key(active_sk_ntt):
"""从恢复出的私钥推导 AES 密钥"""
# 将私钥数组转为逗号连接的字符串,再编码为字节
blob = ",".join(map(str, active_sk_ntt)).encode()
# SHA256 哈希后取前 32 个十六进制字符作为 AES 密钥
return sha256(blob).hexdigest()[:32]


def parse_input(text: str):
"""解析题目输出文本:提取密文和 JSON 数据"""
# 正则提取 ciphertext 后的十六进制密文
ct_m = re.search(r"ciphertext:\s*([0-9a-fA-F]+)", text)
# 正则提取 challenge data 后的 JSON 数据
json_m = re.search(r"challenge data:\s*(\{.*\})", text, flags=re.S)
if not (ct_m and json_m):
raise ValueError("unexpected output format")
return ct_m.group(1).strip(), json.loads(json_m.group(1))


def recover_active_secret_key(data: dict) -> list[int]:
"""
核心:利用两组明密文对,联立二次方程,逐位求解私钥 s
每个 (j, i) 对应一个模数 q_j 和一个系数位置 i
"""
# 多项式阶 N
N = int(data["poly_modulus_degree"])
# 所有 RNS 模数 q_l
moduli = [int(x) for x in data["active_moduli"]]
L = len(moduli)
total = L * N

# 获取两组样本(同一私钥,不同随机数)
s0, s1 = data["samples"]
ct_0, dec_0 = s0["ct"], s0["dec"]
ct_1, dec_1 = s1["ct"], s1["dec"]
# 存储最终恢复的私钥系数
recovered = [0] * total

# 密文 ct 下标计算:分量 comp,模数 j,系数 i
def ct_idx(comp, j, i):
return comp * total + j * N + i

# 明文 dec 下标计算:模数 j,系数 i
def dec_idx(j, i):
return j * N + i

# 遍历每个模数、每个系数位置,求解 s
for j, q in enumerate(moduli):
for i in range(N):
# 读取第一组样本 c0, c1, 2 和解密值 d0
c00 = ct_0[ct_idx(0, j, i)] % q
c01 = ct_0[ct_idx(1, j, i)] % q
c02 = ct_0[ct_idx(2, j, i)] % q
d0 = dec_0[dec_idx(j, i)] % q

# 读取第二组样本 c0, c1, 2 和解密值 d1
c10 = ct_1[ct_idx(0, j, i)] % q
c11 = ct_1[ct_idx(1, j, i)] % q
c12 = ct_1[ct_idx(2, j, i)] % q
d1 = dec_1[dec_idx(j, i)] % q

# 构造方程:C * s² + B * s + A = 0 mod q
A0, B0, C0 = (c00 - d0) % q, c01, c02
A1, B1, C1 = (c10 - d1) % q, c11, c12

# 联立消元,直接解出 s
# s = (A1 * C0 - A0 * C1) / (B0 * C1 - B1 * C0) mod q
den = (B0 * C1 - B1 * C0) % q
num = (A1 * C0 - A0 * C1) % q
recovered[j * N + i] = num * inverse(den, q) % q

return recovered


def main():
# 读取远程输出文件
with open("output.txt", "r", encoding="utf-8", errors="ignore") as f:
text = f.read()

# 解析出 AES 密文和题目参数
ciphertext_hex, data = parse_input(text)

# 核心:恢复私钥
sk_active = recover_active_secret_key(data)

# 用恢复的私钥推导 AES 密钥
key = derive_key(sk_active)

# AES-ECB 解密 flag
aes = AES.new(bytes.fromhex(key), AES.MODE_ECB)
flag = unpad(aes.decrypt(bytes.fromhex(ciphertext_hex)), AES.block_size)
print(flag.decode(errors="replace"))


if __name__ == "__main__":
main()

运行脚本得出 flag

1
W4terCTF{7H15_is_AN_34sY_PrO8lEM_1oR_YOU_AnD_dO_N0t_b3_a1RaId}

2.5 Scale CKKS

CKKS 的 Scale 是什么?a1wAys 在 rescale 的时候没有按照标准去做,他觉得好像没什么大不了的 😊 ,你能发现他的错误并得到他的 flag 🚩 吗?

(1) 题目分析

本题基于 TenSEAL 的 CKKS 方案。服务端将 flag 按字节编码为向量并加密,随后执行 50 次随机线性查询:每次生成一个 7 稀疏的 ±1 系数行 row、两个小扰动系数 g1, g2,调用 buggy_measurement() 计算一个整数,叠加有界噪声后取模输出。全部查询参数与答案以 JSON 形式公开。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def buggy_measurement(..., ct_flag, row, g1, g2):
pt_row = encode_vector(..., row, ct_flag.scale, ct_flag.parms_id())
ev.multiply_plain(ct_flag, pt_row, ct_mul)
ct_sum = slot_sum(ev, galois_keys, ct_mul, nslots)

pt_g1 = encode_vector(..., [g1], ct_sum.scale, ct_sum.parms_id())
ev.multiply_plain(ct_sum, pt_g1, ct_tmp)
ev.rescale_to_next_inplace(ct_tmp)
ct_tmp.scale = float(delta) # BUG 1

pt_g2 = encode_vector(..., [g2], ct_tmp.scale, ct_tmp.parms_id())
ev.multiply_plain(ct_tmp, pt_g2, ct_out)
ev.rescale_to_next_inplace(ct_out)
ct_out.scale = float(delta) # BUG 2

decoded = decrypt_first_slot(dec, encoder, ct_out)
return int(round(decoded / report_scale))

以及外层采样逻辑:

1
2
3
4
5
6
row = random_sparse_row(rng_py, len(flag_vec), ROW_WEIGHT)   # ROW_WEIGHT = 7
g1 = 1.0 + rng_py.randint(-80, 80) / (2 ** 12)
g2 = 1.0 + rng_py.randint(-80, 80) / (2 ** 12)
raw_answer = buggy_measurement(...)
e = rng_py.randint(-ERROR_BOUND, ERROR_BOUND)
answer = centered_mod(raw_answer + e, LWE_MODULUS)

CKKS 中 rescale_to_next 会沿模数链丢弃尾部素数模,使密文 scale 约除以该模数。题目在每次 rescale 后强行把 ct.scale 改回 delta = 2 ** 30,导致解密时按更小的 scale 解释同一密文,底层数值被额外放大。这一错误把同态运算结果变成了关于 flag 的 可预测线性泄露

(2) 建立方程

内积折叠multiply_plain(ct_flag, pt_row) 后每个 slot 近似为 flag[j] * row[j]slot_sum() 通过 Galois 旋转把所有 slot 累加到第 0 槽,得到内积:

$$
s = \langle row, flag\rangle = \sum_j row_j \cdot flag_j
$$

scale 篡改带来的放大:设两次 rescale 丢弃的模数分别为 $q_{n-1}, q_{n-2}$(即 JSON 中 active_coeff_moduli 的最后两项),第 0 槽在最终解密前的真实量级约为:

$$
v \approx \frac{\Delta^4}{q_{n-1} q_{n-2}} \cdot g_1 g_2 \cdot s, \qquad \Delta = 2^{30}
$$

除以 report_scale 并四舍五入,得到 raw_answer;服务端再叠加噪声并取模(cmod 即 centered_mod):

$$
ans \equiv \mathrm{cmod}!\left(\mathrm{round}(H \cdot s) + e,; p\right) \pmod p
$$

其中:

$$
H = \frac{\Delta^4}{q_{n-1} q_{n-2} \cdot R} \cdot g_1 g_2, \qquad R = \text{report_scale} = 2^{40}, \qquad |e| \le B = 1024
$$

$H$ 完全由公开参数确定,g1, g2 也在 JSON 里。CKKS 舍入误差被吸收进 $e$,在 ERROR_BOUND 基础上额外留约 256 的 slack 便可覆盖。

内积上界:每条 row 仅有 7 个 ±1,flag 字节为可打印 ASCII(32 ~ 126),故:

$$
|s| \le 7 \times 126 = 882
$$

取值空间极小,可对每条 query 逐一枚举 $s$,检查是否与观测 answer 在模 $p$ 意义下足够接近。

(3) 筛选候选与 Z3 求解

对第 $k$ 条 query,定义候选集合:

$$
C_k = { s \mid -882 \le s \le 882,\ \mathrm{dist}_p(\mathrm{ans}_k, \mathrm{round}(H_k s)) \le B + slack }
$$

其中 $\mathrm{dist}_p(a, b)$ 是模 $p$ 下的中心距离:

$$
\mathrm{dist}_p(a, b) = |\mathrm{cmod}(a - b, p)|
$$

又记:

$$
H_k = H \cdot g_{1,k} \cdot g_{2,k}
$$

实测每条 query 的 $|C_k|$ 通常只有 1 ~ 2 个。

建立整数变量 $c_0, \ldots, c_{n-1}$,约束 $32 \le c_i \le 126$,并对每条 query 添加:

$$
\langle \mathrm{row}_k, c \rangle \in C_k
$$

50 条稀疏线性约束足以唯一确定 flag

(4) 数据格式与下标

程序输出一行:

1
[+] challenge data: <json>

JSON 主要字段:

字段 含义
num_slots_used flag 字节数 $n$
active_coeff_moduli 当前 RNS 模数链,取最后两项作为 $q_{n-1}, q_{n-2}$
delta, report_scale $\Delta$ 与 $R$
lwe_modulus, error_bound 模数 $p$ 与噪声界 $B$
rows[k][j] 第 $k$ 条 query 的稀疏系数,长度 $n$
g1[k], g2[k] 第 $k$ 条 query 的扰动系数
answers[k] 第 $k$ 条 query 的模加噪输出

运行以下脚本拉取远程输出并保存为 instance.json

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
import json
import re
import socket


def main():
host, port = "127.0.0.1", xxxxx
out_path = "instance.json"

with socket.create_connection((host, port), timeout=5.0) as s:
buf = b""
while True:
d = s.recv(65536)
if not d:
break
buf += d

txt = buf.decode(errors="replace")

m = re.search(r"\[\+\] challenge data: (\{.*\})", txt, re.S)
if not m:
raise SystemExit("[-] could not find challenge JSON in response")

obj = json.loads(m.group(1))
with open(out_path, "w", encoding="utf-8") as f:
json.dump(obj, f)

print(f"[+] saved {out_path}")


if __name__ == "__main__":
main()

(5) 完整解题脚本

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
import json
import re
from typing import List

from z3 import ArithRef, Int, IntVal, Or, Solver, Sum, sat

CHAR_MIN = 32
CHAR_MAX = 126

NOISE_SLACK = 256


def centered_mod(x: int, mod: int) -> int:
"""中心模运算:把结果映射到 [-mod/2, mod/2) 区间"""
x %= mod
return x - mod if x > mod // 2 else x


def mod_dist(a: int, b: int, mod: int) -> int:
"""计算模 p 下的中心距离"""
d = (a - b) % mod
return abs(d - mod) if d > mod // 2 else abs(d)


def parse_instance(text: str) -> dict:
"""从题目输出文本中解析 JSON 格式的挑战数据"""
m = re.search(r"\[\+\]\s*challenge data:\s*(\{.*\})", text, re.S)
if not m:
raise ValueError("challenge JSON not found")
return json.loads(m.group(1))


def scale_factor(inst: dict) -> float:
"""
核心:计算题目 BUG 造成的固定放大系数 H
H = delta⁴ / (q_{n-1} * q_{n-2} * report_scale)
"""
delta = inst["delta"]
report_scale = inst["report_scale"]
# 取模数链最后两个素数(两次 rescale 丢弃的模数)
q_last, q_prev = inst["active_coeff_moduli"][-1], inst["active_coeff_moduli"][-2]
return (delta ** 4) / (q_last * q_prev * report_scale)


def candidate_dots(inst: dict) -> List[List[int]]:
"""
对每条查询,枚举出合法的内积 s
s = row · flag
满足:观测答案 ≈ round(H * g1 * g2 * s) + e
"""
p = inst["lwe_modulus"]
# 总误差界 = 题目给定误差 + 额外余量
err = inst["error_bound"] + NOISE_SLACK
base = scale_factor(inst)

all_cands: List[List[int]] = []
# 遍历每一条查询
for row, g1, g2, ans in zip(inst["rows"], inst["g1"], inst["g2"], inst["answers"]):
# 本次查询的整体系数:H * g1 * g2
coef = base * g1 * g2
# s 的理论最大绝对值
bound = CHAR_MAX * sum(abs(x) for x in row)
# 枚举所有可能 s,筛选出距离足够近的候选
cands = [
s
for s in range(-bound, bound + 1)
if mod_dist(centered_mod(round(coef * s), p), ans, p) <= err
]
if not cands:
raise ValueError(f"empty candidate set at query {len(all_cands)}")
all_cands.append(cands)
return all_cands


def recover_flag(inst: dict) -> str:
"""
用 Z3 求解器:
给定 50 条稀疏线性约束 row·flag = s
求唯一满足所有约束的 ASCII 字节数组 flag
"""
n = inst["num_slots_used"]
# 获取每条查询的候选内积集合
cands = candidate_dots(inst)

# 定义 Z3 变量:flag 的每个字节 c_0 ... c_n
chars: List[ArithRef] = [Int(f"c_{i}") for i in range(n)]
solver = Solver()

# 约束 1:每个字节必须是可打印 ASCII
for c in chars:
solver.add(c >= CHAR_MIN, c <= CHAR_MAX)

# 约束 2:每条查询的内积必须落在候选集合里
for row, cand in zip(inst["rows"], cands):
lin = Sum([IntVal(row[j]) * chars[j] for j in range(n)])
solver.add(Or(*(lin == IntVal(v) for v in cand)))

# 求解
if solver.check() != sat:
raise RuntimeError("Z3 UNSAT")

# 把解转为字符串
model = solver.model()
return "".join(chr(model[c].as_long()) for c in chars)


def main():
with open("instance.json", "r", encoding="utf-8") as f:
inst = json.load(f)
print(recover_flag(inst))


if __name__ == "__main__":
main()

运行脚本得出 flag

1
W4terCTF{Us3_tHE_5cA13_WEIL_4nD_r3scalE_Wi7h_cARE!}

2.7 手撕平方根

看,我能手撕平方根!

(1) 题目分析

服务端生成 1024-bit RSA,公钥指数 $e = 2147483647 = 2^{31}-1$,明文为 flag || random_padding(总长 120 字节)。除输出 nc 外,还提供最多 50 次平方根 Oracle:输入 $a \in [0, 10000)$,若 $a$ 是模 $n$ 的二次剩余则返回一个平方根,否则报错。

关键代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
p, q = mpz(getPrime(1024)), mpz(getPrime(1024))
n = p * q
m = mpz(int.from_bytes(flag + urandom(120 - len(flag)), byteorder="big"))
c = powmod(m, 2147483647, n)

for i in range(50):
a = mpz(input())
sp, sq = Sqrt(a, p), Sqrt(a, q)
if sp is None or sq is None:
print(f"there does not exist an integer x such that x * x % n == {int(a)}")
else:
x = min(CRT(sp, sq), CRT(sp, -sq), CRT(-sp, sq), CRT(-sp, -sq))
print(f"the least non-negative integer x such that x * x % n == {int(a)} is {int(x)}")

模 $n = pq$ 下,二次方程 $x^2 \equiv a \pmod n$ 若有解,共有 4 个平方根(来自 $(\pm sp, \pm sq)$ 的 CRT 组合)。服务端固定返回其中 最小非负 的那一个。

(2) 分解原理

若找到 $x_0, x_1$ 满足:

$$
x_0^2 \equiv x_1^2 \pmod n, \qquad x_0 \not\equiv \pm x_1 \pmod n,
$$

则 $(x_0 - x_1)(x_0 + x_1) \equiv 0 \pmod n$,而 $n \nmid (x_0 - x_1)$、$n \nmid (x_0 + x_1)$,故:

$$
\gcd(x_0 - x_1, n) \in {p, q}
\quad\text{或}\quad
\gcd(x_0 + x_1, n) \in {p, q}.
$$

得到 $p, q$ 后即可正常 RSA 解密。本题的核心是:在 50 次查询内构造 同一数的两个不同平方根

(3) 构造两个不同的平方根

找三个小素数 $\alpha, \beta, \gamma$,满足:

  • 各自是模 $n$ 的 二次非剩余(Oracle 对单查返回不存在);
  • 两两乘积 $\alpha\beta,\ \alpha\gamma,\ \beta\gamma$ 均为 二次剩余

对每对乘积向 Oracle 查询,记:

$$
x = \sqrt{\alpha\beta},\quad y = \sqrt{\alpha\gamma},\quad z = \sqrt{\beta\gamma}.
$$

则:

$$
(xyz)^2 \equiv (\alpha\beta)(\alpha\gamma)(\beta\gamma) \equiv \alpha^2\beta^2\gamma^2 \equiv (\alpha\beta\gamma)^2 \pmod n.
$$

令:

$$
x_0 = xyz \bmod n, \qquad x_1 = \alpha\beta\gamma,
$$

两者都是 $(\alpha\beta\gamma)^2$ 的平方根。由于 $x_0$ 由 Oracle 返回值相乘得到、$x_1$ 是小于 $10000$ 的整数,通常 $x_0 \not\equiv \pm x_1 \pmod n$,一次 $\gcd$ 即可分解。

(4) 查询流程

在 $[2, 100)$ 的小素数中随机扫描,维护三条并行 。每条链形如:

1
[(α, []), (β, [√(αβ)]), (γ, [√(αγ), √(βγ)]), ...]

对素数 $p$:

  1. 若 $p$ 是二次剩余,跳过;
  2. 若某条链为空,将 $p$ 作为链首(第一个非剩余);
  3. 否则查询 $p \cdot \text{链首}$:若仍是非剩余则换链;若是剩余则把 $p$ 加入链,并记录 Oracle 返回的平方根;
  4. 链长 $\ge 3$ 时,对新加入的 $p$ 再查 $p \cdot \text{链中第 } k \text{ 个素数}$,按公式计算候选 $(x_0, x_1)$ 并尝试 $\gcd$。

三条链对应不同的链首非剩余,提高在 50 次内凑齐合法三元组的概率。实测通常 10 次以内 即可分解。

(5) 完整解题脚本

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
import random
import re
import socket

from Crypto.Util.number import GCD, inverse, isPrime, long_to_bytes

# 目标服务端地址
HOST = "127.0.0.1"
PORT = xxxxx


def recv_until(sock: socket.socket, marker: bytes, limit: int = 200_000) -> bytes:
"""辅助函数:从 socket 接收数据直到遇到指定标记"""
data = b""
# 循环读取数据,直到找到标记或超出长度限制
while marker not in data and len(data) < limit:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
return data


def recv_line(sock: socket.socket) -> bytes:
"""辅助函数:读取一行数据"""
data = b""
while b"\n" not in data:
chunk = sock.recv(4096)
if not chunk:
break
data += chunk
# 按换行符分割,返回第一行
return data.split(b"\n", 1)[0]


def query_sqrt(sock: socket.socket, a: int) -> int | None:
"""核心交互函数:向服务端发送数字 a,请求其平方根"""
# 发送请求
sock.sendall(f"{a}\n".encode())
# 读取返回结果
line = recv_line(sock)
# 正则提取结果中的平方根
m = re.search(rb"is (\d+)$", line)
return int(m.group(1)) if m else None


def factor_with_oracle(sock: socket.socket, n: int) -> tuple[int, int]:
"""核心算法:利用平方根预言机构造碰撞,分解模数 n"""
# 生成 2-100 之间的素数列表,用于尝试构造三元组
primes = [i for i in range(2, 100) if isPrime(i)]
random.shuffle(primes)

# 每条链格式:[(素数 p, [p 与链内其他素数乘积的平方根...]), ...]
chains: list[list[tuple[int, list[int]]]] = [[], [], []]

# 遍历小素数,开始构造链
for p in primes:
# 如果 p 本身是二次剩余,跳过(我们需要非剩余)
if query_sqrt(sock, p) is not None:
continue

# 尝试将当前素数 p 加入某一条链
for chain in chains:
# 如果链为空,将 p 作为这条链的起点
if not chain:
chain.append((p, []))
break

# 查询 (链首素数 * p) 的平方根
root = query_sqrt(sock, chain[0][0] * p)
# 如果结果不是剩余(无平方根),换一条链尝试
if root is None:
continue

# 找到了!(链首素数 * p) 是剩余,将 p 加入链
chain.append((p, [root]))

# 遍历链中已有的素数,组合生成新的平方根
for k in range(1, len(chain) - 1):
# 查询 p 和链中第 k 个素数的乘积的平方根
root_k = query_sqrt(sock, p * chain[k][0])
chain[-1][1].append(root_k)

# 关键:组合三个平方根,构造碰撞
for u in range(k):
# 计算 x = sqrt(p * pk) * sqrt(p * pu) * sqrt(pk * pu)
x = chain[-1][1][k] * chain[-1][1][u] * chain[k][1][u] % n
# 计算 y = p * pk * pu
y = chain[-1][0] * chain[k][0] * chain[u][0]

# 此时 x ^ 2 ≡ y ^ 2 (mod n),利用 gcd 分解 n
for diff in (x - y, x + y):
g = GCD(diff % n, n)
# 找到一个有效的因子
if 1 < g < n:
return g, n // g
# 一条链处理成功,跳出循环
break

raise RuntimeError("failed to factor within query budget")


# 辅助函数:从解密后的明文中提取 flag 字符串
def extract_flag(m_int: int, n: int) -> bytes:
k = (n.bit_length() + 7) // 8
# 将大整数转为原始字节
blob = long_to_bytes(m_int, k)
# 查找 flag 特征前缀并截取
start = blob.find(b"W4terCTF{")
if start == -1:
return blob
end = blob.find(b"}", start)
return blob[start: end + 1] if end != -1 else blob[start:]


def main():
# 连接远程服务
sock = socket.create_connection((HOST, PORT), timeout=10)
sock.settimeout(10)

# 接收欢迎信息,提取 RSA 公钥 n 和密文 c
banner = recv_until(sock, b"Feel free")
ns = re.search(rb"n = (\d+)", banner)
cs = re.search(rb"c = (\d+)", banner)
if not ns or not cs:
raise RuntimeError("failed to parse n/c")
n, c = int(ns.group(1)), int(cs.group(1))

# 核心:调用函数,利用 Oracle 分解 n 得到 p, q
p, q = factor_with_oracle(sock, n)
if p > q:
p, q = q, p

# 标准 RSA 解密流程
e = 2147483647 # 题目给定的公钥指数 e = 2 ^ 31 - 1
phi = (p - 1) * (q - 1)
d = inverse(e, phi) # 计算私钥 d
m = pow(c, d, n) # 解密密文得到明文 m

# 提取并打印 flag
print(extract_flag(m, n).decode(errors="replace"))


if __name__ == "__main__":
main()

运行脚本得出 flag

1
W4terCTF{H0W_TO_1aCtorI23_a_NUm83R_w1th_Qu4dRAT1c_r3sldU3}

3 Pwn

3.4 等长条

你好,玩家。欢迎回到 W4terCTF 游戏厅:Capture The Fun!

在过去的几年里,我们曾一起在 2048、贪吃蛇里冲刺高分,也共同在扫雷、推箱子中步步为营 …… 是大家的努力,让 W4terCTF 游戏厅仍然屹立不倒,依旧蒸蒸日上!

而在 4 月 26 日,我们游戏厅也将展开新的篇章。没错,听好了 — 游戏厅就此陷落(倒闭)!都怪你们一年里只来一个星期,我们连发奖金的钱都没了。现在只剩下这台 Beyond 难度的俄罗斯方块,你们凑合玩去吧。

1
W4terCTF{I've_833n_waITIn6_FOr_4935!_h4S_THE_PRO8A81LI7y_6Een_5eCREt1y_chaN6eD?!}

3.5 能力开发・缺陷电气

数据不保证,1 ≤ q ≤ 100,开始的字符串长度 ≤ 100。

1
W4terCTF{WH3n_aLl_GUAR4n7EeS_V4nIsH,_evErythInG_CO1lAPs3S}

3.6 能力开发・路径穿过

穿过迷瘴,看到真相。

1
W4terCTF{wAIK_THrOu6h_tHE_P47h_tO_bEhoID_A_bE7Ter_vIEW}

3.7 能力开发・鸟瞰把握

天空上的鸟儿,它们会看到什么?

1
W4terCTF{I_s3aRcH_the_woR1d_fOr_yoU,_wI5H1nG_t0_5C4N_TH3_r3S7_wi7h_M3}

4 Web

4.2 Safe Store

Rust 以安全著称,既然通过了编译,那这个商店就是安全的 …… 吗?

1
W4terCTF{c0MPiLa7lON_Doe5_n0T_m34N_SAFe7y}

4.4 ez blog

小李子的博客经历了不停的迁移,某天小李子突然看到了博客的远古版本备份,但是他用正确的账号密码怎么登录不上去???小李子最后发现这个 blog 没有他想象中那么 ez …

1
W4terCTF{whY_WOu1d_A_r3V3rs3_shE1l_AnD_cve_4PPeaR_In_A_8lO9?}

4.7 乌萨奇的硬币

Usagi 撿到了一枚奇怪的硬幣,Hachiware 試著投擲了幾次,發現正反面的概率竟然不一樣!硬幣的側面有一行小字:“flag を探しているって聞いた?正面の確率を推測してみて!”

1
W4terCTF{lo77Ry_1S_faK3_aND_PRopeR_use_O1_S355ION_CO0kle_Or_NAn_CAn_5Olv3_Th3_PR0BI3M}

5 Reverse

5.1 Bluntstone

Tarnished, a hardened Bluntstone blocks your path. Shatter its compressed shell to reveal the shifting logic within. Reverse the chaotic magic and reclaim your lost Grace.

1
W4terCTF{3VeN_A_bIun7s7oNE_can_DE1IEct_60DS}

5.2 FunSokoban

请逆向分析附件,找出一段正确的操作序列,使程序进入成功分支。

你需要提交的是触发成功输出所需的输入字符串。flag 格式为:W4terCTF{输入字符串}。

1
W4terCTF{dwwwaawdsdsssaawwssddwwwawaasdsssddwwwdwaadssssaawwwdwdsswaaawdf}

5.3 Strange puts

对、对吗?哦不对不对 😵‍💫。

1
W4terCTF{NIc3_CA7CH_THa7_pTr_WaS_5WAPpEd_aI1_AI0n9}

5.4 Vigorcheck

The Tree Sentinel stands as the first trial for any Tarnished who dares set foot in the Lands Between. He wields three devastating strokes to shatter the resolve of the weak. Canst thou fell the golden guardian and claim thy Grace?

1
W4terCTF{F0U1_7ArNIsh3D_P4sSED_7h3_vIgOr_cHECk!}

5.5 开始电力运输

《终末地的脉动:第十三号能源网络的重建》

在“佩丽卡”这片被历史遗忘的大地上,风总是带着铁锈和源石尘埃的苦涩味道。对于阿戈尔而言,这里既是流放之地,也是重生的摇篮。巨大的工业遗迹如同远古巨兽的骸骨,横亘在红色的荒原之上,而在这些骸骨之间,技术开拓者们正用双手编织着新的神经网络。

故事发生在一处代号为“扇区-09”的资源采集点。这里曾经是旧时代繁华的能源中枢,但在漫长的历史断层中,它早已沉寂,只有那些锈迹斑斑的输电塔还依稀保留着往日的威严。开拓者小队抵达这里时,迎接他们的只有死一般的寂静和警报灯微弱的闪光。这次的任务很明确,却异常艰巨:重启 扇区-09 的核心供电系统,并将周边荒废的前哨站重新接入主网络。

拉电线,听起来是一件枯燥乏味的基础体力活,但在终末地,这项工作被称为“接引生命线”。每一根沉重的线缆都承载着数万伏特的源石动能,它们不仅仅是传输电流的介质,更是连接各个分散据点的纽带。没有了这些线缆,环境改造机无法运转,滤净塔无法提供呼吸用的空气,那些在此地驻守的工程师们也将被黑暗与严寒吞噬。

女主角,年轻的开拓组组长,站在布满碎石的高地上。她的视野尽头是 扇区-09 的主控室,那里有一座巨大的变电站,像是一颗停止跳动的心脏。她手中的终端屏幕闪烁着,显示着错综复杂的电路拓扑图。她深吸一口气,通过通讯频道下达了指令:“铺设组,准备进行最后一段的架设。这根线缆一旦接通,第 13 号能源网络就能全线点亮。”

风沙骤起,打在防护服的玻璃面罩上,发出细碎的声响。重型工程机甲缓缓启动,它们的机械臂上缠绕着粗大的黑色管线。这不是普通的数据线,而是能够承受极端环境的输电主干。机甲操作员必须拥有极高的精准度,因为在狂风和崎岖地形的干扰下,将线缆对接到只有几毫米宽的接口上,无异于在风暴中穿针引线。

随着绞盘的轰鸣声,第一根线缆被拉向了远方的塔架。线缆在空中绷直,发出沉闷的低鸣,仿佛是一张巨大的弓被拉满。这就是“拉电线”的艺术 —— 它是对物理法则的挑战,也是对意志力的考验。每一个拉扯的动作都需要协同,前方的机甲在探路,后方的工程兵在固定支点,中间的观察员时刻监视着线缆的张力,防止因为过紧而崩断,或者因为过松而触地磨损。

在这个过程中,开拓者们面对的不仅仅是物理上的困难。扇区-09 的地下深处隐藏着不稳定的源石矿脉,电磁风暴时刻威胁着线路的安全。有一次,一场突如其来的地磁暴让刚刚架设好的两百米线路瞬间过载,绝缘层冒出了青烟。如果不及时切断并重新铺设,整个前哨站的备用能源都会被烧毁。

“稳住!不要慌!”组长在频道中大声喊道。她冲了上去,手动开启了紧急隔离阀。那一刻,蓝色的电弧在她头顶炸裂,将昏暗的荒原照得亮如白昼。她手动调整了变压器的输出功率,利用旧时代遗留的稳压技术,强行驯服了狂暴的电流。她的手套被高温烫得发黑,但她没有后退半步。当电流重新平稳地流过线缆时,她感觉到的不是恐惧,而是一种源自工业理性的震撼——电流在流动,意味着信息在流动,意味着文明在流动。

经过三天三夜的连续奋战,最后一根主电缆终于抵达了变电站的接口。

那个瞬间,是整段工程的高潮。所有的工程机甲都停止了轰鸣,所有的开拓者都屏住了呼吸。组长颤抖着双手,将插头对准了那深邃的接口。随着“咔哒”一声清脆的机械咬合声,仿佛命运的齿轮重新啮合。

线路接通了。

刹那间,灯光如多米诺骨牌般沿着塔架一层层亮起,原本漆黑的 扇区-09 瞬间被点亮。巨大的输电塔顶端的警示灯开始有节奏地闪烁,红色的光穿透了漫天的风沙,向远处的荒原宣告着人类的归来。电流顺着新铺设的线缆奔向远方的前哨站,那些沉寂的机器重新苏醒,滤净塔开始轰鸣,温暖的灯光驱散了室内的寒冷。

但这不仅仅是光明的降临。在变电站的屏幕上,一组组数据开始疯狂跳动。这是通信数据的潮汐,是佩丽卡协议的握手信号。通过这根物理上的电线,数字逻辑开始在网络中蔓延。分散在各地的服务器、数据库、自动防御系统,因为这一根根导线的连接,重新形成了一个统一的智能体。

组长瘫坐在碎石地上,摘下头盔,任由汗水流下。她看着远处的通明灯火,看着那些横跨天际的黑色线缆,它们在夜色中显得格外迷人。这就是终末地的浪漫——用钢铁和铜线,在绝望中编织希望。每一次接线,都是在对这片说“不”的世界说“是”。电流的嗡嗡声在耳边回响,听起来就像是这片大地重新恢复的心跳声。

在这片荒原上,没有谁是孤岛。只要电线还在连接,信号还在传输,文明的火种就不会熄灭。这便是技术开拓者的誓言,也是每一个在风沙中拉线的人,内心最深的骄傲。

1
W4terCTF{COnN3C7_0NE_8Y_OnE_wiTH1N_boM_th4t_15_EAsY_70_PerfoRM_8UT_l_PreF3r_u5In6_4_x1ranl7E_Re14Y_iNS7EAd}

6 Forensics

6.1 损坏的 Sysprep

小李子从「LilRan 德厚门」网站上下载了一个 Windows 镜像想要重装系统,但他发现无人值守文件被删掉了,到底要怎么办口牙!

访问网页后得到了 install.wim 文件。这是 Windows 的标准映像文件,里面可能打包了多份映像。

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
scc@Ubuntu:/data$ wiminfo install.wim
WIM Information:
----------------
Path: install.wim
GUID: 0x4a14c8dcf7960a55db18143f8b82a91a
Version: 68864
Image Count: 2
Compression: LZX
Chunk Size: 32768 bytes
Part Number: 1/1
Boot Index: 0
Size: 1139056 bytes
Attributes: Relative path junction

Available Images:
-----------------
Index: 1
Name: Windows 10 Pro
Description: Windows build 19045
Directory Count: 6
File Count: 1
Total Bytes: 36
Hard Link Bytes: 0
Creation Time: Mon Apr 27 02:12:05 2026 UTC
Last Modification Time: Mon Apr 27 02:12:05 2026 UTC
WIMBoot compatible: no

Index: 2
Name: Windows PE
Description: ES5S temporary recovery disk
Directory Count: 1
File Count: 1
Total Bytes: 10485760
Hard Link Bytes: 0
Creation Time: Mon Apr 27 02:12:05 2026 UTC
Last Modification Time: Mon Apr 27 02:12:28 2026 UTC
WIMBoot compatible: no

可以看到有 2 个映像。其中 Windows 10 Pro 只有 36 字节,是个空壳。把第二个 Windows PE 挂载下来:

1
2
3
4
5
6
scc@Ubuntu:/data$ mkdir mount
scc@Ubuntu:/data$ wimextract install.wim 2 / --dest-dir mount
Extracting file data: 10 MiB of 10 MiB (100%) done
Done extracting files.
scc@Ubuntu:/data$ ls /data/mount
windows_sysprep.img

如果在 Windows 下查看,双击该文件会提示“光盘映像文件已损坏”。

1
2
scc@Ubuntu:/data/mount$ file windows_sysprep.img
windows_sysprep.img: DOS/MBR boot sector, code offset 0x58+2, OEM-ID "mkfs.fat", sectors 20480 (volumes <=32 MB), Media descriptor 0xf8, sectors/track 32, FAT (32 bit), sectors/FAT 158, serial number 0x5f512357, unlabeled
1
W4terCTF{dEIe7inG_do3s_nO7_meaN_de1Et3d}

7 PPC

7.3 日历・序章

小 T 在不断练习括号序列匹配问题。为了让练习更容易一些,小 T 在每天会根据日历执行一个简单的操作。这样的练习一直持续到 2026 年 12 月 31 日,然而小 T 盼望的 2027 年没有到来。相反,他回到了 2026 年 1 月 1 日。

小 T 瞬间意识到,自己进入了一个时间循环。他希望找到一个新的日历,使得即使在时间循环中也能正常完成练习。显然,小 T 不会规划每天需要执行的操作,因此他希望你能为他设计一个新的日历。

身处时间循环之外的你可以从附件中查看循环和操作的细节。

1
W4terCTF{cONGrA7uI4tions_FOR_yOUr_FirSt_ca1eND4R_pRogr4M_k33p_0N_to_Th3_nex7_0N3}

9 OSINT

9.1 Find Me

通常来说,在 OSINT 题里只要找到图中大致的位置就可以了。但是在 W4terCTF 的赛场上,似乎一切都需要更加精确才行 … ?

flag 的内容:图中拍摄者所在地的经纬度,纬度在前经度在后,精确到小数点后七位。

flag 样例:W4terCTF{12.3456789S,98.7654321E}

图片中带有 Google 水印,可以确定来源为谷歌街景地图。观察图片右侧的路牌提取关键信息:道路编号为 N277 ,主路通往 ZeelandUden 两地,同时可见 WaWi 开头的不完整地名标识。

打开 谷歌地图:搜索 N277,发现该道路位于荷兰,但无法具体定位。搜索 ZeelandUden 可以匹配到具体的城市。缩小地图发现下图所示的路口符合条件,同时确认了不完整字样为 WanroijWilbertoord 这两处地点。

点击该路口,进入街景模式。反复尝试发现在该点位选择合适的角度可以完美匹配原图,读取网址上的经纬度。

按照指定格式构造 flag

1
W4terCTF{51.6329259N,5.7476876E}

9.3 无论到哪都爱吃麦麦

Hachiwa0 在旅途中饥肠辘辘,走着走着竟然看到麦当劳!他毫不犹豫地点了麦麦大快朵颐,这是他吃过的最好吃的一顿麦当劳。许久之后,他翻到了这张照片,却不记得这到底是哪里了。请帮 Hachiwa0 找到这家麦当劳的电话号码和经纬度。

flag 的内容:图中麦当劳的十位电话号码,图中 Hachiwa0 所在地的经纬度,纬度在前经度在后,精确到小数点后四位。

flag 样例:W4terCTF{1145141919_88.8888N_88.8888E}

图的关键信息集中在左下角的餐车,但实在过于模糊无法辨认出与地点有关的信息。利用 谷歌识图 找到了相似的店铺。

打开谷歌地图,搜索 McDonald's Kokusaidori Makishi 找到了店铺,并确定了店铺名称为 国際通り牧志店

进入街景模式,走进左边的小路,匹配成功!读取网址上的经纬度,精确到小数点后四位。

下方给出了电话号码,但尝试过 81988690719988690719 均不正确(不是十位)。

后来在 日本麦当劳官网 上看到了符合要求的电话号码。

按照指定格式构造 flag

1
W4terCTF{0988600719_26.2168N_127.6890E}