Double Free in Axum (Rust)
Double Free in Axum with Rust — how this specific combination creates or exposes the vulnerability
In Rust, memory safety guarantees prevent double-free errors at compile time through ownership and borrowing rules. However, when using unsafe code or FFI (Foreign Function Interface) in Axum handlers, these guarantees can be bypassed, leading to double-free vulnerabilities. Axum, as a web framework built on hyper and Tokio, often interfaces with C libraries for performance-critical tasks like cryptography, compression, or database access. If an Axum handler incorrectly manages the lifecycle of a resource obtained from such an unsafe context — for example, by cloning a raw pointer and attempting to free it twice — a double free can occur.
Consider an Axum route that uses a C library via unsafe Rust to allocate a buffer, processes it, and then attempts to free it. If the same buffer pointer is accidentally freed twice due to flawed ownership logic — such as moving the pointer into an asynchronous task that also tries to clean it up — the allocator's internal state can be corrupted. This may lead to heap corruption, arbitrary code execution, or denial of service. Unlike pure Rust code, where the compiler prevents such errors, the interaction between Axum's asynchronous handlers and unsafe FFI creates a surface where double-free bugs can emerge, especially under concurrent request handling.
Real-world parallels include CVE-2022-24765 in the reqwest HTTP client (which Axum often uses indirectly via hyper), where improper handling of HTTP/2 frame buffers led to use-after-free and double-free conditions. While not in Axum directly, it illustrates how async Rust HTTP stacks can be vulnerable when unsafe code is involved. Similarly, crates like libloading or openssl-sys used in Axum middleware for dynamic library loading or TLS termination have historically had double-free flaws when misused.
Rust-Specific Remediation in Axum — concrete code fixes
To prevent double-free vulnerabilities in Axum when using unsafe code, enforce strict ownership semantics even in FFI contexts. Never clone raw pointers; instead, wrap them in safe abstractions like Box, Vec, or custom Drop implementations that ensure single ownership. Use std::ptr::read or std::ptr::write for transfers, and avoid manual free calls unless absolutely necessary — prefer letting Rust manage memory via Box::into_raw and Box::from_raw.
Below is a corrected Axum handler that safely interacts with a hypothetical C API allocating a buffer. The unsafe block is minimized, and ownership is clearly transferred using Box to prevent double free:
use axum::{extract::State, Json, Router};
use serde::{Deserialize, Serialize};
use std::ptr;
use std::sync::Arc;
#[derive(Serialize)]
struct Response {
message: String,
}
#[derive(Deserialize)]
struct Request {
input: String,
}
// Simulate a C function that allocates a buffer (in reality, via FFI)
extern "C" {
fn allocate_buffer(len: usize) -> *mut u8;
fn process_buffer(ptr: *mut u8, len: usize) -> *mut u8;
fn free_buffer(ptr: *mut u8);
}
async fn handle_request(
State(_): State>,
Json(payload): Json,
) -> Json {
let len = payload.input.len();
// SAFETY: We assume the C function returns a valid pointer or null
let ptr = unsafe { allocate_buffer(len) };
if ptr.is_null() {
return Json(Response {
message: "Allocation failed".to_string(),
});
}
// Wrap the raw pointer in a Box to manage its lifecycle
// SAFETY: We own the pointer from allocate_buffer, and it is valid for `len` bytes
let mut buf = unsafe { Box::from_raw(ptr) };
// Process the buffer (simulate FFI call)
// SAFETY: buf.as_mut_ptr() is valid and we own it
let processed_ptr = unsafe { process_buffer(buf.as_mut_ptr(), len) };
if !processed_ptr.is_null() && processed_ptr != buf.as_mut_ptr() {
// If a new buffer is returned, free the old one (owned by buf) and take ownership of new
// buf is dropped here (freeing original buffer)
let new_buf = unsafe { Box::from_raw(processed_ptr) };
// Use new_buf... then it will be dropped at end of scope
// For simplicity, we just drop it; in real code, you'd use the data
drop(new_buf);
}
// buf is dropped here, freeing the buffer exactly once
Json(Response {
message: "Request processed safely".to_string(),
})
}
fn app() -> Router {
let shared_state = Arc::new(());
Router::new()
.route("/process", axum::routing::post(handle_request))
.with_state(shared_state)
}
Key fixes: (1) Using Box::from_raw to transfer ownership of the raw pointer to Rust, ensuring Drop frees it exactly once. (2) Avoiding manual free_buffer calls when ownership is transferred. (3) Checking for null pointers and handling allocation failures. In Axum, ensure that asynchronous handlers do not move pointers into multiple tasks without proper synchronization — use Arc if shared ownership is needed, but prefer single ownership to eliminate double-free risk entirely.