This was a 315 point pwnable. You’re presented with an interface that gives you two “floppies” that have a description and hold some data. There’s very little functionality: you can read, write, and modify two different floppies.
================== 1. Choose floppy 2. Write 3. Read 4. Modify 5. Exit >
First, I ran checksec on the binary. It was as I expected - both NX and PIE were in use. NX would force me to reuse code in the binary to achieve remote code execution, and PIE means I would need some information leakage to de-randomize the process’ memory.
After doing some reversing, I found the floppy structure to be the following:
in_use needs to be true before we can use Read or Modify on it.
data is a pointer to the heap. I don’t think the
length field was ever used.
There were several bugs in the binary, but only one of them was actually useful.
The first bug is that the program would allow for potentially non-null terminated floppy descriptions. The floppy description field is 10 bytes long, and the program read in exactly 10 bytes. If your input was 10 characters, printing the floppy description would end up reading more characters, until the next null byte.
Unfortunately for us, this isn’t that useful because the compiler inserted 2 bytes of padding right after the
description field. Even if that wasn’t there, the only field that comes after it is the
length field. Because the length is the strlen() of the data field which is capped out at 512 bytes, it’s definitely going to contain null bytes - which means we can’t really read anything after that. The two padding bytes are uninitialized values from the stack, but due to the way the function is written, nothing else ever occupies that space, making it even less useful.
The next bug is in how the read and modify functions check if floppies are usable. All three functions, read/write/modify check to see if a floppy has been selected but read/modify require a floppy has been written to. However, it only checks that a floppy has been written to, which is not necessarily the one you’ve selected.
Once again however, the do_modify function internally does its own check and exits the program entirely if the current floppy hasn’t been written to.
do_read doesn’t do the check, but because the floppy is initialized to all null, nothing useful comes out:
FLOPPY2 DESCRIPTION: DATA: (null)
The next bug, and the only one we actually need is in
do_modify. Recall that the
description field is 10 bytes wide (12 if you count the padding). For some reason, this function accepts 37 bytes for a description, allowing us to overflow from the description into other fields. Normally, there’s nothing useful after the
description field worth overflowing into, but due to the fact the floppies are on the stack and the way the stack is arranged, there are some key things we can go after.
The stack is arranged like the following (the top is higher on the stack)
- return address
- floppy* current_floppy
- floppy floppy1
- floppy floppy2
- int floppy_num
Using the overflow from floppy1’s description field, we can actually end up overwriting most of the return address. However, that’s not exactly useful if we dont’ know where to return to. Things become a little more difficult because we don’t control the last byte of the return address either. Instead, what I did was overflow from floppy2’s
description field, into floppy1’s
data field. By controlling this pointer, we can use the
do_read function to read from any arbitrary address, and the
do_write to write to any arbitrary address.
Step 1. Derandomize the binary
Even with an arbitrary read and write, we stil need to know where the binary is located. I did this first by leaking the value of
current_floppy from the stack. This value contains the address of the floppy currently in use. Because both floppies are on the stack, this gives us a stack address.
I ended up leaking the stack address by using the overflow, instead of the arbitrary read (because we don’t have an arbitrary pointer to read from just yet).
By overflowing from the description over the length, we “bridge” the description string such that the next time we print the description, there are no null bytes in the length to stop the program to keep printing. So next time we print, we get the description, the length (kind of - we overwrote it), and the address of the current floppy. Cool! Now we have a stack address!
From this stack address, we can get an address from the binary. Because this function was called into with the x86
call instruction, there is a return address in the current stack frame. We can calculate an offset from the floppy stack address from before where we know for sure there will be a return address - an address into the binary. Typically, reading things from an offset on the stack is a little difficult to get right (especially on a remote system), but we should have an easier time because we’re only dealing with the main program’s stack frame.
Using the overflow from before, all we need to do is set the
data field of floppy1 to this stack address, and then read the floppy so we can get the address’ contents. We can also use the modify floppy functionality to write anything to this address. Armed with the ability to read anything from anywhere, we can take the stack address from earlier, determine where the return address is stored on the stack, and actually pull the value from the stack.
For example, one test showed me the return address was located at
0xf7724fc3. From using the debugger and reading the assembly, it was clear this was the return address for
0xfc3. By subtracting the smaller, relative address from the bigger absolute address, we get the program base address:
0xf7724000. By doing this programmatically, we can figure out where the binary is located in memory every time.
2. Locate libc
Now that we know where the program is located, we want to know where libc is located so we can call in some additional functionality - namely
system. I did this by leveraging the arbitrary read from before to read the
__cxa_finalize symbol from the GOT. It doesn’t really matter what we read from the GOT as long as it contains a libc address. For some reason, this binary had a really small GOT where none of the other entries were initialized. We also need a second libc address, so that not only can we locate the libc base address, but so we can identify the specific version of libc in use.
3. Leak the heap address
We don’t really need to do this, but having an address where we can control all the contents easily is great. I leaked the heap address by using the same overflow from earlier, and overflow just the
in_use member of the first floppy. This meant if I printed out the description of the second floppy, I would get the data length of the second floppy (overwritten by us), the
in_use member of the first floppy, and because the program is still printing since it hasn’t encountered any null bytes, the
data member (heap address) of the first floppy.
4. Redirect control flow
We have stack addresses, a heap address, the binary’s base address, and libc’s base address. Now we just need to piece it all together.
I used the program’s built in functionality to write a shell command to the heap address. Because I can reliably tell where this shell command is stored, I can later pass it on to
system as the first argument. Next, I used the arbitrary write to set up the argument on the stack. With the libc base address, I calculated the address of
system, and set that as the new return address; instead of returning to the program, we will be returning into libc with our shell command~!