[php-src] Issue #23355: PHP-FPM SIGSEGV with OPcache, an observer, and an extension loaded per pool

[email protected] (nyrzhun)
Newsgroups php.bugs
Message-ID <[email protected]>
Issue: https://github.com/php/php-src/issues/23355
Author: 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:

```sh
./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`:

```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:

```ini
[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
<?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
<?php
echo WfLike::shreddedUniqueStaticMethodName(), "\n";
```

Run:

```sh
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

```text
--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:

```sh
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:

```text
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:

```text
#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

```text
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

```text
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 #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](https://bugs.php.net/bug.php?id=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](https://github.com/php/php-src/issues/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](https://github.com/php/php-src/issues/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](https://github.com/php/php-src/issues/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](https://bugs.php.net/bug.php?id=77577) and
  [#76518](https://bugs.php.net/bug.php?id=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](https://bugs.php.net/bug.php?id=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`](https://github.com/php/php-src/compare/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.
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.