From a5c25e4c796adf656909d30e2a38ee9562545740 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Wed, 19 Aug 2026 11:35:31 +0200 Subject: [PATCH] Protect the cached chunk list against corruption When a chunk becomes empty it is not systematically unmapped: zend_mm_delete_chunk() often keeps it in heap->cached_chunks so a later allocation can reuse it without going back to mmap(). This single-linked list works via chunk->next, stored in free chunks headers, still mapped and writable. This is the same shape as free list poisoning (25360ef24951f1c6b83f8bf85fbdcaff4a1a40e1), and the free lists are the only thing currently protected. An overflow reaching a cached chunk header lets an attacker pick the value that zend_mm_alloc_pages() will pop: chunk = heap->cached_chunks; heap->cached_chunks = chunk->next; /* fully attacker controlled */ The popped pointer is then handed to zend_mm_chunk_init(), which writes through it and links it into the live chunk list, so a single controlled qword in a cached header turns into an arbitrary write. Given that corrupting one allocator list pointer is basically the technique to exploit CVE-2024-2961 in PHP (https://blog.lexfo.fr/iconv-cve-2024-2961-p1.html and https://blog.lexfo.fr/iconv-cve-2024-2961-p2.html), leaving a second unprotected one next to it is not great. Give the cached list the same treatment as the small bins: xor the links with heap->shadow_key, and check that the decoded value is chunk aligned before dereferencing it. NULL terminates the list and is chunk aligned, so it needs no special case. All of this is on the chunk allocation and deletion paths, which are cold, so the cost does not matter. Something that bit me during the development is rekeying: zend_mm_shutdown() calls zend_mm_refresh_key() at the end of every request, but cached chunks deliberately outlive the request, so their links have to be re-encoded with the new key. Same thing in zend_mm_refresh_key_child() for the post-fork re-key. What made this a pity to find out was that the test suite does not cover the re-keying, because the CLI serves a single request per process. It only shows up over the built-in server, where omitting the re-encode aborts on the second request. Testing was done with GDB: force a chunk into the cache, overwrite its link with 0x4141414141414141, then force a pop. Before, the corrupted pointer was accepted silently and became heap->cached_chunks. After, it aborts with "zend_mm_heap corrupted". --- Zend/zend_alloc.c | 63 ++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 54 insertions(+), 9 deletions(-) diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index fc7bc1f4d9d4..c9afb4f05ebf 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -883,6 +883,33 @@ static zend_always_inline void zend_mm_chunk_init(zend_mm_heap *heap, zend_mm_ch chunk->map[0] = ZEND_MM_LRUN(ZEND_MM_FIRST_PAGE); } +/* Cached chunks are linked through their headers, which live in memory a heap + * overflow can reach. The links are therefore xored with the heap key, and the + * decoded value is checked for chunk alignment before being dereferenced. */ +static zend_always_inline zend_mm_chunk *zend_mm_encode_cached_chunk(const zend_mm_heap *heap, const zend_mm_chunk *chunk) +{ + return (zend_mm_chunk*)((uintptr_t)chunk ^ heap->shadow_key); +} + +static zend_always_inline zend_mm_chunk *zend_mm_decode_cached_chunk_key(uintptr_t key, const zend_mm_chunk *chunk) +{ + zend_mm_chunk *next = (zend_mm_chunk*)((uintptr_t)chunk ^ key); + + /* NULL terminates the list and is chunk aligned, so it needs no special case */ + ZEND_MM_CHECK(ZEND_MM_ALIGNED_OFFSET(next, ZEND_MM_CHUNK_SIZE) == 0, "zend_mm_heap corrupted"); + return next; +} + +static zend_always_inline void zend_mm_set_next_cached_chunk(zend_mm_heap *heap, zend_mm_chunk *chunk, const zend_mm_chunk *next) +{ + chunk->next = zend_mm_encode_cached_chunk(heap, next); +} + +static zend_always_inline zend_mm_chunk *zend_mm_get_next_cached_chunk(const zend_mm_heap *heap, const zend_mm_chunk *chunk) +{ + return zend_mm_decode_cached_chunk_key(heap->shadow_key, chunk->next); +} + /***********************/ /* Huge Runs (forward) */ /***********************/ @@ -1031,7 +1058,7 @@ static void *zend_mm_alloc_pages(zend_mm_heap *heap, uint32_t pages_count ZEND_F if (heap->cached_chunks) { heap->cached_chunks_count--; chunk = heap->cached_chunks; - heap->cached_chunks = chunk->next; + heap->cached_chunks = zend_mm_get_next_cached_chunk(heap, chunk); } else { #if ZEND_MM_LIMIT if (UNEXPECTED(ZEND_MM_CHUNK_SIZE > heap->limit - heap->real_size)) { @@ -1150,7 +1177,7 @@ static zend_always_inline void zend_mm_delete_chunk(zend_mm_heap *heap, zend_mm_ && heap->last_chunks_delete_count >= 4)) { /* delay deletion */ heap->cached_chunks_count++; - chunk->next = heap->cached_chunks; + zend_mm_set_next_cached_chunk(heap, chunk, heap->cached_chunks); heap->cached_chunks = chunk; } else { #if ZEND_MM_STAT || ZEND_MM_LIMIT @@ -1168,7 +1195,7 @@ static zend_always_inline void zend_mm_delete_chunk(zend_mm_heap *heap, zend_mm_ zend_mm_chunk_free(heap, chunk, ZEND_MM_CHUNK_SIZE); } else { //TODO: select the best chunk to delete??? - chunk->next = heap->cached_chunks->next; + zend_mm_set_next_cached_chunk(heap, chunk, zend_mm_get_next_cached_chunk(heap, heap->cached_chunks)); zend_mm_chunk_free(heap, heap->cached_chunks, ZEND_MM_CHUNK_SIZE); heap->cached_chunks = chunk; } @@ -2037,6 +2064,14 @@ ZEND_API void zend_mm_refresh_key_child(zend_mm_heap *heap) } } + /* Update cached chunk links with the new key */ + for (zend_mm_chunk *chunk = heap->cached_chunks; chunk != NULL; ) { + zend_mm_chunk *next = zend_mm_decode_cached_chunk_key(old_key, chunk->next); + + zend_mm_set_next_cached_chunk(heap, chunk, next); + chunk = next; + } + #if ZEND_DEBUG heap->pid = getpid(); #endif @@ -2489,7 +2524,7 @@ ZEND_API void zend_mm_shutdown(zend_mm_heap *heap, bool full, bool silent) p = heap->main_chunk->next; while (p != heap->main_chunk) { zend_mm_chunk *q = p->next; - p->next = heap->cached_chunks; + zend_mm_set_next_cached_chunk(heap, p, heap->cached_chunks); heap->cached_chunks = p; p = q; heap->chunks_count--; @@ -2500,7 +2535,7 @@ ZEND_API void zend_mm_shutdown(zend_mm_heap *heap, bool full, bool silent) /* free all cached chunks */ while (heap->cached_chunks) { p = heap->cached_chunks; - heap->cached_chunks = p->next; + heap->cached_chunks = zend_mm_get_next_cached_chunk(heap, p); zend_mm_chunk_free(heap, p, ZEND_MM_CHUNK_SIZE); } /* free the first chunk */ @@ -2511,16 +2546,16 @@ ZEND_API void zend_mm_shutdown(zend_mm_heap *heap, bool full, bool silent) while ((double)heap->cached_chunks_count + 0.9 > heap->avg_chunks_count && heap->cached_chunks) { p = heap->cached_chunks; - heap->cached_chunks = p->next; + heap->cached_chunks = zend_mm_get_next_cached_chunk(heap, p); zend_mm_chunk_free(heap, p, ZEND_MM_CHUNK_SIZE); heap->cached_chunks_count--; } /* clear cached chunks */ p = heap->cached_chunks; while (p != NULL) { - zend_mm_chunk *q = p->next; + zend_mm_chunk *q = zend_mm_get_next_cached_chunk(heap, p); memset(p, 0, sizeof(zend_mm_chunk)); - p->next = q; + zend_mm_set_next_cached_chunk(heap, p, q); p = q; } @@ -2557,7 +2592,17 @@ ZEND_API void zend_mm_shutdown(zend_mm_heap *heap, bool full, bool silent) && "heap was re-used without calling zend_mm_refresh_key_child() after a fork"); #endif + uintptr_t old_key = heap->shadow_key; + zend_mm_refresh_key(heap); + + /* Cached chunks outlive the request, so re-encode their links */ + for (p = heap->cached_chunks; p != NULL; ) { + zend_mm_chunk *q = zend_mm_decode_cached_chunk_key(old_key, p->next); + + zend_mm_set_next_cached_chunk(heap, p, q); + p = q; + } } } @@ -2904,7 +2949,7 @@ ZEND_API zend_result zend_set_memory_limit(size_t memory_limit) /* free some cached chunks to fit into new memory limit */ do { zend_mm_chunk *p = heap->cached_chunks; - heap->cached_chunks = p->next; + heap->cached_chunks = zend_mm_get_next_cached_chunk(heap, p); zend_mm_chunk_free(heap, p, ZEND_MM_CHUNK_SIZE); heap->cached_chunks_count--; heap->real_size -= ZEND_MM_CHUNK_SIZE;