Skip to content

FPM: pools with different disable_functions sharing one opcache run scripts wrongly (var_export() calls var_dump(), SIGSEGV in the restricted pool) on 8.4/8.5/8.6 #24661

Open

Description

@Enfiladeguide

Description

Quoted text written with an LLM and checked by a human developer.

Two pools in one FPM master, one with php_admin_value[disable_functions] and one without, share the opcache. Once a script has been compiled and cached through one pool, the other pool executes it wrongly: an internal function call is dispatched to a different internal function, and a worker of the restricted pool can crash.

fpm.conf:

[global]
daemonize = no
error_log = /dev/stderr
[a]
user = nobody
listen = /tmp/a.sock
listen.owner = nobody
pm = static
pm.max_children = 1
php_admin_value[disable_functions] = exec
[b]
user = nobody
listen = /tmp/b.sock
listen.owner = nobody
pm = static
pm.max_children = 1

t.php:

<?php
echo var_export([1, 'x' => true], true), "\n";

Request t.php through pool a, then through pool b (a ~20-line FastCGI client, fcgi.php, is attached below so that no web server is needed). Resulting output, php:8.5-fpm (8.5.12), default settings (opcache is built in):

pool a: array (
  0 => 1,
  'x' => true,
)
pool b: array(2) {
  [0]=>
  int(1)
  ["x"]=>
  bool(true)
}
bool(true)

Pool b printed the output of var_dump() (and its return value) instead of var_export(). Expected output: the same text as pool a. With the order reversed (b first, then a) pool a, the one with exec disabled, prints debug_zval_dump() style output (array(2) interned {...}).

On Ubuntu's 8.5.4 (8.5.4-0ubuntu1.5, -n with zend_extension=opcache) pool b fails with convert_uudecode() expects exactly 1 argument, 2 given for the same script, i.e. the call is dispatched to a third function.

A script that calls the disabled function itself (t2.php: echo exec('echo hi');), requested through b first and then through a, kills the worker of the restricted pool:

WARNING: [pool a] child 10 exited on signal 11 (SIGSEGV) after 2.015193 seconds from start

What I checked:

result
8.5.12, 8.4.27 (official php:*-fpm images), 8.6.0RC4 (php:8.6-rc-fpm, ZTS), Ubuntu 8.5.4 wrong function called
8.3.36 (php:8.3-fpm) correct
opcache.enable=0 correct
opcache.optimization_level=0 correct
8.4 without zend_extension=opcache (-n) correct
default opcache.optimization_level with the bit 0x20 or the bit 0x40 cleared (one at a time; every other bit alone makes no difference) correct
two separate FPM masters, one pool each, same disable_functions split (8.5.12 and 8.4.27) correct, no cross-effect

I did not trace what exactly gets cached or resolved by index or by pointer, so I cannot say more than that the optimizer passes 0x20 (DFA) and 0x40 (call graph) are both needed. I saw no case where a disabled function actually ran: the restricted pool crashed or printed the output of another function, but never executed exec. FPM accepts php_admin_value[disable_functions] per pool, so a setup like this is not exotic, and the symptom (wrong output rather than an error) is hard to diagnose; the workaround is opcache.enable=0, or one master per differing disable_functions set.

I found no open or closed report about this. Same root cause as GH-17733. Related but different: #21974 (SIGSEGV with disable_functions per pool, ZTS builds only) and #24047 (system id and frameless functions).

To reproduce (podman/docker; the directory holds fpm.conf, t.php, t2.php, fcgi.php, run.sh):

docker run --rm -v "$PWD":/m docker.io/library/php:8.5-fpm sh /m/run.sh "a b a b" t.php
docker run --rm -v "$PWD":/m docker.io/library/php:8.5-fpm sh /m/run.sh "b a" t2.php
docker run --rm -v "$PWD":/m docker.io/library/php:8.5-fpm sh /m/run.sh "a b a b" t.php -d opcache.enable=0   # correct
control: one master per pool (correct on 8.5.12 and 8.4.27)
sed -n '1,10p' /m/fpm.conf > /tmp/fpm-a.conf
{ sed -n '1,3p' /m/fpm.conf; sed -n '11,16p' /m/fpm.conf; } > /tmp/fpm-b.conf
php-fpm -y /tmp/fpm-a.conf -F >/m/fpm-a.out 2>&1 &
php-fpm -y /tmp/fpm-b.conf -F >/m/fpm-b.out 2>&1 &
sleep 2
for p in a b a b b a; do printf -- "pool %s: " "$p"; php /m/fcgi.php /tmp/$p.sock /m/t.php 2>&1 | tr '\n' ' '; echo; done

fcgi.php:

<?php
// Minimal FastCGI GET client: php fcgi.php /tmp/a.sock /m/t.php
[$sock, $script] = [$argv[1], $argv[2]];
$rec = fn(int $t, string $b) => pack('CCnnCx', 1, $t, 1, strlen($b), 0) . $b;
$kv = function (string $k, string $v): string {
    $l = fn(int $n) => $n < 128 ? chr($n) : pack('N', $n | 0x80000000);
    return $l(strlen($k)) . $l(strlen($v)) . $k . $v;
};
$p = $kv('SCRIPT_FILENAME', $script) . $kv('REQUEST_METHOD', 'GET') . $kv('SCRIPT_NAME', '/t.php')
   . $kv('SERVER_PROTOCOL', 'HTTP/1.1') . $kv('REQUEST_URI', '/t.php') . $kv('QUERY_STRING', '');
$s = stream_socket_client("unix://$sock", $en, $es, 10);
fwrite($s, $rec(1, pack('nCx5', 1, 0)) . $rec(4, $p) . $rec(4, '') . $rec(5, ''));
$out = '';
while (!feof($s)) {
    $h = stream_get_contents($s, 8);
    if (strlen($h) < 8) break;
    $d = unpack('Cv/Ct/nid/nlen/Cpad', $h);
    $body = $d['len'] + $d['pad'] ? stream_get_contents($s, $d['len'] + $d['pad']) : '';
    if ($d['t'] === 6 || $d['t'] === 7) $out .= substr($body, 0, $d['len']);
    if ($d['t'] === 3) break;
}
echo preg_replace('~^.*?\r\n\r\n~s', '', $out) ?: "(empty response / connection reset)\n";

run.sh:

ord=$1; scr=$2; shift 2
php -v | head -1
php-fpm "$@" -y /m/fpm.conf -F >/m/fpm.out 2>&1 &
sleep 2
for p in $ord; do printf -- "pool %s: " "$p"; php /m/fcgi.php /tmp/$p.sock /m/$scr 2>&1 | tr '\n' ' '; echo; done
grep -E "SIGSEGV|signal" /m/fpm.out

PHP Version

PHP 8.5.12 (cli) (built: Oct 21 2026 15:49:51) (NTS)   [php:8.5-fpm]
PHP 8.4.27 (cli) (built: Oct 21 2026 15:53:31) (NTS)   [php:8.4-fpm, opcache loaded]
PHP 8.6.0RC4 (cli) (built: Oct 21 2026 22:05:36) (ZTS) [php:8.6-rc-fpm]
PHP 8.5.4-0ubuntu1.5 (Ubuntu 26.04)

Operating System

Debian (official Docker images), Ubuntu 26.04

Activity

  1. ndossche commented on Oct 24, 2026

    @ndossche
    Member

    Just based on my gut feeling, this is probably related to #17733

  2. Enfiladeguide commented on Oct 25, 2026

    @Enfiladeguide
    Author

    Quoted text written with an LLM and checked by a human developer.

    Thanks, that's it. I should have found GH-17733 before filing: every search I ran paired disable_functions with a word that thread doesn't contain (pool, var_export, frameless). Sorry for the duplicate.

    I tried #24664 on this setup and posted the result there. Its test passes, but two pools under one master still call the wrong function, because the pool's disable_functions is applied in the worker after the system ID has been computed. I'll leave it to you whether to keep this open for the FPM case or close it as a duplicate of GH-17733.

  3. ndossche commented on Oct 25, 2026

    @ndossche
    Member

    Thanks, I won't merge the issues, since I think it's useful to keep the fpm one separate from the file cache one. Perhaps fpm van be fixed using a similar mechanism.

  4. added a commit that references this issue on Oct 27, 2026
    89f46e7
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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