Skip to content

Soundness: UAF/concurrent access on the MAPPINGS_CACHE #758

Open

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.

Activity

  1. workingjubilee commented on Jun 26, 2026

    @workingjubilee
    Member

    Hmm. clear_symbol_cache is already unsafe?

    The implicit mapping clearance feels more worrisome.

  2. workingjubilee commented on Jun 26, 2026

    @workingjubilee
    Member

    Oh there's a public function which exposes it safely?

    lol yeah that ain't sound

  3. workingjubilee commented on Jun 26, 2026

    @workingjubilee
    Member

    @Manishearth The bot claims this:

    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).

    Does it have a reproducer that doesn't rely on "just" calling an unsound-to-be-safe-I-have-no-idea-why-it-is function, or is it just blathering here?

    Specifically,

    "Assuming clear_symbol_cache is made unsafe with an appropriate unsafe contract that is sufficiently specified to discourage this misuse, what is another proof-of-concept that will trigger the Use-after-Free here without invoking that function?"

  4. Manishearth commented on Jun 27, 2026

    @Manishearth
    MemberAuthor

    Looks like it's still possible to trigger. Unfortunately you have to actually dlopen a bunch of libraries and then call them. The repro does need unsafe code to successfully dlopen things but it's unsafe code being used correctly afaict.

    Verified that this repro fails. I have not gone in depth through the exploit explanation (pardon the unreviewed LLM output); but it makes sense to me, and I figured it wouldn't hurt to include anyway.

    Segfaulting repro
    use std::ffi::c_void;
    use std::fs;
    use std::path::PathBuf;
    use std::process::Command;
    use std::sync::{Mutex, OnceLock};
    
    // Inline source code for the 4 helper dylibs.
    // All functions are now safe, standard Rust functions (default Rust ABI, no unsafe, no extern "C").
    const DEP1_SRC: &str = r#"
    type DbgCallback = fn();
    type Dyp2Fn = fn(*const (), *const (), DbgCallback);
    #[no_mangle]
    pub fn dep1_foo(fp2: Dyp2Fn, fp3: *const (), fp4: *const (), cb: DbgCallback) {
        fp2(fp3, fp4, cb);
    }
    "#;
    
    const DEP2_SRC: &str = r#"
    type DbgCallback = fn();
    type Dyp3Fn = fn(*const (), DbgCallback);
    #[no_mangle]
    pub fn dep2_foo(fp3: Dyp3Fn, fp4: *const (), cb: DbgCallback) {
        fp3(fp4, cb);
    }
    "#;
    
    const DEP3_SRC: &str = r#"
    type DbgCallback = fn();
    type Dyp4Fn = fn(DbgCallback);
    #[no_mangle]
    pub fn dep3_foo(fp4: Dyp4Fn, cb: DbgCallback) {
        fp4(cb);
    }
    "#;
    
    const DEP4_SRC: &str = r#"
    type DbgCallback = fn();
    #[no_mangle]
    pub fn dep4_foo(cb: DbgCallback) {
        cb();
    }
    "#;
    
    // Global static to store captured IPs as thread-safe `usize` integers.
    static CAPTURED_IPS: OnceLock<Mutex<Vec<usize>>> = OnceLock::new();
    
    // Standard Rust function callback (no extern "C")
    fn backtrace_callback() {
        backtrace::trace(|frame| {
            if let Some(mutex) = CAPTURED_IPS.get() {
                mutex.lock().unwrap().push(frame.ip() as usize);
            }
            true
        });
    }
    
    fn compile_dylib(name: &str, src: &str) -> PathBuf {
        let tmp_dir = std::env::temp_dir();
        let src_path = tmp_dir.join(format!("{}.rs", name));
        
        let (prefix, suffix) = if cfg!(target_os = "windows") {
            ("", "dll")
        } else if cfg!(target_os = "macos") {
            ("lib", "dylib")
        } else {
            ("lib", "so")
        };
        let dylib_path = tmp_dir.join(format!("{}{}.{}", prefix, name, suffix));
    
        fs::write(&src_path, src).unwrap();
    
        println!("Compiling {} to {}...", src_path.display(), dylib_path.display());
        let status = Command::new("rustc")
            .arg("--crate-type=cdylib")
            .arg(&src_path)
            .arg("-o")
            .arg(&dylib_path)
            .status()
            .expect("Failed to run rustc. Make sure rustc is in your PATH.");
    
        assert!(status.success(), "Failed to compile dylib {}", name);
    
        // Clean up source file
        let _ = fs::remove_file(src_path);
    
        dylib_path
    }
    
    fn main() {
        // Miri Guard: Miri does not support loading dynamic libraries at runtime (dlopen).
        if cfg!(miri) {
            println!("⚠️ Miri detected. Skipping LRU eviction reproduction since Miri does not support dynamic library loading (dlopen).");
            return;
        }
    
        CAPTURED_IPS.set(Mutex::new(Vec::new())).unwrap();
    
        // 1. Compile the 4 helper dylibs at runtime
        let path1 = compile_dylib("dep1", DEP1_SRC);
        let path2 = compile_dylib("dep2", DEP2_SRC);
        let path3 = compile_dylib("dep3", DEP3_SRC);
        let path4 = compile_dylib("dep4", DEP4_SRC);
    
        // 2. Load the libraries (requires unsafe due to loading arbitrary binary data)
        let lib1 = unsafe { libloading::Library::new(&path1) }.unwrap();
        let lib2 = unsafe { libloading::Library::new(&path2) }.unwrap();
        let lib3 = unsafe { libloading::Library::new(&path3) }.unwrap();
        let lib4 = unsafe { libloading::Library::new(&path4) }.unwrap();
    
        // 3. Get the symbols as SAFE Rust function pointers (no unsafe, no extern "C")
        type DbgCallback = fn();
        type Dyp1Fn = fn(*const (), *const (), *const (), DbgCallback);
        type Dyp2Fn = fn(*const (), *const (), DbgCallback);
        type Dyp3Fn = fn(*const (), DbgCallback);
        type Dyp4Fn = fn(DbgCallback);
    
        // Obtaining the symbol remains unsafe
        let foo1: libloading::Symbol<Dyp1Fn> = unsafe { lib1.get(b"dep1_foo") }.unwrap();
        let foo2: libloading::Symbol<Dyp2Fn> = unsafe { lib2.get(b"dep2_foo") }.unwrap();
        let foo3: libloading::Symbol<Dyp3Fn> = unsafe { lib3.get(b"dep3_foo") }.unwrap();
        let foo4: libloading::Symbol<Dyp4Fn> = unsafe { lib4.get(b"dep4_foo") }.unwrap();
    
        // 4. Trigger the continuation-passing nested call chain.
        // Notice that calling the functions is now 100% SAFE Rust (no unsafe block)!
        println!("\nExecuting nested call chain across 5 libraries...");
        foo1(*foo2 as *const (), *foo3 as *const (), *foo4 as *const (), backtrace_callback);
    
        // 5. Extract the captured IPs and cast them back to *mut c_void
        let ips: Vec<*mut c_void> = CAPTURED_IPS.get().unwrap().lock().unwrap().iter().map(|&ip| ip as *mut c_void).collect();
        println!("Captured {} stack frames.", ips.len());
    
        // Identify the correct IPs belonging to each library by resolving them.
        let mut ip_main = None;
        let mut ip_dep1 = None;
        let mut ip_dep2 = None;
        let mut ip_dep3 = None;
        let mut ip_dep4 = None;
    
        for &ip in &ips {
            backtrace::resolve(ip, |symbol| {
                if let Some(name) = symbol.name().and_then(|n| n.as_str()) {
                    if name.contains("repro") && ip_main.is_none() {
                        ip_main = Some(ip);
                    } else if name.contains("dep1") && ip_dep1.is_none() {
                        ip_dep1 = Some(ip);
                    } else if name.contains("dep2") && ip_dep2.is_none() {
                        ip_dep2 = Some(ip);
                    } else if name.contains("dep3") && ip_dep3.is_none() {
                        ip_dep3 = Some(ip);
                    } else if name.contains("dep4") && ip_dep4.is_none() {
                        ip_dep4 = Some(ip);
                    }
                }
            });
        }
    
        let ip_main = ip_main.expect("Failed to find repro (main) frame");
        let ip_dep1 = ip_dep1.expect("Failed to find dep1 frame");
        let ip_dep2 = ip_dep2.expect("Failed to find dep2 frame");
        let ip_dep3 = ip_dep3.expect("Failed to find dep3 frame");
        let ip_dep4 = ip_dep4.expect("Failed to find dep4 frame");
    
        println!("\n--- Starting LRU Eviction Exploit ---");
        println!("Target Main IP (to be evicted): {:p}", ip_main);
    
        // 6. Run the exploit!
        // Resolve ip_main. This loads the main binary's DWARF mapping (slot 1).
        backtrace::resolve(ip_main, |symbol1| {
            println!("Inside outer resolve for main. Symbol: {:?}", symbol1.name());
    
            // Recursively resolve the other 4 libraries.
            // This will load 4 new DWARF mappings (dep1, dep2, dep3, dep4),
            // filling the LRU cache (capacity 4) and evicting the main binary's mapping!
            println!("Running recursive resolves to evict main from cache...");
            
            backtrace::resolve(ip_dep1, |s| println!("  Resolved: {:?}", s.name()));
            backtrace::resolve(ip_dep2, |s| println!("  Resolved: {:?}", s.name()));
            backtrace::resolve(ip_dep3, |s| println!("  Resolved: {:?}", s.name()));
            backtrace::resolve(ip_dep4, |s| println!("  Resolved: {:?}", s.name()));
    
            // Access symbol1 again.
            // Since the main binary's mapping was evicted and dropped (unmapped),
            // this should dereference dangling pointers and trigger a Segmentation Fault!
            println!("Recursive resolves finished. Accessing symbol1 name again...");
            let name = symbol1.name();
            println!("Symbol 1 name after eviction: {:?}", name);
        });
    }

    Error

    Compiling /tmp/dep1.rs to /tmp/libdep1.so...
    Compiling /tmp/dep2.rs to /tmp/libdep2.so...
    Compiling /tmp/dep3.rs to /tmp/libdep3.so...
    Compiling /tmp/dep4.rs to /tmp/libdep4.so...
    Executing nested call chain across 5 libraries...
    Captured 18 stack frames.
    --- Starting LRU Eviction Exploit ---
    Target Main IP (to be evicted): 0x56548be74d36
    Inside outer resolve for main. Symbol: Some(repro2::backtrace_callback::hb57166f77ba647f3)
    Running recursive resolves to evict main from cache...
      Resolved: Some("dep1_foo")
      Resolved: Some("dep2_foo")
      Resolved: Some("dep3_foo")
      Resolved: Some("dep4_foo")
    Recursive resolves finished. Accessing symbol1 name again...
    Segmentation fault (core dumped)
    EXIT_CODE: 139
    
    Exploit explanation
    sequenceDiagram
        autonumber
        rect rgb(200, 220, 240)
        Note over Main,Dep4: Step 1: Capture IPs (Nested Stack)
        Main->>Dep1: dep1_foo()
        Dep1->>Dep2: dep2_foo()
        Dep2->>Dep3: dep3_foo()
        Dep3->>Dep4: dep4_foo()
        Dep4->>Main: backtrace_callback() (captures 5 IPs)
        end
        
        rect rgb(240, 200, 200)
        Note over Main,Cache: Step 2: Trigger LRU Eviction
        Main->>Cache: resolve(IP_Main) [Loads Main DWARF into Cache]
        Active Cache Borrow-->>Main: symbol1 (borrowed from Main DWARF)
        
        Note over Main,Cache: Recursive resolves (4 libraries)
        Main->>Cache: resolve(IP_Dep1) [Loads Dep1]
        Main->>Cache: resolve(IP_Dep2) [Loads Dep2]
        Main->>Cache: resolve(IP_Dep3) [Loads Dep3]
        Main->>Cache: resolve(IP_Dep4) [Loads Dep4 -> Evicts Main DWARF! (Capacity=4)]
        Cache--xCache: Drops Main DWARF (munmap & deallocate)
        
        Note over Main: Step 3: Use-After-Free
        Main->>Main: Accesses symbol1.name() (dangling pointer!)
        Note over Main: 💥 Segmentation Fault (Exit 139)
        end
    
    Loading

    1. Stack Setup & IP Capture

    To resolve symbols, backtrace needs valid instruction pointers (IPs) pointing inside mapped library memory (PLT stubs won't work). Symmetrical to this, the exploit executes a nested call chain:
    $$\text{Main} \rightarrow \text{Dep1} \rightarrow \text{Dep2} \rightarrow \text{Dep3} \rightarrow \text{Dep4} \rightarrow \text{callback}$$
    This puts 5 distinct libraries on the stack. The callback runs backtrace::trace to capture these 5 valid IPs.

    2. Cache the Target (Outer Resolve)

    We call backtrace::resolve(ip_main, |symbol1| { ... }).

    • This acquires the global re-entrant lock.
    • It loads the main binary's DWARF mapping into the global LRU cache (occupying slot 1).
    • It yields symbol1, which holds a reference (&'a Symbol) borrowing directly from the active DWARF mapping.

    3. Eviction via Safe Recursion (Inner Resolves)

    Inside the callback, while symbol1's borrow is still active, we exploit the re-entrant lock by recursively calling resolve on the other 4 library IPs (dep1 to dep4).

    • Each of these 4 recursive calls loads a new DWARF mapping into the cache.
    • Because the LRU cache has a fixed capacity of 4, inserting 4 new mappings evicts and drops the oldest one (the main binary's mapping).
    • Dropping the mapping unmaps its Mmap (via munmap on Unix) and deallocates its Stash.

    4. Use-After-Free

    When the recursive calls return, the outer callback resumes. We attempt to access symbol1.name(). Because the underlying DWARF mapping was evicted and unmapped in step 3, the pointer is now dangling, resulting in an immediate Segmentation Fault (exit code 139).

  5. ChrisDenton commented on Jun 27, 2026

    @ChrisDenton
    Member

    So to summarize, we store the last four mappings. If you call resolve within a resolve callback, it's possible to overwrite the existing cache entries (starting with the oldest) but still have pointers to the evicted memory from the outer resolve callback.

    The simplest fix is to either disallow reentrancy completely or at least disallow modifying the cache while a resolve callback is still live.

  6. workingjubilee commented on Jun 27, 2026

    @workingjubilee
    Member

    Verified that this repro fails. I have not gone in depth through the exploit explanation (pardon the unreviewed LLM output); but it makes sense to me, and I figured it wouldn't hurt to include anyway.

    You're a man of culture the web The Culture good taste so you know to put these sorts of text dumps behind <details> so I can just not click it! so it's quite fine.

    I agree that this seems to be sound code being made incorrect in a way not easily solved by simply marking an API as unsafe unless we do that to resolve and I don't think we wish to give up that much... yet, anyways.

  7. Manishearth commented on Jun 27, 2026

    @Manishearth
    MemberAuthor

    Yeah when I was reviewing this my first reaction was "we should just make this non-reentrant" and then I thought that reentrancy might be useful in this context. I don't actually understand if it's important or not, but I can see it could be.

    This is also, in the grand scheme of things, not an important bug to try and fix. But worth trying to fix anyway, I hate it when systems have holes, even if you do have to Brain Wall yourself to fit through them.

  8. workingjubilee commented on Jun 27, 2026

    @workingjubilee
    Member

    I think it's important that backtrace be sound-enough. I'm also not in a rush to fix this, but as long as it is transitively part of every Rust program, it shouldn't have a glaring weakness achieved under correct usage.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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