Repository navigation
libsql: Connection::authorizer keeps a raw pointer to the Connection, causing heap uaf when the original connection is dropped while a transaction holds a clone #2272
Description
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, thenArc::from_raw/re-derive theArcin the callback), or - a dedicated
Box::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"] }- 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
userdatapointer to a freedlocal::Connectionand reads itsauthorizerfield, as confirmed by AddressSanitizer. - The trigger is entirely safe, public Rust API (
Connection::authorizer+Connection::transaction+drop+ an SQL operation on the transaction), with nounsafein user code. - The dangling pointer is subsequently dereferenced via
Arc::deref/RwLock::read; reallocation of the freed region between thedropand the callback can cause the read to observe attacker-influenced bytes.
Activity
breken-ai commentedon Sep 10, 2026
@dywzju09-blip Excellent report - the ASan trace and the lifetime mismatch you identified held up exactly when we verified the chain against current main (
d6c75af): theClonederive, theself as *const Connectionregistration, the callback deref, thetransaction()clone, anddisconnect()gating the close on the lastdrop_refowner.Fix is up: #2281. It takes your first recommended direction - the callback user data now points at the shared
authorizerArcallocation rather than theConnectionvalue. The allocation provably outlives the raw handle: the handle closes only when the lastdrop_refowner disconnects, and every handle owner (Connection,Statement,Transaction) holds a clone of that sameArc, so the address the callback reads can never dangle while the handle can still invoke it. No extra refcounting or ownership transfer needed. Your PoC scenario is included as a regression test (test_authorizer_survives_dropped_registering_connection).Built by breken, your AI support engineer - breken.ai finds the bug, proves the fix with unmatched RCA, answers customers in your voice, and keeps your code and support docs clean - this one's on us.
dywzju09-blip commentedon Sep 13, 2026
@penberg Thanks for the fix — the analysis in #2281 matches the root cause exactly, and the regression test covers the PoC scenario.
Could we get this merged and released? Every published version is currently affected — the latest stable
libsql0.9.30 and all the 0.10.0-pre releases predate the fix — so there's no patched version for downstream users to upgrade to. A 0.9.x patch release would be ideal since 0.10 is still in pre-release, but shipping it in the next 0.10 pre-release works too.Happy to help with anything needed to move it forward.