From 015efae50d29de33a5cef6c6e5e377a8545099f6 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Fri, 13 Nov 2026 01:11:29 +0100 Subject: [PATCH 1/5] Make PCNTL child memory re-keying optional Closes GH-25403. --- NEWS | 5 ++++ Zend/zend_alloc.c | 22 ++++++++++++-- Zend/zend_alloc.h | 1 + ext/pcntl/README.md | 21 +++++++++++++ ext/pcntl/pcntl.c | 21 +++++++++---- ext/pcntl/pcntl.stub.php | 6 ++-- ext/pcntl/pcntl_arginfo.h | 10 +++++-- ext/pcntl/pcntl_decl.h | 8 ++--- ext/pcntl/tests/gh25403_cow.phpt | 32 ++++++++++++++++++++ ext/pcntl/tests/gh25403_fork.phpt | 48 ++++++++++++++++++++++++++++++ ext/pcntl/tests/gh25403_forkx.phpt | 48 ++++++++++++++++++++++++++++++ ext/pcntl/tests/gh25403_rfork.phpt | 48 ++++++++++++++++++++++++++++++ main/main.c | 9 ++++-- main/php_main.h | 1 + 14 files changed, 260 insertions(+), 20 deletions(-) create mode 100644 ext/pcntl/README.md create mode 100644 ext/pcntl/tests/gh25403_cow.phpt create mode 100644 ext/pcntl/tests/gh25403_fork.phpt create mode 100644 ext/pcntl/tests/gh25403_forkx.phpt create mode 100644 ext/pcntl/tests/gh25403_rfork.phpt diff --git a/NEWS b/NEWS index a0bce4ed6f43..e00ba0609b05 100644 --- a/NEWS +++ b/NEWS @@ -92,6 +92,11 @@ PHP NEWS (ndossche) . Fixed JIT ignoring exceptions from typed property set hooks. (David Carlier) +- PCNTL: + . Added the $rekey_memory parameter to pcntl_fork(), pcntl_rfork() and + pcntl_forkx() to optionally avoid re-keying inherited allocator lists + (GH-25403). (Alessio Attilio) + - PDO: . Fixed PDOStatement::getColumnMeta() reading out of bounds for an invalid column index. (Ilia Alshanetsky) diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index 2b52babc98ee..968511abdc92 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -2264,8 +2264,16 @@ static void zend_mm_init_key(zend_mm_heap *heap) zend_mm_refresh_key(heap); } -ZEND_API void zend_mm_refresh_key_child(zend_mm_heap *heap) +static void zend_mm_refresh_key_child_ex(zend_mm_heap *heap, bool rekey) { + if (!rekey) { + memset(&heap->rand_state, 0, sizeof(heap->rand_state)); +#if ZEND_DEBUG + heap->pid = getpid(); +#endif + return; + } + uintptr_t old_key = heap->shadow_key; zend_mm_init_key(heap); @@ -2294,6 +2302,11 @@ ZEND_API void zend_mm_refresh_key_child(zend_mm_heap *heap) #endif } +ZEND_API void zend_mm_refresh_key_child(zend_mm_heap *heap) +{ + zend_mm_refresh_key_child_ex(heap, true); +} + static zend_mm_heap *zend_mm_init(void) { zend_mm_chunk *chunk = (zend_mm_chunk*)zend_mm_chunk_alloc_int(ZEND_MM_CHUNK_SIZE, ZEND_MM_CHUNK_SIZE); @@ -3403,9 +3416,14 @@ ZEND_API void shutdown_memory_manager(bool silent, bool full_shutdown) zend_mm_shutdown(AG(mm_heap), full_shutdown, silent); } +ZEND_API void refresh_memory_manager_ex(bool rekey) +{ + zend_mm_refresh_key_child_ex(AG(mm_heap), rekey); +} + ZEND_API void refresh_memory_manager(void) { - zend_mm_refresh_key_child(AG(mm_heap)); + refresh_memory_manager_ex(true); } ZEND_NORETURN static ZEND_COLD void zend_out_of_memory(void) diff --git a/Zend/zend_alloc.h b/Zend/zend_alloc.h index a8e5c3fa602d..663d2e0441b2 100644 --- a/Zend/zend_alloc.h +++ b/Zend/zend_alloc.h @@ -269,6 +269,7 @@ ZEND_API bool zend_alloc_in_memory_limit_error_reporting(void); ZEND_API void start_memory_manager(void); ZEND_API void shutdown_memory_manager(bool silent, bool full_shutdown); ZEND_API void refresh_memory_manager(void); +ZEND_API void refresh_memory_manager_ex(bool rekey); ZEND_API bool is_zend_mm(void); ZEND_API bool is_zend_ptr(const void *ptr); diff --git a/ext/pcntl/README.md b/ext/pcntl/README.md new file mode 100644 index 000000000000..1de462abb302 --- /dev/null +++ b/ext/pcntl/README.md @@ -0,0 +1,21 @@ +# Memory re-keying after fork + +`pcntl_fork(bool $rekey_memory = true)`, +`pcntl_rfork(int $flags, int $signal = 0, bool $rekey_memory = true)` and +`pcntl_forkx(int $flags, bool $rekey_memory = true)` refresh the allocator's +shadow key in the child by default. This requires traversing and writing the +inherited free lists and cached chunk list. A fragmented heap can therefore +cause substantial child startup time and copy-on-write memory overhead. + +Passing `rekey_memory: false` skips that traversal and retains the inherited +shadow key. Allocator links remain protected, but the parent and children share +the key: disclosure of that key in one process compromises this protection in +other processes that retain it. Callers must weigh this reduced isolation +against the startup and memory savings. The default preserves per-child key +isolation. + +Both modes reset the allocator's random generator state so future key refreshes +use fresh randomness. Both also update allocator process bookkeeping and perform +the usual child initialization, including timer initialization, stat and realpath +cache clearing, and extension child initialization hooks. The setting applies to +one fork; subsequent forks use their own argument, defaulting to `true`. diff --git a/ext/pcntl/pcntl.c b/ext/pcntl/pcntl.c index 3492444925ca..4f0d2e961803 100644 --- a/ext/pcntl/pcntl.c +++ b/ext/pcntl/pcntl.c @@ -268,8 +268,12 @@ PHP_MINFO_FUNCTION(pcntl) PHP_FUNCTION(pcntl_fork) { pid_t id; + bool rekey_memory = true; - ZEND_PARSE_PARAMETERS_NONE(); + ZEND_PARSE_PARAMETERS_START(0, 1) + Z_PARAM_OPTIONAL + Z_PARAM_BOOL(rekey_memory) + ZEND_PARSE_PARAMETERS_END(); id = fork(); if (id == -1) { @@ -295,7 +299,7 @@ PHP_FUNCTION(pcntl_fork) } } else if (id == 0) { - php_child_init(); + php_child_init_ex(rekey_memory); } RETURN_LONG((zend_long) id); @@ -1552,11 +1556,13 @@ PHP_FUNCTION(pcntl_rfork) zend_long flags; zend_long csignal = 0; pid_t pid; + bool rekey_memory = true; - ZEND_PARSE_PARAMETERS_START(1, 2) + ZEND_PARSE_PARAMETERS_START(1, 3) Z_PARAM_LONG(flags) Z_PARAM_OPTIONAL Z_PARAM_LONG(csignal) + Z_PARAM_BOOL(rekey_memory) ZEND_PARSE_PARAMETERS_END(); /* This is a flag to use with great caution in general, preferably not within PHP */ @@ -1599,7 +1605,7 @@ PHP_FUNCTION(pcntl_rfork) php_error_docref(NULL, E_WARNING, "Error %d", errno); } } else if (pid == 0) { - php_child_init(); + php_child_init_ex(rekey_memory); } RETURN_LONG((zend_long) pid); @@ -1616,9 +1622,12 @@ PHP_FUNCTION(pcntl_forkx) { zend_long flags; pid_t pid; + bool rekey_memory = true; - ZEND_PARSE_PARAMETERS_START(1, 1) + ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_LONG(flags) + Z_PARAM_OPTIONAL + Z_PARAM_BOOL(rekey_memory) ZEND_PARSE_PARAMETERS_END(); if (flags < FORK_NOSIGCHLD || flags > FORK_WAITPID) { @@ -1644,7 +1653,7 @@ PHP_FUNCTION(pcntl_forkx) php_error_docref(NULL, E_WARNING, "Error %d", errno); } } else if (pid == 0) { - php_child_init(); + php_child_init_ex(rekey_memory); } RETURN_LONG((zend_long) pid); diff --git a/ext/pcntl/pcntl.stub.php b/ext/pcntl/pcntl.stub.php index 1ab52d025036..f7ce0c524490 100644 --- a/ext/pcntl/pcntl.stub.php +++ b/ext/pcntl/pcntl.stub.php @@ -1000,7 +1000,7 @@ const PCNTL_ECAPMODE = UNKNOWN; #endif - function pcntl_fork(): int {} + function pcntl_fork(bool $rekey_memory = true): int {} /** * @param int $status @@ -1085,11 +1085,11 @@ function pcntl_unshare(int $flags): bool {} #endif #ifdef HAVE_RFORK - function pcntl_rfork(int $flags, int $signal = 0): int{} + function pcntl_rfork(int $flags, int $signal = 0, bool $rekey_memory = true): int{} #endif #ifdef HAVE_FORKX - function pcntl_forkx(int $flags): int{} + function pcntl_forkx(int $flags, bool $rekey_memory = true): int{} #endif #ifdef HAVE_PIDFD_OPEN diff --git a/ext/pcntl/pcntl_arginfo.h b/ext/pcntl/pcntl_arginfo.h index a9d6bb010e29..4cf6fb257083 100644 --- a/ext/pcntl/pcntl_arginfo.h +++ b/ext/pcntl/pcntl_arginfo.h @@ -1,11 +1,12 @@ /* This is a generated file, edit pcntl.stub.php instead. - * Stub hash: ec6306e93fad6d127ff880fc01736ac287619cf7 + * Stub hash: 8c2273c72be8d137c03c46418913ae31c42e716a * Has decl header: yes */ #include "zend_constants.h" #include "zend_enum.h" ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_fork, 0, 0, IS_LONG, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_memory, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_waitpid, 0, 2, IS_LONG, 0) @@ -98,9 +99,10 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_alarm, 0, 1, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, seconds, IS_LONG, 0) ZEND_END_ARG_INFO() -#define arginfo_pcntl_get_last_error arginfo_pcntl_fork +ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_get_last_error, 0, 0, IS_LONG, 0) +ZEND_END_ARG_INFO() -#define arginfo_pcntl_errno arginfo_pcntl_fork +#define arginfo_pcntl_errno arginfo_pcntl_get_last_error #if defined(HAVE_GETPRIORITY) ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_pcntl_getpriority, 0, 0, MAY_BE_LONG|MAY_BE_FALSE) @@ -135,12 +137,14 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_rfork, 0, 1, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, flags, IS_LONG, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, signal, IS_LONG, 0, "0") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_memory, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() #endif #if defined(HAVE_FORKX) ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_forkx, 0, 1, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, flags, IS_LONG, 0) + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_memory, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() #endif diff --git a/ext/pcntl/pcntl_decl.h b/ext/pcntl/pcntl_decl.h index a0fdb4dde755..74c1ce620774 100644 --- a/ext/pcntl/pcntl_decl.h +++ b/ext/pcntl/pcntl_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit pcntl.stub.php instead. - * Stub hash: ec6306e93fad6d127ff880fc01736ac287619cf7 */ + * Stub hash: 8c2273c72be8d137c03c46418913ae31c42e716a */ -#ifndef ZEND_PCNTL_DECL_ec6306e93fad6d127ff880fc01736ac287619cf7_H -#define ZEND_PCNTL_DECL_ec6306e93fad6d127ff880fc01736ac287619cf7_H +#ifndef ZEND_PCNTL_DECL_8c2273c72be8d137c03c46418913ae31c42e716a_H +#define ZEND_PCNTL_DECL_8c2273c72be8d137c03c46418913ae31c42e716a_H typedef enum zend_enum_Pcntl_QosClass { ZEND_ENUM_Pcntl_QosClass_UserInteractive = 1, @@ -12,4 +12,4 @@ typedef enum zend_enum_Pcntl_QosClass { ZEND_ENUM_Pcntl_QosClass_Background = 5, } zend_enum_Pcntl_QosClass; -#endif /* ZEND_PCNTL_DECL_ec6306e93fad6d127ff880fc01736ac287619cf7_H */ +#endif /* ZEND_PCNTL_DECL_8c2273c72be8d137c03c46418913ae31c42e716a_H */ diff --git a/ext/pcntl/tests/gh25403_cow.phpt b/ext/pcntl/tests/gh25403_cow.phpt new file mode 100644 index 000000000000..e6429ce8418a --- /dev/null +++ b/ext/pcntl/tests/gh25403_cow.phpt @@ -0,0 +1,32 @@ +--TEST-- +GH-25403: opting out of child memory re-keying avoids inherited free-list writes +--EXTENSIONS-- +pcntl +--SKIPIF-- + +--INI-- +memory_limit=128M +--FILE-- + $value) { + if ($i % 2) unset($strings[$i]); +} +$pid = pcntl_fork(rekey_memory: false); +if ($pid === -1) throw new Exception('Fork failed'); +if ($pid === 0) { + var_dump(getrusage()['ru_minflt'] < 2048); + exit(0); +} +if (pcntl_waitpid($pid, $status) !== $pid) throw new Exception('Wait failed'); +var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0); +?> +--EXPECT-- +bool(true) +bool(true) diff --git a/ext/pcntl/tests/gh25403_fork.phpt b/ext/pcntl/tests/gh25403_fork.phpt new file mode 100644 index 000000000000..1bf657e82948 --- /dev/null +++ b/ext/pcntl/tests/gh25403_fork.phpt @@ -0,0 +1,48 @@ +--TEST-- +pcntl_fork() preserves child initialization with optional memory re-keying +--EXTENSIONS-- +pcntl +--SKIPIF-- + +--FILE-- + $value) { + if ($i % 2) unset($objects[$i]); + } + realpath(__FILE__); + $pid = pcntl_fork(rekey_memory: $rekey); + if ($pid === -1) throw new Exception('Fork failed'); + if ($pid === 0) { + if (array_key_exists(__FILE__, realpath_cache_get())) exit(1); + for ($i = 0; $i < 8192; $i++) { + $objects[] = str_repeat('y', 128 + $i % 64); + } + gc_mem_caches(); + $pid = pcntl_fork(rekey_memory: !$rekey); + if ($pid === -1) exit(1); + if ($pid === 0) { + unset($objects); + gc_mem_caches(); + exit(0); + } + if (pcntl_waitpid($pid, $status) !== $pid) exit(1); + exit(pcntl_wifexited($status) ? pcntl_wexitstatus($status) : 1); + } + var_dump(pcntl_waitpid($pid, $status) === $pid); + var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0); +} +checkChild(false); +checkChild(true); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/pcntl/tests/gh25403_forkx.phpt b/ext/pcntl/tests/gh25403_forkx.phpt new file mode 100644 index 000000000000..4557a6a68e3a --- /dev/null +++ b/ext/pcntl/tests/gh25403_forkx.phpt @@ -0,0 +1,48 @@ +--TEST-- +pcntl_forkx() preserves child initialization with optional memory re-keying +--EXTENSIONS-- +pcntl +--SKIPIF-- + +--FILE-- + $value) { + if ($i % 2) unset($objects[$i]); + } + realpath(__FILE__); + $pid = pcntl_forkx(FORK_WAITPID, rekey_memory: $rekey); + if ($pid === -1) throw new Exception('Fork failed'); + if ($pid === 0) { + if (array_key_exists(__FILE__, realpath_cache_get())) exit(1); + for ($i = 0; $i < 8192; $i++) { + $objects[] = str_repeat('y', 128 + $i % 64); + } + gc_mem_caches(); + $pid = pcntl_fork(rekey_memory: !$rekey); + if ($pid === -1) exit(1); + if ($pid === 0) { + unset($objects); + gc_mem_caches(); + exit(0); + } + if (pcntl_waitpid($pid, $status) !== $pid) exit(1); + exit(pcntl_wifexited($status) ? pcntl_wexitstatus($status) : 1); + } + var_dump(pcntl_waitpid($pid, $status) === $pid); + var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0); +} +checkChild(false); +checkChild(true); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/pcntl/tests/gh25403_rfork.phpt b/ext/pcntl/tests/gh25403_rfork.phpt new file mode 100644 index 000000000000..ba1ef125c753 --- /dev/null +++ b/ext/pcntl/tests/gh25403_rfork.phpt @@ -0,0 +1,48 @@ +--TEST-- +pcntl_rfork() preserves child initialization with optional memory re-keying +--EXTENSIONS-- +pcntl +--SKIPIF-- + +--FILE-- + $value) { + if ($i % 2) unset($objects[$i]); + } + realpath(__FILE__); + $pid = pcntl_rfork(0, 0, rekey_memory: $rekey); + if ($pid === -1) throw new Exception('Fork failed'); + if ($pid === 0) { + if (array_key_exists(__FILE__, realpath_cache_get())) exit(1); + for ($i = 0; $i < 8192; $i++) { + $objects[] = str_repeat('y', 128 + $i % 64); + } + gc_mem_caches(); + $pid = pcntl_fork(rekey_memory: !$rekey); + if ($pid === -1) exit(1); + if ($pid === 0) { + unset($objects); + gc_mem_caches(); + exit(0); + } + if (pcntl_waitpid($pid, $status) !== $pid) exit(1); + exit(pcntl_wifexited($status) ? pcntl_wexitstatus($status) : 1); + } + var_dump(pcntl_waitpid($pid, $status) === $pid); + var_dump(pcntl_wifexited($status) && pcntl_wexitstatus($status) === 0); +} +checkChild(false); +checkChild(true); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/main/main.c b/main/main.c index 962739df4219..f2c0a81c846e 100644 --- a/main/main.c +++ b/main/main.c @@ -1863,9 +1863,9 @@ static void sigchld_handler(int apar) /* }}} */ #endif -PHPAPI void php_child_init(void) +PHPAPI void php_child_init_ex(bool rekey_memory) { - refresh_memory_manager(); + refresh_memory_manager_ex(rekey_memory); zend_max_execution_timer_init(); php_clear_stat_cache(true, NULL, 0); @@ -1876,6 +1876,11 @@ PHPAPI void php_child_init(void) } ZEND_HASH_FOREACH_END(); } +PHPAPI void php_child_init(void) +{ + php_child_init_ex(true); +} + /* {{{ php_request_startup */ zend_result php_request_startup(void) { diff --git a/main/php_main.h b/main/php_main.h index de2ace1c25ed..8f7d62dc0ff2 100644 --- a/main/php_main.h +++ b/main/php_main.h @@ -48,6 +48,7 @@ PHPAPI char *php_get_version(sapi_module_struct *sapi_module); PHPAPI void php_print_version(sapi_module_struct *sapi_module); PHPAPI void php_child_init(void); +PHPAPI void php_child_init_ex(bool rekey_memory); PHPAPI zend_result php_request_startup(void); PHPAPI void php_request_shutdown(void *dummy); PHPAPI zend_result php_module_startup(sapi_module_struct *sf, zend_module_entry *additional_module); From c147a214888565f5de5d3a45d3ca374840635258 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sat, 14 Nov 2026 19:12:11 +0100 Subject: [PATCH 2/5] Avoid re-keying inherited free lists after fork --- Zend/zend_alloc.c | 39 ++++++++++++++++++--------------------- ext/pcntl/README.md | 15 +++++++-------- 2 files changed, 25 insertions(+), 29 deletions(-) diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index 968511abdc92..a0388660666d 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -2266,33 +2266,30 @@ static void zend_mm_init_key(zend_mm_heap *heap) static void zend_mm_refresh_key_child_ex(zend_mm_heap *heap, bool rekey) { - if (!rekey) { - memset(&heap->rand_state, 0, sizeof(heap->rand_state)); -#if ZEND_DEBUG - heap->pid = getpid(); -#endif - return; - } - uintptr_t old_key = heap->shadow_key; zend_mm_init_key(heap); - /* Update shadow pointers with new key */ - for (uint32_t i = 0; i < ZEND_MM_BINS; i++) { - zend_mm_free_slot *slot = heap->free_slot[i]; - if (!slot) { - continue; - } - zend_mm_free_slot *next; - while ((next = slot->next_free_slot)) { - zend_mm_free_slot **shadow_addr = ZEND_MM_FREE_SLOT_PTR_SHADOW_ADDR(slot, i); - if (UNEXPECTED(next != zend_mm_decode_free_slot_key(old_key, shadow_addr, *shadow_addr))) { - zend_mm_panic("zend_mm_heap corrupted"); + if (rekey) { + /* Update shadow pointers with new key */ + for (uint32_t i = 0; i < ZEND_MM_BINS; i++) { + zend_mm_free_slot *slot = heap->free_slot[i]; + if (!slot) { + continue; + } + zend_mm_free_slot *next; + while ((next = slot->next_free_slot)) { + zend_mm_free_slot **shadow_addr = ZEND_MM_FREE_SLOT_PTR_SHADOW_ADDR(slot, i); + if (UNEXPECTED(next != zend_mm_decode_free_slot_key(old_key, shadow_addr, *shadow_addr))) { + zend_mm_panic("zend_mm_heap corrupted"); + } + zend_mm_set_next_free_slot(heap, i, slot, next); + slot = next; } - zend_mm_set_next_free_slot(heap, i, slot, next); - slot = next; } + } else { + /* Avoid touching inherited free-list pages in the child. */ + memset(heap->free_slot, 0, sizeof(heap->free_slot)); } zend_mm_rekey_cached_chunks(heap, old_key); diff --git a/ext/pcntl/README.md b/ext/pcntl/README.md index 1de462abb302..ee0f5284196c 100644 --- a/ext/pcntl/README.md +++ b/ext/pcntl/README.md @@ -7,15 +7,14 @@ shadow key in the child by default. This requires traversing and writing the inherited free lists and cached chunk list. A fragmented heap can therefore cause substantial child startup time and copy-on-write memory overhead. -Passing `rekey_memory: false` skips that traversal and retains the inherited -shadow key. Allocator links remain protected, but the parent and children share -the key: disclosure of that key in one process compromises this protection in -other processes that retain it. Callers must weigh this reduced isolation -against the startup and memory savings. The default preserves per-child key -isolation. +Passing `rekey_memory: false` skips rewriting inherited free-list links and +discards those free lists in the child. Those freed blocks remain part of their +chunks but cannot be reused by the child. This avoids the shared-key security +trade-off while preserving per-child key isolation. Cached chunks are still +re-keyed. -Both modes reset the allocator's random generator state so future key refreshes -use fresh randomness. Both also update allocator process bookkeeping and perform +Both modes generate a new allocator shadow key and reset its random generator +state. Both also update allocator process bookkeeping and perform the usual child initialization, including timer initialization, stat and realpath cache clearing, and extension child initialization hooks. The setting applies to one fork; subsequent forks use their own argument, defaulting to `true`. From a667bbb0a9e8bb1f9eee0c363750d92cbb4366a7 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sat, 14 Nov 2026 19:12:56 +0100 Subject: [PATCH 3/5] Document PCNTL fork parameter in UPGRADING --- UPGRADING | 8 ++++++++ ext/pcntl/README.md | 20 -------------------- 2 files changed, 8 insertions(+), 20 deletions(-) delete mode 100644 ext/pcntl/README.md diff --git a/UPGRADING b/UPGRADING index d9242ecb3c17..e13bc023ceee 100644 --- a/UPGRADING +++ b/UPGRADING @@ -121,6 +121,14 @@ PHP 8.7 UPGRADE NOTES 64 pipe or file handles no longer crashes; it fails with a warning, as that path remains bounded by WaitForMultipleObjects(). +- PCNTL: + . pcntl_fork(), pcntl_rfork() and pcntl_forkx() now accept an optional + $rekey_memory argument, which defaults to true. Set it to false to avoid + rewriting the child's inherited small free lists. These free blocks will + not be reused by the child, but per-child allocator key isolation is + preserved. Cached chunks are still re-keyed, and the usual child + initialization is performed. + ======================================== 6. New Functions ======================================== diff --git a/ext/pcntl/README.md b/ext/pcntl/README.md deleted file mode 100644 index ee0f5284196c..000000000000 --- a/ext/pcntl/README.md +++ /dev/null @@ -1,20 +0,0 @@ -# Memory re-keying after fork - -`pcntl_fork(bool $rekey_memory = true)`, -`pcntl_rfork(int $flags, int $signal = 0, bool $rekey_memory = true)` and -`pcntl_forkx(int $flags, bool $rekey_memory = true)` refresh the allocator's -shadow key in the child by default. This requires traversing and writing the -inherited free lists and cached chunk list. A fragmented heap can therefore -cause substantial child startup time and copy-on-write memory overhead. - -Passing `rekey_memory: false` skips rewriting inherited free-list links and -discards those free lists in the child. Those freed blocks remain part of their -chunks but cannot be reused by the child. This avoids the shared-key security -trade-off while preserving per-child key isolation. Cached chunks are still -re-keyed. - -Both modes generate a new allocator shadow key and reset its random generator -state. Both also update allocator process bookkeeping and perform -the usual child initialization, including timer initialization, stat and realpath -cache clearing, and extension child initialization hooks. The setting applies to -one fork; subsequent forks use their own argument, defaulting to `true`. From 66f752146bed4fcb0268508fa4a7366dadc91311 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sun, 15 Nov 2026 00:45:00 +0100 Subject: [PATCH 4/5] Clarify PCNTL free-list re-keying option --- NEWS | 4 ++-- UPGRADING | 2 +- Zend/zend_alloc.c | 6 +++--- Zend/zend_alloc.h | 2 +- ext/pcntl/pcntl.c | 18 +++++++++--------- ext/pcntl/pcntl.stub.php | 6 +++--- ext/pcntl/pcntl_arginfo.h | 6 +++--- ext/pcntl/tests/gh25403_cow.phpt | 2 +- ext/pcntl/tests/gh25403_fork.phpt | 4 ++-- ext/pcntl/tests/gh25403_forkx.phpt | 4 ++-- ext/pcntl/tests/gh25403_rfork.phpt | 4 ++-- main/main.c | 4 ++-- main/php_main.h | 2 +- 13 files changed, 32 insertions(+), 32 deletions(-) diff --git a/NEWS b/NEWS index e00ba0609b05..9b7d6afdde82 100644 --- a/NEWS +++ b/NEWS @@ -93,8 +93,8 @@ PHP NEWS . Fixed JIT ignoring exceptions from typed property set hooks. (David Carlier) - PCNTL: - . Added the $rekey_memory parameter to pcntl_fork(), pcntl_rfork() and - pcntl_forkx() to optionally avoid re-keying inherited allocator lists + . Added the $rekey_free_lists parameter to pcntl_fork(), pcntl_rfork() and + pcntl_forkx() to optionally avoid re-keying inherited free lists (GH-25403). (Alessio Attilio) - PDO: diff --git a/UPGRADING b/UPGRADING index e13bc023ceee..6ccd786996e8 100644 --- a/UPGRADING +++ b/UPGRADING @@ -123,7 +123,7 @@ PHP 8.7 UPGRADE NOTES - PCNTL: . pcntl_fork(), pcntl_rfork() and pcntl_forkx() now accept an optional - $rekey_memory argument, which defaults to true. Set it to false to avoid + $rekey_free_lists argument, which defaults to true. Set it to false to avoid rewriting the child's inherited small free lists. These free blocks will not be reused by the child, but per-child allocator key isolation is preserved. Cached chunks are still re-keyed, and the usual child diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index a0388660666d..8df125645692 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -2264,7 +2264,7 @@ static void zend_mm_init_key(zend_mm_heap *heap) zend_mm_refresh_key(heap); } -static void zend_mm_refresh_key_child_ex(zend_mm_heap *heap, bool rekey) +static void zend_mm_refresh_key_child_ex(zend_mm_heap *heap, bool rekey_free_lists) { uintptr_t old_key = heap->shadow_key; @@ -3413,9 +3413,9 @@ ZEND_API void shutdown_memory_manager(bool silent, bool full_shutdown) zend_mm_shutdown(AG(mm_heap), full_shutdown, silent); } -ZEND_API void refresh_memory_manager_ex(bool rekey) +ZEND_API void refresh_memory_manager_ex(bool rekey_free_lists) { - zend_mm_refresh_key_child_ex(AG(mm_heap), rekey); + zend_mm_refresh_key_child_ex(AG(mm_heap), rekey_free_lists); } ZEND_API void refresh_memory_manager(void) diff --git a/Zend/zend_alloc.h b/Zend/zend_alloc.h index 663d2e0441b2..ea0d12beed27 100644 --- a/Zend/zend_alloc.h +++ b/Zend/zend_alloc.h @@ -269,7 +269,7 @@ ZEND_API bool zend_alloc_in_memory_limit_error_reporting(void); ZEND_API void start_memory_manager(void); ZEND_API void shutdown_memory_manager(bool silent, bool full_shutdown); ZEND_API void refresh_memory_manager(void); -ZEND_API void refresh_memory_manager_ex(bool rekey); +ZEND_API void refresh_memory_manager_ex(bool rekey_free_lists); ZEND_API bool is_zend_mm(void); ZEND_API bool is_zend_ptr(const void *ptr); diff --git a/ext/pcntl/pcntl.c b/ext/pcntl/pcntl.c index 4f0d2e961803..df09578f4255 100644 --- a/ext/pcntl/pcntl.c +++ b/ext/pcntl/pcntl.c @@ -268,11 +268,11 @@ PHP_MINFO_FUNCTION(pcntl) PHP_FUNCTION(pcntl_fork) { pid_t id; - bool rekey_memory = true; + bool rekey_free_lists = true; ZEND_PARSE_PARAMETERS_START(0, 1) Z_PARAM_OPTIONAL - Z_PARAM_BOOL(rekey_memory) + Z_PARAM_BOOL(rekey_free_lists) ZEND_PARSE_PARAMETERS_END(); id = fork(); @@ -299,7 +299,7 @@ PHP_FUNCTION(pcntl_fork) } } else if (id == 0) { - php_child_init_ex(rekey_memory); + php_child_init_ex(rekey_free_lists); } RETURN_LONG((zend_long) id); @@ -1556,13 +1556,13 @@ PHP_FUNCTION(pcntl_rfork) zend_long flags; zend_long csignal = 0; pid_t pid; - bool rekey_memory = true; + bool rekey_free_lists = true; ZEND_PARSE_PARAMETERS_START(1, 3) Z_PARAM_LONG(flags) Z_PARAM_OPTIONAL Z_PARAM_LONG(csignal) - Z_PARAM_BOOL(rekey_memory) + Z_PARAM_BOOL(rekey_free_lists) ZEND_PARSE_PARAMETERS_END(); /* This is a flag to use with great caution in general, preferably not within PHP */ @@ -1605,7 +1605,7 @@ PHP_FUNCTION(pcntl_rfork) php_error_docref(NULL, E_WARNING, "Error %d", errno); } } else if (pid == 0) { - php_child_init_ex(rekey_memory); + php_child_init_ex(rekey_free_lists); } RETURN_LONG((zend_long) pid); @@ -1622,12 +1622,12 @@ PHP_FUNCTION(pcntl_forkx) { zend_long flags; pid_t pid; - bool rekey_memory = true; + bool rekey_free_lists = true; ZEND_PARSE_PARAMETERS_START(1, 2) Z_PARAM_LONG(flags) Z_PARAM_OPTIONAL - Z_PARAM_BOOL(rekey_memory) + Z_PARAM_BOOL(rekey_free_lists) ZEND_PARSE_PARAMETERS_END(); if (flags < FORK_NOSIGCHLD || flags > FORK_WAITPID) { @@ -1653,7 +1653,7 @@ PHP_FUNCTION(pcntl_forkx) php_error_docref(NULL, E_WARNING, "Error %d", errno); } } else if (pid == 0) { - php_child_init_ex(rekey_memory); + php_child_init_ex(rekey_free_lists); } RETURN_LONG((zend_long) pid); diff --git a/ext/pcntl/pcntl.stub.php b/ext/pcntl/pcntl.stub.php index f7ce0c524490..8affc892895a 100644 --- a/ext/pcntl/pcntl.stub.php +++ b/ext/pcntl/pcntl.stub.php @@ -1000,7 +1000,7 @@ const PCNTL_ECAPMODE = UNKNOWN; #endif - function pcntl_fork(bool $rekey_memory = true): int {} + function pcntl_fork(bool $rekey_free_lists = true): int {} /** * @param int $status @@ -1085,11 +1085,11 @@ function pcntl_unshare(int $flags): bool {} #endif #ifdef HAVE_RFORK - function pcntl_rfork(int $flags, int $signal = 0, bool $rekey_memory = true): int{} + function pcntl_rfork(int $flags, int $signal = 0, bool $rekey_free_lists = true): int{} #endif #ifdef HAVE_FORKX - function pcntl_forkx(int $flags, bool $rekey_memory = true): int{} + function pcntl_forkx(int $flags, bool $rekey_free_lists = true): int{} #endif #ifdef HAVE_PIDFD_OPEN diff --git a/ext/pcntl/pcntl_arginfo.h b/ext/pcntl/pcntl_arginfo.h index 4cf6fb257083..16fbed158ce4 100644 --- a/ext/pcntl/pcntl_arginfo.h +++ b/ext/pcntl/pcntl_arginfo.h @@ -6,7 +6,7 @@ #include "zend_enum.h" ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_fork, 0, 0, IS_LONG, 0) - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_memory, _IS_BOOL, 0, "true") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_free_lists, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_waitpid, 0, 2, IS_LONG, 0) @@ -137,14 +137,14 @@ ZEND_END_ARG_INFO() ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_rfork, 0, 1, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, flags, IS_LONG, 0) ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, signal, IS_LONG, 0, "0") - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_memory, _IS_BOOL, 0, "true") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_free_lists, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() #endif #if defined(HAVE_FORKX) ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_pcntl_forkx, 0, 1, IS_LONG, 0) ZEND_ARG_TYPE_INFO(0, flags, IS_LONG, 0) - ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_memory, _IS_BOOL, 0, "true") + ZEND_ARG_TYPE_INFO_WITH_DEFAULT_VALUE(0, rekey_free_lists, _IS_BOOL, 0, "true") ZEND_END_ARG_INFO() #endif diff --git a/ext/pcntl/tests/gh25403_cow.phpt b/ext/pcntl/tests/gh25403_cow.phpt index e6429ce8418a..e96ce2b07582 100644 --- a/ext/pcntl/tests/gh25403_cow.phpt +++ b/ext/pcntl/tests/gh25403_cow.phpt @@ -18,7 +18,7 @@ for ($i = 0; $i < 131072; $i++) { foreach ($strings as $i => $value) { if ($i % 2) unset($strings[$i]); } -$pid = pcntl_fork(rekey_memory: false); +$pid = pcntl_fork(rekey_free_lists: false); if ($pid === -1) throw new Exception('Fork failed'); if ($pid === 0) { var_dump(getrusage()['ru_minflt'] < 2048); diff --git a/ext/pcntl/tests/gh25403_fork.phpt b/ext/pcntl/tests/gh25403_fork.phpt index 1bf657e82948..fd5253f7e17a 100644 --- a/ext/pcntl/tests/gh25403_fork.phpt +++ b/ext/pcntl/tests/gh25403_fork.phpt @@ -17,7 +17,7 @@ function checkChild(bool $rekey): void { if ($i % 2) unset($objects[$i]); } realpath(__FILE__); - $pid = pcntl_fork(rekey_memory: $rekey); + $pid = pcntl_fork(rekey_free_lists: $rekey); if ($pid === -1) throw new Exception('Fork failed'); if ($pid === 0) { if (array_key_exists(__FILE__, realpath_cache_get())) exit(1); @@ -25,7 +25,7 @@ function checkChild(bool $rekey): void { $objects[] = str_repeat('y', 128 + $i % 64); } gc_mem_caches(); - $pid = pcntl_fork(rekey_memory: !$rekey); + $pid = pcntl_fork(rekey_free_lists: !$rekey); if ($pid === -1) exit(1); if ($pid === 0) { unset($objects); diff --git a/ext/pcntl/tests/gh25403_forkx.phpt b/ext/pcntl/tests/gh25403_forkx.phpt index 4557a6a68e3a..e3b8b493364a 100644 --- a/ext/pcntl/tests/gh25403_forkx.phpt +++ b/ext/pcntl/tests/gh25403_forkx.phpt @@ -17,7 +17,7 @@ function checkChild(bool $rekey): void { if ($i % 2) unset($objects[$i]); } realpath(__FILE__); - $pid = pcntl_forkx(FORK_WAITPID, rekey_memory: $rekey); + $pid = pcntl_forkx(FORK_WAITPID, rekey_free_lists: $rekey); if ($pid === -1) throw new Exception('Fork failed'); if ($pid === 0) { if (array_key_exists(__FILE__, realpath_cache_get())) exit(1); @@ -25,7 +25,7 @@ function checkChild(bool $rekey): void { $objects[] = str_repeat('y', 128 + $i % 64); } gc_mem_caches(); - $pid = pcntl_fork(rekey_memory: !$rekey); + $pid = pcntl_fork(rekey_free_lists: !$rekey); if ($pid === -1) exit(1); if ($pid === 0) { unset($objects); diff --git a/ext/pcntl/tests/gh25403_rfork.phpt b/ext/pcntl/tests/gh25403_rfork.phpt index ba1ef125c753..24e59998c150 100644 --- a/ext/pcntl/tests/gh25403_rfork.phpt +++ b/ext/pcntl/tests/gh25403_rfork.phpt @@ -17,7 +17,7 @@ function checkChild(bool $rekey): void { if ($i % 2) unset($objects[$i]); } realpath(__FILE__); - $pid = pcntl_rfork(0, 0, rekey_memory: $rekey); + $pid = pcntl_rfork(0, 0, rekey_free_lists: $rekey); if ($pid === -1) throw new Exception('Fork failed'); if ($pid === 0) { if (array_key_exists(__FILE__, realpath_cache_get())) exit(1); @@ -25,7 +25,7 @@ function checkChild(bool $rekey): void { $objects[] = str_repeat('y', 128 + $i % 64); } gc_mem_caches(); - $pid = pcntl_fork(rekey_memory: !$rekey); + $pid = pcntl_fork(rekey_free_lists: !$rekey); if ($pid === -1) exit(1); if ($pid === 0) { unset($objects); diff --git a/main/main.c b/main/main.c index f2c0a81c846e..bd4386f2cb2c 100644 --- a/main/main.c +++ b/main/main.c @@ -1863,9 +1863,9 @@ static void sigchld_handler(int apar) /* }}} */ #endif -PHPAPI void php_child_init_ex(bool rekey_memory) +PHPAPI void php_child_init_ex(bool rekey_free_lists) { - refresh_memory_manager_ex(rekey_memory); + refresh_memory_manager_ex(rekey_free_lists); zend_max_execution_timer_init(); php_clear_stat_cache(true, NULL, 0); diff --git a/main/php_main.h b/main/php_main.h index 8f7d62dc0ff2..008a761c542e 100644 --- a/main/php_main.h +++ b/main/php_main.h @@ -48,7 +48,7 @@ PHPAPI char *php_get_version(sapi_module_struct *sapi_module); PHPAPI void php_print_version(sapi_module_struct *sapi_module); PHPAPI void php_child_init(void); -PHPAPI void php_child_init_ex(bool rekey_memory); +PHPAPI void php_child_init_ex(bool rekey_free_lists); PHPAPI zend_result php_request_startup(void); PHPAPI void php_request_shutdown(void *dummy); PHPAPI zend_result php_module_startup(sapi_module_struct *sf, zend_module_entry *additional_module); From 9982aac87210cba2ce94ab42351b04d0658d2c52 Mon Sep 17 00:00:00 2001 From: Alessio Attilio Date: Sun, 15 Nov 2026 00:53:10 +0100 Subject: [PATCH 5/5] Fix free-list re-keying option variable --- Zend/zend_alloc.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index 8df125645692..86e62e7ce9e1 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -2270,7 +2270,7 @@ static void zend_mm_refresh_key_child_ex(zend_mm_heap *heap, bool rekey_free_lis zend_mm_init_key(heap); - if (rekey) { + if (rekey_free_lists) { /* Update shadow pointers with new key */ for (uint32_t i = 0; i < ZEND_MM_BINS; i++) { zend_mm_free_slot *slot = heap->free_slot[i];