my heaven on earth

blog about pentesting / reverse engineering
106 visitors

Reversing a Broken Rust Binary

Errors inside of an dissassembler
Errors inside of an dissassembler
This post describes reverse engineering a Rust binary from the rev section of TBTL Capture The Flag 2024 (https://tbtl.ctfd.io/) . The challenge is named Safe Rust and provides a compiled Rust program.

The challenge description indicates that the program “isn’t functioning properly”, and indeed, when we run it, we get the following message:

thread 'main' has overflowed its stack
fatal runtime error: stack overflow

Interesting, let’s dig into the binary. The entry point, _start, has a typical libc entry which points to the main function. (Got to fix the damn image function, I'm going to be blogging like this for a little..)

It calls std::rt::lang_start_internal::h983d5a44e7093a3b with a reference to another function, along with the command line arguments provided and some other data. The name suggests this is an internal function provided by the Rust compiler, possibly to start the program.

The code for this function doesn’t reveal anything particularly interesting: It mostly just calls a lot of other standard library functions. However it does eventually call the function provided as the first argument. Thus we can conclude it is just boilerplate code to setup the Rust runtime and call the real main function, rust::main::h1eef4902b3075149.

Looks like it contains a lot of unused lines, possibly Binary Ninja’s HLIL has made some mistakes. We could examine the disassembly to investigate this, but there are other more interesting parts of the function.

It calls rust::generate_key::hb8d203faab29ab07 with 0xbabadeda, then performs a loop to fetch a quadword from the data section and XOR it with this key. It also updates the key via calling rust::generate_key::hb8d203faab29ab07 at the end of each iteration. Smells like decrypting the flag one character at a time.

The generate_key function is too long to show.

arg1 is the first parameter of the function (so 0xbabadeda in the first call). It performs an arithmetic operation on arg1 and stores it as r15_6, which then has a loop that allocates and drops some memory. At the end of the loop it updates r15_6 via another similar arithmetic expression. Finally it conditionally drops some more memory, then returns the generated key r15_6.

The memory allocation and dropping logic isn’t entirely clear, so I decided to debug the program to see what was going on. Although the program errors, luckily it does reach this function once before crashing. Specifically it crashes during the deallocation in the branch immediately before returning the key.

I decided to try and prevent the program from entering this branch.

The temp4 == 1 conditional jump is at address 0x87ea. The jne instruction will jump when the zero flag is not set, set to 0x8811 in this case, which then performs cleanup and returns the key. Otherwise it continues onto the deallocation part, which is where the error occurs.

Therefore we can breakpoint 0x87ea and ensure ZF is set to avoid entering the branch and hitting the error.
I did this and this function returned normally.
Funnily enough, when control was transferred back to rust::main::h1eef4902b3075149, it continued as normal and used the generated key to decrypt the value 0x54 from the data section.

This is the character code for T, which is what we’d expect the flag to start with. Doing the same for a few more iterations indicates that the program is now decrypting the flag character by character, without any errors.

I wrote a script using the GDB Python API to automate this process, and obtain the flag.

import gdb

flag_chars = []

def handle_stop(e):
if isinstance(e, gdb.BreakpointEvent):
# the breakpoint just before error branch is taken
if e.breakpoint.number == 1:
gdb.execute('set $eflags &= ~(1 << 6)')
gdb.execute('continue')
# the breakpoint after each flag char is decrypted
else:
char = gdb.parse_and_eval('$rax')
flag_chars.append(char)
gdb.execute('continue')


gdb.events.stop.connect(handle_stop)

base = 0x555555554000

breakpoint just before error occurs so we can patch ZF

gdb.Breakpoint(f'*{base + 0x87ea}')

breakpoint to where we can read flag chars out

gdb.Breakpoint(f'*{base + 0x890e}')

gdb.execute('run')

flag = ''.join([chr(x) for x in flag_chars])
print(flag)


It sets two breakpoints: The first one to ensure ZF is set to avoid hitting the error, and the second one to obtain each decrypted character. It takes a little while (probably because generating each key involves 999,999 loop iterations), but it eventually prints out the flag.

TBTL{Dr0p_m3_l1k3_ru5t_d035}

( I didn't feel like solving it statically, sorry )
What it looks like to decide to prevent the program from entering current branch
What it looks like to decide to prevent the program from entering current branch