PHP-FPM worker SIGSEGV: `run_time_cache` map_ptr offset beyond `CG(map_ptr_last)` when a class is reached through the CE cache
@devnexen y travaille déjà.
Depuis le 12/9/2026.
- Langage dominant
- C
- Étoiles
- 40.4k
- Forks
- 8.1k
- Merge moyen
- 2 j 13 h
- PR mergées (30 j)
- 96
Description
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 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:
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.c- persisting —
ext/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:
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; /* 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 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. - Worker B calls
C::method().zend_lookup_class_ex()returns immediately through the CE cache fast path (no extend). - The
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). - The value read is non-NULL garbage (measured:
0x4), sozend_init_func_run_time_cache()(Zend/zend_execute.c) skips initialisation and that garbage becomesEX(run_time_cache). - With an observer registered,
_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:
#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 methodReproC1307::m0is 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=1should be equivalent). - A single pool. No per-pool
php_admin_value[extension]— the precondition of #23355 is not present.
[www]
listen = 127.0.0.1:9000
pm = static
pm.max_children = 28
pm.max_requests = 10000
catch_workers_output = yes
opcache.enable=1
opcache.memory_consumption=64
opcache.interned_strings_buffer=4
opcache.max_accelerated_files=20011
opcache.max_wasted_percentage=1
opcache.file_update_protection=0
Code: generate 3,000 PHP files, each with a class that uses extends.
<?php
require_once __DIR__ . '/base.php'; // abstract class ReproBase { public static function baseStatic(int $x): int { … } }
class ReproC123 extends ReproBase {
public static function s0(int $x): int { return parent::baseStatic($x) + 0; }
public function m0(int $x): int { return self::s0($x) * 2; }
/* s1..s5 / m1..m5 likewise */
}
function repro_f123(int $x): int { $o = new ReproC123(); 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
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. - 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.
Reproduction harness (generator script, minimal FastCGI client, load loop): https://gist.github.com/msfukui/dcf8c5721138ad1d053665ca71254425
Notes for reproducing
- Keep the pool's privilege drop. We reproduce this with the production-shaped layout: master started as root,
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. - Use
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. - 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 -9between 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 |
opcache.memory_consumption / max_wasted_percentage |
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 ifZCSG(map_ptr_last) > CG(map_ptr_last), aszend_accel_inheritance_cache_get()already does. This needs a hook on the OPcache side. - Bounds-check
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. - 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.
- #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.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). - #18147 (closed) — a crash through
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. - #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. - #9871 (closed / fixed) — the crash site is exactly the same (
_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. - #13817 (closed / fixed) — "Segmentation fault for enabled observers after pass 4" (from an optimizer pass). #13772 (closed / fixed) — a JIT-only
EX(opline)problem. Both are different causes, and this issue still reproduces on 8.4.25, which contains both fixes. - #23050 (open) — a case where
EX(run_time_cache)is NULL (no OPcache, no JIT, single process). Similar symptom class, different problem.
PHP Version
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.
Guide de contribution
Ouvrir le guide de contribution
Par où commencer
- Lisez l'issue en entier, puis le guide de contribution du projet.
- Signalez en commentaire que vous la prenez — cela évite que deux personnes fassent le même travail.
- Forkez le dépôt et travaillez sur une branche.
- Ouvrez une pull request qui référence le numéro de l'issue.
Évaluation
Cette issue n'a pas encore été évaluée.