A couple of years ago we published a blog post with the intention to create a resource that code auditors and security-minded engineers can refer to when auditing or developing Golang projects.
In that post we covered some bug classes that we have often seen during our own Golang code auditing projects.
Thanks to the second law of thermodynamics time only keeps moving forward, and as any decently supported modern programming language, Go has seen a lot of changes and improvements since our first blog post.
So we decided to create this follow-up post with the same idea in mind.
Since out first post, automated tooling and AI assistants have raised the floor for code review: a lot of the obvious bugs now get caught on the first pass, by anyone. But that floor is also where most reviewers stop, because the tooling stops there too — the rules only flag what they already know to look for. What separates an experienced auditor is knowing the language's sharp edges that no rule covers yet, and going looking for them deliberately.
In this post, first we will start with a quick look at what changes have been made to the language to make security easier and more intuitive for folks (with no particular selection criteria on what we're going to cover).
Then we have a bunch of new footguns we would like to highlight, hopefully to the benefit of everyone auditing or developing Golang projects.
Finally, we are also releasing a couple of semgrep rules to close the gap regarding these risky coding patterns.
The rules can be found in our Semgrep rules repository.
With the introduction out of the way, first let's see what are some of the features that were introduced Golang with a major impact on its security landscape.
To start with something that is mildly concerning, pretty much everything from our previous blog post is still valid in 2026 - with one major caveat.
While path traversal is still a very real thing, over a year ago the release of Go 1.24 introduced a new set of APIs called os.Root.
Without repeating too much of what is very well explained in the Go team's blog post, these APIs are meant to stop path traversal altogether by creating a root directory and on the framework level disallowing reads and writes past this point. Everything below the root is still available, including child directories and symlinks (as long as they stay within the root).
While this is a very useful feature it wasn't without teething problems; however these were quickly fixed in Go 1.25.
In a similar fashion, the Go team has just concluded their math/rand improvement era that ran over the course of Go 1.20 to 1.26 (which is the current version at the time of publishing).
Go went from "passing nil to rsa.GenerateKey is silently catastrophic" to "it doesn't matter what you pass, the function will do the right thing."
This was achieved by tidying up math/rand to the point where developers have an explicit choice between PRNG and crypto/rand - as opposed to manually seeded generators, which are completely gone from the library at this point.
Additionally, the footgun of accidentally using an unsafe random when generating private keys has been removed by making sure that these functions ignore any io.Reader's passed to them and just use a secure internal random unconditionally.
Ok, that's enough preamble, time to dig into the actual footguns.
The subsections below highlight a handful of them, focusing on standard library features.
Recently the toolchain around Go has also seen a fair bit of security improvements due to increased scrutiny, however those will have to wait until part 3 of this blog post series.
Additionally, we are also not going to focus on popular third-party frameworks, only standard library features at this point.
Unlike most other languages Go's int and uint type have no fixed width - they are set at compilation time to the target: 64 bits on amd64 and arm64, 32 bits on 386, arm and WebAssembly.
This is a design choice in order to make efficient sizing choices; however because Go is cross-compilable this means that unless the developer is careful they could make wrong assumptions about the size of integers.
Go silently permits integer overflow and underflow without any runtime error or panic, which can introduce subtle and severe security vulnerabilities when not accounted for by developers.
As an example, strconv.Atoi, the classic string to integer conversion function returns a plain int, which means its range is quietly architecture-dependent.
While strconv.Atoi will error if the returning int is outside of its range, this can still cause silent wrapping issues downstream.
Paul Gerste's DEF CON 32 presentation, "SQL Injection Isn't Dead: Smuggling Queries at the Protocol Level", demonstrates the real-world impact of this issue.
Gerste identified several integer overflows in Go database driver libraries that enabled overflowing the length field for the corresponding wire protocol.
By overflowing length field, he was then able to demonstrate injecting new queries when the packet was sent to the backend database.
This class of vulnerability is not isolated to database drivers.
Integer overflows are a recurring problem in Go data serialisation libraries, where the length of an attribute is derived from an untrusted input that can result in the misinterpretation of data structure boundaries.
Take for example the github.com/tinylib/msgp package that implements the MessagePack binary serialisation format, where the specification states that the maximum length for Binary, String, Array and Map objects is (2^32)-1 bytes long.
A mistake that was found in github.com/tinylib/msgp was that they used Go's len function to retrieve the length of data to be serialised that was then cast using uint32(), as shown in the following code snippet.
github.com/tinylib/msgp@v1.5.0 - msgp/write.go
① Length of the input was string was cast using uint32().
② Writes the full length of the string and is not restricted to sz.
Go's len function returns a int type where int is a signed integer that is at least 32 bits in size, but on 64-bit systems it returns a 64-bit signed integer.
This enabled overflowing the length field that enables manipulating other sections of the data structure during the deserialisation process, as demonstrated in the following code and terminal output.
Example proof-of-concept using github.com/tinylib/msgp@v1.5.0
Output for the above code
To help address silent integer overflows, Trail of Bits have released go-panikint, an open-source tool that detects silent arithmetic overflows in Go programs at runtime.
As almost everything in Internet-world, HTTP headers are defined by a couple of RFCs - how accurately different implementations respect these is a whole other discussion.
One of the RFCs that is relevant for our current interests is RFC 2616, which defines a list of hop-by-hop headers.
These headers are - as the name suggests - only meaningful for a single connection, and as such should not be forwarded by proxies.
As a side note, while this RFC is now obsolete and was replaced by the one described below, there are still legacy code that respect this standard.
To make things "simpler", there is a newer RFC - RFC 7230, which is meant to replace RFC 2616 - that defines the Connection: header which should signal next hop recipients which headers are hop-by-hop and thus should be removed before forwarding the message.
Now to tie this to our Golang adventures: Go has a reverse proxy in the standard library, which can be found under httputil.ReverseProxy.
This reverse proxy is such a teacher's pet that it respects **both** RFCs, which is exactly the source of confusion.
The module also has a `Director` function which can be used to manage incoming requests.
A quick summary of how things works is really well explained in the GitHub issue outlining this footgun, but here it is copied verbatim:
Now let's consider the following code:
If an attacker knows these header names and specifies them in the Connection: header in a malicious request, they can force the authentication or transport encryption to be dropped:
While this is definitely an edge case, it's real enough to have caused advisories to be issued in the past: Hop-by-hop abuse to malform header mutator in Ory Oathkeeper.
The good news is that in Go 1.26 the Director function has been deprecated in favour of the Rewrite function, which receives the request after the hop-by-hop headers have been removed, making it a safe by default choice:
However, the Director was not removed for backwards compatibility, so any projects still using it remain vulnerable.
net/url StructsThe next footgun is a subtle one.
Sometimes we need to create a copy of a net/url struct when we are doing some processing on the URL but don't want the original struct to change.
So we would do something like this:
Seems fine at first.
But the problem is that u := redirectURL is not a copy of the struct as we'd expect, we are just copying a pointer.
So when we try to write to the "copy", such as u.RawQuery = q.Encode(), we are actually writing to the original struct!
This is a race condition and could result in security issues.
We could not find any publicly disclosed vulnerabilities as a direct result of this pattern, probably due to the following:
-race flag should catch this with a decent test suite.url struct in a shared state and mutate it at the same time, which is a developer mistake on top of a footgun.However, we wanted to include this pattern as it is still a live footgun in Go.
A safe pattern would be to dereference the pointer to copy the actual struct such as: u := *redirectURL
Or to create a fresh struct with only the relevant fields copied:
Go strings are length-delimited byte slices. Unlike C, there is no null terminator, and no character is special — a Go string can contain \x00 anywhere and the language will handle it correctly.
(As a side note, if you ever looked at a compiled Go binary and tried to find strings without tools, and haven't lost your sanity, I salute you.)
So len("admin\x00evil") will return 12, and "admin\x00evil" == "admin" will be false.
The footgun manifests at the boundary between Go and anything that uses C-style null-terminated strings: CGO, PAM, LDAP clients, database drivers with a C layer, or any system call that accepts a path or credential.
At that boundary, the string is silently truncated at the first null byte, and the Go layer is none the wiser.
Consider this classic authentication logic:
An attacker submits the username admin\x00 with the admin password. Go's equality check "admin\x00" == "admin" evaluates to false and the blocklist is bypassed.
The string is then passed to the underlying C library, which reads characters until it hits the null byte and sees the username as admin.
The C library authenticates the request against the admin account and returns success.
The Go application, which stored the full admin\x00 string in the session, may then make further decisions based on the raw string — decisions that other parts of the system, also going through C, resolve as admin.
The same class of bug appears in path handling, SQL identifiers passed via C drivers, and LDAP filter construction. Go's own file I/O is not affected — the syscall package rejects strings containing null bytes for path arguments — but any code that calls into C with a user-controlled string is potentially vulnerable.
The fix is to reject null bytes at trust boundaries before they reach any interop layer:
The broader principle is that Go's correct handling of null bytes does not protect you from the systems you interoperate with. Any string that crosses a CGO boundary should be treated as a C string for validation purposes, regardless of how Go itself would interpret it.
This next one will go a bit deeper into how Go works under the hood, but it is an interesting footgun so it should be worth following along.
The encoding/json package marshals custom types by checking whether they implement the json.Marshaler interface — specifically, whether they have a MarshalJSON() ([]byte, error) method.
The subtlety is in the method set rules: a method defined on a pointer receiver *T is only in the method set of *T, not of T.
A value of type T does not satisfy json.Marshaler even if *T does.
Most of the time this difference is invisible because the compiler automatically takes the address of addressable values when calling pointer-receiver methods.
The encoding/json package uses reflection however, not direct method calls, and reflection follows the method set rules strictly.
The result is that encoding a struct by value silently skips the custom marshaller on any field whose type uses a pointer receiver, while encoding a pointer to the same struct calls it correctly.
Ok, that was pretty dense, so let's look at an example:
① This call encodes sess by value. The json package sees the Token field as type RedactedToken, checks whether RedactedToken implements json.Marshaler but it does not (only *RedactedToken does), so it falls back to default string marshalling. The raw token is written to the response.
② This call encodes &sess; the package now encounters *RedactedToken using its reflection mechanism, which satisfies the interface, and the custom marshaller runs.
An engineer who tests with &sess and ships with sess, or receives a Session by value from a function and immediately encodes it will never see a test failure, because the output is valid JSON either way.
A security impact is present where custom marshallers are used for sanitisation: redacting credentials and PII from logs, masking card numbers in API responses, omitting internal fields from public endpoints.
All of those guarantees silently disappear when the containing struct is passed or encoded by value.
The fix is to make the receiver consistent with how the type will actually be encoded — either define MarshalJSON on the value receiver so it is in the method set of both T and *T, or store the field as a pointer (*RedactedToken) so the package always encounters the pointer type during reflection:
The underlying issue has been open since 2017 and remains unresolved.
It is the kind of bug that passes code review cleanly, produces no compiler warning, and only surfaces when the wrong call site is reached in production — at which point the sensitive data has already been serialised.
Cross-Site Request Forgery (CSRF) is a type of web security vulnerability where an attacker tricks a user’s browser into making an unwanted request to a website where the user is already authenticated.
CSRF issues have been a well-known vulnerability class for decades that are rarely observed lately due to security awareness and better protections, yet recent CSRF issues we have discovered have been in Golang web applications with a JSON REST API.
The root cause for these CSRF issues were due to the following conditions:
Missing net/http.CrossOriginProtection Protection
Golang introduced net/http.CrossOriginProtection in go v1.25.0, which is used as a wrapper around handlers that detects either the Sec-Fetch-Site request header or compares the Origin and Host headers to prevent CSRF attacks.
However, this is a recent addition to golang and most web frameworks and applications do not utilise this protection mechanism, either implementing their own CSRF checks or ignoring CSRF as a bug class.
Not Validating the Content-Type Request Header
Developers commonly assume that JSON API endpoints mitigate against CSRF attacks, due to browsers sending a preflight OPTIONS request for application/json content type request bodies.
However, a common oversight we have observed is validating the Content-Type request header when using the net/http server or other minimalistic web framework - such as go-chi.
Forgetting to validate the Content-Type request header enables the use of the text/plain or application/x-www-form-urlencoded content types to bypass preflight OPTIONS request for a cross-site request.
SameSite=None Attribute Set on a Session Cookie
A session cookie was set with the SameSite=None attribute, which enables the inclusion of the cookie in cross-site requests.
While this is a well-known misconfiguration, a footgun that we have identified in a popular dependency is that github.com/gorilla/sessions session manager sets the SameSite=None attribute by default, as shown in the following code snippet.
github.com/gorilla/sessions@v1.4.0 - store.go
① Sets the default SameSite attribute for session cookies to None.
Parsing Quirks with encoding/json
The encoding/json package ignores trailing bytes after parsing a JSON document.
This can be combined with a missing Content-Type validation check to build a text/plain CSRF form attack that is then parsed as a valid JSON request body.
To demonstrate these footguns, a go-chi web application was developed that had the following endpoint that was only accessible to an admin user.
Cookie session management was handled using github.com/gorilla/sessions with default settings, as shown in the following code snippet for initialising a user's session.
The below CSRF proof-of-concept performs a form POST attack that is encoded as text/plain, but the body is parsed as valid JSON as shown in the following request and response.
The sent CSRF request contains a trailing =, but the json.Decoder would ignore the trailing byte
Response for the above attack, confirming the exploitation of the CSRF issue
While this is still not the complete picture of footguns in Go, we hope this post will further serve as a resource for security auditors and engineers to things to look out for in their projects.
We also hope that the Semgrep rules we're releasing with this blog post will be useful to find some beautifully obscure bugs.
As we can see, Go is an ever-changing language, with a lot of positive things happening in its security space.
However, it is also a language made by humans, which means there will be interesting and potentially dangerous patterns that developers need to look out for.
Here at elttam we live in this grey area, and we love to find these nuggets of weird code constructs.
Next time we will look at Go's toolchain and some of the most popular Go projects, as it is not just the standard library that can hold dangers, but the wider ecosystem around Go with its ever increasing complexity.