0x55aa
← Back to Blog

Race Conditions: The Timing Attack Nobody Talks About ⏱️

9 min read

Race Conditions: The Timing Attack Nobody Talks About ⏱️

Ever had a bug that only happens in production? That magically disappears when you try to debug it? That makes your users say "I swear I only clicked once!"

Welcome to race conditions - the timing vulnerability that drives developers insane! 🤯

As someone who's built serverless e-commerce backends handling thousands of concurrent requests, I've seen race conditions cause everything from double charges to negative inventory counts. In security communities, we call these "time-of-check to time-of-use" bugs, and they're WAY more common than you think.

What's a Race Condition? 🏁

Simple explanation: When two things try to happen at the exact same time, and your code wasn't ready for it.

Real-world analogy: You and your roommate both see the last slice of pizza. You both reach for it at the same time. Who gets it? Whoever's faster. Now imagine that with your bank account balance!

The Classic Attack: Double Spending 💸

Here's how hackers exploit race conditions:

The vulnerable code:

// Looks innocent, right? WRONG!
app.post('/buy-item', async (req, res) => {
    const user = await User.findById(req.userId);
    const item = await Item.findById(req.itemId);

    // Check if user has enough money
    if (user.balance >= item.price) {
        // Deduct money
        user.balance -= item.price;
        await user.save();

        // Give item
        await user.addItem(item);
        return res.json({ success: true });
    }
});

Looks fine? Try sending TWO requests at the EXACT same millisecond:

# Hacker opens two browser tabs and clicks "Buy" simultaneously
curl -X POST /buy-item &  # Request 1
curl -X POST /buy-item &  # Request 2

What happens:

  1. Both requests check balance: $100 ✅
  2. Item costs $100
  3. Both requests say "you have enough money!" ✅
  4. Both requests deduct $100
  5. User gets 2 items for $100! 😱

Result: Your e-commerce site just got robbed. By timing.

Real Talk: I've Seen This in Production 💬

In my experience building production systems, I once debugged a promo code bug where users could apply the SAME 50% discount code multiple times by clicking super fast. The check-and-update wasn't atomic!

The damage? $15,000 in unplanned discounts before we caught it. The CEO was... not happy. 😅

In security communities, we often discuss how race conditions are like the "invisible" vulnerability - they don't show up in code reviews, they don't trigger in local testing, but in production with real concurrency? Boom. 💥

The Five Deadly Scenarios 🎯

1. Inventory Management Gone Wrong

// BAD: Check inventory, then decrease
if (product.stock > 0) {
    product.stock -= 1;  // Race condition here!
    await product.save();
}

Result: Overselling. 10 items in stock, 15 orders placed. Customer service nightmare!

2. Promo Code Double-Dipping

// BAD: Check if code is used, then mark as used
if (!coupon.isUsed) {
    coupon.isUsed = true;  // Two requests can both pass the check!
    await coupon.save();
    applyDiscount();
}

Result: Same promo code used twice. Your marketing budget just 2x'd!

3. Account Balance Chaos

// BAD: Read balance, then update
const currentBalance = user.balance;
const newBalance = currentBalance + depositAmount;
user.balance = newBalance;  // Other transactions might've happened!
await user.save();

Result: Money appears/disappears randomly. Trust issues with users!

4. File Upload Overwriting

// BAD: Check if file exists, then write
if (!fs.existsSync(filename)) {
    fs.writeFileSync(filename, data);  // Two uploads with same name!
}

Result: Files randomly get overwritten. Users lose data!

5. Rate Limiting Bypass

// BAD: Check attempt count, then increment
const attempts = await redis.get(`attempts:${userId}`);
if (attempts < 5) {
    await redis.incr(`attempts:${userId}`);  // Gap between check and increment!
    processRequest();
}

Result: Brute force attacks slip through. Your rate limiter is useless!

How to Fix It (The Right Way) 🛡️

Solution 1: Database Transactions (The Professional Way)

Laravel:

DB::transaction(function () use ($user, $item) {
    // Lock the user row until transaction completes
    $user = User::where('id', $userId)->lockForUpdate()->first();

    if ($user->balance >= $item->price) {
        $user->balance -= $item->price;
        $user->save();
        $user->items()->attach($item->id);
    }
});

Node.js + Sequelize:

await sequelize.transaction(async (t) => {
    const user = await User.findByPk(userId, {
        lock: t.LOCK.UPDATE,  // Locks the row
        transaction: t
    });

    if (user.balance >= item.price) {
        user.balance -= item.price;
        await user.save({ transaction: t });
    }
});

The magic: lockForUpdate() prevents other requests from reading the row until you're done!

Solution 2: Atomic Operations (The Fast Way)

Redis:

// ATOMIC: Decrement only if value is positive
const script = `
    if redis.call('get', KEYS[1]) > 0 then
        return redis.call('decr', KEYS[1])
    else
        return -1
    end
`;
const result = await redis.eval(script, 1, `stock:${productId}`);
if (result < 0) {
    return res.json({ error: 'Out of stock' });
}

Database atomic update:

-- All in ONE operation - can't be interrupted!
UPDATE products
SET stock = stock - 1
WHERE id = ? AND stock > 0;

Pro tip: Always check affected_rows. If it's 0, someone else bought the last item!

Solution 3: Optimistic Locking (The Clever Way)

// Add a version field to your model
const product = await Product.findByPk(id);
const originalVersion = product.version;

// Do your calculations...
product.stock -= 1;
product.version += 1;

// Only save if version hasn't changed!
const [updated] = await Product.update(
    { stock: product.stock, version: product.version },
    {
        where: {
            id: product.id,
            version: originalVersion  // Only update if version matches!
        }
    }
);

if (updated === 0) {
    // Someone else modified it! Retry or fail gracefully
    return res.json({ error: 'Please try again' });
}

When someone else updates it first: Version changes, your update fails, user gets a friendly error instead of corrupted data!

Solution 4: Idempotency Keys (The API Way)

app.post('/purchase', async (req, res) => {
    const idempotencyKey = req.headers['idempotency-key'];

    // Check if we've seen this request before
    const existing = await redis.get(`purchase:${idempotencyKey}`);
    if (existing) {
        // Return the same response as last time!
        return res.json(JSON.parse(existing));
    }

    // Process the purchase...
    const result = await processPurchase(req.body);

    // Store result for 24 hours
    await redis.setex(`purchase:${idempotencyKey}`, 86400, JSON.stringify(result));

    return res.json(result);
});

Translation: Each request gets a unique ID. Duplicate requests? We just return the first result. No double-charging!

Testing for Race Conditions 🧪

The simple way (bash):

# Fire 10 requests simultaneously
for i in {1..10}; do
    curl -X POST http://localhost:3000/buy-item &
done
wait

The proper way (automated test):

// Using Promise.all to simulate concurrent requests
test('prevents race condition on purchase', async () => {
    const user = await createUser({ balance: 100 });
    const item = await createItem({ price: 100 });

    // Try to buy the same item twice simultaneously
    const [result1, result2] = await Promise.all([
        fetch('/buy-item', { method: 'POST', body: { itemId: item.id } }),
        fetch('/buy-item', { method: 'POST', body: { itemId: item.id } })
    ]);

    // Only ONE should succeed!
    const successCount = [result1, result2].filter(r => r.ok).length;
    expect(successCount).toBe(1);

    // User should only have ONE item
    const userItems = await user.getItems();
    expect(userItems.length).toBe(1);
});

Quick Checklist: Am I Vulnerable? 📋

Your code has a race condition if:

  • You read a value, then update it (not atomic)
  • You check a condition, then act on it (time gap!)
  • Multiple requests can modify the same data simultaneously
  • You use +=, -=, or ++ on shared resources
  • Your payment/inventory/balance logic isn't in a transaction
  • You don't use database locks on critical operations
  • You process webhooks without idempotency checks

Pro Tips from the Trenches 🎖️

1. Always use transactions for money operations

// If ANYTHING fails, roll back EVERYTHING
await db.transaction(async (trx) => {
    await deductBalance(userId, amount, trx);
    await createOrder(orderData, trx);
    await decreaseStock(items, trx);
    // All or nothing!
});

2. Redis INCR/DECR are your friends

// These are atomic by design!
await redis.decr(`stock:${productId}`);  // Safe!
await redis.incr(`views:${postId}`);    // Safe!

3. Use SELECT FOR UPDATE when money is involved

-- Locks the row until transaction completes
SELECT * FROM accounts WHERE id = ? FOR UPDATE;

4. Log suspicious patterns

if (userPurchasesInLastSecond > 3) {
    logger.warn(`Possible race condition exploit attempt by user ${userId}`);
    // Maybe rate-limit this user?
}

The Tools That Save You 🔧

  • pgTap / MySQL Test Suite: Test concurrent database operations
  • Apache JMeter: Load testing with concurrent requests
  • Locust: Python-based load testing (my favorite!)
  • k6: Modern load testing tool
  • Database query logs: Look for lock waits and deadlocks

Common Mistakes I See 😬

Mistake 1: "It worked in testing!"

  • Testing with 1 user ≠ production with 1000 concurrent users
  • Always test with Promise.all() or concurrent curl requests!

Mistake 2: "I'll just add a delay!"

// NO NO NO NO NO
await checkBalance();
await sleep(100);  // This fixes NOTHING!
await updateBalance();

Wrong! Race conditions happen in microseconds. Sleep won't save you!

Mistake 3: "Let's just reload the page if it fails"

  • Users will notice
  • Your reputation takes a hit
  • Fix the root cause!

The Bottom Line 🎯

Race conditions are like Schrödinger's bug - they exist and don't exist at the same time until you look at them in production! 😹

Remember:

  1. Always use transactions for critical operations
  2. Make operations atomic whenever possible
  3. Test with concurrent requests before deploying
  4. Use database locks for money/inventory
  5. Implement idempotency for APIs

Think of it like this: If your code was a bathroom, would two people trying to use it at the same time cause chaos? If yes, you need a lock! 🚪🔒


Built something that handles millions of concurrent users? Or maybe you got burned by a race condition in production? Hit me up on LinkedIn!

Want more security deep-dives? Check out my other posts or follow along! More content about the weird bugs that only happen at scale! 🚀

Now go add some transactions to your checkout flow! ⏱️✨