Commit 71926644bbb for php.net

commit 71926644bbb7f671a9378c0a83989a811f5b288f
Author: David Carlier <devnexen@gmail.com>
Date:   Sat May 2 16:27:25 2026 +0100

    Fix GH-21927: Use-after-free of self-freeing MultipleIterator children.

    Add a refcount on the child iterator across rewind/next/valid/current/key
    calls so user methods can detach themselves without freeing the object
    mid-call.

    close GH-21933

diff --git a/NEWS b/NEWS
index 35f34920e62..073a6a9acd4 100644
--- a/NEWS
+++ b/NEWS
@@ -12,6 +12,8 @@ PHP                                                                        NEWS
 - SPL:
   . Fix SplFixedArray::setSize leak when destructor grows during clear.
     (David Carlier)
+  . Fixed bug GH-21933 (use after free of self-freeing MultipleIterator
+    children). (David Carlier)

 - Standard:
   . Fixed bug GH-21689 (version_compare() incorrectly handles versions ending
diff --git a/ext/spl/spl_observer.c b/ext/spl/spl_observer.c
index e653d1173b2..56cdbdd4b5f 100644
--- a/ext/spl/spl_observer.c
+++ b/ext/spl/spl_observer.c
@@ -1232,7 +1232,9 @@ PHP_METHOD(MultipleIterator, rewind)
 	zend_hash_internal_pointer_reset_ex(&intern->storage, &intern->pos);
 	while ((element = zend_hash_get_current_data_ptr_ex(&intern->storage, &intern->pos)) != NULL && !EG(exception)) {
 		zend_object *it = element->obj;
+		GC_ADDREF(it);
 		zend_call_known_instance_method_with_0_params(it->ce->iterator_funcs_ptr->zf_rewind, it, NULL);
+		OBJ_RELEASE(it);
 		zend_hash_move_forward_ex(&intern->storage, &intern->pos);
 	}
 }
@@ -1253,7 +1255,9 @@ PHP_METHOD(MultipleIterator, next)
 	zend_hash_internal_pointer_reset_ex(&intern->storage, &intern->pos);
 	while ((element = zend_hash_get_current_data_ptr_ex(&intern->storage, &intern->pos)) != NULL && !EG(exception)) {
 		zend_object *it = element->obj;
+		GC_ADDREF(it);
 		zend_call_known_instance_method_with_0_params(it->ce->iterator_funcs_ptr->zf_next, it, NULL);
+		OBJ_RELEASE(it);
 		zend_hash_move_forward_ex(&intern->storage, &intern->pos);
 	}
 }
@@ -1282,7 +1286,9 @@ PHP_METHOD(MultipleIterator, valid)
 	zend_hash_internal_pointer_reset_ex(&intern->storage, &intern->pos);
 	while ((element = zend_hash_get_current_data_ptr_ex(&intern->storage, &intern->pos)) != NULL && !EG(exception)) {
 		zend_object *it = element->obj;
+		GC_ADDREF(it);
 		zend_call_known_instance_method_with_0_params(it->ce->iterator_funcs_ptr->zf_valid, it, &retval);
+		OBJ_RELEASE(it);

 		if (!Z_ISUNDEF(retval)) {
 			valid = (Z_TYPE(retval) == IS_TRUE);
@@ -1320,6 +1326,9 @@ static void spl_multiple_iterator_get_all(spl_SplObjectStorage *intern, int get_
 	zend_hash_internal_pointer_reset_ex(&intern->storage, &intern->pos);
 	while ((element = zend_hash_get_current_data_ptr_ex(&intern->storage, &intern->pos)) != NULL && !EG(exception)) {
 		zend_object *it = element->obj;
+		zval inf;
+		GC_ADDREF(it);
+		ZVAL_COPY(&inf, &element->inf);
 		zend_call_known_instance_method_with_0_params(it->ce->iterator_funcs_ptr->zf_valid, it, &retval);

 		if (!Z_ISUNDEF(retval)) {
@@ -1336,10 +1345,14 @@ static void spl_multiple_iterator_get_all(spl_SplObjectStorage *intern, int get_
 				zend_call_known_instance_method_with_0_params(it->ce->iterator_funcs_ptr->zf_key, it, &retval);
 			}
 			if (Z_ISUNDEF(retval)) {
+				OBJ_RELEASE(it);
+				zval_ptr_dtor(&inf);
 				zend_throw_exception(spl_ce_RuntimeException, "Failed to call sub iterator method", 0);
 				return;
 			}
 		} else if (intern->flags & MIT_NEED_ALL) {
+			OBJ_RELEASE(it);
+			zval_ptr_dtor(&inf);
 			if (SPL_MULTIPLE_ITERATOR_GET_ALL_CURRENT == get_type) {
 				zend_throw_exception(spl_ce_RuntimeException, "Called current() with non valid sub iterator", 0);
 			} else {
@@ -1351,15 +1364,17 @@ static void spl_multiple_iterator_get_all(spl_SplObjectStorage *intern, int get_
 		}

 		if (intern->flags & MIT_KEYS_ASSOC) {
-			switch (Z_TYPE(element->inf)) {
+			switch (Z_TYPE(inf)) {
 				case IS_LONG:
-					add_index_zval(return_value, Z_LVAL(element->inf), &retval);
+					add_index_zval(return_value, Z_LVAL(inf), &retval);
 					break;
 				case IS_STRING:
-					zend_symtable_update(Z_ARRVAL_P(return_value), Z_STR(element->inf), &retval);
+					zend_symtable_update(Z_ARRVAL_P(return_value), Z_STR(inf), &retval);
 					break;
 				default:
 					zval_ptr_dtor(&retval);
+					OBJ_RELEASE(it);
+					zval_ptr_dtor(&inf);
 					zend_throw_exception(spl_ce_InvalidArgumentException, "Sub-Iterator is associated with NULL", 0);
 					return;
 			}
@@ -1367,6 +1382,8 @@ static void spl_multiple_iterator_get_all(spl_SplObjectStorage *intern, int get_
 			add_next_index_zval(return_value, &retval);
 		}

+		OBJ_RELEASE(it);
+		zval_ptr_dtor(&inf);
 		zend_hash_move_forward_ex(&intern->storage, &intern->pos);
 	}
 }
diff --git a/ext/spl/tests/gh21927.phpt b/ext/spl/tests/gh21927.phpt
new file mode 100644
index 00000000000..a60ea5ca102
--- /dev/null
+++ b/ext/spl/tests/gh21927.phpt
@@ -0,0 +1,151 @@
+--TEST--
+GH-21927: Use-after-free of self-freeing MultipleIterator children
+--FILE--
+<?php
+
+class DetachOnRewind implements Iterator {
+    public function __construct(private MultipleIterator $parent) {}
+    public function rewind(): void {
+        $this->parent->detachIterator($this);
+        echo "rewind: still alive\n";
+    }
+    public function next(): void {}
+    public function current(): mixed { return 0; }
+    public function key(): mixed { return 0; }
+    public function valid(): bool { return false; }
+}
+
+class DetachOnNext implements Iterator {
+    public function __construct(private MultipleIterator $parent) {}
+    public function rewind(): void {}
+    public function next(): void {
+        $this->parent->detachIterator($this);
+        echo "next: still alive\n";
+    }
+    public function current(): mixed { return 0; }
+    public function key(): mixed { return 0; }
+    public function valid(): bool { return true; }
+}
+
+class DetachOnValid implements Iterator {
+    public function __construct(private MultipleIterator $parent) {}
+    public function rewind(): void {}
+    public function next(): void {}
+    public function current(): mixed { return 0; }
+    public function key(): mixed { return 0; }
+    public function valid(): bool {
+        $this->parent->detachIterator($this);
+        echo "valid: still alive\n";
+        return true;
+    }
+}
+
+class DetachOnCurrent implements Iterator {
+    public function __construct(private MultipleIterator $parent) {}
+    public function rewind(): void {}
+    public function next(): void {}
+    public function current(): mixed {
+        $this->parent->detachIterator($this);
+        echo "current: still alive\n";
+        return 'C';
+    }
+    public function key(): mixed { return 'k'; }
+    public function valid(): bool { return true; }
+}
+
+class DetachOnKey implements Iterator {
+    public function __construct(private MultipleIterator $parent) {}
+    public function rewind(): void {}
+    public function next(): void {}
+    public function current(): mixed { return 'C'; }
+    public function key(): mixed {
+        $this->parent->detachIterator($this);
+        echo "key: still alive\n";
+        return 'K';
+    }
+    public function valid(): bool { return true; }
+}
+
+echo "-- detach inside rewind --\n";
+$mi = new MultipleIterator();
+$mi->attachIterator(new DetachOnRewind($mi));
+$mi->rewind();
+var_dump($mi->countIterators());
+
+echo "-- detach inside next --\n";
+$mi = new MultipleIterator();
+$mi->attachIterator(new DetachOnNext($mi));
+$mi->rewind();
+$mi->next();
+var_dump($mi->countIterators());
+
+echo "-- detach inside valid --\n";
+$mi = new MultipleIterator();
+$mi->attachIterator(new DetachOnValid($mi));
+var_dump($mi->valid());
+var_dump($mi->countIterators());
+
+echo "-- detach inside current (numeric keys) --\n";
+$mi = new MultipleIterator();
+$mi->attachIterator(new DetachOnCurrent($mi));
+var_dump($mi->current());
+var_dump($mi->countIterators());
+
+echo "-- detach inside key (numeric keys) --\n";
+$mi = new MultipleIterator();
+$mi->attachIterator(new DetachOnKey($mi));
+var_dump($mi->key());
+var_dump($mi->countIterators());
+
+echo "-- detach inside current (assoc keys, refcounted inf) --\n";
+$mi = new MultipleIterator(MultipleIterator::MIT_NEED_ALL | MultipleIterator::MIT_KEYS_ASSOC);
+$mi->attachIterator(new DetachOnCurrent($mi), 'cur_info_string');
+var_dump($mi->current());
+var_dump($mi->countIterators());
+
+echo "-- detach inside key (assoc keys, refcounted inf) --\n";
+$mi = new MultipleIterator(MultipleIterator::MIT_NEED_ALL | MultipleIterator::MIT_KEYS_ASSOC);
+$mi->attachIterator(new DetachOnKey($mi), 'key_info_string');
+var_dump($mi->key());
+var_dump($mi->countIterators());
+
+?>
+--EXPECT--
+-- detach inside rewind --
+rewind: still alive
+int(0)
+-- detach inside next --
+next: still alive
+int(0)
+-- detach inside valid --
+valid: still alive
+bool(true)
+int(0)
+-- detach inside current (numeric keys) --
+current: still alive
+array(1) {
+  [0]=>
+  string(1) "C"
+}
+int(0)
+-- detach inside key (numeric keys) --
+key: still alive
+array(1) {
+  [0]=>
+  string(1) "K"
+}
+int(0)
+-- detach inside current (assoc keys, refcounted inf) --
+current: still alive
+array(1) {
+  ["cur_info_string"]=>
+  string(1) "C"
+}
+int(0)
+-- detach inside key (assoc keys, refcounted inf) --
+key: still alive
+array(1) {
+  ["key_info_string"]=>
+  string(1) "K"
+}
+int(0)