[php-src] PR #24566: php8.6: Fix set visibility bypass through a primed ZEND_ASSIGN_OBJ cache slot
| From: | brainexe | Date: | Thu, 22 Oct 2026 18:05:49 +0000 |
| Subject: | [php-src] PR #24566: php8.6: Fix set visibility bypass through a primed ZEND_ASSIGN_OBJ cache slot | ||
| Groups: | php.git-pulls | ||
| Request: | Send a blank email to git-pulls+get-39731@lists.php.net to get a copy of this message | ||
Pull Request: https://github.com/php/php-src/pull/24566
Author: brainexe
Since GH-22709 (94136cf9bfb), the
ZEND_ASSIGN_OBJ fast path passes check_writable
= false to zend_assign_to_typed_prop() when the cache slot is primed. This
relies on zend_std_write_property() leaving the slot primed only after the set
visibility check has passed. zend_get_property_offset() primes the slot before that
check, and the asymmetric visibility error path resets it again, but two other paths keep it primed
without the check having passed:
1. **Readonly modification error.** The first write to an initialized readonly property throws
"Cannot modify readonly property" and returns with the slot primed. If the same opline
later runs inside __clone(), where the property is reinitializable, the fast path skips
the implicit protected(set) check, so code outside the class can reinitialize the
property.
2. **__set() fallback.** For an unset() property of a class with
__set(), the visibility check is skipped on purpose and __set() is called,
but the slot stays primed. A later run of the same opline on an object where the property is
initialized then writes to a private(set) property from global scope.
```php
class C {
public private(set) int $x = 0;
public function __set($name, $value) { echo "__set($name)\n"; }
public function unsetX() { unset($this->x); }
}
function w(C $c, int $v) { $c->x = $v; } // same opline every call
$a = new C(); $a->unsetX();
w($a, 1); // __set(x), cache slot stays primed
$b = new C();
w($b, 2); // expected: Error "Cannot modify private(set) property C::$x from global
scope"
var_dump($b->x); // PHP-8.6: int(2)
```
```php
class P {
public readonly int $x;
public function __construct() { $this->x = 1; }
public function __clone() { w($this, 3); }
}
function w(P $p, int $v) { try { $p->x = $v; } catch (Error $e) { echo $e->getMessage(),
"\n"; } }
$p = new P();
w($p, 2); // Cannot modify readonly property P::$x, cache slot stays primed
$q = clone $p; // expected: Error "Cannot modify protected(set) readonly property P::$x
from global scope"
var_dump($q->x); // PHP-8.6: int(3)
```
The fix resets the cache slot on both paths, as the asymmetric visibility error path already does
(moved to a shared exit_reset_cache_slot label). The __set() reset only
happens inside the existing ZEND_ACC_READONLY|ZEND_ACC_PPP_SET_MASK branch, so plain
public properties and the common unset-plus-__set() proxy pattern are unaffected.
The JIT helpers (zend_jit_assign_to_typed_prop() →
verify_readonly_and_avis()) always check the set visibility, so only the VM fast path
is affected; the repros fail the same way with opcache and with function/tracing JIT because these
oplines run in the VM.
Affected: PHP-8.6 since 8.6.0beta1 (all betas and RCs so far) and master. Not affected: PHP-8.5 and
older.
## Tests
- Zend/tests/asymmetric_visibility/cache_slot_magic_set.phpt
- Zend/tests/readonly_props/cache_slot_clone_reinit.phpt
Validated on a debug build with AddressSanitizer of PHP-8.6 (730ef3d3683):
- Both new tests fail without the fix and pass with it, with and without opcache.
- Both reproducers behave correctly with the fix without opcache, with opcache, with function JIT
and with tracing JIT, also in a 5000-iteration hot loop under tracing JIT.
- Zend/tests, tests/classes, tests/lang and
ext/opcache/tests under --asan without opcache, with opcache and with
tracing JIT: 0 failures in each mode, both before (7010 passed) and after the fix (7012 passed, the
2 new tests).
- The patch also applies cleanly to master for the merge-up.