Skip to content

Commit 10a0358

Browse files
committed
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
1 parent c56f5ad commit 10a0358

4 files changed

Lines changed: 119 additions & 1 deletion

File tree

ext/pdo_sqlite/sqlite_driver.c

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -859,7 +859,19 @@ static int authorizer(void *autharg, int access_type, const char *arg1, const ch
859859

860860
int authreturn = SQLITE_DENY;
861861

862+
zend_object *saved_obj = db_obj->authorizer_fcc.object;
863+
zend_object *saved_closure = db_obj->authorizer_fcc.closure;
864+
zend_fcc_addref(&db_obj->authorizer_fcc);
865+
862866
zend_call_known_fcc(&db_obj->authorizer_fcc, &retval, /* argc */ 5, argv, /* named_params */ NULL);
867+
868+
if (saved_obj) {
869+
OBJ_RELEASE(saved_obj);
870+
}
871+
if (saved_closure) {
872+
OBJ_RELEASE(saved_closure);
873+
}
874+
863875
if (Z_ISUNDEF(retval)) {
864876
ZEND_ASSERT(EG(exception));
865877
} else {

ext/pdo_sqlite/tests/gh22122.phpt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
--TEST--
2+
GH-22122 (Use-after-free in Pdo\Sqlite authorizer when callback releases the authorizer)
3+
--EXTENSIONS--
4+
pdo_sqlite
5+
--FILE--
6+
<?php
7+
$db = Pdo\Sqlite::connect('sqlite::memory:');
8+
9+
class Auth {
10+
public string $state = "alive";
11+
12+
public function authorize(int $action, ...$args): int {
13+
global $db;
14+
$db->setAuthorizer(null);
15+
echo "method: ", $this->state, "\n";
16+
return Pdo\Sqlite::OK;
17+
}
18+
}
19+
$auth = new Auth();
20+
$db->setAuthorizer([$auth, 'authorize']);
21+
unset($auth);
22+
$db->exec('SELECT 1');
23+
24+
$capture = "closure-alive";
25+
$closure = function (int $action, ...$args) use (&$capture, $db): int {
26+
$db->setAuthorizer(null);
27+
echo "closure: ", $capture, "\n";
28+
return Pdo\Sqlite::OK;
29+
};
30+
$db->setAuthorizer($closure);
31+
unset($closure);
32+
$db->exec('SELECT 2');
33+
34+
$db->exec('SELECT 3');
35+
echo "post-disable query ok\n";
36+
?>
37+
--EXPECT--
38+
method: alive
39+
closure: closure-alive
40+
post-disable query ok

ext/sqlite3/sqlite3.c

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2197,9 +2197,21 @@ static int php_sqlite3_authorizer(void *autharg, int action, const char *arg1, c
21972197

21982198
int authreturn = SQLITE_DENY;
21992199

2200+
zend_object *saved_obj = db_obj->authorizer_fcc.object;
2201+
zend_object *saved_closure = db_obj->authorizer_fcc.closure;
2202+
zend_fcc_addref(&db_obj->authorizer_fcc);
2203+
22002204
zend_call_known_fcc(&db_obj->authorizer_fcc, &retval, /* argc */ 5, argv, /* named_params */ NULL);
2205+
2206+
if (saved_obj) {
2207+
OBJ_RELEASE(saved_obj);
2208+
}
2209+
if (saved_closure) {
2210+
OBJ_RELEASE(saved_closure);
2211+
}
2212+
22012213
if (Z_ISUNDEF(retval)) {
2202-
php_sqlite3_error(db_obj, 0, "An error occurred while invoking the authorizer callback");
2214+
ZEND_ASSERT(EG(exception));
22032215
} else {
22042216
if (Z_TYPE(retval) != IS_LONG) {
22052217
php_sqlite3_error(db_obj, 0, "The authorizer callback returned an invalid type: expected int");

ext/sqlite3/tests/gh22122.phpt

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
--TEST--
2+
GH-22122 (Use-after-free in SQLite3 authorizer when callback releases the authorizer)
3+
--EXTENSIONS--
4+
sqlite3
5+
--FILE--
6+
<?php
7+
$db = new SQLite3(':memory:');
8+
9+
/* Method receiver - the reported UAF shape. */
10+
class Auth {
11+
public string $state = "alive";
12+
13+
public function authorize(int $action, ...$args): int {
14+
global $db;
15+
$db->setAuthorizer(null);
16+
echo "method: ", $this->state, "\n";
17+
return SQLite3::OK;
18+
}
19+
}
20+
$auth = new Auth();
21+
$db->setAuthorizer([$auth, 'authorize']);
22+
unset($auth);
23+
$db->exec('SELECT 1');
24+
25+
/* Closure receiver - exercises the saved_closure release path. */
26+
$capture = "closure-alive";
27+
$closure = function (int $action, ...$args) use (&$capture, $db): int {
28+
$db->setAuthorizer(null);
29+
echo "closure: ", $capture, "\n";
30+
return SQLite3::OK;
31+
};
32+
$db->setAuthorizer($closure);
33+
unset($closure);
34+
$db->exec('SELECT 2');
35+
36+
/* Confirm the authorizer was actually disabled by the callback (setAuthorizer null). */
37+
$db->exec('SELECT 3');
38+
echo "post-disable query ok\n";
39+
40+
/* Throwing callback should propagate the user's exception without redundant warnings. */
41+
$db->setAuthorizer(function () { throw new RuntimeException("from authorizer"); });
42+
try {
43+
@$db->exec('SELECT 4');
44+
} catch (RuntimeException $e) {
45+
echo "throw: ", $e->getMessage(), "\n";
46+
}
47+
echo "done\n";
48+
?>
49+
--EXPECT--
50+
method: alive
51+
closure: closure-alive
52+
post-disable query ok
53+
throw: from authorizer
54+
done

0 commit comments

Comments
 (0)