DEV Community

Cover image for The OVHcloud CLI Swap Left a Fingerprint: A Script to Check Your Go Binaries
Neeraj Sharma
Neeraj Sharma

Posted on AI-assisted

The OVHcloud CLI Swap Left a Fingerprint: A Script to Check Your Go Binaries

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
Enter fullscreen mode Exit fullscreen mode
$ 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Three things are wrong for a file attached to the v0.15.0 release:

  1. The version is a pseudo-version (an untagged commit from Oct 13), not v0.15.0.
  2. +dirty and vcs.modified=true: it was built with uncommitted changes.
  3. A visible -ldflags line. The project's .goreleaser.yaml builds with -trimpath, and I confirmed in my shell that Go does not record -ldflags in build info when -trimpath is on. A visible -ldflags line means someone ran go build by 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.0 moved 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"}}
Enter fullscreen mode Exit fullscreen mode

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"))
    ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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=false or 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 -ldflags signal is project-specific. gh v2.89.0 on my machine shows a full -ldflags line 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:

  1. Pin the SHA-256 in your own repo. A checksums.txt in 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
Enter fullscreen mode Exit fullscreen mode

(Run against a stand-in file; the hash is the original v0.15.0 arm64 archive from issue #300.)

  1. For Go tools, install through the module proxy. go install github.com/ovh/ovhcloud-cli/cmd/ovhcloud@v0.16.0 fetched the module zip whose hash, h1:qu7za9IqNOQQcV3ddmLdDSvKLwmaIAeA7tsAe6lV4kY=, matches the entry I pulled from sum.golang.org/lookup. The issue #300 reporter switched to exactly this, and source builds were not affected.
  2. Run gobin_audit.py on dev laptops and CI images weekly.
  3. 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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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)