Commit 8ce7f7f5e93 for php.net
commit 8ce7f7f5e93e7e8c527f55e3d98df976a1d0f03c
Author: Lazizbek Ergashev <lazerg2@gmail.com>
Date: Tue Aug 11 18:01:42 2026 +0100
Fix GH-23204: use-after-free when __toString() destroys an array argument
implode() walks the array with ZEND_HASH_FOREACH_VAL while holding no
reference on it. Converting a Stringable element runs user code, and if
that code drops the last remaining reference to the array (`$a = null;`
from __toString()), arData is freed and the next iteration reads freed
memory. strtr() and str_replace() read their array arguments the same
way and crash the same way, so they are fixed here too.
Taking a reference on the table for the duration of the read keeps it
alive and turns an in-place mutation into a separation instead, same as
zend_compare_symbol_tables() does around zend_hash_compare(). In
implode() the reference is released after the pieces have been
concatenated, since the collected zend_strings are still owned by the
array until then.
Close GH-23207
diff --git a/ext/standard/string.c b/ext/standard/string.c
index 0c7a7453eaa..34444d80d18 100644
--- a/ext/standard/string.c
+++ b/ext/standard/string.c
@@ -983,6 +983,9 @@ PHPAPI void php_implode(const zend_string *glue, HashTable *pieces, zval *return
uint32_t flags = ZSTR_GET_COPYABLE_CONCAT_PROPERTIES(glue);
+ /* Converting an element may call __toString(), which can destroy pieces. */
+ GC_TRY_ADDREF(pieces);
+
ZEND_HASH_FOREACH_VAL(pieces, tmp) {
if (EXPECTED(Z_TYPE_P(tmp) == IS_STRING)) {
ptr->str = Z_STR_P(tmp);
@@ -1042,6 +1045,7 @@ PHPAPI void php_implode(const zend_string *glue, HashTable *pieces, zval *return
}
free_alloca(strings, use_heap);
+ GC_TRY_DTOR_NO_REF(pieces);
RETURN_NEW_STR(str);
}
/* }}} */
@@ -3392,7 +3396,12 @@ static void php_strtr_array(zval *return_value, zend_string *str, HashTable *fro
{
if (zend_hash_num_elements(from_ht) < 1) {
RETURN_STR_COPY(str);
- } else if (zend_hash_num_elements(from_ht) == 1) {
+ }
+
+ /* Converting a replacement may call __toString(), which can destroy from_ht. */
+ GC_TRY_ADDREF(from_ht);
+
+ if (zend_hash_num_elements(from_ht) == 1) {
zend_long num_key;
zend_string *str_key, *tmp_str, *replace, *tmp_replace;
zval *entry;
@@ -3421,11 +3430,13 @@ static void php_strtr_array(zval *return_value, zend_string *str, HashTable *fro
}
zend_tmp_string_release(tmp_str);
zend_tmp_string_release(tmp_replace);
- return;
+ break;
} ZEND_HASH_FOREACH_END();
} else {
php_strtr_array_ex(return_value, str, from_ht);
}
+
+ GC_TRY_DTOR_NO_REF(from_ht);
}
/* {{{ Translates characters in str using given translation tables */
@@ -4485,6 +4496,17 @@ static void _php_str_replace_common(
RETURN_THROWS();
}
+ /* Converting an element may call __toString(), which can destroy the arrays. */
+ if (search_ht) {
+ GC_TRY_ADDREF(search_ht);
+ }
+ if (replace_ht) {
+ GC_TRY_ADDREF(replace_ht);
+ }
+ if (subject_ht) {
+ GC_TRY_ADDREF(subject_ht);
+ }
+
/* if subject is an array */
if (subject_ht) {
array_init(return_value);
@@ -4511,6 +4533,16 @@ static void _php_str_replace_common(
if (zcount) {
ZEND_TRY_ASSIGN_REF_LONG(zcount, count);
}
+
+ if (search_ht) {
+ GC_TRY_DTOR_NO_REF(search_ht);
+ }
+ if (replace_ht) {
+ GC_TRY_DTOR_NO_REF(replace_ht);
+ }
+ if (subject_ht) {
+ GC_TRY_DTOR_NO_REF(subject_ht);
+ }
}
/* {{{ php_str_replace_common */
diff --git a/ext/standard/tests/strings/gh23204.phpt b/ext/standard/tests/strings/gh23204.phpt
new file mode 100644
index 00000000000..e2ae20592c5
--- /dev/null
+++ b/ext/standard/tests/strings/gh23204.phpt
@@ -0,0 +1,109 @@
+--TEST--
+GH-23204 (Use-after-free when __toString() destroys the array being read)
+--CREDITS--
+e1abrador
+--FILE--
+<?php
+class Unset_ implements Stringable {
+ public function __toString(): string {
+ global $a;
+ $a = null;
+ return "X";
+ }
+}
+
+$a = [new Unset_, 2, 3, 4];
+echo "destroyed: ", implode(",", $a), "\n";
+var_dump($a);
+
+class Append implements Stringable {
+ public function __toString(): string {
+ global $b;
+ $b[] = str_repeat("y", 32);
+ return "X";
+ }
+}
+
+$b = [new Append, 2, 3, 4];
+echo "appended: ", implode(",", $b), "\n";
+echo "count: ", count($b), "\n";
+
+class Boom implements Stringable {
+ public function __toString(): string {
+ global $c;
+ $c = null;
+ throw new Exception("boom");
+ }
+}
+
+$c = [new Boom, 2, 3, 4];
+try {
+ implode(",", $c);
+} catch (Exception $e) {
+ echo $e::class, ': ', $e->getMessage(), "\n";
+}
+
+class UnsetPats implements Stringable {
+ public function __toString(): string {
+ global $d;
+ $d = null;
+ return "X";
+ }
+}
+
+$d = ["aa" => new UnsetPats, "bb" => "2", "cc" => "3", "dd" => "4"];
+echo "strtr: ", strtr("aabbccdd", $d), "\n";
+
+$e = ["aa" => new UnsetPats];
+$d = &$e;
+echo "strtr single: ", strtr("aabb", $e), "\n";
+
+class UnsetSearch implements Stringable {
+ public function __toString(): string {
+ global $f;
+ $f = null;
+ return "a";
+ }
+}
+
+$f = [new UnsetSearch, "b", "c", "d"];
+echo "str_replace search: ", str_replace($f, "z", "abcd"), "\n";
+
+class UnsetReplace implements Stringable {
+ public function __toString(): string {
+ global $g;
+ $g = null;
+ return "z";
+ }
+}
+
+$g = [new UnsetReplace, "y", "y", "y"];
+echo "str_replace replace: ", str_replace(["a", "b", "c", "d"], $g, "abcd"), "\n";
+
+class UnsetSubject implements Stringable {
+ public function __toString(): string {
+ global $h;
+ $h = null;
+ return "abcd";
+ }
+}
+
+$h = [new UnsetSubject, "abcd"];
+var_dump(str_replace("a", "z", $h));
+?>
+--EXPECT--
+destroyed: X,2,3,4
+NULL
+appended: X,2,3,4
+count: 5
+Exception: boom
+strtr: X234
+strtr single: Xbb
+str_replace search: zzzz
+str_replace replace: zyyy
+array(2) {
+ [0]=>
+ string(4) "zbcd"
+ [1]=>
+ string(4) "zbcd"
+}