---
key: "rust-unsafe-ffi"
title: "Audit unsafe Rust, FFI boundaries and crate dependencies"
url: "https://softwaresecurity.com/domains/appsec/platform-security/rust-unsafe-ffi/"
api: "https://softwaresecurity.com/api/v1/articles/rust-unsafe-ffi.json"
domain: "appsec"
topic: "Application security"
type: "Guide"
identifiers: ["CWE-758","CWE-416","CWE-762","CWE-190","CWE-617","CWE-1395","CWE-1357","A03:2025","A10:2025","V1.4","V15.1","V15.2","CAPEC-538","CAPEC-100"]
tags: ["Memory safety","Error handling and information disclosure","Dependency management","C, C++ and Rust"]
stages: ["build"]
roles: ["developer","appsec-engineer"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Audit unsafe Rust, FFI boundaries and crate dependencies

Know what the Rust compiler proves and what it leaves to you: review unsafe code for soundness, wrap foreign calls in small safe functions, decide how overflow and panics behave in release builds, gate crates with RustSec, and replace C one boundary at a time.

Canonical URL: https://softwaresecurity.com/domains/appsec/platform-security/rust-unsafe-ffi/

## In short

Safe Rust rules out memory corruption and data races, but not panics, leaks, logic bugs or integer wraparound in release builds. Keep unsafe code in small private modules with a `SAFETY` comment on every block, wrap each foreign call in a safe function that checks pointers, lengths, lifetimes and ownership, and test it under Miri and AddressSanitizer. Enable `overflow-checks` for release, never let a panic cross an FFI boundary, and gate crates with cargo-audit and cargo-deny in CI.

- `unsafe` does not switch off the borrow checker; it allows a short list of operations whose preconditions you must prove in a `SAFETY` comment.
- A safe function that lets any safe caller cause undefined behavior is unsound, even if no caller does so today.
- Release builds wrap on integer overflow unless `overflow-checks = true`, and integer casts with `as` truncate silently in every build.
- Since Rust 1.81 a panic that reaches an `extern "C"` function aborts the process; return error codes across FFI instead.
- Miri finds undefined behavior only on the paths your tests run and cannot execute foreign code; cover FFI with AddressSanitizer.
- Build scripts and procedural macros run at build time with your privileges; the August 2026 arrayref compromise delivered its payload through a build script.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| `edition` | `"2024"` | Rust 1.85+. Requires `unsafe extern` blocks and `#[unsafe(no_mangle)]`, makes `unsafe_op_in_unsafe_fn` warn, and marks `std::env::set_var` unsafe. | Rust 2024 edition guide |
| `[lints.rust] unsafe_code` | `"forbid"` | In every crate that needs no unsafe code. The `[lints]` table needs Cargo 1.74 or later. | Cargo Book: the [lints] section |
| `[lints.rust] unsafe_op_in_unsafe_fn` | `"deny"` | Each unsafe operation inside an `unsafe fn` needs its own unsafe block and `SAFETY` comment. | Rust 2024 edition guide |
| `[lints.clippy]` `undocumented_unsafe_blocks`, `multiple_unsafe_ops_per_block`, `missing_safety_doc` | `"deny"` | In crates that contain unsafe code. Clippy checks that the comment exists, not that it is true. | Clippy lint list (clippy 0.1.99) |
| `[profile.release] overflow-checks` | `true` | Cargo's release default is false, which wraps silently. | Cargo Book: profiles |
| Toolchain | Rust 1.96 or later | Fixes Cargo CVE-2026-5222 and CVE-2026-5223, which affect third-party registries. 1.99.0 is current. | Rust security advisories, 25 May 2026 |
| `cargo audit` | `--deny warnings` | Also fails on unmaintained, unsound and yanked crates, not only on vulnerabilities. | cargo-audit 0.22.2 --help |
| cargo-deny `[advisories]` | `unsound = "all"`, `yanked = "deny"`, `unmaintained = "workspace"` | Vulnerability advisories always fail; the unsound default covers direct dependencies only. | cargo-deny 0.20 advisories configuration |
| cargo-deny `[sources]` | `unknown-registry = "deny"`, `unknown-git = "deny"` | List crates.io and any private registry in `allow-registry`. | cargo-deny 0.20 sources configuration |

## Verify it

1. `cargo +nightly miri test` passes for every crate with unsafe code, and each `SAFETY` precondition has a test that pushes a hostile value through the safe API.
2. `cargo clippy --all-targets -- -D warnings` passes with the unsafe lints at deny, and crates without unsafe code declare `unsafe_code = "forbid"`.
3. The FFI tests pass on nightly with `RUSTFLAGS=-Zsanitizer=address` and the C library linked in.
4. A release-build test that adds past `u32::MAX` panics, which proves `overflow-checks` is on.
5. `cargo audit --deny warnings` and `cargo deny check advisories bans sources` pass on the committed `Cargo.lock`, and CI builds use `--locked`.
6. No job that runs `cargo miri` or writes a shared `target/` cache has secrets in its environment.

## What you will learn

- Explain which bugs safe Rust rules out and which it leaves to the developer.
- Review an unsafe block or FFI wrapper for soundness, lifetimes, ownership and panics.
- Configure lints, profiles, Miri and sanitizers that enforce the review rules.
- Gate dependencies with cargo-audit and cargo-deny and respond to a compromised crate.
- Plan the replacement of a C component behind its existing interface.

Audience: Rust developers, reviewers and security engineers who own crates with unsafe code or FFI boundaries, or who are moving C components to Rust

Before you start:

- Read Rust code that uses references, slices, Option and Result.
- Know what a raw pointer and a C function prototype are.
- Run cargo build, test and clippy.

Time: About 40 minutes: 11 to read, 7 on the code and 22 for the exercise

## Know what safe Rust guarantees and what it leaves to you

In safe Rust the compiler and standard library rule out the bugs that dominate C and C++ (/domains/appsec/platform-security/memory-safety-c-cpp/): out-of-bounds access, use after free, double free, reads of uninitialized memory and data races. The Rust Reference states the guarantee as a list of undefined behaviors that safe code cannot cause, including dangling or misaligned pointer access, breaking the aliasing rules, producing an invalid value and unwinding through a function that does not allow it. Code is sound when no safe caller can trigger any of them. Everything in the table below is still possible in safe code.

| Not prevented in safe code | What happens | Control |
| --- | --- | --- |
| Integer overflow | Panics in debug builds; wraps in release builds unless `overflow-checks` is on | `checked_*` arithmetic, `try_from`, `overflow-checks = true` |
| Panics | `unwrap`, `expect`, indexing and division by zero on bad input end the thread or the process | Return `Result` from parsers; no panics across FFI |
| Leaks and deadlocks | Leaking memory, deadlocking and exiting without running destructors are all safe | Resource limits, timeouts and tests |
| Logic and injection bugs | CVE-2024-24576: until Rust 1.77.2, untrusted arguments to Windows batch files run through `Command` could execute shell commands | The same input handling as in any language |
| Undefined behavior in unsafe and foreign code | Safe callers inherit it | Sound wrappers, Miri and sanitizers |

## Treat every unsafe block as a proof you write down

`unsafe` does not switch off the borrow checker. It allows a short list of operations whose rules the compiler cannot check. The main ones are dereferencing a raw pointer, calling an unsafe or foreign function, using a mutable or external static, reading a union field and implementing an unsafe trait; since the 2024 edition, declaring an extern block and applying attributes such as `no_mangle` need `unsafe` too. Each operation has preconditions that the surrounding code must establish.

Make those preconditions reviewable. Put a `// SAFETY:` comment on every unsafe block that says why each precondition holds, give every public `unsafe fn` a `# Safety` section that states what the caller must guarantee, and keep invariants behind private fields in a small module so outside code cannot break them. The Rustonomicon's rule is that unsafe code may trust the specific safe code it depends on, but not safe code its callers supply. smallvec broke that rule: `insert_many` trusted the lower bound of an iterator's `size_hint`, and an iterator that yielded more items caused a heap buffer overflow (RUSTSEC-2021-0003, CVE-2021-25900).

A `SAFETY` comment is only as good as the invariant behind it, and other code can break the invariant later. CVE-2025-68260, in the Linux kernel's Rust Binder driver, came from an unsafe linked-list removal whose comment assumed a node was in its owner's list or in none. But `Node::release` moved the nodes to a local list and dropped the lock before walking it, so another thread's removal raced on the same pointers, corrupted them and crashed the kernel. Linux 6.18.1 has the fix.

## Vulnerable: a safe method that exposes undefined behavior

This method is safe to call and unsound: any caller can pass an index past the end, and `get_unchecked` then reads out of bounds. In a debug build the standard library's precondition checks, which follow the `debug-assertions` setting since Rust 1.78, aborted the test with `"unsafe precondition(s) violated"`. The same test passed silently in a release build.

```rust
/// A record: type (1 byte), length (2 bytes, big-endian), payload.
pub struct Record {
    buf: Vec<u8>,
}

impl Record {
    pub fn new(buf: Vec<u8>) -> Self {
        Record { buf }
    }

    /// Returns payload byte `i`.
    pub fn payload_byte(&self, i: usize) -> u8 {
        // SAFETY: none. Any safe caller can pass an `i` past the end, so this
        // safe function is unsound: it exposes undefined behavior to safe code.
        unsafe { *self.buf.get_unchecked(3 + i) }
    }
}
```

## Fixed: validate once, then expose only checked access

The fixed type validates the record in its only constructor, so `buf.len()` == 3 + payload length holds for every value of the type. The checked accessor needs no unsafe code at all. The unchecked accessor is an `unsafe fn` whose `# Safety` section hands the remaining obligation to the caller, and its `SAFETY` comment cites both facts it relies on.

```rust
/// A record: type (1 byte), length (2 bytes, big-endian), payload.
pub struct Record {
    buf: Vec<u8>,
}

impl Record {
    /// Rejects buffers whose declared length does not match what arrived.
    pub fn parse(buf: Vec<u8>) -> Option<Self> {
        let declared = usize::from(u16::from_be_bytes([*buf.get(1)?, *buf.get(2)?]));
        (buf.len().checked_sub(3)? == declared).then_some(Record { buf })
    }

    pub fn payload_len(&self) -> usize {
        self.buf.len() - 3
    }

    /// Returns payload byte `i`, or `None` when `i` is out of range.
    pub fn payload_byte(&self, i: usize) -> Option<u8> {
        self.buf.get(3usize.checked_add(i)?).copied()
    }

    /// Returns payload byte `i` without a bounds check.
    ///
    /// # Safety
    ///
    /// `i` must be less than the payload length.
    pub unsafe fn payload_byte_unchecked(&self, i: usize) -> u8 {
        debug_assert!(i < self.buf.len() - 3);
        // SAFETY: `parse` guarantees buf.len() == 3 + payload length, and the
        // caller guarantees i < payload length, so 3 + i < buf.len().
        unsafe { *self.buf.get_unchecked(3 + i) }
    }
}
```

## Make the compiler enforce the review rules

Put these settings in the manifest of each crate that is allowed to contain unsafe code, and set `unsafe_code = "forbid"` in the `[lints.rust]` table of every other crate. In testing, each lint stopped the build on a probe file: an unsafe block without a comment, a block with two unsafe operations, a public `unsafe fn` without `# Safety`, and a raw-pointer dereference in an `unsafe fn` without its own block. Clippy checks that a `SAFETY` comment exists, not that it is true; the unsound method above passed every lint.

```toml
[lints.rust]
unsafe_op_in_unsafe_fn = "deny"     # warns by default in edition 2024; make it an error
[lints.clippy]
undocumented_unsafe_blocks = "deny"     # every unsafe block needs a // SAFETY: comment
multiple_unsafe_ops_per_block = "deny"  # one proof obligation per block
missing_safety_doc = "deny"             # every pub unsafe fn documents its # Safety contract

[profile.release]
overflow-checks = true    # panic on arithmetic overflow instead of wrapping silently
```

## Test unsafe code under Miri and FFI under a sanitizer

Miri interprets your tests and reports undefined behavior as it happens: out-of-bounds and use-after-free access, uninitialized reads, misalignment, invalid values, data races and, experimentally, aliasing violations. It reported `"Undefined Behavior"` at the `get_unchecked` call in the release-mode test that passed natively.

Know its limits. Miri checks only the executions your tests drive, so a clean run is not a proof of soundness, and it cannot run foreign code. Mark FFI tests `#[cfg(not(miri))]` and run them under AddressSanitizer on nightly instead; that caught the dangling pointer in the next example. If CI caches `target/` where pull requests can read it, keep secrets away from the job that runs Miri: until the nightly of 2026-09-22, Miri copied every environment variable into `target/`.

```bash
rustup +nightly component add miri
cargo +nightly miri test                  # every test that does not call C
cargo +nightly miri test --release        # no debug precondition checks; Miri still checks
# error: Undefined Behavior: `assume` called with `false`
#   --> src/record_vuln.rs:15:19

# FFI tests: Miri cannot run C, so use AddressSanitizer (nightly; name your target)
RUSTFLAGS="-Zsanitizer=address" cargo +nightly test --target aarch64-apple-darwin
# ERROR: AddressSanitizer: heap-use-after-free ... READ of size 3
#     #0 ... in strlen
#     #1 ... in legacy_greeting
```

## Vulnerable: an FFI wrapper with three lifetime mistakes

Most FFI bugs are ownership and lifetime mistakes that the type system cannot see across the boundary. Here the `CString` is dropped at the end of its let statement, so C reads freed memory; a `NULL` return is dereferenced; and memory from C's `malloc` is released through Rust's allocator, which is undefined behavior even where both happen to call `free`. rustc warns only about the first, through its `dangling_pointers_from_temporaries` lint. A native test printed "hello, " followed by garbage; under AddressSanitizer it reported a heap-use-after-free in `strlen` inside the C function.

```rust
#![allow(clippy::multiple_unsafe_ops_per_block)] // silencing the lint is the first red flag
use std::ffi::{CStr, CString, c_char};

unsafe extern "C" {
    fn legacy_greeting(name: *const c_char) -> *mut c_char;
}

pub fn greeting(name: &str) -> String {
    let c_name = CString::new(name).unwrap().as_ptr(); // the CString is dropped at this `;`
    // SAFETY: wrong on every count; see the fixed version.
    unsafe {
        let p = legacy_greeting(c_name);                         // C reads freed memory
        let s = CStr::from_ptr(p).to_string_lossy().into_owned(); // UB if C returned NULL
        drop(CString::from_raw(p));                              // frees C memory with Rust's allocator
        s
    }
}
```

## Fixed: one safe wrapper per foreign function

Wrap each foreign function once, in a safe function whose signature rules out misuse: slices instead of pointer and length pairs, `&str` in, `String` out and `Option` for failure. Hold memory that C owns as a raw pointer only between the call and the matching C free function. The declarations in an unsafe extern block are promises the compiler cannot check, so generate them from the C headers with bindgen, or generate the header from Rust with cbindgen, and review both sides together.

```rust
use std::ffi::{CStr, CString, c_char, c_int};

// These declarations must match csrc/legacy.c exactly; the compiler cannot check them.
unsafe extern "C" {
    fn legacy_checksum(buf: *const u8, len: usize, out: *mut u32) -> c_int;
    fn legacy_greeting(name: *const c_char) -> *mut c_char;
    fn legacy_free(p: *mut c_char);
}

pub fn checksum(buf: &[u8]) -> Option<u32> {
    let mut out = 0u32;
    // SAFETY: buf is valid for reads of buf.len() bytes and out is a valid u32
    // for the whole call; legacy_checksum keeps neither pointer.
    let rc = unsafe { legacy_checksum(buf.as_ptr(), buf.len(), &mut out) };
    (rc == 0).then_some(out)
}

pub fn greeting(name: &str) -> Option<String> {
    let c_name = CString::new(name).ok()?; // an interior NUL is an error, not a panic
    // SAFETY: c_name is NUL-terminated and lives until the end of this function.
    let p = unsafe { legacy_greeting(c_name.as_ptr()) };
    if p.is_null() {
        return None;
    }
    // SAFETY: p is non-null and NUL-terminated; C owns it until legacy_free.
    let s = unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned();
    // SAFETY: p came from legacy_greeting and is freed once, by the allocator that made it.
    unsafe { legacy_free(p) };
    Some(s)
}
```

## Expose Rust to C without trusting the pointer or panicking

When C calls Rust, the function receives raw pointers and must build the safe types itself. Check for `NULL` before `slice::from_raw_parts`, which is undefined behavior on a null pointer even when the length is 0. Never let a panic escape: since Rust 1.81 a panic that reaches an `extern "C"` function aborts the process, and `catch_unwind` around the call does not stop it; in testing the process exited with `SIGABRT`. Return an error code instead, and use the `"C-unwind"` ABI only where both sides expect unwinding. A C program linked against this function got 2, -1 and -1 for a valid record, a lying length and `NULL`.

```rust
use crate::record::Record;

/// Returns the payload length of a well-formed record, or -1.
///
/// # Safety
///
/// `buf` must be null or point to `len` bytes that stay readable for the whole call.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn record_payload_len(buf: *const u8, len: usize) -> i64 {
    if buf.is_null() || len > isize::MAX as usize {
        return -1; // from_raw_parts requires a non-null pointer, even when len is 0
    }
    // SAFETY: buf is non-null, len fits in isize, and the caller guarantees the bytes.
    let bytes = unsafe { std::slice::from_raw_parts(buf, len) };
    // No unwrap or indexing: a panic here would abort the whole host process.
    match Record::parse(bytes.to_vec()) {
        Some(r) => i64::try_from(r.payload_len()).unwrap_or(-1),
        None => -1,
    }
}
```

## Decide what overflow and panics do in release builds

Cargo's release profile sets `overflow-checks = false`, so `u32::MAX + 2` evaluated to 1 in a release build and panicked in a debug build. Wrapping is defined in Rust, but a wrapped length that sizes a buffer or crosses into C is the same bug as in C. Use `checked_add` and `checked_mul` for values from input, `wrapping_*` where wrapping is intended, and `try_from` instead of `as`, which truncates silently in every build. `overflow-checks = true` turns any case you missed into a panic.

A panic is not memory corruption, but on attacker-controlled input it is a denial of service. Division by zero, slicing past the end, `unwrap` and `expect` all panic. With `panic = "abort"`, or across an `extern "C"` boundary, the whole process exits; with the default unwind strategy the panic unwinds only its own thread, and a `std::sync::Mutex` it held becomes poisoned. In parsers, return errors, consider Clippy's restriction lints `unwrap_used`, `indexing_slicing` and `arithmetic_side_effects`, and fuzz with cargo-fuzz (/domains/appsec/testing/parser-fuzzing/ shows the harness pattern).

```rust
/// Header and body lengths come from the wire.
pub fn frame_len_unchecked(header: u32, body: u32) -> u32 {
    header + body // panics in debug builds; wraps in release unless overflow-checks = true
}

pub fn frame_len(header: u32, body: u32) -> Option<u32> {
    header.checked_add(body)
}

pub fn wire_len(len: usize) -> Option<u16> {
    u16::try_from(len).ok() // `len as u16` would silently turn 70_000 into 4_464
}
```

## Gate crates before they build

Every dependency runs code on the build machine before your tests do, because build scripts and procedural macros execute with your privileges. On 2026-08-20, arrayref 0.3.10, a new release of a popular crate, was published from its maintainer's account with a new dependency, `proc-macro1`, whose build script downloaded a payload; two other crates by the same author were changed too. crates.io removed arrayref 0.3.10 after 86 minutes. RustSec counted 2,285 downloads and noted that most users had older versions pinned in their lockfiles (RUSTSEC-2026-0260). The Rust project believes the maintainer's computer or credentials were compromised. In September it warned of attacks that lure crate owners into video calls about jobs or projects, a style it said is known to be used by North Korea, and said arrayref had been compromised through similar attacks, without knowing whether they were one campaign.

Controls, in the order they pay off:

- Commit `Cargo.lock` and build with `--locked`, so a new release enters only through a reviewed lockfile change.
- Run `cargo audit` and `cargo deny` in CI. With the configuration below, a crate that depended on smallvec 1.6.0 failed on RUSTSEC-2021-0003, and wildcard and git dependencies failed the bans and sources checks.
- Delay new versions. Cargo's `min-publish-age` setting is still nightly-only in Rust 1.99, so set the delay in your update bot.
- Review what each dependency brings. crates.io shows the source that was actually published, which can differ from the repository, and `cargo geiger` counts unsafe Rust per crate; it does not scan C files that build scripts compile.
- Publish your own crates through crates.io Trusted Publishing, and turn off token-based publishing for them.
- Keep the toolchain current: Rust 1.96 fixed two Cargo vulnerabilities that affect third-party registries (CVE-2026-5222 and CVE-2026-5223).

```toml
# cargo deny check advisories bans sources   (cargo-deny 0.20)
[advisories]
# RustSec vulnerability advisories always fail the check.
unsound = "all"              # also fail on unsound-API advisories in transitive dependencies
yanked = "deny"
unmaintained = "workspace"   # fail when a direct dependency is flagged unmaintained
ignore = [
  # { id = "RUSTSEC-0000-0000", reason = "the affected function is never called" },
]

[bans]
wildcards = "deny"           # no `*` version requirements
multiple-versions = "warn"
deny = [
  # { crate = "openssl", reason = "standardized on rustls" },
]

[sources]
unknown-registry = "deny"    # only crates.io, plus registries listed in allow-registry
unknown-git = "deny"         # no git dependencies unless listed in allow-git
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
```

## Run the checks on every pull request

This script ran clean on the fixed crate. Run it without secrets in the environment. `cargo geiger` exits non-zero when it finds files it cannot scan, so treat its output as a report, not a gate.

```bash
#!/bin/sh
# Run from the workspace root on every pull request. Needs: stable + nightly (miri),
# cargo-audit, cargo-deny; cargo-geiger for the report-only step.
set -eu
cargo clippy --locked --all-targets -- -D warnings
cargo test --locked
cargo test --locked --release                      # release profile: overflow-checks = true
cargo +nightly miri test --locked                  # FFI tests are #[cfg(not(miri))]
cargo audit --deny warnings                        # RustSec: vulnerable, unsound, yanked, unmaintained
cargo deny check advisories bans sources
cargo geiger --output-format Ascii || true         # report only: review jumps in unsafe counts
```

## Replace C components one boundary at a time

Android's numbers show where the gain comes from. Google wrote new code in memory-safe languages instead of rewriting old C++, and in 2025 memory-safety bugs fell below 20 percent of Android's vulnerabilities for the first time. With about 5 million lines of Rust and one memory-safety bug, caught before release, Google estimates 0.2 such vulnerabilities per million lines of Rust against about 1,000 per million in its historical C and C++, and says about 4 percent of that Rust is inside unsafe blocks.

To replace a C component, pick one with a narrow interface that parses untrusted input, and implement it in Rust behind the same C signature, as `record_payload_len` does, so callers do not change. Run the old and new versions side by side and compare their results, as CISA's roadmap guidance suggests, and fuzz both with the same corpus. Keep the C-side defenses while C remains: Google's near-miss, a buffer overflow in unsafe Rust in CrabbyAVIF (CVE-2025-48530), never shipped, and Google found that Android's Scudo allocator would have made it non-exploitable, because Scudo puts guard pages around large allocations.

## Pitfalls

Reviews of unsafe and FFI code usually miss one of these.

- Marking a function safe because today's callers pass valid arguments.
- `SAFETY` comments that restate the operation instead of proving its preconditions.
- Trusting a length, pointer or ownership claim from C, or a trait implementation that a caller supplies.
- Testing only debug builds, where overflow panics and precondition checks hide what release builds do.
- Treating a clean Miri run as coverage for FFI code it cannot execute.
- Running `cargo build`, `check` or `test` on a project you do not trust: its build scripts and procedural macros run on your machine. Audit an untrusted lockfile with `cargo audit --file` from another directory.

## Guided practice

A pull request adds thumb, a crate that C code calls to size thumbnail buffers before it allocates them; thumb in turn calls the existing C decoder to read the image header. Review the Rust side.

### Supplied material

```rust
// thumb: C code calls thumb_size(); Rust calls the C decoder's decode_header().
use std::ffi::{CStr, c_char, c_int};

unsafe extern "C" {
    fn decode_header(buf: *const u8, len: usize, w: *mut u32, h: *mut u32) -> c_int;
}

pub fn dimensions(buf: &[u8]) -> (u32, u32) {
    let (mut w, mut h) = (0u32, 0u32);
    unsafe { decode_header(buf.as_ptr(), buf.len(), &mut w, &mut h) };
    (w, h)
}

/// Bytes the C caller must allocate for an RGBA thumbnail no larger than max_side.
#[unsafe(no_mangle)]
pub extern "C" fn thumb_size(buf: *const u8, len: usize, max_side: u32) -> u32 {
    let data = unsafe { std::slice::from_raw_parts(buf, len) };
    let (w, h) = dimensions(data);
    let scale = w.max(h) / max_side;
    (w / scale) * (h / scale) * 4
}

pub fn label(name: *const c_char) -> String {
    unsafe { CStr::from_ptr(name) }.to_str().unwrap().to_owned()
}
```

### 1. Find the unsound functions

Name each safe function that a safe caller could use to cause undefined behavior, and say how.

Worked answer: `thumb_size` and `label` are safe functions that dereference caller-supplied raw pointers, so a dangling or null pointer makes `slice::from_raw_parts` or `CStr::from_ptr` undefined behavior. Both must become `unsafe fn`s with `# Safety` sections, or `label` should take `&CStr`. The unsafe block in `dimensions` has no `SAFETY` comment and ignores `decode_header`'s return code.

### 2. Trace a small image

C calls `thumb_size` for a valid 100 × 80 image with `max_side = 256`. Follow the arithmetic.

Worked answer: `scale = 100 / 256 = 0`, so `w / scale` panics with `"attempt to divide by zero"` in every build. The panic reaches an `extern "C"` function, so the process aborts with `"panic in a function that cannot unwind"`: any small image takes down the host program.

### 3. Trace a huge image

The header says 40,000 × 40,000 and `max_side = 40,000`. Compute the result in a debug build and in a release build with Cargo's default profile.

Worked answer: `scale = 1`, so the result is 40,000 × 40,000 × 4 = 6,400,000,000, which exceeds `u32::MAX`. A debug build panics with `"attempt to multiply with overflow"` and aborts; a release build wraps to 2,105,032,704, so C allocates about 2.1 GB for 6.4 GB of pixels and overflows the heap when it writes them.

### 4. Check the error path

`decode_header` returns -1 for a truncated header. What does `thumb_size` do?

Worked answer: `dimensions` ignores the return code and returns `(0, 0)`, so `scale` is 0 and the division panics, aborting the host again. Without the panic, a size of 0 would reach C as if it were valid.

### 5. Write the fix and the gates

Give corrected signatures and the build and CI settings that would have caught each problem.

Worked answer: `pub unsafe extern "C" fn thumb_size(...) -> u64`, returning `0` for a null pointer, a failed decode, `max_side == 0` or any failed `checked_div` or `checked_mul`; `dimensions` returns `Option<(u32, u32)>`; `label` takes `&CStr` and uses `to_string_lossy`. Settings: `overflow-checks = true`, the four unsafe lints at deny, a Miri test of the arithmetic with a stub decoder, an AddressSanitizer run of the FFI path and a cargo-fuzz target for `thumb_size`.

Deliverable: Review comments that give each defect and the input that triggers it, the corrected Rust code, and the lint and CI changes.

Scope: Paper exercise. The traces were reproduced with Rust 1.99.0 on macOS against a C stub of `decode_header`; a real decoder may fail differently on malformed headers.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [The Rust Reference: Behavior considered undefined (soundness)](https://doc.rust-lang.org/reference/behavior-considered-undefined.html#r-undefined.soundness)
- [The Rust Reference: Behavior not considered unsafe (integer overflow)](https://doc.rust-lang.org/reference/behavior-not-considered-unsafe.html#integer-overflow)
- [The Rustonomicon: How Safe and Unsafe Interact](https://doc.rust-lang.org/nomicon/safe-unsafe-meaning.html)
- [The Rustonomicon: FFI and unwinding](https://doc.rust-lang.org/nomicon/ffi.html#ffi-and-unwinding)
- [The Cargo Book: profiles, overflow-checks](https://doc.rust-lang.org/cargo/reference/profiles.html#overflow-checks)
- [Miri: an undefined behavior detection tool for Rust (README)](https://github.com/rust-lang/miri#readme)
- [CVE-2025-68260: rust_binder race condition on death_list](https://www.cve.org/CVERecord?id=CVE-2025-68260)
- [Rust Blog: Supply chain attack on arrayref (20 August 2026)](https://blog.rust-lang.org/2026/08/20/supply-chain-attack-on-arrayref/)
- [cargo-deny: advisories configuration](https://embarkstudios.github.io/cargo-deny/checks/advisories/cfg.html)
- [Google: Rust in Android: move fast and fix things (13 November 2025)](https://blog.google/security/rust-in-android-move-fast-fix-things/)
- [Rust Blog: Be alert: targeted attacks on prominent Rustaceans (17 September 2026)](https://blog.rust-lang.org/2026/09/17/targeted-attacks/)
- [RustSec: RUSTSEC-2026-0260, arrayref 0.3.10 removed for a malicious dependency](https://rustsec.org/advisories/RUSTSEC-2026-0260.html)