From SVG and back, yet another mutation XSS via namespace confusion for DOMPurify < 2.2.2 bypass

For those who are only interested in the final payload here you go (I won’t judge). For the ones interested in why it works, please bear with me.

Snippet 1: final payload

First of all, I would like to point out that this article and the bypass described here are heavily based on Michał Bentkowski (@SecurityMB) research. The original article and previous bypass who made it possible for me to find this new vector are available here. The article also contains all of the required foundations the reader may need to properly understand how and why the bypass described here works. It took me a few rounds of reading and playing with the LiveDOM++ tool, also provided by @SecurityMB, to really understand why Michał’s original bypass worked in the first place. Gareth Heyes (@garethheyes) also built upon Michał’s work and found a variation of the original bypass days after it got published. I also took my time with Gareth’s bypass to properly understand what was going on.

What is DOMPurify?

DOMPurify usage sample

According to Michał’s article:

In terms of parsing and serializing HTML as well as operations on the DOM tree, the following operations happen in the short snippet above:

html is parsed into the DOM Tree;

DOMPurify sanitizes the DOM Tree (in a nutshell, the process is about walking through all elements and attributes in the DOM tree, and deleting all nodes that are not in the allow-list);

The DOM tree is serialized back into the HTML markup;

After assignment to innerHTML, the browser parses the HTML markup again;

The parsed DOM tree is appended into the DOM tree of the document;

The important takeaway is that the HTML markup is parsed twice and serialized into a string in between.

Namespaces, and why they are important

Namespaces solve the ambiguity problem when a single XML document contains homograph elements and/or properties from different “vocabularies”. For example, all the previously listed specifications contain a tag named style. The style tag has different properties and rendering behaviors associated with it depending on the namespace it's used. This means that a browser will behave differently when it parses a style tag depending on its ancestors (Figure 1, Figure 2).

style tag in the HTML namespace
style tag in the HTML namespace
Figure 1: style tag in the HTML namespace
style tag in the SVG namespace
style tag in the SVG namespace
Figure 2: style tag in the SVG namespace

DOM mutation in a nutshell

Snippet 2: nested form mutation gadget

We can use the LiveDOM++ tool to inspect how the DOM behaves, before and after being sanitized with DOMPurify, when it is fed with the HTML fragment mentioned above.

nested forms parsed twice
nested forms parsed twice
Figure 3: nested forms parsed twice

As demonstrated, when parsed for the first time, the HTML fragment results in a non-compliant DOM tree containing nested form tags. After sanitizing it, DOMPurify will serialize the DOM tree and the resulting string will be parsed again by the browser. It is then possible to verify that the direct parenthood of the input tag is transferred the inner form tag to the outer form tag in the final fragment.
We will refer to the type of HTML markup that results in a mutation that changes the direct parent of a tag as an ownership mutation gadget.

The table tag can also be used to construct an ownership mutation gadget as shown below.

Figure 4: table ownership mutation gadget

In the example above the direct parent of the inner anchor tag is changed from the outer anchor to the div tag. Understanding how these gadgets behave is crucial to understanding the bypass construction. I recommend experimenting with the gadgets on LiveDOM++ to get a better understanding of how the gadgets behave.

Foreign content

Figure 5: html->svg->math->html transition chain

Namespace confusion, putting it all together

At the time of this writing, it is still possible to use the update parser feature of the LiveDOM++ tool to reproduce the original bypass using a vulnerable version of DOMPurify.

Figure 6: update parser feature
Figure 7: original mXSS bypass

The details of why and how this works can be referenced in Michał’s original article. Understanding the original bypass is important because it provides the foundation for the variation described in this article.

Building the final payload

Both Michał and Gareth used mutations coming from HTML to MathML and back. What if we throw SVG in the mix?

The picture below illustrates the basic structure of the bypass. Using a nested form ownership mutation gadget it is possible to change the mglyph tag parent to mtext; that places the descendants of the mglyph tag to be in the MathML namespace in the final DOM tree. Nothing new so far. The difference is that instead of using it to go from HTML to MathML, we are using it to go from SVG to MathML. HTML→MathML→HTML→SVG to HTML→MathML to be precise.

Figure 8: from SVG to MathML

At this point, I knew I was on to something but it took me a while to build a working payload. The whole point was figuring out a chain of tags that were safe in the SVG namespace and that contained an XSS payload when transferred to the MathML namespace.
This is what I came up with:

Snippet 2: bypass fragment

In the SVG namespace, mtext will be handled as an unknown tag. The style tag descendants will be rendered as opposed to its homograph in the HTML namespace, and the id attribute of the SVG path tag is nothing more than a safe, free-form text identifier.

What happens when this snippet is parsed in the MathML namespace though?

Here is the result:

Figure 9: bypass fragment in the MathML namespace

First of all, mtext is handled as a MathML text integration point. This means its descendants will be in the HTML namespace, according to MathML’s current specification.

… when MathML is embedded in HTML, or another document markup language, the example is probably best rendered with only the two inequalities represented as MathML at all, letting the text be part of the surrounding HTML.

Once in the HTML namespace, style behaves differently and treats everything until its closing tag as raw text. Finally, the malicious img tag is parsed followed by a harmless piece of raw text that reads “>.

The following snapshot depicts the DOM tree state before being sanitized by DOMPurify.

Figure 10: final payload DOM tree before sanitization

This next snapshot shows the DOM tree produced by parsing DOMPurify’s output.

Figure 11: final payload DOM tree after sanitization

Disclosure timeline


Security researcher and penetration tester

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store