You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
On a multi-worker PHP-FPM setup with OPcache, while the OPcache SHM is still being filled — that is, right after workers start — a worker can read a map_ptr slot that lies beyond its own CG(map_ptr_last) and use the uninitialised value it finds there as a run_time_cache, which segfaults.
Three things combine. (1) The CE cache fast path in zend_lookup_class_ex() does not synchronise with ZCSG(map_ptr_last) (no extend) when it returns a class that lives in SHM. (2) ZEND_MAP_PTR_GET() has no bounds check. (3) zend_init_func_run_time_cache() treats "not NULL" as "already initialised". When an fcall observer is registered, ZEND_DO_FCALL_SPEC_OBSERVER_HANDLER dereferences that pointer immediately afterwards, which turns the corrupted slot into a deterministic crash.
With an application of a few thousand classes that use extends, 28 workers, a cold SHM and concurrent traffic, this happens roughly once every 100-300 pool starts (one pool start = one php-fpm master and its 28 workers).
Resulted in this output:
WARNING: [pool www] child 47823 exited on signal 11 (SIGSEGV) after 0.585071 seconds from start
But I expected this output instead:
(no crash)
Precautions
An LLM was used for the investigation, the disassembly analysis and the writing of this report. All measurements, core dump values and code references were verified against real runs and the php-src sources.
Details
Root cause
OPcache synchronises the SHM-side allocation counter ZCSG(map_ptr_last) with the worker's CG(map_ptr_last) in these places:
persisting — ext/opcache/zend_persist.c (extend → ZEND_MAP_PTR_NEW() → publish ZCSG(map_ptr_last) = CG(map_ptr_last), all under the SHM lock)
The CE cache fast path in zend_lookup_class_ex() (Zend/zend_execute_API.c) does not:
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)) {
returnce; /* no zend_map_ptr_extend() here */
}
}
ZSTR_VALID_CE_CACHE() (Zend/zend_types.h) bounds-checks only the index of the CE cache slot itself. It does not validate the run_time_cache offsets of the methods of the class it returns. The interned string that carries the CE cache offset lives in SHM and is shared by every worker, so as soon as one worker fills the slot via ZSTR_SET_CE_CACHE_EX() (in zend_accel_inheritance_cache_get() in ext/opcache/ZendAccelerator.c, and in ext/opcache/zend_accelerator_util_funcs.c), every other worker sees it immediately, including workers that have not synchronised yet.
The order that produces the crash:
Worker B loads a script from SHM and extends up to the ZCSG(map_ptr_last) of that moment (measured: CG(map_ptr_last) = 20906).
Worker A links class C (ZEND_ACC_CACHEABLE) and persists it into SHM through zend_accel_inheritance_cache_add(). It allocates slots 20906, 20907, … for the run_time_cache of C's methods, publishes ZCSG(map_ptr_last), and writes the SHM zend_class_entry into the CE cache slot of C's name.
Worker B calls C::method(). zend_lookup_class_ex() returns immediately through the CE cache fast path (no extend).
The run_time_cache offset of C::method (slot 20907) is outside B's CG(map_ptr_last). Since ZEND_MAP_PTR_GET() does not bounds-check, B reads the uninitialised part of the allocation described by CG(map_ptr_size) (rounded up to 4096-entry granularity).
The value read is non-NULL garbage (measured: 0x4), so zend_init_func_run_time_cache() (Zend/zend_execute.c) skips initialisation and that garbage becomes EX(run_time_cache).
With an observer registered, _zend_observe_fcall_begin() (Zend/zend_observer.c) dereferences run_time_cache[zend_observer_fcall_op_array_extension] and segfaults.
Evidence from core dumps
We captured one core on PHP 8.4.25 and two on PHP 8.2.33. The one below is from 8.4.25, with debug symbols:
#5 <signal handler called>
#6 zend_observer_fcall_begin_specialized (allow_generator=false, execute_data=0xffff8a212210)
at Zend/zend_observer.h:92
#7 ZEND_DO_FCALL_SPEC_OBSERVER_HANDLER () at Zend/zend_vm_execute.h:2115
#8 execute_ex (ex=0xffff8a212210) at Zend/zend_vm_execute.h:58972
#9 zend_execute (op_array=0xffff8a261200, return_value=0x0)
at Zend/zend_vm_execute.h:64349
#10 zend_execute_script (type=8, retval=0x0, file_handle=0x0)
at Zend/zend.c:1937
#11 php_execute_script_ex (primary_file=0x0, retval=0x0)
at main/main.c:2577
#12 php_execute_script (primary_file=0xfffff57380f0) at main/main.c:2617
#13 main () at sapi/fpm/fpm/fpm_main.c:1942
Frames #0-#4 are the tracer's crash handler catching SIGSEGV and re-raising it with raise(). That is why the siginfo stored in the core describes the re-raised signal (si_code = SI_TKILL); the original faulting address is derived from the slot value as shown below. In production the crash handler itself reports si_addr = 0x14, which matches that derivation.
Values read from the three cores:
8.4.25 core
8.2.33 core A
8.2.33 core B
crashing function
ReproC1307::m0 (generated)
ReproC2466::m0 (generated)
same shape
slot index of that op_array's run_time_cache
27469
20907
26843
worker's CG(map_ptr_last)
27468
20906
26842
SHM-side ZCSG(map_ptr_last)
42208
46538
46538
CG(map_ptr_size)
28672
24576
28672
value read from the slot
0x4
0x4
0x4
zend_observer_fcall_op_array_extension
2
2
2
address the observer dereferenced
0x14 = 0x4 + 8 × 2
same
same
class ce_flags
0x421288
0x421288
same
In all three, slot index == CG(map_ptr_last) + 1 — exactly one entry past the live range — while ZCSG(map_ptr_last) is far ahead of the worker. ce_flags = 0x421288 is ZEND_ACC_IMMUTABLE | ZEND_ACC_LINKED | ZEND_ACC_CACHED, i.e. a class placed into SHM through the inheritance cache.
The decisive detail is that op_arrays from the same file fall on different sides of the boundary:
8.4.25 core: the caller repro_f1307(), a file-scope function in the same /repro/app/c1307.php, is slot 27467 → in range. The crashing method ReproC1307::m0 is slot 27469 → out of range.
8.2.33 core A: the script's own op_array is slot 20905 → in range. The class method is slot 20907 → out of range.
So the worker did extend far enough for the file it loaded, and only the method slots that another worker allocated afterwards through the inheritance cache are out of range.
How to reproduce
This does not reproduce every time: it is roughly once every 100-300 pool starts. We could not reproduce it with a short self-contained script — it needs several workers racing to fill a cold SHM.
Setup
OPcache enabled. No JIT, no preload, no opcache.file_cache.
An extension that registers an fcall observer is loaded (anything calling zend_observer_fcall_register(); we used the Datadog PHP tracer 1.23.3. zend_test.observer.enabled=1 should be equivalent).
Code: generate 3,000 PHP files, each with a class that uses extends.
<?phprequire_once__DIR__ . '/base.php'; // abstract class ReproBase { public static function baseStatic(int $x): int { … } }class ReproC123 extends ReproBase {
publicstaticfunctions0(int$x): int { returnparent::baseStatic($x) + 0; }
publicfunctionm0(int$x): int { returnself::s0($x) * 2; }
/* s1..s5 / m1..m5 likewise */
}
functionrepro_f123(int$x): int { $o = newReproC123(); return$o->m0($x) + ReproC123::s1($x); }
The entry point require_onces 40 of those files per request and calls each function and static method.
Run
Start php-fpm (starting from an empty OPcache SHM is essential).
From the moment it is up, send FastCGI requests with 60 concurrent clients (8 requests each, 480 requests in about 1.3 s).
While the burst runs, keep invalidating files by updating their mtime with touch so that scripts are re-persisted. Do not append to the files: they grow on every iteration, the op_array sizes change, and the crash rate drops.
Stop php-fpm and go back to 1.
At 0.3-0.9% per pool start, a few hundred to 1,500 iterations produce a handful of exited on signal 11 (SIGSEGV) lines. The crash always happens 0.2-2.0 s after the worker started.
Keep the pool's privilege drop. We reproduce this with the production-shaped layout: master started as root, user = www-data in the pool. Starting the master as a non-root user gave 0 crashes in 1,200 pool starts, and -R with user = root gave 0 in 600. We have not identified the mechanism behind that difference, but do not "simplify" the setup by removing the privilege change.
Use fs.suid_dumpable = 2 to get cores. A worker that drops privileges is not dumpable under the default setting. We recommend this over touching the PHP side: calling prctl(PR_SET_DUMPABLE, 1) from FFI also works mechanically, but even once per worker it adds work inside the very window we are trying to hit, and with that hook in place we saw no crash in 350 pool starts.
Do not let a previous php-fpm master survive between iterations. If an older master still holds the listening socket, the next iteration's requests are served by that already-warm process and you get zero crashes with no error of any kind. This cost us a full day of measurements. pkill -9 between iterations, wait until the port is free, and assert that exactly one master is running.
The core does not land in chdir. PHP changes the working directory to the directory of the executing script during a request, so also look next to the entry point script.
A/B on the presence of the observer (same setup, only the observer changed)
Two experiments, same result. The 8.5 one uses a paired design: every iteration runs the "observer" arm and the "no observer" arm back to back, so host load drift hits both arms equally.
PHP
design
observer loaded
not loaded
one-sided p
8.5.10
paired, 700 pool starts per arm
5
0
0.031
8.2.33
sequential, 1,500 each
8
0
0.004
This experiment alone cannot tell whether the same out-of-range read also happens without an observer and merely stays latent, or whether the observer's overhead widens the race window.
We did not run this A/B on 8.4.25: that environment was used for the core dump, and its per-pool-start rate is lower than 8.5.10's (0.33% vs 0.86%), so a realistic number of iterations would not separate the arms. The code that constitutes the defect is identical across versions, and 8.4.25 does reproduce under the same conditions — that is where the core above comes from.
Affected versions
Measured (same harness throughout; the observed rate depends heavily on harness settings, so please read this as "it happens on every version" rather than as absolute numbers)
PHP
pool starts
SIGSEGV
rate
8.5.10
1,400
12
0.86%
8.4.25
600
2
0.33%
8.2.33
1,500
8
0.53%
The 8.5.10 and 8.4.25 numbers come from the harness with the guard that makes sure php-fpm is really stopped between iterations. 8.4.25 also gave 10 crashes in 1,500 pool starts (0.67%) on the earlier harness. The 8.2.33 number is from that earlier harness too, which appended to the files on every invalidation and may therefore understate the rate (i.e. it is a lower bound).
8.5.10 is the sury.org package (in PHP 8.5 OPcache ships with the core, there is no separate package) plus the Datadog PHP tracer 1.23.3.
Static check: the four ingredients of the defect are identical from PHP-8.1 to master (8.6.0-dev).
8.1
8.2
8.3
8.4
8.5
master
ZEND_MAP_PTR_GET_IMM bounds-checks
no
no
no
no
no
no
ZSTR_VALID_CE_CACHE validates only the CE slot itself
✓
✓
✓
✓
✓
✓
CE cache fast path returns without extending
✓
✓
✓
✓
✓
✓
zend_init_func_run_time_cache trusts non-NULL
✓
✓
✓
✓
✓
✓
#15040 (25d761623c, moving the internal-function run-time cache to a separate area), which resolved #23355, does not cover this path — which is what reproducing on 8.4 shows.
Workarounds we verified
Measured on PHP 8.5.10. (The report is centred on 8.4.25, but verifying workarounds needs the version with the highest per-pool-start rate: 0.86% on 8.5.10 versus 0.33% on 8.4.25.) Every iteration runs the "no workaround" arm and the "with workaround" arm back to back, so host load drift hits both arms equally. Nothing about how these workarounds act is version-specific: they either fill the SHM before traffic arrives, or they remove the observer.
workaround
pool starts per arm
no workaround
with workaround
one-sided p
warm the SHM from a single process before serving traffic
250
5
0
0.031
do not load the extension that registers the fcall observer
700
5
0
0.031
use opcache.preload
450
2
0
0.25 (underpowered)
Preload produced no crash in 450 pool starts, but the baseline arm produced only 2 crashes over the same period, so this arm is underpowered rather than proven. Its mechanism is the same as the warm-up (fill the SHM before traffic arrives), so the result is at least consistent.
OPcache parameters do not remove the defect, but they move the exposure up and down. They are worth listing because they explain why the rate varies so much between environments.
parameter
effect on exposure
opcache.interned_strings_buffer
raising it increases exposure. CE cache slots are allocated together with the interned string of the class name (ext/opcache/zend_persist.c), so when the buffer is exhausted no CE cache is created and the fast path in question is never taken
opcache.max_accelerated_files
exhausting it decreases exposure (scripts over the limit are not persisted, so no new slots are allocated), at the cost of recompiling on every request
a restart is only scheduled when wasted / memory_consumption >= max_wasted_percentage (ext/opcache/ZendAccelerator.c); a restart rewinds the counters with zend_map_ptr_reset() plus ZCSG(map_ptr_last) = CG(map_ptr_last)
opcache.file_cache
may make it worse: zend_file_cache_unserialize_op_array() (ext/opcache/zend_file_cache.c) calls ZEND_MAP_PTR_NEW() in the reading worker
opcache.validate_timestamps=0
stops new allocations in steady state (but not in the window right after startup)
In other words, environments with a small interned strings buffer, or one that has hit its key limit, are less likely to run into this bug.
Suggested fix directions
These involve design decisions, so we only sketch directions.
Synchronise in the CE cache fast path: when zend_lookup_class_ex() returns an SHM class from the CE cache, extend if ZCSG(map_ptr_last) > CG(map_ptr_last), as zend_accel_inheritance_cache_get() already does. This needs a hook on the OPcache side.
Bounds-check ZEND_MAP_PTR_GET(): at least assert offset < CG(map_ptr_last) in debug builds. If an out-of-range offset could be treated as NULL, zend_init_func_run_time_cache() would initialise the cache properly and the failure would be safe.
Reorder the publication of the CE cache slot: only publish the SHM class entry into the CE cache once the map_ptr offsets of that class's methods are valid for every worker.
One note on the extension side: there are implementations that trust the slot contents after only a non-NULL RUN_TIME_CACHE() check and write to it (we verified this in the Datadog PHP tracer), so this defect can lead to memory corruption, not just a read crash.
Related issues
We searched php-src issues for map_ptr / run_time_cache / observer related terms to check for duplicates. We found no existing report that describes this mechanism.
PHP-FPM segfaults with Opcache enabled with Late Static Binding #9396 (open) — "PHP-FPM segfaults with Opcache enabled with Late Static Binding". This may well be the observer-less manifestation of the same bug. FPM only, OPcache-dependent, "in production core dumps other handlers in Zend/zend_vm_execute.h crash the same way", and no minimal reproduction — all of which fits this mechanism (garbage obtained by an out-of-range read is used as the run_time_cache, and whichever handler dereferences it first is where it crashes).
php 8.4.5 crashed when opcache is enable - segfault #18147 (closed) — a crash through zend_lookup_class_ex on 8.4.5 / FPM / OPcache. Possibly the same thing, but after "This stack trace isn't very telling" it was suspended for lack of information. This report supplies what was missing: a reproduction, core dumps, and the invariant being violated.
PHP-FPM SIGSEGV with OPcache, an observer, and an extension loaded per pool #23355 (open) — the same family of defect (a map_ptr offset persisted into SHM means something else to the reader, made visible by an observer), but a different arming path. That one requires multiple pools plus per-pool php_admin_value[extension], and was reported as not applying to 8.4. This one needs nothing but plain multi-worker FPM with OPcache, and reproduces on 8.4.25.
PHP 8.4.25 (fpm-fcgi) (built: Aug 28 2026 07:31:01) (NTS)
Copyright (c) The PHP Group
Built by Debian
Zend Engine v4.4.25, Copyright (c) Zend Technologies
with Zend OPcache v8.4.25, Copyright (c), by Zend Technologies
with ddtrace v1.23.3, Copyright Datadog, by Datadog
with datadog-profiling v1.23.3, Copyright Datadog, by Datadog
with ddappsec v1.23.3, Copyright Datadog, by Datadog
(The core dump shown above was taken on this 8.4.25. The same harness reproduces on 8.5.10 and 8.2.33, and we captured two more cores on 8.2.33.)
Operating System
Debian 12 (bookworm), aarch64, glibc 2.36 — inside a Docker container (kernel 7.0.12-linuxkit) on macOS 26.6.2 / Apple M2 with Docker Desktop 29.7.2.
Description
Overview
On a multi-worker PHP-FPM setup with OPcache, while the OPcache SHM is still being filled — that is, right after workers start — a worker can read a map_ptr slot that lies beyond its own
CG(map_ptr_last)and use the uninitialised value it finds there as arun_time_cache, which segfaults.Three things combine. (1) The CE cache fast path in
zend_lookup_class_ex()does not synchronise withZCSG(map_ptr_last)(no extend) when it returns a class that lives in SHM. (2)ZEND_MAP_PTR_GET()has no bounds check. (3)zend_init_func_run_time_cache()treats "not NULL" as "already initialised". When an fcall observer is registered,ZEND_DO_FCALL_SPEC_OBSERVER_HANDLERdereferences that pointer immediately afterwards, which turns the corrupted slot into a deterministic crash.With an application of a few thousand classes that use
extends, 28 workers, a cold SHM and concurrent traffic, this happens roughly once every 100-300 pool starts (one pool start = one php-fpm master and its 28 workers).Resulted in this output:
But I expected this output instead:
Precautions
Details
Root cause
OPcache synchronises the SHM-side allocation counter
ZCSG(map_ptr_last)with the worker'sCG(map_ptr_last)in these places:zend_accel_load_script()—ext/opcache/zend_accelerator_util_funcs.c(if (ZCSG(map_ptr_last) > CG(map_ptr_last)) zend_map_ptr_extend(...))zend_accel_inheritance_cache_get()/_add()—ext/opcache/ZendAccelerator.cext/opcache/zend_persist.c(extend →ZEND_MAP_PTR_NEW()→ publishZCSG(map_ptr_last) = CG(map_ptr_last), all under the SHM lock)The CE cache fast path in
zend_lookup_class_ex()(Zend/zend_execute_API.c) does not:ZSTR_VALID_CE_CACHE()(Zend/zend_types.h) bounds-checks only the index of the CE cache slot itself. It does not validate therun_time_cacheoffsets of the methods of the class it returns. The interned string that carries the CE cache offset lives in SHM and is shared by every worker, so as soon as one worker fills the slot viaZSTR_SET_CE_CACHE_EX()(inzend_accel_inheritance_cache_get()inext/opcache/ZendAccelerator.c, and inext/opcache/zend_accelerator_util_funcs.c), every other worker sees it immediately, including workers that have not synchronised yet.The order that produces the crash:
ZCSG(map_ptr_last)of that moment (measured:CG(map_ptr_last) = 20906).C(ZEND_ACC_CACHEABLE) and persists it into SHM throughzend_accel_inheritance_cache_add(). It allocates slots 20906, 20907, … for therun_time_cacheofC's methods, publishesZCSG(map_ptr_last), and writes the SHMzend_class_entryinto the CE cache slot ofC's name.C::method().zend_lookup_class_ex()returns immediately through the CE cache fast path (no extend).run_time_cacheoffset ofC::method(slot 20907) is outside B'sCG(map_ptr_last). SinceZEND_MAP_PTR_GET()does not bounds-check, B reads the uninitialised part of the allocation described byCG(map_ptr_size)(rounded up to 4096-entry granularity).0x4), sozend_init_func_run_time_cache()(Zend/zend_execute.c) skips initialisation and that garbage becomesEX(run_time_cache)._zend_observe_fcall_begin()(Zend/zend_observer.c) dereferencesrun_time_cache[zend_observer_fcall_op_array_extension]and segfaults.Evidence from core dumps
We captured one core on PHP 8.4.25 and two on PHP 8.2.33. The one below is from 8.4.25, with debug symbols:
Frames #0-#4 are the tracer's crash handler catching SIGSEGV and re-raising it with
raise(). That is why thesiginfostored in the core describes the re-raised signal (si_code = SI_TKILL); the original faulting address is derived from the slot value as shown below. In production the crash handler itself reportssi_addr = 0x14, which matches that derivation.Values read from the three cores:
ReproC1307::m0(generated)ReproC2466::m0(generated)run_time_cacheCG(map_ptr_last)ZCSG(map_ptr_last)CG(map_ptr_size)0x40x40x4zend_observer_fcall_op_array_extension0x14=0x4 + 8 × 2ce_flags0x4212880x421288In all three, slot index ==
CG(map_ptr_last)+ 1 — exactly one entry past the live range — whileZCSG(map_ptr_last)is far ahead of the worker.ce_flags = 0x421288isZEND_ACC_IMMUTABLE | ZEND_ACC_LINKED | ZEND_ACC_CACHED, i.e. a class placed into SHM through the inheritance cache.The decisive detail is that op_arrays from the same file fall on different sides of the boundary:
repro_f1307(), a file-scope function in the same/repro/app/c1307.php, is slot 27467 → in range. The crashing methodReproC1307::m0is slot 27469 → out of range.So the worker did extend far enough for the file it loaded, and only the method slots that another worker allocated afterwards through the inheritance cache are out of range.
How to reproduce
This does not reproduce every time: it is roughly once every 100-300 pool starts. We could not reproduce it with a short self-contained script — it needs several workers racing to fill a cold SHM.
Setup
opcache.file_cache.zend_observer_fcall_register(); we used the Datadog PHP tracer 1.23.3.zend_test.observer.enabled=1should be equivalent).php_admin_value[extension]— the precondition of PHP-FPM SIGSEGV with OPcache, an observer, and an extension loaded per pool #23355 is not present.Code: generate 3,000 PHP files, each with a class that uses
extends.The entry point
require_onces 40 of those files per request and calls each function and static method.Run
touchso that scripts are re-persisted. Do not append to the files: they grow on every iteration, the op_array sizes change, and the crash rate drops.At 0.3-0.9% per pool start, a few hundred to 1,500 iterations produce a handful of
exited on signal 11 (SIGSEGV)lines. The crash always happens 0.2-2.0 s after the worker started.Reproduction harness (generator script, minimal FastCGI client, load loop): https://gist.github.com/msfukui/dcf8c5721138ad1d053665ca71254425
Notes for reproducing
user = www-datain the pool. Starting the master as a non-root user gave 0 crashes in 1,200 pool starts, and-Rwithuser = rootgave 0 in 600. We have not identified the mechanism behind that difference, but do not "simplify" the setup by removing the privilege change.fs.suid_dumpable = 2to get cores. A worker that drops privileges is not dumpable under the default setting. We recommend this over touching the PHP side: callingprctl(PR_SET_DUMPABLE, 1)from FFI also works mechanically, but even once per worker it adds work inside the very window we are trying to hit, and with that hook in place we saw no crash in 350 pool starts.pkill -9between iterations, wait until the port is free, and assert that exactly one master is running.chdir. PHP changes the working directory to the directory of the executing script during a request, so also look next to the entry point script.A/B on the presence of the observer (same setup, only the observer changed)
Two experiments, same result. The 8.5 one uses a paired design: every iteration runs the "observer" arm and the "no observer" arm back to back, so host load drift hits both arms equally.
This experiment alone cannot tell whether the same out-of-range read also happens without an observer and merely stays latent, or whether the observer's overhead widens the race window.
We did not run this A/B on 8.4.25: that environment was used for the core dump, and its per-pool-start rate is lower than 8.5.10's (0.33% vs 0.86%), so a realistic number of iterations would not separate the arms. The code that constitutes the defect is identical across versions, and 8.4.25 does reproduce under the same conditions — that is where the core above comes from.
Affected versions
Measured (same harness throughout; the observed rate depends heavily on harness settings, so please read this as "it happens on every version" rather than as absolute numbers)
The 8.5.10 and 8.4.25 numbers come from the harness with the guard that makes sure php-fpm is really stopped between iterations. 8.4.25 also gave 10 crashes in 1,500 pool starts (0.67%) on the earlier harness. The 8.2.33 number is from that earlier harness too, which appended to the files on every invalidation and may therefore understate the rate (i.e. it is a lower bound).
8.5.10 is the sury.org package (in PHP 8.5 OPcache ships with the core, there is no separate package) plus the Datadog PHP tracer 1.23.3.
Static check: the four ingredients of the defect are identical from
PHP-8.1tomaster(8.6.0-dev).ZEND_MAP_PTR_GET_IMMbounds-checksZSTR_VALID_CE_CACHEvalidates only the CE slot itselfzend_init_func_run_time_cachetrusts non-NULL#15040 (
25d761623c, moving the internal-function run-time cache to a separate area), which resolved #23355, does not cover this path — which is what reproducing on 8.4 shows.Workarounds we verified
Measured on PHP 8.5.10. (The report is centred on 8.4.25, but verifying workarounds needs the version with the highest per-pool-start rate: 0.86% on 8.5.10 versus 0.33% on 8.4.25.) Every iteration runs the "no workaround" arm and the "with workaround" arm back to back, so host load drift hits both arms equally. Nothing about how these workarounds act is version-specific: they either fill the SHM before traffic arrives, or they remove the observer.
opcache.preloadPreload produced no crash in 450 pool starts, but the baseline arm produced only 2 crashes over the same period, so this arm is underpowered rather than proven. Its mechanism is the same as the warm-up (fill the SHM before traffic arrives), so the result is at least consistent.
OPcache parameters do not remove the defect, but they move the exposure up and down. They are worth listing because they explain why the rate varies so much between environments.
opcache.interned_strings_bufferext/opcache/zend_persist.c), so when the buffer is exhausted no CE cache is created and the fast path in question is never takenopcache.max_accelerated_filesopcache.memory_consumption/max_wasted_percentagewasted / memory_consumption >= max_wasted_percentage(ext/opcache/ZendAccelerator.c); a restart rewinds the counters withzend_map_ptr_reset()plusZCSG(map_ptr_last) = CG(map_ptr_last)opcache.file_cachezend_file_cache_unserialize_op_array()(ext/opcache/zend_file_cache.c) callsZEND_MAP_PTR_NEW()in the reading workeropcache.validate_timestamps=0In other words, environments with a small interned strings buffer, or one that has hit its key limit, are less likely to run into this bug.
Suggested fix directions
These involve design decisions, so we only sketch directions.
zend_lookup_class_ex()returns an SHM class from the CE cache, extend ifZCSG(map_ptr_last) > CG(map_ptr_last), aszend_accel_inheritance_cache_get()already does. This needs a hook on the OPcache side.ZEND_MAP_PTR_GET(): at least assertoffset < CG(map_ptr_last)in debug builds. If an out-of-range offset could be treated as NULL,zend_init_func_run_time_cache()would initialise the cache properly and the failure would be safe.One note on the extension side: there are implementations that trust the slot contents after only a non-NULL
RUN_TIME_CACHE()check and write to it (we verified this in the Datadog PHP tracer), so this defect can lead to memory corruption, not just a read crash.Related issues
We searched php-src issues for
map_ptr/run_time_cache/ observer related terms to check for duplicates. We found no existing report that describes this mechanism.Zend/zend_vm_execute.hcrash the same way", and no minimal reproduction — all of which fits this mechanism (garbage obtained by an out-of-range read is used as therun_time_cache, and whichever handler dereferences it first is where it crashes).zend_lookup_class_exon 8.4.5 / FPM / OPcache. Possibly the same thing, but after "This stack trace isn't very telling" it was suspended for lack of information. This report supplies what was missing: a reproduction, core dumps, and the invariant being violated.php_admin_value[extension], and was reported as not applying to 8.4. This one needs nothing but plain multi-worker FPM with OPcache, and reproduces on 8.4.25._zend_observe_fcall_begin→ZEND_DO_FCALL_SPEC_OBSERVER_HANDLER), but the cause was a user class inheriting an internal method, and it reproduced deterministically in 12 lines. The fix is in 8.4.25.execute_data->oplinepointers in observer fcall handlers when JIT is enabled #13772 (closed / fixed) — a JIT-onlyEX(opline)problem. Both are different causes, and this issue still reproduces on 8.4.25, which contains both fixes.execute_ex:EX(run_time_cache)is NULL for a trait-copiedprivate staticmethod (no opcache, no JIT) #23050 (open) — a case whereEX(run_time_cache)is NULL (no OPcache, no JIT, single process). Similar symptom class, different problem.PHP Version
Operating System
Debian 12 (bookworm), aarch64, glibc 2.36 — inside a Docker container (kernel 7.0.12-linuxkit) on macOS 26.6.2 / Apple M2 with Docker Desktop 29.7.2.