[php-src] Issue #24577: OPcache: CE-cache flag on a shared interned class name becomes visible before its map_ptr offset; FPM child SIGSEGV on a cold
cache
| From: | mago0 | Date: | Thu, 22 Oct 2026 19:59:26 +0000 |
| Subject: | [php-src] Issue #24577: OPcache: CE-cache flag on a shared interned class name becomes visible before its map_ptr offset; FPM child SIGSEGV on a cold cache |
||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253114@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24577
Author: mago0
### Description
With OPcache shared memory and
opcache.interned_strings_buffer > 0, a php-fpm child
can use the class-entry (CE) cache of a shared interned class-name string after another child has
set IS_STR_CLASS_NAME_MAP_PTR on it, but before that child has stored the map_ptr
offset in gc.refcount. It can occur only while the cache is cold and two or more
children compile at the same time.
Writer (PHP 8.3.36, zend_accel_pending_ce_cache_commit(),
[ext/opcache/zend_persist.c#L345-L346](https://github.com/php/php-src/blob/php-8.3.36/ext/opcache/zend_persist.c#L335-L351);
same two lines in zend_accel_get_class_name_map_ptr() in 8.3.35,
[L336-L337](https://github.com/php/php-src/blob/php-8.3.35/ext/opcache/zend_persist.c#L317-L342)):
```c
GC_SET_REFCOUNT(type_name, (uint32_t)Z_LVAL_P(zv));
GC_ADD_FLAGS(type_name, IS_STR_CLASS_NAME_MAP_PTR);
```
These are two plain stores with no barrier. The writer holds the SHM lock, but the reader takes no
lock
([Zend/zend_execute_API.c#L1151](https://github.com/php/php-src/blob/php-8.3.36/Zend/zend_execute_API.c#L1151-L1157),
macros
[Zend/zend_types.h#L861-L868](https://github.com/php/php-src/blob/php-8.3.36/Zend/zend_types.h#L861-L868)):
```c
if (ZSTR_HAS_CE_CACHE(name) && ZSTR_VALID_CE_CACHE(name)) {
ce_cache = GC_REFCOUNT(name);
ce = GET_CE_CACHE(ce_cache);
if (EXPECTED(ce)) return ce;
```
The compilers emit the stores in the reverse order: flag first, then offset. Disassembly of the
commit loop in opcache.so:
- php:8.3.36-fpm-alpine3.24 (GCC 15.2.0): orl $0x20,0x4(%rdx) then
mov %esi,(%rdx) (2 copies of the loop)
- php:8.3.36-fpm-bookworm (GCC 12.2.0): orl $0x20,0x4(%rdx) then mov
%eax,(%rdx)
- 8.3.35 Alpine (GCC 15.2.0) and Debian 12 (GCC 12.2.0) builds of
zend_accel_get_class_name_map_ptr(): the same order
A reader that sees the flag but the old refcount gets refcount 2. That is the value OPcache gives
every new SHM interned string (GC_SET_REFCOUNT(s, 2), ZendAccelerator.c L546).
ZSTR_VALID_CE_CACHE() accepts it: (2-1)/8 = 0 < CG(map_ptr_last).
GET_CE_CACHE(2) then reads 8 unaligned bytes at map_ptr_base + 2, a
non-null garbage pointer, and uses it as a zend_class_entry *. Note that
zend_accel_get_class_name_map_ptr() already avoids offsets <= 2
(while (ret <= 2)), so a refcount of 2 can never be a real offset.
**Crash we analysed (PHP 8.3.35, core dump):**
```
WARNING: [pool www] child 38 exited on signal 11 (SIGSEGV - core dumped) after 2.104541 seconds from
start
```
```
#7 object_init_ex+8 mov 0x1c(%rsi),%eax rsi = 0x3800007f7ffe2770 (non-canonical)
#8 ZEND_NEW handler
#9 execute_ex
#10 zend_generator_resume
#12 execute_ex
#13 zend_execute
#14 zend_execute_scripts
#15 php_execute_script
```
(Frames above #7 were a third-party crash handler that re-raises the signal.)
- PHP position: Symfony MicroKernelTrait::registerBundles(), yield new
$class();. Opline ZEND_FETCH_CLASS (CV $class) then
ZEND_NEW.
- $class is a shared interned string in OPcache SHM.
| Value in the core | |
|---|---|
| CG(map_ptr_real_base) | 0x7f7ffaebb070 |
| CG(map_ptr_base) | 0x7f7ffaebb06f |
| 8 bytes at map_ptr_base + 2 | 0x3800007f7ffe2770 |
| class entry passed to ZEND_NEW | 0x3800007f7ffe2770 |
| string gc.refcount at dump time | 44105 (slot 5513) |
| string flags at dump time | 0x170 (interned, permanent, IS_STR_CLASS_NAME_MAP_PTR) |
| CG(map_ptr_last) of the crashed process | 5513 |
So the lookup ran with refcount 2. Another process stored the real offset (44105) afterwards, for a
slot one past this process's map_ptr_last.
**Sequence (inferred from the values above):**
1. Child A persists a script that names the class and sets the flag on the shared string.
2. Child B runs new $class on the same string. It sees the flag and refcount 2, and
gets a garbage class entry.
3. Child B crashes in object_init_ex with a general protection fault (SIGSEGV,
SI_KERNEL).
### Steps to reproduce
Natural rate (stock build):
- Set up php-fpm with OPcache SHM and interned_strings_buffer=8:
pm=dynamic, 40 start servers, max 80.
- Use a Symfony 5.4 app whose kernel boot instantiates classes by dynamic name.
- Start from a cold cache and send 40 concurrent first requests.
- Result: 1 crash in 4,763 cold starts (792,967 requests). In a Kubernetes environment with the same
image and lower concurrency we saw about 1 crash per 531 pool starts on 8.3.35. We did not get a
core there, so we cannot confirm it was the same race.
Amplified rate:
- Use a copy of opcache.so with only a delay (about 2,000 pause
instructions) inserted between the flag store and the offset store.
- Result: a crash in 30 of 30 cold starts with interned_strings_buffer=8, and in 0 of
180 with interned_strings_buffer=0.
- A source-level way to widen the window (not tried in this form): swap the two stores in the writer
and add a short spin between them.
Resulted in this output:
```
WARNING: [pool www] child N exited on signal 11 (SIGSEGV - core dumped)
```
But I expected this output instead: no crash.
### Workaround
opcache.interned_strings_buffer=0: no shared interned strings, so every string the
writer marks belongs to the script being stored and is not yet visible to other processes. Measured
cost in our app: no change in CPU per request, startup time or memory.
### Suggested fix
Either:
- Publish the offset with release ordering before the flag (ZEND_ATOMIC_FENCE/release
store between the two writes, acquire on the reader), or
- In ZSTR_VALID_CE_CACHE(), also reject GC_REFCOUNT(s) <= 2, since a
real offset is always > 2.
On weakly ordered CPUs the hardware can also reorder the stores even when the compiler does not (not
tested).
### Notes
- We reproduced the crash on 8.3.35. On 8.3.36 the code moved to
zend_accel_pending_ce_cache_commit() (commit 3584a98bda0f), but the two stores and
their compiled order are unchanged. We have not re-run the reproducer on 8.3.36.
- The same writer lines are on PHP-8.4 and master (source only; compiled
order not checked).
- Related but different: #23637 / PR #23881 (CE-cache hit without map_ptr extend), #23355 (per-pool
extensions), bug #81607.
- Loaded extensions: opcache, redis 6.3.0, pdo_mysql, zip, gd, bcmath, sockets, and Datadog
dd-trace-php 1.25.1 (tracer, profiler, appsec). The crash occurred with the tracer disabled and the
profiler and appsec enabled. We have not tried without third-party extensions. The mechanism above
does not need an observer.
- JIT off (opcache.jit_buffer_size=0), no preload, no file cache,
opcache.enable_cli=0.
Relevant ini at the time of the crash:
```
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0
opcache.huge_code_pages=1
```
### PHP Version
PHP 8.3.35 (NTS, crash reproduced, core analysed). PHP 8.3.36 (same source order and same compiled
order, not re-run).
### Operating System
Alpine Linux 3.24 (musl 1.2.6), x86_64, official php:8.3-fpm-alpine3.24 Docker image,
opcache built with docker-php-ext-install (GCC 15.2.0). The same store order is in
php:8.3.36-fpm-bookworm (GCC 12.2.0).