Table of contents

Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign

Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign - Shai Hulud Miasma

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 {
  ev‌al(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 ev‌al.

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

ArtifactMD5SHA-256
index.js stage-one loader8cd0b0fbd4232face584c66d9754bf1ec2a60face766f69f82c972375f35f8ebaa45d6c464176974e631d9a78d6bea0a

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:

IndicatorValueDescription
GitHub GraphQL strings in payloadmutation BatchedCreateCommitOnBranch(, createCommitOnBranch(input: $input, GraphQLClient, GraphQL errors:, buildBatchedMutationGraphQL client code targeting GitHub’s createCommitOnBranch mutation
GitHub REST contents path/contents/results/Repository contents API path referenced in payload
Workflow-injection literalschore/add-codeql-static-analysis, codeql.yml, .github/workflows/Strings used by the worm’s CodeQL-workflow propagation step
Branch-name prefix literaloidc-Prefix used by the worm for runtime-generated exfiltration branch names
Sigstore endpointshxxps://fulcio.sigstore[.]dev, hxxps://rekor.sigstore[.]devReferenced by the payload alongside the SLSA provenance type hxxps://slsa[.]dev/provenance/v1
npm propagation endpointshxxps://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 abusehxxps://registry.npmjs[.]org/-/npm/v1/oidc/token/exchange/package/ with audience claim npm:registry.npmjs[.]orgRe-publishes malicious versions of packages without holding a static publish credential
AWS IMDSv2169[.]254[.]169[.]254 (/latest/api/token, /latest/meta-data/iam/security-credentials/)Instance-role credential theft on EC2
AWS ECS task credentials169[.]254[.]170[.]2Task-role credential theft on ECS/Fargate
HashiCorp Vault127[.]0[.]0[.]1:8200, /v1/auth/aws/login, /v1/auth/kubernetes/loginVault token mint paths exercised with harvested AWS/K8s identities
Azure AD / Graphhxxps://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

IndicatorValueDescription
Manifest entry"preinstall": "node index.js"Triggers the loader on npm install
Stage-one fileindex.js at package root, 4.1 MBCaesar-wrapped numeric array
Decoy sourcemapindex.js.map, 586 bytesStub sourcemap accompanying a 4.1 MB JS file
AES-GCM IV (helper payload)990ee58a36f9058116a8d014IV for the Bun bootstrapper decryption
AES-GCM IV (main payload)431e95574425527d43dc3d80IV 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 URLgithub[.]com/oven-sh/bun/releases/download/bun-v1.3.13/bun-<os>-<arch>.zipLegitimate Bun release, abused by the loader
Runner sudoers write/mnt/runner with mode 0440 and contents runner ALL=(ALL) NOPASSWD:ALLPrivilege escalation on GitHub Actions Linux runners
EDR detection paths/opt/CrowdStrike, /Library/CS/falcon, /opt/carbonblack, /opt/sentineloneProbed 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/tokenFilesystem paths read for Vault tokens

Credential-targeting literals

CategoryLiterals in payload
AWSRegexes 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
AzureEnv 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)
GCPEnv var GOOGLE_APPLICATION_CREDENTIALS; PEM regex /-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/g
HashiCorp VaultEnv vars VAULT_TOKEN, VAULT_AUTH_TOKEN; header X-Vault-Token. Filesystem paths in the In-memory markers table above
KubernetesStrings kubeconfig, k8stoken
Password managersStrings onepassword, bitwarden
npmString npmtoken
GitHub Actions OIDCRegex /^\s+id-token:\s*write/m; env vars GITHUB_REPOSITORY, WORKFLOW_ID
Generic credential sweepRegex /["']?(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.json

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

Figure 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

  1. Treat any host that ran npm install against 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.
  2. Identify exposure. Audit package-lock.json, yarn.lock, and pnpm-lock.yaml across the organization for the affected versions of @redhat-cloud-services/* packages tracked under MSC-2026-6085.
  3. 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.
  4. Review GitHub Actions runs that consumed the affected dependency. Any workflow with id-token: write permissions 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.
  5. Hunt for unfamiliar branches and committed results/ directories. Across every repository any affected token could push to, search for branches named chore/add-codeql-static-analysis, for branches with the oidc- prefix, and for any results/ 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.
  6. Remove staged artifacts from any potentially affected host: a bun binary under the OS tempdir from an unknown source, any /tmp/p*.js files, and any sudoers entry at /mnt/runner. Confirm node_modules is wiped and the lockfiles are regenerated against a known-good version.
  7. Harden installs going forward. Pin @redhat-cloud-services/* to known-good predecessors, install with --ignore-scripts until 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

IndicatorValue
binding.gyp SHA-256 (157 bytes, identical across the wave)ef641e956f91d501b748085996303c96a64d67f63bfeef0dda175e5aa19cca90
Bun downloadgithub.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 markersa 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

Manage open source risk

Recent resources

Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign - @Mastra

Mastra npm Scope Takeover: 140+ Packages Compromised via easy-day-js Dropper

@Mastra npm: 140+ Packages Compromised

Read more
Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign - Blog Cover Threat news

Laravel-Lang Composer tag-rewrite Supply Chain Attack

Four Laravel-Lang Composer packages were poisoned via tag rewrite.

Read more
Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign - Mini Shai Hulud is Back 1

Mini Shai-Hulud Hits @antv: 323 npm Packages Compromised Through the atool Maintainer Account

Mini Shai-Hulud strikes again: 323 npm packages compromised via @antv's atool.

Read more