On Monday, October 19, someone used a contributor's GitHub account to replace the download archives on five OVHcloud CLI releases (v0.11.0 to v0.15.0) with a build that, in CEO Octave Klaba's words, was meant to "retrieve secrets from the disk." No tag moved, no workflow ran. The attacker uploaded new files to old releases and edited checksums.txt so the published hashes matched the bad binaries.
What caught it was one customer's CI job that pinned the SHA-256 of ovhcloud-cli_Linux_arm64.tar.gz in their own repo. The job broke, they ran go version -m on the new binary, and the Go toolchain told them exactly what it was. I rebuilt that detection as a script, reproduced the attacker's build locally, and ran it against every Go binary on my machine. Code and real output below.
The timeline, in minutes
From issue #300 and The Hacker News (all UTC, Oct 19):
12:47 first asset replaced on v0.15.0
13:11 v0.13.0 checksums.txt replaced (last upload the reporter checked)
13:50 pinned-hash CI job fails, issue #300 filed
14:21 OVHcloud deletes the swapped files
17:16 clean v0.16.0 published as an immutable release
$ python3 -c "
from datetime import datetime as d
f=lambda s:d.strptime(s,'%H:%M')
print('swap window ', (f('14:21')-f('12:47')).seconds//60,'min')
print('uploads took ', (f('13:11')-f('12:47')).seconds//60,'min')
print('to detection ', (f('13:50')-f('12:47')).seconds//60,'min')"
swap window 94 min
uploads took 24 min
to detection 63 min
So the documented uploads took 24 minutes, and the bad files were downloadable for 94. Anyone on macOS, Linux x86_64/arm64 or Windows x86_64 who installed through install.sh or a "download latest release" step in that window got the swapped build. I read the repo's install.sh at tag v0.16.0: it downloads the tarball, untars it and installs it, with no checksum step.
The fingerprint
The reporter posted the build info of the swapped binary:
mod github.com/ovh/ovhcloud-cli v0.15.1-0.20261013083505-5ead8ef9b43d+dirty
build -ldflags="-s -w"
build vcs.revision=5ead8ef9b43d354dfb5caadff803b11afc8c5c58
build vcs.modified=true
Three things are wrong for a file attached to the v0.15.0 release:
- The version is a pseudo-version (an untagged commit from Oct 13), not
v0.15.0. -
+dirtyandvcs.modified=true: it was built with uncommitted changes. - A visible
-ldflagsline. The project's.goreleaser.yamlbuilds with-trimpath, and I confirmed in my shell that Go does not record-ldflagsin build info when-trimpathis on. A visible-ldflagsline means someone rango buildby hand.
Since Go 1.24, go build stamps the main module version from the VCS tag and appends +dirty for uncommitted changes. That metadata survives -s -w stripping, and any binary built from a git checkout carries it unless the build passes -buildvcs=false.
Reproducing the attacker's build
I cloned ovh/ovhcloud-cli at v0.16.0 (commit 6ef70c2) and built four binaries:
-
clean: tag checkout,
go build -trimpath - dirty: same, with one uncommitted line added
-
swapped: an untagged commit on top of the tag, one uncommitted line,
-ldflags="-s -w", no-trimpath. This is the issue #300 recipe. -
forged: a local tag named
v0.16.0moved to a different commit, so the binary claims the right version with the wrong code
Then I asked the Go module proxy which commit the real tag points to:
$ curl -s https://proxy.golang.org/github.com/ovh/ovhcloud-cli/@v/v0.16.0.info
{"Version":"v0.16.0","Time":"2026-10-16T14:38:10Z","Origin":{"VCS":"git",
"URL":"https://github.com/ovh/ovhcloud-cli",
"Hash":"6ef70c2389cd22c72821cf8d435365d0dcbe2729","Ref":"refs/tags/v0.16.0"}}
The Origin.Hash is the independent reference the script compares against. A GitHub release page can be edited by any account with write access. The proxy record and sum.golang.org ("an auditable checksum database") sit outside GitHub, so a hijacked account cannot edit them.
The script
gobin_audit.py reads build info from every Go binary in your $PATH (or the paths you pass), checks the commit against the proxy, and exits 1 on a FAIL so you can drop it into CI. Read-only, standard library only, needs go on PATH.
def audit(path):
info = build_info(path) # parses `go version -m` text output
if not info:
return None # not a Go binary
main = info.get("Main") or {}
mod, ver = main.get("Path", "?"), main.get("Version", "")
s = {x["Key"]: x["Value"] for x in info.get("Settings", [])}
rev, dirty = s.get("vcs.revision"), s.get("vcs.modified") == "true"
dirty = dirty or ver.endswith("+dirty")
tag = ver.replace("+dirty", "")
findings = []
if ver in ("", "(devel)"):
findings.append(("WARN", "no version stamp, cannot map to a tag"))
elif PSEUDO.search(tag):
if dirty: # the issue #300 signature
findings.append(("FAIL", f"local dirty build of an untagged commit ({ver})"))
else:
findings.append(("WARN", f"untagged commit (pseudo-version {ver})"))
elif rev:
if dirty:
findings.append(("WARN", "built with uncommitted changes (+dirty); can be legit"))
want = proxy_commit(mod, tag) # Origin.Hash from proxy.golang.org
if want and want.startswith("error"):
findings.append(("INFO", f"proxy lookup failed: {want}"))
elif want and want != rev:
findings.append(("FAIL", f"commit {rev[:12]} != tag {tag} commit {want[:12]}"))
elif want:
findings.append(("OK", f"commit matches {tag} on {PROXY}"))
if not rev:
findings.append(("INFO", "no vcs.revision embedded; compare sha256 with a checksum you trust"))
...
The full file (133 lines, including build_info, proxy_commit with the proxy's !lowercase path escaping, and the PATH walker) is at the end of this post. Output against my four builds:
$ python3 gobin_audit.py ovhcloud-clean ovhcloud-dirty ovhcloud-swapped ovhcloud-forged
ovhcloud-clean
module github.com/ovh/ovhcloud-cli v0.16.0 (go1.27.1)
sha256 8364f5294885acdaf7c6510927d7b54910c9d5bae9031f770b885c4e615a2d72
flags -trimpath=true
OK commit matches v0.16.0 on https://proxy.golang.org
ovhcloud-dirty
module github.com/ovh/ovhcloud-cli v0.16.0+dirty (go1.27.1)
sha256 d124b4a12fa22fdc9b072f1e5682c7a3829df7266e0f483c2e970598cae12a26
flags -trimpath=true
WARN built with uncommitted changes (+dirty); can be legit
OK commit matches v0.16.0 on https://proxy.golang.org
ovhcloud-swapped
module github.com/ovh/ovhcloud-cli v0.16.1-0.20261020124243-08d2b7bdd60b+dirty (go1.27.1)
sha256 93af002eb174dd5c7fc79266fb11fa7e899d07fd42afead346c640a335df5cb9
flags -ldflags="-s -w"
FAIL local dirty build of an untagged commit (v0.16.1-0.20261020124243-08d2b7bdd60b+dirty)
ovhcloud-forged
module github.com/ovh/ovhcloud-cli v0.16.0 (go1.27.1)
sha256 21c9b4f2e8a625e16299469a5a32dc97eaa95242ad24948ad6945c2638898f70
flags -trimpath=true
FAIL commit 08d2b7bdd60b != tag v0.16.0 commit 6ef70c2389cd
4 Go binaries checked, worst result: FAIL
exit=1
Why +dirty on a tagged build is only a WARN: The Hacker News reports that the genuine v0.16.0 release itself reports v0.16.0+dirty. My guess is the go mod tidy before-hook in the goreleaser config, but I have not confirmed it. Either way, failing on +dirty alone would have flagged the clean fix.
Then I ran it with no arguments, which walks $PATH:
$ python3 gobin_audit.py | tail -1
15 Go binaries checked, worst result: WARN
6 OK (gh, golangci-lint, containerd, containerd-shim-runc-v2, ctr, runc), 9 WARN, 0 FAIL. The WARNs were all distro-packaged or toolchain binaries (docker, git-lfs, age, go itself) built without VCS stamps. That is normal; for those the script prints the SHA-256 for you to compare against the distro's own checksums.
Honest caveats
-
Build info is just bytes. A careful attacker builds with
-buildvcs=falseor fakes the stamp. This catches sloppy swaps like this one (Klaba called the binary's quality "poor"), and it will not catch a careful one. -
The
-ldflagssignal is project-specific.ghv2.89.0 on my machine shows a full-ldflagsline and is genuine. Compare against the project's own release config. - It only covers Go. Rust, Node and Python CLIs need their own checks.
The checklist I now run
If you consume CLIs from GitHub releases:
- Pin the SHA-256 in your own repo. A
checksums.txtin the same release protects you from nothing here, because the attacker replaced it too. This is the check that caught the attack:
$ echo "5c2beb56e6d83a4b426565a1557791ef759316abcc4effb38dd627c90aeb80e0 ovhcloud-cli_Linux_arm64.tar.gz" | sha256sum -c -
ovhcloud-cli_Linux_arm64.tar.gz: FAILED
sha256sum: WARNING: 1 computed checksum did NOT match
(Run against a stand-in file; the hash is the original v0.15.0 arm64 archive from issue #300.)
- For Go tools, install through the module proxy.
go install github.com/ovh/ovhcloud-cli/cmd/ovhcloud@v0.16.0fetched the module zip whose hash,h1:qu7za9IqNOQQcV3ddmLdDSvKLwmaIAeA7tsAe6lV4kY=, matches the entry I pulled fromsum.golang.org/lookup. The issue #300 reporter switched to exactly this, and source builds were not affected. - Run
gobin_audit.pyon dev laptops and CI images weekly. - For immutable releases,
gh release verify-asset <tag> <file>checks GitHub's signed attestation. I could not test it here because my sandbox blocks the GitHub API.
If you ran an affected build: rotate everything readable on that machine, from a different machine. My secrets_on_disk.sh lists what to rotate (paths, sizes and dates only, never contents). Against a throwaway home directory with planted files:
$ env -i HOME=./home PATH=/usr/bin:/bin OVH_CONSUMER_KEY=x STRIPE_SECRET_KEY=y bash secrets_on_disk.sh ./app
PATH BYTES MODIFIED
./home/.ovh.conf 80 2026-10-20 12:44:39
./home/.aws/credentials 54 2026-10-20 12:44:39
./home/.kube/config 15 2026-10-20 12:44:39
./home/.npmrc 35 2026-10-20 12:44:39
./home/.ssh/id_ed25519 2 2026-10-20 12:44:39
env OVH_CONSUMER_KEY
env STRIPE_SECRET_KEY
dotenv ./app/.env.production
If you publish CLIs: turn on release immutability (it only locks releases created after you enable it, so republish old ones), enable private vulnerability reporting (issue #300 was public because there was none), and build with -trimpath plus a version ldflag so a hand-built impostor stands out.
Full script: gobin_audit.py
#!/usr/bin/env python3
"""gobin_audit.py: check Go binaries against the tag they claim to be.
For every Go binary found (in the paths given, or in $PATH), read the build
info the Go toolchain embeds and flag:
FAIL binary says it is tag vX but carries a different commit than the one
the Go module proxy recorded for vX
FAIL local dirty build of an untagged commit (the OVHcloud CLI signature)
WARN untagged commit, no version stamp, or +dirty on a tagged build
INFO no VCS info embedded, so the tag cannot be checked
Needs: go on PATH, network access to proxy.golang.org. Read-only.
"""
import hashlib, json, os, re, stat, subprocess, sys, urllib.request
PROXY = os.environ.get("GOAUDIT_PROXY", "https://proxy.golang.org")
PSEUDO = re.compile(r"-(0\.)?\d{14}-[0-9a-f]{12}(\+incompatible)?$")
def build_info(path):
# parse text output: Go 1.24 has no -json flag for go version -m
r = subprocess.run(["go", "version", "-m", path], capture_output=True, text=True)
lines = r.stdout.splitlines()
if r.returncode != 0 or not lines or ": go" not in lines[0]:
return None
info = {"GoVersion": lines[0].rsplit(": ", 1)[1], "Main": {}, "Settings": []}
for line in lines[1:]:
parts = line.strip("\t").split("\t")
if parts[0] == "mod" and len(parts) >= 3:
info["Main"] = {"Path": parts[1], "Version": parts[2]}
elif parts[0] == "build" and "=" in parts[1]:
k, v = parts[1].split("=", 1)
info["Settings"].append({"Key": k, "Value": v})
return info
def escape(mod): # module path escaping used by the proxy protocol
return "".join("!" + c.lower() if c.isupper() else c for c in mod)
def proxy_commit(mod, ver):
url = f"{PROXY}/{escape(mod)}/@v/{ver}.info"
try:
with urllib.request.urlopen(url, timeout=15) as resp:
return json.load(resp).get("Origin", {}).get("Hash")
except Exception as e:
return f"error: {e}"
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
def audit(path):
info = build_info(path)
if not info:
return None
main = info.get("Main") or {}
mod, ver = main.get("Path", "?"), main.get("Version", "")
s = {x["Key"]: x["Value"] for x in info.get("Settings", [])}
rev, dirty = s.get("vcs.revision"), s.get("vcs.modified") == "true"
dirty = dirty or ver.endswith("+dirty")
tag = ver.replace("+dirty", "")
findings = []
if ver in ("", "(devel)"):
findings.append(("WARN", "no version stamp, cannot map to a tag"))
elif PSEUDO.search(tag):
if dirty: # the issue #300 signature: local build of an untagged commit
findings.append(("FAIL", f"local dirty build of an untagged commit ({ver})"))
else:
findings.append(("WARN", f"untagged commit (pseudo-version {ver})"))
elif rev:
if dirty:
findings.append(("WARN", "built with uncommitted changes (+dirty); can be legit"))
want = proxy_commit(mod, tag)
if want and want.startswith("error"):
findings.append(("INFO", f"proxy lookup failed: {want}"))
elif want and want != rev:
findings.append(("FAIL", f"commit {rev[:12]} != tag {tag} commit {want[:12]}"))
elif want:
findings.append(("OK", f"commit matches {tag} on {PROXY}"))
if not rev:
findings.append(("INFO", "no vcs.revision embedded; compare sha256 with a checksum you trust"))
# release pipelines usually build with -trimpath, which hides -ldflags;
# a visible -ldflags line means a build that skipped -trimpath
flags = " ".join(f"{k}={v}" for k, v in s.items() if k in ("-trimpath", "-ldflags"))
return {"path": path, "module": mod, "version": ver, "go": info.get("GoVersion"),
"sha256": sha256(path), "flags": flags or "(none recorded)", "findings": findings}
def candidates(paths):
for p in paths:
if os.path.isfile(p):
yield p
elif os.path.isdir(p):
for name in sorted(os.listdir(p)):
f = os.path.join(p, name)
try:
st = os.stat(f)
except OSError:
continue
if stat.S_ISREG(st.st_mode) and st.st_mode & 0o111:
yield f
def main():
paths = sys.argv[1:] or os.environ.get("PATH", "").split(os.pathsep)
seen, worst, n = set(), "OK", 0
rank = {"OK": 0, "INFO": 1, "WARN": 2, "FAIL": 3}
for f in candidates(paths):
real = os.path.realpath(f)
if real in seen:
continue
seen.add(real)
r = audit(f)
if not r:
continue
n += 1
print(f"{r['path']}\n module {r['module']} {r['version']} ({r['go']})\n sha256 {r['sha256']}")
print(f" flags {r['flags']}")
for level, msg in r["findings"]:
print(f" {level:<5} {msg}")
if rank[level] > rank[worst]:
worst = level
print(f"\n{n} Go binaries checked, worst result: {worst}")
sys.exit(1 if worst == "FAIL" else 0)
if __name__ == "__main__":
main()
At Axionry, this kind of release-pipeline review is part of our fractional CTO work (from $3,000/mo, 10 to 20 hours a month) and of the technical due diligence we do for investors. More hands-on posts like this on the Axionry blog.
Sources: ovh/ovhcloud-cli issue #300, The Hacker News, BleepingComputer, Go 1.24 release notes, Go checksum database, GitHub immutable releases changelog, GitHub: verify release integrity
Top comments (0)