rust-lang / rust-lang/backtrace-rs
Soundness: UAF/concurrent access on the MAPPINGS_CACHE
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 628
- Forks
- 291
- PR merge metrics
- No merged PRs in 30d
Description
[!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
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.
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:
- Use-After-Free via
clear_symbol_cache(): Ifcbcallsbacktrace::clear_symbol_cache(), it creates a second concurrent&mut Cachereference toMAPPINGS_CACHE(Aliasing Violation) and clearscache.mappings. This drops all cachedMappings and unmaps the DWARF file (Mmap), deallocatingcxandstash. Whencbreturns,resolvecontinues iteratingframes.next()on dangling pointers (Use-After-Free). - Use-After-Free via LRU Cache Eviction: The LRU cache has a fixed capacity of 4 (
MAPPINGS_CACHE_SIZE = 4). Ifcbrecursively resolves symbols across 4+ libraries, new mappings evict and drop the oldest mapping actively in use by the outerresolveframe (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 filessrc/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:
clear_symbol_cacheacquires the re-entrant lock (which immediately succeeds).- It calls
Cache::with_global(|cache| cache.mappings.clear()). - This creates a second mutable reference
&mut CachetoMAPPINGS_CACHEwhile the outer&mut Cacheis still live, violating Rust's aliasing rules (instant UB). cache.mappings.clear()drops allMappings in the cache. This drops theStashand theMmapassociated with the active mapping, unmapping the DWARF files and deallocating memory.clear_symbol_cachereturns.- The outer
resolvefunction continues iteratingwhile let Ok(Some(frame)) = frames.next(). However,framesholds references to the unmapped/droppedcxandstash! - 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).
- When the oldest mapping is evicted, it is dropped, unmapping its
Mmapand deallocating itsStash. - The outer
resolveframe continues execution after the recursive calls return. - It attempts to read from
cxorstash, 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 markedunsafebut completely lack a# Safetysection in their documentation explaining their preconditions:
trace_unsynchronized(insrc/backtrace/mod.rs)resolve_unsynchronized(insrc/symbolize/mod.rs)resolve_frame_unsynchronized(insrc/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.rscalls FFI functionsmmap64andmunmapwithout documenting safety preconditions or explaining how the mapped memory's validity is maintained. - Android
Mmap::mapfrom ZIP:src/symbolize/gimli/elf.rsmaps a ZIP-embedded library without safety comments. dl_iterate_phdr:src/symbolize/gimli/libs_dl_iterate_phdr.rscalls FFIlibc::dl_iterate_phdrand casts raw pointers without any// SAFETY:comments.- MSVC API Calls:
src/dbghelp.rsmakes numerous Windows FFI calls (e.g.CreateMutexA,WaitForSingleObjectEx,LoadLibraryA) and performs atomic operations on raw pointer pointers without safety justifications.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/symbolize/gimli.rs, especially Cache::with_global and the MAPPINGS_CACHE handling, then trace the resolve path in src/symbolize/mod.rs. Run the minimal reproduction from the issue, including clear_symbol_cache inside the callback, and examine recursive resolution across multiple libraries. Done means callbacks cannot cause the active mapping to be aliased, evicted, or freed while resolution still uses it.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- security
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 25/100