GhostLabs

GhostRace

ejercicios de burp suite :: tienda de recargas gaming
ghostrace-lab — write-up
Laboratorio
GhostRace
Dificultad
★★★☆☆ media
Categoría
Race Condition (TOCTOU)
Objetivo
http://127.0.0.1:5002
Herramientas
Burp Repeater · agrupar pestañas · enviar en paralelo
Retos
3
⚠ Este documento contiene la solución completa de los 3 retos — resuélvelos primero por tu cuenta

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.

▶ Reto 1 → Comprar gratis — aplicar el mismo cupón de bienvenida muchas veces sobre un pedido
▶ Reto 2 → Saltarse el límite del plan — acumular más de 1 mes de invitación premium
▶ Reto 3 → Reputación de productos — inflar (o hundir) la nota media a base de votos duplicados

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.

Ventana de la carrera (aplica a los 3 retos, mismo patrón) ═══════════════════════════════════════════════════════════════════════ 15 peticiones idénticas, disparadas lo más cerca posible en el tiempo ("Send group in parallel (last-byte sync)" de Burp Repeater) T+0.00s req 1 ── CHECK: ¿ya hecho? NO ──▶ empieza a esperar (sleep 1.0s) T+0.00s req 2 ── CHECK: ¿ya hecho? NO ──▶ empieza a esperar (sleep 1.0s) T+0.00s req 3 ── CHECK: ¿ya hecho? NO ──▶ empieza a esperar (sleep 1.0s) ⋮ (las 15 comprueban "NO" casi a la vez) T+0.00s req 15 ── CHECK: ¿ya hecho? NO ──▶ empieza a esperar (sleep 1.0s) ─────────────────────────── 1 segundo de ventana ─────────────────────── T+1.00s req 1 ── ACT: marca "ya hecho" + aplica el efecto ──▶ 200 OK T+1.00s req 2 ── ACT: marca "ya hecho" + aplica el efecto ──▶ 200 OK (demasiado tarde para avisar: T+1.00s req 3 ── ACT: marca "ya hecho" + aplica el efecto ──▶ 200 OK ya pasaron el CHECK antes) ⋮ T+1.00s req 15 ── ACT: marca "ya hecho" + aplica el efecto ──▶ 200 OK Resultado: el efecto se aplica 15 veces en vez de 1 — el cupón se descuenta 15 veces, se acumulan 15 meses de premium, o se cuelan 15 votos de la misma cuenta.

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

POST /api/checkout/<id>/aplicar-cupon · Proxy > Intercept · Repeater · grupos en paralelo

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.

0
Crear un pedido

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.

Página de checkout recién creada con el cupón BIENVENIDA50
/checkout/1000 — total 9.99€, campo de cupón con BIENVENIDA50 ya escrito
1
Interceptar la petición del cupón

Con Proxy > Intercept activado, pulsamos "Aplicar cupón". La petición queda retenida en Burp antes de llegar al servidor.

Peticion POST aplicar-cupon interceptada en Burp
POST /api/checkout/1000/aplicar-cupon — {"code":"BIENVENIDA50"}, retenida en Intercept

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.

2
Agrupar la pestaña de Repeater

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.

Menu contextual Add tab to group con New tab group
Clic derecho en la pestaña de Repeater > Add tab to group > New tab group
3
Duplicar la pestaña 14 veces

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.

Menu contextual con la opcion Duplicate tab
Clic derecho sobre la pestaña ya agrupada > Duplicate tab

Escribimos 14 en el diálogo (14 copias + la original = 15 peticiones en total):

Dialogo Duplicate tab con el campo puesto a 14
Duplicate tab — 14 veces
15 pestañas numeradas del 1 al 15 dentro del mismo grupo
Group 1 ahora contiene 15 pestañas idénticas (1 a 15), todas con la misma petición
4
Armar "Send group in parallel"

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.

Menu desplegable junto a Send con la opcion Send group in parallel
Flecha junto a Send > "Send group in parallel (last-byte sync)"
Elegir esta opción solo arma el modo — el botón principal pasa a decir "Send group (parallel)". Todavía hace falta pulsarlo para que dispare de verdad.
5
Disparar y comprobar varias respuestas

Pulsamos "Send group (parallel)". Las 15 peticiones salen casi a la vez. La primera pestaña ya muestra el total colapsado:

Respuesta 200 OK con total 0.0 en la pestaña 1
Pestaña 1 — 200 OK, {"ok":true,"total":0.0}

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:

Respuesta 200 OK identica en la pestaña 8
Pestaña 8 — también 200 OK, {"ok":true,"total":0.0}: el descuento se aplicó multiplicativamente tantas veces que, redondeado a 2 decimales, el total ya aparece en 0.0€ en la mayoría de las respuestas
Con un descuento del 50% aplicado 15 veces seguidas, el total real es 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.
6
Verificar el estado real en la app

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.

Resultado
Pedido de 9.99€ con total final 0.00€ — "comprar gratis" de forma literal
Pagina de checkout recargada mostrando total 0.00 euros
/checkout/1000 recargado — Total a pagar: 0.00€, botón "Aplicar cupón" ya deshabilitado (cupón ya consumido)
Causa raíz: entre el 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

POST /api/plan/canjear-invitacion · misma técnica: agrupar + duplicar + enviar en paralelo

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.

1
Estado inicial del plan

Antes de tocar nada, la cuenta está en el plan gratuito, con 0 meses premium acumulados:

Pagina Mi plan mostrando GhostPass Free y 0 meses premium
/plan — GhostPass Free, 0 meses premium acumulados
2
Interceptar el canje y enviarlo a Repeater

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.

Peticion POST canjear-invitacion interceptada
POST /api/plan/canjear-invitacion — {"codigo_amigo":"AMIGO-1234"}
3
Agrupar, duplicar y disparar en paralelo

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):

15 pestañas duplicadas en un nuevo grupo para el reto 2
Group 2 — 15 pestañas idénticas con la petición de canje
Respuesta 200 OK con meses_premium en 1 tras el disparo en paralelo
Una de las pestañas — 200 OK, {"ok":true,"meses_premium":1}

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:

Otra pestaña mostrando meses_premium en 4
Otra pestaña del mismo grupo — {"ok":true,"meses_premium":4}: la cuenta ya lleva 4 canjes aceptados, no 1
4
Verificar el estado real en la app
Resultado
15 meses de GhostPass Premium acumulados con un beneficio pensado para canjearse una sola vez
Pagina Mi plan recargada mostrando GhostPass Premium y 15 meses
/plan recargado — GhostPass Premium, 15 meses premium acumulados, "Ya has canjeado tu invitación" (bloqueado para futuros intentos)
El mensaje "Ya has canjeado tu invitación de bienvenida" confirma que, en uso normal y secuencial, la protección funciona perfectamente — el fallo solo aparece bajo concurrencia real, que es precisamente lo que hace a las race conditions tan fáciles de pasar por alto en una revisión de código o en una prueba manual de la interfaz.
Causa raíz: idéntica a la del reto 1, sobre un recurso distinto — 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

POST /api/productos/<id>/valorar · misma técnica, tercera vez

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.

1
Estado inicial del producto

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:

Pagina de producto mostrando media 4.0 con 2 reseñas y el formulario de valoracion
/producto/4 — Reseñas (2 · media ⭐ 4.0), formulario de valoración con 5★ preseleccionado
2
Interceptar, agrupar, duplicar y disparar

Interceptamos "Enviar valoración" y confirmamos el cuerpo exacto de la petición antes de mandarla a Repeater:

Peticion POST valorar interceptada con valoracion 5
POST /api/productos/4/valorar — {"valoracion":5}

Agrupamos la pestaña y la duplicamos 14 veces, tercera vez con el mismo gesto:

15 pestañas duplicadas en un tercer grupo para el reto 3
Group 3 — 15 pestañas idénticas con la valoración de 5 estrellas

Y disparamos "Send group (parallel)":

Respuesta 200 OK con votos_totales en 1
Una de las pestañas — 200 OK, {"ok":true,"votos_totales":1}
Otra pestaña mostrando votos_totales en 3
Otra pestaña del grupo — {"ok":true,"votos_totales":3}: al menos 3 de las 15 peticiones colaron su voto antes de que el flag de "ya has votado" surtiera efecto
3
Verificar el estado real en la app
Resultado
15 votos de 5 estrellas colados con la misma cuenta — media disparada de 4.0 a 4.9
Pagina de producto recargada con 17 reseñas y media 4.9, incluyendo 15 reseñas de Tu con 5 estrellas
/producto/4 recargado — Reseñas (17 · media ⭐ 4.9), 15 filas nuevas "Tú ⭐5", "Ya has valorado este producto" (bloqueado en adelante)
Vale la pena señalar que el mismo fallo, en sentido contrario, es un vector real de sabotaje de reputación, no solo de autopromoción: la misma técnica con "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.
Causa raíz: tercera repetición del mismo patrón — 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.
RetoFalloMitigació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).
La opción de la base de datos (UNIQUE / UPDATE condicionado) es preferible siempre que el estado ya viva en una base de datos real: el propio motor serializa la operación de forma atómica sin que el código de la aplicación tenga que gestionar ningún lock a mano, ni arriesgarse a olvidarlo en algún endpoint nuevo.

Herramientas utilizadas

HerramientaUso
Proxy > InterceptCapturar 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 groupRequisito previo para poder enviar un conjunto de peticiones "en grupo"
Repeater — Duplicate tabGenerar 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 realConfirmar el resultado en el estado que de verdad quedó guardado en el servidor, no solo fiarse de los "200 OK" que muestra Burp