When debugging complex heap vulnerabilities, it helps to first develop exploits using a debug-enabled libc version that supports source-level debugging. Once the exploit works, you can then adjust the offsets to target the specific libc provided with the challenge.
Unlink Exploitation
Consider a scenario where allocating chunks also stores pointers to the allocated memory regions. To exploit this through the unlink mechanism, we need to forge a fake chunk within an existing chunk (chunk1).
To bypass the double-linked list integrity check:
if (__builtin_expect(FD->bk != P || BK->fd != P, 0))
malloc_printerr(check_action, "corrupted double-linked list", P, AV);
We must satisfy the following conditions:
fakeFD->bk == P1translates to*(&fakeFD + 0x18) == P1which means*fakeFD == &P1 - 0x18fakeBK->fd == P1translates to*(&fakeBK + 0x10) == P1which means*fakeBK == &P1 - 0x10
To bypass the size consistency check:
if (__builtin_expect(chunksize(P) != prev_size(next_chunk(P)), 0))
malloc_printerr("corrupted size vs. prev_size");
We must set chunk2's prev_size to match the fake chunk's size field.
To bypass the nextsize corruption check:
if (!in_smallbin_range(chunksize_nomask(P)) &&
__builtin_expect(P->fd_nextsize != NULL, 0)) {
if (__builtin_expect(P->fd_nextsize->bk_nextsize != P, 0) ||
__builtin_expect(P->bk_nextsize->fd_nextsize != P, 0))
malloc_printerr(check_action, "corrupted double-linked list (not small)", P, AV);
The fake chunk size must fall within the small bin range.
To enable consolidation with the fake chunk, chunk2's PREV_INUSE bit must be cleared (set to 0), and chunk2's size must fall outside the fast bin range.
When chunk2 is freed, backward consolidation with the fake chunk triggers the unlink operation. This results in:
FD->bk = BKgivingP1 = &P1 - 0x10BK->fd = FDgivingP1 = &P1 - 0x18
At this point, we gain control over the entire pointer array, enabling arbitrary read/write capabilities.
Fastbin Attacks
Fastbin Double Free
When a chunk is freed initially, attempting to free it again triggers the double free detection:
if (__builtin_expect(old == p, 0)) {
errstr = "double free or corruption (fasttop)";
goto errout;
}
The check only verifies whether the first chunk in the linked list matches the one being freed. By freeing chunk2 before freeing chunk1 again, we can circumvent this protection.
After this, malloc allocations returning chunk1 are equivalent to exploiting a use-after-free vulnerability. Modifying chunk1's fd pointer to point to a specific address allows us to allocate chunks at controlled locations. However, a size field validation must pass:
if (__builtin_expect(fastbin_index(chunksize(victim)) != idx, 0)) {
errstr = "malloc(): memory corruption (fast)";
errout:
malloc_printerr(check_action, errstr, chunk2mem(victim));
return NULL;
}
The allocated memory location must have a valid size value matching the fastbin's requirements.
House of Spirit
As illustrated, this technique involves forging a fastbin chunk at a target location, then freeing it to allocate from that specific addres.
Creating a forgeable fastbin chunk that survives the free operation requires bypassing several checks:
- The forge chunk's ISMMAP bit must be 0. Mmaped chunks are handled separately during free:
if (chunk_is_mmapped(p)) {
if (!mp_.no_dyn_threshold && p->size > mp_.mmap_threshold && p->size <= DEFAULT_MMAP_THRESHOLD_MAX) {
mp_.mmap_threshold = chunksize(p);
mp_.trim_threshold = 2 * mp_.mmap_threshold;
LIBC_PROBE(memory_mallopt_free_dyn_thresholds, 2,
mp_.mmap_threshold, mp_.trim_threshold);
}
munmap_chunk(p);
return;
}
ar_ptr = arena_for_chunk(p);
_int_free(ar_ptr, p, 0);
- The forged chunk's address must be aligned to MALLOC_ALIGN_MASK:
#define MINSIZE \
(unsigned long) (((MIN_CHUNK_SIZE + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK))
#define aligned_OK(m) (((unsigned long) (m) &MALLOC_ALIGN_MASK) == 0)
if (__glibc_unlikely(size < MINSIZE || !aligned_OK(size))) {
errstr = "free(): invalid size";
goto errout;
}
- The forged chunk's size must fall within the fastbin range:
#define get_max_fast() global_max_fast
if ((unsigned long) (size) <= (unsigned long) (get_max_fast())
- The next chunk's size must be at least 2 * SIZE_SZ and not exceed av->system_mem:
if (__builtin_expect(chunk_at_offset(p, size)->size <= 2 * SIZE_SZ, 0) ||
__builtin_expect(chunksize(chunk_at_offset(p, size)) >= av->system_mem, 0)) {
if (have_lock || ({
assert(locked == 0);
mutex_lock(&av->mutex);
locked = 1;
chunk_at_offset(p, size)->size <= 2 * SIZE_SZ || chunksize(chunk_at_offset(p, size)) >= av->system_mem;
})) {
errstr = "free(): invalid next size (fast)";
goto errout;
}
if (!have_lock) {
(void) mutex_unlock(&av->mutex);
locked = 0;
}
}
- The forged chunk cannot already be at the fastbin head, avoiding double free detection:
if (__builtin_expect(old == p, 0)) {
errstr = "double free or corruption (fasttop)";
goto errout;
}
The pwndbg command try_free can verify whether a chunk can be successfully freed.
Practical Example: lctf2016_pwn200
This challenge has no protection enabled, allowing direct deployment of shellcode on the stack.
The main function structure:
__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
sub_40079D(a1, a2, a3);
sub_400A8E();
return 0LL;
}
The sub_400A8E function performs input handling:
int sub_400A8E()
{
__int64 idx; // [rsp+10h] [rbp-40h]
char buffer[48]; // [rsp+20h] [rbp-30h] BYREF
puts("who are u?");
for ( idx = 0LL; idx <= 47; ++idx )
{
read(0, &buffer[idx], 1uLL);
if ( buffer[idx] == '\n' )
{
buffer[idx] = 0;
break;
}
}
printf("%s, welcome to ISCC~ \n", buffer);
puts("give me your id ~~?");
get_num();
return sub_40029();
}
The buffer has an off-by-one vulnerability—inputting 48 bytes overflows and leaks the stack frame pointer (rbp). The 48-byte buffer can accommodate pwntools-generated shellcode. The ID value gets written to the stack and serves as the next chunk's size field.
The sub_40029 function handles heap allocation:
int sub_40029()
{
char input[56]; // [rsp+0h] [rbp-40h] BYREF
char *target; // [rsp+38h] [rbp-8h]
target = (char *)malloc(0x40uLL);
puts("give me money~");
read(0, input, 64uLL);
strcpy(target, input);
ptr = target;
return sub_400C4();
}
The input buffer has a overflow vulnerability, allowing modification of the target pointer to point to the forged chunk's memory region. The input itself can construct the forged chunk header. The sub_400C4 function provides a menu loop:
int sub_400C4()
{
int choice; // eax
while ( 1 )
{
while ( 1 )
{
menu();
choice = get_num();
if ( choice != 2 )
break;
delete();
}
if ( choice == 3 )
break;
if ( choice == 1 )
alloc();
else
puts("invalid choice");
}
return puts("good bye~");
}
The delete function can free the forged chunk, triggering the House of Spirit vulnerability. The alloc function can reallocate the freed forged chunk to overwrite sub_40029's return address with the shellcode address.
Exploitation Steps:
- Fill name with shellcode and use the off-by-one leak to obtain rbp. Ensure the shellcode contains no null bytes, as they would truncate and prevent leaking rbp.
- Set id to the next chunk's size value (0x41 works).
- In the money buffer, construct the forged chunk header and modify dest to point to the forged chunk's memory region.
- Free the ptr pointer, triggering the House of Spirit vulnerability.
- Allocate the forged chunk and overwrite sub_40029's return address with the shellcode address (rbp-0x50).
- Trigger the shellcode to gain execution.
Alloc to Stack and Arbitrary Allocation
By hijacking a chunk's fd pointer in the fastbin linked list to point to our desired allocation address, we can control critical data like return addresses.
The memory at the fd address must have a size field matching the fastbin's expected size:
#define SIZE_BITS (PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
/* Get size, ignoring use bits */
#define chunksize(p) ((p)->size & ~(SIZE_BITS))
/* offset 2 to use otherwise unindexable first 2 bins */
#define fastbin_index(sz) \
((((unsigned int) (sz)) >> (SIZE_SZ == 8 ? 4 : 3)) - 2)
idx = fastbin_index(nb);
...
/* Get size, ignoring use bits */
#define chunksize(p) ((p)->size & ~(SIZE_BITS))
if (__builtin_expect(fastbin_index(chunksize(victim)) != idx, 0)) {
errstr = "malloc(): memory corruption (fast)";
errout:
malloc_printerr(check_action, errstr, chunk2mem(victim), av);
return NULL;
}
Since the size check ignores the lowest 3 bits and most libc/stack addresses start with 0x7f, we can use the 0x70 fastbin to allocate memory with addresses having 0x7f prefixes. For example, modify the fd pointer to point to an offset before __realloc_hook (typically __malloc_hook - 0x23). Two allocations of size 0x60 will expose a fake chunk at that address, enabling control over __realloc_hook and __malloc_hook.
One-gadgets often fail due to stack frame constraints. To address this, set __malloc_hook to realloc plus an offset and __realloc_hook to the one-gadget. This rearranges the stack to satisfy the gadget's requirements. Alternatively, triggering malloc errors through malloc calls can also reshape the stack layout.
Unsorted Bin Attacks
Unsorted Bin Information Leak
Since unsorted bins use doubly-linked lists, at least one node's fd pointer will point inside the main_arena structure. By leaking this fd pointer, we obtain an address with a fixed offset from main_arena, which is a global malloc_state structure that manages the main arena. As a global variable, main_arena resides in .data or .bss segments. With access to the libc binary, we can calculate the offset between main_arena and the libc base address.
The offset between main_arena and __malloc_hook is 0x10, and __malloc_hook addresses are well-documented for most libc versions, simplifying the processs. For example, using pwntools:
main_arena_offset = ELF("libc.so.6").symbols["__malloc_hook"] + 0x10
This provides the main_arena offset relative to the base address.
Unsorted Bin Write Primitive
When removing an unsorted bin chunk, the operation writes the current unsorted bin position to bck->fd:
/* remove from unsorted list */
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);
By controlling bk, we can write the unsorted bin's address to an arbitrary location. A common application writes a large value to global_max_fast, expanding the fast bin range or causing fastbinsY array overflow for arbitrary write capabilities.
After an unsorted bin attack, the forged chunk is linked into the unsorted bin. To allocate it back, the following checks must pass:
- The size field must be valid:
if (__builtin_expect (chunksize_nomask (victim) <= 2 * SIZE_SZ, 0)
|| __builtin_expect (chunksize_nomask (victim)
> av->system_mem, 0))
malloc_printerr ("malloc(): memory corruption");
- The bk field of the unsorted bin chunk must point to a writable address:
/* remove from unsorted list */
unsorted_chunks (av)->bk = bck;
bck->fd = unsorted_chunks (av);
Subsequent sections cover House of Storm exploitation, which builds upon unsorted bin attacks using large bin attacks to forge both the size and bk fields of a forged chunk, enabling its allocation. However, starting from glibc-2.28, an additional check renders this technique ineffective:
/* remove from unsorted list */
if (__glibc_unlikely (bck->fd != victim))
malloc_printerr ("malloc(): corrupted unsorted chunks 3");
Large Bin Attacks (House of Fun)
Large bin attacks manipulate pointers within chunks already in large bins. By modifying bk and bk_nextsize pointers and triggering chunk insertion operations, we achieve arbitrary write of a heap adress to a target location.
Legacy Large Bin Attack
Before glibc-2.30, the chunk insertion process into large bins lacked validation for bk and bk_nextsize pointers. This allowed two arbitrary writes through modifying these pointers.
When the incoming chunk is not smaller than existing large bin chunks:
if (in_smallbin_range (size))
{
...
}
else
{
victim_index = largebin_index (size);
bck = bin_at (av, victim_index);
fwd = bck->fd;
/* maintain large bins in sorted order */
if (fwd != bck)
{
size |= PREV_INUSE;
/* if smaller than smallest, bypass loop below */
assert (chunk_main_arena (bck->bk));
if ((unsigned long) (size)
< (unsigned long) chunksize_nomask (bck->bk))
{
...
}
else
{
assert (chunk_main_arena (fwd));
while ((unsigned long) size < chunksize_nomask (fwd))
{
fwd = fwd->fd_nextsize;
assert (chunk_main_arena (fwd));
}
if ((unsigned long) size
== (unsigned long) chunksize_nomask (fwd))
fwd = fwd->fd;
else
{
victim->fd_nextsize = fwd;
victim->bk_nextsize = fwd->bk_nextsize;
fwd->bk_nextsize = victim;
victim->bk_nextsize->fd_nextsize = victim;
}
bck = fwd->bk;
}
}
else
victim->fd_nextsize = victim->bk_nextsize = victim;
}
mark_bin (av, victim_index);
victim->bk = bck;
victim->fd = fwd;
fwd->bk = victim;
bck->fd = victim;
By hijacking bk and bk_nextsize of the forwardmost chunk in the large bin (for chunks of the same size in the bk direction) and then freeing a slightly larger chunk, the attack achieves the desired effect.
Starting with glibc-2.30, a check validates the bk pointer when the inserted chunk is not the smallest:
if (bck->fd != fwd)
malloc_printerr ("malloc(): largebin double linked list corrupted (bk)");
Modern Large Bin Attack
When the incoming chunk is smaller than existing large bin chunks:
bck = bin_at (av, victim_index);
fwd = bck->fd;
if (fwd != bck)
{
...
if ((unsigned long) (size)
< (unsigned long) chunksize_nomask (bck->bk))
{
fwd = bck;
bck = bck->bk;
victim->fd_nextsize = fwd->fd;
victim->bk_nextsize = fwd->fd->bk_nextsize;
fwd->fd->bk_nextsize = victim->bk_nextsize->fd_nextsize = victim;
}
...
}
Bypassing checks by making the new large bin chunk smaller than the smallest existing chunk requires forging the size field of the chunk pointed to by bk. Since bk comes from the bins structure and is difficult to hijack, this approach typically isn't practical.
assert ((bck->bk->size & NON_MAIN_ARENA) == 0);
if ((unsigned long) (size) < (unsigned long) (bck->bk->size))
Unlike bk, bk_nextsize derives from fwd(unsorted bin)->fd rather than the unsorted bin itself, making it hijackable. By pointing the smallest chunk in the large bin's bk_nextsize to &target - 0x20 and then inserting a smaller chunk, the target address gets written.
Proof of concept:
#include<stdio.h>
#include<stdlib.h>
#include<assert.h>
int main(){
size_t destination = 0;
size_t *a = malloc(0x428);
malloc(0x18);
size_t *b = malloc(0x418);
malloc(0x18);
free(a);
malloc(0x438);
free(b);
a[3] = (size_t)((&destination)-4);
size_t *result = malloc(0x438);
assert((size_t)(b-2) == destination);
return 0;
}
Beyond allocating larger chunks, smaller allocations can also trigger large bin attacks. Since the condition victim == av->last_remainder isn't satisfied (becoming last_remainder requires being within the small bin size range), chunks from the unsorted bin enter the large bin, triggering the attack.
if (in_smallbin_range (nb) &&
bck == unsorted_chunks (av) &&
victim == av->last_remainder &&
(unsigned long) (size) > (unsigned long) (nb + MINSIZE))
The program then searches the large bin in ascending size order for a suitable chunk. Since large bins access the smallest chunk through first(bin)->bk_nextsize, the search finds the most recently inserted chunk first if its size satisfies the requirement. The chunk is then unlinked, carved to the needed size, and the remainder placed in the unsorted bin. Consequently, the value written to the target is the address of the chunk whose bk_nextsize was previously modified.
bin = bin_at (av, idx);
/* skip scan if empty or largest chunk is too small */
if ((victim = first (bin)) != bin
&& (unsigned long) chunksize_nomask (victim)
>= (unsigned long) (nb))
{
victim = victim->bk_nextsize;
while (((unsigned long) (size = chunksize (victim)) <
(unsigned long) (nb)))
victim = victim->bk_nextsize;
...
unlink_chunk (av, victim);
... // carve chunk and place remainder in unsorted bin
}
Tcache Exploitation
Tcache operates similarly to fast bins but uses the next pointer to reference the next chunk's memory region with fewer validation checks.
Bypassing Tcache Protections
Methods to prevent chunks from entering tcache:
- Free chunks outside the tcache size range.
- Free 7 chunks of the same size to fill the corresponding bin.
- If free operations are limited, use tcache dup to perform 3 allocations, setting the counts field to -1 to bypass tcache.
- Control the tcache_perthread_struct to manipulate counts for bypass.
Tcache Poisoning
By overwriting tcache's next pointer, we can malloc to any address without forging any chunk structure.
House of Autm
This tcachebin technique modifies chunk presize/size through the following process:
- Allocate chunk A within fastbin size range.
- Free A eight consecutive times. The fd gets cleared to 0 and A enters the fastbin.
- Allocate a chunk and modify its fd to point to A - 0x10. The tcache count becomes 6.
- Allocate another chunk. A chunk from the fastbin gets linked into the tcachebin.
- The next allocation returns A-0x10, enabling modification of A's presize/size and other fields.
Starting with glibc-2.29, a tcache key field was added to detect double free. Glibc-2.30 modified the logic—previously checking entry[idx]!=NULL, now checking count[idx] > 0.
// glibc >= 2.30
void *
__libc_malloc (size_t bytes)
{
//......
MAYBE_INIT_TCACHE ();
DIAG_PUSH_NEEDS_COMMENT;
if (tc_idx < mp_.tcache_bins
&& tcache
&& tcache->counts[tc_idx] > 0)
{
return tcache_get (tc_idx);
}
}
// glibc < 2.30
void *
__libc_malloc (size_t bytes)
{
//......
MAYBE_INIT_TCACHE ();
DIAG_PUSH_NEEDS_COMMENT;
if (tc_idx < mp_.tcache_bins
&& tcache
&& tcache->entries[tc_idx] != NULL)
{
return tcache_get (tc_idx);
}
}
Tcache Double Free (tcache dup)
Freeing the same chunk twice, followed by malloc, creates a use-after-free condition, enabling tcache poisoning.
Tcache Perthread Corruption
Through tcache poisoning, allocating to tcache_perthread_struct provides control over the entire tcache subsystem.
House of IO
This attack targets the tcache_perthread_struct structure. The goal involves freeing it and reallocating it to gain control over the entire tcache allocation system.
Tcache House of Spirit
Forge a fake chunk, free it, then malloc it back to gain control over that memory region. The forged chunk only needs a size within the tcache range.
Tcache Extend
Modify a chunk's size field, then free and reallocate it to create heap overlap.
Tcache Key Protection
Since glibc-2.29, tcache introduced a key field located at the chunk's bk offset, storing the tcache structure's address. When free() detects chunk->bk == tcache, it traverses the corresponding tcache linked list to check if the chunk already exists there.
Recent versions of older glibc (like newer 2.27 releases) also incorporate this protection mechanism.
Leaking Heap Addresses
Since tcache uses the fd field for linking, we can leak heap addresses through the tcache key. Starting with glibc-2.34, tcache key changed from the tcache_pthread_struct address to a random value (tcache_key), eliminating the heap leak via key.
// glibc-2.33
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache;
e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
// glibc-2.34
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache_key;
e->next = PROTECT_PTR (&e->next, tcache->entries[tc_idx]);
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
Bypassing Tcache Key Protection
Various techniques exist to bypass tcache key protection before performing tcache double free:
- Clear tcache key: Use UAF methods to clear the tcache key recorded in the freed chunk.
- House of Kauri: Modify size so the same memory region enters different entries on subsequent frees.
- Tcache stash with fastbin double free: Fastbin lacks rigorous double free detection. After filling the corresponding tcache chain, perform the double free in the fastbin. The stash mechanism then moves fastbin chunks back to tcache, converting the fastbin double free into a tcache double free.
- House of Botcake: Free the same chunk to both tcache and unsorted bin. The chunk in the unsorted bin changes size through consolidation. This method allows the double free to be reused multiple times because chunks of different sizes control the same memory.
fastbin_reverse_into_tcache
Calloc retrieves memory from fastbins rather than tcache. After extracting chunks, fastbin entries move into the tcache through the stash mechanism. If we modify a fastbin chunk's fd pointer, a large value gets written at fd + 0x10.
With malloc, first exhaust tcache chunks before triggering the stash mechanism for the attack. To prevent the target's fd from pointing to an invalid address, reserve 6 additional chunks in the fastbin to fill the tcache.
tcache stash unlink
When extracting chunks from small bins, the operation validates that the chunk pointed to by bk has a valid fd:
idx = smallbin_index (nb);
bin = bin_at (av, idx);
if ((victim = last (bin)) != bin)
{
bck = victim->bk;
if (__glibc_unlikely (bck->fd != victim))
malloc_printerr ("malloc(): smallbin double linked list corrupted");
set_inuse_bit_at_offset (victim, nb);
bin->bk = bck;
bck->fd = bin;
...
}
However, the subsequent process of moving remaining small bin chunks into tcache until it's full performs no validation checks.
/* While bin not empty and tcache not full, copy chunks over. */
while (tcache->counts[tc_idx]