From ef12ecdc03949e76e94db0e5c5dd44469237994d Mon Sep 17 00:00:00 2001 From: Jorg Sowa Date: Wed, 27 May 2026 20:08:36 +0200 Subject: [PATCH 1/2] fix: session GC fork --- ext/session/php_session.h | 2 ++ ext/session/session.c | 23 ++++++++--------------- 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/ext/session/php_session.h b/ext/session/php_session.h index 08c08b9a024a..2a3043279cd3 100644 --- a/ext/session/php_session.h +++ b/ext/session/php_session.h @@ -146,6 +146,8 @@ typedef struct _php_ps_globals { zend_string *session_started_filename; uint32_t session_started_lineno; int module_number; + /* Unused since the GC probability draw was made stateless; retained only + * to preserve struct layout (ABI) on stable branches. */ php_random_status_state_pcgoneseq128xslrr64 random_state; php_random_algo_with_state random; zend_long gc_probability; diff --git a/ext/session/session.c b/ext/session/session.c index 1fdfc5d1073f..3d42f4132e17 100644 --- a/ext/session/session.c +++ b/ext/session/session.c @@ -392,9 +392,15 @@ static zend_long php_session_gc(bool immediate) /* GC must be done before reading session data. */ if ((PS(mod_data) || PS(mod_user_implemented))) { - /* Use probability-based GC if not forced and probability is configured */ if (!collect && PS(gc_probability) > 0) { - collect = php_random_range(PS(random), 0, PS(gc_divisor) - 1) < PS(gc_probability); + /* Draw fresh entropy per request instead of using the per-process + * PS(random) state. The latter is seeded once in GINIT, which runs + * before SAPIs such as PHP-FPM fork their workers, so every worker + * would inherit and replay the same GC-decision sequence. */ + uint32_t rand_val; + if (php_random_bytes_silent(&rand_val, sizeof(rand_val)) == SUCCESS) { + collect = (rand_val % (uint32_t) PS(gc_divisor)) < (uint32_t) PS(gc_probability); + } } if (collect) { @@ -2886,19 +2892,6 @@ static PHP_GINIT_FUNCTION(ps) ZVAL_UNDEF(&ps_globals->mod_user_names.ps_validate_sid); ZVAL_UNDEF(&ps_globals->mod_user_names.ps_update_timestamp); ZVAL_UNDEF(&ps_globals->http_session_vars); - - ps_globals->random = (php_random_algo_with_state){ - .algo = &php_random_algo_pcgoneseq128xslrr64, - .state = &ps_globals->random_state, - }; - php_random_uint128_t seed; - if (php_random_bytes_silent(&seed, sizeof(seed)) == FAILURE) { - seed = php_random_uint128_constant( - php_random_generate_fallback_seed(), - php_random_generate_fallback_seed() - ); - } - php_random_pcgoneseq128xslrr64_seed128(ps_globals->random.state, seed); } static PHP_MINIT_FUNCTION(session) From f5501ea10dc74b63d3d9b276c774a622303be82b Mon Sep 17 00:00:00 2001 From: Jorg Sowa Date: Wed, 10 Jun 2026 22:59:35 +0200 Subject: [PATCH 2/2] fix: lazy-reseed GC random state per worker process Replace the per-call php_random_bytes_silent() approach with lazy reseeding of PS(random) on first use. GINIT initializes the PCG state but runs before PHP-FPM forks workers, so all workers would otherwise inherit and replay the same sequence. A one-time CSPRNG call on first GC check per process breaks the correlation while keeping subsequent draws cheap (PCG, not CSPRNG). Add gc_random_seeded bool to php_ps_globals to track whether the per- process reseed has happened. Restore the random struct initialization in GINIT (removed in previous commit) since the field is now actively used again. --- ext/session/php_session.h | 3 +-- ext/session/session.c | 28 +++++++++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/ext/session/php_session.h b/ext/session/php_session.h index 2a3043279cd3..752199198953 100644 --- a/ext/session/php_session.h +++ b/ext/session/php_session.h @@ -146,10 +146,9 @@ typedef struct _php_ps_globals { zend_string *session_started_filename; uint32_t session_started_lineno; int module_number; - /* Unused since the GC probability draw was made stateless; retained only - * to preserve struct layout (ABI) on stable branches. */ php_random_status_state_pcgoneseq128xslrr64 random_state; php_random_algo_with_state random; + bool gc_random_seeded; /* false until first GC check; reseeds after fork */ zend_long gc_probability; zend_long gc_divisor; zend_long gc_maxlifetime; diff --git a/ext/session/session.c b/ext/session/session.c index 3d42f4132e17..9c2735cb03c1 100644 --- a/ext/session/session.c +++ b/ext/session/session.c @@ -393,14 +393,22 @@ static zend_long php_session_gc(bool immediate) /* GC must be done before reading session data. */ if ((PS(mod_data) || PS(mod_user_implemented))) { if (!collect && PS(gc_probability) > 0) { - /* Draw fresh entropy per request instead of using the per-process - * PS(random) state. The latter is seeded once in GINIT, which runs - * before SAPIs such as PHP-FPM fork their workers, so every worker - * would inherit and replay the same GC-decision sequence. */ - uint32_t rand_val; - if (php_random_bytes_silent(&rand_val, sizeof(rand_val)) == SUCCESS) { - collect = (rand_val % (uint32_t) PS(gc_divisor)) < (uint32_t) PS(gc_probability); + /* Reseed PS(random) on first use per process. GINIT runs in the + * master process before SAPIs like PHP-FPM fork their workers, so + * all workers would otherwise inherit and replay the same PCG + * sequence. One CSPRNG call here breaks the correlation. */ + if (UNEXPECTED(!PS(gc_random_seeded))) { + php_random_uint128_t seed; + if (php_random_bytes_silent(&seed, sizeof(seed)) == FAILURE) { + seed = php_random_uint128_constant( + php_random_generate_fallback_seed(), + php_random_generate_fallback_seed() + ); + } + php_random_pcgoneseq128xslrr64_seed128(PS(random).state, seed); + PS(gc_random_seeded) = true; } + collect = php_random_range(PS(random), 0, PS(gc_divisor) - 1) < PS(gc_probability); } if (collect) { @@ -2892,6 +2900,12 @@ static PHP_GINIT_FUNCTION(ps) ZVAL_UNDEF(&ps_globals->mod_user_names.ps_validate_sid); ZVAL_UNDEF(&ps_globals->mod_user_names.ps_update_timestamp); ZVAL_UNDEF(&ps_globals->http_session_vars); + + ps_globals->random = (php_random_algo_with_state){ + .algo = &php_random_algo_pcgoneseq128xslrr64, + .state = &ps_globals->random_state, + }; + ps_globals->gc_random_seeded = false; } static PHP_MINIT_FUNCTION(session)