57 seconds or 6 milliseconds: a first question to a stripped binary
On one stripped Rust binary, a Ghidra 12.2 headless run (import, default analysis, one decompilation) took 57 to 59 s and found 1,123 of 2,096 function starts. Single xref commands took 2.5 to 6.4 ms and matched all 2,096. Plus two places where Ghidra is right and xref is not.
On this page · 5 sections
Here is what a first answer about a binary cost us, measured. We took a 2.3 MB stripped Rust executable and ran Ghidra 12.2 headless on it: start the tool, create a project, import the file, run the default analyzers, decompile one function, delete the project. On an idle MacBook Pro with an M3 Pro, that whole command took 57 to 59 seconds of wall time, about 180 seconds of CPU and up to 1.3 GB of memory, in three runs out of three. Inside it, the analyzers alone reported 48 to 49 seconds, and the one decompilation took 34 to 35 ms.
For an analyst who will spend a day in that database, a minute is nothing. For a coding agent that needs one answer (“which function uses this string”, “what does this function write through its first argument”) and then moves on, the setup is most of the cost. And on this file the default analysis left gaps: Ghidra had created functions at 1,123 of the binary’s 2,096 real function starts.
XRef Code (xref) is built the other way round. It keeps
no database. Every command maps the ELF file, finds the functions, decodes what the question
needs and exits. On the same file and the same machine, six single xref commands took 2.5 to
6.4 ms each, and its function ranges matched the symbol table for all 2,096 functions. We did
not time the same six questions in Ghidra, so this is a comparison of two ways to get a first
answer, not of one question set. Version 0.1.0 was released on November 2, 2026, and archived
on Zenodo as 10.5281/zenodo.23878790.
Where the functions come from
A stripped binary has no symbol table, so the first job of any tool is to find where each
function starts and ends. xref takes them from the .eh_frame section. Unwind records exist
so that a panic can walk the stack, and each record (an FDE) covers an address range of code.
That range is not guaranteed to be a whole source function: a compiler can split one function
into several ranges. On this binary every FDE range xref used matched a function symbol
exactly. rustc, clang and gcc emit these records by default on x86-64, and strip --strip-all leaves them in place, because the
program needs them at run time. A binary with no symbols therefore still lists its own
functions. Where unwind entries are missing, xref falls back to spans inferred from gaps and
call targets, and with .eh_frame removed (strip -R .eh_frame) every boundary is heuristic.
We could check both tools against the truth because the stripped file came from a release binary that still had its symbols. The unstripped binary lists 2,096 functions with a nonzero size:
| Functions created | Start matches a symbol | Start and end both exact | |
|---|---|---|---|
| Ghidra 12.2, default analysis | 1,213 | 1,123 | 983 |
| xref 0.1.0 | 2,096 | 2,096 | 2,096 |
For xref, the first column is the inventory xref stats reports, and the other two come
from asking xref func for the function containing each of the 2,096 symbol addresses: every
answer was a distinct range with the symbol’s exact start and end. Ghidra’s count excludes
external and empty thunk entries. Of the 973 functions it never
created, only 11 fall inside the body of another function; the other 962 are simply not
functions in its database. Ghidra reads .eh_frame too: its GCC Exception Handlers analyzer is
on by default and ran in every one of our runs. So the difference is not that one tool looks at
unwind data and the other does not. We did not trace why each of the 962 was left out, and we
do not offer a reason.
Stripped Rust gives one more thing for free. Every bounds-check panic carries a
core::panic::Location with the source file, line and column, and string literals are
(ptr, len) pairs in .rodata. xref reads both, so a function with no name still says
which source file it came from, and xref where src/loops.rs lists the functions compiled
from that file.
The same questions, measured
All numbers below come from one machine1: MacBook Pro, Apple M3 Pro, 11 cores, 18 GB, load
average about 2. The target is the x86-64 Linux binary from the xref v0.1.0 release, stripped
with llvm-strip --strip-all (2,275,584 bytes, SHA-256 e7d7ff2b…5f4c, 0 symbols).
Ghidra 12.2 ran on OpenJDK 25 with its default analyzers. Its release ships no decompiler binary
for Apple Silicon, so we built one from the sources in the same release (make ghidra_opt).
Without it the decompiler-based analyzers fail, which is why a run without it finished in about
25 seconds.
analyzeHeadless ./proj bench -import xref-0.1.0-linux.stripped \
-loader-imagebase 0 -postScript BenchReport.java -deleteProject
| Ghidra run | Wall time | CPU (user) | Peak memory |
|---|---|---|---|
| 1 | 58.9 s | 179.9 s | 1.23 GB |
| 2 | 57.3 s | 178.8 s | 1.30 GB |
| 3 | 57.1 s | 180.4 s | 1.33 GB |
xref 0.1.0, the Apple Silicon build reading the x86-64 file, ran each command 20 times
after 3 warm-up runs. The times are wall clock for the whole
process, start to exit:
| Question | Command | Median | Range |
|---|---|---|---|
| Inventory | xref stats |
2.5 ms | 2.5–2.8 |
Field layout through rdi, with callees |
xref layout 0xfec40 --reg rdi --callees |
2.9 ms | 2.9–3.0 |
| Functions compiled from a file | xref where src/loops.rs |
3.8 ms | 3.6–4.0 |
| References to a string | xref refs "goto L_" |
4.2 ms | 4.1–4.4 |
| Pseudo-C of one function | xref c 0xfec40 |
6.1 ms | 5.9–6.3 |
| Backward slice to a call | xref slice 0xfec40 --to fn_1ab530 |
6.4 ms | 6.1–7.0 |
The two tables measure different things. The Ghidra number is one cold command that starts a
JVM, imports, analyzes, decompiles once and cleans up. The xref numbers are single commands,
each repeated after warm-up runs. Ghidra’s minute also buys a database that keeps answering
afterwards: inside that run, its decompilation of 0xfec40 took 34 to 35 ms, and lookups in an
existing database are faster still (we did not time those). But even if every later Ghidra
answer were free, one headless run costs as much wall time as about 9,000 xref commands at
6 ms each.
Where Ghidra’s output is right and ours is not
We put both decompilations of 0xfec40 side by side. The function reads lines of the form
goto L_<hex>; and parses the hexadecimal number after the prefix. Both outputs show that
shape: the loop over lines, the search for "goto L_", the hex-digit loop. That is what we
read from them; we did not check either output for equivalence with the machine code beyond
the two places below.
xref printed 131 lines with no goto statements, the string "goto L_" inline, the source
location src/loops.rs:45:36 on each panic call, and a note that the panic helper never
returns. Ghidra printed 114 lines with four goto statements and three labels, and showed the
string and the location as &DAT_00016f02 and &PTR_s_src_loops_rs_00224570.
In two places Ghidra matches the machine code and xref does not.
- A signed byte compare. The code checks for a UTF-8 character boundary with
cmp byte ptr [rbx+rdx], 0xc0followed byjge, a signed comparison of the byte with -64. Ghidra prints*(char *)(lVar3 + uVar8) < -0x40.xrefprints(i64)((u8 *)ptr_6)[v_9] < 192, which zero-extends the byte first and is true for plain ASCII as well. This one is filed as xref issue #32. - A lost carry. On the overflow check of the 64-bit hex accumulator, Ghidra writes
!CARRY8(uVar5, uVar10), whilexrefprints(v_5 < 0 | v_24 >= 16)on an unsigned value, whose first half can never be true. This one is filed as xref issue #31.
Ghidra also prints character constants as characters, == ';' and '+', where xref prints
== 59 and 43. These are the differences we found by reading one function; they are not a
complete list of what xref gets wrong.
What it does not do
- Input is x86-64 ELF only, executables and shared objects. PE, Mach-O and AArch64 are out of scope.
- Languages.
.eh_frameis present in C and C++ builds too, but the published runs, the end-to-end tests and this measurement all use Rust binaries. Rust is what has been shown. - Types are reconstructed from how the machine code uses each value, not from debug
information. Two fields of the same width are indistinguishable, and instructions
xrefdoes not model are kept as inline assembly comments. - One binary, one machine. These are measurements of a 2.3 MB file on an M3 Pro. We have not measured a 200 MB binary, and we did not tune Ghidra’s analyzers beyond the defaults.
The rule we take from it: recover structure from what the binary must keep for itself, and recompute it on every question. Symbols get stripped. Unwind tables and panic locations survive because the program needs them, and a tool that relies on them can answer in milliseconds without an import step.
Footnotes
-
The outputs quoted in the README at tag
v0.1.0(c2bf310) were recorded withxref0.0.5 on a different build. Every number in this post was measured for it on 2026-11-02 withxref0.1.0. ↩
Cite this article
Alexander Panasenko (2026-11-02). 57 seconds or 6 milliseconds: a first question to a stripped binary. https://prod.codes/blog/one-question-to-a-stripped-binary/