BSides Canberra 2018 CTF Write-Up: Neo Boffy

This is a write-up of the Neo Boffy challenge from the BSides Canberra 2018 CTF. This challenge was worth 1000 points.

Being the hardest Linux binary level for the competition, the description was intentionally quite vague:

Prove you’re not a mere human: ssh://immortal.libctf.so

Solution

The first thing we did was run the binary to see what happens:

Output with no arguments

We then tried adding arguments in:

Output with arguments

We noticed that none of the user input was being outputted. We tried with long arguments, format strings, but nothing seemed to work.

We then went to gdb and tried to disassemble main:

Snipped of the disassembly of main

We noticed that the address of the function did not start with anything in particular. This was due to ASLR being enabled on the binary:

Because ASLR is enabled, debugging with gdb becomes harder (as addresses will be random every execution, so breakpoints will not be caught). A trick we learnt during the competition was that the gdb command info proc mapping could be run at the very start of the process creation, meaning we can grab the binary’s base address:

p = gdb.debug(["./neo_boffy"], ["info proc mapping"])
p.interactive()
Example of the output of info proc mapping

Since the functions within the binary were quite complex and long, we decided to put the binary into BinaryNinja, and initially used the Medium Level IL view to easily understand what was occurring in the functions.

Slightly labelled disassembly on main

From this disassembly of the main function, we can deduce the following:

  • If we provide any number of arguments, the address of the first provided argument is stored in item_list.
  • If an additional argument is not provided, a default string (a length of 0x77 + NULL terminator) is used instead.

start_polling is then called, so we move to disassemble that:

disassembly of start_polling

Though this looks a bit complex, we can deduce the following:

  • arg1 of start_polling is a pointer to the jobmgmt function, which is called twice throughout this function
  • 0x78 bytes are allocated on the stack, and cp holds a pointer to that data
  • var_18_1 is a loop counter, for which the loop appears to go 0x78 times

The code inside the loop body translates to the following C code (note the Low Level IL view was used to assist in translating due to some assumptions the Medium Level IL view made regarding variable setting):

char* item_list; // global variable
char* cp; // stored on stack

for (int var_18_1 = 0; var_18_1 <= 0x78; var_18_1++) {
   char currentChar = item_list[var_18_1];
   *(cp++) = currentChar;
}

This loop itself doesn’t look very special. We decided to go through the binary dynamically, inputting a 0x78 string and placing a breakpoint at the loop condition check.

Instead of having to step through the breakpoints 0x70+ times, we can set a conditional breakpoint. The loop counter is always moved into eax to be incremented, so we can wait until we get to a specific value (we’ll use 0x75) to then do our analysis.

The conditional breakpoint we will use is the following:

b *<addr>+0xab7 if $eax == 0x75

Another command we used for displaying the loop counter:

x $ebp-0x14

When we get to the 0x78th loop, and we run till the end, we notice something interesting. Our loop counter is back at 1! Through careful inspection of the loop again, we notice a slight error in the condition check:

var_18_1 < *=* 0x78

There is an off-by-one error! Instead of looping 0x78 times, it actually loops 0x79 times, due to the fact the counter starts at 0, and <= is used instead of <.

Exploiting this off-by-one vulnerability requires a bit of skill. If we use 0x7a characters, we notice that the loop will stop after the 0x79th iteration . This is because the next character in the inputted string is written to cp, which is incremented each loop. cp will point to 1 byte after the loop counter, so it will populate the 0xFF00 area. This will make the loop counter 0x78. How can we get around this?

Recall that C strings are stored with a NULL-terminator. When arguments are passed into main, C will split the strings by where the NULL-terminators are, and have argv point to the start of each string. When main gets the address of the first user supplied argument, this block of memory actually contains all of our supplied input.

If we want our loop counter to be valid, we have to provide a valid loop counter for the lowest byte, as well as the upper 3 bytes as NULLs. This will make the program loop like normal. But how do we send NULLs?

As stated above, C strings are stored with a NULL-terminator. So if an empty string is sent, it will be stored as just a NULL-terminator.

However, we have to account for the fact that changing the loop counter changes what values are written, as the loop counter is used as an index for item_list.

We now know roughly what we have to keep in mind when creating our payload. We now have to determine what we want to write, and where do we want to write it to.

Looking at the function list, we see that there is a read_flag function.

Function list

This function has an offset of 0x8d0. Note that due to ASLR, we need to determine a full address to write in order to execute the read_flag function.

Where do we want to write it. It just happens to be that there is a return address located straight after the loop counter variable. So if we can overwrite the 4 bytes after the loop counter dword, then the binary will jump to that address.

For the time being, we will just write 4 A‘s just to prove that this is the case.

Now to constructing our payload. Here are the facts/thing we need to keep in mind:

  • The payload is 0x79 bytes long
  • The last byte is what our loop counter will be set to. This value will be incremented by one.
  • Whatever the loop counter is set to is where in the payload it will get the next bytes from
  • The next 3 bytes must be NULL bytes, followed by the address we want to jump to.

We can construct the payload like so:

payload = ""
payload += "\x00" * 3
payload += p32(addr)
payload += "\x70"
payload = payload.rjust(0x79, "A")

The payload does the following:

  • The counter will be set to 0x70, and incremented to 0x71 
  • The counter will now look at the NULL’s (which are located at 0x71 from the start of the string) and write those
  • Then the address will be written, then jumped ot
  • The payload is padded to 0x79 bytes.

Using this payload will result in a SIGSEGV, with pc equal to 0x41414141.

We now replace addr with an address pointing to the read_flag function. However, since ASLR is enabled, what value to we provide?

Well, the key thing with ASLR is that not all bits of the address are random. We can run the program once, use the generated base + the read_flag offset, then brute force the ASLR on the server by running the binary multiple times until the flag is printed.

Final solution:

A big thank you to Elttam for organising the CTF challenges, and Ionize for all their support – particularly Ash for being a patient and supportive sounding board while working through this challenge at the competition.