Skip to content

Commit 8255f1d

Browse files
committed
fix(zval): Heap corruption with persistent=true #424
1 parent 6b192e8 commit 8255f1d

File tree

5 files changed

+158
-5
lines changed

5 files changed

+158
-5
lines changed

src/types/zval.rs

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ use crate::{
1313
convert::{FromZval, FromZvalMut, IntoZval, IntoZvalDyn},
1414
error::{Error, Result},
1515
ffi::{
16-
_zval_struct__bindgen_ty_1, _zval_struct__bindgen_ty_2, zend_is_callable,
17-
zend_is_identical, zend_is_iterable, zend_resource, zend_value, zval, zval_ptr_dtor,
16+
_zval_struct__bindgen_ty_1, _zval_struct__bindgen_ty_2, ext_php_rs_zend_string_release,
17+
zend_is_callable, zend_is_identical, zend_is_iterable, zend_resource, zend_value, zval,
18+
zval_ptr_dtor,
1819
},
1920
flags::DataType,
2021
flags::ZvalTypeFlags,
@@ -496,6 +497,21 @@ impl Zval {
496497
/// * `val` - The value to set the zval as.
497498
/// * `persistent` - Whether the string should persist between requests.
498499
///
500+
/// # Persistent Strings
501+
///
502+
/// When `persistent` is `true`, the string is allocated from PHP's
503+
/// persistent heap (using `malloc`) rather than the request-bound heap.
504+
/// This is typically used for strings that need to survive across multiple
505+
/// PHP requests, such as class names, function names, or module-level data.
506+
///
507+
/// **Important:** The string will still be freed when the Zval is dropped.
508+
/// The `persistent` flag only affects which memory allocator is used. If
509+
/// you need a string to outlive the Zval, consider using
510+
/// [`std::mem::forget`] on the Zval or storing the string elsewhere.
511+
///
512+
/// For most use cases (return values, function arguments, temporary
513+
/// storage), you should use `persistent: false`.
514+
///
499515
/// # Errors
500516
///
501517
/// Never returns an error.
@@ -507,6 +523,9 @@ impl Zval {
507523

508524
/// Sets the value of the zval as a Zend string.
509525
///
526+
/// The Zval takes ownership of the string. When the Zval is dropped,
527+
/// the string will be released.
528+
///
510529
/// # Parameters
511530
///
512531
/// * `val` - String content.
@@ -527,9 +546,13 @@ impl Zval {
527546
self.value.str_ = ptr;
528547
}
529548

530-
/// Sets the value of the zval as a interned string. Returns nothing in a
549+
/// Sets the value of the zval as an interned string. Returns nothing in a
531550
/// result when successful.
532551
///
552+
/// Interned strings are stored once and are immutable. PHP stores them in
553+
/// an internal hashtable. Unlike regular strings, interned strings are not
554+
/// reference counted and should not be freed by `zval_ptr_dtor`.
555+
///
533556
/// # Parameters
534557
///
535558
/// * `val` - The value to set the zval as.
@@ -540,7 +563,10 @@ impl Zval {
540563
/// Never returns an error.
541564
// TODO: Check if we can drop the result here.
542565
pub fn set_interned_string(&mut self, val: &str, persistent: bool) -> Result<()> {
543-
self.set_zend_string(ZendStr::new_interned(val, persistent));
566+
// Use InternedStringEx (without RefCounted) because interned strings
567+
// should not have their refcount modified by zval_ptr_dtor.
568+
self.change_type(ZvalTypeFlags::InternedStringEx);
569+
self.value.str_ = ZendStr::new_interned(val, persistent).into_raw();
544570
Ok(())
545571
}
546572

@@ -676,7 +702,21 @@ impl Zval {
676702
fn change_type(&mut self, ty: ZvalTypeFlags) {
677703
// SAFETY: we have exclusive mutable access to this zval so can free the
678704
// contents.
679-
unsafe { zval_ptr_dtor(self) };
705+
//
706+
// For strings, we use zend_string_release directly instead of zval_ptr_dtor
707+
// to correctly handle persistent strings. zend_string_release properly checks
708+
// the IS_STR_PERSISTENT flag and uses the correct deallocator (free vs efree).
709+
// This fixes heap corruption issues when dropping Zvals containing persistent
710+
// strings (see issue #424).
711+
if self.is_string() {
712+
unsafe {
713+
if let Some(str_ptr) = self.value.str_.as_mut() {
714+
ext_php_rs_zend_string_release(str_ptr);
715+
}
716+
}
717+
} else {
718+
unsafe { zval_ptr_dtor(self) };
719+
}
680720
self.u1.type_info = ty.bits();
681721
}
682722

tests/src/integration/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ pub mod magic_method;
1515
pub mod nullable;
1616
pub mod number;
1717
pub mod object;
18+
pub mod persistent_string;
1819
pub mod string;
1920
pub mod types;
2021
pub mod variadic_args;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
//! Integration tests for issue #424 - Zval::set_string with persistent flag
2+
//!
3+
//! Tests that persistent strings are correctly handled when a Zval is dropped.
4+
5+
use ext_php_rs::prelude::*;
6+
use ext_php_rs::types::Zval;
7+
8+
/// Test persistent string creation and drop
9+
#[php_function]
10+
pub fn test_persistent_string() -> String {
11+
let mut z = Zval::new();
12+
let _ = z.set_string("PERSISTENT_STRING", true);
13+
// z is dropped here - this was causing heap corruption before the fix
14+
"persistent string test passed".to_string()
15+
}
16+
17+
/// Test non-persistent string (baseline)
18+
#[php_function]
19+
pub fn test_non_persistent_string() -> String {
20+
let mut z = Zval::new();
21+
let _ = z.set_string("NON_PERSISTENT_STRING", false);
22+
"non-persistent string test passed".to_string()
23+
}
24+
25+
/// Test reading persistent string value before drop
26+
#[php_function]
27+
pub fn test_persistent_string_read() -> String {
28+
let mut z = Zval::new();
29+
let _ = z.set_string("READ_BEFORE_DROP", true);
30+
let value = z.str().unwrap_or("failed");
31+
format!("read: {}", value)
32+
}
33+
34+
/// Test persistent string in a loop
35+
#[php_function]
36+
pub fn test_persistent_string_loop(count: i64) -> String {
37+
for i in 0..count {
38+
let mut z = Zval::new();
39+
let s = format!("LOOP_{}", i);
40+
let _ = z.set_string(&s, true);
41+
}
42+
format!("completed {} iterations", count)
43+
}
44+
45+
/// Test interned string (persistent)
46+
#[php_function]
47+
pub fn test_interned_string_persistent() -> String {
48+
let mut z = Zval::new();
49+
let _ = z.set_interned_string("INTERNED_PERSISTENT", true);
50+
"interned persistent test passed".to_string()
51+
}
52+
53+
/// Test interned string (non-persistent)
54+
#[php_function]
55+
pub fn test_interned_string_non_persistent() -> String {
56+
let mut z = Zval::new();
57+
let _ = z.set_interned_string("INTERNED_NON_PERSISTENT", false);
58+
"interned non-persistent test passed".to_string()
59+
}
60+
61+
pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
62+
builder
63+
.function(wrap_function!(test_persistent_string))
64+
.function(wrap_function!(test_non_persistent_string))
65+
.function(wrap_function!(test_persistent_string_read))
66+
.function(wrap_function!(test_persistent_string_loop))
67+
.function(wrap_function!(test_interned_string_persistent))
68+
.function(wrap_function!(test_interned_string_non_persistent))
69+
}
70+
71+
#[cfg(test)]
72+
mod tests {
73+
#[test]
74+
fn persistent_string_works() {
75+
assert!(crate::integration::test::run_php("persistent_string/persistent_string.php"));
76+
}
77+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/**
4+
* Integration test for issue #424 - Zval::set_string with persistent flag
5+
*
6+
* Tests that persistent strings are correctly handled when a Zval is dropped,
7+
* without causing heap corruption.
8+
*/
9+
10+
// Test 1: Basic persistent string
11+
$result = test_persistent_string();
12+
assert($result === "persistent string test passed", "Persistent string test failed: $result");
13+
14+
// Test 2: Non-persistent string (baseline)
15+
$result = test_non_persistent_string();
16+
assert($result === "non-persistent string test passed", "Non-persistent string test failed: $result");
17+
18+
// Test 3: Read persistent string before drop
19+
$result = test_persistent_string_read();
20+
assert($result === "read: READ_BEFORE_DROP", "Persistent string read test failed: $result");
21+
22+
// Test 4: Persistent string in a loop (stress test)
23+
$result = test_persistent_string_loop(100);
24+
assert($result === "completed 100 iterations", "Persistent string loop test failed: $result");
25+
26+
// Test 5: Interned string (persistent)
27+
$result = test_interned_string_persistent();
28+
assert($result === "interned persistent test passed", "Interned persistent string test failed: $result");
29+
30+
// Test 6: Interned string (non-persistent)
31+
$result = test_interned_string_non_persistent();
32+
assert($result === "interned non-persistent test passed", "Interned non-persistent string test failed: $result");
33+
34+
echo "All persistent string tests passed!\n";

tests/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub fn build_module(module: ModuleBuilder) -> ModuleBuilder {
2929
module = integration::nullable::build_module(module);
3030
module = integration::number::build_module(module);
3131
module = integration::object::build_module(module);
32+
module = integration::persistent_string::build_module(module);
3233
module = integration::string::build_module(module);
3334
module = integration::variadic_args::build_module(module);
3435
module = integration::interface::build_module(module);

0 commit comments

Comments
 (0)