[php-src] PHP-8.6: Fix HashTable UAF when rebound from a parameter __toString()

From: Date: Wed, 30 Sep 2026 12:55:09 +0000
Subject: [php-src] PHP-8.6: Fix HashTable UAF when rebound from a parameter __toString()
Groups: php.cvs 
Request: Send a blank email to php-cvs+get-141780@lists.php.net to get a copy of this message
Author: Ilia Alshanetsky (iliaal) Date: 2026-09-30T08:49:09-04:00 Commit: https://github.com/php/php-src/commit/08a49a922569ddd956beae05ede0dadb90ea576c Raw diff: https://github.com/php/php-src/commit/08a49a922569ddd956beae05ede0dadb90ea576c.diff 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 Changed paths: A ext/pdo_sqlite/tests/pdo_sqlite_reentrant_bind.phpt M NEWS M ext/pdo/pdo_stmt.c M ext/pdo/php_pdo_driver.h Diff: diff --git a/NEWS b/NEWS index 77ed82419aee..ab9bca11cd56 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 5fbfb0d220fd..bb998834048f 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 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-- +<?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

« previous php.cvs (#141780) next »