From 10a0358cd2d0787d893dab26a5e3ab7d50bfab0f Mon Sep 17 00:00:00 2001 From: Ilia Alshanetsky Date: Fri, 22 May 2026 07:47:42 -0400 Subject: [PATCH] Fix GH-22122: UAF in SQLite3/Pdo\Sqlite authorizer when callback releases it zend_call_known_fcc does not addref fcc->object or fcc->closure. When the authorizer callback invokes $db->setAuthorizer(null), zend_fcc_dtor frees the bound $this mid-call. Snapshot object/closure before zend_fcc_addref and release the snapshots after the call. Same fix in Pdo\Sqlite. Replace the misleading "An error occurred" warning on Z_ISUNDEF(retval) with ZEND_ASSERT(EG(exception)) to match Pdo\Sqlite's existing pattern. Fixes GH-22122 --- ext/pdo_sqlite/sqlite_driver.c | 12 +++++++ ext/pdo_sqlite/tests/gh22122.phpt | 40 +++++++++++++++++++++++ ext/sqlite3/sqlite3.c | 14 +++++++- ext/sqlite3/tests/gh22122.phpt | 54 +++++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 ext/pdo_sqlite/tests/gh22122.phpt create mode 100644 ext/sqlite3/tests/gh22122.phpt diff --git a/ext/pdo_sqlite/sqlite_driver.c b/ext/pdo_sqlite/sqlite_driver.c index 39efa02dd583..8d24a90c0810 100644 --- a/ext/pdo_sqlite/sqlite_driver.c +++ b/ext/pdo_sqlite/sqlite_driver.c @@ -859,7 +859,19 @@ static int authorizer(void *autharg, int access_type, const char *arg1, const ch int authreturn = SQLITE_DENY; + zend_object *saved_obj = db_obj->authorizer_fcc.object; + zend_object *saved_closure = db_obj->authorizer_fcc.closure; + zend_fcc_addref(&db_obj->authorizer_fcc); + zend_call_known_fcc(&db_obj->authorizer_fcc, &retval, /* argc */ 5, argv, /* named_params */ NULL); + + if (saved_obj) { + OBJ_RELEASE(saved_obj); + } + if (saved_closure) { + OBJ_RELEASE(saved_closure); + } + if (Z_ISUNDEF(retval)) { ZEND_ASSERT(EG(exception)); } else { diff --git a/ext/pdo_sqlite/tests/gh22122.phpt b/ext/pdo_sqlite/tests/gh22122.phpt new file mode 100644 index 000000000000..ae15d7490ed2 --- /dev/null +++ b/ext/pdo_sqlite/tests/gh22122.phpt @@ -0,0 +1,40 @@ +--TEST-- +GH-22122 (Use-after-free in Pdo\Sqlite authorizer when callback releases the authorizer) +--EXTENSIONS-- +pdo_sqlite +--FILE-- +setAuthorizer(null); + echo "method: ", $this->state, "\n"; + return Pdo\Sqlite::OK; + } +} +$auth = new Auth(); +$db->setAuthorizer([$auth, 'authorize']); +unset($auth); +$db->exec('SELECT 1'); + +$capture = "closure-alive"; +$closure = function (int $action, ...$args) use (&$capture, $db): int { + $db->setAuthorizer(null); + echo "closure: ", $capture, "\n"; + return Pdo\Sqlite::OK; +}; +$db->setAuthorizer($closure); +unset($closure); +$db->exec('SELECT 2'); + +$db->exec('SELECT 3'); +echo "post-disable query ok\n"; +?> +--EXPECT-- +method: alive +closure: closure-alive +post-disable query ok diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c index d257703f17ac..4b7080ea882e 100644 --- a/ext/sqlite3/sqlite3.c +++ b/ext/sqlite3/sqlite3.c @@ -2197,9 +2197,21 @@ static int php_sqlite3_authorizer(void *autharg, int action, const char *arg1, c int authreturn = SQLITE_DENY; + zend_object *saved_obj = db_obj->authorizer_fcc.object; + zend_object *saved_closure = db_obj->authorizer_fcc.closure; + zend_fcc_addref(&db_obj->authorizer_fcc); + zend_call_known_fcc(&db_obj->authorizer_fcc, &retval, /* argc */ 5, argv, /* named_params */ NULL); + + if (saved_obj) { + OBJ_RELEASE(saved_obj); + } + if (saved_closure) { + OBJ_RELEASE(saved_closure); + } + if (Z_ISUNDEF(retval)) { - php_sqlite3_error(db_obj, 0, "An error occurred while invoking the authorizer callback"); + ZEND_ASSERT(EG(exception)); } else { if (Z_TYPE(retval) != IS_LONG) { php_sqlite3_error(db_obj, 0, "The authorizer callback returned an invalid type: expected int"); diff --git a/ext/sqlite3/tests/gh22122.phpt b/ext/sqlite3/tests/gh22122.phpt new file mode 100644 index 000000000000..d2359cc22fb7 --- /dev/null +++ b/ext/sqlite3/tests/gh22122.phpt @@ -0,0 +1,54 @@ +--TEST-- +GH-22122 (Use-after-free in SQLite3 authorizer when callback releases the authorizer) +--EXTENSIONS-- +sqlite3 +--FILE-- +setAuthorizer(null); + echo "method: ", $this->state, "\n"; + return SQLite3::OK; + } +} +$auth = new Auth(); +$db->setAuthorizer([$auth, 'authorize']); +unset($auth); +$db->exec('SELECT 1'); + +/* Closure receiver - exercises the saved_closure release path. */ +$capture = "closure-alive"; +$closure = function (int $action, ...$args) use (&$capture, $db): int { + $db->setAuthorizer(null); + echo "closure: ", $capture, "\n"; + return SQLite3::OK; +}; +$db->setAuthorizer($closure); +unset($closure); +$db->exec('SELECT 2'); + +/* Confirm the authorizer was actually disabled by the callback (setAuthorizer null). */ +$db->exec('SELECT 3'); +echo "post-disable query ok\n"; + +/* Throwing callback should propagate the user's exception without redundant warnings. */ +$db->setAuthorizer(function () { throw new RuntimeException("from authorizer"); }); +try { + @$db->exec('SELECT 4'); +} catch (RuntimeException $e) { + echo "throw: ", $e->getMessage(), "\n"; +} +echo "done\n"; +?> +--EXPECT-- +method: alive +closure: closure-alive +post-disable query ok +throw: from authorizer +done