Skip to content

Soundness: UAF/concurrent access on the MAPPINGS_CACHE #758

Description

@Manishearth

Note

This finding was identified during an agentic unsafe Rust code review performed by Gemini AI, followed by human review and verification.

The Issue

In backtrace v0_3, the symbolication backend using gimli maintains a process-wide global cache of DWARF mappings (static mut MAPPINGS_CACHE) at src/symbolize/gimli.rs#L17-L26, protected by a re-entrant mutex (crate::lock::lock()) in backtrace::resolve at

/// false // only look at the top frame
/// });
/// }
/// ```
#[cfg(feature = "std")]
pub fn resolve_frame<F: FnMut(&Symbol)>(frame: &Frame, cb: F) {
let _guard = crate::lock::lock();
unsafe { resolve_frame_unsynchronized(frame, cb) }

When resolving symbols, Cache::with_global borrows MAPPINGS_CACHE mutably and obtains a DWARF context cx and stash stash from an active cached mapping. It then executes the caller's closure cb(&Symbol) while this mutable cache borrow remains live.

} else if let Some(name) = id.xcoff_name() {
let data = object.section(stash, name).unwrap_or(&[]);
Ok(EndianSlice::new(data, Endian))
} else {
Ok(EndianSlice::new(&[], Endian))
}
})
.ok()?;
if let Some(sup) = sup {
sections
.load_sup(|id| -> Result<_, ()> {
let data = sup.section(stash, id.name()).unwrap_or(&[]);

Because the global lock is re-entrant and cb runs synchronously inside the live borrow of MAPPINGS_CACHE, re-entrant calls in safe code trigger memory safety violations:

  1. Use-After-Free via clear_symbol_cache(): If cb calls backtrace::clear_symbol_cache(), it creates a second concurrent &mut Cache reference to MAPPINGS_CACHE (Aliasing Violation) and clears cache.mappings. This drops all cached Mappings and unmaps the DWARF file (Mmap), deallocating cx and stash. When cb returns, resolve continues iterating frames.next() on dangling pointers (Use-After-Free).
  2. Use-After-Free via LRU Cache Eviction: The LRU cache has a fixed capacity of 4 (MAPPINGS_CACHE_SIZE = 4). If cb recursively resolves symbols across 4+ libraries, new mappings evict and drop the oldest mapping actively in use by the outer resolve frame (Use-After-Free).
Minimal Reproduction (Segfault)
fn main() {
    let mut frame_idx = 0;
    backtrace::trace(|frame| {
        let ip = frame.ip();
        let idx = frame_idx;
        frame_idx += 1;
        
        // Skip the unwinder/backtrace internal frames to target repro1::main (idx 2)
        if idx == 2 {
            println!("Target frame reached (idx {}). Resolving...", idx);
            backtrace::resolve(ip, |symbol| {
                println!("Inside resolve callback. Name before clear: {:?}", symbol.name());
                println!("Clearing symbol cache...");
                backtrace::clear_symbol_cache();
                
                println!("Cache cleared. Accessing name again...");
                // This should read from unmapped memory and crash/segfault!
                let name = symbol.name();
                println!("Symbol name after clear: {:?}", name);
            });
            false // Stop tracing
        } else {
            true // Continue tracing
        }
    });
    println!("Resolve finished successfully.");
}
Target frame reached (idx 2). Resolving...
Inside resolve callback. Name before clear: Some(repro1::main::hbdc6b77fc919de45)
Clearing symbol cache...
Cache cleared. Accessing name again...
Segmentation fault (core dumped)
Exit code: 139
Suggested Fix

Apply necessary runtime bounds checks or formalize the unsafe fn safety contract pre-conditions.

Note

The full audit report below also contains additional minor findings (such as missing safety comments or undocumented FFI assumptions) that are probably worth fixing as well but not the primary goal of this issue. The audit report has not been human-reviewed, it may contain misleading claims.

Full Gemini Codebase Audit Report Appendix

Unsafe Rust Review: backtrace (v0_3)

Overall Safety Assessment

The backtrace crate makes extensive use of unsafe code to interface with system-level backtracing libraries (like libunwind and dbghelp.dll) and manually parses debug information (via gimli/object). While the system-level interfaces are generally well-encapsulated (for example, using re-entrant process-wide named mutexes on Windows and stack-based panicky "bombs" to prevent panic unwinding through foreign C frames on Unix), there is a critical soundness vulnerability in the symbolication cache design.

A combination of re-entrant global locking and a fixed-size LRU cache for DWARF mapping metadata allows safe Rust callbacks to easily trigger use-after-free (UAF) and aliasing violations. In addition, there is a lack of # Safety documentation for public unsafe functions and many internal unsafe blocks lack proper safety comments.

Due to the critical UAF vulnerability, the crate is Unsound (Critical risk).

Critical Findings

Use-After-Free (UAF) and Aliasing Violation in Gimli Symbolication Cache 🔴 🤦

  • Severity: 🔴 High
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Use-After-Free & Aliasing Violation
  • Vulnerability Type: Use-After-Free & Aliasing Violation
  • Location: src/symbolize/gimli.rs (and associated files src/symbolize/mod.rs / src/lib.rs)

Description

The symbolication backend using gimli maintains a process-wide global cache of parsed DWARF mappings:

static mut MAPPINGS_CACHE: Option<Cache> = None;

Access to this cache is protected by a re-entrant global lock:

pub fn resolve<F: FnMut(&Symbol)>(addr: *mut c_void, cb: F) {
    let _guard = crate::lock::lock();
    unsafe { resolve_unsynchronized(addr, cb) }
}

Because the lock is re-entrant, a thread that already holds the lock can acquire it again without blocking.
During resolve, the cache is borrowed mutably via Cache::with_global:

unsafe fn with_global(f: impl FnOnce(&mut Self)) {
    static mut MAPPINGS_CACHE: Option<Cache> = None;
    unsafe {
        f(MAPPINGS_CACHE.get_or_insert_with(Cache::new))
    }
}

Inside the closure f, the symbol for an address is resolved. This yields a DWARF context cx and stash stash borrowed from one of the active mappings in the cache:

let (cx, stash) = match cache.mapping_for_lib(lib) {
    Some((cx, stash)) => (cx, stash),
    None => return,
};

The resolve closure then calls the user's callback cb while still borrowing the cache:

if let Ok(mut frames) = cx.find_frames(stash, addr as u64) {
    while let Ok(Some(frame)) = frames.next() {
        ...
        call(Symbol::Frame { ... }); // calls cb
    }
}

Since the callback runs inside the borrow of cache and under the re-entrant lock, it can execute arbitrary safe Rust code, including calling other backtrace APIs. This allows two separate soundness exploits:

Exploitation Scenario 1: clear_symbol_cache() inside callback

If the safe callback cb calls the public safe function clear_symbol_cache(), it triggers the following sequence:

  1. clear_symbol_cache acquires the re-entrant lock (which immediately succeeds).
  2. It calls Cache::with_global(|cache| cache.mappings.clear()).
  3. This creates a second mutable reference &mut Cache to MAPPINGS_CACHE while the outer &mut Cache is still live, violating Rust's aliasing rules (instant UB).
  4. cache.mappings.clear() drops all Mappings in the cache. This drops the Stash and the Mmap associated with the active mapping, unmapping the DWARF files and deallocating memory.
  5. clear_symbol_cache returns.
  6. The outer resolve function continues iterating while let Ok(Some(frame)) = frames.next(). However, frames holds references to the unmapped/dropped cx and stash!
  7. This causes a use-after-free when frames.next() is called.

Exploitation Scenario 2: Recursive resolve() inside callback

The LRU cache (cache.mappings) has a fixed capacity of 4:

const MAPPINGS_CACHE_SIZE: usize = 4;

If the callback cb recursively calls resolve for addresses in 4 or more different shared libraries, it will insert new mappings into the LRU cache. This will evict the oldest mapping (which is the mapping currently being used by the outer resolve frame).

  1. When the oldest mapping is evicted, it is dropped, unmapping its Mmap and deallocating its Stash.
  2. The outer resolve frame continues execution after the recursive calls return.
  3. It attempts to read from cx or stash, dereferencing pointers to unmapped memory (UAF).

Proof of Concept (PoC)

The following safe Rust code is sufficient to trigger the UAF (on Unix/Linux):

fn main() {
    // Resolve any address
    backtrace::resolve(main as *mut _, |symbol| {
        // Trigger cache clearing while resolve is active
        backtrace::clear_symbol_cache();
    });
}

Critical Findings

Use-After-Free (UAF) and Aliasing Violation in Gimli Symbolication Cache 🔴 🤦

  • Severity: 🔴 High
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Use-After-Free & Aliasing Violation
    [Duplicate entry - see main description above]

Fishy Findings

Stack-Allocated Pointers with 'static Lifetime Lie 🟡 🤸

  • Severity: 🟡 Low
  • Threat Vector: 🤸 Deliberate Contortion
  • Bug Type: Invalid Lifetime
  • Location: src/symbolize/dbghelp.rs (lines 37-47, 225-314)

Description

In src/symbolize/dbghelp.rs, the Windows MSVC symbolication backend copies the symbol name into a stack-allocated buffer:

let mut name_buffer = [0_u8; 256];
let mut name_len = unsafe { WideCharToMultiByte(..., name_buffer.as_mut_ptr(), ...) };
...
let name = ptr::addr_of!(name_buffer[..name_len]);

Then, it constructs the Symbol struct:

cb(&super::Symbol {
    inner: Symbol {
        name, // raw pointer to stack-allocated `name_buffer`
        addr: unsafe { (*info).Address } as *mut _,
        line: lineno,
        filename,
        _filename_cache: unsafe { cache(filename) },
        _marker: marker::PhantomData,
    },
})

However, super::Symbol is declared as:

pub struct Symbol {
    inner: imp::Symbol<'static>,
}

This forces the compiler to treat Symbol::inner as having a 'static lifetime.
Thus, we are constructing a Symbol<'static> where the name raw pointer points to a local variable name_buffer on the stack that will be deallocated as soon as do_resolve returns.

Although this is technically safe because super::Symbol is only handed out as a reference &super::Symbol with a short anonymous lifetime, and there are no public APIs allowing the user to copy or clone it, it is a highly fragile design. It lies about lifetimes to the compiler and relies on the API not exposing ways to leak the internal state.

Missing Safety Comments

Lack of # Safety Documentation on Public Unsafe APIs 🟡 🤦

  • Severity: 🟡 Low
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Missing Documentation
    The following public functions are marked unsafe but completely lack a # Safety section in their documentation explaining their preconditions:
  • trace_unsynchronized (in src/backtrace/mod.rs)
  • resolve_unsynchronized (in src/symbolize/mod.rs)
  • resolve_frame_unsynchronized (in src/symbolize/mod.rs)

They are unsafe because they require external synchronization to prevent concurrent mutable access to global static state (like MAPPINGS_CACHE on Unix or dbghelp.dll initialization on Windows). This contract must be documented.

Lack of // SAFETY: Comments on Unsafe Blocks 🟡 🤦

  • Severity: 🟡 Low
  • Threat Vector: 🤦 Accidental Misuse
  • Bug Type: Missing Safety Comment
    Multiple unsafe operations throughout the codebase are performed without documenting why they are safe:
  • Unix Mmap::map & munmap: src/symbolize/gimli/mmap_unix.rs calls FFI functions mmap64 and munmap without documenting safety preconditions or explaining how the mapped memory's validity is maintained.
  • Android Mmap::map from ZIP: src/symbolize/gimli/elf.rs maps a ZIP-embedded library without safety comments.
  • dl_iterate_phdr: src/symbolize/gimli/libs_dl_iterate_phdr.rs calls FFI libc::dl_iterate_phdr and casts raw pointers without any // SAFETY: comments.
  • MSVC API Calls: src/dbghelp.rs makes numerous Windows FFI calls (e.g. CreateMutexA, WaitForSingleObjectEx, LoadLibraryA) and performs atomic operations on raw pointer pointers without safety justifications.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions