once upon a realloc()03 Nov 2016
Welcome to the fourth episode of the ptmalloc fanzine, in which we explore the possibilities arising from corrupting a chunk that is subsequently passed into realloc.
We touch on the following subjects:
- creating overlapping chunks with realloc
- by corrupting the IS_MMAPPED bit of the old chunk and setting up its
prev_sizefields appropriately, it’s possible to force realloc to return the old chunk regardless of the requested new size, thus arranging for overlapping chunks.
- by growing the old chunk via corruption to encompass other chunks. Upon realloc, the old chunk will be extended into those chunks.
- by corrupting the IS_MMAPPED bit of the old chunk and setting up its
- a wild memcpy appears. An unsigned underflow can be triggered when calculating the size argument of the memcpy call which copies over the contents of the old chunk to the new location.
- abusing mremap. A theoretical overview of the implications of triggering mremap calls with near-arbitrary parameters as a result of a realloc call on a corrupted chunk.
As usual, glibc source links and platform-dependent information all pertain to Ubuntu 16.04 on x86-64, unless stated otherwise.
Overlapping chunks via the IS_MMAPPED path of __libc_realloc
Let’s take a look at the parts of
__libc_realloc concerning mmapped chunks:
The only integrity checks are for wrapping and alignment of the old chunk, then we enter the mmap path if the chunksize has the
IS_MMAPPED bit set. Linux supports
mremap_chunk (abbreviated code below) will be called and its result returned if successful. Otherwise, if the size of the old chunk is big enough, the old chunk is returned. If it is not, the alloc, copy, free part follows.
mremap_chunk only checks if the sum of
prev_size is page-aligned (see the first episode for more information on mmapped chunks). Also, if the aligned request size equals the size of the mmapped chunk, it’s returned without remapping.
Assuming a corruption of a chunk later passed into
realloc with the intent of growing it, there are two possible ways to force
realloc to return the old chunk unchanged and thus arranging for overlapping chunks:
- by corrupting the
prev_sizefields of the chunk so that they pass the wrapping and alignment checks but cause
sizealso needs to be grown to ensure that it will be perceived by
reallocas large enough to accommodate the requested size. There are many ways to achieve this, e.g. by setting
prev_sizeto a huge value, while also growing
size. The end result will be that after the failed remap attempt,
__libc_reallocwill return the original chunk because its corrupted
sizefield is larger than the requested size. The realloc_noop.c example shows this in action:
- another option is to set the
prev_sizefields so that they will satisfy the
size + offset == new_sizebranch in
mremap_chunk(while still passing the integrity checks of course). For this to work, we have to know the request size aligned up to the nearest page boundary, which seems reasonable. See realloc_noop_mremap_exact.c for an example.
A wild memcpy appears
What happens when remapping fails and oldsize is not sufficiently large to hold the requested bytes?
__libc_malloc is called to allocate a chunk of appropriate size, then the contents of the old chunk are copied over via
memcpy. To calculate the copy length,
2*SIZE_SZ is subtracted from the oldsize, meaning a value under 16 will cause an underflow. However, the sizes that can appear there by design from an attacker are limited: the
chunksize macro masks out the flag bits, which leaves us with 0 or 8 as possible corrupted sizes to trigger the underflow.
If we set the size to 0, the check for a wrapping chunk,
(uintptr_t) oldp > (uintptr_t) -oldsize, will fail, since
oldp will definitely be above -0. So we are left with 8 (10, to be precise, since IS_MMAPPED needs to be set). 8 will avoid returning early in the
if (oldsize - SIZE_SZ >= nb) branch and also trigger the underflow. Executing the wild_memcpy.c example:
Leveraging this for anything useful would be rather tricky, maybe in a multithreaded target, e.g. if the
__libc_malloc call returns an mmapped chunk which is below a thread stack. There is some history of exploits for similar primitives but I believe it would be more useful for an attacker to go the overlapping chunks way instead of this.
Overlapping chunks via _int_realloc
IS_MMAPPED bit isn’t set for the chunk, we enter the
_int_realloc function. It begins with the size and next-size checks known from free. Then the interesting parts follow:
- if the old chunk is large enough, use it and free the remainder via
- if the next chunk is the wilderness, expand into it and return.
- if the next chunk is free and its size combined with the old size fits the request, try to expand into it.
- otherwise do the allocate/copy/free dance.
The first two paths can both be used to create overlapping chunks. By corrupting the size of a chunk that is later passed into realloc, the same location will be returned for a request larger than the original size. Some things to consider:
- making top the next chunk is a bit friendlier, as there will be no call to
int_freeif the requested size is less than our fake size, top will simply be moved back
chunk_at_offset (oldp, nb).
- if the remainder is less than MINSIZE, free won’t be called. This may be helpful if we cannot grow the corrupted chunk to a valid chunk boundary, since as mentioned before,
_int_realloconly has the size and next-size checks, while free has considerably more.
The int_realloc_grow_into_top.c and int_realloc_encompass_valid_boundary.c show these techniques. The first produces the output below:
Looking at the code of
mremap_chunk above, the
mremap call promises a primitive similar to the
munmap one from the first episode. I’ll assume familiarity with that post and keep this short and theoretical.
mremap_chunk has the same check to verify that the sum
size is page-aligned, and the kernel ensures that the
old_address parameter of
mremap is page-aligned, so the restrictions appear the same at first.
prev_size field is used to calculate the target of the remapping, the old size, and the new size, while the
size field of the old chunk has to pass the alignment check in
libc_realloc. It seems that if we also control the request size of the realloc call, these obstacles can be avoided and most things can be reused from the first episode. Some random notes on mremap:
- if we make the
old_sizeparameter of mremap zero, it won’t unmap the area at
old_addressbut will do the remapping.
- remapping a file based executable mapping (e.g. the binary itself) with a larger
new_sizeand a zero
old_sizewill create an executable mapping of the binary, including its .data and other sections. This is of questionable usefulness, since it can be expected that after a realloc, the program will try to write to the returned chunk, which will cause a crash in this case.
Comments of any nature are welcome, hit me up on freenode or twitter.
Special thanks to gym again for the rigorous proofreading.