Descripción del laboratorio
GhostRace es una tienda de recargas y códigos digitales para
videojuegos de juguete: tarjetas regalo, gemas de juego, suscripciones a "GhostPass" y una sección de
reseñas de producto. Corre en local (python app/app.py), usa SQLite para el catálogo y las
reseñas de partida (contenido de solo lectura) y no depende de ningún servicio externo una vez arrancada.
Este laboratorio está basado en el vídeo de Gorka El Bochi Morillo "Así los hackers compran gratis por Internet". A diferencia de GhostFlix, GhostCart y GhostGram —donde cada reto es un fallo conceptualmente distinto (IDOR, caché, XSS, SQLi…)—, aquí los 3 retos son la misma familia de fallo aplicada a tres superficies distintas de la tienda: una race condition (más concretamente, un patrón TOCTOU — Time Of Check to Time Of Use). El objetivo pedagógico no es memorizar tres payloads distintos, sino aprender a reconocer el patrón en cualquier sitio donde una app compruebe algo y tarde en confirmarlo.
Antes de empezar: crea tu propia cuenta en /register. No hace falta ninguna segunda sesión
ni cuenta de "víctima" — los 3 retos consisten en atacar tu propia cuenta, disparando
muchas copias de la misma petición contra tu propio pedido, tu propio plan o tu propio voto. Usa
/reset en cualquier momento para devolver todo el laboratorio (base de
datos y los 3 estados en memoria que se atacan) a su estado inicial.
Qué es una race condition y por qué funciona el ataque
Los tres endpoints de este laboratorio comparten la misma estructura interna: primero
comprueban algo ("¿ya se aplicó este cupón a este pedido?", "¿ya canjeaste tu
invitación?", "¿ya has votado este producto?") y, si la comprobación pasa, confirman el
efecto (aplican el descuento, suman el mes premium, guardan el voto). El problema es que entre la
comprobación y la confirmación hay una ventana de tiempo — en este laboratorio, un
segundo entero de time.sleep(1.0) a propósito, para que la ventana sea lo bastante ancha
como para explotarla de forma fiable con Burp Community, sin depender de Turbo Intruder (herramienta de
pago) ni del azar puro de la red.
Si mandamos una sola petición, todo funciona como se espera: comprueba, espera un segundo, confirma. Pero si mandamos quince peticiones idénticas a la vez, las quince llegan al servidor casi en el mismo instante, y las quince ejecutan la comprobación antes de que la primera termine su segundo de espera y marque el pedido/la invitación/el voto como "ya hecho". Para cuando la primera petición confirma el cambio, las otras catorce ya han pasado la comprobación — creen, cada una por su cuenta, que van a ser la primera y única.
Esta es exactamente la técnica que ya vimos en el reto 4 de GhostFlix (el cupón de bienvenida), aplicada aquí tres veces sobre superficies distintas de una tienda. Como la mecánica de captura en Burp es idéntica en los 3 retos, el reto 1 explica cada paso con todo el detalle; los retos 2 y 3 reutilizan la misma técnica de forma más ágil, centrándose en qué cambia del negocio en cada caso.
1Comprar gratis — cupón de bienvenida
Cada pedido nuevo lleva asociado un cupón de bienvenida, BIENVENIDA50, pensado para
aplicarse una sola vez por pedido y descontar un 50% del total. El endpoint que lo
aplica comprueba primero si cupon_aplicado ya vale true en ese pedido; si no,
espera un segundo (simulando una validación "lenta" del cupón contra un servicio externo) y solo entonces
multiplica el total por 0.5 y marca el pedido como ya descontado.
Con la cuenta recién creada, añadimos un producto al carrito (GhostPass Premium — 1 mes, 9.99€) y pulsamos "Tramitar pedido". La página del pedido muestra el total real y un campo para el cupón, ya rellenado con el código de bienvenida.
Con Proxy > Intercept activado, pulsamos "Aplicar cupón". La petición queda retenida en Burp antes de llegar al servidor.
Esta petición individual la no vamos a reenviarla desde aquí: la mandamos a
Repeater con Ctrl+R y luego la descartamos (Drop) en Intercept, para que el pedido
siga sin consumir su cupón hasta el ataque en paralelo.
En Repeater, clic derecho sobre la pestaña > "Add tab to group" > "New tab group". Agrupar pestañas es el primer paso imprescindible: solo se puede enviar "en paralelo" a un grupo de pestañas, no a pestañas sueltas.
Ya agrupada, clic derecho de nuevo sobre la misma pestaña > "Duplicate tab". Burp Community permite indicar cuántas copias de golpe en vez de duplicar una a una.
Escribimos 14 en el diálogo (14 copias + la original = 15 peticiones en total):
Desplegamos la flecha junto al botón Send y elegimos "Send group in parallel (last-byte sync)" — la opción de Burp Community pensada exactamente para esto: retiene el envío de las 15 peticiones hasta el último instante posible y las dispara todas juntas, para que lleguen al servidor lo más cerca posible unas de otras. Mandarlas "rápido" una detrás de otra desde un script normal no basta: la latencia de red ya separa las peticiones más de lo que dura la ventana vulnerable.
Pulsamos "Send group (parallel)". Las 15 peticiones salen casi a la vez. La primera pestaña ya muestra el total colapsado:
No basta con mirar la primera pestaña: revisamos otra cualquiera del grupo (por ejemplo la 8) para
confirmar que varias, no solo la primera, recibieron un 200 OK
aceptando el cupón:
9.99 × 0.5
elevado a la potencia del número de veces que cada petición llegó a ejecutarse antes que las demás —
una cantidad ínfima que, al redondear a 2 decimales, ya se ve como 0.0 en la mayoría de
las 15 respuestas. Esto es exactamente lo esperado: no hace falta que las 15 muestren un valor
distinto para confirmar el fallo, basta con ver que más de una aceptó el cupón.
Los "200 OK" de Burp son una señal, no la prueba definitiva — la comprobación fiable es recargar la página real del pedido y ver el estado que realmente quedó guardado en el servidor.
CHECK (if checkout["cupon_aplicado"]: ...) y el
ACT (multiplicar el total y marcar el pedido) hay una ventana de tiempo en la que el estado
compartido (checkout["cupon_aplicado"]) puede ser leído por varias peticiones antes de que
ninguna de ellas lo haya actualizado todavía. No es un fallo de validación de datos — el código del
cupón era correcto en las 15 peticiones — es un fallo de tiempo.
2Saltarse el límite del plan
En "Mi plan", cada cuenta puede canjear el código de invitación de un amigo para ganar 1 mes de
GhostPass Premium gratis — pero, igual que el cupón del reto 1, el canje debería estar limitado a
una vez por cuenta. No hace falta que el código pertenezca realmente a nadie: el fallo
interesante no está en la validez del código, sino en que el flag invitacion_canjeada se
pueda saltar con el mismo patrón CHECK-espera-ACT del reto anterior.
Antes de tocar nada, la cuenta está en el plan gratuito, con 0 meses premium acumulados:
Escribimos cualquier código (AMIGO-1234) en el campo, activamos Intercept y pulsamos
"Canjear invitación". Igual que en el reto 1, mandamos esta petición a Repeater y la descartamos sin
reenviarla suelta.
Repetimos exactamente los mismos tres gestos que en el reto 1 (agrupar la pestaña, duplicarla 14 veces, armar "Send group in parallel" y disparar):
A diferencia del reto 1 (donde el redondeo a 2 decimales hacía que casi todas las respuestas
mostraran 0.0), aquí meses_premium es un contador entero que sube de uno
en uno — así que distintas pestañas muestran valores distintos según el orden real en que cada hilo
llegó a ejecutar su confirmación:
PLAN_STATE[uid]["invitacion_canjeada"] se lee y se escribe en dos pasos separados por un
time.sleep(1.0), dando tiempo a que otras peticiones lean el valor "no canjeada" antes de
que ninguna lo haya marcado como canjeada todavía.
3Reputación de productos
Cada producto muestra sus reseñas y una media recalculada al vuelo. Cada cuenta debería poder votar un producto una sola vez — el mismo límite de "una vez por cuenta" que ya vimos en los retos 1 y 2, ahora aplicado a una valoración de 1 a 5 estrellas en vez de a un cupón o una invitación.
Elegimos un producto con reseñas de partida (Tarjeta regalo GhostStore 20€, media 4.0 con 2 reseñas sembradas) y dejamos el formulario de valoración en 5 estrellas, el valor por defecto:
Interceptamos "Enviar valoración" y confirmamos el cuerpo exacto de la petición antes de mandarla a Repeater:
Agrupamos la pestaña y la duplicamos 14 veces, tercera vez con el mismo gesto:
Y disparamos "Send group (parallel)":
"valoracion":1 lanzada
contra el producto de un competidor hundiría su nota media de forma igual de eficaz. El fallo no está en
qué valor se manda, está en cuántas veces se cuela el mismo voto de la misma cuenta.
REVIEWS_LIVE[product_id] se comprueba
("¿ya hay un voto de este user_id?") y se actualiza (se añade el nuevo voto) en dos pasos
separados por la misma ventana de un segundo, sin ningún mecanismo que haga esas dos operaciones
atómicas entre sí.
Cómo se protege cada fallo
Los 3 retos comparten la misma causa raíz, así que comparten también la misma familia de mitigaciones — lo que cambia es sobre qué dato concreto se aplica el bloqueo:
# Opción 1 — lock explícito alrededor de CHECK + ACT (sencillo, pero serializa # TODAS las peticiones a ese recurso, no solo las que compiten de verdad) import threading lock = threading.Lock() with lock: if checkout["cupon_aplicado"]: return jsonify({"ok": False}), 409 checkout["total"] *= 0.5 checkout["cupon_aplicado"] = True # Opción 2 — UPDATE atómico con condición, comprobando cuántas filas cambiaron # (el patrón preferido cuando el estado vive en una base de datos real) cur = db.execute( "UPDATE checkouts SET cupon_aplicado=1 WHERE id=? AND cupon_aplicado=0", (checkout_id,), ) if cur.rowcount == 0: return jsonify({"ok": False, "error": "Cupón ya aplicado"}), 409 # rowcount == 1 aquí SOLO puede ser cierto para una petición, aunque # lleguen 100 a la vez: la propia base de datos serializa el UPDATE.
| Reto | Fallo | Mitigación concreta |
|---|---|---|
| 1 | Cupón aplicado N veces | Lock por checkout_id, o UPDATE checkouts SET cupon_aplicado=1 WHERE id=? AND cupon_aplicado=0 y comprobar rowcount antes de aplicar el descuento. |
| 2 | Invitación canjeada N veces | Lock por cuenta, o UPDATE plans SET invitacion_canjeada=1 WHERE user_id=? AND invitacion_canjeada=0 antes de sumar el mes premium. |
| 3 | Voto duplicado N veces | Restricción UNIQUE(product_id, user_id) a nivel de base de datos sobre la tabla de votos — el mismo tipo de defensa que ya vimos aguantar la concurrencia real en el capítulo de race conditions del manual de Burp, sobre email y sobre (ProductId, BasketId). |
Herramientas utilizadas
| Herramienta | Uso |
|---|---|
| Proxy > Intercept | Capturar la petición legítima una sola vez, para poder mandarla a Repeater sin que consuma el recurso antes del ataque real |
| Repeater — Add tab to group | Requisito previo para poder enviar un conjunto de peticiones "en grupo" |
| Repeater — Duplicate tab | Generar las 14 copias adicionales de golpe, sin duplicar una a una |
| Repeater — Send group in parallel (last-byte sync) | La pieza central de los 3 retos: retrasa el envío de todo el grupo hasta el último instante y dispara todas las peticiones lo más juntas posible en el tiempo |
| Recarga de la página real | Confirmar el resultado en el estado que de verdad quedó guardado en el servidor, no solo fiarse de los "200 OK" que muestra Burp |