You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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 itopcache.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 requestchdir = /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:
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:
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.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.
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 differentmeanings to the same
map_ptroffset.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 invalidfunction_tableand the FPM worker terminates with SIGSEGV.The reproducer below uses
posixas the pool-local extension andzend_testasthe observer. There does not appear to be anything specific to either extension:
posixmerely registers enough internal functions for the two ranges to overlap.In production we see this with
imagick(581 internal methods) as the pool-localextension 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.
posixmust be shared, because that iswhat 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, pluslibfcgi-binfor thecgi-fcgiclient used below.Reproducer 1: shell, ~30 seconds
This one is worth running first because it shows the actual signal.
min.ini:fpm.conf— one master, one shared OPcache SHM, two pools:www/seed.php— declaring the class is what bakes a CE-cache offset, allocatedfrom this pool's counter, into the SHM-interned name:
www/fire.php— references the class without declaring it, so no bind-timeCE-cache heal can run:
Run:
Reproducer 2: the same scenario as an FPM test
Save it under
sapi/fpm/tests/and run:Two caveats about this second form. It skips unless the build has
posixsharedand
TEST_FPM_EXTENSION_DIRis exported, and it skips entirely when tests arerun as root unless
TEST_FPM_RUN_AS_ROOT=1is also set. More importantly, whenit 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 pooldies on its first request:
12 of 12 fire-pool requests crash their worker. Backtrace:
with
ht->arData == NULL,ht->nTableMask == 0andsi_addr = 0xffffffff3fe251a4, which is exactly4 * (int32)zend_string_hash("shreddeduniquestaticmethodname"). The faultingaddress is derived from the method name, because
HT_HASH_EX(NULL, h)addresses4*hoff a NULL base — a useful fingerprint when matching production cores tothis bug.
But I expected this output instead
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) persistsCE-cache
map_ptroffsets 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 callsphp_dl(..., MODULE_PERSISTENT, ...)). Loading an extension in only one poolregisters its internal functions, each claiming a slot via
ZEND_MAP_PTR_NEW(internal_function->run_time_cache)inZend/zend_API.c, andso advances that pool's
CG(map_ptr_last), while another pool sharing the sameOPcache 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 fromzend_activate()) pointsevery 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 belowCG(map_ptr_last), so the colliding slot passes the bounds check and its arenaallocation is returned as a class entry. The next static method call runs
zend_hash_find()over a zeroedfunction_tableand 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 thiscollision by design, which is why 8.4+ is unaffected.
Version information
4fa25b04e76ccafda9a778f2ba2aaeb66fad1037(current
PHP-8.2head), 12/12 fire-pool workers killed.3aef16abbdb(PHP-8.3), also 12/12, with anidentical backtrace and an identical
si_addr.debian:12container — install theprerequisites, clone, build, run — so nothing in the setup above depends on our
environment.
affected by this collision.
run_time_cacheviamap_ptrwasintroduced in 8.2.
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.somust actually load in the fire pool and not in the seed pool. Amodule on a
noexecmount or in a directory the pool user cannot read fails toload, 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=0must be set, or the freshly written seedfile is never cached and nothing is persisted into SHM.
PHP_INI_SCAN_DIR=must be empty, or a scannedconf.dmay load the sameextension 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:
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 thesame family of defect: two consumers disagreeing about a slot's meaning.
crash in
zend_hash_find_known_hash()during class lookup, reported against8.4.5. It does not mention pool-local extensions or observers, so it is not
clearly the same defect.
API crash on 8.3.4, but in
zend_observer_fcall_end_all()during requestshutdown — a different code path from the class-lookup fast path here.
FPM/OPcache crash that maintainers suspected involved corrupted SHM. It lacks
the configuration and backtrace needed to establish the same cause.
#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.
map_ptrfailures 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 —
25d761623cfrom #15040, plus the
map_ptr_statichandling from53fa98ecd3(#17835),without which OPcache's
preload_load()reallocates the map_ptr base and leavesthe 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 testclean (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 atbefore 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.2becauseCONTRIBUTING.mdasks for the lowest actively supported affected branch, but weare 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.3only, or nowhere at all, wewould 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 == namein the CE-cache fast path (both are the same interned stringwhen the entry is genuine) and fall through to the self-healing slow path on
mismatch. We are happy to submit either.