algoatson.github.io ~/posts
segfault@algoatson.github.io
back to index

the poison null byte

2026-06-143 min readheap pwn glibc

A strcpy into a heap buffer writes a terminating \x00. If the buffer is exactly chunk-sized, that null lands one byte past the end — on the least-significant byte of the next chunk's size field. That single byte is enough. It's the off-by-one that turns a typo into overlapping chunks.

target./nullbyte
arch
amd64-64-little
libc
2.27
technique
off-by-one NULL overflow -> size/prev_size desync -> chunk overlap
classic killer
the 2.29 "corrupted size vs. prev_size" check

what one null byte does to a size field#

Sizes are 0x10-aligned, so a chunk like 0x511 (size 0x510, PREV_INUSE set). Write \x00 over its low byte and it becomes 0x500:

next chunk's size field — before the off-by-onehex
00000000  00 00 00 00 00 00 00 00  11 05 00 00 00 00 00 00  |................|
...and after one null byte spills into ithex
00000000  00 00 00 00 00 00 00 00  00 05 00 00 00 00 00 00  |................|

Two things changed at once: PREV_INUSE got cleared (now the allocator thinks the previous chunk is free), and the size shrank by 0x10. The allocator's mental map of the heap is now wrong by 16 bytes — and every overlap technique is just exploiting a map that disagrees with reality.

from desync to overlap#

The classic recipe ("shrink a free chunk") chains that desync into two live chunks sharing memory:

  1. Lay out three chunks — call them A, B, C — and free B so it sits in the unsorted bin with a real size of 0x510.
  2. Off-by-one out of A nulls B's size low byte: 0x511 → 0x500. The allocator now believes B is 0x500, though it physically spans 0x510.
  3. Forge a prev_size inside B (at offset 0x4f0) so that C's backward view points into the middle of B, not its true start.
  4. Allocate and free around the seam. When consolidation fires, glibc merges using the forged boundaries, and you end up with one allocation overlapping a still-live chunk.
the desync, in one linediff
- B.size == 0x510   and   C.prev_size == 0x510      # consistent: C knows where B ends
+ B.size == 0x500   and   C.prev_size == 0x510      # the lie: 16 bytes unaccounted for

the exact sizes are a puzzle, not a constant

Which sizes overlap cleanly depends on your libc and whether the tcache is in play — the 0x510/0x500 here is illustrative. Drive it in pwndbg with vis and watch the chunk boundaries; the moment two chunks' user data visibly overlaps, you've won. Don't transplant offsets between challenges.

why overlap is game over#

Once two chunks overlap, one of them controls the other's metadata while it's still live. That's the strongest position in heap exploitation: you can edit a free chunk's fd to poison the tcache, forge a size to reclaim libc pointers, or set up any later House. Overlap is rarely the exploit itself — it's the primitive that unlocks the real one.

where it leads

Overlap a 0x100 chunk over a freed tcache chunk, rewrite its fd to __free_hook, allocate twice, write system:

txt
segfault{0n3_byt3_t00_f4r}

modern status#

glibc 2.29 added the check that specifically kills the shrink:

glibc 2.29 — malloc.c, on consolidation/freediff
+  if (__glibc_unlikely (chunksize (p) != prev_size (next_chunk (p))))
+    malloc_printerr ("corrupted size vs. prev_size");

A chunk's size must agree with the prev_size its neighbour records — exactly the invariant the poison null byte breaks. So the textbook version is ≤ 2.28. The idea outlives the patch, though: the House of Einherjar and several modern off-by-null variants still start from "one byte clears PREV_INUSE." Next, the capstone — the House of Orange, which gets a free chunk and a shell without ever calling free.