Walking through Intigriti's May 2026 XSS challenge
I spent an afternoon on this challenge. The vulnerability was clear, the chain is satisfying, and I went down at least two wrong roads before the right one. Here is the whole story, including the wrong roads, because the dead ends are where the learning lives.
The challenge
https://challenge-0526.intigriti.io/challenge. A retro arcade-themed SPA called Pixel Pioneers, and the goal was to pop alert().
The page is small. A login form, a register form, a profile editor, and a "community feed" of testimonials. The footer claims protection by "SCA Shield v1.0". The only included libraries are DOMPurify 3.0.9 and /js/app.js.
Mapping the surface
The first thing I do on any client-side challenge is pull the JS and read it end-to-end. The interesting bit is loadTestimonials():
data.forEach(t => {
let nameDiv = document.createElement('div');
nameDiv.innerHTML = t.user_name;
let textDiv = document.createElement('div');
textDiv.innerHTML = DOMPurify.sanitize(t.content);
card.appendChild(nameDiv);
card.appendChild(textDiv);
});
textDiv is sanitized. nameDiv is not. The display name is whatever the user puts in their profile (POST /api/profile, field name). So the sink is nameDiv.innerHTML and the source is the name field.
Right after that loop, the function does this:
let config = window.PixelAnalyticsConfig || { enabled: false, scriptUrl: '/js/mock-tracker.js' };
if (config.enabled) {
let s = document.createElement('script');
s.src = config.scriptUrl;
document.body.appendChild(s);
}
I underlined this in my notes. window.PixelAnalyticsConfig is never declared anywhere in the source. The || fallback says quietly: "if someone happens to put something on window here, trust it." That is a tell for DOM clobbering, and I should have stayed with it. I did not.
The SCA Shield mapped one token at a time
Before deciding on a payload shape, I wanted the filter. I wrote a small loop that sends candidate names to /api/profile and prints which ones the server accepts. Within a minute, I had this:
Banned literal characters:
"'().,;Banned keyword substrings (case-sensitive):
alert,document,window,eval,script,src,srcdoc,scriptUrl,onload,onerror,onloadstartAllowed: most HTML tags, every event handler I tried that was not
onloadoronerror,href,data,iframe,object,embed,svg,details,form,input,button,autofocus,formaction,&,#,=, digits, backslash
Two characters jumped out. & is allowed, and # is allowed. That means HTML numeric character references survive the filter, which means banned characters can be smuggled and banned keywords can be split across encoded boundaries. I will come back to that.
Wrong road number one: a direct event handler payload
My first instinct was the dumb one. Skip the analytics tracker chain, just do XSS the normal way:
<details open ontoggle=alert(1)>X</details>
The idea was: alert is alert after JS unicode unescape, so the keyword filter does not see the literal string alert. The numeric references ( 1 ) are (, 1, ) after HTML attribute parsing, so the banned punctuation does not appear in the raw bytes. <details open> fires a toggle event on parse, so even via innerHTML the handler runs.
This works. SCA Shield accepts it. The testimonials endpoint returns it. The browser builds the right DOM. But there is a problem I did not anticipate.
Wrong road number two: the per-user feed
I tried to verify the payload by logging in as a second user and visiting /challenge#testimonials. Zero cards rendered. I checked the API directly. GET /api/testimonials returns only the rows where the logged-in user is the submitter. The "community feed" label is aspirational. It is actually a private feed.
That made my whole approach self-XSS. The attacker sets their own name to a malicious value, submits a testimonial, sees their own testimonial render their own malicious name, and pops an alert at themselves. A judge or victim using a different account would never see the row.
I poked around for a way around this. Tried query parameters (?all=1, ?username=xyz). Tried alternate routes (/api/testimonials/all, /api/admin/testimonials). Tried obvious session fixation: the session cookie value I got was the integer 12, and yes, session=1 is admin and session=2 is a pre-seeded user named xyz. None of that helped me move a payload from one account's feed to another.
This is where I got stuck. The sink was real, the bypass worked, but the exploit was self-XSS, which the rules explicitly disallow.
The hint that fixed my brain
Then I saw a hint was dropped in Intigriti's Twitter handle:
Look closely at the global window variables on the Testimonials page to find an uninitialized configuration object waiting to be overwritten by a clever family of HTML elements.
I had been ignoring the analytics tracker block. That block is the whole point.
The reframing is this. The challenge solution is judged by visiting the URL and observing alert(...). The single-user flow is the demonstration. A submitter registers, sets the name, posts a testimonial, opens /challenge#testimonials themselves, and the alert pops. That is not self-XSS in the rule-book sense, because the payload is persisted on the server and triggered through normal rendering. Once I let go of the "cross-user feed must work" constraint, the path was obvious.
The clobbering anchors
window.PixelAnalyticsConfig is undeclared. If multiple elements share id="PixelAnalyticsConfig", the named element lookup on window returns an HTMLCollection of those elements. HTMLCollection has a namedItem(name) accessor that surfaces children by their name attribute as properties. And HTMLAnchorElement coerces to its href when stringified, which is what s.src = config.scriptUrl does internally.
Stack those four facts, and you get this:
<a id=PixelAnalyticsConfig></a>
<a id=PixelAnalyticsConfig name=enabled></a>
<a id=PixelAnalyticsConfig name=scriptUrl href="data:text/javascript,alert(document.domain)"></a>
After parse:
window.PixelAnalyticsConfigresolves to the HTMLCollection.config.enabledreturns the second anchor. An object is truthy, so theifpasses.config.scriptUrlreturns the third anchor.s.src = anchorcoerces the anchor to a string, which is itshref.The browser fetches
data:text/javascript,alert(document.domain)and runs the body as a script. Alert pops.
Three anchors, a data URI, and the right id and name attributes are all you need.
Getting that string past SCA Shield
Now the encoding game. The clobbering payload as written contains every banned character ((, ), ,, .), every banned keyword (scriptUrl, script, alert, document), and quotes around the data: URI. None of it makes it through directly.
The bypass is HTML5 numeric character references without trailing semicolons. The HTML tokenizer, inside an attribute value, sees &#NN and consumes digits until it hits a non digit. The semicolon is optional. The missing semicolon is a parse error in the spec, but the character still decodes. So the raw bytes never contain the banned character, but the post-parse DOM does.
The substitution table I ended up using:
| Char | Entity |
|---|---|
i |
i |
a |
a |
l |
l |
u |
u |
, |
, |
. |
. |
( |
( |
) |
) |
I encoded one character out of every banned keyword to split the literal substring. scriptUrl becomes scriptUrl. javascript becomes javascript. alert becomes alert. document becomes document. The keyword filter sees broken strings. The HTML parser sees the whole word.
The data: URI has no surrounding quotes. Unquoted attribute values are legal in HTML, terminated by whitespace or >. I used that to avoid sending " or ', both of which are banned.
The final payload
This is the literal string sent as the name field on POST /api/profile:
<a id=PixelAnalyticsConfig><a id=PixelAnalyticsConfig name=enabled><a id=PixelAnalyticsConfig name=scriptUrl href=data:text/javascript,alert(document.domain)>
After HTML parsing in the browser, it is equivalent to:
<a id=PixelAnalyticsConfig></a>
<a id=PixelAnalyticsConfig name=enabled></a>
<a id=PixelAnalyticsConfig name=scriptUrl href="data:text/javascript,alert(document.domain)"></a>
Reproduction in four calls
POST /api/registerwith any{username, password}.POST /api/profilewith the payload above asname. SCA Shield accepts it.POST /api/testimonialswith{"content":"anything"}. The feed needs one row for the rendering pass to fire.Open
https://challenge-0526.intigriti.io/challenge#testimonialsin Chrome. Alert pops on load.
One navigation, which is a single click.
What I would have done differently
The mistake is one I keep making. When a JS file has a pattern that looks like a deliberate hook, that pattern is the answer. The window.PixelAnalyticsConfig || { ... } block was sitting there with a sign on it. I noticed it on the first read, made a note, and then chased a more obvious-looking sink because the obvious sink had a simpler bypass.
The lesson for next time: rank suspicious patterns by how unusual they are, not by how easy they look to exploit. A textbook stored XSS bypass is fun to write, but if the architecture also hands you a DOM clobbering target two functions down, that target is almost certainly the intended path.
Things I learned (or relearned) along the way
HTML numeric character references without semicolons still decode in attribute values. I knew this in theory, and I had forgotten it in practice. Anyone defending against XSS with a literal substring blocklist needs to either normalise all entity forms before matching or give up the blocklist entirely.
DOM clobbering does not need executable HTML. DOMPurify can strip every <script> tag and every event handler and still leave <a id=X name=Y href=Z> intact, because anchors look benign. The clobbering works through the parsed DOM's name resolution rules, not through script execution. If your app reads any control flow value off window or off document.getElementById, an attacker with one HTML write primitive has a path even past a strong sanitiser.
HTMLAnchorElement.toString() returns the href. This is the trick that lets a clobbered anchor stand in for a URL anywhere a string is expected. It is also the reason data: URIs land cleanly when an anchor is the source of a <script src> value.
The "DOM-based" versus "stored" XSS taxonomy is about the sink, not the source. When the payload persists on the server but the sink is innerHTML in client side JS, both labels are partly right. The modern OWASP term is "stored DOM XSS". For triage and CWE, pick the option your platform offers and explain the chain in the writeup.
Read every globally referenced variable before you start writing payloads. If a name shows up on the right-hand side of an || and is never declared on the left-hand side of an =, you have probably found half of the chain already.
Listen to the write-up hints, even when they sound like riddles. The phrase "a clever family of HTML elements" was a near-literal description of the three anchors. I read it and still spent another five minutes looking for a single-element solution.
What the defender should change
Make window.PixelAnalyticsConfig unclobberable. The cleanest fix is to declare it explicitly in the page bootstrap with Object.defineProperty(window, 'PixelAnalyticsConfig', { value: { enabled: false, scriptUrl: '/js/mock-tracker.js' }, writable: false, configurable: false }). Better: stop reading control flow values off window at all. Parse them out of a <script type="application/json"> block with JSON.parse.
Retire the SCA Shield as a security boundary. Blocklists on raw input do not survive the gap between what the parser sees and what the DOM ends up holding. Move the safety to the output side, where the rules are simple: escape on render into HTML text, use safe assignment APIs (textContent, setAttribute with vetted names) instead of innerHTML, and put any user-controlled URL through a scheme allowlist before it gets near a <script> or <iframe> element.
A real CSP would also have prevented this. script-src 'self' blocks the data: URI script loads even when the clobbering succeeds.
Closing
This challenge is a small one with three layers stacked on top of each other: a raw innerHTML sink, a control flow read off an undeclared global, and a server side blocklist that does not normalize entities. None of those bugs is exotic on its own. That is a fair fight, and solving it was a good way to spend a day.


