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:

We then tried adding arguments in:

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
:

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()

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.

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:

Though this looks a bit complex, we can deduce the following:
- arg1 of
start_polling
is a pointer to thejobmgmt
function, which is called twice throughout this function 0x78
bytes are allocated on the stack, andcp
holds a pointer to that datavar_18_1
is a loop counter, for which the loop appears to go0x78
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 0x78
th 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 0x79
th 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.

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 to0x71
- 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.