Double-spend
Theory
Why This Matters
In 2022, a researcher disclosed a critical double-spend vulnerability in a major e-commerce platform's gift card redemption system through a coordinated bug bounty report. By sending two simultaneous POST requests to the redemption endpoint, the researcher was able to spend a single $50 gift card balance twice, receiving $100 in store credit. The root cause was a Time-of-Check-Time-of-Use (TOCTOU) window: both requests read the balance as $50 before either write completed. The platform had no database-level locking on the balance row. The researcher earned a $10,000 bounty and the fix required a full payment subsystem refactor. Similar bugs have appeared in cryptocurrency exchange hot wallets, airline loyalty point systems, and SaaS subscription credit systems — anywhere a balance is read, validated, and then decremented without atomic guarantees.
Core Concept
A double-spend attack exploits the race window between a balance check and the corresponding debit operation. The application reads the current balance (check), confirms it is sufficient (validate), then performs the deduction (act). If two or more requests execute concurrently, both can pass the check phase before either write completes, resulting in both debits being applied to the original pre-debit balance — effectively spending the same funds multiple times.
This is a specific instance of the TOCTOU (Time-of-Check-Time-of-Use) vulnerability class. The race window is the time interval between the read and the write. In a single-threaded, synchronous environment this window is zero. In multi-threaded web servers (the norm), the window can span tens to hundreds of milliseconds — more than enough for a coordinated parallel attack.
The idempotency key pattern is the payment industry's standard mitigation: each debit request carries a unique client-generated token, and the server rejects or deduplicates any second request carrying the same token, even if the first has not yet committed. Database-level pessimistic locking (SELECT FOR UPDATE) forces competing transactions to queue. Optimistic locking uses a version column: the UPDATE statement includes WHERE version = :read_version, and a concurrent writer's version mismatch causes one transaction to fail and retry.
Technical Deep-Dive
# Vulnerable flow — both requests read balance=100 before either write commits
# Request A (Thread 1)
POST /api/wallet/redeem HTTP/1.1
Host: shop.example.com
Cookie: session=abc123
Content-Type: application/json
{"coupon_id": "GIFT50", "amount": 50}
# Server pseudocode (no locking):
# balance = SELECT balance FROM wallets WHERE user_id = ? -- reads 100
# IF balance >= amount:
# UPDATE wallets SET balance = balance - amount ... -- sets 50
# INSERT INTO transactions ...
# RETURN 200 OK
# Request B (Thread 2) — concurrent, sent at same millisecond as A
POST /api/wallet/redeem HTTP/1.1
Host: shop.example.com
Cookie: session=abc123
Content-Type: application/json
{"coupon_id": "GIFT50", "amount": 50}
# Server pseudocode (no locking):
# balance = SELECT balance FROM wallets WHERE user_id = ? -- ALSO reads 100
# IF balance >= amount:
# UPDATE wallets SET balance = balance - amount ... -- sets 50 again
# INSERT INTO transactions ...
# RETURN 200 OK
# Result: wallet decremented twice from 100, but both redemptions succeed.
# Balance ends at 0, but attacker received 2x the credit.
# Burp Turbo Intruder script — single-packet attack (HTTP/2 multiplexing)
# Send N parallel requests within a single TCP frame to minimise race window
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1, # one HTTP/2 connection
requestsPerConnection=20, # all requests in one frame
pipeline=False
)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1') # release all simultaneously
def handleResponse(req, interesting):
if '200' in req.response:
table.add(req)
Security Assessment Methodology
- Identify financial state operations — Enumerate all endpoints that read and modify balance, credits, coupons, or inventory. Look for
/redeem,/spend,/checkout,/transfer,/withdraw. - Confirm the check-then-act pattern — Review API responses: do two near-simultaneous requests both return HTTP 200 with success payloads? If yes, a race window exists.
- Configure Turbo Intruder — Use the single-packet attack template. Set
concurrentConnections=1,requestsPerConnection=N(20–50), and useengine.openGate()to synchronise release. - Verify the exploit result — Check the account balance or transaction ledger after the burst. A successful double-spend manifests as more credits consumed than available, or duplicate fulfillment entries.
- Test idempotency key handling — If the API accepts an
Idempotency-Keyheader, send the same key with two concurrent requests. Observe whether the server deduplicates correctly or processes both. - Attempt database-level confirmation — Where logs are available, confirm whether the race produced duplicate transaction IDs or identical timestamps on separate debit records.
Defensive Countermeasure — Wrap the balance check and debit in a single atomic database transaction using
SELECT ... FOR UPDATE(pessimistic locking) or implement optimistic locking with a version column (UPDATE wallets SET balance = balance - :amount, version = version + 1 WHERE user_id = :uid AND version = :expected_version AND balance >= :amount). Require unique idempotency keys on all payment mutation endpoints and store processed keys in a deduplication table with a short TTL. Never rely on application-layer locks (mutexes, semaphores) that do not survive process restarts or horizontal scaling.
Common Assessment Errors
- Testing with sequential requests instead of concurrent ones — The race window can be as short as 1–5 ms. If requests are sent one at a time, the first will commit before the second reads, and the vulnerability will not manifest. Always use Turbo Intruder or a parallel script.
- Assuming HTTP/1.1 is sufficient for the single-packet attack — HTTP/1.1 pipelines requests but cannot guarantee simultaneous delivery. Use HTTP/2 multiplexing (Turbo Intruder's default when endpoint supports it) to collapse all requests into one TCP frame.
- Missing rate-limit false negatives — A 429 response on the second request looks like protection, but the rate limiter may fire after the race window has already been exploited. Confirm by checking the ledger, not just the HTTP response code.
- Ignoring non-financial endpoints — Double-spend logic applies to any check-then-act: email verification token reuse, one-time password (OTP) consumption, single-use invite links, and limited-quantity item reservations.
- Overlooking idempotency key bypass — Some implementations store idempotency keys only after the transaction commits. A parallel burst sent before any key is stored can still race. Test with concurrent requests before the first response arrives.
- Not testing across distributed nodes — If the application runs on multiple servers without a shared locking mechanism, two requests routed to different nodes can both pass the check independently. Test against load-balanced deployments by pinning sessions or using different sessions simultaneously.
NICE Framework Alignment
| Code | Knowledge/Skill/Task Statement | How This Card Develops It |
|---|---|---|
| K0007 | Knowledge of authentication, authorization, and access control methods | Establishes how race conditions undermine transactional integrity guarantees |
| K0065 | Knowledge of web application security testing techniques | Trains concurrent request exploitation using Turbo Intruder single-packet methodology |
| K0070 | Knowledge of system and application security threats and vulnerabilities | Identifies double-spend as a class of TOCTOU business logic threat |
| S0001 | Skill in conducting vulnerability scans and recognizing vulnerabilities in security systems | Develops ability to detect race windows through parallel request observation |
| T0028 | Identify and analyze vulnerabilities and risks in web applications | Applies systematic check-then-act pattern recognition across financial API endpoints |
| T0570 | Perform technical security assessments of web applications | Structures end-to-end race condition assessment from discovery through exploitation proof |
Further Reading
- Turbo Intruder: Embracing the Dark Side of HTTP/2 — PortSwigger Research Blog
- OWASP Business Logic Security Cheat Sheet — OWASP Foundation
- PaySec: Race Conditions in Payment APIs — Stripe Engineering Blog (internal reference)
Challenge Lab
Reinforce your learning with a hands-on generated challenge based on this card's competency.