Commit 27f17c33227 for php.net

commit 27f17c33227f4c76b23e2113e36ca533d4522615
Author: Niels Dossche <7771979+ndossche@users.noreply.github.com>
Date:   Sat Nov 15 13:40:20 2025 +0100

    Fix GH-20286: use-after-destroy during userland stream_close()

    Closes GH-20493.

    Co-authored-by: David Carlier <devnexen@gmail.com>

diff --git a/NEWS b/NEWS
index b04792c07f8..b56302cff0f 100644
--- a/NEWS
+++ b/NEWS
@@ -6,6 +6,8 @@ PHP                                                                        NEWS
   . Sync all boost.context files with release 1.86.0. (mvorisek)
   . Fixed bug GH-20435 (SensitiveParameter doesn't work for named argument
     passing to variadic parameter). (ndossche)
+  . Fixed bug GH-20286 (use-after-destroy during userland stream_close()).
+    (ndossche, David Carlier)

 - Bz2:
   . Fix assertion failures resulting in crashes with stream filter
diff --git a/Zend/zend_list.c b/Zend/zend_list.c
index 5add19256a6..10aa9174cfc 100644
--- a/Zend/zend_list.c
+++ b/Zend/zend_list.c
@@ -214,21 +214,34 @@ void zend_init_rsrc_plist(void)

 void zend_close_rsrc_list(HashTable *ht)
 {
-	/* Reload ht->arData on each iteration, as it may be reallocated. */
 	uint32_t i = ht->nNumUsed;
+	uint32_t num = ht->nNumUsed;

 retry:
 	zend_try {
 		while (i-- > 0) {
+			/* Reload ht->arData on each iteration, as it may be reallocated. */
 			zval *p = ZEND_HASH_ELEMENT(ht, i);
 			if (Z_TYPE_P(p) != IS_UNDEF) {
 				zend_resource *res = Z_PTR_P(p);
 				if (res->type >= 0) {
 					zend_resource_dtor(res);
+
+					if (UNEXPECTED(ht->nNumUsed != num)) {
+						/* New resources were added, reloop from the start.
+						 * We need to keep the top->down order to avoid freeing resources
+						 * in use by the newly created resources. */
+						i = num = ht->nNumUsed;
+					}
 				}
 			}
 		}
 	} zend_catch {
+		if (UNEXPECTED(ht->nNumUsed != num)) {
+			/* See above */
+			i = num = ht->nNumUsed;
+		}
+
 		/* If we have bailed, we probably executed user code (e.g. user stream
 		 * API). Keep closing resources so they don't leak. User handlers must be
 		 * called now so they aren't called in zend_deactivate() on
diff --git a/ext/standard/tests/streams/gh20286.phpt b/ext/standard/tests/streams/gh20286.phpt
new file mode 100644
index 00000000000..b4d340a4390
--- /dev/null
+++ b/ext/standard/tests/streams/gh20286.phpt
@@ -0,0 +1,43 @@
+--TEST--
+GH-20286 use after destroy on userland stream_close
+--CREDITS--
+vi3tL0u1s
+--SKIPIF--
+<?php
+if (substr(PHP_OS, 0, 3) == 'WIN') die('skip Aborts with STATUS_BAD_FUNCTION_TABLE on Windows');
+?>
+--FILE--
+<?php
+class lib {
+   public $context;
+   function stream_set() {}
+   function stream_set_option() {}
+   function stream_stat() {
+     return true;
+   }
+   function stream_open() {
+     return true;
+   }
+
+   function stream_read($count) {
+     function a() {}
+     include('lib://');
+   }
+
+   function stream_close() {
+     static $count = 0;
+     if ($count++ < 3) // Prevent infinite loop
+      include('lib://');
+   }
+}
+stream_wrapper_register('lib', lib::class);
+include('lib://test.php');
+?>
+--EXPECTF--
+Fatal error: Cannot redeclare a() (previously declared in %s:%d) in %s on line %d
+
+Fatal error: Cannot redeclare a() (previously declared in %s on line %d
+
+Fatal error: Cannot redeclare a() (previously declared in %s on line %d
+
+Fatal error: Cannot redeclare a() (previously declared in %s on line %d
diff --git a/main/streams/userspace.c b/main/streams/userspace.c
index 8d15172ef13..ba66d324659 100644
--- a/main/streams/userspace.c
+++ b/main/streams/userspace.c
@@ -254,6 +254,7 @@ typedef struct _php_userstream_data php_userstream_data_t;
 static zend_result call_method_if_exists(
 		zval *object, zval *method_name, zval *retval, uint32_t param_count, zval *params)
 {
+	ZEND_ASSERT(EG(active));
 	return zend_call_method_if_exists(
 		Z_OBJ_P(object), Z_STR_P(method_name), retval, param_count, params);
 }