Go for Security Auditors: Part 1 - Syntax That Will Trip You Up
Go has a reputation for being easy to pick up. The syntax is minimal, there is no inheritance to untangle, and the standard library handles most of what you need. Developers coming from Java or C++ often feel productive within days.
But that learning curve is deceptive. The simplicity of Go means the language has fewer features, and those features carry more weight. When something behaves unexpectedly, it is not because of some obscure language corner case you never learned. It is because a common pattern you use daily has an edge case you never hit before. And that is exactly where security bugs hide.
We have audited a lot of Go code over the years: consensus clients, bridge infrastructure, validator nodes, financial protocols. The bugs we find are not usually exotic. They come from developers (often experienced ones) misunderstanding how a seemingly simple language feature actually works in practice.
This is the first post in a three-part series:
- Part 1 (this post): Weird syntax, common pitfalls, and testing gotchas. The foundational stuff you need to know before touching a Go codebase.
- Part 2: Where to start a review without being overwhelmed. Top-down vs bottom-up approaches, finding main functions, and locating entry points (HTTP handlers, gRPC services, libp2p listeners, etc.).
- Part 3: Common vulnerabilities. The actual bugs we find repeatedly in Go code.
Weird Syntax
Before we get into the dangerous stuff, let us clear up some syntax that looks strange if you are coming from other languages. You will see these patterns everywhere in Go codebases, and misreading them wastes time.
Sparse Array Initialization
Most languages initialise arrays sequentially. Go lets you skip around:
b := [...]int{100, 3: 400, 500}
// Result: [100 0 0 400 500]
The 3: syntax places 400 at index 3, and Go zero-fills indices 1 and 2 automatically. The 500 then lands at index 4.
This seems like a minor convenience until you are reviewing protocol code that constructs byte arrays for wire formats. If a developer uses this syntax and miscounts indices, you get silent zero-padding in the middle of what should be a packed structure. In cryptographic or serialisation code, unexpected zeros can cause signature verification failures or parsing mismatches that are difficult to debug.
Multi-Parameter Type Sharing
When consecutive function parameters share a type, you only declare it once:
func transferAssets(from, to string, amount, fee uint64) error {
if balance[from] < amount {
return errors.New("not enough funds")
}
// ...
}
This is just syntactic sugar. But when you are skimming a function signature trying to understand what it accepts, it is easy to miss that from and to are both strings. If you see something like func verify(a, b, expected []byte), take a second to confirm all three parameters are meant to be byte slices. Copy-paste errors can propagate here when a parameter that should have been a different type inherits the wrong one.
The Blank Identifier
Go forces you to use every variable you declare. If you do not need a value, you explicitly discard it with an underscore:
for _, operator := range operators {
addr := operator.Addr.Hex()
staked := weiToEth(operator.Staked)
// ...
}
The _ discards the loop index. This is idiomatic Go and you will see it constantly.
The thing to watch for is code where the index should have been used but was not. If you see logic that depends on position within a collection, but the loop discards the index, something is wrong. Either the code is broken or it is doing something seemingly clever with side effects that deserves a closer look.
Closures and Variable Capture
Go supports closures. A function defined inside another function can reference variables from the enclosing scope:
func createCounter() func() int {
i := 0
return func() int {
i++
return i
}
}
The returned function "closes over" i. Each time you call it, i increments and persists. This is powerful for creating stateful callbacks, and Go code uses it heavily.
The problem comes with loops. This used to be one of the most common Go bugs:
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // Bug: captures 'i' by reference
}()
}
// Usually prints "10" ten times, not 0-9
The closure captures i by reference, not by value. All ten goroutines share the same variable, and by the time they execute, the loop has finished and i equals 10.
Go 1.22 finally fixed this by making each loop iteration create a fresh variable. But plenty of production code predates that fix, and you will encounter this pattern in audits for years to come. When you see goroutines spawned in loops, check whether the code was written for pre-1.22 Go and whether it handles variable capture correctly.
Method Receivers
Go does not have classes, but it has methods. You attach a function to a type by declaring a "receiver":
func (s *State) GetHeight(dstID, srcID uint64) uint64 {
s.mu.Lock()
defer s.mu.Unlock()
return s.heights[dstID][srcID]
}
The (s *State) makes this a method on *State. You call it as myState.GetHeight(...).
The critical detail is the asterisk. A pointer receiver (*State) can modify the underlying struct. A value receiver (State) works on a copy. If a method is supposed to update state but uses a value receiver, then mutations silently disappear. This can cause anything from configuration not persisting to security checks being bypassed because an "updated" flag was set on a copy that got discarded.
Infinite Loops
Go has no while keyword. When you need a loop that runs until some condition inside the body, you write a bare for:
for {
select {
case <-ctx.Done():
return
case msg := <-messages:
process(msg)
}
}
This is the standard pattern for event loops, network listeners, and background workers. You will see it in basically every Go service.
Always trace the exit paths. Unbounded loops that do not respect context cancellation are denial-of-service bugs. If a goroutine spins forever because nothing signals it to stop, then you are leaking resources at best and creating a way to exhaust server capacity at worst.
Common Pitfalls
These next issues are not about unusual syntax. They are about common patterns that behave in ways developers do not expect.
Nil Maps vs Empty Maps
This trips up almost everyone at some point:
var nilMap map[string]int // nil
emptyMap := map[string]int{} // empty, but not nil
// Reading from nil map is fine
_ = nilMap["key"] // Returns zero value (0)
// Writing to nil map panics!
nilMap["key"] = 1 // panic: assignment to entry in nil map
A nil map and an empty map look the same when you read from them. Both return zero values for missing keys. But writing to a nil map crashes your program.
This matters because struct fields default to nil:
type Config struct {
Settings map[string]string
}
cfg := Config{}
cfg.Settings["key"] = "value" // panic!
If a constructor does not explicitly initialise the map, you get a nil map panic the first time someone writes to it. The sneaky part is that iterating over a nil map works fine (it just does nothing), so code that only reads might work in tests but crash in production when a new code path tries to write.
Look for structs with map fields and check whether constructors initialise them. Missing initialisation is a latent crash waiting for the right input.
Slice Aliasing
Slices in Go are references to underlying arrays. When you create a slice from another slice, they share memory:
original := []int{1, 2, 3, 4, 5}
slice := original[1:3] // [2, 3]
slice[0] = 99 // Modifies original too!
// original is now [1, 99, 3, 4, 5]
This is intentional. It makes slicing cheap. But it means that what looks like a copy is not a copy at all.
For security-sensitive code, this creates real problems. If you slice off part of a buffer containing sensitive data, both slices reference the same memory. If append later causes one slice to move to a new backing array, zeroing the original no longer clears the copy. And modifications to what you thought was a local copy can propagate back to shared state when they still share the same underlying array.
The append function makes this worse:
a := make([]int, 3, 6) // len=3, cap=6
copy(a, []int{1, 2, 3})
b := a[0:3]
b = append(b, 4)
// Did this modify the underlying array used by a? Yes, because cap was not exceeded
Before append:
┌───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ │ │ │ backing array (cap=6)
└───┴───┴───┴───┴───┴───┘
▲──────────▲ a (len=3)
▲──────────▲ b (len=3)
After b = append(b, 4):
┌───┬───┬───┬───┬───┬───┐
│ 1 │ 2 │ 3 │ 4 │ │ │ same backing array!
└───┴───┴───┴───┴───┴───┘
▲──────────▲ a (len=3, does not see 4)
▲──────────────▲ b (len=4)
When a slice has spare capacity, append modifies in place. When it does not have capacity, append allocates a new array. So whether append creates aliasing depends on the current capacity, which might not be obvious from reading the code. In concurrent code, this can cause state corruption when one goroutine's append unexpectedly modifies another goroutine's view of the data.
Defer Timing
The defer statement schedules a function call to run when the enclosing function returns. Go developers use it constantly for cleanup:
func processFile(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
// ... work with f ...
}
Two things catch people off guard. First, multiple defers execute in LIFO order:
func example() {
defer fmt.Println("first")
defer fmt.Println("second")
defer fmt.Println("third")
}
// Prints: third, second, first
Second, and more dangerous: deferred function arguments are evaluated immediately, not when the deferred function runs:
func logDuration() {
start := time.Now()
defer log.Printf("took %v", time.Since(start)) // Bug!
doExpensiveWork()
}
// Logs "took 0s" because time.Since(start) was evaluated at defer time
The time.Since(start) call happens right when the defer statement executes, not when the function returns. This is a common source of confusing logs and broken metrics. The fix is to wrap it in a closure:
defer func() {
log.Printf("took %v", time.Since(start))
}()
Now the timing happens when the closure executes at function exit.
Interface Nil Confusion
This one is subtle and causes real bugs in error handling:
type MyError struct{ msg string }
func (e *MyError) Error() string { return e.msg }
func mayFail() error {
var err *MyError = nil
return err
}
func main() {
err := mayFail()
if err != nil {
fmt.Println("error!") // This prints!
}
}
You might think that mayFail returns nil.
Not quite. We returned a nil *MyError, but the return type is error (an interface). In Go, an interface value contains two things: a type and a value. The returned interface has type *MyError and value nil. An interface is only nil when both the type and value are nil.
bare nil interface: typed nil interface:
┌──────────────────┐ ┌────────────────────┐
│ type: nil │ │ type: *MyError │
│ value: nil │ │ value: nil │
└──────────────────┘ └────────────────────┘
== nil ✓ != nil ✗
So err != nil is true, even though the underlying pointer is nil.
This causes real bugs. If a function returns a typed nil instead of a bare nil, error checks pass when they should not. The fix is to always return bare nil for success:
func mayFail() error {
// ... do work ...
return nil // Return bare nil, not typed nil
}
When auditing error handling, watch for functions that declare a typed error variable and return it directly. That is often a sign of this bug.
Variable Shadowing
Go allows you to declare a new variable with the same name as one in an outer scope:
func processRequest(r *Request) error {
err := validate(r)
if err != nil {
return err
}
if r.NeedsAuth {
user, err := authenticate(r) // Shadows outer 'err'!
if err != nil {
return err
}
r.User = user
}
err = process(r) // Uses outer 'err'
return err
}
The := inside the if block creates a new err variable that shadows the outer one. Inside that block, err refers to the authenticate result. Outside, it refers to the validate result (or later, the process result).
This code actually works correctly because each error is checked immediately. But the pattern is fragile. If someone refactors and moves the error check, or if logic depends on which error occurred, then shadowing creates confusion about which err you are looking at.
A related pitfall is stale error references. Here is a real bug from a client we reviewed:
forwardUnpacked, err := forward.Inputs.Unpack(updatePayloadBytes[4:])
if err != nil {
return nil, nil, fmt.Errorf("failed to unpack update payload: %w", err)
}
aggregator, ok := forwardUnpacked[0].(common.Address)
if !ok {
return nil, nil, fmt.Errorf("failed to unpack forward data (to): %w", err) // Bug!
}
The type assertion for aggregator does not set err. If the assertion fails, the error message wraps whatever err was from the previous Unpack call, which succeeded (otherwise we would have returned already). The wrapped error is either nil or irrelevant to the actual failure. This pattern is easy to miss because the code looks structurally correct.
Run go vet -shadow on codebases you are reviewing. It catches shadowing issues, and they are worth examining even when they are technically correct.
Testing Gotchas
Tests are part of the attack surface too. Weak tests mean bugs ship. Go also has some testing patterns that look thorough but hide gaps.
External Test Packages
Test files can declare a different package name:
// In mypkg/impl_test.go
package mypkg_test // Note: _test suffix
import "mypkg"
func TestPublicAPI(t *testing.T) {
// Can only access exported symbols
}
With the _test suffix, the test file can only access public symbols. This is good for black-box API testing.
But during security reviews, you often want to see how internal state is tested. Look for test files without the _test suffix on the package name. Those can access unexported functions and fields. If internal invariants are only tested through the public API, then subtle internal bugs might slip through.
Parallel Test Capture Bug
Tests can run in parallel with t.Parallel():
func TestParallel(t *testing.T) {
for _, tc := range testCases {
tc := tc // Required before Go 1.22!
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
// ...
})
}
}
See that tc := tc line? Before Go 1.22, it was required. Without it, all parallel subtests would share the same loop variable and probably test the last case multiple times while skipping others.
If you are reviewing a codebase that targets pre-1.22 Go and you see parallel subtests without this copy, the tests are likely broken. They might pass, but they are not testing what they claim to test.
Table-Driven Test Gaps
Table-driven tests are idiomatic Go:
tests := []struct {
name string
input string
expected int
}{
{"empty", "", 0},
{"simple", "hello", 5},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := process(tt.input)
if got != tt.expected {
t.Errorf("got %d, want %d", got, tt.expected)
}
})
}
This pattern looks comprehensive. There is a nice table of cases! But tables make it easy to add happy-path cases while neglecting edges. Does the table test error conditions? Boundary values? Malformed input?
The structure of a table-driven test can give false confidence. Always check what the table actually contains, not just that a table exists.
Build Tags Hide Tests
Go supports build tags that conditionally include files:
//go:build integration
package mypackage
func TestRequiresDatabase(t *testing.T) {
// Only runs with: go test -tags integration
}
This test only runs if you pass -tags integration. That is fine for slow integration tests. But sometimes security-relevant tests get tagged and then skipped in CI because nobody remembered to enable the tag.
When auditing, run tests with different tag combinations. Check for //go:build !prod or similar patterns that might disable security checks in production builds.
Fuzzing and t.Skip()
Go's built-in fuzzing lets you discard uninteresting inputs:
func FuzzParser(f *testing.F) {
f.Fuzz(func(t *testing.T, data []byte) {
if len(data) < 4 {
t.Skip() // Discard inputs that are too short
return
}
Parse(data)
})
}
Here is the catch: t.Skip() currently does the same thing as return. If the input increases coverage, it still gets added to the corpus. The skip is more of a hint than a directive.
This might change in future Go versions, but for now, do not assume t.Skip() actually discards inputs from the fuzzer's consideration.
Compiler Pragmas
Go has special comments that change how code compiles. They look like comments, so they are easy to miss:
//go:noescape
func riskyOperation(p *byte)
//go:nosplit
func criticalPath() {
// Cannot grow stack
}
//go:embed config.json
var configData []byte
These have security implications:
//go:noescape tells the compiler a pointer does not escape to the heap. If the annotation is wrong, the pointer might be used after its stack frame is gone. Use-after-free in a "safe" language.
//go:nosplit prevents the runtime from growing the stack. If the function recurses or calls deep chains, you get a stack overflow.
//go:linkname lets you access unexported symbols from other packages. It is an escape hatch that breaks encapsulation. If you see it, someone is doing something unusual that deserves scrutiny.
These pragmas are rare in application code. When you see them, slow down and understand why they are there.
Minor Quirks
A few smaller things worth knowing:
Module path encoding: If you see paths like github.com/!cosm!wasm/wasmvm in go.mod or the module cache, the ! characters encode capital letters. Go does this for filesystem compatibility on case-insensitive systems. The real URL is github.com/CosmWasm/wasmvm. Mostly harmless, but confusing when searching for packages.
Struct field alignment: Running go vet -fieldalignment produces warnings about struct size. Go pads struct fields for memory alignment, and field order affects total size. This is usually a performance concern, not a security one. But if you are reviewing code that does binary serialisation or memory-mapped I/O, field layout might matter for correctness.
What is Next
The patterns in this post account for a surprising number of real bugs. They are easy to write, they pass code review, they often pass tests, and they are still wrong.
Part 2 covers how to actually navigate a Go codebase: finding entry points, understanding how HTTP handlers and gRPC services route requests, and identifying the security-critical code paths without getting lost. Part 3 gets into vulnerability patterns specific to Go, drawing from real findings across our audits. From goroutine leaks that hung indefinitely without a timeout, to nil pointer dereferences that crashed in production, to a missing nil check in a client's attestation keeper that panicked on malformed votes.
Part 1 of 3. Next: Navigating Go Codebases.