Athena Wiki

Reversing Rust Binaries

reversingintermediate

Cut through monomorphization bloat, demangle v0 symbols, and read Rust's fat pointers, enums, and panic landmarks when reversing stripped Rust binaries for CTF.

reversingrustrustfiltmanglingv0fat-pointersenumsghidraida

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> or HashMap<K, V> is compiled fresh for every concrete T/K/V the program uses. One generic function in source becomes a dozen near-identical machine functions, each a slightly different specialization. Expect to see core::ptr::drop_in_place and iterator adapters duplicated many times over.
  • Static linking and a fat std. By default rustc statically links libstd, the allocator, and the panic runtime. ldd typically shows only libc, libgcc_s, and libm; 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:: symbols

The 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.:

_ZN4core3fmt9Formatter3pad17h2e3f1a8c9b0d4e5fE

The 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::panic and core::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::unwrap failure paths — call core::panicking::panic with messages like called \Option::unwrap()` on a `None` value`.
  • _Unwind_Resume and the .eh_frame / .gcc_except_table machinery — landing pads for unwinding. These pepper the binary because Rust uses table-based exception handling for panic = 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 &str argument 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 .rodata back-to-back with no separators, exactly like Go. strings shows them concatenated; the length lives in the code as an immediate moved next to the pointer.
  • &dyn Trait and &[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.ro and 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 monomorphized core::ptr::drop_in_place::&lt;T&gt;. 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 what T was).
  • 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 = unwind is the default, every scope with a droppable value gets a landing pad that runs the drops during unwinding. These show up as duplicate-looking drop_in_place calls reached only from the exception tables — distinguish them from the normal exit path.
  • Allocator calls. Vec/String/Box growth 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 appear

Confirm 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 -u

Source 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 _ZN and v0 _R) — never c++filt
  • Harvest panic strings (*.rs paths, 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 Option niche optimization (null = None)
  • Skip drop_in_place, _Unwind_Resume, and monomorphization duplicates as cleanup noise
  • Follow panic_bounds_check to locate the real indexing loop

See Also


Last updated on

On this page