Grey CTF Quals 2024 took place on 27 April. I played with slight_smile and got 3rd place locally, qualifying for the
finals! I solved all the pwn except for heapheapheap, which is too painpainpain to do :((
Scoreboard
Here are my writeups for some of the interesting pwn challenges.
voidset_locale(){ char input[0x20]; printf("Enter new locale: "); fgets(input, 0x20, stdin); char *result = setlocale(LC_TIME, input); if(result == NULL){ puts("Failed to set locale :("); puts("Run locale -a for a list of valid locales."); }else{ puts("Locale changed successfully!"); } }
intmain(){ int choice = 0;
setup();
strcpy(command, "ls");
while (1){ puts("Welcome to international time converter!"); puts("Menu:"); puts("1. Print time"); puts("2. Change language"); puts("3. Exit"); printf("> ");
In this challenge, we can provide arbitrary strftime format strings and also change the locale used to generate the
string. The difference between printf and strftime is that strftime only has 1 “argument” - the time. This makes
it much safer than printf vulnerability. However, in here, we have 2 buffers - the output and command buffer,
where the output buffer is placed before the command buffer in memory. The command buffer is passed to system when
we select option 3.
The limit passed to strftime is 0x30, which means that the output will be at most 0x30 bytes long. memcpy uses the
strlen of the output to copy n bytes to output, which is only 0x20 bytes long. We just need to use specifiers that
output more than 2 bytes (since each specifier is 2 bytes) in order to increase the length of the specifier and overflow
into command.
The goal is to find a datetime string from a certain locale with sh in it, so we can overflow into the command
variable with sh and get RCE. Obviously I’m not Duolingo and I don’t know every language, so I spun up the provided
container to get all the available locales on remote.
However, because we want the command to just be sh, the only one we can use is %b, as the newline will be replaced
by \x00. It’s not enough for the datetime to contain sh, it must end with sh. We need to overflow the buffer such that the T is not in command buffer.
Another way to solve this is to find separate datetime strings that end with h and s, and overflow them
individually, such that the following occurs:
round 1 (output is xxxxxh): xxxxxh\x00 round 2 (output is xxxxs): xxxxsh\x00
The reason why this works is actually slightly more complicated. At first glance, the output from strftime is always
truncated with a null byte, so it shouldn’t be possible to combine multiple strings to form our command. However,
because of uninitialized stack, this exploit is actually feasible. According to the strftime docs:
If the total number of resulting bytes including the terminating null byte is not more than maxsize, strftime() shall
return the number of bytes placed into the array pointed to by s, not including the terminating null byte. Otherwise,
0 shall be returned and the contents of the array are unspecified.
strftime doesn’t terminate the output string with a null byte! The stack must align nicely such that print_time is
always allocated the same stack frame. Since buf isn’t initialized, the old output from strftime will still be
there, and the strlen will be new output + remaining old output. This entire string will then be copied into
output and subsequently overflow into command with the “sh” string we want.
During the CTF, I just used the below exploit which only needs 1 round of input, but it only works in April. I’m too
lazy to find an exploit that works in May for multiple-round inputs :)
# the solution only works in april locale = b"xh_ZA.utf8" p.sendline(b"2") p.sendline(locale) p.sendline(b"1") payload = b"".join([ b"%Y"*7, b"%;", b"%%", b"%b" ])
iflen(payload) > 32: log.info(b"bad") exit()
p.sendline(payload)
p.interactive()
Unfortunately, at the point of making this writeup, it is no longer April and my exploit doesn’t work anymore :(
(also the entire reason for moving grey is so that this chall would be solvable)
The main challenge for both parts is in this snippet:
voidlogin() { char attempt[0x30]; int count = 5;
for (int i = 0; i < 5; i++) { memset(attempt, 0, 0x30); printf("\e[1;91m%d TRIES LEFT.\n\e[0m", 5-i); printf("PIN: "); scanf("%s", attempt); if (!strcmp(attempt, pin)) { view_message(); } } slow_type("\n\e[1;33mAfter five unsuccessful attempts, the phone begins to emit an alarming heat, escalating to a point of no return. In a sudden burst of intensity, it explodes, sealing your fate.\e[0m\n\n"); }
Some context for The Motorola Part 1:
We are supposed to guess the secret PIN which is stored in the heap (our buffer overflow is on the stack,
so we can’t overwrite it). We have a scanf buffer overflow, and there’s a win function defined (view_message). We can
just ret2win here, no need to leak or overwrite the pin.
The difference is that Motorola 1 was compiled to x86_64, while Motorola 2 is compiled to wasm. Surprisingly (or
unsurprisingly), this changes a lot of things under the hood!
To run the binary in GDB, install wasmtime and run the following
command:
When a call is made from a parent function, the instruction is simply call $slow_type. This is a little similar to
call in Intel/AT&T syntax. The current instruction address is pushed onto the call stack, which is a separate stack
for instruction pointers to continue execution after call and call_indirect. This stack lives outside the runtime
VM, so it’s generally not possible to hijack it. The arguments to the function are pushed onto the top of the stack, and
these arguments become the locals array which can be accessed within the function (eg. local.get 14 will get the
15th value on the stack). When the function is finished, the return value(s) are pushed onto the stack and the parent
function continues execution based on the call stack.
Here’s a demo, breaking on the slow_type function:
Probably, the space is writable at some point during VM initialization, and the wasm is compiled to x86_64 “JIT”,
and written to this space.
Another thing - you might have noticed by now that wasm doesn’t really have a concept of registers. Rather (similar to
python bytecode!), it stores most values on the stack (in fact, its entire memory is just a huge array, a continuous
block of memory). And our “stack” (the entire linear memory region within the VM) is found in this memory region:
Anyway, the point of all this is to show that RIP control isn’t possible with regular call, because the call stack is
isolated far far away from the VM’s accessible memory.
What about call_indirect? It takes a function index from the stack, accesses the function table, and calls the
function with the corresponding index. The main purpose is to maintain compatibility with C native functions (eg.
fclose which reads the close function for the target file from the FILE struct, which can only be known at runtime).
Since the arguments must be passed in through the stack, and can be known at compile time, wasm is smart about this and
requires the function signature in call_indirect. For example:
... call_indirect(type0) ...
will crash if the function index at the top of stack references a function that isn’t of type 0. This greatly
restricts the functions we can jump to.
Unfortunately, we don’t have call_indirect in the login function. This is the disassembly for login:
So, RCE is out of the question. How else can we exploit the buffer overflow? We can’t overwrite the pin, because it’s
stored in the heap, far away from the stack…right?
Exploiting quirks of wasm sandboxing
Actually, since wasm was designed to be sandboxed, the linear memory region/array is kept as small as possible, without
the usual gaps between pages like the stack and heap that we see in regular x86_64 binaries. This means that the stack
and heap might actually be contiguous! Let’s check how the stack and heap are set up, by setting a known PIN and known
attempt, and searching for the 2 values in memory:
(I set the pin to TESTPINTESTPINTESTPINTESTPINTESTPIN)
As we realize, the saved pin value is 0x510 after our input buffer! The reason why the heap is placed after the stack is
because (I think) the stack is the first region to be defined, while the heap is only created after the first malloc
call. So, wasm simply defines regions sequentially in the memory space, hence putting it after the stack.
This way, the challenge becomes similar to a strcmp challenge - just spam null bytes between input and saved pin, and
we should be good to go, right?
● ctf/comp/2024-H0/greyctf/the-motorala-2 $ : python3 solve.py [+] Opening connection to challs.nusgreyhats.org on port 30212: Done [*] Switching to interactive mode After several intense attempts, you successfully breach the phone's defenses. Unlocking its secrets, you uncover a massive revelation that holds the power to reshape everything. The once-elusive truth is now in your hands, but little do you know, the plot deepens, and the journey through the clandestine hideout takes an unexpected turn, becoming even more complicated. \x1b[0m
[*] Got EOF while reading in interactive
We are in fact not good to go - it crashed without printing the flag :(
Fixing the heap
Let’s take a closer look at the region between input and PIN:
Most likely, we overwrote some important values here that should not be 0, hence crashing the program. These values are
probably the heap metadata used to maintain the heap, which explains why the program crashed when we tried to read the
flag file.
Luckily, wasm sandboxing once again works to our advantage. Since addresses are simply offsets from the start of the
virtualized memory region, there’s no PIE or ASLR involved, so we can just hardcode all the values - whether addresses
or chunk size values - without needing to leak anything! It took me a while to copy the values over into the exploit
script, but this is the final exploit:
$ : python3 solve.py [+] Opening connection to challs.nusgreyhats.org on port 30212: Done [*] Switching to interactive mode After several intense attempts, you successfully breach the phone's defenses. Unlocking its secrets, you uncover a massive revelation that holds the power to reshape everything. The once-elusive truth is now in your hands, but little do you know, the plot deepens, and the journey through the clandestine hie $out takes an unexpected turn, becoming even more complicated. \x1b[0m grey{s1mpl3_buff3r_0v3rfl0w_w4snt_1t?_r3m3mb3r_t0_r34d_th3_st0ryl1ne:)}
Very cool challenge that inspired me to learn more about wasm! :)