GhostLabs

GhostCart

ejercicios de burp suite :: tienda online
ghostcart-lab — write-up
Laboratorio
GhostCart
Dificultad
★★★☆☆ media
Categoría
Authentication / Access Control (IDOR)
Objetivo
http://127.0.0.1:5000
Herramientas
Burp Proxy · Repeater · HTTP history
Retos
5
⚠ Este documento contiene la solución completa de los 5 retos — resuélvelos primero por tu cuenta

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

▶ Reto 1 → SQL Injection en POST /login
▶ Reto 2 → IDOR sobre pedidos en GET /orders/<id>
▶ Reto 3 → IDOR sobre usuarios en GET /api/users/<id>
▶ Reto 4 → IDOR sobre checkouts en GET /checkout/<id>/resumen
▶ Reto 5 → Account Takeover en POST /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

POST /login · Proxy > Intercept

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.

1
Formulario de login normal

Sin cuenta todavía y sin saber ninguna contraseña, probamos a iniciar sesión.

Formulario de login
/login — formulario estándar de usuario y contraseña
2
Activar Intercept y enviar un intento cualquiera

Con Proxy > Intercept activado, mandamos el formulario con datos inventados.

Intercept activado
Intercept is on — listos para retener el POST /login
Petición original interceptada
Petición retenida: username=cualquiera&password=x
3
Inyectar el payload en el campo usuario

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.

Payload de inyección SQL en el body
Body reescrito a mano en el editor Raw de Burp, con la inyección SQL
No hace falta tocar Content-Length: Burp lo recalcula solo al reenviar desde el editor de Intercept.
4
Forward y comprobación

Reenviamos la petición y desactivamos Intercept.

Resultado
Sesión iniciada como «laura92» sin conocer su contraseña
Catálogo mostrando sesión de laura92
/catalog — "Conectado como laura92", la primera cuenta sembrada en la base de datos
Causa raíz: la query del login se construye con un f-string (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

GET /orders/<id> · Proxy > HTTP history

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.

1
Cuenta nueva, sin pedidos propios

Con una cuenta recién creada (atacante_demo), «Mis pedidos» está vacío.

Mis pedidos vacío
/orders — "Todavía no has hecho ningún pedido"
2
Probar el ID más bajo posible

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.

Pedido ajeno visible
/orders/1 — dirección de envío, artículos e importe de laura92, visibles desde otra cuenta
3
Confirmarlo en Burp

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.

Petición y respuesta en HTTP history
GET /orders/1 con la cookie del atacante — 200 OK, título "Pedido #1 — GhostCart"
Los IDs correlativos hacen que este ataque sea trivial de automatizar: bastaría con recorrer /orders/1, /orders/2, /orders/3… (por ejemplo con Intruder) para volcar todos los pedidos de la tienda.
Causa raíz: el handler de /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

GET /api/users/<id> · Repeater

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.

1
Mi cuenta — datos propios

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.

Mi cuenta con datos propios
/account — ID de cuenta 4, datos propios (sin dirección, cuenta recién creada)
2
Enviar esa petición a Repeater

Localizamos GET /api/users/4 en el historial y la mandamos a Repeater (clic derecho > Send to Repeater, o Ctrl+R).

Petición cargada en Repeater
Repeater con GET /api/users/4 y la cookie de sesión intacta
3
Cambiar el ID a mano

Editamos la primera línea de la petición para pedir el id 1 en vez del propio.

ID cambiado en Repeater
GET /api/users/1 HTTP/1.1 — mismo cookie de atacante_demo, otro id de cuenta
4
Enviar y leer la respuesta
Resultado
Email, dirección y teléfono de laura92, filtrados a otra cuenta
Datos personales filtrados en la respuesta JSON
200 OK — direccion, email, id:1, telefono y username de laura92 en la respuesta
Causa raíz: el mismo patrón que en el reto 2, ahora sobre datos personales en vez de pedidos. Un endpoint de «mi perfil» nunca debería aceptar un id ajeno en la URL — debería derivar el usuario exclusivamente de la sesión (por ejemplo, /api/users/me).

4IDOR — resumen de checkout

GET /checkout/<id>/resumen

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

1
Nuestro propio checkout

Compramos un portátil y llegamos al resumen de nuestro propio checkout, con estado «pendiente».

Checkout propio pendiente
/checkout/3/resumen — perteneciente a atacante_demo, estado PENDIENTE
2
Cambiar el ID en la barra de direcciones

Visitamos /checkout/1/resumen — un checkout ya completado de otra cuenta.

Resultado
Carrito y total del checkout #1 de laura92, visibles sin ser su dueño
Checkout ajeno visible
/checkout/1/resumen — "Perteneciente a laura92", artículos y total completos
Fíjate en que el botón «Confirmar y pagar» no aparece aquí: el checkout ya está 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.
Causa raíz: idéntica a los retos 2 y 3 — falta el filtro 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

POST /reset-password/request · POST /reset-password/confirm · Proxy > Intercept

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.

1
Pedir un reset para la propia cuenta

Como atacante_demo, pedimos un reset para nuestro propio email en /forgot-password.

Formulario de recuperación de contraseña
/forgot-password — este laboratorio no envía correos reales
2
Obtener un token válido y sin usar

El «correo simulado» nos da un token legítimo y, en el enlace, nuestro propio user_id=4.

Token de reset propio en pantalla
Correo simulado con el enlace de restablecimiento para atacante_demo (user_id=4)
Página de elegir nueva contraseña
/reset-password/confirm?token=...&user_id=4 — página para elegir la nueva contraseña
3
Interceptar el envío del formulario

Activamos Intercept y escribimos una nueva contraseña cualquiera para nuestra propia cuenta.

Intercept activado antes de confirmar
Proxy > Intercept activado, listos para retener el POST de confirmación
POST de confirmación interceptado
POST /reset-password/confirm retenido: {"token":"...","user_id":4,"nueva_password":"hackeada456"}
4
Cambiar el user_id por el de la víctima

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

user_id cambiado en el body JSON
Body reescrito: {"token":"...","user_id":1,"nueva_password":"hackeada456"}
5
Forward y verificación
Resultado
"Contraseña de «laura92» actualizada correctamente"
Mensaje de éxito tras el ataque
El servidor confirma el cambio de contraseña de la cuenta de otra persona

Para comprobarlo del todo, iniciamos sesión como laura92 con la contraseña que acabamos de fijar nosotros:

Sesión iniciada como laura92 tras el ataque
/catalog — "Conectado como laura92": control total de su cuenta sin haber recibido ningún correo suyo
Causa raíz: el backend valida el token (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

RetoFalloMitigació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

HerramientaUso
Proxy > InterceptInterceptar y reescribir peticiones al vuelo (retos 1 y 5)
Proxy > HTTP historyRevisar tráfico ya capturado como prueba de un IDOR (reto 2)
RepeaterRepetir una petición cambiando solo el identificador objetivo (reto 3)
Barra de direcciones del navegadorSuficiente para un IDOR de solo lectura sin tocar cabeceras (reto 4)