Skip to content

[pull] master from php:master - #1243

Merged
pull[bot] merged 9 commits into
turkdevops:masterfrom
php:master
Sep 4, 2026
Merged

[pull] master from php:master#1243
pull[bot] merged 9 commits into
turkdevops:masterfrom
php:master

Conversation

@pull

@pull pull Bot commented Sep 4, 2026

Copy link
Copy Markdown

See Commits and Changes for more details.


Created by pull[bot] (v2.0.0-alpha.4)

Can you help keep this open source service alive? 💖 Please sponsor : )

NickSdot and others added 9 commits September 4, 2026 11:43
The shadow of a free list pointer is currently BSWAP(next) ^ shadow_key,
which does not depend on where it is stored, meaning that:

 - zend_mm_free_small() encodes whatever heap->free_slot[bin] happens to
   be, including NULL when the bin has been drained, meaning
   the slot ends up holding shadow_key verbatim.
 - A (next, shadow) pair harvested from one free slot is valid in every
   other slot of every bin. An attacker who can read one free slot can
   therefore forge a link anywhere in the heap without needing the key.

This commit adds the address of the shadow itself into the mix:

    shadow = BSWAP(next) ^ shadow_key ^ (uintptr_t)&shadow

The holder term cancels on decode, so this is one extra xor on a register
that is already live, with no branch. Encoding NULL now yields
shadow_key ^ holder rather than the key, and a shadow only verifies in the
slot it was written for.

Using the address of the shadow rather than the address of the slot means
that zend_mm_get_next_free_slot() does not need to keep the slot alive
after fetching next, which keeps the register pressure unchanged.

This was verified under GDB: Freeing into a drained bin used to store shadow_key
exactly; it now stores shadow_key ^ holder (xoring the two back gives the
address of the shadow). Naïvely replaying a valid (next, shadow) pair from one
slot into another and traversing from it is accepted before this change and
aborts with "zend_mm_heap corrupted" after.

Performance-wise, the impact is in the noise level, which is expected as it more
or less adds a single `xor` instruction per `zend_mm_set_next_free_slot()`.

This commit is a follow up on 25360ef and c561f7d.

Co-authored-by: Arnaud Le Blanc <arnaud.lb@gmail.com>
Empty chunks are not always unmapped. zend_mm_delete_chunk() may retain
them in heap->cached_chunks so they can be reused without another mmap().
The list is linked through chunk headers that stay mapped and writable, so
an overwrite of a link controls the value that zend_mm_alloc_pages()
removes from the cache and hands to zend_mm_chunk_init(), which writes
through it and links it into the active chunk list.

Protect the list with the same key material as the small allocation
freelists. chunk->next keeps the plain pointer and the new
chunk->next_shadow holds an encoded copy:

    next_shadow = BSWAPPTR(next) ^ heap->shadow_key ^ &chunk->next_shadow

The byte swap makes a small overwrite corrupt the most significant bytes
of the address, which is unlikely to yield another valid chunk. Mixing in
the address of next_shadow prevents a valid (next, next_shadow) pair from
being replayed into another chunk. The shadow is an integrity check, not a
secret; the secret remains heap->shadow_key.

Reading a cached link decodes the shadow, checks that the result is
chunk-aligned and that it matches chunk->next, and only then dereferences
it. The head of the list is stored in the heap rather than in a chunk
header, so it gets an alignment check of its own when it is popped.

Cached chunks outlive request resets and forks, so their shadows are
recomputed by zend_mm_rekey_cached_chunks() whenever zend_mm_refresh_key()
or zend_mm_refresh_key_child() changes the key. That walk validates every
link against its old shadow, so corruption is detected rather than
silently re-encoded.

The next_shadow field is carved out of the chunk header's reserve field,
so the header is still 64 bytes, and chunk->next remains the ordinary
doubly-linked-list pointer while the chunk is active.
create_segments() reserves requested_size bytes with MAP_32BIT, frees
them, rounds the address up to the 2 MB huge page boundary, and then
MAP_FIXED-maps requested_size bytes at the new address.

The address goes up but the size stays the same, so the mapping ends up
to 2 MB above the memory we reserved, and MAP_FIXED discards what is
mapped there. If huge pages are available the remap succeeds and
replaces that memory. If they are not, mmap() fails, but the kernel has
already removed it and leaves a hole (mm/vma.c, vms_abort_munmap_vmas).

On a normal host there is usually nothing above the reservation, so this
is not visible. Under Rosetta 2 MAP_32BIT is not honored, the
reservation lands directly below libc, and the overshoot unmaps its
first pages: php-fpm then dies with SIGSEGV shortly after start.

Reserve one extra huge page, so the aligned range always stays inside
the reservation. zend_mm_chunk_alloc_int() already does this for 2 MB
aligned chunks.
* PHP-8.4:
  ext/opcache: keep huge page remap inside the reserved range (#23554)
* PHP-8.5:
  ext/opcache: keep huge page remap inside the reserved range (#23554)
Use unique temporary file names for tests that may run concurrently,
remove a CLEAN section that deletes another test's file, and replace
fixed server ports with ephemeral ports.
@pull pull Bot locked and limited conversation to collaborators Sep 4, 2026
@pull pull Bot added the ⤵️ pull label Sep 4, 2026
@pull
pull Bot merged commit 48d2b45 into turkdevops:master Sep 4, 2026
1 of 3 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants