-->
TISC 2024 ran from 13th to 29th September this year, right after my prelims ended on the 13th. (I actually went cycling on the afternoon of 13th and got lost and only came home at midnight so I couldn’t play as I had planned)
Also before the CTF started I was already 1st place because of alphabetical order :). I knew I was fated to get 1st place.
In an act of heroic defiance, I defied fate and did not get 1st place, solving 11/12 of the challenges in the 16 day duration.
Here are my writeups for the 12 challenges in the CTF. I went the RE route instead of web/cloud this year, and it was pretty fun so I don’t regret it.
(I wrote some of these writeups in a hurry because of my upcoming IB finals that I need to study for - I’ll update them after my finals are over)
The dust has settled since we won the epic battle against PALINDROME one year ago.
Peace returned to cyberspace, but it was short-lived. Two months ago, screens turned deathly blue, and the base went dark. When power returned, a mysterious entity glitched to life on our monitors. No one knows where it came from or what it plans to do.
Amidst the clandestine realm of cyber warfare, intelligence sources have uncovered the presence of a formidable adversary, Vivoxanderith—a digital specter whose footprint spans the darkest corners of the internet. As a skilled cyber operative, you are entrusted with the critical mission of investigating this elusive figure and their network to end their reign of disruption.
Recent breakthroughs have unveiled Vivoxanderith’s online persona: vi_vox223. This revelation marks a pivotal advancement in our pursuit, offering a significant lead towards identifying and neutralizing this threat.
Our mission now requires a meticulous investigation into vi_vox223’s activities and connections within the cyber underworld. Identifying and tracking Vivoxanderith brings us one crucial step closer to uncovering the source of the attack and restoring stability to our systems. It is up to you, agent!
Searching for the mysterious vi_vox223
using
Sherlock, we see an Instagram
profile with the same name. Under his
stories, this elusive figure has created a Discord bot with ID
1284197383864979486. We have the exclusive opportunity to grace our server with
the bot’s presence using this link:
We need to have the elusive D0PP3L64N63R role to unlock the hidden menu. After
that, we can access the filesystem and download the Update_030624.eml
file.
Opening this file, there is an email attached:
Dear Headquarters,=20
I trust this message reaches you securely. I am writing to provide an =
update on my current location. I am currently positioned close to the =
midpoint of the following IDs:
=09
* 8c1e806a3ca19ff=20
=09
* 8c1e806a3c125ff=20
=09
* 8c1e806a3ca1bff=20
My location is pinpointed with precision using Uber's cutting-edge geo=
spatial technology, which employs shape-based location triangulation a=
nd partitions areas of the Earth into identifiable cells.
To initiate secure communication with me, please adhere to the discree=
t method we've established. Transmit the identified location's name th=
rough the secure communication channel accessible at https://www.linke=
din.com/company/the-book-lighthouse
Awaiting your confirmation and further operational directives.=20
Best regards,=20
Vivoxanderith
Using the location IDs provided, we can pinpoint the location to this:
We can also visit the Linkedin provided to access the secret agent under the guise of a Telegram bot with username @TBL_DictioNaryBot. Sending the identified location will confirm our identity and give us the flag.
TISC{OS1N7_Cyb3r_InV35t1g4t0r_uAhf3n}
Good job on identifying the source of the attack! We are one step closer to identifying the mysterious entity, but there’s still much we do not know.
Beyond Discord and Uber H3, seems like our enemies are super excited about AI and using it for image transformation. Your fellow agents have managed to gain access to their image transformation app. Is there anyyy chance we could find some vulnerabilities to identify the secrets they are hiding?
In this level, PALINDROME employs the power of LLMs to edit photographic evidences of their crimes. we’re provided with 1 web interface (3, actually, cos remote kept crashing) where we can upload images, specify some transformation on the image, and download the newly transformed image. It uses an LLM to generate GraphicsMagick commands which are run on the server (this is totally unnecessary and it is clear PALINDROME just wants to hop on the LLM bandwagon). We can get the LLM to generate our own GraphicsMagick commands like so:
and the LLM will replace the input and output filenames accordingly. Neat!
In GraphicsMagick, we can add comments to images, which will append the string to the end of the image file - we can see this when we strings it. Since the command is being ran directly in the shell (bash presumably), we can use globbing to put the contents of flag.txt in the comments.
Uploading any image and entering this command, then downloading the image and
running strings
on it, will give us the flag.
TISC{h3re_1$_y0uR_pr0c3s5eD_im4g3_&m0Re}
Ah, who exactly is behind the attacks? If only our enemies left more images on their image transformation server. We are one step closer, but there is still so much to uncover…
A disc image file was recovered from them! We have heard that they have a history of hiding sensitive data through file hosting sites… Can you help us determine what they might be hiding this time?
Looks like we got our hands on one of PALINDROME’s agent’s disk images… We’re
provided with a huge 1.3GB csitfanUPDATED0509.ad1
file. We can use FTK Imager
to open this and view the contents of the disk.
The title of the challenge hints towards checking out the user’s browser
history, so we should do exactly that. Under the Documents and Settings
folder, there are 2 browser folders - Mozilla and “Mypal68” (but the Mozilla
folder was empty). Going to “Mypal68”, we can see 2 profiles -
ibxh2pqf.default
and a80ofn6a.default-default
. The former is mostly empty,
so let’s look at the latter, where we find the places.sqlite
file. This file
generally contains the browser history for Mozilla browsers - exactly what we
wanted! Let’s extract this file and open it.
Wow so many tables…
Luckily, the moz_places
table was the only table we needed - when dumping the
contents of this table, we see the following row:
17|https://csitfan-chall.s3.amazonaws.com/flag.sus|flag.sus|moc.swanozama.3s.llahc-naftisc.|0|0|0|0|1725522130630000|0Ds_Z7hyaY9W|0|47357524652742|||10
The downloaded file contains the Base64-encoded flag.
TISC{tru3_1nt3rn3t_h1st0r13_8445632pq78dfn3s}
In the dark corners of the internet, whispers of an elite group of hackers aiding our enemies have surfaced. The word on the street is that a good number of members from the elite group happens to be part of an exclusive member tier within AlligatorPay (agpay), a popular payment service.
Your task is to find a way to join this exclusive member tier within AlligatorPay and give us intel on future cyberattacks. AlligatorPay recently launched an online balance checker for their payment cards. We heard it’s still in beta, so maybe you might find something useful.
In this level, PALINDROME is using a service to conduct their shady money laundering schemes where we can upload an “AGPAY” card. The card validation is done client-side, so we can reverse the logic to create our own card. If our card’s balance is equal to 313371337, we will get the flag.
The card format has several things we need to look out for:
card_number + expiry_date + balance
, encrypted
using AES-CBC, with a key and IV of our choicemd5(encrypted_data + iv)
I pasted the script into Gemini and asked it to create a Python script for me. It was actually pretty good and I didn’t have to modify much to obtain a valid card!
After generating our card and uploading it, we get our flag.
TISC{533_Y4_L4T3R_4LL1G4T0R_a8515a1f7004dbf7d5f704b7305cdc5d}
Shucks… it seems like our enemies are making their own silicon chips??!? They have decided to make their own source of trust, a TPM (Trusted Platform Module) or I guess their best attempt at it.
Your fellow agent smuggled one out for us to reverse engineer. Don’t ask us how we did it, we just did it, it was hard …
All we know so far is that their TPM connects to other devices using the i2c bus and does some security stuff inside. Agent! Your mission, should you choose to accept it, is to get us unparalleled intel by finding their TPM’s weakness and exfiltrating its secrets.
You will be provided with the following compressed flash dump:
- MD5 (flash_dump.bin.xz) = fdff2dbda38f694111ad744061ca2f8a
Flash was dumped from the device using the command: esptool.py -p /dev/REDACTED -b 921600 read_flash 0 0x400000 flash_dump.bin
You can perform your attack on a live TPM module via the i2c implant device hosted behind enemy lines:
nc chals.tisc24.ctf.sg 61622
We’re provided with a flash dump of the esp32 chip. The flash dump contains several partitions, including the app that we’re interested in. We can view these partitions using https://github.com/tenable/esp32_image_parser.
And we can dump the app0
partition into an ELF file that we can reverse:
Unfortunately, the chip uses the xtensa
instruction set, which IDA doesn’t
support that well - we can’t decompile stuff nicely. But luckily, Ghidra is able
to output nice decomp so we’ll use Ghidra instead!
I was hoping that the main function would be easy to find, but that’s not the
case. In here, we start at the start
function, which has a lot a lot of calls
everywhere. Even worse, the binary is stripped so we don’t have proper function
names. I tried to trace it but gave up after spending hours trying to identify
the main function.
After clicking through Ghidra for pretty long, I found this banner string in
.dram0.data
that seems quite suspicious:
This string seems like a likely banner output from the chip when we interact with it. We can jump to its xrefs to find our main function.
Some comparisons are being made, presumably against our input. So that’s how we can interact with the chip - by sending a byte of either F, M or C. The protocol is using the I2C protocol, which according to Wikipedia and the instructions provided on remote, consists of a 2 byte data stream. The 1st byte is used to specify the direction of data transfer (read or write) and the address of the chip, while the second byte is the data itself. Since we also need to receive output from the bus, reading is a 2-step process - first read from the chip into the bus, then from the bus into console.
I used the following to guess the address of the chip:
When we interact with the “F” endpoint, we get a random string of data that is probably the encrypted flag. Upon initialization, the chip randomly generates a seed key that is later processed and used to encrypt the flag. Based on this, we can determine the possible keys based on the flag format, and narrow down the possible keys after each encryption, until we have the final key which we can use to decrypt the flag.
I used this script to interact with the chip and read the flag:
I guess their Trusted Platform Modules were not so trusted afterall. What about blockchain? Blockchain is secure by design, right?
It seems like our enemies may have hidden some of their treasures somewhere along in our little island, all secured by this blockchain technology.
We have heard rumours that to access the treasure, you must navigate to the correct location and possess the correct value of the “number used only once”. This unique code is essential for unlocking the fortified gate guarding the treasure!
Ensure your wallet is sufficiently funded for travel and any potential challenges you may encounter. Your journey begins now. It’s your mission now - crack the code and see what treasures they are hiding!
This was strangely not an RE challenge but a blockchain/pwn challenge. The main 2 vulnerabilities are here:
We can do a reentrancy attack here. I used the following to withdraw all the funds:
Once we have sufficient funds, we can call the startUnlockingGate
function.
And here, there isn’t actually any function called “unlockgate()” in the contract (the contracts for treasureLocations were not released). Eventually, we need to deploy our own contract to implement this function.
But how do we deploy our own contract to the hardcoded addresses? When
generating an address for a contract deployment using the CREATE
opcode (not
CREATE2
), the EVM takes into account the deployer’s address and a nonce that
increments every time a contract is deployed. This address is thus deterministic
and can even be controlled partially by the attacker. We can write a script to
bruteforce a possible nonce value that will give us one of the contract’s
addresses:
From this, pulauSemakau’s address seems to be manually set by the challenge to be achievable within a small nonce (30-50). So we can repeatedly deploy contracts until we deploy a contract at the required nonce value.
What can we do with the unlockGate
call? Since it’s being called with
delegatecall
, the memory layout of the calling contract is inherited directly
by the callee. So, we can edit the isLocationOpen
to directly set
pulauSemakau
to true and get the flag.
Full exploit contracts:
You’ve come so far, brave agents! Let us continue our mission to identify our threats, and retrieve the crucial information that they are hiding from the world.
While scanning their network, your fellow agents chanced upon a tool used by the adversary that checks for the validity of a secret passphrase.
We know that they use this phrase for establishing communications between one another, but the one we have is way outdated… It’s time for an update.
This level had a bit a difficult RE but it was partly a blockchain chall too. Seems like PALINDROME really likes blockchain…
The web interface has SSTI that allows us to view the entire response data object from this function. Unfortunately we can’t use this for RCE because it limits our input length to 32, which is too short for an RCE payload.
setup_contract_bytecode
and adminpanel_contract_bytecode
are the partial
bytecodes of the respective contracts. I used https://ethervm.io/decompile to
decompile the bytecode into semi-readable Solidity code, but it wasn’t really
helpful. In the end, I painfully parsed the assembly step-by-step to figure
out the stack state and stuff.
After hours of reversing, my revelation was that in the assembly for
adminpanel
, we can see that it checks if the input is of length 16. If so, it
slices off the TISC prefix here:
and calls the secret contract to get the flag (I think, we can’t actually tell from the delegatecall).
Afterwards, it does some comparison stuff here:
I’m not sure why, but apparently matching each character spends a bit more gas. So based on the gas usage which we can see via the SSTI, we can sequentially determine each character of the flag. Each time the gas increases, we know that character is correct.
This is the payload I used:
{_________}{{response_data}}
TISC{g@s_Ga5_94S}
Breaking news! We’ve managed to seize an app from their device.
It seems to be an app that stores user data, but doesn’t seem to do much other than that… The other agent who recovered this said he heard them say something about parts of the app are only loaded during runtime, hiding crucial details.
It’s up to you now! Can you break through the walls and unveil the hidden secrets within this app?
Is PALINDROME secretly the Earth-Trisolaris Organization??
We’re provided with an Android app which seems to be a very useless input field
that does nothing but store the input in-memory. Opening the APK in JADX, we can
see a hidden activity in AndroidManifest.xml
(com.wall.facer.query
). We can
manually load this activity in Android Studio to reveal a activity that requests
for a IV and secret key, to decrypt the flag.
Also, we can see a few suspicious values in res/values/strings.xml
.
The base64 values are wallowinpain
, data/
and sqlite.db
respectively.
Unzipping the APK and going to the assets folder, we can see the sqlite.db
and
several strange files in data/
:
$ : ls
# name type size modified
─────────────────────────────────────────────────────────────
0 0$d4a1NDA5TkDcvPPA_97qGA file 1.4 KB 43 years ago
1 1$-jdd8_tomhupBCl9KWd8xA file 1.3 KB 43 years ago
2 2$lFLwXjQ9kfzjBqIAI43f-Q file 2.1 KB 43 years ago
3 3$JwwVFYd1_JvfrcL91sUOoQ file 1.2 KB 43 years ago
4 4$Xz61-8GuN_p5gECXlLwIyA file 1.8 KB 43 years ago
5 5$Je3mRGwJ1MvkQ-ZXfApZgQ file 1.9 KB 43 years ago
6 6$KrPqTP4Iu8-DNlpja70rcA file 1.2 KB 43 years ago
7 7$K30_BnqsT-e6-qRdbWhW4Q file 1.4 KB 43 years ago
8 8$svSIG6hueT4M509sCJTACQ file 709 B 43 years ago
sqlite.db
is not a valid SQLite database (though it has the SQLite 3 magic
bytes) and trying to open it gives an error. I spent a good amount of time
thinking the db was encrypted and tried to decrypt it to no avail.
After digging in JADX a bit, I found this really suspicious function that seems to be reading a file and decrypting it:
Most likely, this function is decrypting the mysterious sqlite.db
file. Using
Frida, I re-implemented the 2 sections in JS using the wrapper provided:
On logging the result, we can convert it from the byte array into a binary in Python and obtain a decrypted .dex file! (PALINDROME is really sneaky)
Opening the new .dex file in JADX, we see a few more clues:
However, when we enter in the 2 inputs, in the logs, we see that we need to
patch the binary to bypass certain checks. In Frida, we can hook onto
System.load
, just before the native library is deleted, so we can extract it
from the device’s filesystem and reverse/patch it.
The following part was really painful to reverse and patch. There are 3 checks that we need to bypass:
/sys/wall/facer
file needs
to exist. Unfortunately, we cannot create the file normally, but we can patch
the binary to change it to check for some other file, like /etc/passwd
.After all the patches were done, we can technically hook onto System.load
and
replace the library in the filesystem with the patched one. However, I couldn’t
get it to work properly, so I patched the loaded library in-memory instead.
After that, we pass all the checks, it spits out the key and IV, and we can put it in the app to get the flag:
TISC{1_4m_y0ur_w4llbr34k3r_!i#Leb}
Almost there agent, we might have a chance to gain access into the enemy’s systems again!! We are so close.
But, it seems like they’ve developed a robust anti-malware service that’s thwarting all attempts to breach their systems!
We’ve found this import hashing plugin which is a key component of their malware analysis pipeline. Agent, can you find a way around it?
In this challenge, PALINDROME is using a radare2 plugin to output a hash of all
the imports of a binary (this is a common malware analysis technique). It takes
in a Portable Executable (PE) file and parses the imports to generate a hash.
This site was really useful to
learn about how the PE format encodes the import tables. Basically, there are 3
tables involved - a table of library entries, a table of library names, and a
table of function entries. The library entry table is the main table that is
parsed by radare2 to determine the PE’s imports. This table has several fields,
including OriginalFirstThunk
and Name
. OriginalFirstThunk
points to the
start of a null-terminated table of function entries, where each entry is a
pointer to the name of the function (there’s another value before the name of
the function that isn’t important for our exploit). The Name
field simply
points to the address of the library name. Note, however, that these addresses
are RVA addresses - their physical addresses which we can see in a hex editor
are not the same as the value we should be putting in, but offset by a certain
amount. I compiled a test PE file with a few imports to determine the fixed
offset, and from there added the offset to each of my desired entries.
(I attempted to use the python lief
library to generate a PE file with
arbitrary imports, but it wasn’t working very well. There was also a C# library
that could do the same but I was too lazy to setup a C# environment just for the
challenge. In the end, I manually crafted my exploit using HxD, so I won’t have
an exploit script for this challenge, just an overview of what I did during the
CTF.)
Taking a look in IDA, we can see how this plugin works - it appends all the imports and library names into a really long string, then hashes the entire string to get a unique hash for the set of imported functions. However, we can see that the index which keeps track of the end of the string during the appending process might possibly overflow here:
Even though the remaining space left on the string is checked against the total
length of the library name and function name, the actual number of characters
appended is 2 more than the value specified, one for the dot and one for the
comma. So, if the total length of index + library_name + function_name
is less
than 4094, but the actual total length is 4096, then we can overflow the index
and do a array out of bounds. With this out of bounds, we can immediately modify
name_len
and libname_without_extension_len
, which are used to change the
value of idx
. Hence, we can almost arbitrarily control idx
now.
The maximum lengths of the function name and library name are both 255 bytes, since radare2 truncates the remaining string. We can easily circumvent this by doing manual calculations on how many such entries we need to approach the overflow value, and once we’re near enough, using a smaller entry to get the index to point outside the array.
In the appending code above, we can see that libname gets appended first before function name is appended. So, we can use libname to OOB into name_len, and then subsequently, when the program reads name_len, we can use name to control the value of idx to point to wherever we want.
After controlling idx, where should we point it to? If we scroll up to the beginning of the function, we can see this happening:
So the command that generates the import hash is actually a radare2 command that is hardcoded into the binary, but placed on the stack.
During exploitation I faced an issue with encoding (0xff would be encoded to 0xc0ff in UTF-8), so my index ended up pointing somewhere else. I verified this by launching radare in GDB so I could see the exact value of the index after the overwrite.
I was too lazy to fix this properly, so after overwriting index, I just added
enough entries to increment index back to the desired value. Even though it
probably overwrote something important higher up in the stack it doesn’t matter
once we manage to replace echo
with our desired flag command:
echo ; cp /app/flag.txt out;
The whitespace is so our hash doesn’t interfere with the payload. After parsing this file, remote returns us the flag:
TISC{pwn1n6_w17h_w1nd0w5_p3}
!!! We’ve found a weird device with a timer counting down! Ccould..it… be…a bomb…?? Your fellow agents found some access into the engineer’s machine, will you be able to find some clues and diffuse it before it’s too late?
For details on your instance, talk to @DiffuseInstanceBot on Telegram.
Note: Instances may be refreshed periodically. Remember to save your work outside of the instance!
This challenge was the most painful and guessy challenge in the CTF (yes, even more guessy than the Level 01 OSINT). I don’t think I enjoyed this challenge very much at all, especially given the last part is quite intense RE which I already dislike.
We start off in a Windows SSH machine as the diffuser
user, with a lot of red
herrings left around in the desktop. There’s also a diffuse
user which we
don’t have access to. After trying some commands, we might realize that there’s
a web server running on the machine on port 80. We can curl this server and
observe that it seems to be running XAMPP v8.1.25, which a quick Google search
will show that it’s vulnerable to
https://github.com/AlperenY-cs/CVE-2024-4577. We can trigger this RCE exploit
in Poowershell as such:
The XAMPP user seems to have all admin privileges, so we can change the password
of the diffuse
user and SSH as that user instead. In this new desktop, we find
a few interesting files, particularly the firmware for a certain bomb device.
Reversing this firmware was really quite a pain. It uses the XTENSA architecture, which IDA doesn’t have decompilation support for, and Ghidra’s decomp for it was quite trash. In fact, for this challenge, I found dynamic reversing a lot more useful than static, but we’ll visit that later. First, we need to figure out how to actually run the firmware.
In the user’s desktop, there are also some photos of the bomb device, including the Arduino board used to run the firmware. We can see from the board that the processor is atmega328p, and we can download an IDA configuration for this processor type from here.
Annoyingly, the schematic for the bomb was hidden in another folder in
AppData/Roaming, and I only found the schematic.pdf
file after looking through
almost literally everything, and stumbling on the backup history for Notepad++,
which the challenge author probably used to setup the original instance. There I
saw the setup.ps1
file which points me to the schematic.pdf
file.
Because I didn’t have the schematic initially, I tried using a variety of simulation software, such as simavr and AVR Simulator, all to no avail. Why? Because my assumptions about how the bomb worked, based on the fake image of the bomb provided in the folder, were wrong. I even tried to replicate the wiring of the bomb using eye power and the image, but nothing was showing up on the simulated LCD. Frustrated, I decided to take a break and wait for Gerrard to solve the challenge first, while I went to do my ever-growing pile of long-overdue schoolwork.
After Gerrard solved this challenge, I came back to take another look at the
windows VM provided, and finally found the schematic.pdf
file. This schematic
was completely different from the provided image:
Not only that, the PDF had a hidden page - on the second page was the secret key which we were meant to embed in the chip. The only hint towards this was the “Page 1 of 2” on the bottom of the PDF - such a small hint for a crucial detail.
(After solving the challenge, I realized that the A0/A1 pins also have a small “rng” label on it - apparently this was supposed to inform participants that we were supposed to connect a component here to control a certain RNG value, which we’ll look into later.)
Enough ranting - at least this meant I was actually making some progress. As the schematic components were labelled according to their IDs in the Wokwi simulation software, it was really useful for solving the last part of this challenge.
After setting up the wiring of the device as shown in the schematic, it was finally time to run the bomb firmware. On uploading the firmware and running it, we see some output from the LCD screen. However, entering the bomb code which we found in the firmware - 39AB41D072C - doesn’t defuse the bomb, but complains about a wrong decryption key. This is where the chip in the schematic comes in. Unfortunately, there was no indication as to what we were meant to do with the chip, other than that it should provide the decryption key. To make things worse, the Arduino would send a certain output to the chip to initialize the UART connection and poll to receive characters, which misled me into thinking we were supposed to do something with the received string. The most unlucky of all - the received string, when XORed against the entire decryption key found in the PDF, gives a string comprising of entirely printable characters, which made me think I was on the right track. Unfortunately, I was getting further from the solution.
At the same time, I started doing some dynamic analysis on the firmware. The XTENSA architecture was quite interesting - it had 32 8-bit registers, yet it operated on 16-bit values, so 1 value is usually represented by 2 registers. This is especially true for the X, Y, and Z registers, aliases for r27:r28, r29:r30, and r31:r32 respectively. In addition, the writable regions are stored completely separately from the code region (something called the Harvard architecture), so a memory address such as 0x37a can’t be properly resolved in IDA, since it also corresponds to a code address. GDB, which is based on von Neumann architectures, addresses this issue by offsetting the RAM region by a fixed offset 0x800000, and resolving the references to these addresses accordingly, so we need to add this value to the addresses we see in the disassembly if we wish to view it in GDB.
I soon figured that at some point between inputting the code and printing the error message, certain strings, including my decryption key, were being XORed against a single byte key. By setting this value to 0x00 and then checking the unchanged input strings, I found that the key 0xe8 would actually decrypt the last string to TISC{ - and then the decryption afterwards fails. I spent another painful day tracing this value all the way from its source - an instruction that reads a 16-bit value from the ADC register. This register, apparently, is controlled by the A0/A1 pins on the Arduino, The series of operations performed on the value obtained from this register is quite complex in assembly, but less so in Python:
This script will generate some possible values of the ADC register that will result in a decryption key of 0xe8. After running this script, we notice a few values like 1, 256 etc will work. Following this, I tried a few combinations of output from the chip with the secret key - sending back the string we received, XORing each respective byte of the received string and the secret key, and finally sending just the secret key - which worked perfectly. The LCD then tells us that the flag is found in the I2C bus. Looking at where I first saw the flag format, I realized the flag was no longer there. At this point, it was 3am and I was really tired of this challenge, so I just searched through memory for the flag format and found the flag at 0x80034d.
TISC{h3y_Lo0k_1_m4d3_My_0wn_h4rdw4r3_t0k3n!}
All thanks to you agent, we are now safe from that bomb threat, and we’re also in their systems now!
We’ve gained access to a server used to store notes by the enemy. It looks to be designed by a rookie, so it can’t be that hard to find an entry. Unfortunately, it seems that they are one step ahead of us, and deployed a cutting edge sandbox by Microsoft around the notes app. Can you break through it and give us more clues on who is behind all of these attacks?
This challenge was a really interesting sandbox escape challenge. We’re given quite a few binaries:
chall
, the parent which we interact with and is responsible for creating
the sandboxed processsandboxed-lib
, the user-defined library that defines functions for thelibrary_runner
, the binary that runs in sandboxed mode
parent process to call in sandbox contextlibsandbox.so
, the sandbox library code itselfThis is the main process that we interact with:
The functions called from sandbox
will run under the sandbox context, while
the others will run as per normal in the parent process. Let’s look at how the
allocator works in the sandbox.
The Verona sandbox, developed by Microsoft, of course would use an allocator
also developed by Microsoft. Under the hood it uses snmalloc
, which is a
relatively new allocator that promises high levels of security compared to
glibc’s dlmalloc
(or ptmalloc2
). You can read their paper on the development
and main features of the allocator
here.
In another useful set of
documents, the
developers outline the security features of snmalloc
. I’ll give a brief
outline of the various hardening techniques.
Similar to kmalloc
, snmalloc
implements a large-chunk allocator, aka slab
allocator, in which chunks of the same size are allocated inside and maintained.
Large chunks can also be directly allocated by the slab allocator, unlike in
kmalloc
.
Metadata of slabs is stored in a separate region, while metadata of chunks is
stored on the chunk itself, like regular malloc
.
Reading the source for the sandbox, it seems that there are 3 different heap allocators in use.
We need to start from the bottom and work our way up, to exploit the regular malloc and get RCE.
Debugging this challenge was slightly difficult because of the forked process -
if we wish to debug the parent, we should attach at the very start using
gdb.debug
; otherwise, we should gdb.attach
after the desired child process
has been spawned. Every time we exit the inner loop, the child process will be
killed, so our debugging session won’t remain active. Therefore, we need to
attach at the right child process to prevent this from happening. Now, let’s
get to the exploit!
The first vulnerability is easy to spot - there is use after free on the deleted
notes since the array entry isn’t cleared. We can even edit the freed chunk from
edit_note
. In GDB, we can see that the chunks hold the metadata for the next
chunk to be loaded into the allocator. However, the allocator is LIFO, and so
overwriting the metadata of a currently freed chunk doesn’t do anything until
the allocator runs out of free chunks, and wraps around to our previously freed
chunks.
We can do a simple heap feng shui to align our heap such that new allocations of
content
chunks will fall onto the previously allocated and freed note
chunks. This way, we can arbitrarily control the note struct as shown:
Firstly, we should want to leak some heap values from the sandboxed process. We
can increase the size to view more cool stuff when calling printfn
.
Secondly, we want a libc leak. We can change the contents
pointer to point to
known regions such as the GOT, and read the addresses of the resolved functions
to leak libc.
Thirdly, we want a libsandboxed.so
leak as well, which we can leak from custom
overrides for functions like malloc
.
Fourthly, since we have libc leak, we can overwrite the entry of a more
insignificant function like malloc, and set it to call a function of our choice.
A good target to call is try_alloc
and try_dealloc
, which we can use to
pivot from from the small allocator to the large one.
In the large allocator, we see that metadata is now stored on the parent, so we
can’t edit it. However, with try_alloc
, we can allocate arbitrary of
large-size objects, which we may be able to exploit. These objects are passed
through a delete
somewhere in the code, and we can follow the patched code to
see the conditions for a page’s metadata be freed, following which we can pass
in arbitrary pointers to free. I’ll go into more detail about this soon.
After getting arbitrary free, I didn’t have a very good structure to UAF, so I just freed the feedback struct. On creating another sandbox, a lot of chunks will be allocated. Doing some heap feng shui, we can make sure that the object which allocates into our feedback chunk has a pointer that points to some region before our feedback chunk. That way, we can overwrite the pointer once to get 1 arbitrary write.
However, it turns out that the chunk which is allocated into the feedback chunk is some strange struct that has a pointer 0xa00 bytes before our feedback chunk. If we were to spam null bytes into the extremely large region between the two, the program would most definitely crash when trying to use the heap again later on by freeing stuff. So, we need to first leak the values, then replace all the values verbatim up to the feedback pointer, which we set to our arbitrary write location. In fact, even after doing this, I still struggled with exiting normally via the interface, because the heap was already corrupted when we created fake chunks for the heap feng shui to control what struct was allocated into our feedback chunk. We’ll deal with this later, since this means our final exploit needs to exit abnormally instead of through the provided interface.
In this case, I want to do a House of Apple, but I still lack a libc leak in the
parent. This means I actually need to overwrite the feedback pointer twice -
once to leak libc, and once to overwrite the stderr file struct (there aren’t
any unsorted chunks within the leaked region). Since our primitive right now
only allows for 1 arbitrary write, we need to be clever with this to work around
this limitation. In the end, I decided to set the feedback content pointer to
the write GOT entry so we can leak it, then overwrite it to exit
. Then, since
the GOT is above the global pointer to the feedback chunk, we can also overwrite
this, and fake another feedback chunk in the same region to initiate our second
arbitrary write. This time, we will overwrite stderr with the required House of
Apple values to get RCE and our flag!
Sorry if this writeup was a bit incoherent - I’m really sleepy right now and rushing out the writeup for the submission which I’m late for already, so it’s a complete mess. I’ll clean it up when I have the time to. Here’s my final exploit script:
All your investigations have led you to this place: a desolate hut, abandoned and hidden deep within the forest.
As you push open the creaking door, the musty air hints at recent activity. Scattered belongings and half-burnt logs in the fireplace suggest someone left in a hurry.
In the dim light, you spot a flickering screen on a dusty table — a computer, still running. On the display, a game title flashes ominously: “Revenge of the Dragon.”
We know this is it. If we solve this we solve it all — can you break in and find out who is behind these attacks?
During the CTF itself, I had a local exploit working - but I forgot I was running on nokaslr, so one of my leaks turned out to be insufficient for the full exploit. Unfortunately I ran out of time because my exploit takes way too long to run (~4 mins after optimizing everything) and accounting for server lag, I simply didn’t have enough time to test on remote. After my IB exams, I’ve updated my exploit to include the final leak required - here’s my writeup for the upsolve.
The challenge is a kernel module that hosts a “minecraft” game. It maintains the player’s information and inventory, info about mobs, and a feedback chunk which we can allocate and use. A client was also provided to interact with the module, although we won’t be using it because our exploit doesn’t require it.
Let’s dive deeper into some of the module’s inner workings.
Like most kernel modules, we can interact with it via ioctl(fd, FUNC_CODE, ...args)
. The corresponding function codes are defined here:
The module maintains mutexes to prevent race conditions. It has 4 mutexes defined:
Each of the mutexes should be locked when a function that modifies or accesses the corresponding struct is called. This prevents other functions that also require the mutex from running until the mutex is freed - thereby preventing race conditions.
The player can buy items from the shop using gold. There are 2 items available -
swords and health potions. When an item is bought, it first creates a global
reference to the item using inventoryHeader_t
(if there isn’t one existing
already), and subsequently increments the refCount
field for every new item
bought. It maintains a linked list among items that currently exist, which
forms the player’s inventory. This way, every time an item is added or removed,
or accessed in some way, the inventory will be iterated through to find the
item with the matching UUID first, then it is directly accessed and updated.
When refCount
reaches 0, there is an automatic garbage collection system that
runs on specific function calls, which will free the item. Since the garbage
collector doesn’t know which objects own a reference to the item, it necessarily
can’t null out existing references - and anyway when refCount
is 0, there
should not be any existing reference to the item. The collector,
init_garbage_collection
, is called in use_health_potion
and remove_item
.
The player is a global struct and it stores the amount of health and gold, as
well as a pointer to a currently equipped item (inventoryEntry_t->item
). The
equipped item’s attack
field can be read through QUERY_WEAPON_STATS
.
Pointers to mobs are held in the global mobs[3]
array. cur_mob
indexes
through mobs
, increasing every time we kill a mob. We can also decrement
cur_mob
after we kill the dragon through RESET
. A mob_t
contains various
fields, all of which can be read through QUERY_MOB_STATS
.
The player is able to submit “feedback” for the developers to see. The feedback
chunk is first allocated when the function is called for the first time, and
subsequently the existing chunk is updated with a bound based on the stored size
of the feedback stored within the chunk itself. The data in the feedback chunk
is stored directly after the feedback_header_t
.
Now, let’s start planning our exploit!
Race condition?? What about the mutexes?
Turns out, in some of the functions, the mutexes aren’t properly locked or
unlocked. In fact, many of the mutexes aren’t properly unlocked when the
function exits, which causes the module to be unable to obtain the mutex again
and hence stop functioning, but this isn’t very useful for us. What is useful is
that in use_health_potion
, when there are no health potions available,
INVENTORY_MUTEX
can be unlocked arbitrarily.
This allows us to exploit a race condition on functions using INVENTORY_MUTEX
. One such function is add_item
, which is called when buying items from the shop.
Recalling that refCount
is of type uint_8
, we can overflow it when we have a
race condition that leads to TOC-TOU bug. Additionally, the printk
function
helps us a little in exploiting the race condition because it’s known to be
quite slow, allowing us to slip in another thread while it’s waiting to print to
kernel logs.
We need to assign a few threads to 1 of 2 tasks - unlocking the mutex through use_health_potion
, and spamming swords through add_item
.
After experimenting a lot, I finally determined a good method to exploit the race condition.
refCount
slots - this ensures that we don’t make refCount > 0
again,
assuming the race condition is triggered only onceadd_sword
threadsrefCount
isn’t overflowed and reaches 255 again, rinse and repeatrefCount
is overflowed, stop all threads and double-check to make sure
no thread caused refCount
to increment againUsing global variables to control the threads, the below solution works quite reliably to trigger the race condition.
Now that refCount
is 0, we can trigger the free on our sword by calling the
automatic garbage collection in heal
.
Now, we can access the UAF object via player->equipped
. Recalling the shape
of weapon_t
:
Another object we can control that is also of size 0x20 is inventoryEntry_t
.
And fortunately, both of these objects are allocated without accounting, so they can end up in the same cache:
To get the kernel to allocate to our UAF object is as simple as spamming
inventoryEntry_t
until we’re lucky. At this point, sometimes the kernel just
refuses to allocate to my UAF object and I need to restart the exploit.
Now, because of the linked list system, inventoryEntry_t->list_head
will hold
a pointer back to inventory
, the LIST_HEAD
, so we can leak its address via
weapon_t->name
. weapon_t->attack
will contain a kheap address which is a
really large integer. We can use this to one-shot the dragon.
After killing the dragon, we are able to decrement cur_mob
, which is of type
int8_t
, and index behind the mobs
array.
Let’s take a look at the array to see what we can target.
gef> x/32gx 0xffffffffc032f8a0 - 0xa0
0xffffffffc032f800: 0x0000000d00000000 0xffffffffc032e054
0xffffffffc032f810: 0x0000000000000000 0x0000000000000000
0xffffffffc032f820: 0x0000000000000000 0x0000000000000000
0xffffffffc032f830: 0x0000000000000000 0xffffffffc032f838
0xffffffffc032f840: 0xffffffffc032f838 0xffffffffc032f848
0xffffffffc032f850: 0xffffffffc032f848 0xffffffffc032dc52
0xffffffffc032f860: 0x0000000000000002 0x0000000000000000
0xffffffffc032f870: 0x0000000000000000 0x0000000000000000
0xffffffffc032f880: 0x0000000100000001 0x0000000300000001
0xffffffffc032f890: 0xffff898182a89400 0x0000000000000000
0xffffffffc032f8a0: <mobs> 0xffffffffc032f200 0xffffffffc032f1c0
0xffffffffc032f8b0: 0xffffffffc032f180 0x0000000000000000
0xffffffffc032f8c0: 0x0000000000000000 0x0000000000000000
0xffffffffc032f8d0: 0x0000000000000000 0x0000000000000000
0xffffffffc032f8e0: 0x0000000000000000 0x0000000000000000
0xffffffffc032f8f0: 0x0000000000000000 0x0000000000000000
gef> x/5gx 0xffff898182a89400 // feedback
0xffff898182a89400: 0x0000000066647361 0x0000000000000000
0xffff898182a89410: 0x00000000000003e8 0x0000000000000000
0xffff898182a89420: 0x0000000000000000
gef> x/5gx 0xffffffffc032f200 // SLIME
0xffffffffc032f200: 0x000000454d494c53 0x0000000000000000
0xffffffffc032f210: 0x0000000000000005 0x0000000000000005
0xffffffffc032f220: 0x0000000000000001
As we can see, at mobs[-2]
we have a pointer to the feedback
chunk in kheap.
Comparing its layout with a SLIME mob, we see that mob_t->health
overlaps with
feedback_header_t->size
. Earlier, we used our sword to attack the mobs to
change their health. We can do the same with this fake feedback
mob, changing
size
to some arbitrarily large value and getting a heap overflow in
feedback
.
After using our negative OOB, we should reset cur_mob
to 0 so that we can
continue using it for more leaks. We can do this by underflowing cur_mob
to
0x7f
, which causes it to be reset to 0 when QUERY_MOB_STATS
is called.
(For now, let’s not worry about the size of feedback
- we’ll deal with it
later)
Depending on the size of the feedback created, it will be found in
kmalloc-cg-x
caches, since it’s allocated with GFP_KERNEL_ACCOUNT
.
kmem_cache: 0xffff898181043800
name: kmalloc-cg-1k
flags: 0x44000000 (__CMPXCHG_DOUBLE | SLAB_ACCOUNT)
object size: 0x400 (chunk size: 0x400)
offset (next pointer in chunk): 0x200
random (xor key): 0x9bf145033eec81b1 ^ byteswap(&chunk->next)
red_left_pad: 0x0
kmem_cache_cpu (cpu1): 0xffff8981beab6300
active page: 0xffffe6a3000aa200
virtual address: 0xffff898182a88000
num pages: 4
in-use: 1/16
frozen: 1
layout: 0x000 0xffff898182a88000 (next: 0xffff898182a89c00)
0x001 0xffff898182a88400 (next: 0xffff898182a8ac00)
0x002 0xffff898182a88800 (next: 0x0)
0x003 0xffff898182a88c00 (next: 0xffff898182a8a800)
0x004 0xffff898182a89000 (next: 0xffff898182a8a400)
0x005 0xffff898182a89400 (in-use) // feedback chunk!
0x006 0xffff898182a89800 (next: 0xffff898182a89000)
0x007 0xffff898182a89c00 (next: 0xffff898182a8b800)
...
We have almost full write on any objects allocated after our feedback chunk now.
2 commonly exploited structs that exist in kmalloc-cg-x
caches are msg_msg
and pipe_buf
. However, msg_msg
can only give us a very iffy arb write, while
pipe_buf
can give us RIP control directly, so I decided to use pipe_buf
.
During the CTF I considered using
seq_operations
, but I couldn’t pull off theret2ptregs
trick to finish my rop chain - check out flyyee’s writeup where he usedseq_ops
andret2ptregs
to solve it and get the $10k prize!
pipe_buf
has an ops
field that we can overwrite using the heap overflow. We
will use ops->release
to plant our gadget. So, what we need now is a known
memory region in which we can write a gadget address to. Then we can write the
address of the region to ops
, where addr+0x8
will contain our gadget which
will be called when the pipe_buf
is closed.
Here we have a small problem though. We don’t actually have that many leaks,
especially not a kheap leak in arguably the easiest region to write our gadget
to, kmalloc-cg-1k
. No other functions in the module allow us to write
arbitrary input to memory other than get_feedback
. In fact, the only other
write we have is using the ATTACK function to decrement pointers by a known
attack value - which can be feasible but not very ideal, since it would be very
slow. Anyway, in the end I used the second technique.
If we continue looking behind the mobs
array, we can find several pointers
that might be of use to us. Some of the pointers point back to the same region
above the array, which we can then exploit to do a “master/slave” arb write
technique - OOBing to the master, we control where the slave points to, then
OOBing to the slave, we write to the desired target address. Other pointers
point to known regions in memory which contain suitably large values in the
health
field, so we can use it to decrement repeatedly until it becomes our
desired gadget address - then use it as our fake vtable.
In the end, the second route was sufficient, but during the CTF, I forgot
that I was debugging on nokaslr, so looking through memory, I thought the fake
vtable address was a constant offset relative to inventoryAddr
. In fact, when
kaslr is on, the regions are shifted around and I required an additional leak to
resolve my vtable address. Unfortunately, I only realized this at 8:50pm, 10
minutes before the end of the competition, because my exploit was way too slow
to debug.
Anyway, the plan is to:
pipe_buf
until there is one right after the feedback chunkpipe_buf->ops
Let’s plan where to write our vtable to. At mobs[-43]
, we can see a pointer to
an object that has a leak in mob_t->health
. This leak is within the same
region as another pointer at mobs[-90]
, where this time, the mob_t->health
is pointing to a pretty large value that we can decrement to our gadget address.
This value can also leak our kbase at the same time. Perfect!
gef> x/10gx 0xffffffffc032f8a0 - 8*43 // leak
0xffffffffc032f748: 0xffffffffc032f750 0xffffffffc0330000
0xffffffffc032f758: 0x0000000000000014 0xffffffffc03301e0
0xffffffffc032f768: 0xffffffffc03302fa 0xffff8981829d9800
0xffffffffc032f778: 0xffff89818298e900 0xffff8981829a72c0
0xffffffffc032f788: 0x0000000000000000 0x0000000000000000
gef> x/5gx 0xffffffffc032f750
0xffffffffc032f750: 0xffffffffc0330000 0x0000000000000014
0xffffffffc032f760: 0xffffffffc03301e0 0xffffffffc03302fa
0xffffffffc032f770: 0xffff8981829d9800
gef> x/10gx 0xffffffffc032f8a0 - 8*90 // write
0xffffffffc032f5d0: 0xffffffffc032f540 0x0000000000000000
0xffffffffc032f5e0: 0x0000000000000000 0x0000000000000000
0xffffffffc032f5f0: 0xffff898182a5d800 0x0000000000000000
0xffffffffc032f600: 0xffff898182990320 0xffff89818299ea40
gef> x/5gx 0xffffffffc032f540
0xffffffffc032f540: 0x0000000000000000 0xffffffffa85a26e0
0xffffffffc032f550: 0xffffffffa85a26e0 0x000000006770726b
0xffffffffc032f560: 0x0000000000000000
Implementation:
Note the arguments passed to ops->release
:
rsi
contains the address of pipe_buf
, which we know is a constant offset
from the start of our feedback input. With the appropriate gadgets manipulating
rsi
, we can eventually migrate our stack onto pipe_buf
and construct our
final rop chain there. First, let’s see what we can do with the vtable RIP
control.
Our constraints with the very first gadget are quite tight. Because we’re using
attack
to decrement by 1 continuously until we reach the desired gadget
address, we ideally want to find a gadget as close to the value as possible, yet
still smaller than the value itself. This means we want a gadget with as large a
value as possible without exceeding the value that we’re decrementing. Searching
through the gadgets with ROPgadget, we can narrow down our possible choices to
these 3:
Gadgets B and C aren’t as convenient as A, as they don’t
push rsi
, so we need a 2nd and 3rd stage intermediate chain.
Gadget A was my initial go-to, because it conveniently pushes pipe_buf
to the
stack, and then jumps to pipe_buf - 0x7f
. This allows us to use a pop rsp
gadget later on to migrate the stack directly to pipe_buf
. However, because
the value is the smallest among the three, it will take too long to decrement to
this value. During the competition, when I had local solve working, I was using
this gadget and thought that remote was terminating my connection early because
the exploit was taking too long. It turned out not to be the case but whatever
LOL
Gadget B was slightly better but not by much. It calls rsi
directly, meaning
that we can place our 2nd stage chain directly at the start of pipe_buf
.
In the end, it was still too far away so I didn’t use it.
Gadget C was the one I ended up using. It jumps to pipe_buf + 0x66
, meaning
our 2nd stage chain should be placed there.
Even with gadget C, the decrementing process wasn’t exactly faster than a
speeding bullet. It takes about 4 minutes locally to decrement the pointer
0x9a04ac
times.
[*] current value at fake vtable is 0xffffffff93fa26e0
[*] desired gadget value is 0xffffffff93602234
[*] decrementing fake vtable 0x9a04ac times, will take a while...
Now that we have the appropriate vtable set up, we can construct our 2nd
stage ROP chain starting at pipe_buf+0x66
. Our goal is to somehow pop rsi
into rsp
so that we can perform the stack pivot. A gadget I found that allows
this is
This will push rsi
onto the real stack, and then jump to pipe_buf-0x7f
,
where we can continue our 3rd stage chain.
Now that pipe_buf
is at top of stack, we can simply place a pop rsp ; ret
gadget at pipe_buf-0x7f
to pop pipe_buf
into rsp
, successfully pivoting
our stack to pipe_buf
.
I promise this is the last stage..
Finally, we can place our ROP chain to pop a root shell, at the start of
pipe_buf
. The rest is standard kernel pwn stuff, barring a few extra gadgets
needed along to way to avoid overwriting or crashing at the vtable/2nd stage/3rd
stage gadgets.
Finally, closing the pipe_buf
will grant us root shell :))
Final exploit:
This challenge, yet again, was really fun and I learnt a lot from my second attempt at a kernel pwn challenge - and first one that I managed to solve locally during the competition duration. Hopefully this means I’ll actually solve my next one on remote…?
Thanks to the TISC organizers and challenge authors for their hard work and dedication - this year’s TISC was really enjoyable and interesting :)