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.
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