# Poisoning a Go Cache

> Source: <https://lukasschwab.me/blog/gen/poisoning-go-cache.html>
> Published: 2026-09-27 09:57:23+00:00

Earlier this month I explained Go’s caches in a post I coauthored for
CloudX, [‘Scaling Golang
CI by Replacing `actions/setup-go`.’](https://www.cloudx.ai/posts/setup-go) If you run Go in
GitHub Actions, the post deserves a read (and
`cloudx-io/setup-go` deserves your consideration). If you
just want to learn more about Go caches without thinking about GitHub,
this post is for you.

Your Go cache (`$GOCACHE`) is a directory of files mapping
“action IDs” — hashes representing an atomic part of a request, such as
a build — to corresponding outputs. At CloudX we use this understanding
to make our engineers more effective, but you can use the same
understanding to do surprising, stupid, pathological things instead.

This post will explore your Go cache’s structure in greater depth by
demonstrating [cache
poisoning](https://en.wikipedia.org/wiki/Cache_poisoning): we can change the behavior of a Go program by
manipulating the cache, even without changing the program’s source
code.

We’ll use an absolutely minimal demo project, but the same principles apply to much more complex Go modules. Our demo is a simple Go module:

It contains a minimal `go.mod`:

```
module github.com/lukasschwab/demo

go 1.27
```

It contains a target package `truthy`, which just provides
the boolean value `true`:

```
package truthy

func Value() bool { return true }
```

Finally, it contains an entry-point in `main.go` for
demonstration purposes:

```
package main

import "github.com/lukasschwab/demo/truthy"

func main() {
    println(truthy.Value())
}
```

What happens if you run `main.go`? Exactly what you’d
expect:

``` bash
$ go run main.go
true
```

Well, that’s what it does *if nobody has poisoned your Go
cache.* Without changing this source code in any way, we can reverse
the program’s behavior: `println(truthy.Value())` will print
`false`!

First, observe the cache contents. The Go toolchain populates the
cache once, then reuses the cache contents for later requests. We’ll
poison the cached artifacts for package `truthy`; let’s start
by building it with an isolated cache so we can inspect these
artifacts.

``` bash
$ GOCACHE="/demo/cache" go build ./truthy
$ tree --prune /demo/cache
/demo/cache
├── 25
│   └── 25b28cbb223b28161c768050739328f6f0fdc5e83a2618046ff89448f3a6c708-d
├── 2f
│   └── 2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17-d
├── 56
│   └── 56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a
├── 7a
│   └── 7ad9f061c611f521651194d122993f9a9226f606e05e76ac0e1eafe161ec1091-d
├── 9b
│   └── 9b6e194bae01948fba16d0d48981c453d8ce937c45333f1888348f6ec67efdf6-a
├── ac
│   └── ac9c6630f8e0cb23af031113e331e8a2c5ff0ed127b536593c2e7abe3c055d43-a
├── c2
│   └── c22e5acea0c87a8cdbc9eb8d5195bde41f2bb41605c29622c82b6cee553f6b38-a
├── e3
│   └── e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-d
├── README
└── trim.txt
```

There are two kinds of files in the cache, distinguished by suffix:
`-d` and `-a`.

`-d` files are *data;* the whole point of the cache
is to make these outputs reusable. Specifically, in our example these
are package archives: portable, intermediate build artifacts that can be
linked together into executable binaries. These files are
content-addressed — the filename is always the SHA-256 sum of the file
contents:

``` bash
$ sha256sum demo/cache/e3/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-d
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  /demo/cache/e3/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-d
```

Content-addressing data this way compacts the cache. We could have
seven independent packages that all depend on package
`truthy`; if they share a Go cache, it will only store
package `truthy`’s data once.

Taken alone, these `-d` files are opaque. (To derive the
filename you want to restore, you need to compute the file contents;
that defeats the purpose of caching them!) This is where the
`-a` files come in: these are *action indexes,* and
they map action identifiers — hashes of user inputs, including source
code, flags, dependencies, and environment variables — to the
corresponding package archives.

Each `-a` file contains a single line with five
whitespace-separated values. Take a peek inside one of the action
indexes for package `truthy`, `56394f7…-a`:

| Value | Meaning | 
|---|---|
| `v1` | Format version — always `v1` for now. | 
| `56394f7…` | The action identifier, matching this `-a` filename. | 
| `2f8abba…` | The output identifier, pointing to a `2f8abba…-d` data file in the cache. | 
| `2218` | Size of the `2f8abba…-d` data file in bytes. | 
| `1790143015118664000` | Unix timestamp for this action index (nanoseconds). | 

The build cache saves time because action IDs are much cheaper to
calculate than the package archives stored in data files. To use the
cache, the Go toolchain calculates an action ID and checks if it’s
present in the cache. If there’s a corresponding `-a` file,
Go follows the pointer to the `-d` data and reuses it — job
done. Otherwise, it proceeds to build the package archive (expedited by
cache checks deeper in the build tree) before stashing the final data in
a `-d`- and `-a`-file pair.

To poison our program’s behavior, we need to intercept this
cache-lookup process. Specifically, we can find the true action ID for
the `truthy` package build, but rewrite its `-a`
file to point at a poisonous new `-d` package archive.

To get the action ID for package `truthy`, work backwards:
use `go list` to find its package archive, then find the
action index file pointing at it.

``` bash
# Find the final package archive data for package truthy.
$ GOCACHE="/demo/cache" go list -export -f '{{.Export}}' ./truthy
/demo/cache/2f/2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17-d

# Use that hash to find the corresponding action index.
$ grep -l "2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17" \
    /demo/cache/**/*-a
cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a
```

The other files in the cache, under other hashes, represent other
intermediates in the `go build` process. In theory those
could also be targets for poisoning, but for the purposes of this demo
they’re superfluous.

Define a poison file with exactly the same public interface as
`truthy.go`. Sharing this external interface ensures the
poison package archive will link into the same binaries the target
`truthy` package would.

```
package truthy

func Value() bool { return false }
```

We also need Go to build our poison package archive as if it
corresponded to `/demo/truthy` rather than
`/demo/poison`. To do this, we use a go tool feature called
“overlays.” Anytime the Go build tools would read
`truthy/truthy.go`, this overlay replaces that with a read of
`poison/truthy.go`:

```
{
    "Replace": {
        "/demo/truthy/truthy.go": "demo/poison/truthy.go"
    }
}
```

So now, when we build package `truthy` with that overlay,
we’ll write a poisoned package archive into the cache:

``` bash
$ GOCACHE="/demo/cache" go list -overlay overlay.json -export -f '{{.Export}}' \
    ./truthy
/demo/cache/2d/2dbc3cb27e5eb61aa8267224780ecf293e31ecedc587ba0752ed07d6334abf2e-d
```

The last step is to update the `-a` package index we
identified for `truthy` earlier with a reference to this
poisoned package archive: replace the healthy hash `2f8abba…`
with the poisoned hash `2dbc3cb…` and update the stored data
file size to match (in this case, it’s unchanged).

``` bash
$ cat cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a
v1 56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6 2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17                 2218  1790143015118664000

# Rewrite the healthy output identifier with our poisoned output identifier.
$ sed -i '' 's/2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17/2dbc3cb27e5eb61aa8267224780ecf293e31ecedc587ba0752ed07d6334abf2e/g' cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a
$ cat cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a
v1 56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6 2dbc3cb27e5eb61aa8267224780ecf293e31ecedc587ba0752ed07d6334abf2e                 2218  1790143015118664000
```

Now running our entry-point script triggers a build that blithely uses the poisoned package archive:

``` bash
# We've already poisoned the cache, so we can delete the source files.
$ rm -rf ./poison

# The source code calls ./truthy, but this run exhibits poisoned behavior!
$ GOCACHE="/demo/cache" go run main.go
false
```

Of course, this example is contrived. Cache poisoning *in
anger* would look very different:

`truthy`, replacing common dependencies like the Go standard
library’s `crypto` with malicious alternates.`go list`
cache inspections we did here can be precomputed for common OS
architectures, Go versions, etc. Aside from those parameters, nothing
about your Go cache is unique to your device.
An adversary with write access to files on your machine has better things to do than tamper with your Go cache — by rational choice, they’ll steal your secret keys instead.

Cache poisoning is a more pressing concern when it smuggles logic
over a trust boundary. That’s why GitHub’s Actions cache allows
non-default branches to read cache entries produced by the default, but
prevents the default from ever loading a cache entry produced by a
non-default branch: an untrusted PR could poison the Actions cache with
a package archive that steals credentials when executed in the
privileged default-branch context. If you’ve ever wondered why your Go
tests always re-execute on `main` even though they just
passed in your PR checks, now you know!

I’d have a hard time spotting cache poisoning in CI. You could push code to a PR that poisons the cache, then force-push to eliminate that commit. Even though it the poisoning code won’t appear in the commit history, later CI runs will blithely use the malicious records.

If you can’t trust *everything* with access to your build
cache, you can’t trust the builds downstream.
