Table of contents
Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign
On June 1, 2026, multiple npm packages in the @redhat-cloud-services scope were published with malicious versions. Each tarball ships a 4.1 MB obfuscated JavaScript file added to package.json as a preinstall hook. The hook runs a multi-stage loader that ends in a Bun-executed credential stealer hitting AWS, Azure, GCP, HashiCorp Vault, Kubernetes, GitHub Actions OIDC, npm, Bitwarden, and 1Password.
The compromise
The published manifest in each affected version declares a script that the legitimate upstream repository does not:
"scripts": {
"build": "tsc",
"preinstall": "node index.js"
}
Figure 1: The added preinstall hook in package.json.
The preinstall lifecycle phase fires before any dependency code runs. Inside the tarball, only two files have a recent modification time: the modified package.json and index.js itself (which weighs 4.1 MB). Every other file, including the sourcemap index.js.map at 586 bytes, retains the epoch placeholder that npm pack writes when there is no original modification time.
The attack chain:
npm install
└─ preinstall: node index.js (stage 1, 4.1 MB Caesar wrapper)
└─ decoded JS, ~1.2 MB (stage 2, AES-128-GCM unwrap)
├─ payload _b, 898 B (stage 3, Bun runtime bootstrapper)
│ └─ curl bun-v1.3.13 from github[.]com/oven-sh/bun
└─ payload _p, 620 KB (stage 4, obfuscator.io credential stealer)
└─ bun run /tmp/p<random>.js
└─ scrapes AWS, Azure, GCP, Vault, K8s, GitHub OIDC, npm, Bitwarden, 1Password
Figure 2: The full chain. Stage 1 is plain JavaScript with a Caesar wrapper; stage 2 owns the AES-GCM unwrap; stages 3 and 4 split between a runtime bootstrapper and the credential stealer it executes.
Stage 1: The preinstall hook
index.js is a single line. The outer shape is a Caesar wrapper that reads a numeric array, converts it to a string, applies a per-character alphabetic shift, and evaluates the result:
try {
eval(function(s, n) {
return s.replace(/[a-zA-Z]/g, function(c) {
var b = c <= "Z" ? 65 : 97;
return String.fromCharCode((c.charCodeAt(0) - b + n) % 26 + b);
});
}([/* 1,272,397 character codes */].map(function(c) {
return String.fromCharCode(c);
}).join(""), 6))
} catch(e) {}Figure 3: The Caesar wrapper. The numeric array carries the stage-two source code with every alphabetic character shifted backward by 6 positions; the shift function with n=6 undoes it before eval.
There is no encryption here and no key. The decoded source is roughly 1.2 MB of JavaScript.
Stage 2: AES-128-GCM unwrap
Once decoded, the second-stage source imports node:crypto, defines a single AES-128-GCM helper, and calls it twice with hardcoded keys, IVs, authentication tags, and ciphertexts:
const _d = (k, i, a, c) => {
const d = crypto.createDecipheriv("aes-128-gcm",
Buffer.from(k, "hex"), Buffer.from(i, "hex"),
{ authTagLength: 16 });
d.setAuthTag(Buffer.from(a, "hex"));
return Buffer.concat([d.update(Buffer.from(c, "hex")), d.final()]);
};
const _b = _d(/* key */, /* iv */, /* tag */, /* ciphertext */).toString("utf8");
const _p = _d(/* key */, /* iv */, /* tag */, /* ciphertext */).toString("utf8");
Figure 4: The unwrap step. The 16-byte authentication tag is verified on each call, so a single byte flipped in the tarball would abort the chain. The two payloads serve different roles: _b is 898 bytes and contains a Bun runtime bootstrapper; _p is 620 KB and is the credential stealer. The IVs are listed in the IOC table below.
Stage 3: Bun runtime bootstrap
The 898-byte helper defines a global getBunPath() function that resolves the host platform and architecture, creates a temp directory under the OS tempdir, downloads the Bun v1.3.13 archive from github[.]com/oven-sh/bun/releases/download/bun-v1.3.13/bun-<os>-<arch>.zip, unzips it, makes the binary executable, and caches the path. The download URL is the legitimate oven-sh/bun release endpoint.
The next stage runs under bun, not node.
Stage 4: Credential harvesting and exfiltration
The 620 KB payload is written to /tmp/p<random>.js (path hardcoded with /tmp/p plus Math.random().toString(36).slice(2)), executed by the Bun binary, and unlinked. If Bun is already present, the bootstrap step is skipped.
The payload is packed with obfuscator.io: a 2,219-entry string table rotated at startup until a checksum expression passes, with each entry encoded as custom-alphabet base64 then URL-percent decoded. Regex literals, HTTP header names, env-var reads, and parser fragments remain in cleartext.
The packed payload contains hardcoded credential-recognition literals covering every major cloud and CI surface: AWS (regexes, STS-response XML parsers, IMDSv2 headers), Azure (ARM env vars and a regex for vault key prefixes), GCP (a service-account env var and a PEM-key regex), HashiCorp Vault (token env vars, header, and filesystem paths), Kubernetes, password managers (Bitwarden and 1Password), npm tokens, GitHub Actions OIDC, and a generic credential sweep regex. The per-category literals are listed in the Credential-targeting literals table below.
Exfiltration channel. No attacker-controlled domain is present in the payload. Every URL targets a legitimate vendor: GitHub, npm registry, AWS metadata, Vault, Microsoft Graph/Login, Google APIs, Sigstore. The exfiltration channel is the GitHub API. The payload contains the GraphQL strings mutation BatchedCreateCommitOnBranch( and createCommitOnBranch(input: $input, the REST path /contents/results/, the branch-name literal chore/add-codeql-static-analysis, the workflow filename codeql.yml, and the prefix string oidc-.
Propagation channel. The npm endpoints /-/whoami, /-/v1/search?text=maintainer:, /-/npm/v1/tokens, /-/org/, and /-/npm/v1/oidc/token/exchange/package/ are present, alongside the audience claim npm:registry.npmjs[.]org. The Sigstore endpoints hxxps://fulcio.sigstore[.]dev and hxxps://rekor.sigstore[.]dev and the SLSA provenance type hxxps://slsa[.]dev/provenance/v1 are also present.
The payload also contains EDR-product directory probes and a privileged sudoers write to /mnt/runner (full details in the IOC tables below).
Indicators of Compromise
Artifact hashes
| Artifact | MD5 | SHA-256 |
|---|---|---|
index.js stage-one loader | 8cd0b0fbd4232face584c66d9754bf1e | c2a60face766f69f82c972375f35f8ebaa45d6c464176974e631d9a78d6bea0a |
Network IOCs
There is no attacker-controlled command-and-control domain. The payload’s outbound traffic uses legitimate vendor endpoints exclusively, with stolen victim credentials providing access. The following are the endpoints the worm reaches with stolen tokens:
| Indicator | Value | Description |
|---|---|---|
| GitHub GraphQL strings in payload | mutation BatchedCreateCommitOnBranch(, createCommitOnBranch(input: $input, GraphQLClient, GraphQL errors:, buildBatchedMutation | GraphQL client code targeting GitHub’s createCommitOnBranch mutation |
| GitHub REST contents path | /contents/results/ | Repository contents API path referenced in payload |
| Workflow-injection literals | chore/add-codeql-static-analysis, codeql.yml, .github/workflows/ | Strings used by the worm’s CodeQL-workflow propagation step |
| Branch-name prefix literal | oidc- | Prefix used by the worm for runtime-generated exfiltration branch names |
| Sigstore endpoints | hxxps://fulcio.sigstore[.]dev, hxxps://rekor.sigstore[.]dev | Referenced by the payload alongside the SLSA provenance type hxxps://slsa[.]dev/provenance/v1 |
| npm propagation endpoints | hxxps://registry.npmjs[.]org/-/whoami, hxxps://registry.npmjs[.]org/-/v1/search?text=maintainer:, hxxps://registry.npmjs[.]org/-/npm/v1/tokens, hxxps://registry.npmjs[.]org/-/org/ | Enumerates packages reachable from a stolen npm token |
| npm Trusted Publishing abuse | hxxps://registry.npmjs[.]org/-/npm/v1/oidc/token/exchange/package/ with audience claim npm:registry.npmjs[.]org | Re-publishes malicious versions of packages without holding a static publish credential |
| AWS IMDSv2 | 169[.]254[.]169[.]254 (/latest/api/token, /latest/meta-data/iam/security-credentials/) | Instance-role credential theft on EC2 |
| AWS ECS task credentials | 169[.]254[.]170[.]2 | Task-role credential theft on ECS/Fargate |
| HashiCorp Vault | 127[.]0[.]0[.]1:8200, /v1/auth/aws/login, /v1/auth/kubernetes/login | Vault token mint paths exercised with harvested AWS/K8s identities |
| Azure AD / Graph | hxxps://login.microsoftonline[.]com/, hxxps://graph.microsoft[.]com/v1.0/me, hxxps://graph.microsoft[.]com/v1.0/servicePrincipals?$filter=id eq ' | Service-principal enumeration via Graph |
In-memory and on-disk markers
| Indicator | Value | Description |
|---|---|---|
| Manifest entry | "preinstall": "node index.js" | Triggers the loader on npm install |
| Stage-one file | index.js at package root, 4.1 MB | Caesar-wrapped numeric array |
| Decoy sourcemap | index.js.map, 586 bytes | Stub sourcemap accompanying a 4.1 MB JS file |
| AES-GCM IV (helper payload) | 990ee58a36f9058116a8d014 | IV for the Bun bootstrapper decryption |
| AES-GCM IV (main payload) | 431e95574425527d43dc3d80 | IV for the credential-stealer decryption |
| Stage-four write path | /tmp/p<random>.js (Math.random().toString(36).slice(2)) | Temp file holding the packed payload before bun run |
| Bun runtime path | <os tempdir>/b-<mkdtempSync suffix>/bun (bun.exe on Windows) | Where the on-demand Bun binary is staged |
| Bun download URL | github[.]com/oven-sh/bun/releases/download/bun-v1.3.13/bun-<os>-<arch>.zip | Legitimate Bun release, abused by the loader |
| Runner sudoers write | /mnt/runner with mode 0440 and contents runner ALL=(ALL) NOPASSWD:ALL | Privilege escalation on GitHub Actions Linux runners |
| EDR detection paths | /opt/CrowdStrike, /Library/CS/falcon, /opt/carbonblack, /opt/sentinelone | Probed to fingerprint endpoint protection |
| Vault token locations | /etc/vault/token, /run/secrets/VAULT_TOKEN, /run/secrets/vault_token, /var/run/secrets/vault-token, /var/run/secrets/vault/token | Filesystem paths read for Vault tokens |
Credential-targeting literals
| Category | Literals in payload |
|---|---|
| AWS | Regexes aws_access_key_id, aws_secret_access_key, aws_session_token; STS-response XML parsers <AccessKeyId>, <SecretAccessKey>, <SessionToken>; IMDSv2 headers X-aws-ec2-metadata-token, X-aws-ec2-metadata-token-ttl-seconds |
| Azure | Env vars ARM_CLIENT_ID, ARM_CLIENT_SECRET, ARM_TENANT_ID, ARM_OIDC_TOKEN_FILE_PATH, KEY_VAULT_NAME, AZURE_VAULT_NAME; regex (AccountKey|accessKey|client_secret) |
| GCP | Env var GOOGLE_APPLICATION_CREDENTIALS; PEM regex /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g |
| HashiCorp Vault | Env vars VAULT_TOKEN, VAULT_AUTH_TOKEN; header X-Vault-Token. Filesystem paths in the In-memory markers table above |
| Kubernetes | Strings kubeconfig, k8stoken |
| Password managers | Strings onepassword, bitwarden |
| npm | String npmtoken |
| GitHub Actions OIDC | Regex /^\s+id-token:\s*write/m; env vars GITHUB_REPOSITORY, WORKFLOW_ID |
| Generic credential sweep | Regex /["']?(password|passwd|pass|pwd|secret|token|key|api[_-]?key|auth)["']?\s*["':=]\s*["'][^"'{}\s]{4,}["']/gi |
Detection and remediation
Detection
# Surface in-scope dependencies from a project manifest
jq -r '.dependencies + .devDependencies | to_entries[]
| select(.key | startswith("@redhat-cloud-services/"))
| "\(.key)@\(.value)"' package.jsonFigure 5: A quick way to surface @redhat-cloud-services/* dependencies from a project manifest. Compare each result against the affected versions tracked under MSC-2026-6085.
# Lockfile-level check (npm, all lockfile versions)
grep -E '"(node_modules/)?@redhat-cloud-services/[a-z0-9-]+":' package-lock.jsonFigure 6: Surface every @redhat-cloud-services/* entry in a lockfile so resolved versions can be compared against the affected versions tracked under MSC-2026-6085.
On hosts that may have installed an affected version, the presence of a bun binary at an unfamiliar path under the OS tempdir (for example, /tmp/b-<random>/bun on Linux or /var/folders/<…>/b-<random>/bun on macOS), or a file matching /tmp/p<base36>.js, is a high-confidence indicator. Both artifacts are removed on successful execution but may remain after a crash.
For repositories the affected machine could push to, search for a branch literally named chore/add-codeql-static-analysis containing a results/ directory with files committed during the exposure window, and for any branch with the oidc- prefix that was not created by your team. Either pattern matches the literal strings present in the payload.
What to do
- Treat any host that ran
npm installagainst an affected version as credential-compromised. This includes developer laptops, CI/CD runners, and any container builds that resolved one of the affected versions during build. - Identify exposure. Audit
package-lock.json,yarn.lock, andpnpm-lock.yamlacross the organization for the affected versions of@redhat-cloud-services/*packages tracked underMSC-2026-6085. - Rotate all credentials accessible from any affected machine. AWS IAM keys and any STS-issued session tokens. Azure ARM service-principal client secrets and any federated OIDC tokens. GCP service-account keys. HashiCorp Vault tokens. Kubernetes kubeconfigs. GitHub PATs. npm tokens, including granular tokens. Any Bitwarden or 1Password vaults unlocked on the affected host.
- Review GitHub Actions runs that consumed the affected dependency. Any workflow with
id-token: writepermissions that ran during the exposure window should be assumed to have leaked its OIDC token. Audit recent branches and commits against repositories those tokens could reach. - Hunt for unfamiliar branches and committed
results/directories. Across every repository any affected token could push to, search for branches namedchore/add-codeql-static-analysis, for branches with theoidc-prefix, and for anyresults/directory committed during the exposure window. Delete the branch, force-revoke the token that created it, and rotate any credential whose value appears in those committed files. - Remove staged artifacts from any potentially affected host: a
bunbinary under the OS tempdir from an unknown source, any/tmp/p*.jsfiles, and any sudoers entry at/mnt/runner. Confirmnode_modulesis wiped and the lockfiles are regenerated against a known-good version. - Harden installs going forward. Pin
@redhat-cloud-services/*to known-good predecessors, install with--ignore-scriptsuntil a verified clean version is republished, and lock the registry view on internal mirrors so the affected versions cannot be re-fetched.
Update, June 3 to 4, 2026: new wrapper, recycled worm
The operator returned two days later with a burst of 57 packages and 286 malicious versions, published in about two hours. The most-downloaded victims were @vapi-ai/server-sdk at 408,000+ monthly downloads and ai-sdk-ollama at 120,000+ monthly downloads. The delivery is rebuilt. The worm underneath is the same family.
The trigger left package.json entirely. There is no preinstall or postinstall script. The tarball ships a binding.gyp, and npm hands any binding.gyp to node-gyp during the native build step. node-gyp evaluates a command-substitution expression at config time, so the loader runs there:
"sources": ["<!(node index.js > /dev/null 2>&1 && echo stub.c)"]
Figure 7: The binding.gyp command-substitution that runs index.js at install time.
A scanner that only inspects npm lifecycle scripts sees a script-free package. Two files give the injection away. The manifest lists only dist, README.md, CHANGELOG.md, and LICENSE under files, yet the tarball also carries index.js and binding.gyp. Those two, plus a modified package.json, hold a June 4, 2026 timestamp, while every legitimate dist file carries npm’s normalized placeholder timestamp. The mismatch shows the package was repacked after the original build to inject the loader.
The loader is the same design as the June 1 wave. Stage one is a Caesar wrapper over a 1.3 million element character array, with the shift changed from 6 to 15. Stage two then AES-128-GCM decrypts two payloads with a hardcoded hex key, IV, and authentication tag, exactly as before. The encryption layer was not strengthened; only the trigger that reaches it changed.
The runtime is unchanged from the June 1 wave. One decrypted blob defines getBunPath(), downloads Bun v1.3.13 from the legitimate oven-sh release endpoint with curl, unzips it into a b- prefixed temp directory created with mkdtempSync, and marks it executable. The real payload is written to /tmp/p<random>.js and executed with bun run, reusing Bun if the loader is already running under it, otherwise using the downloaded copy. Running the worm under Bun side-steps Node-level hooks and instrumentation and gives the operator a consistent runtime even on hosts that do not have it. The one new piece is the trigger: a binding.gyp command-substitution in place of the preinstall hook.
Underneath the new wrapper is the same worm. The Bun stage carries the same fingerprints as the June 1 payload: githubFetch, githubHeaders, and githubJson helpers, a header builder that injects an Authorization token, and fetch calls against the GitHub API. This is the same GitHub-token credential theft and repository propagation described above.
New wrapper, recycled worm. The binding.gyp trigger is the new detection signal; the Bun bootstrap and the credential-stealing core are unchanged from June 1.
Indicators for the June 3 to 4 wave
| Indicator | Value |
|---|---|
binding.gyp SHA-256 (157 bytes, identical across the wave) | ef641e956f91d501b748085996303c96a64d67f63bfeef0dda175e5aa19cca90 |
| Bun download | github.com/oven-sh/bun/releases/download/bun-v1.3.13/bun-{linux,darwin,windows}-{x64-baseline,aarch64}.zip |
| On-disk artifacts | /tmp/b-<random>/bun and /tmp/p<random>.js |
| Injection markers | a binding.gyp containing <!(node index.js ...) and a root index.js not listed under package.json files |
Conclusion
The @redhat-cloud-services wave runs its execution under Bun rather than Node and references only legitimate vendor APIs. The payload contains GraphQL mutation literals targeting GitHub’s createCommitOnBranch, a CodeQL-themed branch, and the npm Trusted Publishing OIDC exchange. Mend.io will continue tracking the @redhat-cloud-services scope and adjacent enterprise-vendor npm namespaces.
Mend.io coverage
Mend.io has issued 1 MSC advisory covering all affected packages:
MSC-2026-6085
References
- RedHatInsights/frontend-components upstream repository: https://github.com/RedHatInsights/frontend-components
- Bun v1.3.13 release (legitimate, abused as runtime delivery): https://github.com/oven-sh/bun/releases/tag/bun-v1.3.13