July 7, 2026

Go and Rust Binary Reversing for CTF: Taming the Fat Binary

Go and Rust reversing for CTF: recover Go names from pclntab with GoReSym, read length-delimited strings, beat Rust name mangling, and find the flag comparison.

You can reverse C. Then you opened a Go binary and panicked.

You double-click the binary in Ghidra and the function list has forty thousand entries. Almost none are named. The strings window is a wall of run-together text with no gaps. There are no familiar libc imports because there is no libc: everything is statically linked. Your first instinct is that you opened something obfuscated. You did not. You opened a perfectly normal Go or Rust binary, and it looks like that on purpose.

Here is the short version so you can start working immediately. The size and the missing names are a side effect of static linking plus a fat language runtime, not a defense. Both toolchains leave you a map back to the source. In Go the map is a table called the pclntab, and a tool called GoReSym reads it and renames every function for you. In Rust the names are never stripped from a default build, they are just mangled, and the panic strings are signposts that point straight at the interesting code. The job is not to read all forty thousand functions. It is to find main.main, follow a string into the one function that matters, and watch the compare.

A Go or Rust binary is not obfuscated. It is verbose. Verbose is searchable, and searchable is solvable.

The rest of this post is the detail behind those four moves. It pairs with the Ghidra guide for the tooling, the x86 assembly guide for reading the disassembly, and the GDB guide for the dynamic half. If you can already reverse a small C crackme, you have every prerequisite. What changes is scale and naming, and both have mechanical fixes.

Why are these binaries so huge and scary?

A C "hello world" dynamically linked against glibc is a few kilobytes. The equivalent Go program is a few megabytes, and a Rust one is hundreds of kilobytes to a couple of megabytes. Nothing is wrong. Three things stack up.

  • Static linking by default. Go links its entire runtime and standard library into every binary. There is no libc.so to leak and no PLT full of named imports to anchor on. Rust statically links its standard library too, though it does dynamically link the system libc on most targets.
  • A fat language runtime. Go ships a garbage collector, a goroutine scheduler, reflection metadata, and a full type system, all compiled in. Most of those forty thousand functions are runtime internals you will never touch.
  • Monomorphization (Rust). Generics are compiled by stamping out a separate concrete copy of each function for every type it is used with. A single Vec<T> method can become a dozen near-identical functions. This is why Rust binaries balloon and why the function list is full of look-alikes.
Key insight: The scary part is a feature of the question, not the answer. The challenge author wrote maybe one or two functions of interest. The other thirty-nine thousand nine hundred are stock runtime and library code shared by every binary the compiler emits. Your entire job is to filter the author's code out of the noise, and the language gives you the tools to do exactly that.

Go: how do I get the function names back?

This is the single highest-leverage move in Go reversing, so do it first. The Go compiler embeds a structure called the pclntab (Program Counter Line Table) into every binary. The runtime needs it to print stack traces with real function and file names, which means a complete, accurate map of address to name is sitting in the file whether or not the symbol table was stripped. Even strip usually leaves it, because removing it would break panics. In ELF files it often lives in a section literally named .gopclntab.

You do not parse this by hand. GoReSym, Mandiant's open-source Go symbol recovery tool, reads the pclntab and emits JSON listing every function name and address, plus the Go compiler version and build metadata.

# Confirm it is Go and find the build version
$ go version ./chall # if the go toolchain is installed
./chall: go1.21.4
$ strings ./chall | grep -m1 'go1\.'
go1.21.4
# Recover every function name + address as JSON
$ ./GoReSym -t -p ./chall > syms.json
$ jq '.UserFunctions[] | {name: .FullName, start: .Start}' syms.json | head
{ "name": "main.main", "start": 5181088 }
{ "name": "main.checkFlag", "start": 5181440 }
{ "name": "main.xorEncode", "start": 5182016 }

The -t flag also recovers user-defined types, and -pprints the embedded paths, which often leak the author's package and source filenames. Once you have syms.json, apply it inside your disassembler. The GoReSym repository ships rename scripts for IDA, Binary Ninja, and Ghidra that read the JSON and label every function. After that, your forty-thousand-entry list has main.checkFlag sitting in it, named, and you go straight there.

Tip: No GoReSym handy? Recent Ghidra ships a Golang analyzer that parses the pclntab during auto-analysis. Open the binary, accept the default analyzers, and check the Symbol Tree for main.*entries. IDA users can use the bundled or community Golang loader scripts. GoReSym is the most consistent across Go versions because it is built from the compiler's own source, handling pclntab layouts from pre-1.2 through 1.20 and later. See the GoReSym project for builds and the rename scripts.

Filter ruthlessly. Anything under runtime., syscall., reflect., fmt., or the standard library is almost never the target. The author's code lives under main. and under whatever module path the -p output revealed. Two package prefixes usually contain the whole challenge.

Go: why do the strings run together with no gaps?

You run strings and get a single uninterrupted ribbon like flagincorrecttry againpicoCTF{... with nothing separating the words. This trips up everyone the first time. The reason is fundamental to how Go represents text and you have to understand it to read the disassembly.

A Go string is a pointer plus a length. There is no terminating null byte, so the compiler packs every string constant end to end in one blob.

In C a string is a pointer and you read until a 0x00. In Go a string is a two-word header: a pointer to the bytes and an explicit length count. Because the length is stored separately, the bytes themselves need no terminator, so the linker concatenates all string literals into one contiguous region with no separators. strings sees no nulls and prints the whole region as one line. The substring boundaries are invisible to strings but exact in the code.

What this means in the disassembly: a string is never just a lea of an address. It is an address and a constant length, almost always loaded together. When you see a pointer into the string blob immediately followed by a move of a small integer constant, that integer is the length, and pointer plus length carves the exact substring out of the ribbon.

; Loading the Go string "picoCTF{" (8 bytes) into a string header
lea rax, [rip + 0x12af30] ; rax = pointer into the packed string blob
mov ebx, 8 ; rbx = length 8 <-- this is the real boundary
; the pair (rax=0x...12af30, rbx=8) is the whole string "picoCTF{"
; the comparison you care about often looks like:
call runtime.memequal ; Go compiles == on strings to a length + memequal check
Note: String equality in Go compiles to a fast path: compare the two lengths first, and only if they match call runtime.memequal on the bytes. When you are hunting for a flag check, a length comparison against a constant followed by runtime.memequal is the tell. The constant length is a free hint about how long the flag is.

Go: what is weird about the calling convention?

Go used a stack-based calling convention for years, then moved to a register-based ABI starting with Go 1.17. CTF binaries today are mostly 1.17 or newer, so expect arguments in registers, but the register order is not the System V order you memorized for C. On amd64 the integer argument registers are rax, rbx, rcx, rdi, rsi, r8, r9, r10, r11, in that sequence. Results come back in the same registers. If your decompiler labels parameters oddly, this mismatch is usually why.

A few more Go-isms that will otherwise waste your time:

  • The first thing in many functions is a stack check. A compare against fs:[...] or a field of the goroutine struct, then a call to runtime.morestack_noctxt. It is goroutine stack-growth boilerplate. Skip past it; the real logic starts after.
  • Slices and strings are multi-word. A []byte slice is pointer, length, capacity (three words). A string is pointer, length (two words). When a single Go argument seems to occupy two or three registers, that is one slice or string, not several values.
  • Interface values are two words too: a type pointer and a data pointer. fmt.Println(x) passing through an interface{} is why simple print calls look heavier than expected.
Warning: Do not trust your decompiler's argument count on Go code until you have set the calling convention. In Ghidra, after the Golang analyzer runs, functions are tagged with the Go ABI; if you imported names manually instead, set the convention on the function so the parameter recovery stops inventing phantom arguments.

Rust: what is all this mangled junk in the names?

Rust is friendlier than Go in one big way: a default cargo build keeps symbols, so the function list is already populated. The catch is that the names are mangled. You will see entries like _ZN4core3fmt9Formatter3pad17h2c3f...E or the newer _RNvNtCs... form. That trailing hash and the length-prefixed components are the mangling scheme encoding the full module path and a disambiguator.

Demangle them and they become readable:

# rustfilt turns mangled symbols into readable paths (cargo install rustfilt)
$ nm ./chall | rustfilt | grep -i 'main\|check\|flag' | head
0000000000008a40 t chall::check_flag
0000000000008b10 t chall::main
0000000000008c00 t core::fmt::Formatter::pad
# Or demangle a single symbol inline
$ echo '_ZN5chall10check_flag17h9a1bE' | rustfilt
chall::check_flag

Both IDA and Ghidra demangle Rust symbols in recent versions; if yours shows raw _ZN... names, enable the demangler or pipe nm through rustfilt and use that as a map. The crate name (here chall) is your main. equivalent: filter to it and ignore core::, alloc::, and std::.

Monomorphization is the other tax. Because each generic instantiation is its own function, you will see many copies of the same logic specialized for different types. Do not waste time diffing them. Demangle, find the one whose type parameters match the challenge data (the copy specialized on u8 or char for a flag checker), and ignore the rest. The mangled hash suffix differs per instantiation, so the demangled path plus the type is what disambiguates.

Note: Rust's name mangling is documented in the language reference and the symbol-mangling RFC; the practical takeaway is that names survive a normal build, so a stripped Rust binary (strip run after the fact, or a release build with symbols removed) is a deliberate choice by the author, not the default. See the Rust documentation for str for how the core string type is laid out.

Rust: how do panics and &str become landmarks?

Rust gives you two gifts that Go does not. The first is panic strings. Indexing past the end of a slice, unwrapping a None, or a failed assertion all emit a panic with a message that embeds the exact source file and line number. Those strings are scattered through the binary like address labels.

$ strings ./chall | grep -iE 'src/|panicked|unwrap|index out of'
src/main.rs
called `Option::unwrap()` on a `None` value
src/main.rs:42:18
index out of bounds: the len is 32 but the index is

That src/main.rs:42:18is a direct line reference into the author's source. Find the code that references that string in the disassembly and you are standing next to line 42 of the challenge. Cross-reference the panic string's address in your disassembler and let the xref jump you into the relevant function. It is the closest thing reversing has to debug symbols you did not ask for.

The second gift is that a Rust &str behaves like a Go string: it is a pointer plus a length, not a null-terminated buffer. So the same packed-string ribbon and the same pointer-plus-length pattern in the disassembly apply. A flag comparison in Rust usually compiles down to a length check followed by a byte compare, frequently inlined or routed through a memcmp-like helper.

In both languages the move is identical: a string is an address and a length, and the flag check is a length compare followed by a byte-for-byte compare. Find that pattern and you have found the answer.
Tip: Set RUST_BACKTRACE=1 and run the binary with a wrong input. If it panics, the backtrace prints demangled function names and the exact failing line for free, no disassembly required. Many Rust CTF binaries leak their whole structure this way the moment you feed them garbage.

What is the actual workflow, start to finish?

Same skeleton for both languages: identify, name, find main, follow a string, locate the compare, confirm in a debugger. The steps barely change between Go and Rust once the names are recovered.

Step 1: identify the language and toolchain. Confirm what you are looking at before you pick tools.

$ file ./chall
./chall: ELF 64-bit LSB executable, x86-64, statically linked, stripped
$ strings ./chall | grep -m1 -E 'go1\.|rustc|cargo'
go1.21.4 # -> Go. Reach for GoReSym.
# (or) rustc version 1.78.0 # -> Rust. Reach for rustfilt + demangler.

Step 2: recover names. Go: run GoReSym and apply the JSON with the rename script. Rust: enable the demangler or build an nm | rustfilt map. Either way, you now have main.main / chall::main in the list.

Step 3: find main and the strings near it. Jump to main.main (Go) or the crate's main (Rust). Note that Go's main.main is not the ELF entry point; the runtime bootstraps first and then calls it, so navigate by name, not by entry. Look at the string xrefs from there: prompts like Enter the flag: and verdicts like Correct! bracket the check.

Step 4: follow the failure string to the compare. Find the Incorrect / Wrong / try again string, look at what references it, and walk backward to the conditional branch that decides between the success and failure messages. That branch is the flag check. In both languages it is a length test plus a byte comparison, as shown above.

Step 5: confirm dynamically with a breakpoint. Static reading tells you where the compare is; the debugger tells you what it compares against. Break on the comparison and read the operands.

$ gdb ./chall
(gdb) break *0x8b44 # address of the compare you found statically
(gdb) run
Enter the flag: AAAAAAAA
Breakpoint 1, 0x08b44 in main.checkFlag ()
(gdb) x/s $rsi # one operand is your input...
0x...: "AAAAAAAA"
(gdb) x/s $rdi # ...the other is often the expected value
0x...: "picoCTF{...}" # the flag, or the key to derive it
Warning: If the flag is transformed (XORed, hashed, or run through a per-character routine) rather than compared as plaintext, the breakpoint shows you the transform, not the flag. Set the breakpoint inside the per-character loop, dump the constant it operates against, and reverse the operation. The GDB guide covers scripting that loop dump with hook-stop commands so you do not single-step a hundred iterations by hand.

Where do I practice this on picoCTF?

picoCTF 2025 shipped a three-part Rust series built precisely for the player crossing over from C reversing. They escalate from "read the demangled source" to "reverse a transform," which is the exact ladder this post describes.

  • picoCTF 2025 Rust fixme 1 gets you comfortable with the Rust toolchain and reading a demangled binary without the size panic.
  • picoCTF 2025 Rust fixme 2 pushes you to follow the strings and the panic landmarks into the function that matters.
  • picoCTF 2025 Rust fixme 3 is the one where the static read finds the compare and a breakpoint confirms the value, the full workflow above end to end.

Do all three in order. They are the cheapest way to build the muscle memory of demangle, find main, follow the string, set the breakpoint, before you meet a Go binary in a harder event where the same five moves apply with GoReSym swapped in for rustfilt.

Quick reference

Identify and name

file ./chall # static? stripped? arch?
strings ./chall | grep -m1 -E 'go1\.|rustc' # which language
# Go: recover every function name from the pclntab
./GoReSym -t -p ./chall > syms.json
# then run the IDA/Ghidra/Binja rename script from the GoReSym repo
# Rust: demangle the symbol table
nm ./chall | rustfilt | grep -i 'main\|check\|flag'
RUST_BACKTRACE=1 ./chall # panic = free demangled backtrace

Remember the data shapes

  • Go string and Rust &str: pointer + length, no null terminator. Strings pack together in strings output.
  • The compare = length test, then runtime.memequal (Go) or a memcmp-like byte compare (Rust). The constant length tells you the flag length.
  • Filter to main. / crate name. Ignore runtime., core::, alloc::, std::.
  • Go ABI (1.17+) order: rax, rbx, rcx, rdi, rsi, r8, r9, ..., not System V. main.main is not the entry point.
  • Rust panic strings embed src/file.rs:line:col. Use them as labels into the author's code.

For the surrounding skills, the Ghidra guide covers applying the recovered symbols and reading the decompiler output, the x86 assembly guide covers the instruction patterns the compare compiles to, and the GDB guide covers scripting the breakpoint that confirms the value. The primary references worth bookmarking are GoReSym for Go and the Rust str documentation for the string layout.

A fat binary is not a wall, it is a haystack with the needle clearly labeled: name the functions, follow a string to the compare, and let a breakpoint read you the answer.