すべての投稿に戻る
公開日 · 著者 Renaud Deraison

手がかりはタイムスタンプだった

npmは2026年7月8日、16年で最大のセキュリティ変更をリリースした——インストールスクリプトを既定で無効化。その1週間以内に、2件の別々のnpm侵害が、ペイロードをパッケージのインストール時ではなくインポート時に発火するよう移し、新しい既定値も--ignore-scriptsもまっすぐ迂回した。エコシステムがちょうど1年かけて監視を学んだ仕組みは、すでに移動していた。移動しなかった唯一のものは、悪意あるバージョンの新しさ——そしてそれこそがBromureの経過日数ゲートが確認するシグナルだ。

エコシステム全体が1年をかけて、npmのインストールスクリプトこそが危険だと 学んだ。7月8日、npmはそれを既定で無効にした。その3日後、ある侵害はペイロード をインストールスクリプトの外へ移した。さらに3日後、2件目はインストール スクリプトをまったく持たずに現れた。どちらもパッケージをインポートした ときに発火する。修正と回避は同じ週に交差した。

ある開発者が7月14日にプロジェクトへ@asyncapi/generatorを追加し、npm installを実行する。そのパッケージにはpreinstallinstallpostinstallフックもない——スクリプトスキャナーが印を付けるものは何もなく、 npmの真新しい既定値がスキップすべきものも何もない。インストールはきれいに 終わる。コードが初めてrequire('@asyncapi/generator')を実行したその瞬間、 パッケージ自身のエントリーポイントに組み込まれたローダーが分離したNode プロセスを起動し、8.2 MBの第二段階をIPFS経由で取得して復号し、認証情報を 探し始める。インストールに関して何もおかしく見えなかったのは、おかしかった 部分がインストール時に起きなかったからだ。

昨年の攻撃に向けて出された修正

ほぼ1年のあいだ、npmサプライチェーン攻撃の物語は同じだった——侵害された パッケージがpostinstallフックを抱え、npm installがあなたのマシンでそれを 自動実行する。そこで7月8日、npmはバージョン12 をリリースした——このツールの16年で最大のセキュリティ再設計だ。依存関係の ライフサイクルスクリプトは既定で無効になった。「依存関係のpreinstall、install、 postinstallスクリプトは、許可しない限り実行されない」。Git依存やリモートURLの ソースもオプトインになった。これは本物の改善であり、1年分のワームが通り抜けた 扉を一つ閉じる。

扉を一つ閉じる。攻撃者はすでにもう一つの扉を見つけていた。

ペイロードはインポート時へ移った

7月11日、npmパッケージjscramblerが侵害された ——約3時間にわたって5つの悪意あるバージョンが公開された。最初の3つ、8.14.0 から8.17.0は「preinstallフックからドロッパーを実行した」、古典的なパターンだ。 それからキャンペーンの途中で、攻撃者は扉を変えた。「8.18.0以降ではインストール フックが完全に消え、同一のドロッパーが代わりにdist/index.jsの先頭に自己実行 関数として注入される」ため、「インストール時ではなく、パッケージがインポート されたときやCLIが実行されたときに発火する」。Socketは理由を率直に名指しする。 「これは意図的な回避だ。preinstall/postinstallスクリプトだけを検査する スキャナーを打ち負かし、npm install --ignore-scriptsを生き延びる」。

その3日後、npmの@asyncapi組織 が同じ手口で侵害されたが、最初からそのために作り込まれていた。4つのパッケージ にまたがる5つのバージョン——@asyncapi/specs 6.11.2-alpha.1と6.11.2、 @asyncapi/generator 3.3.1、@asyncapi/generator-components 0.7.1、 @asyncapi/generator-helpers 1.1.1——は、攻撃者が設定を誤ったGitHub Actions ワークフローを使ってプロジェクトのボットトークンを流出させた後に再公開された。 ここで重要なのはMicrosoftの所見だ。「影響を受けたすべてのパッケージは、 package.jsonにpreinstall、install、postinstallフックを一切宣言していなかった」。 ローダーは代わりに各パッケージの通常のエントリーポイントの中に座っていた—— specsにはindex.js、ジェネレーターのテンプレート奥深くにはvalidator.js。 そして緩和策の助言は明確だ。「npm install --ignore-scriptsを緩和策として 頼ってはならない。このキャンペーンはライフサイクルフック経由ではなく、 モジュールがインポートされたときに実行される」。

jscrambler · 7/118.18.0: フック削除AsyncAPI · 7/14フックなしインストールの扉preinstall / postinstallnpm v12 の既定で閉鎖--ignore-scripts で閉鎖閉鎖インポートの扉require() / import入口のローダーnpm v12 は無関係開放同じペイロード分離 node プロセスIPFS 経由の 8.2 MB認証情報コレクターC2 · 85.137.53.71
同じペイロード、違う扉。1年のあいだ、ペイロードはインストールスクリプトの扉から来ていた——npmはpreinstall/postinstallを自動実行する。npm v12(7月8日)はこの扉を既定で閉じ、--ignore-scriptsは手動で閉じる。だが2つ目の扉が同じ部屋へ通じている——パッケージをインポートしたときに走るコードだ。jscrambler(7月11日)はドロッパーをpreinstallフックからdist/index.jsへ移し、AsyncAPI(7月14日)はインストールフックをまったく持たず、ローダーをパッケージ自身のエントリーポイントに組み込んで現れた。インストールの扉を閉じても、インポートの扉には触れない。

無関係な2人のアクターが、3日の間隔で、エコシステムの目玉の修正が稼働した まさにその週に、同じ手に行き着く。これは偶然ではない。防御が仕組みを名指し するとき、こうなる——その仕組みが立ち位置になり、別の場所に立つことは攻撃者 にとって数行のコードで済む。「このパッケージにインストールフックはあるか?」と 問うスキャナーは、AsyncAPIから正直な「ない」を受け取り、通してしまう。

移動しなかった唯一のもの

配送の仕組みを取り除いて、両方の侵害を通じて何が一定だったかを見よう。 jscramblerのバージョンは数時間だけ公開されていた。AsyncAPIの安定版は08:30 UTCに公開され、下流の検知は08:49に始まった——19分だ。両方のインシデントの 悪意あるバージョンはすべて、公開から2時間の十分内に取り下げられた。ペイロード はインストールフックからエントリーポイントへ移り、配送はライフサイクル スクリプトからランタイムローダーへ移った。移動しなかった唯一の性質は、毒を 仕込まれた各バージョンが真新しかったことだ。公開されたばかりのバージョンは 侵害の共通の指紋である。なぜならそれが攻撃の形だからだ——トークンを盗み、 公開し、見つかり、取り下げられる。窓は構造的に短い。

それこそがBromureの経過日数ゲートが確認する性質であり、 既定で有効だ。すべてのパッケージ取得——npm、PyPI、Cargo、その他——は、 エージェントのインストールが1バイトでも見る前にホストの中間者プロキシを通り、 プロキシはしきい値より若いどんなバージョンも拒否する。既定で2日だ。latestの ような浮動参照やsemverの範囲は、十分に古い最新バージョンへ静かに解決される。 新鮮すぎるバージョンへの固定参照は、理由を説明するBromureのエラーとともに 451として返る。ペイロードがどこにあるかは決して問わない。コードが preinstallで発火するかrequire()で発火するか、フックが在るか無いか、 --ignore-scriptsが設定されているかは関係ない。19分前に公開されたバージョン は、19分前だから拒否される。この2つのキャンペーンは、2日ゲートに対して到着 時点で死んでいた——Bromureがペイロードを認識したからではなく、そもそも ペイロードをまったく見なかったからだ。

仕組みの検査 · ペイロードを見るインストールフックはある?preinstall / install / postinstallAsyncAPI は正直に答える:いいえ検査を通過require() で発火Bromure 経過日数 · バージョンを見るこのバージョンは何日前?しきい値: 2日, 既定で有効レジストリの公開時刻:19 分前拒否 · 451ペイロードは未解決
パッケージを通すかどうかを決める2つのやり方。仕組みの検査はペイロードについて問う——インストールフックはあるか?——そしてAsyncAPIは正直に「ない」と答えるので、検査は通してしまう。Bromureの経過日数ゲートはバージョンについて問う——何日前か?——そして19分前に公開されたバージョンは、そのコードがどこで発火しようと451で拒否される。仕組みは1週間のうちに2つの攻撃のあいだでインストール時からインポート時へ移った。経過日数は移動しなかった。新鮮な公開こそが侵害の形だからだ。

経過日数ゲートは、新しいパッケージが悪意あるという主張ではない——ほとんどは 問題なく、2日前のリリースはまだとても新しい。それはリスクがどこに集中するか についての主張だ。侵害の危険な窓は、盗まれたトークンがバージョンを押し込んだ 後、メンテナが気づいて取り下げるまでの最初の数時間である。2日待っても パッケージが安全になるわけではないが、これらの攻撃が生きるまさにその区間から あなたのインストールを外し、しかもあなたがこれから実行するコードを一行も検査 せずにそうする。公開当日に必要なパッケージには、ゲートはパッケージ単位の例外を 認める。それ以外はすべて2日を待つ。

それでもバージョンが十分に古いとき

経過日数ゲートはフィルターであって壁ではない。より短いしきい値を設定し、初日に 必要なパッケージを除外し、あるいは2日を超えて老いるほど長く検知されずにいた 悪意あるバージョンを取り下げれば、毒入りのコードはインストールに届く。その場合 のBromureの答えは、以前のサプライチェーンの記事が説明しているものであり、経過 日数ゲートが防御のすべてではなくフィルターであることを許される理由でもある—— インストールを実行するエージェントは、あなたのMacの上にはいない。

それは使い捨てのLinux VMの中、ハイパーバイザーの境界ひとつ向こうにいる。だから AsyncAPIのローダーが発火し、その認証情報コレクターが探しに行くとき——そして それは激しく探し、百を超える環境変数 を掃引する。NPM_TOKENAWS_ACCESS_KEYANTHROPIC_API_KEYを含み、さらに ディスク上の.npmrc~/.aws/credentialsid_ed25519、kubeconfigも—— 見つけるのはVMのコピーであり、それらはプレースホルダーのbrm_…文字列だ。 本物の値はホストに住む。エージェントがそれを必要とする正当なリクエストを行うと、 ホストのプロキシはその一つのホップのあいだだけ、回線上で偽物を本物の値と 入れ替え、また戻す。秘密はコレクターがかき集めるためにVMのメモリへ着地する ことは決してない。ペイロードがコピーする~/.ssh鍵は、そのプロファイル用に 作られた使い捨てのペアだ。そしてペイロードが試みるC2アップロード——この場合は 85.137.53.71宛て——は、あらゆる宛先を記録する同じホストプロキシを通って出て いくので、あなたが一度も起動していないNodeプロセスが未知のIPへソケットを開く ことは、静かな転送ではなくセキュリティログの一行になる。窓が閉じると、VMと ペイロードがその中で行ったすべては消える。

これは今年のはじめにRed Hat自身のnpmスコープ へ乗り込んだのと同じMiasmaランタイムファミリーだ。コレクターは成熟し、 キャンペーンをまたいで再利用される。変わり続けるのは配送の仕組みの側だ。

この特定の週の居心地悪い教訓は、仕組みに固定された防御は半減期が日単位だと いうことだ。エコシステムは1年をかけてインストールスクリプトを注視すべきものに し、修正をリリースし、そしてリリースノートが冷めるより前に、2つの別々の アクターがペイロードを移すのを見た。その競争は次の仕組みを名指しすることでは 勝てない。コードが発火できる場所は常に他にもあるからだ。勝つのは、攻撃者が安く 変えられないもの——バージョンが何日前か——を確認し、すり抜けたものを、何も 持ち出せない場所で実行することによってだ。試してみる