[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.