Commit 630406c8956 for php
commit 630406c89566c3ffd19623456b0516302c747a85
Author: Marc Bennewitz <marc@mabe.berlin>
Date: Wed Sep 30 06:48:31 2026 +0200
zend_alloc: move a small block shrunk to the size of the bin below
Reallocating a small block to exactly the size of the bin below kept it
in place, in its bin. efree_size() with that size then frees it into the
bin below: heap->size is decreased by too little, and the slot of a run
of the larger bin ends up on the free list of the smaller one. The next
zend_mm_gc() counts that slot for the smaller bin and stores the run in
the page map with the number of the smaller bin, so the run's remaining
blocks are then freed and its pages released with the wrong layout.
Truncate when the size fits the bin below, so the block lives in the bin
its size maps to. This costs no instruction; it only turns a realloc to
exactly the smaller bin size from in place into a copy.
The test uses a new zend_test function, zend_test_erealloc_block_size(),
to compare the block size after erealloc() with that of a fresh
allocation of the same size.
Closes GH-24014.
diff --git a/NEWS b/NEWS
index 4d15d50f643..f2c2a126aa6 100644
--- a/NEWS
+++ b/NEWS
@@ -7,6 +7,9 @@ PHP NEWS
in its previous chain. (Edmond)
. Fixed bug GH-23979 (Nullsafe operator must not flush delayed oplines of an
enclosing function). (ndossche)
+ . Fixed memory manager keeping a block reallocated to exactly the size of
+ the next smaller bin in its larger bin, which efree_size() then freed
+ into the wrong one. (Marc Bennewitz)
- DOM:
. Fixed bug GH-23352 (UAF reading an attribute value node retained across
diff --git a/Zend/tests/zend_alloc_realloc_shrink_boundary.phpt b/Zend/tests/zend_alloc_realloc_shrink_boundary.phpt
new file mode 100644
index 00000000000..0fae7c2acda
--- /dev/null
+++ b/Zend/tests/zend_alloc_realloc_shrink_boundary.phpt
@@ -0,0 +1,40 @@
+--TEST--
+erealloc() of a small block keeps it in the bin its new size maps to
+--EXTENSIONS--
+zend_test
+--SKIPIF--
+<?php
+if (getenv("USE_ZEND_ALLOC") === "0") die("skip requires zmm");
+?>
+--FILE--
+<?php
+
+// The largest request of each small bin, found from fresh allocations.
+$largest = [];
+for ($n = 1; $n <= 3072; $n++) {
+ [, $block] = zend_test_erealloc_block_size($n, $n);
+ if ($block > 3072) {
+ break;
+ }
+ $largest[$block] = $n;
+}
+var_dump(count($largest) > 20);
+
+// Shrink a block of each bin to every smaller size, down to the size of the
+// bin below, which efree_size() maps to that bin.
+$mismatches = [];
+foreach ($largest as $old_size) {
+ for ($new_size = 1; $new_size < $old_size; $new_size++) {
+ [$block, $fresh] = zend_test_erealloc_block_size($old_size, $new_size);
+ if ($block !== $fresh) {
+ $mismatches[] = "$old_size -> $new_size: block of $block, expected $fresh";
+ }
+ }
+}
+var_dump(array_slice($mismatches, 0, 5));
+
+?>
+--EXPECT--
+bool(true)
+array(0) {
+}
diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c
index 7768d4e3af3..0b0ecb44e23 100644
--- a/Zend/zend_alloc.c
+++ b/Zend/zend_alloc.c
@@ -1713,8 +1713,9 @@ static zend_always_inline void *zend_mm_realloc_heap(zend_mm_heap *heap, void *p
/* Check if requested size fits into current bin */
if (size <= old_size) {
- /* Check if truncation is necessary */
- if (old_bin_num > 0 && size < bin_data_size[old_bin_num - 1]) {
+ /* Check if truncation is necessary. A size that fits the
+ * bin below moves there, as efree_size() maps it to that bin. */
+ if (old_bin_num > 0 && size <= bin_data_size[old_bin_num - 1]) {
/* truncation */
ret = zend_mm_alloc_small(heap, ZEND_MM_SMALL_SIZE_TO_BIN(size) ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
copy_size = use_copy_size ? MIN(size, copy_size) : size;
diff --git a/ext/zend_test/test.c b/ext/zend_test/test.c
index c4be7ff42fb..c5ae9a71c20 100644
--- a/ext/zend_test/test.c
+++ b/ext/zend_test/test.c
@@ -174,6 +174,37 @@ static ZEND_FUNCTION(zend_leak_bytes)
emalloc(leakbytes);
}
+/* Reallocate a block of old_size bytes to new_size bytes and return its block
+ * size, along with the block size of a fresh allocation of new_size bytes. */
+static ZEND_FUNCTION(zend_test_erealloc_block_size)
+{
+ zend_long old_size, new_size;
+
+ ZEND_PARSE_PARAMETERS_START(2, 2)
+ Z_PARAM_LONG(old_size)
+ Z_PARAM_LONG(new_size)
+ ZEND_PARSE_PARAMETERS_END();
+
+ if (old_size < 1) {
+ zend_argument_value_error(1, "must be greater than 0");
+ RETURN_THROWS();
+ }
+ if (new_size < 1) {
+ zend_argument_value_error(2, "must be greater than 0");
+ RETURN_THROWS();
+ }
+
+ void *ptr = erealloc(emalloc(old_size), new_size);
+ void *fresh = emalloc(new_size);
+
+ array_init(return_value);
+ add_next_index_long(return_value, zend_mem_block_size(ptr));
+ add_next_index_long(return_value, zend_mem_block_size(fresh));
+
+ efree(fresh);
+ efree(ptr);
+}
+
/* Leak a refcounted variable */
static ZEND_FUNCTION(zend_leak_variable)
{
diff --git a/ext/zend_test/test.stub.php b/ext/zend_test/test.stub.php
index dfe04caabbb..c595677fc4a 100644
--- a/ext/zend_test/test.stub.php
+++ b/ext/zend_test/test.stub.php
@@ -246,6 +246,8 @@ function zend_leak_variable(mixed $variable): void {}
function zend_leak_bytes(int $bytes = 3): void {}
+ function zend_test_erealloc_block_size(int $old_size, int $new_size): array {}
+
function zend_string_or_object(object|string $param): object|string {}
function zend_string_or_object_or_null(object|string|null $param): object|string|null {}
diff --git a/ext/zend_test/test_arginfo.h b/ext/zend_test/test_arginfo.h
index c08feb90046..93792ac7c66 100644
Binary files a/ext/zend_test/test_arginfo.h and b/ext/zend_test/test_arginfo.h differ