Commit 7300fa520dd for php.net

commit 7300fa520dd38dadf9563a9104aa0036cb864147
Author: Ilia Alshanetsky <ilia@ilia.ws>
Date:   Mon Aug 24 10:36:56 2026 -0400

    ext/sqlite3: reject close() from inside a callback

    SQLite3::close() called from within a userland function, aggregate,
    collation or authorizer callback freed the registered statements and
    functions while sqlite3 was still executing, corrupting the active
    statement and crashing the request. Track callback re-entry with a
    per-database counter shared by all four callback kinds and throw an
    Error from close() while it is non-zero; the database stays usable and
    can be closed after the query completes.

    Closes GH-23650

diff --git a/NEWS b/NEWS
index 9a862b7e0e1..50815738590 100644
--- a/NEWS
+++ b/NEWS
@@ -27,6 +27,10 @@ PHP                                                                        NEWS
   . Fixed PDOStatement::getColumnMeta() reading out of bounds for an invalid
     column index. (Ilia Alshanetsky)

+- SQLite:
+  . Fixed a crash when SQLite3::close() is called from a userland callback.
+    (Ilia Alshanetsky)
+
 - Zip:
   . Fixed ZipArchive::extractTo() ignoring files given in a non-list array.
     (David Carlier)
diff --git a/ext/sqlite3/php_sqlite3_structs.h b/ext/sqlite3/php_sqlite3_structs.h
index 6d445d6642d..43e595affe1 100644
--- a/ext/sqlite3/php_sqlite3_structs.h
+++ b/ext/sqlite3/php_sqlite3_structs.h
@@ -50,6 +50,7 @@ typedef struct _php_sqlite3_func {
 	zend_fcall_info_cache func;
 	zend_fcall_info_cache step;
 	zend_fcall_info_cache fini;
+	unsigned int *in_callback_ptr;
 } php_sqlite3_func;

 /* Structure for SQLite collation function */
@@ -58,6 +59,7 @@ typedef struct _php_sqlite3_collation {

 	const char *collation_name;
 	zend_fcall_info_cache cmp_func;
+	unsigned int *in_callback_ptr;
 } php_sqlite3_collation;

 /* Structure for SQLite Database object. */
@@ -69,6 +71,7 @@ typedef struct _php_sqlite3_db_object  {
 	zend_fcall_info_cache authorizer_fcc;

 	bool exception;
+	unsigned int in_callback;

 	zend_llist free_list;
 	zend_object zo;
diff --git a/ext/sqlite3/sqlite3.c b/ext/sqlite3/sqlite3.c
index 20714c38aad..93e50b97c7f 100644
--- a/ext/sqlite3/sqlite3.c
+++ b/ext/sqlite3/sqlite3.c
@@ -191,6 +191,10 @@ PHP_METHOD(SQLite3, close)
 	}

 	if (db_obj->initialised) {
+		if (db_obj->in_callback) {
+			zend_throw_error(NULL, "Cannot close SQLite3 database while inside a callback");
+			RETURN_THROWS();
+		}
 		zend_llist_clean(&(db_obj->free_list));
 		if(db_obj->db) {
 			errcode = sqlite3_close(db_obj->db);
@@ -774,6 +778,9 @@ static int sqlite3_do_callback(zend_fcall_info_cache *fcc, uint32_t argc, sqlite
 	uint32_t fake_argc;
 	zend_result ret = SUCCESS;
 	php_sqlite3_agg_context *agg_context = NULL;
+	bool bailout = false;
+	php_sqlite3_func *cb_func = (php_sqlite3_func *)sqlite3_user_data(context);
+	unsigned int *in_callback = cb_func ? cb_func->in_callback_ptr : NULL;

 	if (is_agg) {
 		is_agg = 2;
@@ -781,6 +788,10 @@ static int sqlite3_do_callback(zend_fcall_info_cache *fcc, uint32_t argc, sqlite

 	fake_argc = argc + is_agg;

+	if (in_callback) {
+		(*in_callback)++;
+	}
+
 	/* build up the params */
 	if (fake_argc) {
 		zargs = (zval *)safe_emalloc(fake_argc, sizeof(zval), 0);
@@ -823,7 +834,15 @@ static int sqlite3_do_callback(zend_fcall_info_cache *fcc, uint32_t argc, sqlite
 		}
 	}

+	zend_try {
 	zend_call_known_fcc(fcc, &retval, fake_argc, zargs, /* named_params */ NULL);
+	} zend_catch {
+		bailout = true;
+	} zend_end_try();
+
+	if (in_callback) {
+		(*in_callback)--;
+	}

 	/* clean up the params */
 	if (is_agg) {
@@ -889,6 +908,9 @@ static int sqlite3_do_callback(zend_fcall_info_cache *fcc, uint32_t argc, sqlite
 	if (!Z_ISUNDEF(retval)) {
 		zval_ptr_dtor(&retval);
 	}
+	if (bailout) {
+		zend_bailout();
+	}
 	return ret;
 }
 /* }}}*/
@@ -929,6 +951,7 @@ static int php_sqlite3_callback_compare(void *coll, int a_len, const void *a, in
 	zval zargs[2];
 	zval retval;
 	int ret = 0;
+	bool bailout = false;

 	// Exception occurred on previous callback. Don't attempt to call function.
 	if (EG(exception)) {
@@ -938,10 +961,26 @@ static int php_sqlite3_callback_compare(void *coll, int a_len, const void *a, in
 	ZVAL_STRINGL(&zargs[0], a, a_len);
 	ZVAL_STRINGL(&zargs[1], b, b_len);

+	if (collation->in_callback_ptr) {
+		(*collation->in_callback_ptr)++;
+	}
+
+	zend_try {
 	zend_call_known_fcc(&collation->cmp_func, &retval, /* argc */ 2, zargs, /* named_params */ NULL);
+	} zend_catch {
+		bailout = true;
+	} zend_end_try();
+
+	if (collation->in_callback_ptr) {
+		(*collation->in_callback_ptr)--;
+	}

 	zval_ptr_dtor(&zargs[0]);
 	zval_ptr_dtor(&zargs[1]);
+	if (bailout) {
+		zval_ptr_dtor(&retval);
+		zend_bailout();
+	}

 	if (EG(exception)) {
 		ret = 0;
@@ -988,6 +1027,7 @@ PHP_METHOD(SQLite3, createFunction)
 	}

 	func = (php_sqlite3_func *)ecalloc(1, sizeof(*func));
+	func->in_callback_ptr = &db_obj->in_callback;

 	if (sqlite3_create_function(db_obj->db, sql_func, sql_func_num_args, flags | SQLITE_UTF8, func, php_sqlite3_callback_func, NULL, NULL) == SQLITE_OK) {
 		func->func_name = estrdup(sql_func);
@@ -1037,6 +1077,7 @@ PHP_METHOD(SQLite3, createAggregate)
 	}

 	func = (php_sqlite3_func *)ecalloc(1, sizeof(*func));
+	func->in_callback_ptr = &db_obj->in_callback;

 	if (sqlite3_create_function(db_obj->db, sql_func, sql_func_num_args, SQLITE_UTF8, func, NULL, php_sqlite3_callback_step, php_sqlite3_callback_final) == SQLITE_OK) {
 		func->func_name = estrdup(sql_func);
@@ -1085,6 +1126,7 @@ PHP_METHOD(SQLite3, createCollation)
 	}

 	collation = (php_sqlite3_collation *)ecalloc(1, sizeof(*collation));
+	collation->in_callback_ptr = &db_obj->in_callback;
 	if (sqlite3_create_collation(db_obj->db, collation_name, SQLITE_UTF8, collation, php_sqlite3_callback_compare) == SQLITE_OK) {
 		collation->collation_name = estrdup(collation_name);

@@ -2151,8 +2193,15 @@ static int php_sqlite3_authorizer(void *autharg, int action, const char *arg1, c
 	}

 	int authreturn = SQLITE_DENY;
+	bool bailout = false;

+	db_obj->in_callback++;
+	zend_try {
 	zend_call_known_fcc(&db_obj->authorizer_fcc, &retval, /* argc */ 5, argv, /* named_params */ NULL);
+	} zend_catch {
+		bailout = true;
+	} zend_end_try();
+	db_obj->in_callback--;
 	if (Z_ISUNDEF(retval)) {
 		php_sqlite3_error(db_obj, 0, "An error occurred while invoking the authorizer callback");
 	} else {
@@ -2176,6 +2225,9 @@ static int php_sqlite3_authorizer(void *autharg, int action, const char *arg1, c
 	zval_ptr_dtor(&argv[3]);
 	zval_ptr_dtor(&argv[4]);

+	if (bailout) {
+		zend_bailout();
+	}
 	return authreturn;
 }
 /* }}} */
diff --git a/ext/sqlite3/tests/sqlite3_close_from_callback.phpt b/ext/sqlite3/tests/sqlite3_close_from_callback.phpt
new file mode 100644
index 00000000000..0b5523717f7
--- /dev/null
+++ b/ext/sqlite3/tests/sqlite3_close_from_callback.phpt
@@ -0,0 +1,35 @@
+--TEST--
+SQLite3::close() from within a UDF callback must not corrupt active statement
+--EXTENSIONS--
+sqlite3
+--FILE--
+<?php
+$db = new SQLite3(':memory:');
+$db->createFunction('boom', function () use ($db) {
+    try {
+        var_dump($db->close());
+    } catch (Throwable $e) {
+        echo $e::class, ": ", $e->getMessage(), "\n";
+    }
+    return 1;
+});
+$stmt = $db->prepare('SELECT boom()');
+var_dump($stmt !== false);
+$res = $stmt->execute();
+var_dump($res !== false);
+var_dump($res->fetchArray(SQLITE3_NUM));
+$res->finalize();
+var_dump($db->close());
+echo "done\n";
+?>
+--EXPECT--
+bool(true)
+Error: Cannot close SQLite3 database while inside a callback
+bool(true)
+Error: Cannot close SQLite3 database while inside a callback
+array(1) {
+  [0]=>
+  int(1)
+}
+bool(true)
+done
diff --git a/ext/sqlite3/tests/sqlite3_close_from_callback_aggregate.phpt b/ext/sqlite3/tests/sqlite3_close_from_callback_aggregate.phpt
new file mode 100644
index 00000000000..b5bdb9615a7
--- /dev/null
+++ b/ext/sqlite3/tests/sqlite3_close_from_callback_aggregate.phpt
@@ -0,0 +1,41 @@
+--TEST--
+SQLite3::close() from within a createAggregate() callback must not corrupt active statement
+--EXTENSIONS--
+sqlite3
+--FILE--
+<?php
+$db = new SQLite3(':memory:');
+$db->exec('CREATE TABLE t (a INTEGER)');
+$db->exec('INSERT INTO t VALUES (1)');
+$db->exec('INSERT INTO t VALUES (2)');
+
+$reported = false;
+$db->createAggregate('agg', function ($context, $rows, $a) use (&$db, &$reported) {
+    if (!$reported) {
+        $reported = true;
+        try {
+            var_dump($db->close());
+        } catch (Throwable $e) {
+            echo $e::class, ": ", $e->getMessage(), "\n";
+        }
+    }
+    return (int) $context + $a;
+}, function ($context, $rows) {
+    return $context;
+}, 1);
+
+$stmt = $db->prepare('SELECT agg(a) FROM t');
+$res = $stmt->execute();
+var_dump($res->fetchArray(SQLITE3_NUM));
+$res->finalize();
+var_dump($db->close());
+echo "done\n";
+?>
+--EXPECT--
+Error: Cannot close SQLite3 database while inside a callback
+array(1) {
+  [0]=>
+  int(3)
+}
+bool(true)
+done
diff --git a/ext/sqlite3/tests/sqlite3_close_from_callback_authorizer.phpt b/ext/sqlite3/tests/sqlite3_close_from_callback_authorizer.phpt
new file mode 100644
index 00000000000..1d5cf9e4922
--- /dev/null
+++ b/ext/sqlite3/tests/sqlite3_close_from_callback_authorizer.phpt
@@ -0,0 +1,38 @@
+--TEST--
+SQLite3::close() from within a setAuthorizer() callback must not finalize live statements
+--EXTENSIONS--
+sqlite3
+--FILE--
+<?php
+$db = new SQLite3(':memory:');
+$db->exec('CREATE TABLE t (a INTEGER)');
+$db->exec('INSERT INTO t VALUES (7)');
+$live = $db->prepare('SELECT a FROM t');
+
+$reported = false;
+$db->setAuthorizer(function ($action, $arg1, $arg2, $arg3, $arg4) use (&$db, &$reported) {
+    if (!$reported) {
+        $reported = true;
+        try {
+            var_dump($db->close());
+        } catch (Throwable $e) {
+            echo $e::class, ": ", $e->getMessage(), "\n";
+        }
+    }
+    return SQLite3::OK;
+});
+
+$db->prepare('SELECT 1');
+
+$res = $live->execute();
+var_dump($res->fetchArray(SQLITE3_NUM));
+$res->finalize();
+echo "done\n";
+?>
+--EXPECT--
+Error: Cannot close SQLite3 database while inside a callback
+array(1) {
+  [0]=>
+  int(7)
+}
+done
diff --git a/ext/sqlite3/tests/sqlite3_close_from_callback_collation.phpt b/ext/sqlite3/tests/sqlite3_close_from_callback_collation.phpt
new file mode 100644
index 00000000000..16906894161
--- /dev/null
+++ b/ext/sqlite3/tests/sqlite3_close_from_callback_collation.phpt
@@ -0,0 +1,39 @@
+--TEST--
+SQLite3::close() from within a createCollation() callback must not corrupt active statement
+--EXTENSIONS--
+sqlite3
+--FILE--
+<?php
+$db = new SQLite3(':memory:');
+$db->exec('CREATE TABLE t (s TEXT)');
+$db->exec("INSERT INTO t VALUES ('b')");
+$db->exec("INSERT INTO t VALUES ('a')");
+
+$reported = false;
+$db->createCollation('CB', function ($x, $y) use (&$db, &$reported) {
+    if (!$reported) {
+        $reported = true;
+        try {
+            var_dump($db->close());
+        } catch (Throwable $e) {
+            echo $e::class, ": ", $e->getMessage(), "\n";
+        }
+    }
+    return strcmp($x, $y);
+});
+
+$stmt = $db->prepare('SELECT s FROM t ORDER BY s COLLATE CB');
+$res = $stmt->execute();
+while ($row = $res->fetchArray(SQLITE3_NUM)) {
+    echo $row[0], "\n";
+}
+$res->finalize();
+var_dump($db->close());
+echo "done\n";
+?>
+--EXPECT--
+Error: Cannot close SQLite3 database while inside a callback
+a
+b
+bool(true)
+done