Skip to content

Bind free list shadow pointers to the slot holding them - #23376

Open
jvoisin wants to merge 1 commit into
php:masterfrom
jvoisin:xorixor
Open

Bind free list shadow pointers to the slot holding them#23376
jvoisin wants to merge 1 commit into
php:masterfrom
jvoisin:xorixor

Conversation

@jvoisin

@jvoisin jvoisin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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 slot that holds the shadow into the mix:

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

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.

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 slot address). 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.

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 slot that holds the shadow into the mix:

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

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.

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
slot address). 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.
@jvoisin

jvoisin commented Aug 19, 2026

Copy link
Copy Markdown
Contributor Author

Part of #14083

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant