Back to Learning Center
highOWASP Testing GuideCWE-362CWE-367

Race Conditions

Race conditions occur when applications process multiple requests concurrently without proper synchronization. Attackers exploit the timing gap between security checks and subsequent actions, a vulnerability class known as Time-of-Check to Time-of-Use (TOCTOU). These bugs can bypass business logic, duplicate rewards, and corrupt data.

What Are Race Conditions?

A race condition exists when the behavior of software depends on the sequence or timing of uncontrollable events. In web applications, this typically happens when multiple threads or processes access shared resources without proper locking mechanisms.

TOCTOU (Time-of-Check to Time-of-Use) is the most common pattern. The application checks a condition (like 'has this coupon been used?'), then performs an action based on that check. If an attacker can change the state between the check and the use, the security assumption is violated.

Common Attack Scenarios

Coupon/Discount Abuse

An e-commerce site checks if a single-use coupon is valid, then applies the discount. By sending multiple simultaneous requests, all might pass the validity check before any marks the coupon as used.

Balance/Withdrawal Double-Spend

A banking app checks account balance, then processes withdrawal. Multiple simultaneous withdrawal requests might all see the original balance and succeed, overdrawing the account.

Limit Bypass

Applications that limit actions (votes per user, downloads per day, API calls per minute) can have their limits bypassed when multiple requests arrive simultaneously before the counter is updated.

How Race Conditions Work

Consider this vulnerable coupon redemption flow:

javascript
// Vulnerable coupon redemption
async function redeemCoupon(userId, couponCode) {
  // Step 1: Check if coupon exists and is unused
  const coupon = await db.query(
    'SELECT * FROM coupons WHERE code = ? AND used = false',
    [couponCode]
  );
  
  if (!coupon) {
    return { error: 'Invalid or used coupon' };
  }
  
  // RACE WINDOW: Time gap between check and update
  // Multiple requests can pass the check above
  
  // Step 2: Apply discount to user's cart
  await applyDiscount(userId, coupon.discount);
  
  // Step 3: Mark coupon as used
  await db.query(
    'UPDATE coupons SET used = true WHERE code = ?',
    [couponCode]
  );
  
  return { success: true };
}

The Race Window

Between the SELECT query and the UPDATE query, there's a window where the coupon is verified but not yet marked as used. If two requests enter this window simultaneously:

  1. Request A checks coupon → unused (passes)
  2. Request B checks coupon → still unused (passes)
  3. Request A applies discount
  4. Request B applies discount
  5. Both requests mark coupon as used

Testing for Race Conditions

Using Burp Suite Turbo Intruder

python
# Turbo Intruder script for race condition testing
def queueRequests(target, wordlists):
    engine = RequestEngine(
        endpoint=target.endpoint,
        concurrentConnections=30,
        requestsPerConnection=100,
        pipeline=False
    )
    
    # Queue 30 identical requests
    for i in range(30):
        engine.queue(target.req, gate='race1')
    
    # Release all requests simultaneously
    engine.openGate('race1')

def handleResponse(req, interesting):
    table.add(req)

Using Python with Threading

python
import requests
import threading
import time

url = 'https://target.com/api/redeem'
headers = {'Authorization': 'Bearer TOKEN'}
payload = {'coupon': 'SINGLE-USE-CODE'}

results = []
barrier = threading.Barrier(20)  # Sync 20 threads

def send_request():
    barrier.wait()  # Wait for all threads
    response = requests.post(url, json=payload, headers=headers)
    results.append(response.json())

# Create and start 20 threads
threads = [threading.Thread(target=send_request) for _ in range(20)]
for t in threads:
    t.start()
for t in threads:
    t.join()

# Count successful redemptions
successes = sum(1 for r in results if r.get('success'))
print(f'Successful redemptions: {successes}')

Prevention Strategies

Atomic Database Operations

Combine check and update into a single atomic operation:

javascript
// Secure: Atomic update with condition
async function redeemCoupon(userId, couponCode) {
  // Single atomic query: update only if unused
  const result = await db.query(
    `UPDATE coupons 
     SET used = true, used_by = ?, used_at = NOW()
     WHERE code = ? AND used = false`,
    [userId, couponCode]
  );
  
  // Check if any row was actually updated
  if (result.affectedRows === 0) {
    return { error: 'Invalid or already used coupon' };
  }
  
  // Only apply discount if update succeeded
  const coupon = await db.query(
    'SELECT discount FROM coupons WHERE code = ?',
    [couponCode]
  );
  
  await applyDiscount(userId, coupon.discount);
  return { success: true };
}

Database Transactions with Proper Isolation

javascript
// Secure: Using transaction with row locking
async function withdrawFunds(userId, amount) {
  const connection = await db.getConnection();
  
  try {
    await connection.beginTransaction();
    
    // SELECT FOR UPDATE locks the row
    const [account] = await connection.query(
      'SELECT balance FROM accounts WHERE user_id = ? FOR UPDATE',
      [userId]
    );
    
    if (account.balance < amount) {
      await connection.rollback();
      return { error: 'Insufficient funds' };
    }
    
    await connection.query(
      'UPDATE accounts SET balance = balance - ? WHERE user_id = ?',
      [amount, userId]
    );
    
    await connection.commit();
    return { success: true, newBalance: account.balance - amount };
    
  } catch (error) {
    await connection.rollback();
    throw error;
  } finally {
    connection.release();
  }
}

Distributed Locking

For distributed systems, use Redis locks or similar:

javascript
import Redis from 'ioredis';
const redis = new Redis();

async function redeemWithLock(userId, couponCode) {
  const lockKey = `lock:coupon:${couponCode}`;
  const lockValue = `${userId}-${Date.now()}`;
  
  // Try to acquire lock (10 second expiry)
  const acquired = await redis.set(
    lockKey, 
    lockValue, 
    'EX', 10, 
    'NX'
  );
  
  if (!acquired) {
    return { error: 'Coupon being processed, try again' };
  }
  
  try {
    // Safe to process - we have exclusive access
    return await processCouponRedemption(userId, couponCode);
  } finally {
    // Release lock only if we still own it
    const script = `
      if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
      else
        return 0
      end
    `;
    await redis.eval(script, 1, lockKey, lockValue);
  }
}

Idempotency Keys

Require clients to provide unique idempotency keys:

javascript
async function processPayment(req) {
  const idempotencyKey = req.headers['idempotency-key'];
  
  if (!idempotencyKey) {
    return { error: 'Idempotency-Key header required' };
  }
  
  // Check if this request was already processed
  const existing = await redis.get(`idem:${idempotencyKey}`);
  if (existing) {
    return JSON.parse(existing); // Return cached response
  }
  
  // Process the payment
  const result = await processPaymentInternal(req.body);
  
  // Cache the result for 24 hours
  await redis.set(
    `idem:${idempotencyKey}`,
    JSON.stringify(result),
    'EX', 86400
  );
  
  return result;
}

Security Checklist

  • Use atomic database operations for state changes
  • Apply SELECT FOR UPDATE or equivalent row locking
  • Implement distributed locking for multi-server deployments
  • Require idempotency keys for sensitive operations
  • Test critical endpoints with concurrent request tools
  • Review all check-then-act patterns in business logic
  • Use database constraints as a final safety net
  • Monitor for duplicate transactions in production

Practice Challenges

View all