Skip to content

PHP-FPM: PHP_VALUE settings persist across requests #24689

Open

Description

@ultravistor

Description

I haven't thoroughly tested the exact reproduction steps or conditions yet, but I have observed this behavior for years: settings passed via PHP_VALUE appear to persist throughout the lifetime of a PHP-FPM worker.

SetEnvIf ^ ^ "PHP_VALUE=post_max_size=40G;"
SetEnvIf ^ ^ "PHP_VALUE=upload_max_filesize=2G;"

This of course doesn't work; only the latter setting is supposed to work. But in reality, both post_max_size and upload_max_filesize are often set with this configuration.
Recently, I realized this could be a bug, as it seems I can actually place an .htaccess file anywhere in the document root with "PHP_VALUE=post_max_size=100G", and then post_max_size becomes 100G even above the directory where I placed the .htaccess file, if the request is handled by the same PHP-FPM worker.
The issue seems to be that once a PHP-FPM worker receives an .htaccess-originated PHP_VALUE, it doesn't clear the value when it finishes processing the request, and the next request for any page handled by the same worker uses the same value.

Expected behavior

Each request should start from the PHP/FPM configuration baseline.
A PHP_VALUE override supplied for request A should affect request A only.
If request B does not contain that override, request B should see the
configured default/pool value.

Actual behavior

For example:
Request A:

PHP_VALUE=upload_max_filesize=2G

The request reports:

upload_max_filesize=2G

Request B is then handled by the same FPM worker without a PHP_VALUE
override for upload_max_filesize.
Request B still reports:

upload_max_filesize=2G

instead of the configured baseline value.
The behavior is reproducible when both requests are handled by the same

FPM worker.

Reproduction

PHP script:

<?php
echo "pid=", getmypid(), "\n";
echo "upload_max_filesize=", ini_get('upload_max_filesize'), "\n";
echo "post_max_size=", ini_get('post_max_size'), "\n";
  1. Send a request with PHP_VALUE=upload_max_filesize=2G.
  2. Confirm that the override is applied.
  3. Send another request handled by the same FPM worker without that
    override.
  4. Observe that the previous value remains active.

Environment

  • PHP: 8.5.12
  • SAPI: FPM/FastCGI
  • OS: macOS
  • PHP built from source

Investigation

The FastCGI INI parser applies PHP_VALUE / PHP_ADMIN_VALUE through
fpm_php_apply_defines_ex().
That path ultimately modifies the zend_ini_entry through the FPM master
INI alteration path.
In contrast, request-scoped INI changes made through the normal Zend INI
alteration API are registered in EG(modified_ini_directives), preserving
the original value so that request deactivation can restore it.
This may explain why an FPM FastCGI override can survive request
deactivation, but further investigation is needed to determine the correct
fix without changing the semantics of FPM pool-level php_value and
php_admin_value settings.

PHP Version

PHP 8.5.12

Operating System

macOS

Activity

  1. ultravistor commented on Oct 25, 2026

    @ultravistor
    Author

    Below is a rough patch for this issue.

    fastcgi_ini_parser() in sapi/fpm/fpm/fpm_main.c

    --- a/sapi/fpm/fpm/fpm_main.c
    +++ b/sapi/fpm/fpm/fpm_main.c
    @@
     static void fastcgi_ini_parser(zval *arg1, zval *arg2, zval *arg3, int callback_type, void *arg) /* {{{ */
     {
     	int *mode = (int *)arg;
     	char *key;
     	char *value = NULL;
    -	struct key_value_s kv;
    +	zend_string *key_str;
    +	zend_string *value_str;
    +	zend_result result;
    
     	if (!mode || !arg1) return;
    
     	if (callback_type != ZEND_INI_PARSER_ENTRY) {
     		zlog(ZLOG_ERROR, "Passing INI directive through FastCGI: only classic entries are allowed");
     		return;
     	}
    
     	key = Z_STRVAL_P(arg1);
    
     	if (!key || strlen(key) < 1) {
     		zlog(ZLOG_ERROR, "Passing INI directive through FastCGI: empty key");
     		return;
     	}
    
     	if (arg2) {
     		value = Z_STRVAL_P(arg2);
     	}
    
     	if (!value) {
     		zlog(ZLOG_ERROR, "Passing INI directive through FastCGI: empty value for key '%s'", key);
     		return;
     	}
    
    -	kv.key = key;
    -	kv.value = value;
    -	kv.next = NULL;
    -	if (fpm_php_apply_defines_ex(&kv, *mode) == -1) {
    +	key_str = zend_string_init(key, strlen(key), 0);
    +	value_str = zend_string_init(value, strlen(value), 0);
    +
    +	result = zend_alter_ini_entry_ex(
    +		key_str,
    +		value_str,
    +		*mode,
    +		ZEND_INI_STAGE_ACTIVATE,
    +		true
    +	);
    +
    +	zend_string_release(value_str);
    +	zend_string_release(key_str);
    +
    +	if (result == FAILURE) {
     		zlog(ZLOG_ERROR, "Passing INI directive through FastCGI: unable to set '%s'", key);
     	}
     }

    This is only a rough patch and may need further changes.

  2. srayuth089 commented on Oct 25, 2026

    @srayuth089

    This observation connects to the "serialize-handler mismatch" path documented in the php2shell patch-gap research (dinosn/php-cve-2026-10961x-lab-poc, finding #2, 2026-10-22).

    The scenario there: if PHP_VALUE (or .user.ini) can set session.serialize_handler for one request, and the setting persists in the FPM worker as you describe, then:

    1. Request A (attacker-influenced): ini_set('session.serialize_handler', 'php_serialize') + session write
    2. Request B (different request, same worker): default php handler decodes the session written by php_serialize
    3. The |O: payload in the session data triggers __destruct during var_hash cleanup

    The persistence you've observed (PHP_VALUE surviving across requests in the same worker) is what makes this cross-request attack feasible — without persistence, each request would start with a clean handler.

    This was tested on PHP 8.4.26-FPM and confirmed to work when the handler divergence is set up (either via php_value in pool config or .user.ini per-directory). The patch's incomplete_classes_only guard (3584a98) is scoped to the pre-RINIT upload-progress window (PS(in_rfc1867_update)), not to app-phase session_start() decode — so this path survives the 2026-10 security patch.

    If PHP_VALUE persistence is intentional FPM behavior (performance optimization to avoid re-parsing pool config), it might be worth documenting this interaction as a known security consideration for multi-tenant FPM setups.

  3. ultravistor commented on Oct 25, 2026

    @ultravistor
    Author

    Thanks, I just tested session.serialize_handler and it looks like the same persistence occurs.

    • Request A
      SetEnvIf ^ ^ "PHP_VALUE=session.serialize_handler=php_serialize;"
    • Request B
      # SetEnvIf ^ ^ "PHP_VALUE=session.serialize_handler=php_serialize;"

    While the default value appears to be php, when both requests are handled by the same PHP-FPM worker, ini_get('session.serialize_handler') reported php_serialize in Request B using the test page below.

    echo getmypid(), "\n";
    echo ini_get('session.serialize_handler'), "\n";
    exit;

    So the issue is not limited to settings such as post_max_size or upload_max_filesize (it also affects session.serialize_handler). I haven't fully tested the exploit scenario described above yet. But thanks for your response.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions