Skip to content

fix: heap corruption when returning Binary of 0/1 packed bytes#730

Merged
ptondereau merged 1 commit intoextphprs:masterfrom
ptondereau:fix/729-binary-interned-heap-corruption
Apr 22, 2026
Merged

fix: heap corruption when returning Binary of 0/1 packed bytes#730
ptondereau merged 1 commit intoextphprs:masterfrom
ptondereau:fix/729-binary-interned-heap-corruption

Conversation

@ptondereau
Copy link
Copy Markdown
Member

@ptondereau ptondereau commented Apr 22, 2026

Description

Fixes #729.

What was broken

Returning a Binary from a #[php_function] crashed PHP with zend_mm_heap corrupted whenever the packed byte length was 0 or 1. A minimal repro from the issue:

#[php_function]
pub fn test_binary() -> Binary<u8> {
    Binary::from(vec![42])
}

The regression shipped in 0.15.8.

Why it crashed (short version for readers new to PHP internals)

PHP keeps a small table of pre-allocated, immutable strings that live for the entire process lifetime: one empty string (zend_empty_string) and one single-byte string per byte value (zend_one_char_string[0..=255]). They are meant to be shared and must never be freed by extension code.

When a zval holds a string, PHP records whether that string is:

  • IS_STRING_EX, meaning "refcounted, the engine owns the memory and must release it on drop", or
  • IS_INTERNED_STRING_EX, meaning "immutable global, do not touch on drop".

The previous optimization PR (#701) taught our ext_php_rs_zend_string_init C shim to return these interned globals directly for 0 and 1 byte inputs, and taught Zval::set_zend_string to detect them via the GC_IMMUTABLE flag and pick IS_INTERNED_STRING_EX. That part is fine.

The bug is that Zval::set_binary, which is what the Binary<T> return path uses, was not updated. It always marked the zval as refcounted. When PHP later tried to release the zval it tried to efree a process-global static, which is heap corruption.

The fix

Zval::set_binary now wraps the raw pointer returned by Pack::pack_into into the safe ZBox<ZendStr> wrapper and delegates to Zval::set_zend_string. That function already reads GC_IMMUTABLE and picks the correct flag, mirroring PHP's own ZVAL_STR macro in Zend/zend_types.h.

After this change there is a single place in the crate that decides the refcounted vs interned flag for a string zval, which removes the class of bug "someone added a new string path and forgot to handle interned statics".

Extra cleanups bundled in

Hardened NULL guard in the C shim. src/wrapper.c uses #pragma weak to reference zend_empty_string and zend_one_char_string so the fast path falls back to zend_string_init if the symbols do not resolve at link time. The previous check only verified zend_one_char_string. If only one of the two weak symbols failed to resolve, a len == 0 call would dereference a NULL zend_empty_string. The check now covers both symbols.

Silent truncation fixed in Zval::binary and Zval::binary_slice. These methods previously divided the byte length by size_of::<T>() and silently dropped trailing bytes. For example, calling binary::<u32>() on a 5 byte string returned Some(vec![one_u32]) and threw away the last byte. Both functions now return None when the byte length is not a clean multiple of the element size. Binary::from_zval already propagates None as a conversion failure, so callers that rely on PHP's FromZval boundary will see the same "could not convert" error surface they already handle for other failure modes.

Notes on behavior change

The change to Zval::binary / Zval::binary_slice is a minor behavioral change: callers that previously received truncated data now receive None. Any downstream code that was relying on the truncation was almost certainly masking a correctness issue. No other public API changes.

Checklist

Returning a Binary from a #[php_function] crashed PHP with "zend_mm_heap corrupted" when the packed byte length was 0 or 1. This was a regression introduced in 0.15.8 by PR extphprs#701, which short-circuits ext_php_rs_zend_string_init to return PHP's interned permanent statics (zend_empty_string, zend_one_char_string[c]) for length 0 and 1. Zval::set_zend_string was updated in that PR to detect these statics via GC_IMMUTABLE and flag the zval as InternedStringEx, but Zval::set_binary was missed and always flagged the zval as refcounted. On drop PHP tried to free a process-global static, leading to heap corruption.

Zval::set_binary now wraps the raw pointer from Pack::pack_into in ZBox<ZendStr> and delegates to set_zend_string, so every path that assigns a zend_string to a zval shares one flag-selection site, matching PHP's own ZVAL_STR macro in Zend/zend_types.h.

Also tightened the NULL guard in wrapper.c so the fast path is safe when only one of the two weak symbols resolves at link time, and fixed an adjacent correctness issue where Zval::binary::<T>() and Zval::binary_slice::<T>() silently truncated trailing bytes when the byte length was not a multiple of size_of::<T>(). Both functions now return None in that case rather than dropping data.

Fixes extphprs#729
@coveralls
Copy link
Copy Markdown

Coverage Report for CI Build 24792404673

Coverage increased (+0.3%) to 66.241%

Details

  • Coverage increased (+0.3%) from the base build.
  • Patch coverage: 6 uncovered changes across 1 file (67 of 73 lines covered, 91.78%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
src/types/zval.rs 73 67 91.78%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
src/types/zval.rs 1 81.91%

Coverage Stats

Coverage Status
Relevant Lines: 13063
Covered Lines: 8653
Line Coverage: 66.24%
Coverage Strength: 33.15 hits per line

💛 - Coveralls

@ptondereau ptondereau marked this pull request as ready for review April 22, 2026 17:25
@github-actions
Copy link
Copy Markdown

🐰 Bencher Report

Branchfix/729-binary-interned-heap-corruption
TestbedPHP 8.4.20 (cli) (built: Apr 11 2026 00:52:25) (NTS)

⚠️ WARNING: Truncated view!

The full continuous benchmarking report exceeds the maximum length allowed on this platform.

⚠️ WARNING: No Threshold found!

Without a Threshold, no Alerts will ever be generated.

🐰 View full continuous benchmarking report in Bencher

@ptondereau ptondereau merged commit 74bffd4 into extphprs:master Apr 22, 2026
66 checks passed
@ptondereau ptondereau deleted the fix/729-binary-interned-heap-corruption branch April 22, 2026 17:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Core dump when returning 1 byte Binary string

2 participants