Skip to content

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

@dywzju09-blip

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions