Go 1.27.4 and Go 1.26.11 came out on 2026-11-10 with 12 security fixes, and they ask for two different jobs. Eleven of the fixes are in the toolchain: building a malicious package could run code on the machine doing the build. The twelfth is in net/http and can crash an HTTP/1 server that uses MaxBytesReader. The first is fixed by upgrading the Go on your CI runners and laptops. The second is only fixed once you rebuild and redeploy the server with the new toolchain.
Which Go versions get the fix
Only the two supported lines. The golang-announce post lists 1.27.4 and 1.26.11, and the Go release history states the policy: "Each major Go release is supported until there are two newer major releases." Go 1.27.0 shipped on 2026-08-19, and the last 1.25 release, go1.25.14, shipped the same day. If you're still on 1.25 or older, there is no patch coming; the fix is to move to 1.26.11 or 1.27.4.
We ran the examples below on Go 1.25.13 (an unpatched toolchain) and on Go 1.27.4 on macOS arm64, so you can see both sides of each fix.
The eleven toolchain fixes: building is executing
cgo lets a package pass compiler and linker flags through #cgo directives in its source. Because those flags come from whoever wrote the package, the go command checks them against an allowlist before handing them to the C compiler and linker. All eleven toolchain CVEs (CVE-2026-97033 through CVE-2026-97043) are ways around that check or around the linker's own input handling. The announcement describes every one of them the same way: building a malicious or crafted package leads to code execution, file writes or linker flag injection at build time. A few examples from the list:
--sysrootwas allowed, and it relocates GCC's and Clang's compiler subprograms, spec files and startup objects to a directory the attacker picks. Clang's-targetcould make it run helper tools from the working directory.- On macOS,
-l to_libraryin#cgo LDFLAGSis recombined by the Apple linker into-lto_library, which loads an arbitrary LTO plugin. .sysofiles and other linker inputs weren't validated, so they could carry GNU ld linker scripts, Windows.drectvesections, Mach-OLC_LINKER_OPTIONcommands or LTO IR.#cgo pkg-config:could load.pcfiles straight from the package directory.- Commas in
//go:cgo_*compiler directives could split one linker argument into several.
The --sysroot case is easy to see. This package passes a harmless sysroot to the C compiler:
package main
/*
#cgo CFLAGS: --sysroot=/nonexistent
int answer(void) { return 42; }
*/
import "C"
import "fmt"
func main() { fmt.Println(C.answer()) }
On Go 1.25.13, go build accepts it without a word, and go build -x shows --sysroot=/nonexistent going straight to cc. On Go 1.27.4 the go command refuses the flag:
$ go version
go version go1.27.4 darwin/arm64
$ go build -o /dev/null ./sysroot
example.com/cgoflags/sysroot: invalid flag in #cgo CFLAGS: --sysroot=/nonexistent (see https://go.dev/s/invalidflag)
The important question is who builds untrusted code, and the honest answer is nearly everyone. Every dependency in your module graph is code you compile without reading. So is the fork in a pull request that your CI builds, and the tool you install with go install example.com/tool@latest. Every trigger in the announcement is a compile or link step, so go build, go test, go run and go install are the commands to worry about. Treat every build host as exposed until its toolchain is upgraded, with shared CI runners first, since they hold secrets and build other people's branches.
Don't lean on CGO_ENABLED=0 as a stopgap. The announcement doesn't offer it as a mitigation, and two of the issues don't need cgo source: the .syso bug notes that unrecognized .syso formats "forced external linking in pure-Go builds", and the two-argument //go:cgo_import_dynamic directive "is permitted in non-cgo Go source files".
The fixes can also break a legitimate build that relied on one of the newly rejected flags, such as --sysroot in a cross-compiling setup. The documented escape hatch, the CGO_CFLAGS_ALLOW variable, still works. In the 1.27.4 source, cmd/go/internal/work/security.go checks CGO_CFLAGS_ALLOW and its LDFLAGS and CXXFLAGS siblings before its list of invalid flags, so CGO_CFLAGS_ALLOW='--sysroot=.*' lets that flag through again (we read this in the source rather than ran it). Set it only in the builds that need it, because it re-opens exactly the hole the fix closed.
The net/http crash: MaxBytesReader and concurrent header writes
CVE-2026-97054 is the one that affects running services. When a body wrapped by MaxBytesReader (or MaxBytesHandler) goes over its limit, the server marks the connection to close. In the 1.25.13 source that is requestTooLarge in net/http/server.go, and it writes to the response header map from whichever goroutine is reading the body:
func (w *response) requestTooLarge() {
w.closeAfterReply = true
w.requestBodyLimitHit = true
if !w.wroteHeader {
w.Header().Set("Connection", "close")
}
}
If the handler reads the body on one goroutine while setting response headers on another, that's two goroutines writing one map. Your code never touches headers from the reading goroutine, so it looks race-free, but it isn't. A streaming proxy is the usual shape. Here's a minimal version:
package main
import (
"bytes"
"fmt"
"io"
"net"
"net/http"
"strconv"
"sync"
)
// upload reads the request body in a goroutine (as a streaming proxy
// would) while the handler goroutine fills in response headers.
func upload(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
io.Copy(io.Discard, r.Body) // hits the MaxBytesHandler limit
}()
for i := 0; i < 1000; i++ {
w.Header().Set("X-Upstream-"+strconv.Itoa(i), "ok")
}
wg.Wait()
w.WriteHeader(http.StatusRequestEntityTooLarge)
}
func main() {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
panic(err)
}
h := http.MaxBytesHandler(http.HandlerFunc(upload), 1024)
go http.Serve(ln, h)
url := "http://" + ln.Addr().String() + "/upload"
body := bytes.Repeat([]byte("a"), 64*1024)
for i := 0; i < 200; i++ {
resp, err := http.Post(url, "application/octet-stream", bytes.NewReader(body))
if err != nil {
fmt.Println("request", i, "failed:", err)
continue
}
resp.Body.Close()
}
fmt.Println("done")
}
When we ran it with go run . on Go 1.25.13, it never got to done. The process died (file paths trimmed from the stack):
fatal error: concurrent map writes
goroutine 17 [running]:
internal/runtime/maps.fatal({0x1006b5b35?, 0x1004d500c?})
net/textproto.MIMEHeader.Set(...)
net/http.Header.Set(...)
main.upload({0x1007c2888, 0x140001ba000}, 0x140001a6140)
...
exit status 2
That is worse than an ordinary panic. The Handler documentation says the server recovers a panic in ServeHTTP and treats it as isolated to the request, but a concurrent map write isn't a panic. Since Go 1.6, when the runtime detects it, "it prints a diagnosis and crashes the program". An oversized request that hits the race takes the whole process down, along with every other request it was serving.
The race detector names both writers. Under go run -race . on 1.25.13, one of the reports reads:
WARNING: DATA RACE
Write at 0x00c0000942d0 by goroutine 14:
runtime.mapaccess2_faststr()
net/textproto.MIMEHeader.Set()
net/http.Header.Set()
net/http.(*response).requestTooLarge()
net/http.(*maxBytesReader).Read()
...
Previous write at 0x00c0000942d0 by goroutine 10:
runtime.mapaccess2_faststr()
net/textproto.MIMEHeader.Set()
net/http.Header.Set()
main.upload()
The same program under go run -race . on Go 1.27.4 prints done after all 200 requests, with no race report.
If you can't redeploy right away, take the header write out of the reading goroutine by enforcing the limit yourself. This variant uses io.LimitReader and sets Connection: close from the handler goroutine once the reader is done. On 1.25.13 with -race, it answered all 200 oversized requests with 413 and produced no race report:
const limit = 1024
func upload(w http.ResponseWriter, r *http.Request) {
var wg sync.WaitGroup
var n int64
wg.Add(1)
go func() {
defer wg.Done()
n, _ = io.Copy(io.Discard, io.LimitReader(r.Body, limit+1))
}()
for i := 0; i < 1000; i++ {
w.Header().Set("X-Upstream-"+strconv.Itoa(i), "ok")
}
wg.Wait()
if n > limit {
w.Header().Set("Connection", "close")
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusOK)
}
Treat this as a stopgap. Per the announcement, only handlers that read the body concurrently with writing the response header are exposed, and the net/http docs already advise that "cautious handlers should read the Request.Body first, and then reply".
Rolling it out
For the toolchain fixes, the useful lever is the toolchain line in go.mod. It sets a minimum suggested toolchain without raising the module's language version:
$ go get [email protected]
go: added toolchain go1.27.4
$ go version
go version go1.27.4 darwin/arm64
That second command ran on a machine whose installed Go is 1.25.13. Under the default GOTOOLCHAIN=auto, the go command downloaded and switched to go1.27.4 on its own. The toolchain documentation spells out the limit: with GOTOOLCHAIN=local "the go command always runs the bundled Go toolchain", and repackaged toolchains may change the default. Check what your CI image sets, and print go version in the build log so you can tell which toolchain actually ran.
For the net/http fix, what matters is the toolchain each deployed binary was built with, not what's installed today. go version reads that from the binary:
$ go version ./uploadsvc
./uploadsvc: go1.25.13
A binary that reports anything other than go1.26.11 or later on 1.26, or go1.27.4 or later on 1.27, hasn't got the fix, and upgrading the toolchain doesn't help it until it's rebuilt.
Don't wait for govulncheck to flag it. When we downloaded the Go vulnerability database index on 2026-11-11, its 4,789 entries had nothing modified after 2026-11-05 and no alias for any of these 12 CVEs. Until the entries land, checking the Go version yourself is the reliable signal.
No comment for this article.