Commit 83c17e31494 for php.net

commit 83c17e3149494f63d05f6cc140966cd196ab200c
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Mon Sep 21 10:00:41 2026 -0400

    Fix GH-21776: use-after-free in zend_std_read_property magic __isset (#21786)

    When __isset drops the last non-temp reference to $this (e.g.
    $GLOBALS['o'] = 0), the OBJ_RELEASE after the __isset call freed zobj
    before zend_std_read_property reached the shared uninit_error check
    at zend_lazy_object_must_init(zobj), a heap-use-after-free.

    The GC_ADDREF/OBJ_RELEASE pair around __isset has been correct since
    2018. The 2023 lazy-object support added a zobj read in the shared
    fall-through path without extending the isset branch's ref coverage
    to match. Defer the release via a local flag so zobj stays alive
    through the lazy-init check and the recursive read on the initialized
    instance. Route the lazy-init block's exits through a release_zobj_exit
    label so the deferred release fires on those paths too, while the hot
    paths that already released inline skip the flag check.

    Closes GH-21776

diff --git a/Zend/tests/gh21776.phpt b/Zend/tests/gh21776.phpt
new file mode 100644
index 00000000000..4f6ae956a22
--- /dev/null
+++ b/Zend/tests/gh21776.phpt
@@ -0,0 +1,16 @@
+--TEST--
+GH-21776 (Heap use-after-free in zend_object_is_lazy via magic __isset)
+--FILE--
+<?php
+class C {
+    function __isset($x) {
+        $GLOBALS['o'] = 0;
+        return true;
+    }
+}
+$o = new C;
+$o->a ?? 0;
+echo "OK\n";
+?>
+--EXPECT--
+OK
diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c
index 8ca6d212fd7..800f9111c3d 100644
--- a/Zend/zend_object_handlers.c
+++ b/Zend/zend_object_handlers.c
@@ -752,6 +752,7 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int
 	uintptr_t property_offset;
 	const zend_property_info *prop_info = NULL;
 	uint32_t *guard = NULL;
+	bool release_zobj = false;

 #if DEBUG_OBJECT_HANDLERS
 	fprintf(stderr, "Read object #%d property: %s\n", zobj->handle, ZSTR_VAL(name));
@@ -970,7 +971,7 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int
 			if (zobj->ce->__get && !((*guard) & IN_GET)) {
 				goto call_getter;
 			}
-			OBJ_RELEASE(zobj);
+			release_zobj = true;
 		} else if (zobj->ce->__get && !((*guard) & IN_GET)) {
 			goto call_getter_addref;
 		}
@@ -1019,7 +1020,7 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int
 			zend_object *instance = zend_lazy_object_init(zobj);
 			if (!instance) {
 				retval = &EG(uninitialized_zval);
-				goto exit;
+				goto release_zobj_exit;
 			}

 			if (UNEXPECTED(guard && (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS))) {
@@ -1032,11 +1033,12 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int
 					(*guard) |= guard_type;
 					retval = zend_std_read_property(instance, name, type, cache_slot, rv);
 					(*guard) &= ~guard_type;
-					return retval;
+					goto release_zobj_exit;
 				}
 			}

-			return zend_std_read_property(instance, name, type, cache_slot, rv);
+			retval = zend_std_read_property(instance, name, type, cache_slot, rv);
+			goto release_zobj_exit;
 		}
 	}
 	if (type != BP_VAR_IS) {
@@ -1048,6 +1050,10 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int
 	}
 	retval = &EG(uninitialized_zval);

+release_zobj_exit:
+	if (release_zobj) {
+		OBJ_RELEASE(zobj);
+	}
 exit:
 	return retval;
 }