One challenge we wrote for HKCERT CTF 2022 is Minecraft Geoguessr. In this blog post, we will talk about stuffs behind the scene, including how the challenge was created and the lessons learnt. If you are interested in the solution, please refer to @mystiz’s blog.
The challenge is written by @apple and @mystiz, and it was solved by four teams (in the order of solving):
- S0162: CCC Kei Yuen College 🥇
- T0024: HKUST
- S0125: Carmel Secondary School
- O0056: T0003 (2022) @ TWY’s Temple
The players were given a web service. It receives 5-tuples $(x, y, z, \theta, \varphi)$ from the players ($x, y, z$ are the coordinates, and $\theta$ and $\varphi$ are the yaw and pitch respectively) and push it to the request queue. Our intern will consume the queue – He teleports to the correspond location, takes a screenshot and reflects it to the requester.
We are also given the below screenshot, where only the prefix of the flag hkcert22{
is shown. The objective is to recover the flag.
How did we hire our intern?
Earlier in October 2022, we posted our job description (Link here!) on various employing websites. As soon as we posted, we received around thousands of applications and we spent two days interviewing our candidates. Eventually, one candidate stood out among the shortlist.
…Well, that’s not the case. Jokes aside, let’s read the actual details.
Why Minecraft Geoguessr?
We always wanted to create a Minecraft-based CTF challenge. Minecraft is a fun game - not because M$ releasing new game features or mobs every few months, but it has an active hacking community to extend the game: Mods, server plugins, resource pack, etc. I (@apple) learned a lot with Minecraft, my first bash script ever written was a Minecraft texture generator that make use of ffmpeg
to convert video into animated block textures, which turns vanilla Minecraft into a video player (sound enabled!)
It would be great if we can hide some flags in Minecraft. Be it some kind of redstone machinery, or command injection (INSERT LINK HERE). However, due to the licensing problem we couldn’t make it fair to all participants so far, so the ideas were discarded.
On October 26 (16 days before HKCERT CTF), @Mystiz had an idea on the challenge. However, he thinks that he is only capable to write an solution but not the infrastructure. Here @apple hops in and suggested ways to implement the infrastructure.
Now we have one more five-star challenge in HKCERT CTF!
Infrastructure
The infrastructure for the challenge was fun. We need the actual Minecraft game for rendering the screenshots, then a Minecraft server so that we can teleport the player (thanks to Bukkit project and its forks to provide a convenient API), and finally a web front-end to for the players to interact with.
Report Done| APP APP --->|VNC take screenshot| MCCLIENT USER -->|Use| APP
The hardest part of the infra is to register a WoRkING MiCroSofT account so that we can login to the Minecraft client with the license.
Docker and stuff
Luckily, the Minecraft client works without GPU, so we can pack it into Docker and deploy everywhere. We tweaked acaranta/docker-minecraft-client. However, it consume way more computing power than we thought, so in the end it end up on its own VM.
Minecraft server plugin
We had some discussion on how the server should be teleporting the player. @Mystiz suggest that the Web server could send the Minecraft commands to the Minecraft server directly with rcon
to teleport the player, while @apple thinks it is better to develop a server plugin to poll teleport instructions from the server. Which is better?
In the end @apple implements the server plugin with ScriptCraft, a plugin that uses the JavaScript engine (Narshon/Rhino) in Java that enables writing JavaScript minecraft plugins. Instant reload. No compilation. But turns out all the headaches are from the JavaScript.
The JavaScript engine has been removed from Java few years ago and therefore it got deprecated. As the engine has been removed from most of the implementation of Java JVM, it is hard to get ScriptCraft working, but I do it anyway as I felt nostalgic with it.
Pre-generating world map
It is a common practice for server owners to pre-generate world map to reduce server resource consumption. In our case, we have to teleport our camera (player) as fast as possible, therefore the world map was pre-generated for the whole search space to avoid jamming the queue (and as a bonus, avoid side-channel attacks). Luckily, there were existing plugins like WorldBorder and Chunkmaster to do so.
Although the generation was painfully slow, we wait till the end and got a ~80GB world map. One of the reason for the slowness is it generates the world with all the entities, caves, and stuffs that are not useful for solving the challenge. We deliberately did not disable them, as it is not Minecraft without them!
Instead, we tried to disabled mobs/animals AI to squeeze more processing power from our CPU by tweaking entity-activation-range
setting in Spigot. By setting the activation range to a minimal value, the entities (mobs/animals) stand still and doesn’t move at all so you can take the best shot with your favourite llama.
We also disabled weather to deliver better screenshot to everyone, setting the time to noon to avoid unintended solution… etc. There was an unexpectedly long list of options could be set on a Minecraft world, even without any plugin.
Screenshot taker
Another thing we implemented is a screenshot taker (Yes, it is not performed by the intern). We wrote a microservice called which takes screenshots via a VNC connection. In short, the service is a VNC viewer that connects to the VNC server (the Minecraft client). The screen of the VNC is projected to a HTML5 canvas. It is then rendered as a DOM and saved as a blob of PNG.
The blob is then saved to the database and delivered to the players.
How can we improve?
This is one of the challenges we spent a lot of effort in. The architecture is not tiny as there are multiple components involved.
We received quite a number of comments (complaints) from the players. We also identified some issues regarding the system. We were only able to fix some of them during the competition…
There are even downtimes for the services and we have to find excuses during those moments:
Lack of labour
This might be one of the most popular challenge in HKCERT CTF 2022 – since many of the players knows Minecraft, they decided to give it a go. There are in total 8000+ screenshots taken in a span of 45 hours.
It takes 15 seconds for our intern to capture one screenshot. During peak hours, there was around 220 pending requests, which implies that the waiting time is around 55 minutes.
It is obvious that we should have allocated more budget on hiring more interns. During the competition, we made some announcements claiming that our intern is too busy. Unfortunately, it is hard to fix that during the contest. It is not that we are lack of budget to hire more interns, but our infrastructure. Scalability was the issue – the service was only able to handle one “intern” back then.
Unhelpful hCaptcha
Many contestants complained hCaptcha is bad. For instance, it is not user-friendly, and there are automated solvers online.
The intention for using hCaptcha was to prevent users from brute forcing. Given that it can be automated, we are obviously not doing a good job. Alas, it is not a good option to use reCAPTCHA when hosting international CTFs because it is blocked in some countries.
Subtle input range
We mentioned that $-20000 \leq x, z \leq 20000$ in the challenge statement. Well, the range is shown in the error message, which is too subtle. We should have the conditions explicitly mentioned.
Improper input validations
Surprisingly, we were struck by a few validation issues. Those validation issues were so bad that the service even crashed.
There is a missing validation we observed during the contest. It happened in the API GET /api/jobs/:id
. Unlike Javascript, the integers in PostgreSQL are limited between -2147483648 and 2147483647.
router.get('/jobs/:id(\\d+)', async function (req, res) {
const id = parseInt(req.params.id, 10)
const [
{ rows: jobs },
{ rows: [{ min_id: minId }]}
] = await Promise.all([
db.pg.query(`SELECT id, created_at, updated_at, status, image FROM jobs WHERE session_id = $1::text AND id = $2::integer`, [req.session, id]),
db.pg.query(`SELECT MIN(id) as min_id FROM jobs WHERE status != 3`)
])
if (jobs.length === 0) return res.status(404).json({ error: 'Job not found.' })
const job = jobs[0]
if (job.status !== 3) return res.send('Processing')
return res.send(`<img src="${job['image']}">`)
})
Some players happened to send a large enough ID: 1234000410064738
. Now there is an exception when running the SQL query. The code did not catch the exception and it eventually crashed.
A mysterious overload
We are using ScriptCraft this time. During the contest, we hit an exception regarding overloading. According to the message, there are two overloads for the Location
constructor:
new Location(org.bukkit.World, double, double, double)
, andnew Location(org.bukkit.World, double, double, double, float, float)
.
However, it cannot identify the correct overload for new Location(world, 0, 64, 0, 0.1, 0.1)
. This is strange because we have been using it during the contest. We cannot figure out the reasons, and we should test more throughoutly next time for a more robust challenge environment.