Skip to content

Fix GH-24577: CE-cache flag on interned class name visible before map_ptr offset - #24585

Open
prateekbhujel wants to merge 2 commits into
php:PHP-8.4from
prateekbhujel:prateek/fix-gh24577-ce-cache-race
Open

prateekbhujel wants to merge 2 commits into
php:PHP-8.4from
prateekbhujel:prateek/fix-gh24577-ce-cache-race

Conversation

@prateekbhujel

@prateekbhujel prateekbhujel commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes GH-24577.

What was broken

Under concurrent traffic on a cold OPcache with opcache.interned_strings_buffer > 0, worker processes can race when one child persists class definitions that attach a CE-cache slot to an already-shared interned class-name string while another child looks up that same class name.

PHP reuses type_name->gc.refcount to store the map_ptr offset and tags the string with IS_STR_CLASS_NAME_MAP_PTR. However:

  1. In ext/opcache/zend_persist.c (zend_accel_pending_ce_cache_commit()), GC_SET_REFCOUNT and GC_ADD_FLAGS(type_name, IS_STR_CLASS_NAME_MAP_PTR) are plain non-atomic stores to adjacent struct fields (gc.refcount and gc.u.type_info), which compilers (e.g. GCC 12/15) or weakly ordered CPUs can emit or make visible with the flag store before the offset store. In Zend/zend.c (zend_alloc_ce_cache()), GC_ADD_FLAGS also preceded GC_SET_REFCOUNT in source order.
  2. ZSTR_VALID_CE_CACHE() in Zend/zend_types.h only checked (GC_REFCOUNT(s) - 1) / sizeof(void*) < CG(map_ptr_last). Because OPcache SHM interned strings initialize with refcount 2, (2 - 1) / sizeof(void*) == 0 < CG(map_ptr_last) evaluated to true.
  3. A reader observing IS_STR_CLASS_NAME_MAP_PTR while gc.refcount was still 2 treated 2 as a valid offset and dereferenced 8 unaligned bytes at (map_ptr_base + 2), crashing in object_init_ex (SIGSEGV).
  4. Similarly, ZSTR_SET_CE_CACHE_EX(s, ce, 0) (which bypasses normal validation) had no guard against writing to (map_ptr_base + 2) if encountered while gc.refcount <= 2.

The fix

  1. Harden ZSTR_VALID_CE_CACHE(): Check GC_REFCOUNT(s) > 2. Valid map_ptr offsets are strictly greater than 2 and always odd (k * sizeof(void*) + 1), so any refcount <= 2 is rejected as a cache miss without adding atomic barrier overhead to the hot class-lookup path.
  2. Harden ZSTR_SET_CE_CACHE_EX(): Guard the setter with EXPECTED(GC_REFCOUNT(s) > 2) so an uncommitted offset (<= 2) is never written to even when validate == 0.
  3. Source store order in Zend/zend.c: Order GC_SET_REFCOUNT before GC_ADD_FLAGS in zend_alloc_ce_cache() to match ext/opcache/zend_persist.c.
  4. Regression test: Add ext/opcache/tests/gh24577.phpt, which first caches a separate consumer script in OPcache SHM so the target class-name strings exist in the shared interned strings buffer with refcount = 2, then releases a barrier-synchronized Writer (persisting the class declarations) and Readers (probing the pre-existing shared interned strings).

When OPcache persists a script into shared memory, zend_accel_pending_ce_cache_commit()
(and zend_alloc_ce_cache() in Zend/zend.c) writes the allocated map_ptr offset
into type_name->gc.refcount and sets IS_STR_CLASS_NAME_MAP_PTR on the shared
interned string. Because these are plain non-atomic stores to adjacent struct
fields, the compiler or weakly ordered CPU can make IS_STR_CLASS_NAME_MAP_PTR
visible to concurrent worker processes before the map_ptr offset overwrites the
initial SHM interned string refcount of 2.

Previously, ZSTR_VALID_CE_CACHE() only checked
(GC_REFCOUNT(s) - 1) / sizeof(void *) < CG(map_ptr_last), which accepted
refcount 2 ((2 - 1) / 8 == 0 < CG(map_ptr_last)) and dereferenced an unaligned
address at map_ptr_base + 2.

Require GC_REFCOUNT(s) > 2 in ZSTR_VALID_CE_CACHE() and ZSTR_SET_CE_CACHE_EX(),
and order GC_SET_REFCOUNT before GC_ADD_FLAGS in zend_alloc_ce_cache().
@prateekbhujel
prateekbhujel force-pushed the prateek/fix-gh24577-ce-cache-race branch from 5c21092 to 4f811bd Compare October 24, 2026 03:47
@prateekbhujel
prateekbhujel changed the base branch from master to PHP-8.4 October 24, 2026 03:47

@ndossche ndossche left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think that if a solution using memory barriers is feasible, we should do that instead of the hardening. Nothing prevents the compiler or CPU from reordering the assignments again.
This will require adding barriers in a few places as there are multiple places where GC_ADD_FLAGS(type_name, IS_STR_CLASS_NAME_MAP_PTR); is done

Replace hardening check with explicit release and acquire memory barriers
to prevent compiler and CPU reordering between GC_SET_REFCOUNT and
GC_ADD_FLAGS(..., IS_STR_CLASS_NAME_MAP_PTR) across all interned class cache
writers and readers.
@prateekbhujel

Copy link
Copy Markdown
Contributor Author

Agreed, memory barriers are definitely the right approach here instead of the refcount guard.

I've reverted the ZSTR_VALID_CE_CACHE workaround and switched to release/acquire fences with ZEND_ATOMIC_FENCE_*:

  • ZSTR_HAS_CE_CACHE() now issues an acquire fence when IS_STR_CLASS_NAME_MAP_PTR is set.
  • Added release fences between GC_SET_REFCOUNT and IS_STR_CLASS_NAME_MAP_PTR across all writer sites (zend_persist.c, ZendAccelerator.c, and zend_alloc_ce_cache in zend.c, where the write order was also flipped so the refcount store happens first).

Verified the emitted store ordering in disassembly and ext/opcache/tests/gh24577.phpt is passing.

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.

OPcache: CE-cache flag on a shared interned class name becomes visible before its map_ptr offset; FPM child SIGSEGV on a cold cache

2 participants