Safeguard
Industry Analysis

Auditing unsafe Rust FFI boundaries for memory corruption...

A step-by-step rust ffi security audit: map unsafe boundaries, fuzz with cargo-fuzz, run Miri and sanitizers, and verify ownership to catch memory corruption before shipping.

Aman Khan
AppSec Engineer
8 min read

Rust's ownership model eliminates whole classes of memory bugs — until code crosses the extern "C" boundary. The moment a Rust function accepts a raw pointer from C, or hands one back, the compiler's guarantees stop and the programmer's discipline takes over. A thorough rust ffi security audit is the only reliable way to find the use-after-free, double-free, buffer overflow, and type-confusion bugs that hide in unsafe blocks wrapping foreign calls. These aren't theoretical: CVEs in bindings for image codecs, crypto libraries, and parsers have repeatedly traced back to mismatched ownership assumptions across an FFI boundary.

This guide walks through a repeatable process for auditing Rust unsafe FFI code: mapping the boundary, inventorying pointer and lifetime assumptions, fuzzing the surface, running Miri and sanitizers, checking ownership and drop semantics, and hardening build configuration. By the end, you'll have a checklist and tooling pipeline you can run against any crate that talks to C, C++, or other native code.

Step 1: Scope the Rust FFI Security Audit Across Your Crate Graph

Before touching any code, build a complete map of where Rust stops trusting the compiler. Start by finding every extern block, every #[no_mangle] export, and every call into a -sys crate:

# Find raw FFI declarations and exports
grep -rn 'extern "C"' --include="*.rs" .
grep -rn '#\[no_mangle\]' --include="*.rs" .

# List sys-crates in the dependency tree — these wrap C libraries directly
cargo tree | grep -i '\-sys'

# Surface every unsafe block for line-by-line review
grep -rn 'unsafe' --include="*.rs" . | grep -v '^.*://'

For larger codebases, cargo-geiger gives a quantified view of unsafe usage per dependency:

cargo install cargo-geiger
cargo geiger --output-format GitHubMarkdown

The goal of this step isn't to fix anything yet — it's to produce an inventory. Every unsafe extern "C" function, every raw pointer parameter, and every callback registered with foreign code is a candidate for memory corruption in Rust and needs to end up on your audit list before you move on.

Step 2: Inventory Pointer, Lifetime, and Null Assumptions at Each Call Site

Once you know where the boundary is, examine what each function assumes about the data crossing it. The compiler cannot check these invariants across FFI, so they exist only in comments, documentation, or the author's head — which is exactly why they drift out of sync with reality.

For each unsafe extern "C" signature, answer explicitly:

  • Who allocated this pointer, and who is responsible for freeing it?
  • Is the pointer guaranteed non-null, and is that checked before dereference?
  • What is the valid lifetime of the pointed-to data — does it outlive the call, or must it be copied?
  • If a slice or buffer length is passed as a separate parameter, can the two ever disagree?

A common pattern worth flagging every time you see it:

#[no_mangle]
pub unsafe extern "C" fn process_buffer(ptr: *const u8, len: usize) -> i32 {
    // No null check, no bounds validation on len vs. actual allocation size
    let data = std::slice::from_raw_parts(ptr, len);
    // ...
    0
}

from_raw_parts trusts the caller completely. If len doesn't match the real allocation, you get an out-of-bounds read with no panic, no bounds check, and no signal until it's exploited or crashes silently. Document the trust boundary for every such call, and treat any undocumented one as a finding.

Step 3: Fuzz the Unsafe FFI Surface with cargo-fuzz

Static review catches obvious mistakes, but foreign function interface security problems are often triggered by unusual input combinations that only a fuzzer will find. Wrap each FFI entry point in a fuzz target:

cargo install cargo-fuzz
cargo fuzz init
// fuzz_targets/ffi_process_buffer.rs
#![no_main]
use libfuzzer_sys::fuzz_target;

fuzz_target!(|data: &[u8]| {
    unsafe {
        let _ = my_crate::process_buffer(data.as_ptr(), data.len());
    }
});

Run it with AddressSanitizer enabled — this is essential, since ASan is what actually catches the heap corruption rather than letting it pass silently:

cargo fuzz run ffi_process_buffer -- -max_len=4096

Let this run for hours, not minutes, on any function that parses attacker-reachable data. If your FFI surface accepts network input, file formats, or user-supplied buffers, this step alone often surfaces the highest-severity findings in the audit.

Step 4: Run Miri and Sanitizers to Catch Undefined Behavior

Miri interprets your MIR and flags undefined behavior that compiles cleanly but is technically illegal — invalid pointer arithmetic, data races, and misaligned reads chief among them. It's especially good at catching the subtle violations that only manifest under specific optimization levels.

rustup +nightly component add miri
cargo +nightly miri test

For code that genuinely cannot run under Miri (real C library calls, syscalls), fall back to sanitizer builds:

RUSTFLAGS="-Z sanitizer=address" \
  cargo +nightly build --target x86_64-unknown-linux-gnu

RUSTFLAGS="-Z sanitizer=memory" \
  cargo +nightly build --target x86_64-unknown-linux-gnu

Run your existing test suite and any fuzz corpora against both the ASan and MSan builds. MemorySanitizer in particular is valuable at FFI boundaries because uninitialized memory passed from C into Rust (or vice versa) is a frequent, hard-to-spot source of memory corruption in Rust codebases that mix languages.

Step 5: Audit Ownership Transfer and Drop Semantics

Double-frees and use-after-frees at FFI boundaries almost always trace back to ambiguous ownership transfer. When a Rust Box, Vec, or CString crosses into C, one side must stop being responsible for freeing it — and that handoff needs to be enforced in code, not just in a comment.

Check every function that returns an owned pointer to C:

#[no_mangle]
pub extern "C" fn create_context() -> *mut Context {
    Box::into_raw(Box::new(Context::new()))
}

#[no_mangle]
pub unsafe extern "C" fn free_context(ptr: *mut Context) {
    if !ptr.is_null() {
        drop(Box::from_raw(ptr));
    }
}

Verify there is exactly one free_* function per create_* function, that it's actually called by every C-side consumer, and that no code path calls it twice or reconstructs a Box from a pointer that Rust's allocator didn't produce (e.g., a pointer originally allocated by malloc). Mixing allocators across the boundary is a subtle but real source of corruption — Box::from_raw must only ever be called on memory that came from Box::into_raw or an equivalent Rust allocation.

Step 6: Harden Build Flags and Linkage

Even a clean audit benefits from compiler-level backstops. Enable overflow checks and stack protection in release builds where the performance cost is acceptable:

# Cargo.toml
[profile.release]
overflow-checks = true
debug-assertions = true
panic = "abort"
RUSTFLAGS="-C control-flow-guard -Z stack-protector=all" \
  cargo +nightly build --release

On the C/C++ side of the boundary, make sure the native library is also built with -fstack-protector-strong, -D_FORTIFY_SOURCE=2, and RELRO/PIE enabled — an audit of the Rust side is incomplete if the foreign code it calls has no hardening at all.

Troubleshooting and Verification

  • Miri reports "unsupported operation" on a real syscall. This is expected — Miri can't model every FFI call. Isolate the unsafe block with #[cfg(miri)] shims that return plausible mock data so the surrounding logic still gets checked.
  • ASan reports a leak, not a corruption. Leaks at FFI boundaries are usually a missing free_* call rather than a free_* bug — check the C-side call sites before assuming the Rust code is wrong.
  • Fuzzer finds nothing after hours. Confirm the harness is actually reaching the unsafe code — instrument with eprintln! temporarily, or check coverage with cargo fuzz coverage to see if the corpus is exploring the target function at all.
  • False positive on a raw pointer cast. Verify alignment and provenance explicitly with std::ptr::addr_of! rather than dismissing the warning; misaligned reads across FFI are a legitimate and common bug class, not a linter quirk.
  • Verification checklist before sign-off: every unsafe extern function has a documented ownership contract, every buffer parameter pair has a length-consistency check, the fuzz corpus has run without new crashes for at least 24 hours, and both ASan and Miri pass on the full test suite.

How Safeguard Helps

Manually auditing every unsafe FFI boundary across a large dependency graph doesn't scale, especially as -sys crates get updated and new native dependencies enter the supply chain unnoticed. Safeguard continuously scans your Rust codebase and its transitive dependencies to flag unsafe extern functions, unvetted -sys crates, and ownership-transfer patterns that historically correlate with memory corruption, surfacing them alongside CVE and provenance data for every native library in the chain. Instead of re-running this checklist by hand on every release, teams use Safeguard to gate CI on newly introduced unsafe FFI surface area, track which boundaries have been reviewed, and get alerted the moment a dependency update changes a function signature crossing the Rust-to-C boundary — turning a one-time audit into continuous foreign function interface security coverage.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.