From 25672edebcc149bbd6b3523d5e9da50e9345f109 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 20 Mar 2026 21:25:30 -0400 Subject: [PATCH 1/2] Fix GH-21478: Forward property operations to real instance for initialized lazy proxies zend_std_read_property, write_property, unset_property, and has_property invoked magic methods on the proxy even when the real instance's guard was already set, causing double invocations. Forward directly to the real instance when its guard indicates a recursive call. Updates gh18038-002 and gh18038-009 test expectations to match. --- Zend/tests/lazy_objects/gh18038-002.phpt | 1 - Zend/tests/lazy_objects/gh18038-004.phpt | 1 - Zend/tests/lazy_objects/gh18038-007.phpt | 1 - Zend/tests/lazy_objects/gh18038-009.phpt | 1 - Zend/tests/lazy_objects/gh20875.phpt | 8 --- Zend/tests/lazy_objects/gh21478-isset.phpt | 30 ++++++++++ .../gh21478-proxy-get-override.phpt | 30 ++++++++++ Zend/tests/lazy_objects/gh21478-set.phpt | 32 ++++++++++ Zend/tests/lazy_objects/gh21478-unset.phpt | 30 ++++++++++ Zend/tests/lazy_objects/gh21478.phpt | 32 ++++++++++ Zend/zend_object_handlers.c | 60 +++++++++++++++++++ 11 files changed, 214 insertions(+), 12 deletions(-) create mode 100644 Zend/tests/lazy_objects/gh21478-isset.phpt create mode 100644 Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt create mode 100644 Zend/tests/lazy_objects/gh21478-set.phpt create mode 100644 Zend/tests/lazy_objects/gh21478-unset.phpt create mode 100644 Zend/tests/lazy_objects/gh21478.phpt diff --git a/Zend/tests/lazy_objects/gh18038-002.phpt b/Zend/tests/lazy_objects/gh18038-002.phpt index 4c12f21de8115..d363731c62a09 100644 --- a/Zend/tests/lazy_objects/gh18038-002.phpt +++ b/Zend/tests/lazy_objects/gh18038-002.phpt @@ -34,5 +34,4 @@ var_dump($obj->prop); --EXPECT-- init string(19) "RealInstance::__set" -string(12) "Proxy::__set" int(2) diff --git a/Zend/tests/lazy_objects/gh18038-004.phpt b/Zend/tests/lazy_objects/gh18038-004.phpt index 8810efb6bec2e..c1495c5a6d8d6 100644 --- a/Zend/tests/lazy_objects/gh18038-004.phpt +++ b/Zend/tests/lazy_objects/gh18038-004.phpt @@ -36,7 +36,6 @@ var_dump($real->prop); --EXPECTF-- init string(19) "RealInstance::__get" -string(12) "Proxy::__get" Warning: Undefined property: RealInstance::$prop in %s on line %d NULL diff --git a/Zend/tests/lazy_objects/gh18038-007.phpt b/Zend/tests/lazy_objects/gh18038-007.phpt index 9925190a19801..4c7c0d0b4b0a6 100644 --- a/Zend/tests/lazy_objects/gh18038-007.phpt +++ b/Zend/tests/lazy_objects/gh18038-007.phpt @@ -36,6 +36,5 @@ var_dump(isset($real->prop[''])); --EXPECT-- init string(21) "RealInstance::__isset" -string(14) "Proxy::__isset" bool(false) bool(false) diff --git a/Zend/tests/lazy_objects/gh18038-009.phpt b/Zend/tests/lazy_objects/gh18038-009.phpt index 3c165a71ccffe..11067cdb970bd 100644 --- a/Zend/tests/lazy_objects/gh18038-009.phpt +++ b/Zend/tests/lazy_objects/gh18038-009.phpt @@ -36,6 +36,5 @@ var_dump(isset($real->prop)); --EXPECT-- init string(21) "RealInstance::__isset" -string(14) "Proxy::__isset" bool(false) bool(false) diff --git a/Zend/tests/lazy_objects/gh20875.phpt b/Zend/tests/lazy_objects/gh20875.phpt index 72e16011320c3..ff036edabd596 100644 --- a/Zend/tests/lazy_objects/gh20875.phpt +++ b/Zend/tests/lazy_objects/gh20875.phpt @@ -31,14 +31,6 @@ Warning: Undefined variable $a in %s on line %d Warning: Undefined variable $v in %s on line %d -Notice: Indirect modification of overloaded property A::$b has no effect in %s on line %d - -Warning: Undefined variable $x in %s on line %d - -Notice: Object of class stdClass could not be converted to int in %s on line %d - -Warning: Undefined variable $v in %s on line %d - Notice: Indirect modification of overloaded property A::$f has no effect in %s on line %d Fatal error: Uncaught Error: Cannot assign by reference to overloaded object in %s:%d diff --git a/Zend/tests/lazy_objects/gh21478-isset.phpt b/Zend/tests/lazy_objects/gh21478-isset.phpt new file mode 100644 index 0000000000000..9138984af01bf --- /dev/null +++ b/Zend/tests/lazy_objects/gh21478-isset.phpt @@ -0,0 +1,30 @@ +--TEST-- +GH-21478: __isset on lazy proxy should not double-invoke when real instance guard is set +--FILE-- +{$name}); + } +} + +class Bar extends Foo {} + +$rc = new ReflectionClass(Bar::class); +$proxy = $rc->newLazyProxy(function () { + echo "Init\n"; + return new Foo(); +}); + +$real = $rc->initializeLazyObject($proxy); +isset($real->x); + +?> +--EXPECT-- +Init +__isset($x) on Foo diff --git a/Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt b/Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt new file mode 100644 index 0000000000000..520c8f6623531 --- /dev/null +++ b/Zend/tests/lazy_objects/gh21478-proxy-get-override.phpt @@ -0,0 +1,30 @@ +--TEST-- +GH-21478: Proxy's own __get runs when accessed directly (not from real instance) +--FILE-- +newLazyProxy(function () { + return new Foo(); +}); +$rc->initializeLazyObject($proxy); + +$proxy->x; + +?> +--EXPECT-- +Bar x diff --git a/Zend/tests/lazy_objects/gh21478-set.phpt b/Zend/tests/lazy_objects/gh21478-set.phpt new file mode 100644 index 0000000000000..0b2f872de11a6 --- /dev/null +++ b/Zend/tests/lazy_objects/gh21478-set.phpt @@ -0,0 +1,32 @@ +--TEST-- +GH-21478: __set on lazy proxy should not double-invoke when real instance guard is set +--FILE-- +{$name} = $value; + } +} + +#[AllowDynamicProperties] +class Bar extends Foo {} + +$rc = new ReflectionClass(Bar::class); +$proxy = $rc->newLazyProxy(function () { + echo "Init\n"; + return new Foo(); +}); + +$real = $rc->initializeLazyObject($proxy); +$real->x = 1; + +?> +--EXPECT-- +Init +__set($x) on Foo diff --git a/Zend/tests/lazy_objects/gh21478-unset.phpt b/Zend/tests/lazy_objects/gh21478-unset.phpt new file mode 100644 index 0000000000000..5febbd235d824 --- /dev/null +++ b/Zend/tests/lazy_objects/gh21478-unset.phpt @@ -0,0 +1,30 @@ +--TEST-- +GH-21478: __unset on lazy proxy should not double-invoke when real instance guard is set +--FILE-- +{$name}); + } +} + +class Bar extends Foo {} + +$rc = new ReflectionClass(Bar::class); +$proxy = $rc->newLazyProxy(function () { + echo "Init\n"; + return new Foo(); +}); + +$real = $rc->initializeLazyObject($proxy); +unset($real->x); + +?> +--EXPECT-- +Init +__unset($x) on Foo diff --git a/Zend/tests/lazy_objects/gh21478.phpt b/Zend/tests/lazy_objects/gh21478.phpt new file mode 100644 index 0000000000000..aaa226a9a09a7 --- /dev/null +++ b/Zend/tests/lazy_objects/gh21478.phpt @@ -0,0 +1,32 @@ +--TEST-- +GH-21478 (Property access on lazy proxy may invoke magic method despite real instance guards) +--FILE-- +{$name}; + } +} + +class Bar extends Foo {} + +$rc = new ReflectionClass(Bar::class); +$proxy = $rc->newLazyProxy(function () { + echo "Init\n"; + return new Foo(); +}); + +$real = $rc->initializeLazyObject($proxy); +$real->x; + +?> +--EXPECTF-- +Init +__get($x) on Foo + +Warning: Undefined property: Foo::$x in %s on line %d diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c index 7e03139dc426b..54331a306c368 100644 --- a/Zend/zend_object_handlers.c +++ b/Zend/zend_object_handlers.c @@ -893,6 +893,23 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int retval = &EG(uninitialized_zval); + /* For initialized lazy proxies: if the real instance's magic method + * guard is already set for this property, we are inside a recursive + * call from the real instance's __get/__isset. Forward directly to + * the real instance to avoid double invocation. (GH-21478) */ + if (UNEXPECTED(zend_object_is_lazy_proxy(zobj) + && zend_lazy_object_initialized(zobj))) { + zend_object *instance = zend_lazy_object_get_instance(zobj); + if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) { + uint32_t *instance_guard = zend_get_property_guard(instance, name); + uint32_t guard_type = ((type == BP_VAR_IS) && zobj->ce->__isset) + ? IN_ISSET : IN_GET; + if ((*instance_guard) & guard_type) { + return zend_std_read_property(instance, name, type, cache_slot, rv); + } + } + } + /* magic isset */ if ((type == BP_VAR_IS) && zobj->ce->__isset) { zval tmp_result; @@ -1209,6 +1226,20 @@ found:; goto exit; } + /* For initialized lazy proxies: if the real instance's __set guard + * is already set, we are inside a recursive call from the real + * instance's __set. Forward directly to avoid double invocation. */ + if (UNEXPECTED(zend_object_is_lazy_proxy(zobj) + && zend_lazy_object_initialized(zobj))) { + zend_object *instance = zend_lazy_object_get_instance(zobj); + if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) { + uint32_t *instance_guard = zend_get_property_guard(instance, name); + if ((*instance_guard) & IN_SET) { + return zend_std_write_property(instance, name, value, cache_slot); + } + } + } + /* magic set */ if (zobj->ce->__set) { if (!guard) { @@ -1603,6 +1634,21 @@ ZEND_API void zend_std_unset_property(zend_object *zobj, zend_string *name, void return; } + /* For initialized lazy proxies: if the real instance's __unset guard + * is already set, we are inside a recursive call from the real + * instance's __unset. Forward directly to avoid double invocation. */ + if (UNEXPECTED(zend_object_is_lazy_proxy(zobj) + && zend_lazy_object_initialized(zobj))) { + zend_object *instance = zend_lazy_object_get_instance(zobj); + if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) { + uint32_t *instance_guard = zend_get_property_guard(instance, name); + if ((*instance_guard) & IN_UNSET) { + zend_std_unset_property(instance, name, cache_slot); + return; + } + } + } + /* magic unset */ if (zobj->ce->__unset) { if (!guard) { @@ -2399,6 +2445,20 @@ ZEND_API int zend_std_has_property(zend_object *zobj, zend_string *name, int has goto exit; } + /* For initialized lazy proxies: if the real instance's __isset guard + * is already set, we are inside a recursive call from the real + * instance's __isset. Forward directly to avoid double invocation. */ + if (UNEXPECTED(zend_object_is_lazy_proxy(zobj) + && zend_lazy_object_initialized(zobj))) { + zend_object *instance = zend_lazy_object_get_instance(zobj); + if (instance->ce->ce_flags & ZEND_ACC_USE_GUARDS) { + uint32_t *instance_guard = zend_get_property_guard(instance, name); + if ((*instance_guard) & IN_ISSET) { + return zend_std_has_property(instance, name, has_set_exists, cache_slot); + } + } + } + if (!zobj->ce->__isset) { goto lazy_init; } From bf927ad7366fa0979e273a07b0d13b1819dd7f49 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Tue, 31 Mar 2026 10:42:13 -0400 Subject: [PATCH 2/2] Fix assertion failure when &__get forwards through initialized lazy proxy When read_property forwards to the real instance and gets back &EG(uninitialized_zval), return rv with ZVAL_NULL instead. This preserves the invariant that read_property won't return the uninitialized sentinel when get_property_ptr_ptr returned NULL. --- .../gh21478-proxy-get-ref-forward.phpt | 32 +++++++++++++++++++ Zend/zend_object_handlers.c | 7 +++- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt diff --git a/Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt b/Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt new file mode 100644 index 0000000000000..fa737cf18f2e2 --- /dev/null +++ b/Zend/tests/lazy_objects/gh21478-proxy-get-ref-forward.phpt @@ -0,0 +1,32 @@ +--TEST-- +GH-21478: No assertion failure when &__get forwards through initialized lazy proxy +--FILE-- +{$name}; + } +} + +class Bar extends Foo {} + +$rc = new ReflectionClass(Bar::class); +$proxy = $rc->newLazyProxy(function () { + echo "Init\n"; + return new Foo(); +}); + +$real = $rc->initializeLazyObject($proxy); +$a = &$real->x; +var_dump($a); +?> +--EXPECTF-- +Init +Foo::__get($x) on Foo + +Warning: Undefined property: Foo::$x in %s on line %d +NULL diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c index 54331a306c368..90f0e1099619b 100644 --- a/Zend/zend_object_handlers.c +++ b/Zend/zend_object_handlers.c @@ -905,7 +905,12 @@ ZEND_API zval *zend_std_read_property(zend_object *zobj, zend_string *name, int uint32_t guard_type = ((type == BP_VAR_IS) && zobj->ce->__isset) ? IN_ISSET : IN_GET; if ((*instance_guard) & guard_type) { - return zend_std_read_property(instance, name, type, cache_slot, rv); + retval = zend_std_read_property(instance, name, type, cache_slot, rv); + if (retval == &EG(uninitialized_zval)) { + ZVAL_NULL(rv); + retval = rv; + } + return retval; } } }