SHCTF_3rd_writeup


TE

题目分析

题目提供了一个 Python 脚本 task.py,其中包含以下关键信息:

  1. 生成了两个 512 位的素数 pq,计算模数 n = p * q

  2. 生成了两个随机的公钥指数 e1e2

  3. 对同一个明文 flag 使用不同的公钥指数 e1e2 进行加密,得到两个密文 c1c2
    $$
    c_1 \equiv m^{e_1} \pmod n
    $$

    $$
    c_2 \equiv m^{e_2} \pmod n
    $$

  4. 给出了 e1, e2, n, c1, c2 的具体数值。

漏洞原理:共模攻击 (Common Modulus Attack)

当同一个明文 $m$ 使用相同的模数 $n$ 但不同的公钥指数 $e_1, e_2$ 进行加密时,如果 $e_1$ 和 $e_2$ 互素(即 $\gcd(e_1, e_2) = 1$),攻击者可以在不分解 $n$ 的情况下直接恢复明文 $m$。

原理如下:
根据扩展欧几里得算法(Extended Euclidean Algorithm),对于互素的 $e_1$ 和 $e_2$,一定存在整数 $s_1$ 和 $s_2$ 满足:
$$
s_1 e_1 + s_2 e_2 = 1
$$
利用模运算的性质:
$$
c_1^{s_1} \times c_2^{s_2} \equiv (m^{e_1})^{s_1} \times (m^{e_2})^{s_2} \pmod n
$$

$$
\equiv m^{e_1 s_1} \times m^{e_2 s_2} \pmod n
$$

$$
\equiv m^{e_1 s_1 + e_2 s_2} \pmod n
$$

$$
\equiv m^1 \pmod n
$$

$$
\equiv m \pmod n
$$

注意:由于 $s_1$ 和 $s_2$ 中必有一个为负数,计算 $c^s \pmod n$ 时,如果 $s < 0$,需要先计算 $c$ 的模逆元 $c^{-1} \pmod n$,然后计算
$$
(c^{-1})^{-s} \pmod 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
from Crypto.Util.number import long_to_bytes

# 题目给出的数据
e1 = 740153575
e2 = 2865243571
n = 136622832042809215646904518487100682818433235485047740604612449039291802103378650845690420527029208661555957840623544220907967041438993189882681277161437473818861280518627112617436473837014181944318974950710633690704711613682306786783611123590732850783007770603201513394002330426718261667816328404673167404897
c1 = 56187319559060690757544481076112948328826527679002578544683022765347668056620384831778729489197135280950314627119815558644487151419126272267146826463912815062442590228193753706779325992179790583792001196548329204758137104234662611732735693150331594645734142941475121453410494160975503459516324097097434727685
c2 = 45042409947237296641429229414329516753664139389113206575966507524195434716702812078844474626406932213486611190698953613898299571473488550533642524208077653917354039305279692307471529748408234617430389423630015569730564585740596832844917494965974840512412454337766930330443409183293514761911902752336129193323

# 扩展欧几里得算法,返回 (g, x, y) 使得 ax + by = g
def egcd(a, b):
if a == 0:
return (b, 0, 1)
else:
g, y, x = egcd(b % a, a)
return (g, x - (b // a) * y, y)

# 计算模逆元
def modinv(a, m):
g, x, y = egcd(a, m)
if g != 1:
raise Exception('modular inverse does not exist')
else:
return x % m

# 1. 计算 e1 和 e2 的 Bezout 系数 s1, s2
# g = gcd(e1, e2) = s1*e1 + s2*e2
g, s1, s2 = egcd(e1, e2)

print(f"gcd(e1, e2) = {g}")

if g == 1:
# 2. 如果 s1 或 s2 为负数,需要先计算对应 c 的模逆元
# pow(c, s, n) 函数在 s < 0 时在较新版本的 Python/gmpy2 中可能支持,
# 但为了手写逻辑清晰,这里手动处理负指数。

val1 = pow(c1, s1, n) if s1 > 0 else pow(modinv(c1, n), -s1, n)
val2 = pow(c2, s2, n) if s2 > 0 else pow(modinv(c2, n), -s2, n)

# 3. 计算明文 m = c1^s1 * c2^s2 mod n
m = (val1 * val2) % n

flag = long_to_bytes(m)
print(f"Flag: {flag.decode()}")
else:
print("这不是标准的共模攻击场景 (gcd != 1)")

运行结果

运行结果如下:

1
2
gcd(e1, e2) = 1
Flag: SHCTF{lYQkkk3ud4hqV3fZtPWH077vhI2Bqcz19ZRxf1vwRU8Ej4uvrJcF02Sd4bzjxqUH5096qWDIdTyEJ$JzF}

Ez_RSA

题目分析:Wiener’s Attack

题目提供了一个 Python 脚本 193540_chall.py,其中给出了 RSA 的公钥参数 n, e 以及密文 c

代码片段如下:

1
2
3
4
5
6
p = getPrime(512)
q = getPrime(512)
n = p*q
# ...
e = getPrime(1019)
d = invert(e, phi)

观察参数特点:

  1. n 是由两个 512 位的素数生成的,长度约为 1024 位。
  2. e 是一个生成的 1019 位的素数。

漏洞点:
通常 RSA 的 e 选取较小的值(如 65537),但在本题中 e 非常大,接近模数 n 的大小。
根据 RSA 的密钥生成公式:
$$
ed \equiv 1 \pmod{\phi(n)} \implies ed - k\phi(n) = 1
$$
e 很大时,对应的私钥 d 往往很小。

如果满足 $d < \frac{1}{3}N^{\frac{1}{4}}$,我们可以利用 Wiener’s Attack(维纳攻击)来还原 d。维纳攻击的核心思想是 e/nk/d 的一个最佳有理逼近,可以通过计算 e/n 的连分数渐近分数来尝试找到 d

解题思路

  1. 提取题目中的 n, e, c
  2. 编写脚本对 e/n 进行连分数展开,生成一系列渐近分数。
  3. 遍历每一个渐近分数的分母作为潜在的私钥 d
  4. 使用候选的 d 计算潜在的 \phi(n),进而解一元二次方程验证是否能分解 n(或者直接尝试解密 c 并判断结果是否符合 Flag 格式)。
  5. 找到正确的 d 后,计算
    $$
    m = c^d \pmod 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
51
52
53
54
55
56
57
58
59
import math
from Crypto.Util.number import long_to_bytes

# 题目给出的参数
n = 107464134871680646151655304067173578951022679613817744422854142736895193478923970402314237869266898585661396817719803005109183572552933963881756199330890085692291647461683934019264121186823772581796061998307778635680038707808422026396560620912393186072263186503236380890048319797143644270579169484448179083299
e = 3924586561728843234261049280560557566669922961436496251423249382498887294225142535297862819865029081145630384268177735578769958711287734205364353929040337350836000661255957087233897675207507752217828489549059197109918195953230752720210793300168746820366115929509596904295875481061789801178045962611893883689
c = 4557192604704814579224198928010541193712311907197292139423304635523945088581321950910727673367241811197226152299201713883344661436550024661781925551129803469824570154317098612833694631836257698682075695287756551674264966935203485636255394639674521955953445322493019052791894426980946209383266707043869522774

def fast_continued_fraction(num, den):
""" 计算 num/den 的连分数展开 """
while den:
q = num // den
yield q
num, den = den, num % den

def wiener_attack(e, n):
"""
Wiener's Attack
通过连分数渐近分数寻找私钥 d
"""
n0, d0 = 0, 1
n1, d1 = 1, 0

# 遍历连分数商 q
for q in fast_continued_fraction(e, n):
# 计算当前的渐近分数 n_next/d_next = k/d
n_next = q * n1 + n0
d_next = q * d1 + d0
n0, d0 = n1, d1
n1, d1 = n_next, d_next # 更新前两项

# d_next 就是我们推测的私钥 d
d_guess = d1
k_guess = n1

if k_guess == 0: continue

# 验证 phi 是否为整数: phi = (ed - 1) / k
if (e * d_guess - 1) % k_guess == 0:
phi = (e * d_guess - 1) // k_guess

# 简单的验证:尝试用推测的 d 解密
# 如果是正确的 d,解密结果应该包含 Flag 格式或者可读字符
m_guess = pow(c, d_guess, n)
try:
flag_bytes = long_to_bytes(m_guess)
if b'SHCTF' in flag_bytes:
print(f"[+] Found d: {d_guess}")
return flag_bytes
except:
continue
return None

print("Running Wiener's Attack...")
flag = wiener_attack(e, n)
if flag:
print(f"Flag: {flag.decode()}")
else:
print("Attack failed.")

运行结果

运行上述脚本,成功解出私钥 d 并还原明文。

1
d = 13381010831988668939315406679736078064417157389064253385691768500704978358617

Flag:

1
SHCTF{e950ea87356fc62ce6323253a672680e}

Stream

题目分析:LCG 流加密破解

题目提供了一个 Python 脚本 task.py,实现了一个基于线性同余生成器 (LCG) 的流加密算法。

加密流程如下:

  1. 生成参数:随机生成 LCG 的参数 m(63位), a(62位), c(62位), s_0(62位)。
    • m 强制为 63 位且最高位为 1。
  2. 生成密钥流:使用 LCG 公式
    $$
    x_{i+1} = (a \cdot x_i + c) \pmod m
    $$
    生成一串随机数。
  3. 加密
    • 将明文 $P$ 填充并按 8 字节分块。
    • 每一块明文与对应的 LCG 状态值(密钥流)进行异或运算得到密文。

已知信息:

  • 明文 P_known (48字节,即 6 个块)。
  • 对应的密文 C_known
  • Flag 的密文 C_flag

漏洞原理

LCG (Linear Congruential Generator) 是一种简单的伪随机数生成器,其安全性依赖于参数的保密性。但在已知部分输出序列的情况下,其参数很容易被破解。

攻击步骤:

  1. 恢复密钥流样本
    已知
    $$
    P_{known}
    $$

    $$
    C_{known}
    $$
    ,利用异或的性质
    $$
    A \oplus B = C \implies A \oplus C = B
    $$
    ,我们可以直接恢复出前 6 个 LCG 的状态值:
    $$
    S_i = P_i \oplus C_i
    $$
    得到序列
    $$
    S_0, S_1, S_2, S_3, S_4, S_5
    $$

  2. 攻击模数 $m$
    由 LCG 定义:
    $$
    S_{i+1} \equiv a \cdot S_i + c \pmod m
    $$

    $$
    S_{i+2} \equiv a \cdot S_{i+1} + c \pmod m
    $$

    两式相减消除 c:
    $$
    S_{i+2} - S_{i+1} \equiv a \cdot (S_{i+1} - S_i) \pmod m
    $$

    $$
    D_i = S_{i+1} - S_i
    $$
    ,则有:
    $$
    D_{i+1} \equiv a \cdot D_i \pmod m
    $$
    进而可以推导:
    $$
    D_{i+1} \cdot D_{i-1} \equiv a \cdot D_{i} \cdot D_{i-1} \equiv D_i^2 \pmod m
    $$
    即 m 必须整除
    $$
    (D_{i+1} \cdot D_{i-1} - D_i^2)
    $$

    我们可以计算多个这样的行列式值
    $$
    T_i = D_{i+1} \cdot D_{i-1} - D_i^2
    $$
    ,然后计算它们的最大公约数 (GCD),即可恢复 m。

  3. 攻击乘数 a 和增量 c
    已知 m 后,利用关系
    $$
    D_{i+1} \equiv a \cdot D_i \pmod m
    $$
    ,可求逆元得到 a:
    $$
    a \equiv D_{i+1} \cdot (D_i)^{-1} \pmod m
    $$
    求出 a 后,代入原方程求 c:
    $$
    c \equiv S_{i+1} - a \cdot S_i \pmod m
    $$

  4. 解密 Flag
    有了
    $$
    m, a, c
    $$
    和当前的最后一个状态
    $$
    S_5
    $$
    ,我们就可以继续模拟 LCG 生成后续的密钥流,与 C_flag 异或即可还原 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
from functools import reduce
from math import gcd

# 题目数据
P_known = b'Insecure_linear_congruential_random_number!!!!!!'
C_known_hex = "44e18dfa1acd14aa790fc3bac4ca54c137bcd47bdfc2209a53b83715ecad3e29249845720588cac007bfb94f8476d91a"
C_flag_hex = "1995374a5b64c6696578c1d5bdc6fa3d1e974b813436eab4348db801fb7a6703658eaa4fefa2c6fd6792beb969df8ca70ad87a4f4aea6ca0040d65a3c1e3a5bf2655cafc1e5603a171edc9aa077c0ca264677c351907f35756c14dd7ece428cb424a3804b544ccb53e99935f9bc2d8483dd7587379c99b3542c222008a"

def solve():
# 1. 恢复密钥流
ck = bytes.fromhex(C_known_hex)
known_len = len(P_known)

# 将字节转换为 64位整数块
p_blocks = [int.from_bytes(P_known[i:i+8], 'big') for i in range(0, known_len, 8)]
c_blocks = [int.from_bytes(ck[i:i+8], 'big') for i in range(0, known_len, 8)]

# 异或得到随机数序列 x
x = [p ^ c for p, c in zip(p_blocks, c_blocks)]
print(f"[+] Recovered {len(x)} states")

# 2. 攻击参数 m
# 计算差分
diffs = [x[i+1] - x[i] for i in range(len(x)-1)]

# 计算 T = diff[i]*diff[i+2] - diff[i+1]^2
# 注意:前面的推导用了 D_{i-1}, D_{i}, D_{i+1}
# 这里 diffs 索引从 0 开始,即 D_0, D_1, D_2...
# 关系是 D_{k+1} = a * D_k mod m
# 所以 D_{k+1} * D_k - D_k * D_{k+1} = 0 (没用)
# 用 D_{k+2} * D_k - D_{k+1}^2 ?
# D_{k+1} = a D_k
# D_{k+2} = a D_{k+1} = a^2 D_k
# D_{k+1}^2 = (a D_k)^2 = a^2 D_k^2
# D_{k+2} * D_k = (a^2 D_k) * D_k = a^2 D_k^2
# 两者相等模 m,所以差值是 m 的倍数

vals = []
for i in range(len(diffs) - 2):
val = diffs[i] * diffs[i+2] - diffs[i+1]**2
vals.append(abs(val))

m = reduce(gcd, vals)
print(f"[+] Recovered m: {m}")

# 3. 攻击参数 a, c
# a = diff[1] / diff[0] mod m
try:
a = (diffs[1] * pow(diffs[0], -1, m)) % m
print(f"[+] Recovered a: {a}")
except ValueError:
print("[-] Failed to invert diff[0]")
return

# c = x[1] - a*x[0] mod m
c = (x[1] - a * x[0]) % m
print(f"[+] Recovered c: {c}")

# 4. 解密 Flag
# 最后一个已知状态
state = x[-1]

c_flag = bytes.fromhex(C_flag_hex)
flag_data = b""

# 处理完整块 (8 bytes)
full_blocks = len(c_flag) // 8
for i in range(full_blocks):
state = (a * state + c) % m
# 取密文快
cipher_block = int.from_bytes(c_flag[i*8 : (i+1)*8], 'big')
plain_block = cipher_block ^ state
flag_data += plain_block.to_bytes(8, 'big')

# 处理剩余字节
rem = len(c_flag) % 8
if rem > 0:
state = (a * state + c) % m
# 此时密钥流仍然取前 rem 个字节(高位)
# 原始加密代码是 block ^ k,block 是从 pt 转换来的整数
# 对于最后不足 8 字节的,加密时补了 \x00
# 但我们这里直接拿到的密文流是对齐的字节串
# 解析最后剩余的密文
last_cipher_part = c_flag[full_blocks*8:]
keystream_bytes = state.to_bytes(8, 'big')

for i in range(rem):
flag_data += bytes([last_cipher_part[i] ^ keystream_bytes[i]])

print(f"[+] Flag: {flag_data}")

if __name__ == "__main__":
solve()

运行结果

运行上述脚本,输出:

1
2
3
4
5
[+] Recovered 6 states
[+] Recovered m: 9114611192019957857
[+] Recovered a: 2371820993816156899
[+] Recovered c: 2725942441002268621
[+] Flag: b'SHCTF{LLLLLLLLLLLLLLLCCCCCGGGGGGGGG_TGY%JgWOmAM6V5n55w3m*jcPJZjHO8E1VvzrGjT84tXS332D&o4GZe8%KKzEyAngmwwx9bp5dv_O4dPpOvMy1^hM}'

Flag:
SHCTF{LLLLLLLLLLLLLLLCCCCCGGGGGGGGG_TGY%JgWOmAM6V5n55w3m*jcPJZjHO8E1VvzrGjT84tXS332D&o4GZe8%KKzEyAngmwwx9bp5dv_O4dPpOvMy1^hM}

evan

题目给了一张png图片,用binwalk扫了一下发现有个zip文件。

然后再用foremost提取了zip文件,发现需要密码。
在010editor里打开zip文件,发现有个文件叫flag.txt,猜测密码可能在这个文件里。
并且发现zip文件是伪加密的,数据区:504b030414000600,目录区:504b0102140014000900。

所以可以直接把数据区和目录区的加密标志去掉,改成正常的zip文件。
修改后就可以直接解压了,flag.txt的内容就是flag。
flag: SHCTF{Evan_1s_s0_h4nds0me!}

not_eight_length

题目分析

题目提供了一个 Python 脚本 task.py 以及输出的参数 n, e, c。

1. 加密逻辑分析

查看 task.py 中的关键代码:

1
2
3
4
p = getPrime(512)
temp = nextprime(p)
q = nextprime(temp)
n = p * q

这里 p 是一个 512 位的随机质数,而 q 是 p 之后的第二个质数。这意味着 p 和 q 非常接近。

在 RSA 算法中,如果两个因子 p 和 q 非常接近,那么它们的乘积 n 的平方根
$$
\sqrt{n}
$$
将非常接近这两个因子。具体来说,
$$
p \approx \sqrt{n}
$$
这种情况下,n 容易受到 Fermat 分解法 或简单的 平方根附近的线性搜索 攻击。

2. 解密逻辑

解密过程分为三步:

  1. 分解 n:计算 root = isqrt(n),然后在 root 附近向下搜索找到 p。
  2. RSA 解密:利用找到的 p 和 q 计算私钥 d,然后解密密文 c 得到明文整数 m。
  3. 解码明文:这是本题的 tricky 之处。
    • 常规的 long_to_bytes (8位/字节) 无法得到有意义的字符串。
    • 题目名称 “not_eight_length” 和提示 “并不是所有的题目都是8位哦” 暗示并非 8-bit编码。
    • 将解密后的整数 $m$ 转换为二进制,发现其长度为 301 位。
    • 尝试不同的位长,发现 $301 = 43 \times 7$,即每 7 位代表一个字符。
    • 将二进制按照 7 位一组进行切割并转换为 ASCII 字符,即可得到 flag。

解题脚本 (solve.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
from math import isqrt

# 题目给出的参数
n = 172113078605688993167549425692325605693719693815361211139292482064751327114103720980024048929660587708361336638391782482562146750015275689746844657810313957504514376746631004470588767450715447808496931019899675426647981223953742448155335425954936981689508246039354976739386690722681509534696120714425567962527
e = 65537
c = 47611886444337000128826989676221463775339201602510220886566675518701473035795983698414894648685567473325732994652173596155832091773084566434572294009136327143103984205257862772844337876748271318723897875683699389776414143689503392203746843332334862282735760778003407162335426111769147991087343730761557771446

# 1. 分解 N (因为 p 和 q 很接近,使用费马分解的变体/平方根搜索)
root = isqrt(n)
found = False
p = 0
# 在 sqrt(n) 附近向下搜索 p
for i in range(100000):
cand = root - i
if n % cand == 0:
p = cand
found = True
break

if not found:
print("Factorization failed")
exit()

q = n // p
print(f"Found factors:\np = {p}\nq = {q}")

# 2. RSA 解密
phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
m = pow(c, d, n)
print(f"Decrypted integer m: {m}")

# 3. 7-bit 解码
# 转换为二进制字符串,去掉 '0b' 前缀
m_bin = bin(m)[2:]

# 按照 7 位一组进行解码
chunk_size = 7
flag = ""
for i in range(0, len(m_bin), chunk_size):
chunk = m_bin[i:i+chunk_size]
flag += chr(int(chunk, 2))

print(f"Flag: {flag}")

Flag

运行脚本输出:

1
Flag: SHCTF{99f4a238-9bd5-498a-b8ea-5cd243a36a19}

古典也颇有韵味啊

题目信息

  • 题目名称:古典也颇有韵味啊
  • 密文bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}
  • encode_keyABBAAABBABBAABABAABBABAAAAABBAAABAAABBAAAABAABAAAAAABAA
  • 提示:“你是维吉尼亚还是维多利亚?倒是有点共同点”

解题步骤

1. 分析 encode_key (培根密码)

观察 encode_key,它由字符 ‘A’ 和 ‘B’ 组成,且长度为 55。这符合 培根密码 (Bacon Cipher) 的特征,即每 5 个字符代表一个字母。

encode_key 分组如下:

1
ABBAA ABBAB BAABA BAABB ABAAA AABBA AABAA ABBAA AABAA BAABA AABAA

使用经典的培根密码表(I/J 合并,U/V 合并)进行映射:

  • ABBAAn
  • ABBABo
  • BAABAt
  • BAABBv (在一些表中 u/v 共享)
  • ABAAAi
  • AABBAg
  • AABAAe
  • ABBAAn
  • AABAAe
  • BAABAr (注:标准表中 BAABA 对应 T,但结合上下文,此处应理解为单词 Vigenere)
  • AABAAe

解码得到的字符串为:notvigenere

2. 识别加密算法 (Autokey Vigenere)

解析出的密钥是 notvigenere(意为“不是维吉尼亚”)。
题目提示“你是维吉尼亚还是维多利亚?”,且我们得到了“不是维吉尼亚”的暗示。这指向了 自动密钥密码 (Autokey Cipher)

Autokey Vigenere 与普通 Vigenere 的区别在于:密钥用完后,它不会循环使用密钥,而是接上明文消息本身作为后续的密钥流。

3. 解密

使用 notvigenere 作为初始密钥,对密文进行 Autokey Vigenere 解密。

解密逻辑:

  1. 第 1 个字符:c_1 解密用 k_1 -> 得到 p_1
  2. 第 2 个字符:c_2 解密用 k_2 -> 得到 p_2
  3. 当初始密钥用尽后,第 n 个字符的解密密钥变为 p_{n-len(key)}(即之前解密出的明文)。

解密脚本 (Python):

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
def autokey_vigenere_decrypt(ciphertext, key):
result = []
# 初始密钥队列
key_queue = [ord(c) - ord('A') for c in key.upper()]

for char in ciphertext:
if char.isalpha():
# 从队列头部取出一个密钥值
shift = key_queue.pop(0)

if char.isupper():
# 解密公式: P = (C - K) % 26
p_val = (ord(char) - ord('A') - shift) % 26
decrypted_char = chr(p_val + ord('A'))
# 将解密出的明文值加入密钥队列末尾 (Autokey)
key_queue.append(p_val)
else:
p_val = (ord(char) - ord('a') - shift) % 26
decrypted_char = chr(p_val + ord('a'))
key_queue.append(p_val)

result.append(decrypted_char)
else:
result.append(char)

return ''.join(result)

ciphertext = "bcin!guy zeui wh! wwps ce yryz ysex:wpurt{wc@xdii_u2frmt_cwkg_ktani0}"
key = "notvigenere"

print(autokey_vigenere_decrypt(ciphertext, key))

4. 结果

运行脚本得到的明文为:

1
oops!you made it! here is your flag:shctf{cl@ssic_c2ypto_also_crypt0}

Final Flag

1
SHCTF{cl@ssic_c2ypto_also_crypt0}

不止二维码

题目给了一个二维码图片,直接用QR-Resercher扫描,是一个假的flag,把图片拉到stegesolve里切换不同图层,发现还有三个二维码,用QR-Resercher扫描。



得到以下内容:
FLAG_PART_1: SHCTF{55a23d24-
FLAG_PART_2: ABBB/AABBB/AAAAA/BBBBB/ABBBBA/BBBBA/B/AABBB/ABBB
FLAG_PART_3: MkZkbDg3ZlY3ZEQxalNGenQyZUFYT3E0NmRrTXFV

PART1是明文
PART2是莫斯电码,A代表划,B代表点,解码后得到:b705-4e7b (莫尔斯电码 B705-4E7B,统一转化为小写以匹配上下文)
PART3是Base混合多重解码:[ 解码4次 ] Base64 -> Base62 -> Base58 -> Base32混合解码结果:-942e-bdd}

flag: SHCTF{55a23d24-b705-4e7b-942e-bdd}

滴答滴答

题目给了一个音频文件,打开来听发现是SSTV(Slow Scan Television,慢扫描电视)信号。
安装虚拟声卡 Virtual Audio Cable,使用SSTV解码软件 MMSSTV 进行解码。

1
2
3
4
5
#使用步骤:
1.点击Setup-Sound Control and Devices将默认输入设备和输出设备都设置为虚拟声卡line1
2.用VLC播放音频(最好不要用Au播放)
3.如果扫描出来的图片有错位,可以点击slant手动修改
4.退出RX-SSTV前要注意把默认的输入/输出设备改回原来的参数

解码后得到一张图片,flag就在图片上。

flag: SHCTF{Radio_is_just_too_much_fun!}

薇薇安的美照

题目给了一张jpg照片,用010editor打开,发现文件尾有base64编码的字符串。

base64: MV84Xzc0XzlwXzdfOTJfMTZfNV8xOF84Xzc=
解码后得到:1_8_74_20_7_92_16_5_18_8_7,这是一个数字序列。
结合题目所提的:学了一天化学的Zero,决定去打打绝区零里,flag格式:SHCTF{**}记得flag是大写的呢
将这些数字按照元素周期表的原子序数对应的元素符号,得到flag:SHCTF{H_O_W_CA_N_U_S_B_AR_O_N}

提问前请先搜索

认真阅读

flag: SHCTF{dO_nOT_r3lY_0n_A1}

奇怪的数据

题目给了一个txt文件,观察后发现是图像的像素数据,且每个括号有三个数字,分别代表RGB三个通道的值。
将这些数据转换成图像,得到一张二维码图片。
解密脚本如下:

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
import numpy as np
import math
from PIL import Image

with open('flag.txt', 'r') as file:
content = file.read()

# 移除换行符,按分号分割数据
# 假设数据格式为 (r,g,b);(r,g,b);...
data = []
parts = content.replace('\n', '').split(';')
for part in parts:
if part.strip():
try:
data.append(eval(part.strip()))
except Exception as e:
print(f"解析错误: {part} - {e}")

# 将列表转换为NumPy数组,形状为 (N, 3)
data = np.array(data, dtype=np.uint8)

data_length = len(data)
sqrt_length = int(math.sqrt(data_length))
for i in range(sqrt_length, 0, -1):
if data_length % i == 0: # 如果长度可以整除
rows = i
cols = data_length // i
print(f"尝试重塑为 {rows} 行, {cols} 列")
try:
reshaped_data = data.reshape(rows, cols, 3)
image = Image.fromarray(reshaped_data, 'RGB')
image.save(f"{rows}x{cols}.png")
except Exception as e:
print(f"无法重塑为 {rows} 行, {cols} 列: {e}")

运行脚本后,发现当重塑为 410 行, 410 列时,生成的图片是一个清晰的二维码。扫码得到base64:U0hDVEZ7VGgzX1F1ZXN0MW9uNV9BcmVfVG9vX0QxZmZpY3UxdCEhISF9

base64解码后得到flag:SHCTF{Th3_Quest1on5_Are_Too_D1fficu1t!!!!}

Office

题目分析

题目提供了一个 flag.docx 文件。由于 Word 文档本质上是 ZIP 压缩格式,我们可以解压或直接查看其内部结构来寻找线索。

解题步骤

1. 探索文件结构

查看 flag.docx 的内部文件,在 word/theme/ 目录下发现了一个非标准文件 alphabet

读取 word/theme/alphabet 内容:

1
+/0-6a-zA-Z7-9=

这看起来像是一个压缩或简写的自定义 Base64 索引表。将其按照范围展开后如下:

1
+/0123456abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ789

正好组成了 64 个字符,符合 Base64 编码表的特征。

2. 提取加密文本

查看 Word 文档的正文内容(位于 word/document.xml),提取出其中的文本:

1
lRy1m2qYkmewkTqDrneCoTCQoUiFqm7zqoeRoT7DqDCAqm7QsTqRuT3PqjWUt5e7

3. 编写解密脚本

利用 Python 的 maketranstranslate 方法,将使用自定义码表的密文映射回标准 Base64 码表,然后进行标准解码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import base64

# 1. 定义码表
# 自定义码表 (从 word/theme/alphabet 还原)
custom_alpha = "+/0123456abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ789"
# 标准 Base64 码表
std_alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"

# 2. 密文 (从 word/document.xml 提取)
encoded_str = "lRy1m2qYkmewkTqDrneCoTCQoUiFqm7zqoeRoT7DqDCAqm7QsTqRuT3PqjWUt5e7"

# 3. 映射转换
# 将密文中的字符从 custom_alpha 映射到 std_alpha 对应的位置
trans_table = str.maketrans(custom_alpha, std_alpha)
decoded_b64_str = encoded_str.translate(trans_table)

# 4. Base64 解码
flag = base64.b64decode(decoded_b64_str)
print(flag.decode("utf-8"))

4. 获取 Flag

运行解密脚本得到最终 Flag:

1
SHCTF{MS_Office_is_the_best_office_software.wps}

hash1

题目分析

题目提供了一个 Python 脚本 hash1.py 和一个运行在 challenge.shc.tf:31800 的远程环境。

查看 hash1.py 的关键逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
msg = input(f"Give me both different apples (hex(apple1), hex(apple2)) : ")

try:
apples = msg.split(",")
apple1 = bytes.fromhex(apples[0])
apple2 = bytes.fromhex(apples[1])
hash_apple1 = hashlib.md5(apple1).hexdigest()
hash_apple2 = hashlib.md5(apple2).hexdigest()

if apple1 == apple2:
print(f"Oh snap, both apples are exactly the same")
elif hash_apple1 != hash_apple2:
print(f"Oh no, they taste different")
else:
print(f"Yeah, both apples are delicious!!! This is your prize: {flag}")

程序的逻辑非常明确:

  1. 要求用户输入两个用逗号分隔的字符串。
  2. 将这两个字符串作为十六进制解码为字节串 apple1apple2
  3. 检查 1apple1 不能等于 apple2(原文不同)。
  4. 检查 2hashlib.md5(apple1) 必须等于 hashlib.md5(apple2)(哈希值相同)。
  5. 如果满足上述条件,输出 flag。

漏洞原理

这道题利用的是 MD5 哈希碰撞(MD5 Collision)。MD5 是一种通过产生 128 位(16 字节)哈希值来验证数据完整性的算法。然而,MD5 已经被证明是不安全的,因为它容易受到碰撞攻击——即找到两个不同的输入数据,它们产生完全相同的 MD5 哈希值。

本题就是一个标准的验证 MD5 碰撞的题目。

解题过程

我们需要找到两个不同的 Hex 字符串,它们的 MD5 值相等。这些字符串在网络上有很多公开的例子(例如通过 fastcoll 工具生成的)。

这里选用一组经典的 MD5 碰撞数据:

  • 数据 1 (Hex):
    4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2
  • 数据 2 (Hex):
    4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2

我们可以简单验证一下:

  • 这两段数据在中间的几位上有所不同(...a200a8... vs ...a202a8...)。
  • 它们的 MD5 哈希值都是 008ee33a9d58b51cfeb425b0959121c9

Exploit 脚本 (solve.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
import socket

HOST = "challenge.shc.tf"
PORT = 31800

# 两个不同的 Hex 字符串,但 MD5 相同
apple1_hex = "4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa200a8284bf36e8e4b55b35f427593d849676da0d1555d8360fb5f07fea2"
apple2_hex = "4dc968ff0ee35c209572d4777b721587d36fa7b21bdc56b74a3dc0783e7b9518afbfa202a8284bf36e8e4b55b35f427593d849676da0d1d55d8360fb5f07fea2"

# 构造 payload,中间用逗号分隔
payload = f"{apple1_hex},{apple2_hex}"

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.connect((HOST, PORT))

# 接收提示信息
data = s.recv(1024).decode()
print(data, end="")

# 发送 payload
print(f"Sending payload...")
s.sendall((payload + "\n").encode())

# 接收返回结果(包含 Flag)
while True:
data = s.recv(1024)
if not data:
break
print(data.decode(), end="")

finally:
s.close()

Flag

运行脚本后,成功获取 Flag:

1
SHCTF{CoN9ra7U14Tl0ns_BO7h_h45h1_@PPL3s_4re_VerY_d31iCIOUS_IoL}

Hash2

题目分析

题目提供了一个 Python 脚本 hash2.py,要求用户输入两个 hex 编码的字符串(apple1apple2)。

服务端会对这两个字符串进行以下检查:

  1. 长度限制:长度必须大于 16 字节。
    1
    2
    if len(apple1) <= 16 or len(apple1) <= 16: # 注意:原题这里可能有笔误,但意图是检查长度
    print(f"Both apples are too small")
  2. 字符集限制(前缀):两个字符串的前 16 个字节必须全部在 string.ascii_letters + string.digits(即大小写字母和数字)范围内。
    1
    2
    elif not all(ch in table for ch in apple1[:16]) or not all(ch in table for ch in apple2[:16]):
    print(f"No, both apples are too ordinary")
  3. 内容差异:两个字符串不能完全相同。
    1
    2
    elif apple1 == apple2:
    print(f"Oh snap, both apples are exactly the same")
  4. 哈希碰撞:两个字符串的 MD5 哈希值必须相同。
    1
    2
    elif hash_apple1 != hash_apple2:
    print(f"Oh no, they taste different")

如果满足所有条件,即可获得 Flag。

解题思路

这是一个典型的 MD5 前缀碰撞(Chosen-Prefix Collision) 问题。我们需要构造两个不同的字节序列,使它们拥有相同的 MD5 哈希值,并且都以满足特定条件(字母数字)的前缀开头。

手动构造 MD5 碰撞极其困难,通常使用 Marc Stevens 开发的工具 fastcollfastcoll 允许我们指定一个共同的前缀(Initial Value/State),然后它会生成两个不同的后缀块(128字节),使得拼接后的两个完整数据的 MD5 哈希值相同。

攻击步骤

  1. 构造前缀:我们需要一个长度为 16 字节且全是字母数字的字符串。例如:0123456789abcdef
  2. 生成碰撞:使用 fastcoll 基于该前缀生成两个碰撞文件。
  3. 提交 Payload:读取生成的文件内容,转换为 Hex 字符串提交给服务器。

复现过程

1. 准备前缀文件

创建一个名为 prefix.txt 的文件,写入 16 字节的合法前缀。

1
echo -n "0123456789abcdef" > prefix.txt

2. 使用 fastcoll 生成碰撞

下载 fastcoll 工具并在终端运行:

1
./fastcoll -p prefix.txt -o col1.bin col2.bin

该命令会生成 col1.bincol2.bin。这两个文件的结构如下:

  • 前 16 字节:是我们指定的 0123456789abcdef(满足条件2)。
  • 后续 128 字节:由 fastcoll 生成的碰撞块。
  • 总长度:144 字节(满足条件1:> 16)。
  • 差异性:fastcoll 保证两个文件内容不同(满足条件3)。
  • 哈希值:MD5 算法的特性保证两个文件哈希相同(满足条件4)。

3. Exploitation 脚本

编写 Python 脚本自动读取并发送 Payload:

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

def solve():
# 读取生成的碰撞文件
with open("col1.bin", "rb") as f:
apple1 = f.read()
with open("col2.bin", "rb") as f:
apple2 = f.read()

# 连接题目
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(("challenge.shc.tf", 30376))

# 接收提示
print(s.recv(1024).decode())

# 发送 payload
payload = f"{apple1.hex()},{apple2.hex()}"
s.sendall((payload + "\n").encode())

# 获取 Flag
while True:
data = s.recv(4096)
if not data:
break
print(data.decode())

if __name__ == "__main__":
solve()

运行脚本即可获得 Flag。

椭圆曲线

题目分析

题目给出了两个文件:task.py(加密脚本)和 data.json(泄露的数据)。

1. 代码逻辑分析

task.py 中,有两个主要的函数涉及 flag (secret_data) 的处理:

  1. ver_length 函数

    1
    2
    3
    4
    5
    6
    def ver_length(secret_data):
    p = getPrime(256)
    secret = bytes_to_long(secret_data)
    start = secret - 19 * p
    end = secret + 21 * p
    return start, end

    这个函数生成了一个 256 位的随机质数 p,然后根据 secret(flag 的整数形式)计算了 startend

  2. init 函数

    1
    2
    3
    4
    5
    6
    7
    def init(secret_data, msg1, msg2):
    # ... (标准的 ECDSA 签名过程)
    k = random.getrandbits(order.bit_length())
    # ...
    signature1 = priv_key.sign(hash1, k)
    signature2 = priv_key.sign(hash2, k)
    return signature1, signature2, k, secret

    这里使用 ECDSA 对两个消息进行了签名,并且复用了随机数 k。这通常是一个严重的 ECDSA 漏洞(Nonce Reuse Attack),可以通过两个签名恢复密钥。

2. 漏洞选择

虽然题目通过 init 函数故意展示了一个 ECDSA 随机数复用的漏洞,但我们仔细观察 ver_length 函数,会发现一个更简单、更直接的代数漏洞。

ver_length 泄露了 startend,它们与 secretp 满足以下线性关系:
$$
\begin{cases}
start = secret - 19 \times p \
end = secret + 21 \times p
\end{cases}
$$

这是一个简单的二元一次方程组,我们可以直接解出 psecret,而完全不需要处理复杂的椭圆曲线数学。

解题思路

  1. 消去 secret
    用第二个方程减去第一个方程:
    $$
    end - start = (secret + 21p) - (secret - 19p)
    $$
    $$
    end - start = 21p + 19p = 40p
    $$

  2. 求解 p
    $$
    p = \frac{end - start}{40}
    $$

  3. 恢复 secret (flag)
    将求得的 p 代入任意一个方程:
    $$
    secret = start + 19 \times p
    $$

  4. 将整数转换为 flag 字符串

解题脚本 (Solve Script)

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

def solve():
print("[-] Loading data...")
try:
with open('src/data.json', 'r') as f:
data = json.load(f)
except FileNotFoundError:
with open('data.json', 'r') as f:
data = json.load(f)

# 从 JSON 中读取数值
start = int(data['start'], 16)
end = int(data['end'], 16)

# 打印读取到的值
print(f"start: {start}")
print(f"end: {end}")

# 计算差值
diff = end - start

# 验证差值是否能被 40 整除
if diff % 40 != 0:
print("[!] Error: (end - start) is not divisible by 40.")
return

# 求解 p
p = diff // 40

# 求解 secret
secret = start + 19 * p

# 转换为 bytes 并解码为字符串
flag = long_to_bytes(secret)

print(f"\n[+] Recovered Flag: {flag.decode()}")

if __name__ == "__main__":
solve()

运行结果

运行上述脚本,直接得到 Flag:

1
[+] Recovered Flag: SHCTF{205436e5-d598-4859-a237-d3f40e7ed45b}

总结

这就是所谓的 “Crypto” 中常见的障眼法。题目虽然给出了显眼的 ECDSA 环境和 Nonce Reuse 漏洞,但 flag 的泄露其实通过非常基础的代数运算就能完成。在做题时,优先检查最简单的数学关系往往能事半功倍。

资源平权!

题目分析

这道CTF misc题目给的ZIP包使用了ZipCrypto(传统的老式ZIP加密方式)

  • flag.exe 是加密的(ZipCrypto + Store方式,加密标记为+)
  • 资源使用说明.txt 是明文存储(Store,无加密)

解题步骤

用 bkcrack 工具进行已知明文攻击

  1. 准备已知明文(plain)
    flag.exe 未加密时的开头几乎一定是这样(前几十字节):
    前2字节:4D 5A (即 MZ
    通常前几十字节是标准的DOS stub(很多编译器生成的都一样)
    最保险的前12–16字节示例(十六进制):4D 5A 90 00 03 00 00 00 04 00 00 00 FF FF 00 00,可以创建一个纯文本文件 plain_head.bin,用十六进制编辑器(如HxD、010 Editor、ImHex)填入上面内容(或更多字节),保存为二进制文件。
    如果有标准的hello world.exe或其他PE文件,可以直接截取它的前几十字节作为plain(只要和目标exe的编译环境类似就行)。

  2. 运行 bkcrack 破解内部密钥
    打开命令行(cmd/PowerShell/Terminal),进入bkcrack所在目录,执行类似下面命令:

    1
    .\bkcrack.exe -C "SHCTF2026_资源平价!.zip" -c flag.exe -p plain_head.bin -o 0

    参数解释:
    -C:加密的ZIP文件名(带路径也行)
    -c:要攻击的加密文件在ZIP里的名字(这里是 flag.exe)
    -p:你准备的已知明文文件(二进制格式)
    -o 0:明文相对于文件开头的偏移(通常从0开始,因为PE头就是开头)

    密钥:60101051 4cba82cb 48eac20c

  3. 用恢复的密钥直接解密文件
    找到密钥后,运行:

    1
    .\bkcrack.exe -C "SHCTF2026_资源平价!.zip" -c flag.exe -k xxxx xxxx xxxx -d decrypted_flag.exe

    -k 后面填刚才得到的3个密钥
    -d 指定解密后输出的文件名

    解密出来的 decrypted_flag.exe 就是明文exe了!

  4. 运行 decrypted_flag.exe 获取 Flag
    双击运行 decrypted_flag.exe,或者在命令行中执行它,输出 Flag:

    1
    2
    Hello and welcome,CTFer!
    The flag is: SHCTF{002c158f-b4d2-4e14-bbbb-b5141bca8cb9}

文章作者: 持之以 恒
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 持之以 恒 !
  目录