
Go has rapidly become a popular programming language for developing cloud software, command-line tools, and even malware. Yet for reverse engineers, Go binaries have always been a headache: unlike C or C++, the Golang compiler generates binary code following conventions that make decompilation harder. For example, the common Golang idiom of returning multiple values from a function previously decompiled into cryptic Pseudo-C with our decompiler. The good news? IDA 9.2 (coming soon) substantially improves on Go decompilation, saving analysts from the hassles of hand-reconstructing function prototypes and enabling them to focus on the real logic.
Current state of Golang decompilation
If you’ve ever opened a Go binary in IDA and Hex-Rays, you’ve probably seen something like this: functions with undefined stack variables, mystery return values, and function calls with argument lists the size of a skyscraper. This makes it tough to follow data flow, especially when functions chain multiple returns or pass complex structures.
For example, the innocent Fscanf function from the fmt package at source level looks like this:
func Fscanf(r io.Reader, format string, a ...interface{}) (n int, err error) {
s, old := newScanState(r, false, false)
n, err = s.doScanf(format, a)
s.free(old)
return
}
If we were to compile this function and ask the 9.1 decompiler for its opinion, then we would see the following:
Comparing this to the Golang source, we immediately spot several problems:
- The function call argument lists of newScanState, doScanf, and free are long
- Many local variables are marked in orange as potentially undefined. For example, it is not evident who initializes v9, v10, or v11, which are used to initialize v17, v18, and v19, respectively.
- We have no clue how the return value v16 (returned by doScanf) corresponds to the actual return values of fmt.Fscanf (int n, and error err).
These woes are a consequence of Go’s application binary interfaces (ABIs). If we hop back to IDA’s disassembly view, then this is what the epilogue of fmt.Fscanf looks like on x86-64:
; <snip>
.text:00000000004997CA call fmt__ptr_ss_free
.text:00000000004997CF mov rax, [rsp+90h+var_38]
.text:00000000004997D4 mov rbx, [rsp+90h+var_40]
.text:00000000004997D9 mov rcx, [rsp+90h+var_8]
.text:00000000004997E1 add rsp, 90h
.text:00000000004997E8 pop rbp
.text:00000000004997E9 retn
Starting from the bottom: the add rsp, 90h; pop rbp; retn sequence is a standard stack frame teardown. The interesting part is what happens right before it: all three of rax, rbx, and rcx registers are used to pass return values to the caller. This will be a surprise to those accustomed to C/C++ x86-64 calling conventions, where we would typically only expect the rax register to receive the (integral) return value of the function. The calling context confirms that these multiple return registers are indeed used.
; <snip>
.text:00000000004A7B86 call fmt_Fscanf
.text:00000000004A7B8B mov rax, rbx
.text:00000000004A7B8E mov rbx, rcx
; <snip>
In C terms, what is happening here is that the compiler is returning a struct by value and spreading its component fields across three separate registers. This apparently simple example only scratches the surface – Go binary aficionados will know that this is not the only way that return values are handled in Golang.
Remedying Golang decompilation
IDA 9.2 remedies Go’s decompilation woes with the introduction of tuple types into IDA’s type system. In short, tuples are like structs, but with a few key differences:
- tuple members can be scattered across registers and memory (stack) locations
- Two tuples are considered to be equal if their field offsets, sizes, and types match field names and alignments are ignored
We use tuples to model Golang return values that are spread across multiple locations. Tuple types enable the decompiler to produce much more readable output: the same fmt.Fscanf function decompiled using IDA 9.2 looks as follows:
This is much more readable! The following improvements are evident
- No more orange-marked uninitialized stack variables.
- The return value of newScanState is passed into doScanf and free.
- Three members of the value of doScanf form the return value of fmt.Fscanf. On closer inspection, we would find _r0 to correspond to int n and _r1/_r2 to error err (see https://pkg.go.dev/cmd/compile/internal/types#pkg-variables)
The retval_4996C0, retval_49E420, retval_499F60, retval_4996C0 types are defined as tuples (no automated recovery of member names for standard library functions (yet), sorry):
00000000 __tuple retval_4996C0 // sizeof=0x18
00000000 {
00000000 _QWORD _r0;
00000008 _QWORD _r1;
00000010 _QWORD _r2;
00000018 };
00000000 __tuple retval_49E420 // sizeof=0x18
00000000 {
00000000 _QWORD _r0;
00000008 _QWORD _r1;
00000010 _QWORD _r2;
00000018 };
00000000 __tuple retval_499F60 // sizeof=0x38
00000000 {
00000000 _QWORD _r0;
00000008 _QWORD _r1;
00000010 _QWORD _r2;
00000018 _QWORD _r3;
00000020 _QWORD _r4;
00000028 _QWORD _r5;
00000030 _QWORD _r6;
00000038 };
00000000 __tuple retval_4996C0 // sizeof=0x18
00000000 {
00000000 _QWORD _r0;
00000008 _QWORD _r1;
00000010 _QWORD _r2;
00000018 };
Taking the pain out of Flare-On 11, Challenge 2
Putting everything together, let's look at a Golang binary from the 2024 edition of the annual Flare-On reversing competition.
In IDA 9.1, we see the following:
The same location in IDA 9.2 (also notice the correctly recovered string lengths):
Conclusion
Reverse engineering Go binaries has always been a bit like wrestling with an octopus: too many moving parts, and never quite where you expect them. With the IDA’s new tuple types and the latest Hex-Rays decompiler improvements, the task gets a whole lot easier. By better tracking Golang ABI parameters and return values, analysts can finally read decompiled Go functions in a way that feels natural and intuitive.
Whether you’re dissecting a new malware family, auditing Go-based tooling, or just curious about what’s hiding inside a binary, the new version of Hex-Rays helps you spend less time fixing prototypes and more time doing real reverse engineering.
Grab the latest IDA release and try it on your Go targets - you might be surprised how much smoother the workflow feels.