Articles

Two scanners agreed, and the code was not in my binary

A scan of the web server my project builds reported a High-severity vulnerability in its HTTP/2 library. A second scanner, one that checks which functions a program actually calls, confirmed the vulnerability and found four more. I drafted the release note warning operators that anyone on the internet could take down their dashboard. I then checked the binary and discovered that the vulnerable server wasn't present.

12 October 2026 路 8 min read 路 PerspectiveGraph 1.37.0 路 Go, Trivy, govulncheck


In short

  • Two scanners reported HTTP/2 vulnerabilities in a binary that doesn't contain the vulnerable server: one reads module versions, the other reads function names, but in this case neither is what actually runs.
  • I believed them, and wrote a warning that overestimated the risk. Two commands on the binary showed which HTTP/2 server was actually present.
  • I updated the library anyway. A result that is incorrect for a reason only I can explain is still visible on every operator's screen.

What was reported

PerspectiveGraph has a recipe for running in production on a single machine. It involves using Caddy, a web server written in Go, in front of the dashboard to serve HTTPS connections, and compiling Caddy from source using my chosen Go version. Every image created by the project is scanned on every change, and on 9 October the scan of that one failed:

usr/bin/caddy (gobinary)
Total: 1 (HIGH: 1, CRITICAL: 0)

Library            Vulnerability    Severity  Installed  Fixed
golang.org/x/net   CVE-2026-78669   HIGH      v0.59.0    0.60.0
Trivy on the image, at the threshold that fails the build (trimmed).

Caddy 2.11.7, the newest release, depends on version 0.59.0 of Go's extended networking library, golang.org/x/net. This version contains a known vulnerability in the HTTP/2 code that allows a client to overload a server's CPU. There was no newer version of Caddy available to migrate to.

The second opinion agreed

A scanner like Trivy compares versions: this library, in this version, has this advisory. It doesn't know if the program uses the affected part. Go has a tool that does, govulncheck: it tracks the calls a program can make and reports an advisory only if the program can reach one of the functions listed as affected. I ran it on the same build, expecting it to narrow the finding. It expanded it:

Vulnerability #1: GO-2026-6617  HTTP/2 server crash due to HPACK encoder race
Vulnerability #2: GO-2026-6612  Double flow control refund on HTTP/2 server streams
Vulnerability #3: GO-2026-6611  Excessive CPU consumption from repeated initial window changes
Vulnerability #4: GO-2026-6610  HTTP/2 transport accepts malformed framing-related headers
Vulnerability #5: GO-2026-6603  HTTP/2 server memory exhaustion due to Trailer headers
    Found in: golang.org/x/net@v0.59.0
    Fixed in: golang.org/x/net@v0.60.0
    Example traces found:
      #1: main.go:16:15: caddy.main calls cmd.Main, which eventually calls http2.ConfigureServer

Your code is affected by 5 vulnerabilities from 1 module.
govulncheck on the same build (one line per advisory, one trace kept).

Five advisories, three of which involved ways a client could crash or overload a server, and a trace from Caddy's own main to the function that sets up the HTTP/2 server. Two tools that work differently had reached the same conclusion.

What I wrote first

So I believed it. The fix was already in: Caddy is now built from a small Go module of the project's own, which has the same requirements as Caddy 2.11.7, except for golang.org/x/net 0.60.0. All that remained was the note informing operators of the previous release of the risks they were exposed to. My draft stated that Caddy is the only service the recipe publishes to the internet, and that three of the five advisories represented a way for anyone who could reach it to crash the dashboard.

That sentence makes a statement about a running program. All I had was a list of dependencies and a graph of function calls. Before publishing it, I took a look at the program.

What is in the binary

With Go 1.27, golang.org/x/net/http2 has become a thin layer over the HTTP/2 implementation in the standard library. The package still contains its own server and client, but the first line of each file decides whether it is compiled, and for Go 1.27 and later the answer is no:

server.go            //go:build !(go1.27 && !http2legacy)
server_wrap.go       //go:build go1.27 && !http2legacy
transport.go         //go:build !(go1.27 && !http2legacy)
transport_wrap.go    //go:build go1.27 && !http2legacy
Build constraints in golang.org/x/net 0.59.0: the server and the client, or the wrappers around the standard library's.

The recipe compiles with Go 1.27.2. Asking Go which files it uses for that package confirms it:

$ go list -f '{{join .GoFiles "\n"}}' golang.org/x/net/http2 | grep -E 'server|transport'
server_common.go
server_wrap.go
transport_common.go
transport_wrap.go

The server.go file, where the vulnerable server resides, isn't listed. And the binary says the same thing, in a way that doesn't depend on my correct interpretation of build constraints. I built it from the sources and with the Go version used by the previous release, leaving in the symbol table that the release removes:

$ go tool nm caddy | grep -c 'golang.org/x/net/http2.(\*serverConn)'
0
$ go tool nm caddy | grep -c 'net/http/internal/http2.(\*serverConn)'
86
Symbols of the type that serves an HTTP/2 connection: none from golang.org/x/net, 86 from the standard library.

The HTTP/2 server responding on port 443 was the one from the standard library. The same five advisories apply to this one, and Go 1.27.2 fixes them. The server that was troubling the scanners had never been compiled in.

Why two tools agreed on something untrue

Neither tool was flawed. Each answered the question it poses, and I interpreted both answers as a third question.

Trivy reads the list of modules present in a Go binary and compares the versions. The module was present, at the affected version. This is a true statement about the module.

govulncheck goes further, and it's fair to trust it more. But what it follows is names. The advisory lists the affected package functions, and ConfigureServer is one of them. Caddy calls ConfigureServer. With Go 1.27, the function with that name is still present, in a file compiled either way, and all it does is pass the work to the standard library. The name is reachable; the code described in the advisory isn't behind it.

It wasn't all a false alarm. Two of the files the fix changed are compiled into every build: the one that reads HTTP/2 frames, and the one that manages flow control accounts. Libraries that speak HTTP/2 themselves, like gRPC, use that frame reader. The recipe's Caddy uses HTTP/2 via the standard library and doesn't go through that reader, but the unpatched code was present in the binary, and I wouldn't call it unreachable for every Caddy configuration.

Fixed anyway

I kept the update. An operator running the scans sees a High finding, and the real answer - "that server is excluded by a build constraint, and the one that runs is patched" - is an argument they would have to trust me on, or repeat at every scan. There's a format for formally stating this, a VEX statement with the justification vulnerable_code_not_present, and Trivy's output itself suggested it. I think it's the right tool when no fix exists. In this case, one existed, and moving a dependency by a version is cheaper for everyone than maintaining a statement.

What has changed is the note. It now states what the scanners report, what the binary contains, how it was verified, that there is no known way to exploit the recipe as distributed, and that it has been fixed anyway. It also tells operators the only thing they need to do, which the first draft omitted: the Caddy image is built on their machine, so pulling the new release does not replace it.

The same mistake, one level higher

The next day, the public page for the project's Helm chart showed thirteen vulnerabilities found in the Go standard library at 1.27.1, two of which were High. My first thought was that some image of mine was still compiled with the older Go. This wasn't the case. The report is a list for the entire chart, and must be opened image by image: each one of the thirteen vulnerabilities was in the binary of NATS, the message bus used by the chart, which the maintainers had compiled with Go 1.27.1. Their next release, compiled with 1.27.2, had just been released, and the corresponding image hadn't yet been published. This time, the vulnerabilities were real. They simply weren't in my code, and the solution was to use their release, not modify mine.

Update, 1.38.1. NATS published the 2.15.1 image on 12 October, and 1.38.1 deploys it. The thirteen findings are gone from the chart's report. What is left is one advisory against golang.org/x/crypto, once in each Go binary: it covers every version of the module because of its openpgp package, and neither binary compiles that package.

What this does not prove

  • I didn't perform an attack. I showed that the vulnerable server isn't in the binary. I didn't point a malicious HTTP/2 client at the old build to see what would happen.
  • Only the server was checked so carefully. The affected version's frame reader was compiled in. I know the recipe's configuration doesn't route requests through it; I haven't demonstrated that no configuration does.
  • This applies to this build. Compiled with Go 1.26, or with the http2legacy build tag, the same source produces a binary containing the vulnerable server. The answer lies in the binary, not in a version of Caddy.
  • The scanners were not wrong to speak. A scanner that remained silent in this case would have to understand the build constraints of every library in every language. Excessive reporting is the right failure for that tool. The mistake was mine, in making its statement stronger.

What I take from it

"This version has an advisory", "this function can be called" and "this can be exploited here" are three different statements, and every scanner makes exactly one of them. Before writing the third, look at what gets executed. For a Go program that's two commands.

This is also the argument my project uses regarding other people's findings: a vulnerability is relevant when something can reach it, and a list of findings doesn't specify which ones. I had the list, two tools long, and I still wrote the alarm first. Knowing the argument didn't stop me from making the mistake. Verification did.

And when a finding is incorrect for a subtle reason, fix it if possible. Being right about a build constraint doesn't help the person reading the report.


All articlesBack to the CV