Summary
Connection::authorizer registers a SQLite authorizer callback whose userdata is a raw pointer to the owning local::Connection (self as *const Connection). Connection::transaction clones the inner LibsqlConnection into a new allocation, so dropping the original Connection frees that struct while the underlying sqlite3 handle stays alive through the clone's drop_ref reference. Any SQL executed on the transaction afterwards re-enters authorizer_callback, which dereferences the dangling pointer and reads freed heap memory. This is a reproducible heap-uaf reachable entirely from safe Rust, confirmed against libsql 0.9.30 under Linux x86_64 ASan.
Details
Root Cause
local::Connection derives Clone. Its authorizer method hands SQLite a pointer to this Connection value (self as *const Connection) as the authorizer callback's userdata. A cloned Connection is a different Connection value at a different address, even though it shares the same sqlite3* handle (kept alive via the shared drop_ref: Arc<()>) and the same authorizer: Arc<RwLock<Option<AuthHook>>>.
Connection::transaction clones the inner connection. When the caller then drops the original Connection, its Drop implementation runs disconnect(), which closes sqlite3 only when drop_ref has no remaining clones — so the sqlite3 handle survives through the clone, but the original Connection struct is freed. The next authorizer callback reads(*conn).authorizer from that freed memory.
Relevant Code
libsql-0.9.30/src/local/connection.rs:
#[derive(Clone)]
pub struct Connection {
pub(crate) raw: *mut ffi::sqlite3,
drop_ref: Arc<()>,
authorizer: Arc<RwLock<Option<AuthHook>>>,
}
let (callback, user_data) = match hook {
Some(_) => {
let callback = authorizer_callback as unsafe extern "C" fn(_, _, _, _, _, _) -> _;
let user_data = self as *const Connection as *mut ::std::os::raw::c_void;
(Some(callback), user_data)
}
None => (None, std::ptr::null_mut()),
};
pub fn disconnect(&mut self) {
if Arc::get_mut(&mut self.drop_ref).is_some() {
unsafe { libsql_sys::ffi::sqlite3_close_v2(self.raw) };
}
}
unsafe extern "C" fn authorizer_callback(
user_data: *mut ::std::os::raw::c_void,
code: ::std::os::raw::c_int,
arg1: *const ::std::os::raw::c_char,
arg2: *const ::std::os::raw::c_char,
database_name: *const ::std::os::raw::c_char,
accessor: *const ::std::os::raw::c_char,
) -> ::std::os::raw::c_int {
let conn = user_data as *const Connection;
let hook = unsafe { (*conn).authorizer.read() };
libsql-0.9.30/src/local/impls.rs:
async fn transaction(&self, tx_behavior: TransactionBehavior) -> Result<Transaction> {
let tx = crate::local::Transaction::begin(self.conn.clone(), tx_behavior)?;
Ok(Transaction {
inner: Box::new(LibsqlTx(Some(tx))),
conn: Connection {
conn: Arc::new(self.clone()),
},
close: None,
})
}
The API contract mismatch is that the callback userdata points to a Connection value that is neither stable across Clone nor guaranteed to outlive the sqlite3 handle; the handle's lifetime is governed by drop_ref/Arc, while the userdata pointer is governed by the individual Connection value's Drop.
Recommended Fix
Do not pass self as the callback userdata. Instead point userdata at an allocation whose lifetime is independent of the Connection value's address, for example:
- the heap allocation behind the existing
authorizer: Arc<RwLock<Option<AuthHook>>> (e.g. Arc::as_ptr(&self.authorizer) as *mut c_void, then Arc::from_raw/re-derive the Arc in the callback), or
- a dedicated
Box::into_raw hook owned by the sqlite3 handle and freed when the authorizer is cleared and when the connection is disconnected.
PoC
Requirements
libsql = { version = "=0.9.30", default-features = false, features = ["core"] }
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
- Build and run under AddressSanitizer (Linux x86_64,
-Z sanitizer=address / nightly toolchain).
PoC
use libsql::{Authorization, Builder};
use std::sync::Arc;
#[tokio::main]
async fn main() {
let db = Builder::new_local(":memory:").build().await.unwrap();
let conn = db.connect().unwrap();
conn.authorizer(Some(Arc::new(|ctx| {
let _ = format!("{:?}", ctx.action);
Authorization::Allow
})))
.unwrap();
let tx = conn.transaction().await.unwrap();
eprintln!("transaction opened; dropping original connection");
drop(conn);
eprintln!("executing on tx (authorizer should fire on dangling Connection)");
match tx.execute("CREATE TABLE t(x INTEGER)", ()).await {
Ok(n) => eprintln!("execute Ok({n})"),
Err(e) => eprintln!("execute err: {e}"),
}
eprintln!("authorizer_uaf finished without abort");
}
Evidence Output
transaction opened; dropping original connection
executing on tx (authorizer should fire on dangling Connection)
=================================================================
ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 8 at 0x... thread T0
#3 ... libsql::local::connection::authorizer_callback .../libsql-0.9.30/src/local/connection.rs:705
#4 ... sqlite3AuthCheck .../libsql-ffi-0.9.30/bundled/src/sqlite3.c
...
freed by thread T0 here:
... core::mem::drop::<libsql::connection::Connection> ... (PoC: drop(conn))
SUMMARY: AddressSanitizer: heap-use-after-free ... in NonNull<...>::as_ref
The READ of size 8 occurs at local/connection.rs:705 (let hook = unsafe { (*conn).authorizer.read() };), reading the authorizer field of the Connection that was freed by drop(conn) in the PoC. The read targets the freed ArcInner<LibsqlConnection> region. The control path (no authorizer registered, or the original Connection kept alive) does not trigger the authorizer callback from a dangling pointer.
Impact
Confirmed
- Heap use-after-free (CWE-416): the authorizer callback dereferences a
userdata pointer to a freed local::Connection and reads its authorizer field, as confirmed by AddressSanitizer.
- The trigger is entirely safe, public Rust API (
Connection::authorizer + Connection::transaction + drop + an SQL operation on the transaction), with no unsafe in user code.
- The dangling pointer is subsequently dereferenced via
Arc::deref/RwLock::read; reallocation of the freed region between the drop and the callback can cause the read to observe attacker-influenced bytes.
Summary
Connection::authorizerregisters a SQLite authorizer callback whoseuserdatais a raw pointer to the owninglocal::Connection(self as *const Connection).Connection::transactionclones the innerLibsqlConnectioninto a new allocation, so dropping the originalConnectionfrees that struct while the underlyingsqlite3handle stays alive through the clone'sdrop_refreference. Any SQL executed on the transaction afterwards re-entersauthorizer_callback, which dereferences the dangling pointer and reads freed heap memory. This is a reproducible heap-uaf reachable entirely from safe Rust, confirmed againstlibsql 0.9.30under Linux x86_64 ASan.Details
Root Cause
local::ConnectionderivesClone. Itsauthorizermethod hands SQLite a pointer to thisConnectionvalue (self as *const Connection) as the authorizer callback'suserdata. A clonedConnectionis a differentConnectionvalue at a different address, even though it shares the samesqlite3*handle (kept alive via the shareddrop_ref: Arc<()>) and the sameauthorizer: Arc<RwLock<Option<AuthHook>>>.Connection::transactionclones the inner connection. When the caller then drops the originalConnection, itsDropimplementation runsdisconnect(), which closessqlite3only whendrop_refhas no remaining clones — so thesqlite3handle survives through the clone, but the originalConnectionstruct is freed. The next authorizer callback reads(*conn).authorizerfrom that freed memory.Relevant Code
libsql-0.9.30/src/local/connection.rs:libsql-0.9.30/src/local/impls.rs:The API contract mismatch is that the callback
userdatapoints to aConnectionvalue that is neither stable acrossClonenor guaranteed to outlive thesqlite3handle; the handle's lifetime is governed bydrop_ref/Arc, while theuserdatapointer is governed by the individualConnectionvalue'sDrop.Recommended Fix
Do not pass
selfas the callbackuserdata. Instead pointuserdataat an allocation whose lifetime is independent of theConnectionvalue's address, for example:authorizer: Arc<RwLock<Option<AuthHook>>>(e.g.Arc::as_ptr(&self.authorizer) as *mut c_void, thenArc::from_raw/re-derive theArcin the callback), orBox::into_rawhook owned by thesqlite3handle and freed when the authorizer is cleared and when the connection is disconnected.PoC
Requirements
libsql = { version = "=0.9.30", default-features = false, features = ["core"] }tokio = { version = "1", features = ["rt-multi-thread", "macros"] }-Z sanitizer=address/ nightly toolchain).PoC
Evidence Output
The
READ of size 8occurs atlocal/connection.rs:705(let hook = unsafe { (*conn).authorizer.read() };), reading theauthorizerfield of theConnectionthat was freed bydrop(conn)in the PoC. The read targets the freedArcInner<LibsqlConnection>region. The control path (noauthorizerregistered, or the originalConnectionkept alive) does not trigger the authorizer callback from a dangling pointer.Impact
Confirmed
userdatapointer to a freedlocal::Connectionand reads itsauthorizerfield, as confirmed by AddressSanitizer.Connection::authorizer+Connection::transaction+drop+ an SQL operation on the transaction), with nounsafein user code.Arc::deref/RwLock::read; reallocation of the freed region between thedropand the callback can cause the read to observe attacker-influenced bytes.