Go 1.27.4 security release: the cgo build fixes and the net/http crash

Summary

Go 1.27.4 and 1.26.11 address 12 security fixes: eleven toolchain flaws let crafted cgo directives and linker inputs bypass flag validation and execute code, write files, or inject linker flags at build time, so every build host and CI runner running go build, test, run, or install should be upgraded, with CGO_CFLAGS_ALLOW used only as a narrow escape hatch for legitimate newly rejected flags. The twelfth, in net/http, is a concurrent map write when MaxBytesReader marks an oversized request and a handler reads the body while another goroutine sets response headers, causing a fatal process crash rather than a recoverable panic; rebuilding and redeploying with the patched toolchain is the real fix. A temporary workaround is to enforce limits with io.LimitReader and set Connection: close from the handler goroutine, but deployed binaries must be checked with go version to confirm they were built with 1.26.11+ or 1.27.4+. Since the vulnerability database may not yet list these CVEs, verify the toolchain and binary versions directly rather than waiting for govulncheck.

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:

  • --sysroot was allowed, and it relocates GCC's and Clang's compiler subprograms, spec files and startup objects to a directory the attacker picks. Clang's -target could make it run helper tools from the working directory.
  • On macOS, -l to_library in #cgo LDFLAGS is recombined by the Apple linker into -lto_library, which loads an arbitrary LTO plugin.
  • .syso files and other linker inputs weren't validated, so they could carry GNU ld linker scripts, Windows .drectve sections, Mach-O LC_LINKER_OPTION commands or LTO IR.
  • #cgo pkg-config: could load .pc files 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.

GO 1.27.4 GO 1.26.11 CGO NET/HTTP MAXBYTESREADER GOTOOLCHAIN CVE-2026-97054

  RELATED

  COMMENTS

0

No comment for this article.