Repository navigation
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
backtrace-rs/src/symbolize/mod.rs
Lines 98 to 105 in d902726
| /// 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.
backtrace-rs/src/symbolize/gimli.rs
Lines 130 to 142 in d902726
| } 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:
- 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.
Activity
workingjubilee commentedon Jun 26, 2026
Hmm.
clear_symbol_cacheis alreadyunsafe?The implicit mapping clearance feels more worrisome.
workingjubilee commentedon Jun 26, 2026
Oh there's a public function which exposes it safely?
lol yeah that ain't sound
workingjubilee commentedon Jun 26, 2026
@Manishearth The bot claims this:
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_cacheis madeunsafewith an appropriateunsafecontract 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?"Manishearth commentedon Jun 27, 2026
Looks like it's still possible to trigger. Unfortunately you have to actually
dlopena bunch of libraries and then call them. The repro does need unsafe code to successfullydlopenthings 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
Error
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) end1. Stack Setup & IP Capture
To resolve symbols,
$$\text{Main} \rightarrow \text{Dep1} \rightarrow \text{Dep2} \rightarrow \text{Dep3} \rightarrow \text{Dep4} \rightarrow \text{callback}$$
backtraceneeds valid instruction pointers (IPs) pointing inside mapped library memory (PLT stubs won't work). Symmetrical to this, the exploit executes a nested call chain:This puts 5 distinct libraries on the stack. The callback runs
backtrace::traceto capture these 5 valid IPs.2. Cache the Target (Outer Resolve)
We call
backtrace::resolve(ip_main, |symbol1| { ... }).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 callingresolveon the other 4 library IPs (dep1todep4).Mmap(viamunmapon Unix) and deallocates itsStash.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).ChrisDenton commentedon Jun 27, 2026
So to summarize, we store the last four mappings. If you call
resolvewithin aresolvecallback, it's possible to overwrite the existing cache entries (starting with the oldest) but still have pointers to the evicted memory from the outerresolvecallback.The simplest fix is to either disallow reentrancy completely or at least disallow modifying the cache while a
resolvecallback is still live.workingjubilee commentedon Jun 27, 2026
You're a man of
culturethe webThe Culturegood 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
unsafeunless we do that toresolveand I don't think we wish to give up that much... yet, anyways.Manishearth commentedon Jun 27, 2026
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.
workingjubilee commentedon Jun 27, 2026
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.
resolvepasses symbols which can be invalidated #753 as a duplicate of this issue