From 40618363e84438a2df4631b8372124c58c6eed22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matthias=20D=C3=B6tsch?= Date: Wed, 14 Oct 2026 20:25:27 +0200 Subject: [PATCH] Fix set visibility bypass through a primed ZEND_ASSIGN_OBJ cache slot Since GH-22709, the ZEND_ASSIGN_OBJ fast path doesn't check the set visibility of a property when the cache slot is primed, assuming that write_property() only leaves it primed after the check passed. But write_property() also left it primed when it threw a readonly modification error, or fell back to __set() for an unset property, without checking the set visibility. A later execution of the same opline could then modify a private(set) property, or reinitialize a readonly property in __clone(), from a scope without set access. Reset the cache slot on these paths as well. --- .../cache_slot_magic_set.phpt | 28 +++++++++++++++++++ .../cache_slot_clone_reinit.phpt | 26 +++++++++++++++++ Zend/zend_object_handlers.c | 24 +++++++++++----- 3 files changed, 71 insertions(+), 7 deletions(-) create mode 100644 Zend/tests/asymmetric_visibility/cache_slot_magic_set.phpt create mode 100644 Zend/tests/readonly_props/cache_slot_clone_reinit.phpt diff --git a/Zend/tests/asymmetric_visibility/cache_slot_magic_set.phpt b/Zend/tests/asymmetric_visibility/cache_slot_magic_set.phpt new file mode 100644 index 000000000000..df5b3fc2ec88 --- /dev/null +++ b/Zend/tests/asymmetric_visibility/cache_slot_magic_set.phpt @@ -0,0 +1,28 @@ +--TEST-- +Asymmetric visibility: cache slot primed by a __set() fallback must not skip the set visibility check +--FILE-- +x); } +} +function w(C $c, int $v) { + try { + $c->x = $v; // same opline every call + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } +} +$a = new C(); +$a->unsetX(); +w($a, 1); // unset property: goes to __set(), cache slot stays primed +$b = new C(); +w($b, 2); // initialized property, must fail: private(set) from global scope +var_dump($b->x); + +?> +--EXPECT-- +__set(x) +Cannot modify private(set) property C::$x from global scope +int(0) diff --git a/Zend/tests/readonly_props/cache_slot_clone_reinit.phpt b/Zend/tests/readonly_props/cache_slot_clone_reinit.phpt new file mode 100644 index 000000000000..70a3bc77e0e5 --- /dev/null +++ b/Zend/tests/readonly_props/cache_slot_clone_reinit.phpt @@ -0,0 +1,26 @@ +--TEST-- +Readonly: cache slot primed by a failed modification must not skip the set visibility check on reinitialization +--FILE-- +x = 1; } + public function __clone() { w($this, 3); } +} +function w(P $p, int $v) { + try { + $p->x = $v; // same opline every call + } catch (Error $e) { + echo $e->getMessage(), "\n"; + } +} +$p = new P(); +w($p, 2); // readonly modification error, cache slot stays primed +$q = clone $p; // __clone -> w(): global scope must not be able to reinitialize +var_dump($q->x); + +?> +--EXPECT-- +Cannot modify readonly property P::$x +Cannot modify protected(set) readonly property P::$x from global scope +int(1) diff --git a/Zend/zend_object_handlers.c b/Zend/zend_object_handlers.c index 800f9111c3de..efea3d216af3 100644 --- a/Zend/zend_object_handlers.c +++ b/Zend/zend_object_handlers.c @@ -1131,18 +1131,19 @@ ZEND_API zval *zend_std_write_property(zend_object *zobj, zend_string *name, zva && !(Z_PROP_FLAG_P(variable_ptr) & IS_PROP_REINITABLE)) { zend_readonly_property_modification_error(prop_info); variable_ptr = &EG(error_zval); - goto exit; + /* The set visibility was not checked */ + goto exit_reset_cache_slot; } if ((prop_info->flags & ZEND_ACC_PPP_SET_MASK) && !zend_asymmetric_property_has_set_access(prop_info)) { zend_asymmetric_visibility_property_modification_error(prop_info, "modify"); variable_ptr = &EG(error_zval); - if (cache_slot) { - /* Reset cache slot to dodge fast path in next execution. */ - CACHE_POLYMORPHIC_PTR_EX(cache_slot, NULL, NULL); - CACHE_PTR_EX(cache_slot + 2, NULL); - } - goto exit; + goto exit_reset_cache_slot; } + } else if (cache_slot) { + /* __set() will be called without checking the set visibility. The ZEND_ASSIGN_OBJ + * fast path skips that check when the cache slot is primed, so reset it. */ + CACHE_POLYMORPHIC_PTR_EX(cache_slot, NULL, NULL); + CACHE_PTR_EX(cache_slot + 2, NULL); } } @@ -1347,6 +1348,15 @@ found:; exit: return variable_ptr; +exit_reset_cache_slot: + if (cache_slot) { + /* Reset cache slot to dodge the fast path, which doesn't check the set visibility, + * in the next execution. */ + CACHE_POLYMORPHIC_PTR_EX(cache_slot, NULL, NULL); + CACHE_PTR_EX(cache_slot + 2, NULL); + } + return variable_ptr; + lazy_init: return forward_write_to_lazy_object(zobj, name, value, cache_slot, /* guarded */ false);