Skip to content

Commit 6ebb4ce

Browse files
committed
Zend: Add opt-in request-scoped allocation no-reuse mode
Enable with ZEND_MM_NO_REUSE=1 before engine startup. Clear and retain freed small allocations, page runs, and huge mappings until heap shutdown. Move reallocations to fresh storage and prevent allocator GC from reclaiming retired ranges. Keep retained mappings charged against memory_limit and reject incompatible allocator overrides rather than silently allowing reuse. This mitigates UAF exploitation through allocation reclamation like CVE-2026-109618 and CVE-2026-109619, at the cost of memory usage approaching cumulative allocation volume per request. Add tests for address non-overlap, clearing, reallocations, shutdown, accounting, and configuration errors. Document usage and limitations. This provides a nice alternative to #14304.
1 parent b9ea38e commit 6ebb4ce

12 files changed

Lines changed: 401 additions & 9 deletions

‎Zend/README.md‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,54 @@ Zend MM disabled:
2121
USE_ZEND_ALLOC=0 valgrind --leak-check=full sapi/cli/php -r 'leak();'
2222
```
2323

24+
### Request-scoped no-reuse mode
25+
26+
Set `ZEND_MM_NO_REUSE=1` in the process environment before starting PHP to
27+
prevent native Zend MM from reusing freed allocation ranges within a request:
28+
29+
```bash
30+
ZEND_MM_NO_REUSE=1 sapi/cli/php script.php
31+
```
32+
33+
The default is disabled: leave the variable unset or set it to `0`. Only the
34+
literal values `0` and `1` are accepted; other values cause an allocator panic.
35+
This is not a `php.ini` option. It is read at engine startup, so changing it with
36+
`putenv()` has no effect on the running engine. For web SAPIs, set it in the
37+
launcher or service environment before PHP starts, and restart PHP processes
38+
after changing it.
39+
40+
With the mode enabled:
41+
42+
- Freed small allocations, page runs, and huge mappings are cleared and retained
43+
until their heap shuts down, after request shutdown callbacks have finished.
44+
Retired payloads are not used to store allocator free-list links.
45+
- Reallocations of existing blocks always move to fresh storage, including
46+
shrinking and unchanged-size reallocations. The original storage is retired.
47+
- Allocator garbage collection does not reclaim retired storage. Addresses may
48+
be reused after heap shutdown, normally in the next request.
49+
- Retained mappings remain charged against `memory_limit`. Reaching the limit
50+
fails through the normal allocation-error path; it never enables reuse.
51+
`memory_get_usage(false)` can decrease after freeing an allocation even though
52+
the underlying storage remains retained.
53+
54+
Memory consumption approaches cumulative allocation volume rather than peak
55+
live memory. Clearing freed storage and moving reallocations also add memory
56+
bandwidth and copying costs. The mode is intended for selected short-lived
57+
requests, not long-running scripts or application workers that do not perform
58+
normal PHP request shutdown.
59+
60+
The mode requires native Zend MM. Combining it with `USE_ZEND_ALLOC=0`, custom
61+
heap or storage handlers, or `ZEND_MM_DEBUG` allocator wrappers causes an
62+
allocator panic rather than silently bypassing the protection.
63+
64+
This is exploit mitigation, not memory safety or a sandbox. Invalid accesses
65+
to retired storage may still occur without trapping. Persistent allocations and
66+
extension-owned allocations outside native Zend MM are not covered, nor are
67+
dangling references that survive heap shutdown.
68+
69+
See [Testing ZendMM No-Reuse Mode](../docs/source/miscellaneous/running-tests.rst#testing-zendmm-no-reuse-mode)
70+
for regression-test instructions.
71+
2472
### Shared extensions
2573

2674
Since PHP 5.3.11 it is possible to prevent shared extensions from unloading so
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
--TEST--
2+
ZendMM no-reuse mode keeps retired huge mappings accounted for
3+
--ENV--
4+
USE_ZEND_ALLOC=1
5+
ZEND_MM_NO_REUSE=1
6+
--FILE--
7+
<?php
8+
putenv("ZEND_MM_NO_REUSE=0");
9+
$initial = memory_get_usage(true);
10+
$buffer = str_repeat("x", 3 * 1024 * 1024);
11+
$allocated = memory_get_usage();
12+
$committed = memory_get_usage(true);
13+
unset($buffer);
14+
$released = memory_get_usage();
15+
$retained = memory_get_usage(true);
16+
$collected = gc_mem_caches();
17+
18+
var_dump($committed - $initial >= 3 * 1024 * 1024);
19+
var_dump($allocated - $released >= 3 * 1024 * 1024);
20+
var_dump($retained === $committed);
21+
var_dump($collected === 0);
22+
var_dump(memory_get_usage(true) === $retained);
23+
24+
register_shutdown_function(function () use ($retained) {
25+
var_dump(memory_get_usage(true) >= $retained);
26+
$buffer = str_repeat("y", 3 * 1024 * 1024);
27+
unset($buffer);
28+
var_dump(memory_get_usage(true) > $retained);
29+
});
30+
?>
31+
--EXPECT--
32+
bool(true)
33+
bool(true)
34+
bool(true)
35+
bool(true)
36+
bool(true)
37+
bool(true)
38+
bool(true)
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
--TEST--
2+
ZendMM no-reuse mode rejects invalid settings and incompatible allocators
3+
--SKIPIF--
4+
<?php
5+
if (!function_exists("proc_open")) {
6+
die("skip proc_open is required");
7+
}
8+
?>
9+
--FILE--
10+
<?php
11+
$env = getenv();
12+
$cases = [
13+
["ZEND_MM_NO_REUSE" => "invalid", "USE_ZEND_ALLOC" => "1"],
14+
["ZEND_MM_NO_REUSE" => "1", "USE_ZEND_ALLOC" => "0"],
15+
["ZEND_MM_NO_REUSE" => "1", "USE_ZEND_ALLOC" => "1", "ZEND_MM_DEBUG" => "poison_free=0"],
16+
];
17+
foreach ($cases as $case) {
18+
$process = proc_open(
19+
[PHP_BINARY, "-n", "-r", "echo 'Unexpected startup';"],
20+
[0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]],
21+
$pipes,
22+
null,
23+
array_replace($env, $case)
24+
);
25+
fclose($pipes[0]);
26+
$output = stream_get_contents($pipes[1]);
27+
$error = stream_get_contents($pipes[2]);
28+
fclose($pipes[1]);
29+
fclose($pipes[2]);
30+
var_dump(proc_close($process) !== 0);
31+
var_dump($output === "");
32+
echo strtok($error, "\r\n"), "\n";
33+
}
34+
?>
35+
--EXPECT--
36+
bool(true)
37+
bool(true)
38+
ZEND_MM_NO_REUSE must be 0 or 1
39+
bool(true)
40+
bool(true)
41+
ZEND_MM_NO_REUSE requires native ZendMM (USE_ZEND_ALLOC=1)
42+
bool(true)
43+
bool(true)
44+
ZEND_MM_NO_REUSE does not support custom heap handlers
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
--TEST--
2+
ZendMM no-reuse mode charges retired allocations against the memory limit
3+
--ENV--
4+
USE_ZEND_ALLOC=1
5+
ZEND_MM_NO_REUSE=1
6+
--INI--
7+
memory_limit=16M
8+
--FILE--
9+
<?php
10+
for ($i = 0; $i < 100; $i++) {
11+
$buffer = str_repeat("x", 1024 * 1024);
12+
unset($buffer);
13+
gc_mem_caches();
14+
}
15+
echo "Unexpected reuse\n";
16+
?>
17+
--EXPECTF--
18+
Fatal error: Allowed memory size of 16777216 bytes exhausted %s

‎Zend/zend_alloc.c‎

Lines changed: 76 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ typedef zend_ulong zend_mm_bitset; /* 4-byte or 8-byte integer */
188188
typedef zend_mm_bitset zend_mm_page_map[ZEND_MM_PAGE_MAP_LEN]; /* 64B */
189189

190190
#define ZEND_MM_IS_FRUN 0x00000000
191+
#define ZEND_MM_IS_RETIRED 0x20000000
191192
#define ZEND_MM_IS_LRUN 0x40000000
192193
#define ZEND_MM_IS_SRUN 0x80000000
193194

@@ -229,6 +230,7 @@ typedef struct _zend_mm_chunk zend_mm_chunk;
229230
typedef struct _zend_mm_huge_list zend_mm_huge_list;
230231

231232
static bool zend_mm_use_huge_pages = false;
233+
static bool zend_mm_no_reuse = false;
232234

233235
/*
234236
* Memory is retrieved from OS by chunks of fixed size 2MB.
@@ -287,6 +289,7 @@ struct _zend_mm_heap {
287289
#endif
288290

289291
zend_mm_huge_list *huge_list; /* list of huge allocated blocks */
292+
zend_mm_huge_list *retired_huge_list;
290293

291294
zend_mm_chunk *main_chunk;
292295
zend_mm_chunk *cached_chunks; /* list of unused chunks */
@@ -1261,6 +1264,12 @@ static zend_always_inline void zend_mm_free_large(zend_mm_heap *heap, zend_mm_ch
12611264
#if ZEND_MM_STAT
12621265
heap->size -= pages_count * ZEND_MM_PAGE_SIZE;
12631266
#endif
1267+
if (UNEXPECTED(zend_mm_no_reuse)) {
1268+
ZEND_MM_CHECK(!(chunk->map[page_num] & ZEND_MM_IS_RETIRED), "zend_mm_heap corrupted (double free)");
1269+
chunk->map[page_num] |= ZEND_MM_IS_RETIRED;
1270+
memset(ZEND_MM_PAGE_ADDR(chunk, page_num), 0, pages_count * ZEND_MM_PAGE_SIZE);
1271+
return;
1272+
}
12641273
zend_mm_free_pages(heap, chunk, page_num, pages_count);
12651274
}
12661275

@@ -1496,6 +1505,11 @@ static zend_always_inline void zend_mm_free_small(zend_mm_heap *heap, void *ptr,
14961505
} while (0);
14971506
#endif
14981507

1508+
if (UNEXPECTED(zend_mm_no_reuse)) {
1509+
memset(ptr, 0, bin_data_size[bin_num]);
1510+
return;
1511+
}
1512+
14991513
p = (zend_mm_free_slot*)ptr;
15001514
#if ZEND_MM_HEAP_PROTECTION
15011515
/* Catch the most common double-free pattern for free. */
@@ -1755,6 +1769,11 @@ static zend_always_inline void *zend_mm_realloc_heap(zend_mm_heap *heap, void *p
17551769
zend_mm_debug_info *dbg;
17561770
#endif
17571771

1772+
if (UNEXPECTED(zend_mm_no_reuse) && ptr != NULL) {
1773+
old_size = zend_mm_size(heap, ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
1774+
return zend_mm_realloc_slow(heap, ptr, size, MIN(old_size, MIN(size, copy_size)) ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
1775+
}
1776+
17581777
page_offset = ZEND_MM_ALIGNED_OFFSET(ptr, ZEND_MM_CHUNK_SIZE);
17591778
if (UNEXPECTED(page_offset == 0)) {
17601779
if (EXPECTED(ptr == NULL)) {
@@ -1968,6 +1987,12 @@ static size_t zend_mm_del_huge_block(zend_mm_heap *heap, void *ptr ZEND_FILE_LIN
19681987
list->next->prev = list->prev;
19691988
}
19701989
size = list->size;
1990+
if (UNEXPECTED(zend_mm_no_reuse)) {
1991+
list->next = heap->retired_huge_list;
1992+
list->prev = NULL;
1993+
heap->retired_huge_list = list;
1994+
return size;
1995+
}
19711996
zend_mm_free_heap(heap, list ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
19721997
return size;
19731998
}
@@ -2094,10 +2119,14 @@ static void zend_mm_free_huge(zend_mm_heap *heap, void *ptr ZEND_FILE_LINE_DC ZE
20942119
ZEND_MM_CHECK(ZEND_MM_ALIGNED_OFFSET(ptr, ZEND_MM_CHUNK_SIZE) == 0, "zend_mm_heap corrupted");
20952120
size = zend_mm_del_huge_block(heap, ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC);
20962121
zend_mm_check_huge_block_size(heap, size);
2097-
zend_mm_chunk_free(heap, ptr, size);
2122+
if (UNEXPECTED(zend_mm_no_reuse)) {
2123+
memset(ptr, 0, size);
2124+
} else {
2125+
zend_mm_chunk_free(heap, ptr, size);
20982126
#if ZEND_MM_STAT || ZEND_MM_LIMIT
2099-
heap->real_size -= size;
2127+
heap->real_size -= size;
21002128
#endif
2129+
}
21012130
#if ZEND_MM_STAT
21022131
heap->size -= size;
21032132
#endif
@@ -2196,6 +2225,7 @@ static zend_mm_heap *zend_mm_init(void)
21962225
heap->storage = NULL;
21972226
#endif
21982227
heap->huge_list = NULL;
2228+
heap->retired_huge_list = NULL;
21992229
#if ZEND_DEBUG
22002230
heap->pid = getpid();
22012231
#endif
@@ -2213,6 +2243,10 @@ ZEND_API size_t zend_mm_gc(zend_mm_heap *heap)
22132243
bool has_free_pages;
22142244
size_t collected = 0;
22152245

2246+
if (UNEXPECTED(zend_mm_no_reuse)) {
2247+
return 0;
2248+
}
2249+
22162250
#if ZEND_MM_CUSTOM
22172251
if (heap->use_custom_heap) {
22182252
size_t (*gc)(void) = heap->custom_heap._gc;
@@ -2377,6 +2411,10 @@ static zend_long zend_mm_find_leaks(zend_mm_heap *heap, zend_mm_chunk *p, uint32
23772411
i += bin_pages[bin_num];
23782412
} else /* if (p->map[i] & ZEND_MM_IS_LRUN) */ {
23792413
int pages_count = ZEND_MM_LRUN_PAGES(p->map[i]);
2414+
if (p->map[i] & ZEND_MM_IS_RETIRED) {
2415+
i += pages_count;
2416+
continue;
2417+
}
23802418
zend_mm_debug_info *dbg = (zend_mm_debug_info*)((char*)p + ZEND_MM_PAGE_SIZE * (i + pages_count) - ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_debug_info)));
23812419

23822420
if (dbg->filename == leak->filename && dbg->lineno == leak->lineno) {
@@ -2496,6 +2534,10 @@ static void zend_mm_check_leaks(zend_mm_heap *heap)
24962534
i += bin_pages[bin_num];
24972535
} else /* if (p->map[i] & ZEND_MM_IS_LRUN) */ {
24982536
int pages_count = ZEND_MM_LRUN_PAGES(p->map[i]);
2537+
if (p->map[i] & ZEND_MM_IS_RETIRED) {
2538+
i += pages_count;
2539+
continue;
2540+
}
24992541
zend_mm_debug_info *dbg = (zend_mm_debug_info*)((char*)p + ZEND_MM_PAGE_SIZE * (i + pages_count) - ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_debug_info)));
25002542

25012543
leak.addr = (void*)((char*)p + ZEND_MM_PAGE_SIZE * i);
@@ -2583,6 +2625,21 @@ ZEND_API void zend_mm_shutdown(zend_mm_heap *heap, bool full, bool silent)
25832625
}
25842626
#endif
25852627

2628+
/* Retired mappings stay reserved until all request callbacks have finished. */
2629+
list = heap->retired_huge_list;
2630+
heap->retired_huge_list = NULL;
2631+
while (list != NULL) {
2632+
zend_mm_huge_list *next = list->next;
2633+
size_t size = list->size;
2634+
zend_mm_check_huge_block_size(heap, size);
2635+
zend_mm_chunk_free(heap, list->ptr, size);
2636+
#if ZEND_MM_STAT || ZEND_MM_LIMIT
2637+
heap->real_size -= size;
2638+
#endif
2639+
zend_mm_free_heap(heap, list ZEND_FILE_LINE_CC ZEND_FILE_LINE_EMPTY_CC);
2640+
list = next;
2641+
}
2642+
25862643
#if ZEND_DEBUG
25872644
if (!silent) {
25882645
char *tmp = getenv("ZEND_ALLOC_PRINT_LEAKS");
@@ -3401,6 +3458,9 @@ static void alloc_globals_ctor(zend_alloc_globals *alloc_globals)
34013458
#if ZEND_MM_CUSTOM
34023459
tmp = getenv("USE_ZEND_ALLOC");
34033460
if (tmp && !ZEND_ATOL(tmp)) {
3461+
if (zend_mm_no_reuse) {
3462+
zend_mm_panic("ZEND_MM_NO_REUSE requires native ZendMM (USE_ZEND_ALLOC=1)");
3463+
}
34043464
bool tracked = (tmp = getenv("USE_TRACKED_ALLOC")) && ZEND_ATOL(tmp);
34053465
zend_mm_heap *mm_heap = alloc_globals->mm_heap = malloc(sizeof(zend_mm_heap));
34063466
memset(mm_heap, 0, sizeof(zend_mm_heap));
@@ -3449,6 +3509,12 @@ static void alloc_globals_dtor(zend_alloc_globals *alloc_globals)
34493509

34503510
ZEND_API void start_memory_manager(void)
34513511
{
3512+
const char *no_reuse = getenv("ZEND_MM_NO_REUSE");
3513+
if (no_reuse && strcmp(no_reuse, "0") != 0 && strcmp(no_reuse, "1") != 0) {
3514+
zend_mm_panic("ZEND_MM_NO_REUSE must be 0 or 1");
3515+
}
3516+
zend_mm_no_reuse = no_reuse && strcmp(no_reuse, "1") == 0;
3517+
34523518
#ifndef _WIN32
34533519
# if defined(_SC_PAGESIZE)
34543520
REAL_PAGE_SIZE = sysconf(_SC_PAGESIZE);
@@ -3506,6 +3572,10 @@ ZEND_API void zend_mm_set_custom_handlers_ex(zend_mm_heap *heap,
35063572
#if ZEND_MM_CUSTOM
35073573
zend_mm_heap *_heap = (zend_mm_heap*)heap;
35083574

3575+
if (UNEXPECTED(zend_mm_no_reuse) && (_malloc || _free || _realloc || _gc || _shutdown)) {
3576+
zend_mm_panic("ZEND_MM_NO_REUSE does not support custom heap handlers");
3577+
}
3578+
35093579
if (!_malloc && !_free && !_realloc) {
35103580
_heap->use_custom_heap = ZEND_MM_CUSTOM_HEAP_NONE;
35113581
} else {
@@ -3585,6 +3655,9 @@ ZEND_API zend_mm_heap *zend_mm_startup(void)
35853655

35863656
ZEND_API zend_mm_heap *zend_mm_startup_ex(const zend_mm_handlers *handlers, void *data, size_t data_size)
35873657
{
3658+
if (UNEXPECTED(zend_mm_no_reuse)) {
3659+
zend_mm_panic("ZEND_MM_NO_REUSE does not support custom storage handlers");
3660+
}
35883661
#if ZEND_MM_STORAGE
35893662
zend_mm_storage *storage;
35903663
zend_mm_storage tmp_storage = {
@@ -3636,6 +3709,7 @@ ZEND_API zend_mm_heap *zend_mm_startup_ex(const zend_mm_handlers *handlers, void
36363709
#endif
36373710
heap->storage = &tmp_storage;
36383711
heap->huge_list = NULL;
3712+
heap->retired_huge_list = NULL;
36393713
memset(heap->free_slot, 0, sizeof(heap->free_slot));
36403714
storage = _zend_mm_alloc(heap, sizeof(zend_mm_storage) + data_size ZEND_FILE_LINE_CC ZEND_FILE_LINE_CC);
36413715
if (!storage) {
@@ -3702,4 +3776,3 @@ ZEND_API char * __zend_strdup(const char *s)
37023776
}
37033777
zend_out_of_memory();
37043778
}
3705-

‎docs/source/miscellaneous/running-tests.rst‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,25 @@ The test runner can run tests in parallel, by using the ``-j`` option:
8383
8484
php run-tests.php -j24 ext/date/*.phpt
8585
86+
Testing ZendMM No-Reuse Mode
87+
============================
88+
89+
The ``ZEND_MM_NO_REUSE`` environment option is documented in the `Zend memory-manager README
90+
<https://github.com/php/php-src/blob/master/Zend/README.md#request-scoped-no-reuse-mode>`__. Build
91+
with ``--enable-zend-test`` and run its dedicated regression tests:
92+
93+
.. code:: shell
94+
95+
sapi/cli/php run-tests.php Zend/tests/zend_mm_no_reuse_*.phpt \
96+
ext/zend_test/tests/zend_mm_no_reuse.phpt
97+
98+
These tests enable the mode in their own test processes. They check address-range non-overlap,
99+
clearing, reallocations, GC, repeated heap shutdown, memory accounting, and incompatible
100+
configurations.
101+
102+
Most ordinary tests should still be run in the default allocator mode; tests relying on allocation
103+
recycling or bounded memory usage can behave differently with no-reuse enabled.
104+
86105
**************
87106
Test results
88107
**************

0 commit comments

Comments
 (0)