On August 2020, @blackb6a was invited to co-organize HKCERT CTF 2020 (which is held on November 2020) as one of the challenge authors. This is a CTF for secondary and tertiary students in Hong Kong. Although I had experience preparing CTFs earlier, this is actually the first CTF officially prepared by Black Bauhinia. I have written four challenges for this CTF - Sanity Check II (Web), LF2 (Reverse), Sign In Please (Crypto) and Calm Down (Crypto). There will also be some stories behind the scenes.

Why writing something that happened half year ago? I don’t have a particular reason for this, I think they should be documented for completeness.


Sanity Check II (Web)

Challenge Summary

It is the easiest challenge amongst the ones that I created. There are 32 (out of 74) and 42 (out of 69) solves in the secondary and tertiary divisions respectively.

Is your sanity enough? I afraid you are unable to face the upcoming challenges...

In the challenge, there is a web service for player to input his name. After that, there will be a battle between the players and Cthulhu. If the player beats Cthulhu, then he wins the flag. However, the battle logic is performed on the client side. The Docker environment is available here.


I have been wondering why the cthulhu-styled sanity check never appeared in CTFs, given that there's a sanity check challenge in most of them.

I am going to skip the solution as this is pretty straight-forward.

LF2 (Reverse)

Challenge Summary

There is a solve from the tertiary division and zero solves from the secondary division.

Have you ever played Little Fighter 2? I have modified stage.dat to make the game more interesting. Of course, I hid a flag inside, too.

Attachment: stage.dat

The stage.dat provided is pretty much the same as the stage.dat given from v2.0a of LF2, except that the first 123 bytes are different.


Since I am hosting a CTF for Hong Kong, I wanted to create some questions about the city. For example, Little Fighter 2 (LF2) is a game made in Hong Kong that I liked when I was young. It was the first game that I tried to modify the data files to add more skills on characters at that time.


The intended way will be reverse engineering the executable for the game, lf2.exe. The are functions those encrypt and decrypt .dat files. Let's see the function at 0x4146B0, which encrypts raw content into a .dat file.

This is a simple encryption function for data files.

The decompiled code suggests how a raw content are encrypted:

  1. Prepend 123 bytes to the content (Lines 22-30).
  2. Encrypt the content with Vigenere cipher (Lines 34-41).

The key is given on line 13, too: SiuHungIsAGoodBearBecauseHeIsVeryGood. The first 123 bytes are dropped normally. It is however crucial in the challenge and let's keep them. Hence, it suffices to decrypt the data file with the below snippet:

with open('stage.dat', 'rb') as f:
    d = f.read()

key = b'SiuHungIsAGoodBearBecauseHeIsVeryGood'

d = bytes([(d[i] - key[i % len(key)]) % 256 for i in range(len(d))])

with open('stage', 'wb') as f:

The flag hkcert20{lf2_i5_st1l1_awes0me} is right in front of us. Yeah, LF2 is still awesome and there are still animated films1 regarding to the game last month!

An easier solution

Instead of reversing the binary of the game, reverse the data changers. For example, LF2 - Online Data Changer is written in JavaScript. The below snippet shows how they decrypt the data files:

var key = "odBearBecauseHeIsVeryGoodSiuHungIsAGo";

//Algorithm from here: http://www.lf-empire.de/forum/showthread.php?tid=10301
function decrypt() {
    var reader = new FileReader();
    reader.onload = function(){
        data = reader.result;
        var text = "";
        for (var i = 123, j = 0, len = data.length, klen = key.length; i < len; i++, j++)
            text += String.fromCharCode(data.charCodeAt(i) - key.charCodeAt(j%klen));
        var editor = ace.edit("DCode");
        editor.setValue(text, -1);
        editor.getSession().setUndoManager(new ace.UndoManager());

Here we have the encryption (and decryption) algorithm. We are able to retrieve the flag by patching the script to decrypt the first 123 bytes.

Sign In Please (Crypto)

Challenge Summary

There is a solve from the tertiary division and zero solves from the secondary division.

I have implemented a secure authentication system. You can't eavesdrop the passwords, can you?

Attachment: chall.py

We are given ten attempts in total for spy or auth. The objective is to recover a 16-byte password $p$.

  • For the spy request, we are able to fix a permutation-box pbox and a four-byte salt. The password along with the given salt will be permuted based on pbox and is hashed with SHA-256. The digest is returned.
  • For the auth request, we need to hash the password along with the salt and the P-box given by the server and return its digest. If it checks out with the expected result, we will be given the flag.

Remarkably, the permutation-box is not necessarily straight. The only condition is len(set(pbox)) == 20. Let's have some examples:

salt     = b'0123'

# Example 1
pbox = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]
permutated_password = permute(pbox, password, salt)
assert permutated_password == b'ABCDEFGHIJKLMNOP0123'

# Example 2
pbox = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
permutated_password = permute(pbox, password, salt)
# No way, only 19 distinct elements in pbox

# Example 3
pbox = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 3, 3]
permutated_password = permute(pbox, password, salt)
assert permutated_password == b'ABCDEFGHIJKLMNOP0123DD'

# Example 4
pbox = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, -10, -11, -12, -13, -14, -15, -16, -17, -18, -19]
permutated_password = permute(pbox, password, salt)
assert permutated_password == b'ABCDEFGHIJJIHGFEDCBA'


The challenge exploits the Merkle-Damgard construction for SHA256 to reveal the secret. There is a writeup compiled by TWY from Firebird CTF team.

Calm Down (Crypto)

Challenge Summary

There is a solve from the tertiary division. I expected this problem to be very challenging, we did not release this to the secondary division.

I am so excited having a chance talking to Alice. She told me to calm down - and sent me an encrypted secret.

Attachment: chall.py

We are given a RSA modulus $n$ and a ciphertext $c$ that contains the flag. We are also given an oracle $\mathscr{O}: \mathbb{Z} \rightarrow \{0, 1\}$, where

\[\mathscr{O}(c) = 1\Longleftrightarrow m = c^d\ \text{mod}\ n\wedge m\equiv 46\ (\text{mod}\ 256).\]

In short, the oracle takes a ciphertext $c$ and returns a positive result (nice) if and only if its corresponding plaintext ends with a fullstop (ASCII value being 46).


The challenge is a variant of least significant oracle and the Bleichenbacher's attack for RSA. Unfortunately, the message is so small (relative to the modulus) that it can be solved with binary search, which is unintended. This is a writeup compiled by hoifanrd from Firebird CTF team.

Behind the Scenes

Burning nice challenge ideas

Since this is the first CTF to high school students in Hong Kong, we wanted to make some interesting hacking challenges. One idea I had is Minecraft command injection. Suppose that we have a web interface that allows players to shoot arrows by setting initial velocity Y and Z below. The objective is to hit the bullseye with the spawned arrow.

summon arrow 0 100 0 {Fire:200,Motion:[0.0,Y,Z]}

That looks easy, right? Here is the twist: There is a glass wall in between. The video shows Y = 0.5 and Z = 3.6:

Let's cut the crap. Because that I did not properly validating the initial velocity, there is a Minecraft-style command injection. Looking at the summon command, there is a parameter called Passengers where we can mount a creeper on the arrow. This is what we have:

Y = 0.5
Z = 3.6], Passengers:[{"id":"minecraft:creeper"}

By looking up the documentation for creeper, there are two important NBT tags those are specific to creepers: ignited and Fuse. ignited is a boolean flag indicating that the creeper is ignited, and Fuse is the number of ticks for the creeper to explode after ignited. By trial-and-error, we find the best set of parameters for the arrow to penetrate the glass wall and hit the bullseye.

Y = 0.4
Z = 3.6], Passengers:[{"id":"minecraft:creeper","ignited":1b,"Fuse":10b}

Unfortunately, I was not motivated enough to make the challenge happen. I instead used this as an example when I deliver a workshop prior to the game. This actually attracted some students enrolling to the contest.

Interesting flag submissions: OSINT master

During the contest, I found there are some strange attempts on the crypto challenges I made. One of the submission being:


I found this particularly funny, since that was actually the flag of a challenge I made (challenge and writeup here). Nice try, but this is a crypto challenge instead of an OSINT one (and I am not well-known reusing challenges).

Cooperation is difficult

There are a number of co-organizers who helped creating questions. Since we are the most experienced playing CTFs, we took the leading role to create challenges. There are two people I feel particularly frustrating while working with them.

When the deadline is approaching, some of them made a lot of guessy challenges and I was very frustrated. Well... I think they are just redefining CTF games in a weird way. This is an example of a crypto challenge they made, where I didn't even know how is the hint related. If you are interested about the solution, just Google the numbers. I even tweeted here and there without context at that time.

I tried my best to protect the author's identity.

Luckily, we are the leading team for problemsetting and are able to decide what challenges should be included in the game. Most guessy challenges are rejected and students need not to see them during the contest. This may seemed good for the participants, but definitely kills my brain cells. One co-organizating party felt bad when their challenges got rejected. Although I have documented the reasons why aren't the challenges accepted (for example, it is impossible to recover a 9-byte message encrypted with a variable-size XOR key), he just didn't read it and blamed me. Well... I will never want to work with them.

To express my discontent, There is a weird signature on each of the challenges I made (one in Calm down). I was expecting that the co-organizers is aware of this during the review period. However, they didn't look at the challenges at all. We decided to release it in production. πŸ˜„

  1. Desmond Lo (2021) "ε°ζœ‹ε‹ι½Šζ‰“δΊ€ LITTLE FIGHTER: REBORN - EP. 1"
    https://www.youtube.com/watch?v=aBU3vFYea7U ↩