Post

Lesson 8.3: Go's string, slice, interface and goroutine in assembly

Lesson 8.3: Go's string, slice, interface and goroutine in assembly

In the last lesson you recovered Go function names with pclntab. Function names are only half of it. You also need to know Go’s own data types, because they look nothing like C. A Go string doesn’t end with a 0 byte, a slice is three fields, an interface carries a type pointer, and every go func becomes a runtime call. If you don’t know this, Go disassembly looks broken.

Every asm snippet and number in this lesson is real output, built with Go 1.22 on Linux x64 and taken with go tool objdump and go tool nm.

Go string: pointer plus length, no 0 byte

This trips up C people first. In C a string is a run of bytes ending in \x00, so you read until you hit 0. In Go a string value is a struct with two fields:

1
2
3
4
type string struct {
    data *byte   // pointer to the first byte
    len  int     // length, in bytes
}

There’s no terminator, the length sits in its own field. For reversing this means string literals in a Go binary are merged into one long run with nothing between them. The compiler puts everything into one blob, and each use site keeps only a pointer and a length into its own piece.

You can see the blob in this lesson’s demo binary:

1
2
secret length:1907348632812595367431640625unexpected EOFunsafe...
sum:true3125-Inf+Inffileboolint8uintchanfunccall...

The string "secret length:" runs straight into "1907348..." and then "unexpected EOF", with no 0 byte between them. If you open this in IDA and let it detect strings the C way, it merges the whole cluster into one giant meaningless string, or cuts in the wrong place.

So I don’t trust the string boundaries IDA guesses. I follow the code that loads the string, because the compiler always loads both the pointer and the length there. This is the real asm that loads the string "GopherReverse" (13 characters):

LEAQ 0x78bd(IP), CX        ; CX = pointer to the string's data
MOVQ CX, 0x98(SP)          ; save the pointer
MOVL $0xd, AX              ; AX = 0xd = 13 = length of "GopherReverse"

0xd is 13, the length of "GopherReverse". A useful rule is that if you see a LEAQ loading a pointer into the string blob, with a small constant loaded into another register right next to it, that constant is almost certainly the string length. Take the pointer, add the length, and you cut the string cleanly out of the blob.

Slice: pointer, len, cap

A Go slice is a three-field struct, 24 bytes on x64:

1
2
3
4
5
type slice struct {
    data *T   // pointer to the backing array
    len  int  // number of elements in use
    cap  int  // capacity
}

When you create a slice literal, the compiler builds the backing array and fills in the values. This is the real asm that []int{3, 8, 15, 16, 23, 42} builds:

MOVQ $0x3,  0x30(SP)       ; element 0 = 3
MOVQ $0x8,  0x38(SP)       ; element 1 = 8
MOVQ $0xf,  0x40(SP)       ; element 2 = 15 (0xf)
MOVQ $0x10, 0x48(SP)       ; element 3 = 16
MOVQ $0x17, 0x50(SP)       ; element 4 = 23 (0x17)
MOVQ $0x2a, 0x58(SP)       ; element 5 = 42 (0x2a)

Six int elements, 8 bytes each, written back to back on the stack, exactly 8 apart. That’s how you recognize a slice literal, a series of MOVQ writing values to consecutive stack positions. When the slice is passed into a function, three values travel together, which are pointer, len, cap.

Interface: two pointers

An interface value is also a two-field struct:

1
2
3
4
type iface struct {
    tab  *itab   // pointer to the type table (itab), gives the dynamic type and methods
    data *T      // pointer to the real data
}

The itab holds type info and the method table, a role similar to the vtable in C++ (see Lesson 4.2). When calling a method through an interface, Go reads the function pointer from the itab and calls it indirectly, like a virtual call. In asm you see a MOVQ fetching the function pointer from an offset in the itab and then a CALL on it. If you see go:itab.*os.File,io.Writer in the disassembly (it’s in the demo), that’s an interface. It pairs the concrete type *os.File with the interface io.Writer.

Goroutine: go func becomes runtime.newproc

This is the part of Go that looks strangest. When you write go f(x), the compiler doesn’t call f directly. It packages the function and parameters and calls runtime.newproc, and Go’s scheduler runs it on a separate goroutine. In the demo, nm shows:

1
2
3
4
main.main.func1              ; the goroutine body (closure)
main.main.gowrap1            ; wrapper generated by Go
main.main.func1.deferwrap1   ; wrapper for a defer inside
runtime.newproc              ; the function that creates a goroutine

So a CALL runtime.newproc means there’s a go func(...) there. The goroutine’s real body is in a separate function named like main.xxx.funcN. To see what the goroutine does, jump into that funcN function.

Similarly, defer becomes deferwrap/runtime.deferproc and runs when the function returns (runtime.deferreturn). Channels become calls to runtime.chansend/runtime.chanrecv. Maps become runtime.makemap, runtime.mapaccess, runtime.mapassign. If you recognize these runtime names you can read the high-level intent of the code without tracing every instruction.

Recognizing runtime calls

Go hands a lot of work to the runtime, and the runtime functions have clear names (once you’ve recovered symbols in Lesson 8.2). Remember a few common ones and reading Go gets much faster:

When you see the callIt means
runtime.newprocthere’s a go func(...)
runtime.deferproc / deferreturnthere’s a defer
runtime.makemap / mapaccess / mapassignmap operations
runtime.makeslice / growslicecreating/growing a slice
runtime.chansend / chanrecvsending/receiving on a channel
runtime.convT64 / convTstringboxing a value into an interface
runtime.stringtoslicebyteconverting string to []byte
runtime.concatstringsstring concatenation

Lab

LAB 8.3Download the source files for this lab

The goal is to see that Go strings are glued together in the binary, and to follow how the code loads a string with a pointer plus a length. You need a Go toolchain (go version), and the lab was checked with Go 1.22 on Linux x64. The program is main.go, and you build it with:

1
go build -o demo83 main.go

Look at the size. A program of a few lines comes out near 2 MB, because Go embeds the whole runtime and the garbage collector.

First look at the string blob. Run this to find the run of glued strings:

1
strings demo83 | grep "secret length"

Notice that secret length: runs straight into other strings with nothing separating them. That’s the evidence that a Go string doesn’t end with a 0 byte. Next measure the secret string. How many characters does "GopherReverse" have? Disassemble main.main and look for that constant in hex:

1
go tool objdump -s "main.main$" demo83 | grep -iE "LEAQ|MOVL"

Find a MOVL $0x..., AX whose value equals the string length. The string pointer is loaded with a LEAQ nearby.

Then find the slice literal. In the same objdump output, look for the run of MOVQ $0x..., 0x..(SP) instructions that load the values 3, 8, 15, 16, 23, 42 (hex 0x3, 0x8, 0xf, 0x10, 0x17, 0x2a) into stack slots 8 bytes apart. That’s []int{...} being built. Finally find the goroutine. List the symbols and look for the goroutine body:

1
go tool nm demo83 | grep -iE "func1|newproc|gowrap|deferwrap"

The function main.main.func1 is the body of go func(...), and runtime.newproc is where the goroutine gets created.

Two questions to think about. Why does letting IDA auto-detect C-style strings on a Go binary give wrong results? And if the binary is stripped, what still lets you find main.main (the hint is to look back at Lesson 8.2)?

Show solution

All the output below is real, built with Go 1.22.0 on Linux x64. For the string blob:

1
2
$ strings demo83 | grep "secret length"
secret length:1907348632812595367431640625unexpected EOFunsafe...

The string "secret length:" is glued to "1907348..." and then to "unexpected EOF", with no 0 byte separating them. Another example from the same binary:

1
sum:true3125-Inf+Inffileboolint8uintchanfunccall...

That’s how Go strings work. The compiler merges every string literal (including those of the runtime) into one shared blob, and each use site keeps only a pointer and a length.

For the length, "GopherReverse" has 13 characters. In the disassembly:

LEAQ 0x78bd(IP), CX        ; CX = pointer to the string data in the blob
MOVQ CX, 0x98(SP)
MOVL $0xd, AX              ; 0xd = 13 = length of "GopherReverse"

0xd is 13, matching the length. This is the common pattern, where LEAQ loads the pointer and a small constant loads the length. The pointer plus the length cuts the string cleanly out of the blob.

For the slice literal:

MOVQ $0x3,  0x30(SP)       ; 3
MOVQ $0x8,  0x38(SP)       ; 8
MOVQ $0xf,  0x40(SP)       ; 15
MOVQ $0x10, 0x48(SP)       ; 16
MOVQ $0x17, 0x50(SP)       ; 23
MOVQ $0x2a, 0x58(SP)       ; 42

Six int elements exactly 8 bytes apart on the stack. That’s []int{3, 8, 15, 16, 23, 42} being built on the stack before the slice header (data, len, cap) pointing at it is created.

For the goroutine:

1
2
3
4
5
$ go tool nm demo83 | grep -iE "func1|newproc|gowrap|deferwrap"
  480e60 T main.main.func1
  480e00 T main.main.gowrap1
  480f40 T main.main.func1.deferwrap1
  43f4a0 T runtime.newproc

main.main.func1 is the real body of go func(id int){...}, runtime.newproc is where the goroutine is created (the compiler turns go f() into this call), and deferwrap1 is the wrapper for defer wg.Done() inside the goroutine. Also note that sumSlice doesn’t appear in nm because the compiler inlined it into main. Inlining like this is normal in Go, and it’s another reason the function tree in the binary doesn’t match the source one to one.

On the questions, IDA’s C-style string detection goes wrong because it looks for a 0 terminator byte and Go strings don’t have one. It merges the whole blob into one enormous string or cuts in the wrong places, so you rely on the pointer plus length pair where the code loads the string. A stripped binary still lets you find main.main thanks to pclntab (see Lesson 8.2), the table that keeps the mapping from addresses to function names and that GoReSym can read.

Key takeaways

A Go string is (pointer, length) and is not terminated by a 0 byte, so string literals merge into one stuck-together blob. To cut a string correctly, follow the code that loads it, meaning a LEAQ pointer next to a small constant, which is the length. A slice is (data, len, cap), 24 bytes, and a slice literal is a series of MOVQ into consecutive stack slots.

An interface is (itab, data), and calling a method through the itab is like a virtual call. go func becomes runtime.newproc, and the real body sits in a separate funcN function. Learn the runtime function names (newproc, deferproc, makemap, chansend) so you can read the intent quickly.

This post is licensed under CC BY 4.0 by the author.