Commit c96585e7bff for php.net

commit c96585e7bff6b8819b1888b0482bbe5875c5a4f6
Author: Julien Voisin <jvoisin@users.noreply.github.com>
Date:   Fri Sep 4 14:17:26 2026 +0200

    Protect the cached chunk list against corruption (#23367)

    Empty chunks are not always unmapped. zend_mm_delete_chunk() may retain
    them in heap->cached_chunks so they can be reused without another mmap().
    The list is linked through chunk headers that stay mapped and writable, so
    an overwrite of a link controls the value that zend_mm_alloc_pages()
    removes from the cache and hands to zend_mm_chunk_init(), which writes
    through it and links it into the active chunk list.

    Protect the list with the same key material as the small allocation
    freelists. chunk->next keeps the plain pointer and the new
    chunk->next_shadow holds an encoded copy:

        next_shadow = BSWAPPTR(next) ^ heap->shadow_key ^ &chunk->next_shadow

    The byte swap makes a small overwrite corrupt the most significant bytes
    of the address, which is unlikely to yield another valid chunk. Mixing in
    the address of next_shadow prevents a valid (next, next_shadow) pair from
    being replayed into another chunk. The shadow is an integrity check, not a
    secret; the secret remains heap->shadow_key.

    Reading a cached link decodes the shadow, checks that the result is
    chunk-aligned and that it matches chunk->next, and only then dereferences
    it. The head of the list is stored in the heap rather than in a chunk
    header, so it gets an alignment check of its own when it is popped.

    Cached chunks outlive request resets and forks, so their shadows are
    recomputed by zend_mm_rekey_cached_chunks() whenever zend_mm_refresh_key()
    or zend_mm_refresh_key_child() changes the key. That walk validates every
    link against its old shadow, so corruption is detected rather than
    silently re-encoded.

    The next_shadow field is carved out of the chunk header's reserve field,
    so the header is still 64 bytes, and chunk->next remains the ordinary
    doubly-linked-list pointer while the chunk is active.

diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c
index 09b29182f9e..f6c0a1ad0e9 100644
--- a/Zend/zend_alloc.c
+++ b/Zend/zend_alloc.c
@@ -326,10 +326,11 @@ struct _zend_mm_chunk {
 	zend_mm_heap      *heap;
 	zend_mm_chunk     *next;
 	zend_mm_chunk     *prev;
+	zend_mm_chunk     *next_shadow;             /* shadow of "next" while the chunk is cached */
 	uint32_t           free_pages;				/* number of free pages */
 	uint32_t           free_tail;               /* number of free pages at the end of chunk */
 	uint32_t           num;
-	char               reserve[64 - (sizeof(void*) * 3 + sizeof(uint32_t) * 3)];
+	char               reserve[64 - (sizeof(void*) * 4 + sizeof(uint32_t) * 3)];
 	zend_mm_heap       heap_slot;               /* used only in main chunk */
 	zend_mm_page_map   free_map;                /* 512 bits or 64 bytes */
 	zend_mm_page_info  map[ZEND_MM_PAGES];      /* 2 KB = 512 * 4 */
@@ -883,6 +884,64 @@ 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, so the link is mirrored in an encoded shadow. The shadow
+ * is byte-swapped, so that small overwrites hit the most significant bytes of
+ * the address, XOR'ed with the heap key, and XOR'ed with its own address so
+ * that a valid (link, shadow) pair cannot be replayed into another chunk. */
+static zend_always_inline zend_mm_chunk *zend_mm_encode_cached_chunk(const zend_mm_heap *heap, const void *holder, const zend_mm_chunk *next)
+{
+#ifdef WORDS_BIGENDIAN
+	return (zend_mm_chunk*)((uintptr_t)next ^ heap->shadow_key ^ (uintptr_t)holder);
+#else
+	return (zend_mm_chunk*)(BSWAPPTR((uintptr_t)next) ^ heap->shadow_key ^ (uintptr_t)holder);
+#endif
+}
+
+static zend_always_inline zend_mm_chunk *zend_mm_decode_cached_chunk_key(uintptr_t key, const void *holder, const zend_mm_chunk *encoded)
+{
+#ifdef WORDS_BIGENDIAN
+	zend_mm_chunk *next = (zend_mm_chunk*)((uintptr_t)encoded ^ key ^ (uintptr_t)holder);
+#else
+	zend_mm_chunk *next = (zend_mm_chunk*)(BSWAPPTR((uintptr_t)encoded ^ key ^ (uintptr_t)holder));
+#endif
+
+	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, zend_mm_chunk *next)
+{
+	chunk->next = next;
+	chunk->next_shadow = zend_mm_encode_cached_chunk(heap, &chunk->next_shadow, next);
+}
+
+static zend_always_inline zend_mm_chunk *zend_mm_get_next_cached_chunk_key(uintptr_t key, const zend_mm_chunk *chunk)
+{
+	zend_mm_chunk *next = zend_mm_decode_cached_chunk_key(key, &chunk->next_shadow, chunk->next_shadow);
+
+	ZEND_MM_CHECK(chunk->next == next, "zend_mm_heap corrupted");
+	return 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_get_next_cached_chunk_key(heap->shadow_key, chunk);
+}
+
+/* Re-encode the cached links after the heap key changed. */
+static zend_always_inline void zend_mm_rekey_cached_chunks(zend_mm_heap *heap, uintptr_t old_key)
+{
+	zend_mm_chunk *chunk = heap->cached_chunks;
+
+	while (chunk != NULL) {
+		zend_mm_chunk *next = zend_mm_get_next_cached_chunk_key(old_key, chunk);
+
+		zend_mm_set_next_cached_chunk(heap, chunk, next);
+		chunk = next;
+	}
+}
+
 /***********************/
 /* Huge Runs (forward) */
 /***********************/
@@ -1031,7 +1090,9 @@ 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;
+				/* The list head lives in the heap, which is as reachable as the chunk headers. */
+				ZEND_MM_CHECK(ZEND_MM_ALIGNED_OFFSET(chunk, ZEND_MM_CHUNK_SIZE) == 0, "zend_mm_heap corrupted");
+				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 +1211,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 +1229,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;
 		}
@@ -2068,6 +2129,8 @@ ZEND_API void zend_mm_refresh_key_child(zend_mm_heap *heap)
 		}
 	}

+	zend_mm_rekey_cached_chunks(heap, old_key);
+
 #if ZEND_DEBUG
 	heap->pid = getpid();
 #endif
@@ -2521,7 +2584,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--;
@@ -2532,7 +2595,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 */
@@ -2543,16 +2606,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;
 		}

@@ -2589,7 +2652,12 @@ 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 */
+		zend_mm_rekey_cached_chunks(heap, old_key);
 	}
 }

@@ -2936,7 +3004,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;