From 59266c65ddaa11431b190b85f8a31108a9a3fea1 Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Thu, 13 Aug 2026 10:35:34 -0400 Subject: [PATCH] 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. --- NEWS | 4 + ext/pdo/pdo_stmt.c | 23 ++++ ext/pdo/php_pdo_driver.h | 3 +- .../tests/pdo_sqlite_reentrant_bind.phpt | 102 ++++++++++++++++++ 4 files changed, 131 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_sqlite/tests/pdo_sqlite_reentrant_bind.phpt diff --git a/NEWS b/NEWS index 0d9e42792078..e674ac79a65b 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,10 @@ PHP NEWS . Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or next() call on the inner generator). (iliaal) +- PDO: + . Fixed a use-after-free when bindValue()/execute()/closeCursor() is called + from a bound parameter's __toString() during execute(). (iliaal) + 27 Aug 2026, PHP 8.4.25 - Core: diff --git a/ext/pdo/pdo_stmt.c b/ext/pdo/pdo_stmt.c index 4e41ea40f08b..09dacd194f29 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; } /* }}} */ @@ -394,6 +405,9 @@ PHP_METHOD(PDOStatement, execute) ZEND_PARSE_PARAMETERS_END(); PHP_STMT_GET_OBJ; + if (!pdo_stmt_disallow_reentrant_param_event(stmt)) { + RETURN_THROWS(); + } PDO_STMT_CLEAR_ERR(); if (input_params) { @@ -1436,6 +1450,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; @@ -1485,6 +1502,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) { @@ -1930,6 +1950,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 c3930f402246..3f9ef4e214de 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 000000000000..3d2be7c7ed60 --- /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-- +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