An unintended solution for Intigriti Challenge 0526 — exploiting unsanitized
innerHTML in the display name field, bypassing a custom server-side
blocklist using HTML numeric entities and an overlooked event handler.
Intigriti's monthly XSS challenges test creative exploitation skills under real-world constraints. The May 2026 challenge presented a retro arcade-themed web application with a custom server-side filter.
While there was an intended solution path, I identified a stored XSS vulnerability in the profile display name field — an unintended attack surface the challenge authors confirmed after submission.
The root cause is in loadTestimonials() inside /js/app.js. The function renders both the testimonial content and the author display name, but handles them inconsistently:
app.js// loadTestimonials() — /js/app.js
data.forEach(t => {
let nameDiv = document.createElement('div');
nameDiv.className = 'user-name';
nameDiv.innerHTML = t.user_name; // ← no sanitization — XSS sink
let textDiv = document.createElement('div');
textDiv.className = 'user-text';
textDiv.innerHTML = DOMPurify.sanitize(t.content); // ← safe
card.appendChild(nameDiv);
card.appendChild(textDiv);
container.appendChild(card);
});
The testimonial body is protected with DOMPurify. The display name is not. Any HTML stored in user_name executes in every visitor's browser when the testimonials page loads.
renderProfile() itself — it populates the input field with value="${currentUser.name}", meaning whatever was submitted is returned from the server unchanged. Verified at #profile.
The server's custom "Advanced SCA Shield" blocked these characters and keywords:
| Blocked | Purpose | Bypass |
|---|---|---|
| ( ) . , ; " ' | Function calls, property access, string delimiters | ( ) . — numeric entities, no semicolon needed |
| alert | XSS canary | alert — encode first character only |
| document | DOM access | document — encode first character only |
| onload onerror script window eval | Common attack vectors | ontoggle — absent from blocklist |
HTML numeric entities like ( don't require a trailing semicolon when followed by a non-digit character. The browser decodes them correctly; the server's string-matching filter sees no blocked character.
Encoding only the first character of a blocked keyword breaks pattern matching. a decodes to a and the parser stops at l (non-digit), producing alert in the DOM — a string the server filter never matched.
All common event handlers were blocked. The remaining candidates each had a problem:
| Handler | Why it failed |
|---|---|
| onerror / onload | Explicitly blocked by the server filter |
| onmouseover / onclick | Requires user interaction — not reliable |
| script | Keyword blocked by the server filter |
ontoggle was the answer — completely absent from the blocklist.
Combined with <details open>, it fires the toggle event
the instant the element connects to the DOM, requiring zero user interaction.
The <details> tag itself is a plain HTML5 element with no suspicious keywords,
making it invisible to signature-based filters.
Combining all three techniques, the payload set as the display name field:
Navigate to challenge-0526.intigriti.io/challenge#register and create an account.
Login at challenge-0526.intigriti.io/challenge#login, then navigate to the Profile page.
At challenge-0526.intigriti.io/challenge#profile, set the Display Name field to:
payload<details ontoggle=alert(document.domain) open>
Go to challenge-0526.intigriti.io/challenge#testimonials and submit any testimonial to publish your display name to the public feed.
Any authenticated user visiting the URL below will trigger the alert immediately — no interaction required:
urlhttps://challenge-0526.intigriti.io/challenge#testimonials
✓ alert(document.domain) fires automatically
textContent for plain-text fields. Bypasses the HTML parser entirely — the correct approach when HTML rendering is not needed.
jsnameDiv.textContent = t.user_name; // no HTML parsing
content and user_name were handled was the core mistake.
jsnameDiv.innerHTML = DOMPurify.sanitize(t.user_name);
This challenge illustrates how inconsistent sanitization creates real vulnerabilities. The testimonial content was handled correctly — but one overlooked innerHTML assignment on an adjacent field was enough to compromise every user on the platform simultaneously.
The filter bypass required no exotic tooling — only an understanding of how browsers decode HTML entities before parsing event handler attributes, and awareness of which event handlers auto-fire on DOM insertion.
Special thanks to ayoub__intigriti and the Intigriti team for a well-constructed challenge.
Follow me on X: @hasn0x