Skip to content

php8.6: Fix set visibility bypass through a primed ZEND_ASSIGN_OBJ cache slot - #24566

Open
brainexe wants to merge 2 commits into
php:PHP-8.6from
brainexe:fix/assign-obj-cache-slot-visibility
Open

brainexe wants to merge 2 commits into
php:PHP-8.6from
brainexe:fix/assign-obj-cache-slot-visibility

Conversation

@brainexe

Copy link
Copy Markdown
Contributor

Since GH-22709 (94136cf), 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.
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)
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.

Since phpGH-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.
@anun333

anun333 commented Oct 26, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

Tested this alongside #24251, which fixes the __set() path of the same regression (GH-24250), each applied onto today's PHP-8.6 (a588565de24), debug builds:

Test PHP-8.6 #24251 this PR
gh24250.phpt (#24251) FAIL PASS PASS
cache_slot_magic_set.phpt (this PR) FAIL PASS PASS
cache_slot_clone_reinit.phpt (this PR) FAIL FAIL PASS

So this covers #24251's case as well, and the readonly-error path #24251 doesn't. Zend/tests/asymmetric_visibility, readonly_props and property_hooks pass on all three builds, and all of Zend/tests with this PR: 5,647 passed, 0 failed (PHP-8.6 alone: 5,645, 0).

The failing WINDOWS_X64_ZTS check isn't from this change: it stops at zend_jit_ir.c(542), jit_CONST_ADDR undefined, which PHP-8.6 fixed after this branch's base in 547a26bd170 ("JIT: Fix Windows build of zend_jit_ir.c"). A rebase should clear it.

How this was tested

Linux x86_64, gcc 13.3. Each tree is origin/PHP-8.6 with the PR's commit cherry-picked on top:

./buildconf --force
./configure --enable-debug --disable-cgi --disable-phpdbg --disable-fileinfo --enable-pcntl --enable-zend-test --enable-fpm
make -j4

The three tests copied out of the two PRs and run on each build with TEST_PHP_EXECUTABLE=sapi/cli/php sapi/cli/php run-tests.php -n -q <tests>; the folders with run-tests.php -n -q Zend/tests/asymmetric_visibility Zend/tests/readonly_props Zend/tests/property_hooks. The Windows log: job 119997760226 of run 39828385968.

@adapik adapik left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A NEWS entry is missing

Comment thread Zend/zend_object_handlers.c Outdated
return variable_ptr;

exit_reset_cache_slot:
if (cache_slot) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The slot reset is now written out twice (the exit_reset_cache_slot label and the new else branch). A small static helper would avoid the duplication and keep the invariant in one place. It can then be called from the readonly error, the asymmetric visibility error and the __set() branch, and the new label is no longer needed.

@adapik

adapik commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

Fix looks correct, left some minor comments.

One general concern: the ASSIGN_OBJ fast path from GH-22709 relies on an implicit invariant ("primed slot = write access was checked"), but the slot is primed before the checks, so every exit that skips them must remember to reset it. We've already have 3 such exits, and a future one will silently bring the bypass back.

It would be safer to make this hold by construction, e.g. prime the slot only after the checks pass, or separate the two things: the property lookup cache and a "write access verified" marker that only the fast path consults. That doesn't need to block this fix, but it deserves a follow-up for master.

Extract the cache slot reset into a static helper so the invariant
(the ZEND_ASSIGN_OBJ fast path skips the set visibility check on a
primed slot) is documented in one place, and call it from the readonly
error, the asymmetric visibility error and the __set() fallback. The
exit_reset_cache_slot label is no longer needed.
@anun333

anun333 commented Nov 3, 2026

Copy link
Copy Markdown

Quoted below: written with Claude, an LLM, as CONTRIBUTING.md asks us to mark. The builds and test runs behind it are on my machine; the commands to repeat them are under the fold.

Re-tested at the current head (c69e19f1d3, with the helper) on today's PHP-8.6 (5a6116fe34b). It applies with a NEWS-only conflict, and the results match my earlier comment:

Test PHP-8.6 this PR
cache_slot_magic_set.phpt FAIL PASS
cache_slot_clone_reinit.phpt FAIL PASS
gh24250.phpt from #24251 FAIL PASS
Zend/tests/asymmetric_visibility, readonly_props, property_hooks 299 passed, 0 failed 301 passed, 0 failed
Zend/tests 5,673 passed, 0 failed 5,675 passed, 0 failed

New since then, the JIT: with the tracing JIT and its hot counters at 1, all three tests fail on PHP-8.6 and pass with this PR. With the function JIT the two cache-slot tests do the same, while gh24250.phpt already passes on PHP-8.6. The three folders pass on both builds in both modes.

How this was tested

Linux x86_64, gcc 13.3, debug builds of origin/PHP-8.6 at 5a6116fe34b and of it with this PR's two commits cherry-picked (the NEWS conflict resolved to the branch's side):

./buildconf --force
./configure --enable-debug --disable-cgi --disable-phpdbg --disable-fileinfo --enable-pcntl --enable-zend-test --enable-fpm --with-curl --enable-mbstring --disable-mbregex --with-mysqli --with-pdo-mysql --with-openssl --with-bz2
make -j4

The three tests, copied out of the two PRs so both builds run the same files: TEST_PHP_EXECUTABLE=$PWD/sapi/cli/php sapi/cli/php run-tests.php -n -q <tests>, then again with -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M and either -d opcache.jit=function or -d opcache.jit=tracing with opcache.jit_hot_func, jit_hot_loop, jit_hot_return and jit_hot_side_exit set to 1. The folders and the suite with run-tests.php -n -q -j4.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants