Security assessment techniques for Go projects

The Trail of Bits Assurance practice has received an influx of Go projects, following the success of our Kubernetes assessment this summer. As a result, we’ve been adapting for Go projects some of the security assessment techniques and tactics we’ve used with other compiled languages.

We started by understanding the design of the language, identifying areas where developers may not fully understand the functionality of a language semantic. Many of these misunderstood semantics originated from findings we reported to our clients and independent research into the language itself. While not exhaustive, some of these problem areas include scoping, coroutines, error handling, and dependency management. Notably, many of theses are not directly related to the runtime. The Go runtime itself is designed to be safe by default, preventing many C-like vulnerabilities.

With a better understanding of the root causes, we searched for existing tooling to help us quickly and effectively instrument client codebases. The result was a sample of static and dynamic open-source tools, including several that were Go-agnostic. To complement these tools, we also identified several compiler configurations that help with instrumentation.

Static analysis

Because Go is a compiled language, the compiler detects and prevents many potentially erroneous patterns before the binary executable is even produced. While this is a major annoyance for newer Go developers, these warnings are extremely important in preventing unexpected behavior and keeping code clean and readable.

Static analysis tends to catch a lot of very low hanging fruit not included in compiler errors and warnings. Within the Go ecosystem, there are many disparate tools such as go-vet, staticcheck, and those within the analysis package. These tools typically identify problems like variable shadowing, unsafe pointer use, and unused function return values. Investigating the areas of a project where these tools display warnings typically leads to exploitable functionality.

These tools are by no means perfect. For example, go-vet can miss very common accidents like the one below, where the A function’s err return value is unused, and immediately reassigned during the assignment of bSuccess on the left-hand side of the expression. The compiler will not provide a warning, and go-vet does not detect this; nor does errcheck. In fact, the tools that successfully identify this case (non-exhaustive) are the aforementioned staticcheck and ineffassign, which identify the err return value of A as unused or ineffectual.

package mainimport "fmt"func A() (bool, error) { return false, fmt.Errorf("I get overridden!") }func B() (bool, error) { return true, nil }func main() {	aSuccess, err := A()	bSuccess, err := B()	if err != nil {		fmt.Println(err)	}	fmt.Println(aSuccess, ":", bSuccess)}

Figure 1: An example program showing an ineffectual assignment of err tricking go-vet and errcheck into considering err as checked.

$ go run .false : true$ errcheck .$ go vet .$ staticcheck .main.go:5:50: error strings should not be capitalized (ST1005)main.go:5:50: error strings should not end with punctuation or a newline (ST1005)main.go:10:12: this value of err is never used (SA4006)$ ineffassign .<snip>/main.go:10:12: ineffectual assignment to err

Figure 2: The output of the example program, along with errcheck, go-vet, staticcheck, and ineffassign.

When you look deeper into this example, you may wonder why the compiler does not warn on this problem. The Go compiler will error when variables are not used within a program, but this example successfully compiles. This is caused by the semantics of the “short variable declaration.”

ShortVarDecl = IdentifierList ":=" ExpressionList .

Figure 3: The grammar specification of the “short variable declaration.”

According to the specification, the short variable declaration has the special ability to redeclare variables as long as:

  • The redeclaration is in a multi-variable short declaration.
  • The redeclared variable is declared earlier in the same block or function’s parameter list.
  • The redeclared variable is of the same type as the previous declaration.
  • At least one non-blank variable in the declaration is new.

All of these constraints hold in the previous example, preventing the compiler from producing errors for this problem.

Many tools have edge cases like this where they are unsuccessful in identifying related issues, or identify an issue but describe it differently. Compounding the problem, these tools often require building the Go source code before analysis can be performed. This makes third-party security assessments complicated if the analysts cannot easily build the codebase or its dependencies.

Despite these pitfalls, when put together, the available tools can provide good hints as to where to look for problems within a given project, with just a little bit of effort. We recommend using gosec, go-vet, and staticcheck, at a minimum. These have the best documentation and ergonomics for most codebases. They also provide a wide variety of checks (such as ineffassign or errcheck) for common issues, without getting too specific. For more in-depth analysis of a particular type of issue, however, one might have to use the more specific analyzers, develop custom tooling directly against the SSA, or use $emmle.

Dynamic analysis

Once static analysis has been performed and the results have been reviewed, dynamic analysis techniques are typically the next step for deeper results. Due to Go’s memory safety, the problems normally found with dynamic analysis are those that result in a hard crash or an invalidation of program state. Various tools and approaches have been built to help identify these types of issues within the Go ecosystem. Additionally, it’s possible to retrofit existing language-agnostic tooling for the dynamic testing of Go software, which we show next.


The best-known dynamic testing tool in the Go space is likely Dimitry Vyukov’s implementation of dvyukov/go-fuzz. This tool allows you to quickly and effectively implement mutational fuzzing. It even has an extensive wall of trophies. More advanced users may also find the distributed fuzzing and libFuzzer support useful when hunting for bugs.

Google also produced a more primitive fuzzer with a confusingly similar name, google/gofuzz, that assists users by initializing structures with random values. Unlike Dimitry’s go-fuzz, Google’s gofuzz does not generate a harness or assist with storing crash output, fuzzed input, or any other type of information. While this can be a downside for testing some targets, it makes for a lightweight and extensible framework.

For the sake of brevity, we refer you to examples of both tools in their respective READMEs.

Property testing

Diverging from more traditional fuzzing approaches, Go’s testing package (typically used for unit and integration testing) provides the testing/quick sub-package for “black box testing” of Go functions. In other terms, it is a basic primitive for property testing. Given a function and generator, the package can be used to build a harness to test for potential property violations given the range of the input generator. The following example is pulled directly from the documentation.

func TestOddMultipleOfThree(t *testing.T) {	f := func(x int) bool {		y := OddMultipleOfThree(x)		return y%2 == 1 && y%3 == 0	}	if err := quick.Check(f, nil); err != nil {		t.Error(err)	}}

Figure 4: The OddMultipleOfThree function is being tested, where its return value should always be an odd multiple of three. If it’s not, the f function will return false and the property will be violated. This is detected by the quick.Check function.

While the functionality provided by this package is acceptable for simple applications of property testing, important properties do not often fit well into such a basic interface. To address these shortcomings, the leanovate/gopter framework was born. Gopter provides a wide variety of generators for the common Go types, and has helpers to assist you in creating your own generators compatible with Gopter. Stateful tests are also supported through the gopter/commands sub-package, which is useful for testing that properties hold across sequences of actions. Compounding this, when a property is violated, Gopter shrinks the generated inputs. See a brief example of property tests with input shrinking in the output below.

package main_testimport (  ""  ""  ""  "math"  "testing")type Compute struct {  A uint32  B uint32}func (c *Compute) CoerceInt () { c.A = c.A % 10; c.B = c.B % 10; }func (c Compute) Add () uint32 { return c.A + c.B }func (c Compute) Subtract () uint32 { return c.A - c.B }func (c Compute) Divide () uint32 { return c.A / c.B }func (c Compute) Multiply () uint32 { return c.A * c.B }func TestCompute(t *testing.T) {  parameters := gopter.DefaultTestParameters()  parameters.Rng.Seed(1234) // Just for this example to generate reproducible results  properties := gopter.NewProperties(parameters)  properties.Property("Add should never fail.", prop.ForAll(    func(a uint32, b uint32) bool {      inpCompute := Compute{A: a, B: b}      inpCompute.CoerceInt()      inpCompute.Add()      return true    },    gen.UInt32Range(0, math.MaxUint32),    gen.UInt32Range(0, math.MaxUint32),  ))  properties.Property("Subtract should never fail.", prop.ForAll(    func(a uint32, b uint32) bool {      inpCompute := Compute{A: a, B: b}      inpCompute.CoerceInt()      inpCompute.Subtract()      return true    },    gen.UInt32Range(0, math.MaxUint32),    gen.UInt32Range(0, math.MaxUint32),  ))    properties.Property("Multiply should never fail.", prop.ForAll(    func(a uint32, b uint32) bool {      inpCompute := Compute{A: a, B: b}      inpCompute.CoerceInt()      inpCompute.Multiply()      return true    },    gen.UInt32Range(0, math.MaxUint32),    gen.UInt32Range(0, math.MaxUint32),  ))  properties.Property("Divide should never fail.", prop.ForAll(    func(a uint32, b uint32) bool {      inpCompute := Compute{A: a, B: b}      inpCompute.CoerceInt()      inpCompute.Divide()      return true    },    gen.UInt32Range(0, math.MaxUint32),    gen.UInt32Range(0, math.MaxUint32),  ))  properties.TestingRun(t)}

Figure 5: The testing harness for the Compute structure.

user@host:~/Desktop/gopter_math$ go test+ Add should never fail.: OK, passed 100 tests.Elapsed time: 253.291µs+ Subtract should never fail.: OK, passed 100 tests.Elapsed time: 203.55µs+ Multiply should never fail.: OK, passed 100 tests.Elapsed time: 203.464µs! Divide should never fail.: Error on property evaluation after 1 passed   tests: Check paniced: runtime error: integer divide by zerogoroutine 5 [running]:runtime/debug.Stack(0x5583a0, 0xc0000ccd80, 0xc00009d580)	/usr/lib/go-1.12/src/runtime/debug/stack.go:24	/home/user/go/src/  o:43 +0xebpanic(0x554480, 0x6aa440)	/usr/lib/go-1.12/src/runtime/panic.go:522 +0x1b5_/home/user/Desktop/gopter_math_test.Compute.Divide(...)	/home/user/Desktop/gopter_math/main_test.go:18_/home/user/Desktop/gopter_math_test.TestCompute.func4(0x0, 0x0)	/home/user/Desktop/gopter_math/main_test.go:63 +0x3d# <snip for brevity>ARG_0: 0ARG_0_ORIGINAL (1 shrinks): 117380812ARG_1: 0ARG_1_ORIGINAL (1 shrinks): 3287875120Elapsed time: 183.113µs--- FAIL: TestCompute (0.00s)    properties.go:57: failed with initial seed: 1568637945819043624FAILexit status 1FAIL	_/home/user/Desktop/gopter_math	0.004s

Figure 6: Executing the test harness and observing the output of the property tests, where Divide fails.

Fault injection

Fault injection has been surprisingly effective when attacking Go systems. The most common mistakes we found using this method involve the handling of the error type. Since error is only a type in Go, when it is returned it does not change a program’s execution flow on it’s own like a panic statement would. We identify such bugs by enforcing errors from the lowest level: the kernel. Because Go produces static binaries, faults must be injected without LD_PRELOAD. One of our tools, KRF, allows us to do exactly this.

During our recent assessment of the Kubernetes codebase, the use of KRF provided a finding deep inside a vendored dependency, simply by randomly faulting read and write system calls spawned by a process and its children. This technique was effective against the Kubelet, which commonly interfaces with the underlying system. The bug was triggered when the ionice command was faulted, producing no output to STDOUT and sending an error to STDERR. After the error was logged, execution continued instead of returning the error in STDERR to the caller. This results in STDOUT later being indexed, causing an index out of range runtime panic.

E0320 19:31:54.493854    6450 fs.go:591] Failed to read from stdout for cmd [ionice -c3 nice -n 19 du -s /var/lib/docker/overlay2/bbfc9596c0b12fb31c70db5ffdb78f47af303247bea7b93eee2cbf9062e307d8/diff] - read |0: bad file descriptorpanic: runtime error: index out of rangegoroutine 289 [running], 0x5e, 0x1bf08eb000, 0x1, 0x0, 0xc0011a7188)    /workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/*RealFsInfo).GetDirDiskUsage(0xc000bdbb60, 0xc001192c60, 0x5e, 0x1bf08eb000, 0x0, 0x0, 0x0)    /workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/*realFsHandler).update(0xc000ee7560, 0x0, 0x0)    /workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/*realFsHandler).trackUsage(0xc000ee7560)    /workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/ +0x13bcreated*realFsHandler).Start    /workspace/anago-v1.13.4-beta.0.55+c27b913fddd1a6/src/ +0x3f

Figure 7: The shortened callstack of the resulting Kubelet panic.

stdoutb, souterr := ioutil.ReadAll(stdoutp)if souterr != nil {	klog.Errorf("Failed to read from stdout for cmd %v - %v", cmd.Args, souterr)}

Figure 8: The logging of STDERR without returning the error to the caller.

usageInKb, err := strconv.ParseUint(strings.Fields(stdout)[0], 10, 64)

Figure 9: The attempted indexing of STDOUT, even though it is empty. This is the cause of the runtime panic.

For a more complete walkthrough containing reproduction steps, our Kubernetes Final Report details the use of KRF against the Kubelet in Appendix G (pg. 109).

Go’s compiler also allows instrumentation to be included in a binary, which permits detection of race conditions at runtime. This is extremely useful for identifying potentially exploitable races as an attacker, but it can also be leveraged to identify incorrect handling of defer, panic, and recover. We built trailofbits/on-edge to do exactly this: Identify global state changes between a function entrypoint and the point at which a function panics, and exfiltrate this information through the Go race detector. More in-depth use of OnEdge can be found in our previous blog post, “Panicking the Right Way in Go.”

In practice, we recommend using:

All of these tools, with the exception of KRF, require a bit of effort to use in practice.

Using the compiler to our advantage

The Go compiler has many built-in features and directives that aid in finding bugs. These features are hidden in and throughout various switches, and require a bit of configuration for our purposes.

Subverting the type system

Sometimes when attempting to test the functionality of a system, the exported functions aren’t what we want to test. Getting testable access to the desired functions may require renaming a lot of them so they can be exported, which can be burdensome. To help address this problem, the build directives of the compiler can be used to perform name linking, accessing controls provided by the export system. As an example of this functionality, the program below (graciously extracted from a Stack Overflow answer) accesses the unexported reflect.typelinks function and subsequently iterates the type link table to identify types present in the compiled program.

package mainimport (	"fmt"	"reflect"	"unsafe")func Typelinks() (sections []unsafe.Pointer, offset [][]int32) {	return typelinks()}//go:linkname typelinks reflect.typelinksfunc typelinks() (sections []unsafe.Pointer, offset [][]int32)func Add(p unsafe.Pointer, x uintptr, whySafe string) unsafe.Pointer {	return add(p, x, whySafe)}//go:linkname add reflect.addfunc add(p unsafe.Pointer, x uintptr, whySafe string) unsafe.Pointerfunc main() {	sections, offsets := Typelinks()	for i, base := range sections {		for _, offset := range offsets[i] {			typeAddr := Add(base, uintptr(offset), "")			typ := reflect.TypeOf(*(*interface{})(unsafe.Pointer(&typeAddr)))			fmt.Println(typ)		}	}}

Figure 10: A generalized version of the Stack Overflow answer, using the linkname build directive.

$ go run main.go **reflect.rtype**runtime._defer**runtime._type**runtime.funcval**runtime.g**runtime.hchan**runtime.heapArena**runtime.itab**runtime.mcache**runtime.moduledata**runtime.mspan**runtime.notInHeap**runtime.p**runtime.special**runtime.sudog**runtime.treapNode**sync.entry**sync.poolChainElt**syscall.Dirent**uint8...

Figure 11: The output of the typelinks table.

In situations where you need even more granular control at runtime (i.e., more than just the linkname directive), you can write in Go’s intermediate assembly and include it during compilation. While it may be incomplete and slightly out of date in some places, the teh-cmc/go-internals repository provides a great introduction to how Go assembles functions.

Compiler-generated coverage maps

To help with testing, the Go compiler can perform preprocessing to generate coverage information. This is intended for identifying unit and integration testing coverage information, but we can also use it to identify coverage generated by our fuzzing and property testing. Filippo Valsorda provides a simple example of this in a blog post.

Type-width safety

Go has support for automatically determining the size of integers and floating point numbers based on the target platform. However, it also allows for fixed-width definitions, such as int32 and int64. When mixing both automatic and fixed-width sizes, there are opportunities for incorrect assumptions about behaviour across multiple target platforms.

Testing against both 32-bit and 64-bit platform builds of a target will help identify platform-specific problems. These problems tend to be found in areas performing validation, decoding, or type conversion, where improper assumption about source and destination type properties are made. Examples of this were identified in the Kubernetes security assessment, specifically TOB-K8S-015: Overflows when using strconv.Atoi and downcasting the result (pg. 42 in the Kubernetes Final Report), with an example inlined below.

// updatePodContainers updates PodSpec.Containers.Ports with passed parameters.func updatePodPorts(params map[string]string, podSpec *v1.PodSpec) (err error) {    port := -1    hostPort := -1    if len(params["port"]) > 0 {        port, err = strconv.Atoi(params["port"]) // <-- this should parse port as strconv.ParseUint(params["port"], 10, 16) if err != nil { return err } } // (...) // Don't include the port if it was not specified. if len(params["port"]) > 0 {        podSpec.Containers[0].Ports = []v1.ContainerPort{            {                ContainerPort: int32(port), // <-- this should later just be uint16(port)            },        }...

Figure 12: An example of downcasting to a fixed-width integer from an automatic-width integer (returned by Atoi).

root@k8s-1:/home/vagrant# kubectl expose deployment nginx-deployment --port 4294967377 --target-port 4294967376E0402 09:25:31.888983    3625 intstr.go:61] value: 4294967376 overflows int32goroutine 1 [running]:runtime/debug.Stack(0xc000e54eb8, 0xc4f1e9b8, 0xa3ce32e2a3d43b34)	/usr/local/go/src/runtime/debug/stack.go:24, 0xa, 0x100000050, 0x0, 0x0)...service/nginx-deployment exposed

Figure 13: The resulting overflow from incorrect type-width assumptions.

In practice, the type system subversion is rarely necessary. Most interesting targets for testing are already exported, available through traditional imports. We recommend using this only when helpers and similar unexported functions are required for testing. As for testing type-width safety, we recommend compiling against all targets when possible, even if it is not directly supported, since problems may be more apparent on different targets. Finally, we recommend generating coverage reports on projects with unit and integration tests, at a minimum. It helps identify areas that are not directly tested, which can be prioritized for review.

A note about dependencies

In languages such as JavaScript and Rust, dependency managers have built-in support for dependency auditing—scanning project dependencies for versions known to have vulnerabilities. In Go, no such tool exists, at least not in a publicly available and non-experimental state.

This lack likely stems from the fact that there are many different methods of dependency management: go-mod, go-get, vendored, etc. These various methods use radically different approaches, resulting in no straightforward way to universally identify dependencies and their versions. Furthermore, in some cases it is common for developers to subsequently modify their vendored dependency source code.

The problem of dependency management has progressed over the years of Go’s development, and most developers are moving towards the use of go mod. This allows dependencies to be tracked and versioned within a project through the go.mod file, opening the door for future dependency scanning efforts. An example of such an effort can be seen within the OWASP DependencyCheck tool, which has an experimental go mod plugin.


Ultimately, there are quite a few tools available for use within the Go ecosystem. Although mostly disparate, the various static analysis tools help identify “low hanging fruit” within a given project. When looking for deeper concerns, fuzzing, property testing, and fault injection tools are readily available. Compiler configuration subsequently augments the dynamic techniques, making it easier to build harnesses and evaluate their effectiveness.

Interested in seeing these techniques shake out bugs in your Go systems? Trail of Bits can make that happen. Do you want custom analysis built specifically for your organization? We do that too. Contact us!

*** This is a Security Bloggers Network syndicated blog from Trail of Bits Blog authored by Robert Tonic. Read the original post at: