Recover function names, method signatures, types, and interface dispatch tables from stripped Go binaries, with inlined call stacks the leaf-only tools miss.
Stripped Go binaries still carry pclntab, moduledata, typelinks, and itablinks because the runtime needs them for stack traces and reflection. unstrip reads all of it: every function with file and line, every method with its Go-syntax signature where the linker kept the type record, every type with full struct layout, every (interface, concrete) pair the dispatcher uses, and the module dependency tree. ELF, Mach-O, PE. amd64 and arm64. Go 1.18 through 1.25.
cargo install unstrip
Prebuilt binaries for Linux, macOS, and Windows are on the releases page.
unstrip ./bin # function names, files, lines
unstrip ./bin --info # container, Go version, garble heuristic
unstrip ./bin --format ghidra > apply.py # Python script for Ghidra Script Manager
unstrip -h prints a one-screen cheat sheet. unstrip --help lists every flag grouped by section. The full reference with one paragraph per flag lives in docs/USAGE.md.
Method signatures attached to recovered symbols. Every method on a type the Go linker emitted a _type record for gets a Go-syntax signature like (_0 []uint8) (int, error) appended to its name in the default listing, in --addr lookups, and in the Ghidra/IDA/Binja exporter comments. Other Go-binary symbol-recovery tools do not recover signatures at all.
Inlined call stacks, not just leaf functions. --addr returns the full inline tree from FUNCDATA_InlTree, so a PC that lands in inlined code resolves to the chain that led there, with file and line for every frame.
Built-in Ghidra, IDA, and Binary Ninja Python exporters via --format, with C struct decls and correct field offsets. One Rust binary emits scripts for all three RE tools; --install-plugin <ida|ghidra|binja> drops a wrapper plugin into the user's plugin directory.
--data-at <addr> interprets bytes at a data address through the recovered itab table and function table. Iface headers resolve to concrete type plus dispatched method body. Slice headers expand to ptr/len/cap with the data pointer symbolized. String headers print a quoted preview.
--xref <symbol> enumerates every call site targeting a function, on amd64 and arm64 binaries alike. Direct CALL/BL coverage is solid on both. Indirect dispatch through itab method slots is reported alongside direct calls with the resolved itab and method name carried inline.
A 1.4 MiB static Rust binary, no runtime deps. Go 1.18 through 1.25, ELF + Mach-O + PE, amd64 + arm64, PIE + non-PIE, one tool.
Categorical comparison against GoReSym, gore, and redress lives in COMPARISON.md. Headline: similar core function recovery; the tools differ on what they include in the type catalog, on which RE-tool integrations they ship, and on how they handle obfuscated binaries.
A short verified-only table follows. The longer differences and the "when to reach for which" guidance are in COMPARISON.md.
| Capability | unstrip | GoReSym | redress | gore |
|---|---|---|---|---|
| CLI vs library | CLI | CLI | CLI | library |
| Go 1.18 through 1.25 | yes | yes | yes | yes |
| Pre-Go-1.18 | no | Go 1.2+ | Go 1.5+ | Go 1.5+ |
| Method-signature recovery | yes | no | no | no |
| Ghidra + IDA + Binja exporters | yes | yes | no | no |
| Inlined call stacks on PC lookup | yes | unverified | no | no |
--data-at symbolic data inspection |
yes | no | no | no |
--xref on amd64 and arm64 |
yes | n/a | n/a | n/a |
| Static binary, no runtime deps | yes (Rust) | yes (Go) | yes (Go) | n/a |
unstrip ./samples/hello.stripped
Function names, source files, line numbers, one function per line. Methods on types the binary carries a _type record for get their Go-syntax signature appended:
$ unstrip ./crypto-pipeline.stripped
0x00000000004b3f20 main.(*HashingReader).Read main.go
0x00000000004b4000 main.(*CountingWriter).Write(_0 []uint8) (int, error) (itab thunk) main.go
0x00000000004b4280 main.(*AesGcm).Encrypt(_0 []uint8) ([]uint8, error) (itab thunk) main.go
0x00000000004b4820 main.(*Pipeline).ProcessFrame main.go
0x00000000004b4a40 main.(*Pipeline).StreamThrough main.go
0x0000000000465b40 errors.(*errorString).Error() string errors/errors.go
0x0000000000479e00 os.(*File).Write(_0 []uint8) (int, error) os/file.go
0x00000000004b4d40 main.main main.go
...
Parameter names are not in the binary; positional placeholders (_0, _1, ...) keep the shape correct. Coverage scope and what to expect:
- Methods that implement an interface (
CountingWriter.Write,AesGcm.Encrypt,errorString.Error,os.File.Writeabove): signatures recover reliably. The(itab thunk)marker shows the iface dispatch path; the signature on that row comes from the interface's declared funcType. - Methods on types reached only through internal calls (
HashingReader.Read,Pipeline.ProcessFrame,Pipeline.StreamThroughabove): the Go linker may have elided the type's_typerecord because no runtime path needs it. The function name is recovered frompclntab; the signature is not in the binary. unstrip prints the bare name for those. - Free top-level functions (
main.main,main.NewAesGcm, etc.): never had per-function signature records to begin with. Bare names.
Pass --no-signatures for the older shorter listing.
$ unstrip hello.stripped --info
go version: go1.22.2
container: ELF (amd64, little-endian)
pclntab: 0x00000000000c0c40 (405 KB)
functions: 1556
text start: 0x0000000000401000
ptr size: 8
quantum: 1
When the binary's been obfuscated, --info says so instead of pretending the symbols are real:
$ unstrip suspicious.bin --info
go version: (not detected)
...
garble heuristic: likely garbled
- pclntab magic is not the standard 0xfffffff1 (garble rewrites it)
- runtime.buildVersion is missing or non-standard (garble overwrites it)
- 554/4310 user-package function names look hashed
For a structured verdict and exit-code semantics (0 garbled, 1 clean, 2 indeterminate), use --detect-garble.
$ unstrip ./samples/myapp --buildinfo
go version: go1.22.2
path: example.com/myapp
main module: example.com/myapp v1.4.2
dependencies (12)
github.com/spf13/cobra v1.8.0
github.com/aws/aws-sdk-go-v2 v1.30.5
github.com/jmespath/go-jmespath v0.4.0
...
build settings
buildmode exe
GOOS linux
vcs git
vcs.revision 3f8a912...
vcs.modified false
From a stripped binary, no debug info, no SDK installed:
$ unstrip ./samples/myapp --types --filter cobra.Command
0x00000000005c01a0 struct size=728 *cobra.Command
+0000 Use: type@0x589ec0
+0010 Aliases: type@0x588960
+0028 SuggestFor: type@0x588960
+0040 Short: type@0x589ec0
+0050 GroupID: type@0x589ec0
...
+02c8 TraverseChildren: type@0x58a2c0
+02c9 Hidden: type@0x58a2c0
+02ca SilenceErrors: type@0x58a2c0
+02d0 SuggestionsMinimumDistance: type@0x58a100
Full struct layouts, including unexported fields. Field types are cross-referenced by address so you can navigate the type graph. JSON output is wired for tooling.
The (interface, concrete) pairs the runtime uses for dispatch, what your dynamic dispatch sites actually go to:
$ unstrip ./samples/myapp --itabs --filter Writer
0x00000000005a18c8 *io.Writer => *os.File
0x00000000005a1948 *io.Writer => *bytes.Buffer
0x00000000005a19a8 *io.Writer => *gzip.Writer
0x00000000005a1a08 *io.StringWriter => *bufio.Writer
For use inside a debugger, a crash dump parser, or a disassembly comment generator. When the PC lands in inlined code, you get the full call stack from the inline tree, not just the leaf:
$ unstrip caddy --addr 0x4151a4
(inlined) runtime.evacuated /usr/lib/go-1.22/src/runtime/map.go:205
(physical) runtime.mapaccess2 /usr/lib/go-1.22/src/runtime/map.go:457
For batch lookups against a crash dump, use --addr-file. One parse, N lookups:
$ cat crash.txt | unstrip suspicious.bin --addr-file -
(inlined) io.copyBuffer /usr/lib/go-1.22/src/io/io.go:418
(physical) io.Copy /usr/lib/go-1.22/src/io/io.go:388
0x000000000045a200 net.(*conn).Read /usr/lib/go-1.22/src/net/net.go:179
0x00000000004b8c40 main.handleClient /tmp/build/main.go:42
If the runtime addresses came from an ASLR-rebased process, pass --rebase <DELTA> (the difference between actual load base and link-time preferred base) and unstrip translates back to the link-time VA on disk.
--format ida, --format ghidra, or --format binja emits a Python script that, run inside that tool, applies every recovered function (with file:line as a function comment) and every recovered struct type as a C declaration the tool's parser can ingest:
$ unstrip ./samples/myapp --format ghidra > apply.py
# In Ghidra: Window -> Script Manager -> Run apply.py
Function names sanitize down to valid disassembler labels; the original Go name (with parens, brackets, generics arguments) is preserved as a function comment.
For a menu-item install instead of running the script every time:
unstrip --install-plugin ida # ~/.idapro/plugins/, registers as Edit -> Plugins -> Load Go symbols (unstrip), Ctrl-Shift-G
unstrip --install-plugin ghidra # ~/ghidra_scripts/, run from Script Manager
A Binary Ninja installer (--install-plugin binja) drops the wrapper into the Binary Ninja plugins directory and registers a menu item under Plugins -> Load Go symbols (unstrip).
--symbols-as elf writes a new ELF with a populated .symtab + .strtab built from the recovered functions. The result is a strict superset of the input that still runs, but nm, gdb, objdump --syms, perf, addr2line, eBPF stack traces, and delve all see the names.
$ nm helm
nm: helm: no symbols
$ unstrip --symbols-as elf -o helm.symbols helm
wrote 75630 symbols to helm.symbols
$ nm helm.symbols | head -3
0000000000401000 T fatalf
00000000004012b0 T _cgo_get_context_function
00000000004011e0 T _cgo_set_stacklo
$ gdb -q helm.symbols -ex 'info functions main.main' -ex quit
0x00000000027f50e0 main.main
0x00000000027f53a0 main.main.func1
Use --in-place --yes to overwrite the input file. Today supports ELF64 little-endian only; Mach-O and PE rewrite ships later.
--diff <OLD_BINARY> is a name-port helper, not a structural binary differ. It pairs functions across two builds in two passes: first by exact address match (identical), then by exact Go symbol name at a new address (renamed). Everything else falls into added or removed. It does not hash basic blocks, fingerprint control-flow graphs, or follow functions across inlining, splitting, or compiler-driven renames. If a function was inlined away, renamed by the toolchain, or restructured, this tool will report it as removed plus added rather than matching it.
For real structural diffing across optimization and inlining changes, reach for BinDiff or Diaphora. --diff here exists for the narrow but common case where you annotated v1.0 in your disassembler, v1.1 just shipped, and you want the symbol names you already recovered to land on the obvious counterparts in the new build:
$ unstrip ./malware-v1.1.bin --diff ./malware-v1.0.bin
old: 4687 functions
new: 4823 functions
identical: 4421
renamed: 198
moved addr: 12 (same address, different name)
added: 192 (in new, not in old)
removed: 68 (in old, not in new)
Pair with --port-symbols ida|ghidra|binja to emit a script that renames every paired function in the new binary using whatever name it had in the old one:
$ unstrip ./malware-v1.1.bin --diff ./malware-v1.0.bin --port-symbols ghidra > port.py
# In Ghidra (with v1.1 loaded): Script Manager -> port.py
--xrefs scans .text for direct CALL/BL targets and resolves each pair against the recovered function set. Combined with --from, --to, --depth, or --callgraph, it answers the questions an analyst opens IDA to ask:
$ unstrip ./helm --xrefs --from main.main --depth 1 | head
main.main -> github.com/spf13/cobra.(*Command).ExecuteC
main.main -> main.newRootCmd
main.main -> main.warning
main.main -> os.Exit
$ unstrip ./helm --xrefs --to crypto/rsa.SignPSS
crypto/tls.(*signOpts).Sign -> crypto/rsa.SignPSS
$ unstrip ./helm --xrefs --callgraph > graph.dot && dot -Tsvg graph.dot -o graph.svg
250,000+ caller-callee edges enumerated on a 65 MiB helm binary in under 300 ms. Direct calls only; virtual dispatch through itabs lives in --itabs.
--xref <SYMBOL> answers "who calls this," scoped to one target. Faster than building the whole graph when you only have one question, and grouped by caller so the output reads the way a human reads xrefs in IDA:
$ unstrip ./helm --xref runtime.mallocgc | head
runtime.makechan @ 0x000000000040a260:
0x000000000040a2ee direct
0x000000000040a340 direct
0x000000000040a380 direct
runtime.convT @ 0x000000000040fb00:
0x000000000040fb29 direct
Accepts a function name (resolved through pclntab) or a hex address (0x...). When the target is an interface-method implementation, indirect dispatch sites of the form CALL [rip+itab+slot] are reported alongside direct calls with the resolved itab and method name carried inline. The compiler usually loads the slot pointer into a register first; those register-indirect sites do not show up here because resolving them requires basic-block tracking we deliberately don't ship. In practice the indirect-itab scanner finds matches on hand-written assembly and unusual codegen; expect zero hits from stock Go compiler output. The strict-encoding hits that do appear carry zero false positives.
amd64 and arm64. Direct-CALL coverage is solid on both; indirect-itab on arm64 catches the canonical LDR Xt, [Xn, #slot]; BLR Xt pair but misses other dispatch patterns the compiler emits, pending a real disassembler integration.
--goroutines lists every runtime.newproc and runtime.deferproc call site in .text and resolves the target function the goroutine or deferred call will run when possible. Surfaces the control flow hidden behind go func() { ... } and defer in Go programs that other tools don't:
$ unstrip ./sliver-client --goroutines | head
0x0000000000424798 runtime.newproc -> runtime.runFinalizers (0x424880) runtime/mfinal.go:169
0x00000000004259c0 runtime.newproc -> (target unresolved) runtime/mgc.go:209
0x00000000004479d5 runtime.newproc -> runtime.forcegchelper (0x447a00) runtime/proc.go:360
0x000000000045e480 runtime.newproc -> runtime.ensureSigM.func1 (0x4767e0) runtime/signal_unix.go:1061
0x000000000049c4ae runtime.newproc -> (target unresolved) sync/waitgroup.go:235
When the target resolves (40-50% of sites in real binaries), you see exactly which function the goroutine runs. When the LEA pattern doesn't match the heuristic (the funcval came from a register or stack slot), the call site and source file:line still show so you can open the source. amd64 and arm64 only today.
--dispatch-resolver ghidra emits a Ghidra Python script that embeds the recovered itab dispatch table and, when invoked at a virtual call site, prints every (interface, concrete impl) pair whose method table contains the dereferenced slot:
$ unstrip ./target --dispatch-resolver ghidra > unstrip_dispatch.py
# Inside Ghidra: Script Manager -> add unstrip_dispatch.py
# Place cursor on `CALL qword ptr [reg + 0x18]` and run the action.
# Output (console):
# unstrip dispatch: looking for itabs with a method at slot 3 (offset 0x18)
# io.Writer => *os.File :: Write -> 0x4b9020
# io.Writer => *bytes.Buffer :: Write -> 0x4c1a40
# io.Writer => *bufio.Writer :: Write -> 0x4cd900
# unstrip dispatch: 3 candidates
This turns virtual dispatch through an itab, the worst class of stripped-Go reversing target, into a five-second lookup. The slot-offset heuristic pulls the integer scalar from the call's second operand; combined with the itab table unstrip already recovers, you get the candidate target set without re-running any analysis. amd64 today; arm64 BLR/BR-through-register support is straightforward to add once we have a test fixture.
--capabilities matches the recovered types, itabs, and function names against a curated rule set and reports what the binary appears to do. First-pass answer to "what is this?":
$ unstrip ./sliver-client --capabilities
[crypto]
TLS client/server
X.509 / PKI
RSA, ECDSA, AES
[network]
HTTP server, HTTP client, gRPC, DNS, raw TCP, raw UDP
[offensive-hint]
shell command execution
raw socket / ICMP
Sliver C2 implant
[process]
child process spawn
syscall direct invocation
signal handling
[serialization]
JSON encoding
Protocol Buffers
The rules cover HTTP servers and clients, TLS, X.509, RSA/ECDSA/AES, AWS/GCP/Azure SDKs, Kubernetes client, Docker/containerd, SQL/SQLite/Postgres/Redis/MongoDB, JSON/proto/YAML, file and child-process operations, raw sockets, and known offensive frameworks (Sliver). Each match includes up to five evidence strings showing exactly which recovered type or function triggered it.
--info includes a count of stdlib interface implementations the binary ships. Useful for the first-pass question "does this binary speak HTTP, do crypto, write files?"
stdlib interface implementations:
*error 142
*io.Reader 47
*io.Writer 38
*http.Handler 14
*net.Conn 11
*crypto.Signer 3
(--fingerprint --behavioral exists for hashing that counter into a SHA-256; coarser than --fingerprint and not garble-stable, included for completeness.)
For tools that want to consume unstrip's recovery from Rust, the unstrip::inline module exposes the inlined call graph the compiler recorded in FUNCDATA_InlTree. One Node per physical function; one Edge per inlined call site the compiler kept after optimization:
use unstrip::gobin::GoBinary;
use unstrip::inline::{inline_callgraph, EdgeKind, NodeKind};
use unstrip::moduledata::ModuleData;
use unstrip::pclntab::Pclntab;
let bin = GoBinary::open("./target")?;
let md = ModuleData::locate(&bin)?;
let pcln = Pclntab::parse(&bin)?.with_gofunc(md.gofunc);
let graph = inline_callgraph(&bin, &pcln)?;
for e in &graph.edges {
if matches!(e.kind, EdgeKind::Inlined) {
let caller = graph.node(e.from).unwrap();
let callee = graph.node(e.to).unwrap();
println!("{} -> {} @ 0x{:x}", caller.name, callee.name, e.call_site);
}
}NodeKind::Physical nodes are real pclntab functions with their entry PC as addr. NodeKind::AnonymousInline nodes carry their parent's PC plus the inline-tree start line, and their addr is a synthetic high-bit-tagged ID (Node::is_anonymous_addr) so they cannot collide with a real text VA. Anonymous-inline records appear when the compiler kept the inlined call's topology but the binary was built with -tiny, -ldflags=-s -w, or garble's name obfuscation, all of which zero the callee's name_off. The topology is intact in all these cases; only the name is gone.
The runtime.inlinedCall record is byte-identical across Go 1.20 through 1.26 (the layout has not drifted since startLine was added in Go 1.20). The decoder runs unchanged across that range. Garble's entryoff XOR rewrite does not touch the inline-tree FUNCDATA section, so the decoder also runs unchanged on garble-obfuscated binaries; callee names come back obfuscated, not stripped.
For direct (non-inlined) call edges and full-graph traversal, use --xrefs (CLI) or unstrip::xrefs (library). EdgeKind::Direct is reserved on the inlined call graph for a future fold of those edges; today only EdgeKind::Inlined is emitted by inline_callgraph.
Every Go binary built since 1.2 carries a pclntab, a self-describing table that maps program counters to function names, source files, and lines. The runtime uses it for stack traces, so strip leaves it alone.
unstrip does five things, layered:
- Find the pclntab. Look up
.gopclntab/__gopclntabby name; if the section table's stripped or the section was renamed, fall back to a magic-byte scan with a structural sanity filter (so we don't false-positive on random0xfffffff1sequences inside other data). - Walk the function table. For each function entry, resolve the name through the funcnametab, the file through the cutab -> filetab indirection, and the start line through the pcln pc-value table.
- Find the moduledata. Scan
.noptrdata/.datafor a pointer to the pclntab, that's the first field ofruntime.firstmoduledata. Verify by checking that the next five slice headers all point inside the pclntab (the corroborating-references trick that defeats false positives). - Walk the type graph. Start from
typelinks(the linker's list of every type the program references), parse each_typeheader, resolve its name through the names blob relative tomoduledata.types, decode kind-specific extra data (pointer.elem, slice.elem, struct fields, ...), and follow the references to pull in typestypelinksitself missed. Same trick foritablinks. - Parse buildinfo. Locate the
\xff Go buildinf:marker, decode the inline string format introduced in Go 1.18, strip the 16-byte sentinel framing, parse the tab-separatedpath/mod/dep/buildrecords.
No relocations. No rewriting. Read-only output. Pipe it into IDA, Ghidra, gdb, or a script.
What works:
- Go 1.18 through 1.25
- ELF, Mach-O, and PE containers
- amd64 and arm64
- PIE and non-PIE
- Garble-obfuscated binaries (detected and flagged; structural data still recovered)
- Function names, file paths, line numbers
- Method signatures in Go syntax for every method whose type the Go linker kept
- Full type recovery (names, kinds, sizes, struct fields with offsets and embedded flags)
- Interface to concrete type recovery via itabs
- Module dependency tree, build settings, VCS info
- PC-to-symbol reverse lookup with inlined call stacks
- Direct CALL/BL call-site enumeration on amd64 and arm64; indirect-itab dispatch reporting alongside
- IDA, Ghidra, Binary Ninja, JSON, and human-readable output
Tested against: Linux ELF amd64 and arm64 (PIE and non-PIE), Windows PE amd64, on Go versions in the supported range. Mach-O code paths are exercised by unit tests; the integration suite has no macOS-built fixture yet. Open an issue if it breaks. Garble heuristic detection works and structural recovery survives the default mode (string literals, inline trees, and the funcdata array all stay walkable).
What's out of scope, and where to look instead:
- Go < 1.18: pre-1.18 pclntab layouts are unsupported. Use GoReSym for those.
- Generic ELF symbol recovery: not this tool. Use
eu-unstripfrom elfutils. - Decompilation: not this tool. This gives you names; a decompiler gives you code.
Known gaps in v1.0:
- Type recovery surfaces struct package paths as
_pkg_path(read-and-discarded). Function-type argument types (in/out parameter type pointers) aren't enumerated, you get the count + variadic flag but not the signature types. - Free top-level function signatures: the binary records signatures for methods (on types reached at runtime) but not for free top-level functions; their signatures are absent without DWARF.
Work we have not landed in v1.0. Ordered roughly by how often we expect it to come up.
Pre-Go-1.18 pclntab parsing. The Go 1.13 through 1.17 layouts are different enough that the current parser does not even try. GoReSym covers the older corpus; we do not yet. Until we do, that is the gap.
Free-function signature recovery. Methods ship today (we walk _type.uncommon().methods and the itab method tables). Free top-level functions do not appear in either table, so their signatures are absent from a stripped binary unless the compiler emitted a funcType for reflection or interface storage. Closing that gap means either reading DWARF when present or recognizing reflective uses and back-resolving.
i386 and 32-bit arm. The pclntab layout drifts on 32-bit targets. No demand yet; the day a 32-bit IoT Go binary becomes a real corpus problem, this jumps to the top.
--symbols-as for Mach-O. ELF and PE write today. The Mach-O equivalent (LC_SYMTAB patching) ships once we have a tested fixture; we do not currently have one we can publish.
Rust binary symbol recovery. Not an unstrip mode. If we build it, it ships as a separate tool. DWARF parsing is its own problem and conflating the two would be a mistake.
unstrip parses attacker-controlled bytes. We treat that as the default operating assumption.
What we do:
- Every offset read is bounds-checked against the source slice; reads that would run past the end return an error rather than panicking.
- Every length field decoded from the binary is range-capped at a sane ceiling (function counts, type counts, method counts, slice lengths). A binary that claims
nfunc = 0xffffffffrejects rather than allocating. Result-returning APIs throughout the library; nounwrap()on attacker-controlled paths.- Per-type recovery is best-effort; one malformed record drops that record, not the whole pass.
What we do not claim:
- We have not run a structured fuzzing campaign against the pclntab and moduledata parsers. The bounds-checking discipline above is the substitute today; a real fuzz runner is post-launch work.
- Garble-obfuscated inputs are detected and flagged, but garble's full transform surface evolves; an obfuscator update that goes further than current behavior may produce output we mis-parse before we patch.
- Mach-O code paths are exercised by unit tests, not by a full integration fixture matrix yet.
If you find a crash, an out-of-bounds read, or any other safety issue: see SECURITY.md for the private reporting contact.
PRs welcome. Read CONTRIBUTING.md before opening one. Good first issues are tagged good first issue. For vulnerability reports see SECURITY.md.
If unstrip can't recover symbols from a binary in the supported matrix, that's a bug. Open an issue with the binary attached if you can share it, or the Go version and target triple if you can't.
Dual-licensed under MIT or Apache-2.0 at your option. Pick whichever fits your downstream needs:
Contact: mohamed@riven-labs.com.
A Riven Labs project.