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.

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 - 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
Miasma: Red Hat Cloud Services npm Packages Hit by a Mini Shai-Hulud-Style Campaign - Mend securing RubyGems

Inside the RubyGems Supply Chain Attack: How Mend Defender Caught a Coordinated Flood Before It Spread

How Mend.io caught a coordinated RubyGems attack and what it teaches us.

Read more