Commit 08a49a92256 for php

commit 08a49a922569ddd956beae05ede0dadb90ea576c
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Thu Aug 13 10:35:34 2026 -0400

    Fix HashTable UAF when rebound from a parameter __toString()

    dispatch_param_event iterates bound_params with ZEND_HASH_FOREACH
    while sqlite's EXEC_PRE hook can run __toString; execute() then
    destroys the table and bindValue() replaces buckets. Steal one
    _reserved bit as in_param_event (no layout size change; the header
    is installed) and throw Error from bindParam, bindValue, bindColumn,
    execute, and closeCursor while the hook is running. fetch is left
    unguarded: nested FOREACH is read-only and FETCH_POST writes column
    zvals, not the HashTable. 8.5/master already have a uint16_t
    bitfield with in_fetch; the forward merge needs in_param_event:1
    and reserved:11.

    Closes GH-23252

diff --git a/NEWS b/NEWS
index 77ed82419ae..ab9bca11cd5 100644
--- a/NEWS
+++ b/NEWS
@@ -121,6 +121,8 @@ PHP                                                                        NEWS
     that is not in the result set. (Ilia Alshanetsky)
   . Fixed PDOStatement::execute() leaving the previous result available
     after a failed execution. (Ilia Alshanetsky)
+  . Fixed a use-after-free when bindValue()/execute()/closeCursor() is called
+    from a bound parameter's __toString() during execute(). (Ilia Alshanetsky)
   . Fixed bug GH-23962 (Destroying a persistent PDO instance rolls back a
     transaction still in use by another instance). (Lazizbek Ergashev)
   . Fixed PDO::setAttribute() installing a PDO::ATTR_STATEMENT_CLASS class
diff --git a/ext/pdo/pdo_stmt.c b/ext/pdo/pdo_stmt.c
index 5fbfb0d220f..bb998834048 100644
--- a/ext/pdo/pdo_stmt.c
+++ b/ext/pdo/pdo_stmt.c
@@ -89,6 +89,15 @@ static inline bool rewrite_name_to_position(pdo_stmt_t *stmt, struct pdo_bound_p
 }
 /* }}} */

+static bool pdo_stmt_disallow_reentrant_param_event(pdo_stmt_t *stmt)
+{
+	if (UNEXPECTED(stmt->in_param_event)) {
+		zend_throw_error(NULL, "Cannot modify a PDOStatement while parameter hooks are running");
+		return false;
+	}
+	return true;
+}
+
 /* trigger callback hook for parameters */
 static bool dispatch_param_event(pdo_stmt_t *stmt, enum pdo_param_event event_type) /* {{{ */
 {
@@ -104,6 +113,7 @@ static bool dispatch_param_event(pdo_stmt_t *stmt, enum pdo_param_event event_ty
 		return 1;
 	}

+	stmt->in_param_event = 1;
 	ht = stmt->bound_params;

 iterate:
@@ -121,6 +131,7 @@ static bool dispatch_param_event(pdo_stmt_t *stmt, enum pdo_param_event event_ty
 		goto iterate;
 	}

+	stmt->in_param_event = 0;
 	return ret;
 }
 /* }}} */
@@ -415,6 +426,9 @@ PHP_METHOD(PDOStatement, execute)
 	ZEND_PARSE_PARAMETERS_END();

 	PHP_STMT_GET_OBJ;
+	if (!pdo_stmt_disallow_reentrant_param_event(stmt)) {
+		RETURN_THROWS();
+	}

 	if (stmt->executed) {
 		pdo_stmt_invalidate_result(stmt);
@@ -1461,6 +1475,9 @@ static void register_bound_param(INTERNAL_FUNCTION_PARAMETERS, int is_param) /*
 	ZEND_PARSE_PARAMETERS_END();

 	PHP_STMT_GET_OBJ;
+	if (!pdo_stmt_disallow_reentrant_param_event(stmt)) {
+		RETURN_THROWS();
+	}

 	param.param_type = (int) param_type;

@@ -1513,6 +1530,9 @@ PHP_METHOD(PDOStatement, bindValue)
 	ZEND_PARSE_PARAMETERS_END();

 	PHP_STMT_GET_OBJ;
+	if (!pdo_stmt_disallow_reentrant_param_event(stmt)) {
+		RETURN_THROWS();
+	}
 	param.param_type = (int) param_type;

 	if (param.name) {
@@ -1969,6 +1989,9 @@ PHP_METHOD(PDOStatement, closeCursor)
 	ZEND_PARSE_PARAMETERS_NONE();

 	PHP_STMT_GET_OBJ;
+	if (!pdo_stmt_disallow_reentrant_param_event(stmt)) {
+		RETURN_THROWS();
+	}
 	if (!stmt->methods->cursor_closer) {
 		/* emulate it by fetching and discarding rows */
 		do {
diff --git a/ext/pdo/php_pdo_driver.h b/ext/pdo/php_pdo_driver.h
index c3930f40224..3f9ef4e214d 100644
--- a/ext/pdo/php_pdo_driver.h
+++ b/ext/pdo/php_pdo_driver.h
@@ -567,8 +567,9 @@ struct _pdo_stmt_t {
 	 * bindParam() for its prepared statements, if false, PDO should
 	 * emulate prepare and bind on its behalf */
 	unsigned supports_placeholders:2;
+	unsigned in_param_event:1;

-	unsigned _reserved:29;
+	unsigned _reserved:28;

 	/* the number of columns in the result set; not valid until after
 	 * the statement has been executed at least once.  In some cases, might
diff --git a/ext/pdo_sqlite/tests/pdo_sqlite_reentrant_bind.phpt b/ext/pdo_sqlite/tests/pdo_sqlite_reentrant_bind.phpt
new file mode 100644
index 00000000000..3d2be7c7ed6
--- /dev/null
+++ b/ext/pdo_sqlite/tests/pdo_sqlite_reentrant_bind.phpt
@@ -0,0 +1,102 @@
+--TEST--
+Rebinding or re-executing from a parameter __toString() must not mutate bound_params mid-FOREACH
+--EXTENSIONS--
+pdo_sqlite
+--FILE--
+<?php
+class Rebind {
+    public function __construct(private PDOStatement $stmt) {}
+    public function __toString() {
+        try {
+            $this->stmt->bindValue(1, 'x');
+            echo "bindValue: no error\n";
+        } catch (Error $e) {
+            echo $e::class, ": ", $e->getMessage(), "\n";
+        }
+        return 'rebind';
+    }
+}
+
+class Reexec {
+    public function __construct(private PDOStatement $stmt) {}
+    public function __toString() {
+        try {
+            $this->stmt->execute(['x', 'y']);
+            echo "execute: no error\n";
+        } catch (Error $e) {
+            echo $e::class, ": ", $e->getMessage(), "\n";
+        }
+        return 'reexec';
+    }
+}
+
+class Reclose {
+    public function __construct(private PDOStatement $stmt) {}
+    public function __toString() {
+        try {
+            $this->stmt->closeCursor();
+            echo "closeCursor: no error\n";
+        } catch (Error $e) {
+            echo $e::class, ": ", $e->getMessage(), "\n";
+        }
+        return 'reclose';
+    }
+}
+
+$db = new PDO('sqlite::memory:');
+
+echo "bindValue:\n";
+$stmt = $db->prepare('SELECT ?, ?');
+$p1 = 'placeholder';
+$p2 = 'second';
+$stmt->bindParam(1, $p1);
+$stmt->bindParam(2, $p2);
+$p1 = new Rebind($stmt);
+try {
+    $stmt->execute();
+    echo "execute after bindValue: no error\n";
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), "\n";
+}
+
+echo "execute:\n";
+$stmt = $db->prepare('SELECT ?, ?');
+$p1 = 'placeholder';
+$p2 = 'second';
+$stmt->bindParam(1, $p1);
+$stmt->bindParam(2, $p2);
+$p1 = new Reexec($stmt);
+try {
+    $stmt->execute();
+    echo "execute after execute: no error\n";
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), "\n";
+}
+
+echo "closeCursor:\n";
+$stmt = $db->prepare('SELECT ?, ?');
+$p1 = 'placeholder';
+$p2 = 'second';
+$stmt->bindParam(1, $p1);
+$stmt->bindParam(2, $p2);
+$p1 = new Reclose($stmt);
+try {
+    $stmt->execute();
+    echo "execute after closeCursor: no error\n";
+} catch (Throwable $e) {
+    echo $e::class, ": ", $e->getMessage(), "\n";
+}
+
+echo "done\n";
+?>
+--EXPECT--
+bindValue:
+Error: Cannot modify a PDOStatement while parameter hooks are running
+execute after bindValue: no error
+execute:
+Error: Cannot modify a PDOStatement while parameter hooks are running
+execute after execute: no error
+closeCursor:
+Error: Cannot modify a PDOStatement while parameter hooks are running
+execute after closeCursor: no error
+done