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