Safe use of unsafe.Pointer
Package unsafe
provides an escape hatch from
Go’s type system, enabling interactions with low-level and system call APIs, in
a manner similar to C programs. However, unsafe
has several rules which must
be followed in order to perform these interactions in a sane way. It’s easy to
make subtle mistakes when writing unsafe
code,
but these mistakes can often be avoided.
This blog post will introduce some of the current and upcoming Go tooling that can
verify safe usage of the unsafe.Pointer
type in your Go programs. If you have
not worked with unsafe
before, I recommend reading my
previous Gopher Academy Advent series blog
on the topic.
Extra care and caution must be taken whenever introducing unsafe
to a code
base, but these diagnostic tools can help you solve problems before they lead to
a major bug or a possible security flaw in your application.
Compile-time verification with go vet
For several years now, the go vet
tool has had the ability to check for
invalid conversions between the unsafe.Pointer
and uintptr
types.
Let’s take a look at an example program. Suppose we would like to use pointer arithmetic to iterate over and print each element of an array:
|
|
At first glance, this program appears to work as expected, and we can see each of the array’s elements printed to our terminal:
1 2 |
$ go run main.go 1 2 3 |
However, there is a subtle flaw in this program. What does go vet
have to say?
1 2 3 |
$ go vet . # github.com/mdlayher/example ./main.go:20:33: possible misuse of unsafe.Pointer |
In order to understand this error, we must consult the rules
of the unsafe.Pointer
type:
Converting a Pointer to a uintptr produces the memory address of the value pointed at, as an integer. The usual use for such a uintptr is to print it.
Conversion of a uintptr back to Pointer is not valid in general.
A uintptr is an integer, not a reference. Converting a Pointer to a uintptr creates an integer value with no pointer semantics. Even if a uintptr holds the address of some object, the garbage collector will not update that uintptr’s value if the object moves, nor will that uintptr keep the object from being reclaimed.
We can isolate the offending portion of our program as follows:
|
|
Because we store the uintptr
value in p
but do not immediately make use of
that value, it’s possible that when a garbage collection occurs, the address
(now stored in p
as a uintptr integer) will no longer be valid!
Let’s assume this scenario has occurred and that p
no longer points at a
uint32
. Perhaps when we reinterpret p
as a pointer, the memory pointed at is
now being used to store a user’s authentication credentials or a TLS private key.
We’ve introduced a potential security flaw in our application and could easily
leak sensitive material to an attacker through a normal channel such as stdout
or an HTTP API’s response body.
In effect, once we’ve converted an unsafe.Pointer
to uintptr
, we cannot
safely convert it back to unsafe.Pointer
, with the exception of one special
case:
If p points into an allocated object, it can be advanced through the object by conversion to uintptr, addition of an offset, and conversion back to Pointer.
In order to perform this pointer arithmetic iteration logic safely, we must perform the type conversions and pointer arithmetic all at once:
|
|
This program produces the same result as before, but is now considered valid by
go vet
as well!
1 2 3 |
$ go run main.go 1 2 3 $ go vet . |
I don’t recommend using pointer arithmetic for iteration logic in this way, but it’s excellent that Go provides this escape hatch (and tooling for using it safely!) when it is truly needed.
Run-time verification with the Go compiler’s checkptr
debugging flag
The Go compiler recently gained support for a new debugging flag
which can instrument uses of unsafe.Pointer
to detect invalid usage patterns
at runtime. As of Go 1.13, this feature is unreleased, but can be used by
installing Go from tip:
1 2 3 4 5 6 7 8 9 |
$ go get golang.org/dl/gotip go: finding golang.org/dl latest ... $ gotip download Updating the go development tree... ... Success. You may now run 'gotip'! $ gotip version go version devel +8054b13 Thu Nov 28 15:16:27 2019 +0000 linux/amd64 |
Let’s review another example. Suppose we are passing a Go structure to a Linux kernel API which would typically accept a C union. One pattern for doing this is to have an overarching Go structure which contains a raw byte array (mimicking a C union), and then to create typed variants for possible argument combinations.
|
|
When we run this program on a stable version of Go (as of Go 1.13.4), we can see
that the first 8 bytes of the array contain our uint64
data in its native
endian format (little endian on my machine):
1 2 |
$ go run main.go []byte{0xff, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0} |
However, it turns out there is an issue with this program as well. If we attempt
to run the program on Go tip with the checkptr
debug flag, we will see:
1 2 3 4 5 6 7 |
$ gotip run -gcflags=all=-d=checkptr main.go panic: runtime error: unsafe pointer conversion goroutine 1 [running]: main.main() /home/matt/src/github.com/mdlayher/example/main.go:17 +0x60 exit status 2 |
This check is still quite new and as such does not provide much information beyond the “unsafe pointer conversion” panic message and a stack trace. But the stack trace does at least provide a hint that line 17 is suspect.
By casting a smaller structure into a larger one, we enable reading arbitrary
memory beyond the end of the smaller structure’s data! This is another way that
careless use of unsafe
could result in a security vulnerability in your
application.
In order to perform this operation safely, we have to make sure that we initialize the “union” structure first before copying data into it, so we can ensure that arbitrary memory is not accessed:
|
|
We can now run our updated program with the same flags as before, and we will see that the issue has been resolved:
1 2 |
$ gotip run -gcflags=all=-d=checkptr main.go []byte{0xff, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0} |
By removing the slicing operation from the fmt.Printf
call, we can verify that
the remainder of the byte array has been initialized to 0
bytes:
1 2 3 4 5 6 |
[32]uint8{ 0xff, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, } |
This is a very easy mistake to make, and in fact, I recently had to fix this
exact issue in code I wrote for the tests in x/sys/unix
!
I’ve written a fair amount of unsafe
code in Go, but even veteran programmers
can easily make mistakes. This is why these types of diagnostic tools are so
important!
Conclusion
Package unsafe
is a very powerful tool with a razor-sharp edge, but it should
not be feared. When interacting with Linux kernel system call APIs, it is often
necessary to resort to unsafe
code. Making effective use of tools such as
go vet
and the checkptr
compiler debugging flag is crucial in order to
ensure safety in your applications.
If you work with unsafe
code on a regular basis, I highly recommend joining
the #darkarts
channel on Gophers Slack.
There are a lot of veterans in that channel who have helped me learn how to make
effective use of unsafe
in my own applications.
If you have any questions, feel free to contact me! I’m mdlayher on Gophers Slack, GitHub and Twitter.
Special thanks to:
- Cuong Manh Le (@cuonglm) for his insight regarding the
=all
modifier for thecheckptr
debugging flag - Miki Tebeka (@tebeka) for review of this post