Skip to content

PHP-FPM SIGSEGV with OPcache, an observer, and an extension loaded per pool #23355

Description

@nyrzhun

Description

When two PHP-FPM pools share one OPcache SHM segment, but only one pool loads an
extension through php_admin_value[extension], the pools can assign different
meanings to the same map_ptr offset.

If an fcall observer is also registered, a class-entry cache offset persisted by
one pool can refer to an internal-function run-time-cache slot in the other pool.
A class lookup may then treat the run-time-cache allocation as a
zend_class_entry *. A subsequent static method lookup dereferences its invalid
function_table and the FPM worker terminates with SIGSEGV.

The reproducer below uses posix as the pool-local extension and zend_test as
the observer. There does not appear to be anything specific to either extension:
posix merely registers enough internal functions for the two ranges to overlap.
In production we see this with imagick (581 internal methods) as the pool-local
extension and an APM extension providing the observer, on shared-hosting
configurations where per-pool extension= lines are common and documented.

Build

Both reproducers need the same build. posix must be shared, because that is
what the second pool loads after the master has forked:

./buildconf --force
./configure --disable-all --enable-opcache --enable-zend-test --enable-fpm \
            --enable-posix=shared
make -j"$(nproc)"

On Debian/Ubuntu the prerequisites are build-essential autoconf bison re2c pkg-config, plus libfcgi-bin for the cgi-fcgi client used below.

Reproducer 1: shell, ~30 seconds

This one is worth running first because it shows the actual signal.

min.ini:

zend_extension=/path/to/build/modules/opcache.so
extension_dir=/opt/mapptr/modules

opcache.enable=1
opcache.jit=off
; REQUIRED: the seed file is written moments before the first request, and the
; default 2s protection window would stop OPcache from ever caching it
opcache.file_update_protection=0

zend_test.observer.enabled=1
zend_test.observer.show_output=0

fpm.conf — one master, one shared OPcache SHM, two pools:

[global]
error_log = /dev/shm/mapptr/fpm-error.log
daemonize = yes

; pool WITHOUT the pool-local extension: its map_ptr numbering stays at the
; engine baseline, and it is the pool that persists the class into SHM
[seed]
user = nobody
listen = /dev/shm/mapptr/seed.sock
listen.mode = 0666
pm = static
pm.max_children = 1
chdir = /dev/shm/mapptr/www

; pool WITH a pool-local extension loaded post-fork: its internal functions own
; the map_ptr band that the seed pool's persisted CE-cache offset points into
[fire]
user = nobody
listen = /dev/shm/mapptr/fire.sock
listen.mode = 0666
pm = static
pm.max_children = 2
pm.max_requests = 1              ; fresh worker per request
chdir = /dev/shm/mapptr/www
php_admin_value[extension] = posix.so

www/seed.php — declaring the class is what bakes a CE-cache offset, allocated
from this pool's counter, into the SHM-interned name:

<?php
class WfLike {
    public static function shreddedUniqueStaticMethodName() { return 42; }
}
echo WfLike::shreddedUniqueStaticMethodName(), "\n";

www/fire.php — references the class without declaring it, so no bind-time
CE-cache heal can run:

<?php
echo WfLike::shreddedUniqueStaticMethodName(), "\n";

Run:

mkdir -p /dev/shm/mapptr/www /opt/mapptr/modules
# the pool-local module must be readable by the pool user AND not on a noexec
# mount: /dev/shm is noexec in containers and on hardened hosts, where dlopen()
# fails with "failed to map segment from shared object" and the pools then
# never diverge
cp /path/to/build/modules/posix.so /opt/mapptr/modules/
chmod -R a+rX /opt/mapptr /dev/shm/mapptr

# PHP_INI_SCAN_DIR= so posix is not ALSO loaded globally by conf.d; if both
# pools load it, the numbering stays in sync and nothing crashes
PHP_INI_SCAN_DIR= php-fpm --fpm-config fpm.conf -c min.ini

# 1) seed: persist WfLike into the shared SHM from the pool without posix
SCRIPT_FILENAME=/dev/shm/mapptr/www/seed.php REQUEST_METHOD=GET \
    cgi-fcgi -bind -connect /dev/shm/mapptr/seed.sock

# 2) fire: every fresh worker of the posix pool dies on its first request
SCRIPT_FILENAME=/dev/shm/mapptr/www/fire.php REQUEST_METHOD=GET \
    cgi-fcgi -bind -connect /dev/shm/mapptr/fire.sock

Reproducer 2: the same scenario as an FPM test

--TEST--
FPM: SHM-persisted CE-cache offsets collide with pool-local extension map_ptr slots
--SKIPIF--
<?php
include "skipif.inc";
$extDir = getenv('TEST_FPM_EXTENSION_DIR');
if (!$extDir) die('skip TEST_FPM_EXTENSION_DIR is required');
// opcache provides the shared SHM that carries the baked offset; posix is only
// a convenient pool-local extension with enough internal functions to own the
// colliding band (any such extension works)
foreach (['opcache', 'posix'] as $ext) {
    if (!file_exists("$extDir/$ext.so")) die("skip $ext.so not present in TEST_FPM_EXTENSION_DIR");
}
if (!extension_loaded('zend_test')) die('skip zend_test extension required for the observer');
?>
--FILE--
<?php

require_once "tester.inc";

// Two pools of one master share a single opcache SHM. Only the "fire" pool
// loads a pool-local extension (post-fork), so its map_ptr numbering diverges
// from the "seed" pool's. The CE-cache offset the seed pool bakes into the
// shared interned class name then points, in the fire pool, into the band of
// slots owned by that extension's internal-function run_time_caches.
$cfg = <<<EOT
[global]
error_log = {{FILE:LOG}}

[seed]
listen = {{ADDR[seed]}}
pm = static
pm.max_children = 1
catch_workers_output = yes

[fire]
listen = {{ADDR[fire]}}
pm = static
pm.max_children = 2
pm.max_requests = 1
catch_workers_output = yes
php_admin_value[extension] = posix.so
EOT;

$tester = new FPM\Tester($cfg);

$dir = sys_get_temp_dir();
$seedFile = $dir . '/mapptr_seed.php';
$fireFile = $dir . '/mapptr_fire.php';

// declares the class: persisting it bakes a CE-cache offset (allocated from the
// seed pool's counter) into the SHM-interned name
file_put_contents($seedFile, <<<'PHPCODE'
<?php
class WfLike {
    public static function shreddedUniqueStaticMethodName() { return 42; }
}
echo WfLike::shreddedUniqueStaticMethodName();
PHPCODE);

// only references the class, never declares it, so no bind-time CE-cache heal
file_put_contents($fireFile, <<<'PHPCODE'
<?php
try {
    WfLike::shreddedUniqueStaticMethodName();
    echo "UNEXPECTED-SUCCESS";
} catch (\Error $e) {
    echo $e->getMessage();
}
PHPCODE);

$tester->start(iniEntries: [
    'zend_extension'                 => 'opcache.so',
    'opcache.enable'                 => '1',
    'opcache.enable_cli'             => '0',
    'opcache.jit'                    => 'off',
    // the seed file is written moments before the request; without this the
    // default 2s protection window stops opcache from persisting it at all
    'opcache.file_update_protection' => '0',
    'opcache.jit_buffer_size'        => '0',
    // registering an fcall observer is what makes the engine zero-fill every
    // internal function's run_time_cache slot on each request
    'zend_test.observer.enabled'     => '1',
    'zend_test.observer.show_output' => '0',
]);
$tester->expectLogStartNotices();

$tester
    ->request(address: '{{ADDR[seed]}}', scriptFilename: $seedFile)
    ->expectBody('42');

// each fire request gets a fresh worker (pm.max_requests = 1); on an affected
// build these SIGSEGV instead of raising the class-not-found Error
for ($i = 0; $i < 3; $i++) {
    $tester
        ->request(address: '{{ADDR[fire]}}', scriptFilename: $fireFile)
        ->expectBody('Class "WfLike" not found');
}

$tester->terminate();
$tester->close();
@unlink($seedFile);
@unlink($fireFile);

?>
Done
--EXPECT--
Done
--CLEAN--
<?php
require_once "tester.inc";
FPM\Tester::clean();
@unlink(sys_get_temp_dir() . '/mapptr_seed.php');
@unlink(sys_get_temp_dir() . '/mapptr_fire.php');
?>

Save it under sapi/fpm/tests/ and run:

TEST_FPM_EXTENSION_DIR="$PWD/modules" \
  make test TESTS=sapi/fpm/tests/mapptr-pool-local-extension-collision.phpt

Two caveats about this second form. It skips unless the build has posix shared
and TEST_FPM_EXTENSION_DIR is exported, and it skips entirely when tests are
run as root unless TEST_FPM_RUN_AS_ROOT=1 is also set. More importantly, when
it does fail, the test output does not mention a signal: the FPM test client
reports the dead worker as Not in white list. Check listen.allowed_clients.,
which is simply how it renders a socket closed with no response. That message is
misleading — the reason to prefer reproducer 1 is that it shows the SIGSEGV
directly.

Resulted in this behavior

The seed pool answers normally with 42. Every fresh worker in the fire pool
dies on its first request:

WARNING: [pool fire] child 65543 exited on signal 11 (SIGSEGV - core dumped) after 0.13 seconds from start
WARNING: [pool fire] child 65544 exited on signal 11 (SIGSEGV - core dumped) after 0.24 seconds from start

12 of 12 fire-pool requests crash their worker. Backtrace:

#0  zend_hash_find_bucket (ht=..., key=...)   Zend/zend_hash.c
#1  zend_hash_find
#2  zend_std_get_static_method                Zend/zend_object_handlers.c
#3  ZEND_INIT_STATIC_METHOD_CALL_SPEC_CONST_CONST_HANDLER
#4  execute_ex

with ht->arData == NULL, ht->nTableMask == 0 and
si_addr = 0xffffffff3fe251a4, which is exactly
4 * (int32)zend_string_hash("shreddeduniquestaticmethodname"). The faulting
address is derived from the method name, because HT_HASH_EX(NULL, h) addresses
4*h off a NULL base — a useful fingerprint when matching production cores to
this bug.

But I expected this output instead

Class "WfLike" not found

and for the FPM worker to remain alive. That is what happens on 8.4+, and on
8.2/8.3 with the change described at the end.

Root cause

zend_accel_get_class_name_map_ptr() (ext/opcache/zend_persist.c) persists
CE-cache map_ptr offsets into SHM-interned class-name strings. On PHP 8.2/8.3,
internal-function run-time caches and these CE caches are allocated from the same
dynamic map-pointer counter.

FPM applies a pool-level php_admin_value[extension] after the master forks
(fpm_php_apply_defines_ex(), sapi/fpm/fpm/fpm_php.c, which calls
php_dl(..., MODULE_PERSISTENT, ...)). Loading an extension in only one pool
registers its internal functions, each claiming a slot via
ZEND_MAP_PTR_NEW(internal_function->run_time_cache) in Zend/zend_API.c, and
so advances that pool's CG(map_ptr_last), while another pool sharing the same
OPcache SHM does not advance it. Consequently an offset created and persisted by
one pool can name an internal-function run-time-cache slot in another pool.

With an fcall observer registered, zend_init_internal_run_time_cache()
(Zend/zend_extensions.c, called per request from zend_activate()) points
every internal function's slot at a zero-initialized arena slice. The CE-cache
fast path in zend_lookup_class_ex() only checks that the offset is below
CG(map_ptr_last), so the colliding slot passes the bounds check and its arena
allocation is returned as a class entry. The next static method call runs
zend_hash_find() over a zeroed function_table and faults.

Without an observer the slot stays NULL after the per-request memset, the fast
path falls through, and the cache heals — the bug is latent. With an observer it
is deterministic on the worker's first request, so the pool crash-loops until the
SHM is recycled.

PHP 8.4 introduced a separate static map-pointer region for internal-function
run-time caches in #15040 / commit 25d761623c. That separation prevents this
collision by design, which is why 8.4+ is unaffected.

Version information

PHP 8.2.34-dev (cli) (built: Aug 18 2026 08:02:59) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.2.34-dev, Copyright (c) Zend Technologies
    with Zend OPcache v8.2.34-dev, Copyright (c), by Zend Technologies
  • Reproduced on PHP 8.2.34-dev at 4fa25b04e76ccafda9a778f2ba2aaeb66fad1037
    (current PHP-8.2 head), 12/12 fire-pool workers killed.
  • Reproduced on PHP 8.3.31-dev at 3aef16abbdb (PHP-8.3), also 12/12, with an
    identical backtrace and an identical si_addr.
  • Reproduced from scratch in a stock debian:12 container — install the
    prerequisites, clone, build, run — so nothing in the setup above depends on our
    environment.
  • PHP 8.4 and later contain the static-region change from Make internal run_time_cache a persistent allocation #15040 and are not
    affected by this collision.
  • PHP 8.1 is not affected: internal-function run_time_cache via map_ptr was
    introduced in 8.2.
  • JIT is off in the reproducer. (JIT is a second, independent way to arm the same
    collision, since a started JIT also claims op_array extension handles.)

Operating system

Linux x86-64. Reproduced on Debian 12 and on EL8-based distributions; no
distribution-specific ingredient.

If it does not reproduce

Each of these produces a clean, error-free run rather than a visible failure, so
they are worth checking before concluding it does not reproduce:

  • posix.so must actually load in the fire pool and not in the seed pool. A
    module on a noexec mount or in a directory the pool user cannot read fails to
    load, and the two pools then never diverge. Request a script containing
    var_dump(extension_loaded('posix')) on both sockets to confirm.
  • opcache.file_update_protection=0 must be set, or the freshly written seed
    file is never cached and nothing is persisted into SHM.
  • PHP_INI_SCAN_DIR= must be empty, or a scanned conf.d may load the same
    extension globally into both pools.

Possibly related reports

We searched for prior reports before filing. None of these establishes this
mechanism, but they are adjacent enough to be worth linking:

  • bugs.php.net #81380 is the closest
    precedent: an OPcache cache-slot collision with observer data after an Apache
    reload, where "after reload, result.num of INIT_FCALL (which is cache slot
    num) is 0, and so occupies the same slot as the observer data". Different
    SAPI and lifecycle, and it was fixed in 2021 (commit c884a5a), but it is the
    same family of defect: two consumers disagreeing about a slot's meaning.
  • #18147: an OPcache-enabled FPM
    crash in zend_hash_find_known_hash() during class lookup, reported against
    8.4.5. It does not mention pool-local extensions or observers, so it is not
    clearly the same defect.
  • #13817: an OPcache + Observer
    API crash on 8.3.4, but in zend_observer_fcall_end_all() during request
    shutdown — a different code path from the class-lookup fast path here.
  • #14261: an unreproduced 8.2
    FPM/OPcache crash that maintainers suspected involved corrupted SHM. It lacks
    the configuration and backtrace needed to establish the same cause.
  • bugs.php.net #77577 and
    #76518: older FPM crashes involving
    OPcache plus extensions loaded per pool. Both crash during shutdown on PHP 7.2
    and neither has a reproducer establishing this map-pointer collision.
  • bugs.php.net #79055: older map_ptr
    failures involving the OPcache file cache and SHM, but not FPM pool-local
    module registration.

Proposed fix

We have a backport of the static map-pointer region to 8.2/8.3 — 25d761623c
from #15040, plus the map_ptr_static handling from 53fa98ecd3 (#17835),
without which OPcache's preload_load() reallocates the map_ptr base and leaves
the static region behind. It is five engine files plus the regression test above.
On the branch head named above it turns 12/12 crashing requests into 0/12, with
make test clean (JIT on and off, NTS and ZTS) and no new valgrind errors.

The change is on a branch against current PHP-8.2, if it is useful to look at
before deciding:
PHP-8.2...nyrzhun:php-src:fix-mapptr-pool-local-collision-8.2
(one commit, seven files). No pull request has been opened yet — see the
question below about which branch, if any, this should target.

Since 8.2 and 8.3 are both in security-fixes-only support, we would appreciate
guidance on whether such a backport is acceptable and which branch it should
target before we open the pull request. We prepared it against PHP-8.2 because
CONTRIBUTING.md asks for the lowest actively supported affected branch, but we
are aware 8.2's security window closes at the end of 2026 while 8.3's runs a year
longer — if you would rather this went to PHP-8.3 only, or nowhere at all, we
would rather hear that than have you spend review time on it. If the backport is considered too large
for those branches, a much smaller hardening is possible: validate
ce->name == name in the CE-cache fast path (both are the same interned string
when the entry is genuine) and fall through to the self-healing slow path on
mismatch. We are happy to submit either.


Disclosure, per CONTRIBUTING.md's request about LLM usage in GitHub comments:
this report was drafted with LLM assistance. The analysis, the core-dump
evidence, the reproducers and every number quoted here were produced and
verified by running them on real builds, not generated.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions