OVHcloud CLI (2026)¶
The Lesson: The version was old. The file was new. Attackers didn't publish a release. They swapped the binaries inside five releases that had shipped weeks or months earlier, and they swapped checksums.txt along with them. Every control that asks "how old is this version?" waved it through. The only thing that caught it was a hash a stranger had written down before the swap and kept somewhere the attacker couldn't reach.
Tags Move
The cooldown rule in my own fleet's runbooks has one line I used to think was a footnote: count from the digest's publish date, not the tag's, because tags move. I wrote it with container images in mind. On October 19th it turned out to cover a whole incident. v0.15.0 was a month old on Monday morning. By lunchtime the bytes behind it were about an hour old, and nothing that reads release dates could tell the difference.
What Happened¶
ovhcloud-cli is OVHcloud's official command-line tool, a Go binary distributed as archives attached to GitHub releases. On Monday, October 19, 2026, someone used an OVH developer's GitHub account to replace the release assets on v0.11.0 through v0.15.0 with a credential stealer.1
Times in UTC (OVH's advisory gives them in Paris time, UTC+2):1
| Time | Event |
|---|---|
| 12:47 | First malicious binary uploaded to v0.15.0 (macOS) |
| 12:57 | All v0.15.0 binaries replaced |
| 13:30 | Binaries on v0.11.0 through v0.15.0 replaced |
| 13:50 | A user opens issue #300: their CI's pinned SHA-256 stopped matching2 |
| 14:21 | OVH deletes the malicious binaries |
| 16:52 | Public announcement on X from CEO Octave Klaba4 |
| 17:16 | Clean v0.16.0 published as an immutable release with a signed tag |
The binaries were live for about an hour and a half. OVH counts roughly 250 downloads.1 Only downloads inside that window are affected. An archive fetched last month is the real one.
What was not touched matters as much as what was. The source code and the build chain were clean.1 No tag was pushed and no workflow ran.3 go install and source builds were unaffected, and so was the Docker image, which OVH builds in an internal pipeline rather than from GitHub assets.3 The attacker never went near the build. They went around it.
The Swap¶
A GitHub release is a container. The tag points at a commit, and the release has assets attached: binaries, archives, a checksums file. Unless the repository has release immutability turned on, anyone with write access can delete an asset and upload a new one with the same name, at any time, through the ordinary Releases API. No tag push, no workflow run, no new version number.23
That's what happened here. The v0.15.0 tag still pointed at the same commit it had on September 16th.3 The Go module proxy still served that commit. Only the files hanging off the release changed.
And the attacker replaced checksums.txt too. So anyone doing the textbook thing — download the archive, download the checksums from the same release page, verify one against the other — got a perfect match. A checksum from the same place as the file is a checksum from whoever controls that place. It proves the download wasn't corrupted in transit. It says nothing about who put the file there.
The swapped binary also told on itself, if you knew where to look. Go embeds build metadata in every binary, and go version -m on the replacement showed a version of v0.15.1-0.20261013083505-5ead8ef9b43d+dirty, vcs.modified=true, and none of the linker flags from the project's GoReleaser config.2 It was a local go build of a later commit with uncommitted changes on top, uploaded by hand, wearing an older version's name.
Who Caught It¶
Not a scanner. Not OVH. A user's CI job.
They had pinned the SHA-256 of ovhcloud-cli_Linux_arm64.tar.gz for v0.15.0 when they first adopted it, and kept that value in their own build configuration. On Monday the build failed:2
before: 5c2beb56e6d83a4b426565a1557791ef759316abcc4effb38dd627c90aeb80e0
after: 3833daffcaaaab27e4e0e0833240ffcb1df8df648f4ec0bc4a75c176e28933cb
Two things made that pin work, and they're the whole lesson:
- It was recorded before the swap. Trust on first use sounds weak, and against an attacker who's there on day one it is. Against an attacker who shows up months later, it's exactly the right tool.
- It was stored somewhere else. The value lived in the user's repository, not on the release page. The attacker could rewrite everything OVH published. They couldn't rewrite a stranger's CI config.
The reporter's workaround is worth noticing too: they switched to building from the Go module proxy.2 Go's checksum database is an append-only transparency log. Once a module version's hash is recorded there, the go command will refuse anything that doesn't match it, forever. The source channel had a tamper-evident ledger. The binary channel had a setting, and the setting was off.
What a Cooldown Can't See¶
This guide recommends release cooldowns — refuse anything published in the last day or week, and let the rest of the internet find the malware first. For keyv and LiteLLM that would have been enough.
Here it does nothing. A cooldown reads the publish date of a version. v0.15.0 was published September 16th. On October 19th it was thirty-three days old and sailed through any reasonable age gate, carrying bytes that were minutes old.
Cooldowns work on npm, PyPI, crates.io, and the Go proxy because those registries won't let a version's contents change after publication. On those registries, how old the version is and how old the bytes are come to the same thing. GitHub release assets don't make that promise unless someone turns it on. Neither do container image tags, or anything you fetch by URL. On any of those, a cooldown is measuring the wrong thing.
The Account¶
OVH's advisory says the developer's personal MacBook was infected on October 20, 2025, a year to the day before the advisory, through a malicious extension (color-picker-universal 2.8.91). The release swap used keys stolen from it. In March 2026 several of the account's personal repositories were compromised as well. The developer's OVH work laptop isn't known to be compromised.1
OpenSourceMalware traced that March compromise: the account's personal repositories force-pushed with an obfuscated preinstall.js loader on March 5–6, 2026. The loader takes its next-stage URL from a Solana memo, and the wallet appears on public GlassWorm indicator lists. They call this likely a new GlassWorm operation.3 That link runs through the account and the wallet, not through the CLI payload itself. Treat it as a strong lead, not a verdict.
Two details are worth sitting with:
The account wasn't a contributor to this repository. It had merged work in other OVH repos, but had no commits in ovhcloud-cli.3 It could still upload release assets there, presumably through organization or team permissions. Write access granted broadly reaches repositories a person never touches, and an attacker holding that person's keys can reach those repositories too.
A year is a long time to sit on a key. This wasn't smash-and-grab. Whoever held that token waited for, or went looking for, the most valuable thing it could write to. An official cloud provider's CLI, the tool people install specifically to hold cloud credentials, is about as good as it gets.
The Payload¶
Per OVH, the binaries carried an embedded stealer that collected:1
- local databases from code editors and AI coding assistants
- AWS, Google Cloud, and Azure credentials
- Docker, Kubernetes, npm, DigitalOcean, and Terraform configuration
- SSH keys,
.netrc, and the global Git config
It staged everything in a temporary envchecker-* directory, archived it, and uploaded the archive to an endpoint it looked up through Solana RPC calls, so the operator can move the drop point without touching the binary.1 Klaba's advice was short: "If you are affected, please change all the secrets on your disk."4
Immutability Is a Setting¶
OVH's own root-cause statement is one sentence: the repository "was not appropriately configured to ensure release immutability."1
GitHub's immutable releases lock a release's assets and tag at publication and generate a release attestation for them.5 It's opt-in, per repository or by organization policy. The line in the documentation that deserves a highlighter is this one:
Be aware that immutability will only apply to future releases.6
Turning it on Monday afternoon protected v0.16.0. By GitHub's own description, it did nothing for the five releases that were just swapped, or for any release published before the switch was flipped. Every project that enables immutability after reading this incident is protecting the next release, not the back catalog. Your users' pinned versions live in the back catalog.
What To Check¶
Don't run the binary to find out. OVH's advisory lists the malicious hashes. Compare against those, from a different machine if you can.1
Look for the staging directory. envchecker- followed by digits in your temp directory means it ran.
If it ran, the machine's secrets are gone. Cloud keys, SSH keys, registry tokens, kubeconfigs, AI assistant credentials: rotate them, from a clean machine. Reinstalling the CLI doesn't revoke anything.
Check your CI caches and build images. A runner that fetched one of these releases during the window may have baked it into an image or a cache layer that outlives the deletion.
What Would Have Held¶
A hash pinned at adoption time, stored outside the release. That's literally what caught it. When you adopt a binary from a GitHub release, record its hash in your own repository and verify against that, never against a checksums file fetched in the same breath:
# Recorded once, when we adopted this version. Lives in our repo.
EXPECTED_SHA256="<hash you recorded at adoption>"
curl -fsSLo cli.tar.gz "$RELEASE_URL"
echo "${EXPECTED_SHA256} cli.tar.gz" | sha256sum -c -
Building from source through the language's ledger. go install went through the module proxy and the checksum database, and both refused to change. Where a project offers both a binary and a module, the module has the stronger integrity story.
Release immutability, on before the first release. On OVH's side this is the whole fix, and it has to be on from the start.
A warm cache. Anyone who'd already pulled the real v0.15.0 into a mirror or build image was untouched. Same asterisk as always: a cache that refreshed during the window cached the stealer.
Not a cooldown. Worth saying plainly, since this guide pushes them hard. Time is a control against new versions. It's blind to old versions with new contents.
Why It Mattered¶
Version age and artifact age are different numbers. Every age gate, every "don't install anything from the last week," every vulnerability policy keyed to version strings is betting that a version's bytes don't change. On immutable registries that's a safe bet. Everywhere else, the bet is in the attacker's hands.
A checksum from the same source as the file is decoration. It catches a flipped bit. It doesn't catch the person who controls the page. Integrity comes from a value recorded earlier, kept elsewhere, or anchored in a log nobody can rewrite.
The build was clean. It didn't matter. SolarWinds taught everyone to harden the build system, and keyv showed that a clean build of poisoned source gets signed anyway. This one skipped the build entirely. The distribution step, the part after CI says green, was the weak link.
Personal machines are corporate attack surface. The developer's work laptop wasn't compromised. Their personal one was, and the keys on it opened the company's release page.
Where It Sits¶
| tj-actions (2025) | Notepad++ (2025) | OVHcloud CLI (2026) | |
|---|---|---|---|
| What moved | Git tags, repointed to a malicious commit | Update server, redirecting chosen users | Release assets, replaced under existing tags |
| Source touched? | Yes, malicious commit | No | No |
| Build touched? | N/A | No | No |
| New version? | No | No | No |
| Who was safe | Anyone pinned to a commit SHA | Anyone whose updater strictly verified signatures | Anyone with a hash recorded before the swap |
| Caught by | StepSecurity's runner network monitoring | Nobody, for six months | One user's failing CI job, about an hour in |
Same shape three times: the name you trust stays put, and what it points at changes underneath. tj-actions moved the pointer. Notepad++ moved the server. OVHcloud moved the file. The defense is the same each time too: pin to something that can't move, and keep the pin where the attacker can't edit it.
-
"ovhcloud-cli security incident - October 19th, 2026." GitHub Security Advisory GHSA-862f-55f5-vmvw. OVHcloud. 20 October 2026. https://github.com/ovh/ovhcloud-cli/security/advisories/GHSA-862f-55f5-vmvw — affected window, root cause, payload behavior, IOC hashes. Times in the advisory are UTC+2. ↩↩↩↩↩↩↩↩↩
-
"Release assets of v0.13.0–v0.15.0 replaced on 2026-10-19 with a dirty build of a later commit." ovh/ovhcloud-cli issue #300. 19 October 2026. https://github.com/ovh/ovhcloud-cli/issues/300 — the original report: before/after hashes,
go version -moutput, upload timestamps. The issue covers v0.13.0–v0.15.0; OVH's advisory later extended the range to v0.11.0. ↩↩↩↩↩ -
"Glassworm Hacks OVH Clouds CLI tool." OpenSourceMalware. October 2026. https://opensourcemalware.com/blog/glassworm-hacks-ovh-clouds-cli-tool — account history, the March 2026 force pushes, the Solana wallet, and GlassWorm attribution, with its own confirmed/inferred split. ↩↩↩↩↩↩
-
"OVHcloud CLI releases backdoored on GitHub to steal developer secrets." BleepingComputer. 20 October 2026. https://www.bleepingcomputer.com/news/security/ovhcloud-cli-releases-backdoored-on-github-to-steal-developer-secrets/ — quotes Octave Klaba's posts on X. ↩↩
-
"Immutable releases." GitHub Docs. https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/immutable-releases ↩
-
"Preventing changes to your releases." GitHub Docs. https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/preventing-changes-to-your-releases ↩