Skip to content

Commit b4d16f7

Browse files
authored
fix(zval): Heap corruption with persistent=true #424 (#622)
1 parent 38c763f commit b4d16f7

File tree

6 files changed

+135
-6
lines changed

6 files changed

+135
-6
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/sapi.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ fn test_sapi_multithread() {
166166
Ok(zval) => {
167167
assert!(zval.is_string());
168168
let string = zval.string().unwrap();
169-
let output = string.to_string();
169+
let output = string.clone();
170170
assert_eq!(output, format!("Hello, thread-{i}!"));
171171

172172
results.lock().unwrap().push((i, output));

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: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
use ext_php_rs::prelude::*;
2+
use ext_php_rs::types::Zval;
3+
4+
#[php_function]
5+
pub fn test_persistent_string() -> String {
6+
let mut z = Zval::new();
7+
let _ = z.set_string("PERSISTENT_STRING", true);
8+
// z is dropped here - this was causing heap corruption before the fix
9+
"persistent string test passed".to_string()
10+
}
11+
12+
#[php_function]
13+
pub fn test_non_persistent_string() -> String {
14+
let mut z = Zval::new();
15+
let _ = z.set_string("NON_PERSISTENT_STRING", false);
16+
"non-persistent string test passed".to_string()
17+
}
18+
19+
#[php_function]
20+
pub fn test_persistent_string_read() -> String {
21+
let mut z = Zval::new();
22+
let _ = z.set_string("READ_BEFORE_DROP", true);
23+
let value = z.str().unwrap_or("failed");
24+
format!("read: {value}")
25+
}
26+
27+
#[php_function]
28+
pub fn test_persistent_string_loop(count: i64) -> String {
29+
for i in 0..count {
30+
let mut z = Zval::new();
31+
let s = format!("LOOP_{i}");
32+
let _ = z.set_string(&s, true);
33+
}
34+
format!("completed {count} iterations")
35+
}
36+
37+
#[php_function]
38+
pub fn test_interned_string_persistent() -> String {
39+
let mut z = Zval::new();
40+
let _ = z.set_interned_string("INTERNED_PERSISTENT", true);
41+
"interned persistent test passed".to_string()
42+
}
43+
44+
#[php_function]
45+
pub fn test_interned_string_non_persistent() -> String {
46+
let mut z = Zval::new();
47+
let _ = z.set_interned_string("INTERNED_NON_PERSISTENT", false);
48+
"interned non-persistent test passed".to_string()
49+
}
50+
51+
pub fn build_module(builder: ModuleBuilder) -> ModuleBuilder {
52+
builder
53+
.function(wrap_function!(test_persistent_string))
54+
.function(wrap_function!(test_non_persistent_string))
55+
.function(wrap_function!(test_persistent_string_read))
56+
.function(wrap_function!(test_persistent_string_loop))
57+
.function(wrap_function!(test_interned_string_persistent))
58+
.function(wrap_function!(test_interned_string_non_persistent))
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
#[test]
64+
fn persistent_string_works() {
65+
assert!(crate::integration::test::run_php(
66+
"persistent_string/persistent_string.php"
67+
));
68+
}
69+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
$result = test_persistent_string();
3+
assert($result === "persistent string test passed", "Persistent string test failed: $result");
4+
5+
$result = test_non_persistent_string();
6+
assert($result === "non-persistent string test passed", "Non-persistent string test failed: $result");
7+
8+
$result = test_persistent_string_read();
9+
assert($result === "read: READ_BEFORE_DROP", "Persistent string read test failed: $result");
10+
11+
$result = test_persistent_string_loop(100);
12+
assert($result === "completed 100 iterations", "Persistent string loop test failed: $result");
13+
14+
$result = test_interned_string_persistent();
15+
assert($result === "interned persistent test passed", "Interned persistent string test failed: $result");
16+
17+
$result = test_interned_string_non_persistent();
18+
assert($result === "interned non-persistent test passed", "Interned non-persistent string test failed: $result");

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)