Mix

Chaining DOM clobbering and CSP bypasses for XSS


At Intigriti, we host monthly web-based Capture The Flag (CTF) challenges as a way to engage with the security researcher community. This month’s challenge, brought forward by Kulindu, presented us with a Secure Search Portal that, on the surface, appeared to be well protected. A strict Content Security Policy and DOMPurify sanitization gave the impression that this month’s task of executing an XSS vulnerability would be difficult. But as we’ll see, chaining several gadgets together proved otherwise.

This article provides a step-by-step walkthrough for solving March’s CTF challenge while demonstrating how chaining DOM clobbering with a CSP bypass can result in an exploitable DOM-based XSS vulnerability.

Let’s dive in!

The Secure Search Portal is a clean, minimal web application that allows visitors to search through what’s described as a secure enclave. The interface itself is straightforward, it features a search box, a results section, and a “Report to Admin” button that sends a URL to an admin bot for review.

INTIGRITI 0326 CTF Challenge

Looking at the challenge rules, we can note the following:

  • We must find a flag in the following format: INTIGRITI{.*}

  • The correct solution should leverage an XSS vulnerability on the challenge page

  • Self-XSS or MiTM attacks are not allowed

  • The attack should not require more than a single click (submitting a URL to the admin bot)

The presence of the reporting feature already gave us a clue that the challenge would involve tricking a privileged bot into exfiltrating its session cookie or performing any other privileged actions. But first, we need to find a way to execute arbitrary JavaScript code on behalf of the bot.

As usual, we start by examining the application to understand how it’s built. Using tools like Wappalyzer or BuiltWith, we can determine that the backend runs on Node.js with Express. The challenge also included a few JS files, so we could analyze the JavaScript code loaded by the challenge page. However, unlike some past challenges, we did not receive access to the backend source code for this CTF. Practically, this means we’d need to rely on our observations and the client-side code to piece things together.

The first thing that immediately caught our attention was the Content Security Policy header, visible in the response headers:

Content Security Policy header

If you have some prior knowledge about how CSPs work, you’d understand that this policy is a fairly restrictive CSP. The script-src 'self' directive is the one that matters most to us, it means that only scripts served from the same origin are allowed to execute. Inline scripts like are completely blocked. There’s also an X-Content-Type-Options: nosniff header, which prevents MIME-type sniffing attacks.

Next, let’s look at how user input is handled. In main.js, we can see that the query parameter q is reflected on the page, but it’s first passed through DOMPurify:

const cleanHTML = DOMPurify.sanitize(q, {
    FORBID_ATTR: ['id', 'class', 'style'],
    KEEP_CONTENT: true
});

resultsContainer.innerHTML = `

Results for: ${cleanHTML}

`;

DOMPurify is one of the most robust HTML sanitizers available, and the challenge is running version 3.0.6. On top of that, the FORBID_ATTR option explicitly blocks the id, class, and style attributes. This is interesting because it suggests the challenge author was specifically trying to block DOM clobbering attacks that rely on the id attribute.

At this point, it looks as if we’re stuck. The CSP blocks inline scripts, and DOMPurify strips out any dangerous input. This means we’ll need to gather some more information. Let’s dig deeper into the other JavaScript files that the application loads.

The application loads three JavaScript files: purify.min.js, components.js, and main.js. We’ve already analyzed main.js, so let’s shift our focus to the components.js file, which contains two interesting code snippets. Let’s explore them all.

1. ComponentManager

The first thing that stands out in components.js is a ComponentManager class:

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 looks promising, as the ComponentManager searches for elements in the DOM that have a data-component attribute with a value of true, reads their data-config attribute (a JSON string), and dynamically creates a



Source link