Topic: mcp server memory safety rust ffi

MCP server memory safety with Rust FFI — safe patterns for native addons

Rust's ownership system eliminates memory safety bugs in safe code. But MCP server native addons that call C libraries, expose functions to Node.js via napi-rs, or use unsafe blocks to interface with system APIs step outside that guarantee. The Rust compiler cannot verify the memory safety of code that crosses an FFI boundary — it trusts the programmer's declarations about pointer validity, struct layout, and lifetime. Getting these declarations wrong produces the same class of memory unsafety as unguarded C code: dangling pointers, aliasing violations, and undefined behavior that the optimizer is free to miscompile.

ABI compatibility: #[repr(C)] on every struct crossing the boundary

Rust's default struct layout is unspecified — the compiler may reorder fields, insert padding, or use a representation that differs from C's. When a struct is passed to or received from a C function over FFI, both sides must agree on its exact memory layout. Forgetting #[repr(C)] is a silent ABI mismatch: the struct compiles, links, and runs — but field accesses in C read from different offsets than Rust wrote to, producing corrupted data.

// Rust — VULNERABLE: default repr, field layout unspecified
struct ToolCallContext {
    request_id: u64,
    user_token: *const u8,
    token_len: usize,
    flags: u32,
}

extern "C" fn handle_tool_call(ctx: *const ToolCallContext) { /* ... */ }
// C code calling this function reads request_id from offset 0,
// but Rust may have placed flags there for alignment reasons.
// Rust — SAFE: explicit C-compatible layout
#[repr(C)]
struct ToolCallContext {
    request_id: u64,
    user_token: *const u8,
    token_len: usize,
    flags: u32,
}

// Now field offsets match what a C header would produce for the same struct.
// Use bindgen to auto-generate these structs from C headers when available.

The rule is simple: any struct that crosses the FFI boundary — whether passed by value, by pointer, or embedded in another struct — must have #[repr(C)]. For complex types, use bindgen to generate the Rust struct definitions from C headers rather than writing them by hand. bindgen produces the correct repr(C) layout including any compiler-specific padding that the C header implies.

Panic at FFI boundaries: undefined behavior that compiles silently

Unwinding a Rust panic across an FFI boundary into C (or into Node.js via napi) is undefined behavior. The C call frame does not have RAII destructors that Rust's unwind mechanism expects. The result is typically a process abort or silent memory corruption, depending on the platform and compiler configuration. Every Rust function that is exported over FFI must catch panics before they unwind out of the Rust frame.

// Rust — VULNERABLE: panic may unwind into Node.js / C runtime
#[no_mangle]
pub extern "C" fn audit_tool_args(ptr: *const u8, len: usize) -> i32 {
    let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
    let parsed = serde_json::from_slice::<serde_json::Value>(slice)
        .expect("JSON parse error");  // panics on malformed input
    // panic unwinds across FFI boundary → undefined behavior
    process_value(&parsed)
}
// Rust — SAFE: catch_unwind at the FFI boundary
use std::panic;

#[no_mangle]
pub extern "C" fn audit_tool_args(ptr: *const u8, len: usize) -> i32 {
    let result = panic::catch_unwind(|| {
        let slice = unsafe { std::slice::from_raw_parts(ptr, len) };
        match serde_json::from_slice::<serde_json::Value>(slice) {
            Ok(parsed) => process_value(&parsed),
            Err(_) => -1,  // return error code instead of panicking
        }
    });
    result.unwrap_or(-2)  // -2 signals caught panic to C caller
}

In napi-rs, the framework wraps Rust closures in catch_unwind automatically for functions registered via the #[napi] attribute macro. Raw extern "C" exports and manual napi callbacks do not have this protection — they require explicit catch_unwind wrappers.

Safe CStr usage: null checks and lifetime ownership

Constructing a CStr from a raw pointer received over FFI requires meeting two invariants that the Rust type system cannot enforce: the pointer must be non-null, and the memory it points to must remain valid for the duration of the CStr reference. Neither invariant is checked at runtime by CStr::from_ptr — calling it with a null pointer or a pointer to freed memory is immediate undefined behavior.

// Rust — VULNERABLE: unchecked pointer dereference
unsafe fn process_cstring_arg(ptr: *const std::os::raw::c_char) {
    let cstr = CStr::from_ptr(ptr);  // UB if ptr is null or dangling
    let s = cstr.to_str().unwrap();
    do_work(s);
}
// Rust — SAFE: null check before constructing CStr; bounded lifetime
unsafe fn process_cstring_arg(ptr: *const std::os::raw::c_char) -> Option<&str> {
    if ptr.is_null() {
        return None;
    }
    // SAFETY: caller guarantees ptr points to a null-terminated C string
    // that remains valid for the duration of this call.
    let cstr = CStr::from_ptr(ptr);
    cstr.to_str().ok()
}

Document every unsafe block with a // SAFETY: comment that states the invariants being asserted. This serves both as self-documentation and as an audit target: SkillAudit's static analysis flags unsafe blocks that lack // SAFETY: comments as undocumented safety assertions.

Aliasing violations: &mut and *mut at the same address

Rust's borrow checker prevents aliasing of mutable references in safe code. In FFI code, the restriction is still present but unenforced at compile time: passing both a &mut T and a raw *mut T pointing to the same memory to the same function simultaneously violates the aliasing rules and is undefined behavior. The optimizer may assume that a mutable reference is the only alias to its target and cache a read value across a write through the raw pointer, producing incorrect results even on correct-looking code.

// Rust — VULNERABLE: aliasing &mut and *mut to same memory
let mut buffer = [0u8; 256];
let raw_ptr = buffer.as_mut_ptr();

// BUG: both mut_ref and raw_ptr alias buffer
let mut_ref = &mut buffer[0];
// Passing both to a C function gives it two aliased mutable pointers.
// The Rust compiler may optimize assuming mut_ref is the only writer.
unsafe { c_process(mut_ref as *mut u8, raw_ptr, 256); }

Safe pattern: use either the mutable reference or the raw pointer, not both. When C code needs to write through a pointer, pass only the raw pointer and do not create any Rust reference to that memory while the C code might be using it:

// Rust — SAFE: single mutable access path
let mut buffer = vec![0u8; 256];
let ptr = buffer.as_mut_ptr();
let len = buffer.len();

// Drop any borrow of buffer before passing ptr to C
unsafe {
    // Only ptr accesses buffer during this call; no Rust reference is active
    c_process(ptr, len);
}

// Now safe to create Rust references again
let result = &buffer[0..len];

SkillAudit detection

SkillAudit's Security axis performs static analysis of Rust source in MCP native addons. It flags structs passed over FFI boundaries that lack #[repr(C)], extern "C" functions that do not contain a catch_unwind wrapper and use .unwrap(), .expect(), or panic!() internally, CStr::from_ptr calls that are not preceded by a null check, and unsafe blocks that lack a // SAFETY: comment. The LLM probe sends malformed JSON arguments to tool handlers compiled from Rust FFI code and checks whether the server survives the parse error without aborting. Run a free audit at skillaudit.dev to check your Rust-based MCP native addon for FFI safety issues.

Related: dangling pointer security, use-after-free, double-free security, security checklist.