1075 words
5 minutes
CVE-2026-0540: How a CTF Detour Led Us to a DOMPurify mXSS - Daft

TL;DR#

DOMPurify 3.1.3 through 3.3.1 contained an mXSS edge case that became exploitable when sanitized output was reparsed inside special wrappers such as xmp, iframe, noembed, noframes, noscript, and script. The bug received CVE-2026-0540, was publicly disclosed by Fluid Attacks as advisory daft, and was fixed in 3.3.2 with a small patch series rather than a single one-line change.

What made this one fun is how we found it. Cristian Vargas and I were playing a CTF and, almost by accident, tried an XSS route against a sink that looked correctly sanitized. Both of us managed to pop it anyway. At that point we stopped thinking about the challenge and started asking the more interesting question: was this actually a DOMPurify edge case? It turned out the answer was yes, and it was not part of the challenge after all.


How We Found It#

This bug did not start with a source audit. It started with a mistake.

Cristian and I were solving a CTF challenge and went after an XSS sink that was already going through DOMPurify. The sink should have been boring. Instead, we both managed to bypass it.

That was the moment the challenge stopped mattering.

The payload was surviving sanitization as inert-looking attribute data, but after the application wrapped the sanitized result and reparsed it with innerHTML, the browser produced a different DOM tree. That is the classic shape of mutation XSS: the dangerous behavior does not exist in the first parse, it appears in the second one.

Once we realized that, we built a minimal reproducer outside the challenge and confirmed the behavior consistently enough to turn it into a proper report.


Why This Happened In DOMPurify#

The interesting part was not the innerHTML sink by itself. DOMPurify’s own docs already warn that sanitizing once and then changing context afterwards can void the effects of sanitization. The more specific issue was that the library’s SAFE_FOR_XML handling was already trying to defend against dangerous sequences in attribute values, but its regex did not cover all the wrappers that mattered here.

Before the fix, the relevant check in src/purify.ts looked like this:

/((--!?|])>)|<\/(style|title|textarea)/i

That meant DOMPurify knew certain closing sequences inside attributes were too risky to keep when SAFE_FOR_XML was on, but it was only accounting for style, title, and textarea.

The missing cases were the raw-text or raw-text-like wrappers that made our PoC work:

  • xmp
  • iframe
  • noembed
  • noframes
  • noscript

And, in a follow-up patch commit, script was added as well.

Since SAFE_FOR_XML is enabled by default, this was not an obscure opt-in corner case. It was a default-on safeguard that simply did not model the full set of wrappers it needed to care about.


Reproducing The Behavior#

Our PoC used a server-side DOMPurify instance backed by jsdom, returned the sanitized string to the browser, and then deliberately reparsed it inside special wrappers.

The minimal server-side path was:

const { JSDOM } = require('jsdom');
const createDOMPurify = require('dompurify');
const window = new JSDOM('').window;
const DOMPurify = createDOMPurify(window);
app.post('/sanitize', (req, res) => {
const sanitized = DOMPurify.sanitize(req.body.input);
res.json({ sanitized });
});

The client-side sink was:

const sanitized = data.sanitized || '';
sink.innerHTML = '<' + wrapper + '>' + sanitized + '</' + wrapper + '>';

With wrapper = "xmp" and this payload:

<img src=x alt="</xmp><img src=x onerror=alert(1)>">

the second parse produced a live onerror handler.


Fuzzing The Wrappers#

Once we had a working xmp case, the next question was obvious: how many other wrappers behave the same way?

We fuzzed the wrappers listed in DOMPurify’s special-content handling and then broadened that into a larger tag sweep. The interesting set in the vulnerable flow ended up being:

  • xmp
  • iframe
  • noembed
  • noframes
  • noscript
  • script

That final script case matters because it was not part of the first rawtext-regex expansion. It appeared in the follow-up patch, which is one reason the remediation story here is more accurately described as a patch series than a single fix.

One nuance is important: this is not the same thing as saying “browser-only DOMPurify is trivially bypassed everywhere.” In my testing, a browser-only PoC using DOMPurify directly in the page escaped the closing sequences and did not reproduce. The exploitable condition showed up in the end-to-end flow where sanitized output was produced and later reparsed into a different context.

That distinction is also why this bug sat in an awkward but interesting place between a library issue and an integration misuse pattern.


Advisory Evidence#

PoC Video#

XSS Triggered#

Advisory evidence showing the alert triggered after reparsing the sanitized payload


What DOMPurify’s Docs And Threat Model Say#

DOMPurify’s documentation already contains two warnings that matter here.

First, the README explicitly says that if you sanitize HTML and then modify it afterwards, you can void the effects of sanitization. That maps directly to a flow that sanitizes once and later reparses the result inside a new wrapper.

Second, the threat model says DOMPurify does not protect against “faulty use or flipping of markup context.” Their example is sanitizing HTML and then throwing it into SVG or another XML-based context. The underlying principle is the same: if you change parsing context after sanitization, you can create a bypass.

So the honest framing is not “DOMPurify promised to solve every context switch and completely failed.” The honest framing is narrower and more useful:

  • DOMPurify documents that context flipping is dangerous and partly out of scope.
  • At the same time, SAFE_FOR_XML is a default-on defense specifically meant to neutralize risky structural sequences in attributes.
  • That defense missed several wrappers, and upstream patched the gap.

In other words, even if the issue lives near the edge of the stated threat model, the project still chose to harden the sanitizer and ship a fix. That is the right outcome, and it is one reason I think this advisory is worth studying.


The Patch Was Not One Commit#

If you only look at the release page, it is easy to treat 3.3.2 as a single black-box fix. That misses what actually happened.

The remediation on the 3.x line landed as a short sequence:

  1. Commit 729097f expanded the SAFE_FOR_XML regex to include xmp, noscript, iframe, noembed, and noframes.
  2. Commit 302b51d extended that regex again to also cover script.
  3. Release commit 5e56114 bundled the final 3.3.2 release.

The core source-level change ended up like this:

/((--!?|])>)|<\/(style|script|title|xmp|textarea|noscript|iframe|noembed|noframes)/i

There was also a backport on the legacy 2.x branch. Commit d59bfe7 carried the same idea into 2.5.9, which matters for deployments still pinned to the MSIE-compatible line.

So if you are documenting this issue, the accurate story is:

  • one advisory
  • one public fixed version on 3.x
  • multiple security-relevant commits
  • one legacy-branch backport

References#

  1. Fluid Attacks advisory: Daft
  2. DOMPurify release 3.3.2
  3. DOMPurify release 2.5.9
  4. Commit 729097f: expand SAFE_FOR_XML regex on 3.x
  5. Commit 302b51d: add script to the regex
  6. Commit d59bfe7: 2.x backport
  7. DOMPurify README
  8. DOMPurify Security Goals & Threat Model
  9. VulnCheck advisory
CVE-2026-0540: How a CTF Detour Led Us to a DOMPurify mXSS - Daft
http://caverav.cl/posts/dompurify-mxss/dompurify-mxss/
Author
Camilo Vera
Published at
2026-03-29
License
CC BY-NC-SA 4.0