Skip to content

Fix alignment in zend_string_safe_alloc()/zend_string_safe_realloc() - #23869

Draft
realFlowControl wants to merge 2 commits into
php:masterfrom
realFlowControl:florian/zstr-safe-alloc-align
Draft

realFlowControl wants to merge 2 commits into
php:masterfrom
realFlowControl:florian/zstr-safe-alloc-align

Conversation

@realFlowControl

@realFlowControl realFlowControl commented Sep 23, 2026

Copy link
Copy Markdown
Contributor

On x86_64 a zend_string is a 24-byte header, then the characters, then \0. zend_string_alloc() rounds that size up to a multiple of 8: ALIGN(24 + len + 1)

zend_string_safe_alloc() and zend_string_safe_realloc() should do the same, but they round up only part of the size and add the rest on top: n * m + ALIGN(24 + l + 1)

So these strings can be up to 7 bytes too small. That matters because the x86_64 asm in zend_string_equal_val() compares 8 bytes at a time. It expects the rounded size and reads up to that end. The same problem exists in the 32-bit x86 asm, which reads 4 bytes at a time.

Example

implode(",", ["abc", "d", "efg"]); // "abc,d,efg", 9 chars

implode() calls zend_string_safe_alloc(2, 1, 7): 2 separators of 1 byte each, plus 7 bytes of pieces.

So we need 34 bytes of memory (24 (header) + 9 (chars) + 1 (NULL)). In current master we do 2 * 1 + ALIGN(24 + 7 + 1) = 34 (24 + 7 + 1 = 32 is already a multiple of 8, but adding the two separator bytes gives 34, which is not). What we'd expect and what zend_string_equal_val() relies on is 40 bytes: ALIGN(2 * 1 + 24 + 7 + 1).

So the asm in zend_string_equal_val() reads 6 bytes past the end of the block.

Why did we not notice

The extra bytes are almost always there anyway:

  • ZendMM puts 34 bytes in the 40-byte bin
  • glibc malloc (USE_ZEND_ALLOC=0) gives 40 usable bytes for a 34-byte request
  • ASAN does not check inline asm
  • Valgrind replaces zend_string_equal_val() with a memcmp version

Reproducer

Electric Fence puts each allocation at the end of a page, followed by a page we can't read, forcing a segfault if we do.

docker run --rm -ti --platform linux/amd64 php:8.5-cli bash
# inside the container
apt-get update -qq && apt-get install -y -qq electric-fence
cat > /t.php <<"EOF"
<?php
$a = implode(",", ["abc", "d", "efg"]);
$b = implode(",", ["abc", "d", "efg"]);
var_dump($a === $b);
EOF
USE_ZEND_ALLOC=0 EF_ALIGNMENT=1 LD_PRELOAD=/usr/lib/libefence.so php -n /t.php

Checking the resulting core file with gdb:

Core was generated by `/usr/local/bin/php '' '''.
Program terminated with signal SIGSEGV, Segmentation fault.
#0  0x0000004000820d1b in zend_string_equal_val ()

Bonus

Less memory: str_repeat("x", 25) to str_repeat("x", 31) now use a 56-byte block instead of 64.

Thanks @morrisonlevi for pointing this out.

@realFlowControl
realFlowControl force-pushed the florian/zstr-safe-alloc-align branch from e779de2 to 49938ad Compare September 24, 2026 07:05
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