Reversing Rust Binaries
Cut through monomorphization bloat, demangle v0 symbols, and read Rust's fat pointers, enums, and panic landmarks when reversing stripped Rust binaries for CTF.
Reversing Rust Binaries
Rust binaries share Go's first impression — huge, statically linked, and full of standard-library code that has nothing to do with the challenge — but they fail you in a different way. There is no pclntab handing you back every function name. Instead, Rust leans on the system toolchain (LLVM, the platform linker) and produces something that looks like an aggressively optimized C++ binary: heavily inlined, monomorphized, and littered with panic machinery.
Your leverage comes from three places: Rust's symbol mangling (which, unlike a strip-ed Go binary's metadata, is often still present and fully demangleable), the panic/unwind code that LLVM cannot hide, and the very regular memory layout of slices, Option, and Result. Learn to read those and a Rust binary stops being a wall of core::* noise.
A strip-ed Rust binary loses much more than a strip-ed Go binary — Go reconstructs names from runtime metadata, Rust cannot. If symbols survived, demangle them immediately. If they did not, you fall back to panic strings and layout pattern-matching.
Why Rust Binaries Are Bloated
- Monomorphization. Generics in Rust are not type-erased. A
Vec<T>orHashMap<K, V>is compiled fresh for every concreteT/K/Vthe program uses. One generic function in source becomes a dozen near-identical machine functions, each a slightly different specialization. Expect to seecore::ptr::drop_in_placeand iterator adapters duplicated many times over. - Static linking and a fat std. By default
rustcstatically linkslibstd, the allocator, and the panic runtime.lddtypically shows onlylibc,libgcc_s, andlibm; everything Rust is baked in. - Aggressive inlining. LLVM inlines small functions ruthlessly, so the author's logic is frequently smeared across a larger function rather than living in a tidy
check_flag. Iterator chains (.map().filter().collect()) often collapse into one flat loop with no call boundaries.
file ./chal
# ELF 64-bit LSB pie executable, x86-64, dynamically linked, ... stripped
# Quick "is this Rust?" tells:
strings ./chal | grep -m1 "rustc" # e.g. rustc version in a panic path
strings ./chal | grep -Eo "/rustc/[0-9a-f]+" # rustc commit hash in embedded paths
strings ./chal | grep -E "src/main\.rs|\.cargo/registry"
strings ./chal | grep -m1 "_ZN4core\|_RN" # mangled core:: symbolsThe embedded /rustc/<hash>/library/... and .cargo/registry/... source paths in panic messages are a dead giveaway, and they often leak crate names and even line numbers of the interesting code.
Name Mangling and Demangling
This is the single highest-value step. If symbols are present, Rust's mangling is reversible to a clean path like chal::validate::check_flag.
Rust has used two mangling schemes; you will meet both.
The original scheme reuses the C++ Itanium _ZN...E form with a trailing hash, e.g.:
_ZN4core3fmt9Formatter3pad17h2e3f1a8c9b0d4e5fEThe 17h.... block is a 16-hex-digit disambiguating hash (the 17 is its length). Legacy mangling is lossy: generic parameters are not faithfully encoded, so two different monomorphizations can collapse to the same demangled name distinguished only by hash. A plain C++ demangler (c++filt) will partially decode these but mishandles the hash and Rust-specific escapes — use a Rust-aware demangler instead.
c++filt is the wrong tool for Rust. It half-works on legacy _ZN symbols (leaving the 17h... hash visible) and does nothing for v0 _R symbols. Always reach for rustfilt/rustc-demangle so both schemes are handled.
Panic and Unwind Machinery as Landmarks
Because Rust inlines so aggressively, the most reliable landmarks are not function boundaries — they are panics. Every array index, slice operation, integer arithmetic in debug builds, and unwrap()/expect() inserts a call into the panic runtime, and the call site carries a string and source location.
core::panicking::panicandcore::panicking::panic_fmt— the central panic entry points.core::panicking::panic_bounds_check— an out-of-bounds index check. Its presence tells you the preceding code indexed a slice, and the constants nearby are the index and length.core::option::Option::unwrap/Result::unwrapfailure paths — callcore::panicking::panicwith messages likecalled \Option::unwrap()` on a `None` value`._Unwind_Resumeand the.eh_frame/.gcc_except_tablemachinery — landing pads for unwinding. These pepper the binary because Rust uses table-based exception handling forpanic = unwind(the default).
# Panic messages double as a function map — they embed source paths and line numbers:
strings ./chal | grep -E "\.rs($|:)" | sort -u | head
# src/main.rs
# called `Result::unwrap()` on an `Err` value
# index out of bounds: the len is
# The panic location struct (file ptr, file len, line, col) sits next to each call.Treat a panic_bounds_check call as a road sign, not noise. It marks exactly where the program touched a slice, and the immediate operands around it are the length and the index you need to understand the loop. Compiling the target with panic = "abort" removes the unwind tables but the panic strings usually remain.
Fat Pointers, Slices, and Strings
Rust has no null-terminated strings. Like Go, it uses length-carrying representations, and reading them wrong is the classic mistake.
// &[T] and &str are two-word "fat pointers":
struct Slice {
T* ptr; // pointer to first element — NOT null-terminated
size_t len; // element count (bytes, for &str)
};
// String / Vec<T> are three words (owned, heap-allocated):
struct Vec {
T* ptr; // heap buffer
size_t cap; // allocated capacity
size_t len; // current length
};Consequences for reversing:
- A
&strargument occupies two registers/stack slots (pointer + length). A function that looks like it takes(ptr, len, ptr, len)is very likely taking two string slices. - String literals are packed into
.rodataback-to-back with no separators, exactly like Go.stringsshows them concatenated; the length lives in the code as an immediate moved next to the pointer. &dyn Traitand&[T]are both fat pointers, but the second word differs: a slice's second word is a length, a trait object's second word is a vtable pointer. If the second word points into.rodata/.data.rel.roand is dereferenced for an indirect call, it is a vtable, not a length.
# Don't trust null-terminated extraction; correlate the pointer with its adjacent length:
objdump -d ./chal | rustfilt | grep -B2 -A2 "lea.*rip"When you find a string pointer in the decompiler, read the immediately adjacent length moved alongside it and slice exactly that many bytes. Never read to the next null byte — Rust does not put one there.
Enum, Option, and Result Layout
Rust enums are tagged unions, but the compiler is clever about the tag, and knowing the rules lets you read them at a glance.
Ownership and Drop Artifacts
Ownership leaves fingerprints in the generated code even though it is a compile-time concept:
drop_in_place. When a value goes out of scope, the compiler emits a call to a monomorphizedcore::ptr::drop_in_place::<T>. These cluster at function exits and in cleanup landing pads. They are noise for logic, but they reliably mark scope ends and the types involved (the demangled name tells you whatTwas).- Move vs copy. Moves are often just register/memory copies with the source abandoned; there is no runtime move semantics to see. Cleanup (drop) is what makes ownership visible, not the move itself.
- RAII cleanup blocks. Because
panic = unwindis the default, every scope with a droppable value gets a landing pad that runs the drops during unwinding. These show up as duplicate-lookingdrop_in_placecalls reached only from the exception tables — distinguish them from the normal exit path. - Allocator calls.
Vec/String/Boxgrowth and frees route through__rust_alloc,__rust_dealloc, and__rust_realloc. Seeing these confirms heap-backed containers and brackets their lifetimes.
Tooling and Workflow
Ghidra 11.0+ added first-class Rust support: automatic Rust binary detection, v0 and legacy demangling with RustPath awareness for traits, FunctionID signatures for std functions, and a Rust string analysis pass that makes &str readable in the decompiler. Enable the Rust analyzers in Auto Analysis. For older Ghidra, community plugins backfill v0 demangling and Rust type annotation.
Auto Analysis → enable "Rust String Analysis" and demangler options
Window → Symbol Tree → demangled paths like chal::check_flag appearConfirm it's Rust and grab the toolchain
file ./chal
strings ./chal | grep -Eo "/rustc/[0-9a-f]+" # rustc commit → version
strings ./chal | grep -E "\.cargo/registry|src/.*\.rs"Demangle every symbol you have
nm ./chal | rustfilt | grep -v '^_\?_ZN4core\|core::' # drop std noise
# If stripped: skip to panic strings; names are gone.Map the code with panic strings
strings ./chal | grep -E "\.rs($|:)|unwrap|index out of bounds" | sort -uSource paths and line numbers point straight at the author's logic.
Load in a Rust-aware disassembler
Ghidra 11+ (enable Rust analyzers), or IDA/Binary Ninja with rustfilt on exports. Re-run analysis so &str and demangled names apply.
Read the layout, not the noise
Treat &str/&[T] as ptr+len, decode Option/Result by their discriminant or niche, follow panic_bounds_check to find the real loop, and ignore the drop_in_place cleanup unless it carries logic.
Checklist
-
file+strings | grep /rustc/→ confirm Rust and read the toolchain hash - Demangle with
rustfilt(handles legacy_ZNand v0_R) — neverc++filt - Harvest panic strings (
*.rspaths,unwrap,index out of bounds) as a function map - Load in Ghidra 11+ (Rust analyzers) or IDA/Binary Ninja; re-analyze
- Treat
&str/&[T]as ptr+len — slice the exact length, never to a null byte - Distinguish fat-pointer second word: length (slice) vs vtable (
&dyn Trait) - Decode enums by discriminant; remember
Optionniche optimization (null =None) - Skip
drop_in_place,_Unwind_Resume, and monomorphization duplicates as cleanup noise - Follow
panic_bounds_checkto locate the real indexing loop
See Also
Last updated on
Reversing Go Binaries
Recover symbols, decode the Go calling convention, and tame runtime noise when reversing stripped Go binaries for CTF.
Reversing .NET Binaries
Decompile CIL back to readable C#, strip ConfuserEx with de4dot, patch and debug live in dnSpyEx, and tackle single-file, AOT, and Unity IL2CPP builds.