Intigriti · May 2026 · Web Security

Zero-Interaction Stored XSS
via Filter Bypass

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.

Stored XSS Filter Bypass Zero Interaction Unintended Solution

Introduction

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.

Vulnerability Analysis

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.

i Evidence that the value is persisted as-is server-side comes from renderProfile() itself — it populates the input field with value="${currentUser.name}", meaning whatever was submitted is returned from the server unchanged. Verified at #profile.

Filter Bypass

The server's custom "Advanced SCA Shield" blocked these characters and keywords:

BlockedPurposeBypass
( ) . , ; " ' Function calls, property access, string delimiters &#40 &#41 &#46 — numeric entities, no semicolon needed
alert XSS canary &#97lert — encode first character only
document DOM access &#100ocument — encode first character only
onload onerror script window eval Common attack vectors ontoggle — absent from blocklist

Technique 1 — Semicolon-free Numeric Entities

HTML numeric entities like &#40 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.

Technique 2 — Partial Keyword Encoding

Encoding only the first character of a blocked keyword breaks pattern matching. &#97 decodes to a and the parser stops at l (non-digit), producing alert in the DOM — a string the server filter never matched.

Technique 3 — Auto-firing Event Handler

All common event handlers were blocked. The remaining candidates each had a problem:

HandlerWhy it failed
onerror / onloadExplicitly blocked by the server filter
onmouseover / onclickRequires user interaction — not reliable
scriptKeyword 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.

Final Payload

Combining all three techniques, the payload set as the display name field:

Stored in database
<details ontoggle=&#97lert&#40&#100ocument&#46domain&#41 open>
Browser decodes to
<details ontoggle=alert(document.domain) open>
! The browser performs HTML entity decoding before parsing event handler attributes. The filter inspects the raw stored string — which never contains the blocked characters or keywords.

Steps to Reproduce

1
Register an account

Navigate to challenge-0526.intigriti.io/challenge#register and create an account.

2
Login and go to Profile

Login at challenge-0526.intigriti.io/challenge#login, then navigate to the Profile page.

3
Set Display Name to the payload

At challenge-0526.intigriti.io/challenge#profile, set the Display Name field to:

payload<details ontoggle=&#97lert&#40&#100ocument&#46domain&#41 open>
4
Submit any testimonial

Go to challenge-0526.intigriti.io/challenge#testimonials and submit any testimonial to publish your display name to the public feed.

5
Trigger

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

Execution Flow

Victim navigates to #testimonials
route() → renderTestimonials() → loadTestimonials()
fetch('challenge#testimonials') returns attacker's entry
nameDiv.innerHTML = t.user_name — no sanitization
container.appendChild(card) — element connects to DOM
<details open> fires toggle event automatically

alert(document.domain) confirmed — zero user interaction

Remediation

Conclusion

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