once upon a realloc()
03 Nov 2016Welcome 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.
TLDR
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
size
andprev_size
fields 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
, so 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 size
and 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
size
andprev_size
fields of the chunk so that they pass the wrapping and alignment checks but causemremap
to fail.size
also needs to be grown to ensure that it will be perceived byrealloc
as large enough to accommodate the requested size. There are many ways to achieve this, e.g. by settingprev_size
to a huge value, while also growingsize
. The end result will be that after the failed remap attempt,__libc_realloc
will return the original chunk because its corruptedsize
field is larger than the requested size. The realloc_noop.c example shows this in action:
- another option is to set the
size
andprev_size
fields so that they will satisfy thesize + offset == new_size
branch inmremap_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
If the 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
_int_free
. - 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_free
if the requested size is less than our fake size, top will simply be moved backchunk_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_realloc
only 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:
Abusing mremap
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 prev_size
and 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.
However, in mremap_chunk
, the 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_size
parameter of mremap zero, it won’t unmap the area atold_address
but will do the remapping. - remapping a file based executable mapping (e.g. the binary itself) with a larger
new_size
and a zeroold_size
will 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.
Closing words
Comments of any nature are welcome, hit me up on freenode or twitter.
Special thanks to gym again for the rigorous proofreading.