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 thwarted 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.
Navigating the Digital Labyrinth (Level 01)
979 solves
Navigating the Digital Labyrinth
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!
Submit
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:
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.
“
Language, Labyrinth and (Graphics)Magick (Level 02)
450 solves
Language, Labyrinth and (Graphics)Magick
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?
Submit
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.
Digging Up History (Level 03)
341 solves
Digging Up History
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?
Submit
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:
The downloaded file contains the Base64-encoded flag.
AlligatorPay (Level 04)
304 solves
AlligatorPay
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.
Submit
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:
Starts with AGPAY and ends with ENDAGP
Has data corresponding to card_number + expiry_date + balance, encrypted
using AES-CBC, with a key and IV of our choice
Has a checksum of md5(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.
Hardware isn’t that Hard! (Level 05)
89 solves
Hardware isn't that Hard!
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
After their failure at implementing a secure credit transfer scheme for their
funds, you’d think PALINDROME has learnt from their mistakes and would stop
implementing their own systems… but apparently not - now they’re rolling their
own secure communications channel as well. 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:
RE Path - Noncevigator (Level 06)
27 solves
Noncevigator
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!
Finally, PALINDROME is outsourcing their technology to something well-known and
trusted - blockchain! Despite being on the RE path, 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:
RE Path - Baby Flagchecker (Level 07)
17 solves
Baby Flagchecker
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.
Submit
This level had a bit of difficult RE, but it was mostly 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:
Wallfacer (Level 08)
33 solves
Wallfacer
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/:
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:
Entering “I am a tomb” into the MainActivity input pwill generate a .so
native library and load it into the application. (“I am a tomb” - the scene
where Blue Space encounters the 4D spaceship about to be 3-dimensionalized)
“Only Advance” will trigger a function from the native library to generate
the IV and key for flag decryption. (“Only Advance” - Wade when sending
Tianming’s brain to the First Trisolaran Fleet)
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:
“I need a very specific file to be available”: /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.
I don’t even know what the next function checks, but we can patch the value
of RAX used in the jumptable so that we jump to the “Input verification
success” part instead of the “hahaha” part.
“Bet you can’t fix the correct constant”: I also don’t know what’s going on
here, but we can just patch the binary to set the RAX value to not jump to
the “I’m afraid I’m going to have to stop you” part
After all the patches were done, we can technically hook onto
System.load:{java} 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:
Imphash (Level 09)
17 solves
Imphash
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:
The whitespace is so our hash doesn’t interfere with the payload. After parsing
this file, remote returns us the flag:
Diffuse (Level 10)
14 solves
Diffuse
!!! 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!
Submit
We’ve gained access to PALINDROME’s bomb expert’s computer. Time is of the
essence - we need to defuse it quickly! PALINDROME too knows this, and decided
to place a million red herrings all around the system to delay us as long as
possible, and either keep us guessing forever or frustrate us enough so we give
up. This challenge was the most painful and guessy challenge in the CTF. I
didn’t enjoy this challenge very much at all, especially given the last part is
quite intense RE which I already dislike on its own - and not to mention the
first few parts are full of guessing and randomly stumbling upon crucial pieces
of the challenge well-hidden throughout the Windows computer.
We start off in a Windows SSH machine as diffuser , with a lot of strange
pictures left around in the desktop for no reason. 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 Powershell as such:
Once we have RCE, we can use it to run commands as the XAMPP user. 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 bad. 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 by simulating the device.
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 (and completely useless) image of the bomb provided in the folder, were
wrong. I even tried to replicate the wiring of the bomb using eye power on 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 (flyyee) 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 worlds apart from the provided image:
The LCD display was completely different - in the image, it was a Hitachi
HD44780 LCD, the most common LCD display on the market. However, the
schematic suggested the LCD used was wokwi-lcd1602-i2c - not only is the LCD
chip different (PCF8574T rather than HD44780), the protocol used is also
different (I2C instead of standard).
The image showed that the bomb had 2 input buttons - but in the schematic,
the bomb is connected to a keypad instead.
The image didn’t have the 7segment component, while the schematic had it.
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 using the
Wokwi VSCode extension, 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 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 architecture, 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.
Through some intense debugging, 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 to 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, though equally as
confusing and arbitrary:
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, so I connected a power source to the A0 pin
and set its value to 1.
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,
completely ignoring the bytes that the chip receives
which surprisingly worked, meaning the received bytes were just useless red
herrings as well. 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.
Sandboxed Notes App (Level 11)
3 solves
Sandboxed Notes App
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?
Finally we’re past that annoying guessy challenge. PALINDROME wants to reward us
for enduring that mess, and so, the next step to stop PALINDROME is a standard
note-storing app that of course is vulnerable to UAF. However, to spice things
up, PALINDROME is using a Microsoft-developed sandbox to keep us from escaping -
surely this is too secure for us to break through?
We’re given quite a few binaries:
chall, the parent which we interact with and is responsible for creating
the sandboxed process
sandboxed-lib, the user-defined library that defines functions for the
library_runner, the binary that runs in sandboxed mode
parent process to call in sandbox context
libsandbox.so, the sandbox library code itself
This 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. The way the context is
sandboxed is simple yet quite effective - by spawning a separate process for the
child sandbox, it effectively can’t affect the parent in any way. But that would
be quite useless, so a select few memory regions are shared between parent and
child so that they can still communicate. The regions have different permissions
for parent and child based on the needs of the sandbox library. Besides these
regions, all other memory regions are private to either parent or child.
By the way, to debug this challenge and run it locally was slightly difficult.
Because of the host of libraries required for the sandbox library, I was
performing local debugging at first without linking the libraries properly.
This led to a few discrepancies in leaks within the exploit, so for the final
exploit I had to debug directly in docker and tweak the leaked values
slightly. As of writing this writeup, I’ve migrated my entire setup into the
container - neovim, pwntools, pwndbg and zellij - and simply treated the
container as my host environment. I think this is the easiest way to go about
debugging the exploit without trying to link all the libraries properly.
Let’s look at how the allocator works in the sandbox.
snmalloc - the sandbox’s malloc
The Verona sandbox, being developed by Microsoft, of course would use an
allocator also developed by Microsoft. Under the hood it uses snmalloc, which
is a relatively modern allocator that promises high levels of security compared
to other more traditional mallocs. 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.
Separate slab and chunk allocators
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 accessible only by the parent,
while metadata of chunks is stored on the chunk itself, like regular malloc.
Small-chunk allocator
In the above example, there are 2 freed chunks of size 0x20, at 0x7f47c0010aa0
and 0x7f47c0010ac0. The second chunk has been freed before the first chunk. As
can be seen, the chunk that is freed earlier stores the fd pointer to the chunk
that is freed later. Logically, this implies that a global pointer is stored
somewhere pointing to the earlier chunk, and upon allocation is updated to point
to the later chunk. This means that the small chunk allocator in snmalloc is
actually FIFO. This is unlike regular malloc where the last chunk to be freed
is at the head of the linked list, and will be the first to be allocated.
What this means, for a generic UAF, is that overwriting the fd pointer won’t
immediately corrupt the linked list. Only after the program cycles through all
the previously-freed chunks and reaches the victim chunk, will the corrupted
pointer be read into the global pointer and become our arbitrary alloc.
Of course, Microsoft devs are smarter than that, and have already accounted for
this. I won’t dwelve more into their double linked-list
mitigations
since the challenge provides us a much easier way to exploit the UAF.
UAF in sandbox
Recall that the small-chunk allocator executes in the context of the sandboxed
library. Looking at the code, we can immediately spot the UAF here:
notes[idx] and notes[idx]->contents still hold references to the
freed chunks, which is our easy UAF. Recalling the FIFO behaviour of the
allocator, we will need to repeatedly fuzz (allocate) a large number of chunks
until we manage to overwrite a previously-freed chunk that we still have a
reference to.
Before we fuzz the chunks, we want to see what we wish to do with this
primitive. Below are the fields of a struct note (size 0x20):
Wait, if we allocate a contents chunk onto a previously freed struct note, we can actually overwrite a function pointer already - how
tantalizing! Besides that, we can also manipulate note->size, which will
allow us to get some necessary leaks. Lastly, we can also overwrite
*contents to get arbitrary read and write. This UAF primitive really
allows us to do anything within the sandbox!
In order to guarantee that a contents chunk falls into a struct note, we do a simple heap feng shui to offset struct note in the
0x20 slab by 1 allocation.
After fuzzing, we find that on the 255th allocation, the contents of
notes[3] will lie on the struct note chunk of notes[0].
We can then set the size to a really large value. We can see that this works in
gdb:
notes[0] was supposed to be of size 0x20, but has now been edited to
size 0x100 by editing notes[3].
Leaking the sandbox
Let’s see what values we can leak using this:
At 0x7efc40010ac8, we can leak the address of print_note and thus the
base address of sandboxedlib.so, and at 0x7efc40010ad0, we can leak the base
address of the 0x20 slab, acting as a heap leak (sorta).
Using the base address of sandboxedlib.so, we can then target its GOT to leak
more important values. Obviously, we can leak the sandboxed libc from here.
However, because of sandbox hardening, a lot of syscalls are disabled by
default. Popping a shell in sandbox context wouldn’t work since the syscall is
blacklisted too. The full list of seccomp filters can be found under
verona_sandbox/include/process_sandbox/platform/sandbox_seccomp-bqf.h. So
having arbitrary control over a function pointer in the sandboxed process isn’t
exactly the endgame.
Below is a snippet of sandboxedlib.so’s GOT. It’s a lot of values, but 2
values that stand out are free and malloc. Why are their values
not in libc region?
If we take a look, these addresses actually belong to library_runner - the
binary provided by Verona that acts as the child process to execute sandboxed
calls within the sandbox context. Not that leaking the libc is entirely useless,
but I chose to leak library_runner instead, because being part of Verona’s
codebase, it should have quite a few interesting and useful gadgets, for our ROP
chain later on, that regular libc wouldn’t have. We can leak it by simply
re-using our UAF over note[0], pointing *content to
malloc@got[plt] and reading the note.
ROP in sandbox
Actually, when making this writeup, I realized that an easier method would
have been to pivot our stack onto notes[0] by using the address of the
note already stored in rdi, and afterwards to just construct our ROP chain
on the note instead… anyway, I was lucky and managed to do the below too
If we take a closer look at the heap value we’ve leaked from our note earlier,
we can see that it’s found in this region:
This region looks way too huge to just contain a heap for snmalloc, doesn’t
it? If we look further down, we can actually see that there’s also a stack
that’s found at a fixed offset from our heap region. This stack is used by
library_runner (and strangely, the actual stack region remains completely
unused) - so from our heap leak, we can determine a location to write our ROP
chain to! From GDB, the below region seems like a good candidate, having a
function pointer that looks like it returned from view_note. We’ll be writing
our ROP chain here later.
But, we haven’t actually planned what to do with the ROP chain yet. Since we
can’t target anything in libc, let’s look at what we have in sandboxedlib.so.
These functions I overlooked initially because I thought it was a part of
snmalloc initialization. However, these functions actually aren’t used
anywhere in the library, nor in the parent process. Suspicious, isn’t it?
Searching through verona-sandbox’s repo, I found that the 2 highlighted lines
above were actually quite a common pattern within the repo. This led me to
believe that the implementation somehow interacts in a really obscure way with
the sandbox.
After reading a bit more, I found this description of PageMapUpdates:
So it appears to be a special device that, when written to, will make a request
to the parent for a “pagemap update”. Since we’re starting to deal with
behaviour defined by the sandbox code, we should probably look at the diff
applied to the Verona source code.
Diff analysis
Besides blacklisting some syscalls, the diff also deals with parts of the code
related to the page map. It mostly removes certain key checks that make our
exploit easier to implement.
As we can see in the diff above, the is_metaentry_valid check has been
removed. Below is the implementation for AllocChunk after the diff:
So when AllocChunk is called, the metaentry object is
initialized directly from rpc.args[1] without validation. This can be
quite useful for us, but let’s take a look at what metaentry does first.
After jumping through endless abstractions, we arrive at the (not technically
final) function handling the initialization of a metaentry. By supplying
arbitrary addresses for meta, we can initialize fake metaentry
objects at arbitrary addresses as well.
This is also in the context of AllocChunk. By right, when performing
AllocChunk, a flag is set to indicate that the PageMap is being used
by the sandbox.
As it turns out, the metaentry object is freed when the sandbox is freed
too. meta.get_slab_metadata() just returns the pointer to the supposed
metaentry of the large chunk.
As such, we now have an arbitrary free on the parent allocator! This is
extremely useful for us as now we’ve escaped the sandbox and introduced a
primitive in the parent.
Our goal, with the ROP chain, is to pass arbitrary pointers to try_alloc(size_t size, uintptr_t meta, uintptr_t ras). size can be set to a large value
like 0x4000, while meta is the address we want to free upon leaving the
sandbox. ras is set to 0x800 to bypass some checks when freeing.
In library_runner, we’ll be using these gadgets to control rdi, rsi and
rdx:
For the last gadget, because of the and operation, we need to set the
value of rbx to 0xffffffffffffffff so it doesn’t clobber rdx.
Our full ROP chain will be as follows:
The rbp_from_gdb value is just any valid stack value so that our program
doesn’t crash due to a non-writable address in rbp. In this case, since the
initial value in rbp before our ROP chain is executed can be calculated from
the sandbox heap address (heap + 0x7ef450), we can simply use that value.
For the final address to return to (the value we’re popping into rsp), we can
choose a location on the stack that is close to returning to the parent process.
That way, we don’t have to deal with any iffy stuff before the program returns
to the parent. Below is a good spot to return to:
Arbitrary free in parent
Now, with this ROP chain, we can prepare arbitrary addresses to be freed in the
parent’s malloc once the sandbox is deleted. Looking at the challenge source,
we have 1 chunk that we can control in the parent allocator - the feedback
chunk. It has the following struct:
*ptr and size coincide with fd and bk values of freed
chunks - this is wonderful for our exploit! To have a valid *ptr value, we
should want either a fastbin or unsorted chunk in feedback, since tcache
pointers will be mangled and not point to a valid address. So in our arbitrary
free, we want to free 7 chunks of size 0x1f0, then the feedback chunk, so that
we get a nice heap address in our ptr field. Then hopefully we can do
something useful with it afterwards.
However, at this point we don’t have our parent heap leak yet, so we can’t pass
feedback’s address into our arbitrary free ROP chain. Luckily, we can see in the
implementation for view_feedback that it prints out the entire
feedback chunk, which is not nulled out in get_feedback. So our heap
leak is as simple as freeing some chunks and making them consolidate into
unsorted bin, then allocating the feedback chunk and reading the fastbin heap
pointers.
To do this, at the start of my exploit, I allocate a bunch of chunks in sandbox
to trigger page allocation by the parent, then delete the sandbox to free all
the pages allocated for me. This is enough to get a bunch of fastbin chunks that
consolidate into an unsorted chunk by the time we allocate our feedback chunk.
Now, with our parent heap leak, we can proceed with the arbitrary free as
planned! We will fake our 0x1f0 chunks within feedback itself since we can
easily write to it and we know its address.
Exploiting the feedback struct
After freeing everything, this is the state of our feedback chunk:
And when we re-enter the sandbox, the consolidated unsorted chunk is
re-allocated into a 0x50 size chunk:
Now, we have a heap pointer that points to 0xa00 bytes before our feedback
chunk. This is great for us as it means we can now overwrite the ptr field to
achieve our arbitrary read/write. However, because we’re gonna overwrite a 0xa00
region with null bytes, it’s gonna mess up the heap quite badly - this will
complicate things later on.
What should we write to then? Keep in mind that once we overwrite the ptr
field, our write region will basically be fixed, since we can’t overwrite it
again - essentially making this a one-time arbitrary read/write. However, we
still don’t have libc leak in the parent, and so we technically need 2 arbitrary
read/writes.
One way to solve this is to leak libc via unsorted bin addresses, such that we
don’t need to point our ptr for the arbitrary read. Technically, this is
feasible since the unsorted chunk is now below our feedback chunk (feedback
chunk was previously the start of unsorted chunk). However, because size is so
huge, it takes really long to finish printing everything - and anyway, this
would be a boring way to wrap up this super fun challenge, no?
Another way is to target the GOT. Since PIE is disabled and the pointer to
feedback chunk is a global variable, by pointing ptr to a GOT address, we can
perform our read for libc leak, while at the same time faking a new feedback
struct to point the global feedback variable to for our second arbitrary
write.
Attacking the GOT
With this plan, we’re very close to popping our shell. It would be really easy
if we could just use a one gadget to overwrite our GOT values with. However,
none of the one gadgets were viable, and the challenge doesn’t have any
functions that we can overwrite with system to pass /bin/sh as the first
parameter. Unfortunately, this means we need to do a House of Apple and then try
to exit. However, because we already messed up the heap as mentioned earlier,
when we try to exit within the sandbox context, a bunch of frees are called
which detect the messed-up heap and cause the program to crash. Hence, we can’t
exit normally through the provided interface. This complicates our exploit a
little, but not much - here’s our plan:
Point ptr to the GOT function we intend to leak and subsequently overwrite
with exit - this function should be rarely called, or easy for us to call
arbitrarily, so that we can control when exit is called.
Replace the rest of the GOT so we don’t mess up anything important.
At the global feedback variable, point it to an empty region where we’ll
fake our feedback struct at.
In the fake feedback struct, set ptr to _IO_2_1_stderr_ which we’re gonna
attack.
Send all the above as our first payload, and in the second payload, perform
House of Apple.
With this, upon executing the second payload, we just need to trigger our fake
exit function to get our shell! I chose write as the target GOT function
since it’s only called in view_feedback and nowhere else.
Final exploit
This challenge was super fun and interesting - it didn’t use any fancy C++
tricks or structs; just a great deal of debugging and clever exploits because of
the complexity of the Verona sandbox and snmalloc. I really enjoyed the 2 days I
spent on this challenge, and even now while writing this writeup 4 months later -
lots of thanks to the author of this challenge!
Revenge of the Dragon (Level 12)
1 solves
Revenge of the Dragon
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?
Now that we have RCE in the system running the note-storing app, the last step
is to get root and stop PALINDROME once and for all. For a crime syndicate,
PALINDROME seems to really love Minecraft and has installed a Minecraft-esque
kernel driver on their system. The goal is to exploit this driver to get root.
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.
Inner workings
Interaction
Like most kernel modules, we can interact with it via ioctl(fd, FUNC_CODE, ...args). The corresponding function codes are defined here:
Mutexes
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.
Inventory system
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.
Player
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.
Mobs
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.
Feedback
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 to integer overflow
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.
Reach the limit of 255 swords with no race condition
Remove a few swords to give enough buffer for the threads to sync up, but not
enough such that the race condition occurs too early
Mine just enough gold, such that we have one more gold than available
refCount slots - this ensures that we don’t make refCount > 0 again,
assuming the race condition is triggered only once
Start the add_sword threads
If refCount isn’t overflowed and reaches 255 again, rinse and repeat
If refCount is overflowed, stop all threads and double-check to make sure
no thread caused refCount to increment again
Using global variables to control the threads, the below solution works quite
reliably to trigger the race condition.
Exploiting the UAF
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.
Negative OOB
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.
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)
Exploiting the heap overflow
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.
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 the ret2ptregs trick to finish my rop chain - check out flyyee’s
writeup
where he used seq_ops and ret2ptregs 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:
Spray pipe_buf until there is one right after the feedback chunk
Leak vtable address
Overwrite the adjacent pipe_buf->ops
Write the rest of the rop chain using feedback as well
Creating the fake vtable
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!
Implementation:
Planning the rop chain
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.
ROP chain, stage 1
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.
ROP chain, stage 2
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.
ROP chain, stage 3
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.
ROP chain, stage 4
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.
root!!
Finally, closing the pipe_buf will grant us root shell :))
Final exploit:
This challenge too was really fun and I learnt a lot from my second attempt at a
kernel pwn challenge - and my 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 :) Looking
forward to next year’s as well!