Commit 8723e6606a5 for php.net
commit 8723e6606a5a52ad1aa6d8851c17d980085032b6
Author: Marc Bennewitz <marc@mabe.berlin>
Date: Wed Sep 23 20:06:41 2026 +0100
Fix GH-23811: SplFixedArray leak on re-init and broken setSize()
zend_object_alloc() zeroes the object, so cached_resize started at 0, which
spl_fixedarray_resize() reads as "resize in progress" and returns early.
setSize() therefore did nothing on subclasses whose constructor does not
call parent::__construct(). Initialise the struct on object creation.
setSize(0) clears the array before destroying its elements, so it looks
unconstructed to userland. __construct(), __wakeup() and __unserialize()
then re-initialised it, and the in-progress clear discarded the buffer
they had installed. cb3dc62fd90 fixed the same leak for a re-entrant
setSize() by testing the resize sentinel first; apply that test to the
other three entry points.
Close GH-23812
diff --git a/NEWS b/NEWS
index 9021450c122..ba4baf65461 100644
--- a/NEWS
+++ b/NEWS
@@ -78,6 +78,12 @@ PHP NEWS
. Fixed socket_select() silently truncating sets larger than FD_SETSIZE on
Windows. (David Carlier)
+- SPL:
+ . Fixed SplFixedArray::setSize() doing nothing on subclasses whose
+ constructor does not call parent::__construct(). (Marc Bennewitz)
+ . Fixed memory leak when __construct(), __wakeup() or __unserialize() is
+ called from an element destructor during setSize(0). (Marc Bennewitz)
+
- SQLite:
. Fixed a crash when SQLite3::close() is called from a userland callback.
(Ilia Alshanetsky)
diff --git a/ext/spl/spl_fixedarray.c b/ext/spl/spl_fixedarray.c
index 8f8108e2f90..18025e0cbeb 100644
--- a/ext/spl/spl_fixedarray.c
+++ b/ext/spl/spl_fixedarray.c
@@ -77,6 +77,14 @@ static bool spl_fixedarray_empty(spl_fixedarray *array)
return true;
}
+/* True while spl_fixedarray_resize() runs. A clear empties the array before
+ * destroying its elements, so emptiness alone cannot tell "never constructed"
+ * from "clear in progress"; re-initialising in that window leaks. */
+static bool spl_fixedarray_resize_in_progress(const spl_fixedarray *array)
+{
+ return array->cached_resize >= 0;
+}
+
static void spl_fixedarray_default_ctor(spl_fixedarray *array)
{
array->size = 0;
@@ -188,9 +196,9 @@ static void spl_fixedarray_resize(spl_fixedarray *array, zend_long size)
/* clearing the array */
if (size == 0) {
+ /* Clears elements and size; resetting them afterwards would leak
+ * anything a destructor re-installed. */
spl_fixedarray_dtor(array);
- array->elements = NULL;
- array->size = 0;
} else if (size > array->size) {
array->elements = safe_erealloc(array->elements, size, sizeof(zval), 0);
spl_fixedarray_init_elems(array, array->size, size);
@@ -201,8 +209,12 @@ static void spl_fixedarray_resize(spl_fixedarray *array, zend_long size)
array->elements = erealloc(array->elements, sizeof(zval) * size);
}
- /* If resized within the destructor, take the last resize command and perform it */
+ /* If resized within the destructor, take the last resize command and
+ * perform it. The sentinel is still set: re-initialising during a
+ * resize is refused. */
zend_long cached_resize = array->cached_resize;
+ ZEND_ASSERT(cached_resize >= 0);
+
array->cached_resize = -1;
if (cached_resize != size) {
spl_fixedarray_resize(array, cached_resize);
@@ -285,6 +297,9 @@ static zend_object *spl_fixedarray_object_new_ex(zend_class_entry *class_type, z
if (orig && clone_orig) {
spl_fixedarray_object *other = spl_fixed_array_from_obj(orig);
spl_fixedarray_copy_ctor(&intern->array, &other->array);
+ } else {
+ /* The zeroed struct would mean "resizing"; set the sentinel. */
+ spl_fixedarray_default_ctor(&intern->array);
}
while (parent) {
@@ -554,7 +569,7 @@ PHP_METHOD(SplFixedArray, __construct)
intern = Z_SPLFIXEDARRAY_P(object);
- if (!spl_fixedarray_empty(&intern->array)) {
+ if (UNEXPECTED(!spl_fixedarray_empty(&intern->array) || spl_fixedarray_resize_in_progress(&intern->array))) {
/* called __construct() twice, bail out */
return;
}
@@ -572,7 +587,7 @@ PHP_METHOD(SplFixedArray, __wakeup)
RETURN_THROWS();
}
- if (intern->array.size == 0) {
+ if (EXPECTED(intern->array.size == 0 && !spl_fixedarray_resize_in_progress(&intern->array))) {
int index = 0;
int size = zend_hash_num_elements(intern_ht);
@@ -634,7 +649,7 @@ PHP_METHOD(SplFixedArray, __unserialize)
RETURN_THROWS();
}
- if (intern->array.size == 0) {
+ if (EXPECTED(intern->array.size == 0 && !spl_fixedarray_resize_in_progress(&intern->array))) {
size = zend_hash_num_elements(data);
spl_fixedarray_init_non_empty_struct(&intern->array, size);
if (!size) {
diff --git a/ext/spl/tests/SplFixedArray_setSize_destruct_reinit_during_clear.phpt b/ext/spl/tests/SplFixedArray_setSize_destruct_reinit_during_clear.phpt
new file mode 100644
index 00000000000..838bf30c828
--- /dev/null
+++ b/ext/spl/tests/SplFixedArray_setSize_destruct_reinit_during_clear.phpt
@@ -0,0 +1,68 @@
+--TEST--
+SplFixedArray::setSize: re-initialising from a destructor during clear (GH-23811)
+--DESCRIPTION--
+setSize(0) clears elements and size before running the element destructors, so
+the array momentarily looks like it was never constructed. __construct(),
+__wakeup() and __unserialize() must not re-initialise it in that window: the
+in-progress clear would discard whatever they installed, leaking it.
+--FILE--
+<?php
+class Reentrant {
+ public static $arr = null;
+ public static $action = null;
+ public function __destruct() {
+ if (self::$action === null) {
+ return;
+ }
+ $fn = self::$action;
+ self::$action = null;
+ $fn(self::$arr);
+ }
+}
+
+function clear_with(callable $action): void {
+ $arr = new SplFixedArray(2);
+ $arr[0] = new Reentrant();
+ $arr[1] = "tail";
+ Reentrant::$arr = $arr;
+ Reentrant::$action = $action;
+
+ $arr->setSize(0);
+ echo "size: ", $arr->getSize(), "\n";
+
+ /* The array must still be usable. */
+ $arr->setSize(1);
+ $arr[0] = "ok";
+ var_dump($arr[0]);
+
+ Reentrant::$arr = null;
+ Reentrant::$action = null;
+}
+
+echo "-- __construct() --\n";
+clear_with(function ($arr) { $arr->__construct(5); });
+
+/* __construct() is ignored, but the following setSize() is still recorded as
+ * the pending resize and applied once the clear finishes. */
+echo "-- __construct() then setSize() --\n";
+clear_with(function ($arr) { $arr->__construct(7); $arr->setSize(3); });
+
+echo "-- __unserialize() --\n";
+clear_with(function ($arr) { $arr->__unserialize(["a", "b", "c"]); });
+
+echo "-- __wakeup() --\n";
+clear_with(function ($arr) { @$arr->__wakeup(); });
+?>
+--EXPECT--
+-- __construct() --
+size: 0
+string(2) "ok"
+-- __construct() then setSize() --
+size: 3
+string(2) "ok"
+-- __unserialize() --
+size: 0
+string(2) "ok"
+-- __wakeup() --
+size: 0
+string(2) "ok"
diff --git a/ext/spl/tests/SplFixedArray_subclass_without_parent_construct.phpt b/ext/spl/tests/SplFixedArray_subclass_without_parent_construct.phpt
new file mode 100644
index 00000000000..969661b3dca
--- /dev/null
+++ b/ext/spl/tests/SplFixedArray_subclass_without_parent_construct.phpt
@@ -0,0 +1,63 @@
+--TEST--
+SplFixedArray: subclass not calling parent::__construct() (GH-23811)
+--DESCRIPTION--
+The internal struct is zeroed on object creation, which used to leave the
+"resize in progress" sentinel at 0 instead of -1. setSize() then took the
+re-entrancy early return and silently did nothing, leaving the array stuck
+at size 0 for the lifetime of the object.
+--FILE--
+<?php
+class Unconstructed extends SplFixedArray {
+ public function __construct() {
+ /* deliberately does not call parent::__construct() */
+ }
+}
+
+$a = new Unconstructed();
+echo "initial: ", $a->getSize(), "\n";
+
+$a->setSize(3);
+echo "after setSize(3): ", $a->getSize(), "\n";
+
+$a[0] = "x";
+$a[2] = "z";
+var_dump($a->toArray());
+
+$a->setSize(1);
+echo "after setSize(1): ", $a->getSize(), "\n";
+
+$a->setSize(0);
+echo "after setSize(0): ", $a->getSize(), "\n";
+
+/* Deferred initialisation: calling the parent constructor later still works. */
+class LateInit extends SplFixedArray {
+ public function __construct() {
+ }
+ public function init(int $size): void {
+ parent::__construct($size);
+ }
+}
+$b = new LateInit();
+$b->init(2);
+echo "deferred parent::__construct(2): ", $b->getSize(), "\n";
+
+/* Cloning one of these must also yield a resizable array. */
+$c = clone new Unconstructed();
+$c->setSize(2);
+echo "clone then setSize(2): ", $c->getSize(), "\n";
+?>
+--EXPECT--
+initial: 0
+after setSize(3): 3
+array(3) {
+ [0]=>
+ string(1) "x"
+ [1]=>
+ NULL
+ [2]=>
+ string(1) "z"
+}
+after setSize(1): 1
+after setSize(0): 0
+deferred parent::__construct(2): 2
+clone then setSize(2): 2