Skip to content

PHP-FPM worker SIGSEGV: run_time_cache map_ptr offset beyond CG(map_ptr_last) when a class is reached through the CE cache #23637

Description

@msfukui

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() → 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)) {
        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:

  1. 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).
  2. 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.
  3. Worker B calls C::method(). zend_lookup_class_ex() returns immediately through the CE cache fast path (no extend).
  4. 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).
  5. 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).
  6. 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).
  • A single pool. No per-pool php_admin_value[extension] — the precondition of PHP-FPM SIGSEGV with OPcache, an observer, and an extension loaded per pool #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

  1. Start php-fpm (starting from an empty OPcache SHM is essential).
  2. From the moment it is up, send FastCGI requests with 60 concurrent clients (8 requests each, 480 requests in about 1.3 s).
  3. 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.
  4. 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-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
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.

  1. 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.
  2. 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.
  3. 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 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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions