Descripción del laboratorio
GhostCart es una tienda de electrónica de juguete (portátiles,
componentes, periféricos), deliberadamente vulnerable, con base de datos SQLite real por debajo — a
diferencia de su app hermana GhostFlix, aquí la inyección SQL del primer reto es genuina, no simulada.
Corre en local (python app/app.py) y no depende de ningún servicio externo.
Los cinco retos encadenan el mismo hilo argumental que sigue el vídeo de Gorka El Bochi "Así los hackers hackean tiendas online": un atacante sin cuenta se cuela dentro (reto 1), y una vez dentro descubre que casi cualquier identificador de la tienda — pedidos, perfiles, checkouts — se puede leer sin comprobar de quién es (retos 2 a 4), hasta terminar secuestrando la cuenta de otra persona por completo (reto 5).
POST /loginGET /orders/<id>GET /api/users/<id>GET /checkout/<id>/resumenPOST /reset-password/confirm
Antes de empezar: crea una cuenta en /register y configura el navegador (o usa el navegador
embebido de Burp) para que pase por el Proxy. Las capturas de este documento se tomaron con la app
arrancada en el puerto 5099 en vez del 5000 por defecto — no afecta a nada del
razonamiento. Usa /reset en cualquier momento para devolver todo el
laboratorio (base de datos completa) a su estado inicial.
1SQL Injection en el login
El formulario de login parece de lo más normal. Pero por debajo, la consulta SQL que comprueba usuario
y contraseña se construye concatenando texto directamente, sin parametrizar. Eso significa que cualquier
comilla que metamos en el campo usuario pasa a formar parte de la sentencia SQL real.
Sin cuenta todavía y sin saber ninguna contraseña, probamos a iniciar sesión.
Con Proxy > Intercept activado, mandamos el formulario con datos inventados.
En la pestaña Raw del editor, sustituimos el cuerpo de la petición por:
username=' OR '1'='1' -- &password=x
Esto reescribe la consulta del servidor a algo equivalente a
SELECT * FROM users WHERE username='' OR '1'='1' -- ' AND password='x' — el
-- comenta el resto de la sentencia, así que la condición de la contraseña nunca
llega a evaluarse. La cláusula '1'='1' es siempre verdadera, así que la consulta
devuelve la primera fila de la tabla users.
Content-Length: Burp lo recalcula solo al reenviar desde el
editor de Intercept.
Reenviamos la petición y desactivamos Intercept.
f"...WHERE username='{username}'...") en vez de con parámetros vinculados
(cursor.execute(query, (username, password))). Cualquier framework de acceso a datos moderno
soluciona esto por defecto si se usa correctamente — el fallo está en no haberlo usado.
2IDOR — pedidos de otros usuarios
La sección «Mis pedidos» lista correctamente solo los pedidos propios. Pero el detalle de cada pedido
vive en una URL con un identificador correlativo (/orders/1, /orders/2…) y el
backend nunca comprueba que ese pedido pertenezca a quien lo pide.
Con una cuenta recién creada (atacante_demo), «Mis pedidos» está vacío.
Sin haber comprado nunca nada, visitamos directamente /orders/1 — el primer pedido que
existe en toda la tienda, sembrado en la base de datos para la cuenta laura92.
En Proxy > HTTP history, la petición queda registrada: se envía únicamente la
cookie de sesión de atacante_demo y el servidor responde 200 OK con el
HTML completo del pedido de otra cuenta.
/orders/1, /orders/2, /orders/3… (por ejemplo con Intruder) para
volcar todos los pedidos de la tienda.
/orders/<id> consulta el pedido solo por su ID
(WHERE id=?) y nunca añade AND user_id = session['user_id']. Cualquier objeto
referenciado por un ID en la URL necesita esa comprobación de propiedad en el servidor.
3IDOR — datos de otros usuarios
La página «Mi cuenta» carga tu email, dirección y teléfono con una llamada a
/api/users/<tu-propio-id>. El id va en la URL en vez de en la sesión — y, otra vez, el
backend nunca comprueba que ese id coincida con la cuenta logueada.
Con la sesión de atacante_demo (id de cuenta 4), «Mi cuenta» hace un
fetch a /api/users/4 para rellenar la página.
Localizamos GET /api/users/4 en el historial y la mandamos a Repeater
(clic derecho > Send to Repeater, o Ctrl+R).
Editamos la primera línea de la petición para pedir el id 1 en vez del propio.
/api/users/me).
4IDOR — resumen de checkout
Cada vez que alguien empieza a pagar se crea una fila de checkout con un id correlativo,
igual que los pedidos. La página de resumen (/checkout/<id>/resumen) tampoco comprueba
de quién es ese checkout — aquí nos quedamos en ver el checkout ajeno, sin llegar a confirmarlo
(esa acción sí comprueba la propiedad en el servidor).
Compramos un portátil y llegamos al resumen de nuestro propio checkout, con estado «pendiente».
Visitamos /checkout/1/resumen — un checkout ya completado de otra cuenta.
completado,
y además esa acción sí valida la propiedad en el servidor. El fallo de este reto es de
exposición de información, no de manipulación de compra — ese vector ya lo cubre
GhostFlix.
AND user_id = session['user_id']
en la consulta del checkout. Tres endpoints distintos, el mismo error de diseño repetido.
5Account Takeover — reset de contraseña
El laboratorio no envía correos reales: al pedir un reset, el token y el user_id al que
pertenece se muestran directamente en pantalla, como si acabaran de llegar a la bandeja de entrada. El
paso de confirmación recibe token, user_id y nueva_password en el
mismo JSON — y aunque comprueba que el token exista y no esté usado, nunca comprueba que ese
token se emitiera para el user_id indicado.
Como atacante_demo, pedimos un reset para nuestro propio email en
/forgot-password.
El «correo simulado» nos da un token legítimo y, en el enlace, nuestro propio
user_id=4.
Activamos Intercept y escribimos una nueva contraseña cualquiera para nuestra propia cuenta.
Nuestro token sigue siendo válido — solo cambiamos a quién apunta. Sustituimos
"user_id":4 por "user_id":1 (la cuenta de laura92, cuyo id ya
conocemos por el reto 3).
Para comprobarlo del todo, iniciamos sesión como laura92 con la contraseña que acabamos de fijar nosotros:
existe y no usado) pero nunca cruza
password_resets.user_id == user_id recibido. Un token solo debería poder canjearse para la
cuenta exacta para la que se emitió — comprobarlo es una única línea de código y cierra el reto entero.
Cómo se protege cada fallo
| Reto | Fallo | Mitigación |
|---|---|---|
| 1 | SQL Injection | Consultas parametrizadas (execute(query, params)) siempre; nunca construir SQL por concatenación de texto de usuario. |
| 2 | IDOR — pedidos | Filtrar siempre por user_id de sesión además de por el ID del recurso, o usar identificadores no adivinables (UUID). |
| 3 | IDOR — usuarios | Endpoints de "mi perfil" sin id en la URL (/api/users/me); derivar siempre el sujeto de la sesión, no del cliente. |
| 4 | IDOR — checkout | Misma comprobación de propiedad en cada endpoint que exponga un recurso por ID, incluidas las vistas de solo lectura. |
| 5 | Account Takeover | Vincular criptográficamente el token al destinatario (o no aceptar user_id en la petición en absoluto: derivarlo del propio token). |
Herramientas utilizadas
| Herramienta | Uso |
|---|---|
| Proxy > Intercept | Interceptar y reescribir peticiones al vuelo (retos 1 y 5) |
| Proxy > HTTP history | Revisar tráfico ya capturado como prueba de un IDOR (reto 2) |
| Repeater | Repetir una petición cambiando solo el identificador objetivo (reto 3) |
| Barra de direcciones del navegador | Suficiente para un IDOR de solo lectura sin tocar cabeceras (reto 4) |