目に見えないnpmマルウェア:特定のバージョン表現でセキュリティチェックを回避
npm CLIには、非常に便利でよく知られたセキュリティ機能があります。npm パッケージをインストールする際、CLI はパッケージとその依存関係をすべてチェックして、既知の脆弱性がないか調べます。
このチェックはパッケージのインストール時(npm install
実行時)に自動で行われますが、npm auditを実行することで手動で行うことも可能です。
これは、既知の脆弱性を持つパッケージを使用しないよう、開発者に警告を発する重要なセキュリティ対策につながります。
最近、セキュリティに影響を与える可能性のあるnpm ツールによる予期せぬ動作を検知しました。それは、npm install
と npm audit
の両方において、特定のバージョン形式になっている場合、パッケージに対する脆弱性に関する警告が表示されないというもので、これらのパッケージを使用している開発者は重大な脆弱性やマルウェアを自分のシステムやnpmパッケージの間接的な依存関係として持ち込んでしまう可能性があります。
このブログでは、この問題の詳細、そして攻撃者が公開した悪意のあるパッケージのセキュリティチェックを回避するためにこの問題をどのように利用するのか、また開発者がこの問題に混乱させられないようにするにはどうすればよいのかについて詳しく説明します。
思いがけない結果
いくつかの npm パッケージで作業している際、npm CLI と JFrog Xray で報告された脆弱性の間に興味深い不一致があることに気が付きました。
特定のパッケージ「cruddl 2.0.0-update.2
」をインストールすると、npm と Xray から異なる脆弱性の報告を受け取りました。
同じパッケージに対して、npmの発見した脆弱性は0件でしたが、Xrayは脆弱性を1件(CVE-2022-36084)発見しました。これがバグの可能性があるものなのか、それとも別のものなのか、調べてみることにしました。
課題に関する詳細調査
いくつかの試行錯誤とパッケージのインストールを行ったところ、この不一致はインストールしたパッケージのバージョンにダッシュ/ハイフン文字(例:1.2.3-a
)が含まれている場合のみ発生することに気づきました。ではなぜこのようなことが起こるのでしょうか?
パッケージをアップロードするとき、npmはセマンティック バージョニング(Semantic Versioning)に準拠した厳密なバージョン形式を推奨しており、node-semverで解析可能でなければなりません。例えば、これら2つは有効なバージョン表現です: 5.6.7
, 5.6.7-a
。
npm-install/auditツールは、依存するすべてのパッケージとそのバージョンをjson(辞書形式)に収集し、Bulk Advisory endpointという名前のnpm APIエンドポイントに送ります。このエンドポイントは各パッケージとバージョンを調べ、 バージョンをアドバイザリの影響範囲と一致させることで関連するアドバイザリを見つけようとします。そして、APIエンドポイントから返されるリストに、これらすべての関連するアドバイザリを追加します。
今回確認したのは、Bulk advisory endpointは、バージョンにハイフン(-)の後に追加文字を含むパッケージのセキュリティアドバイザリの取得に失敗するということです。
「ハイフン付きバージョン」って何のこと??
セマンティック バージョニング(Semantic Versioning)の仕様によると、バージョンの形式はMAJOR.MINOR.PATCHです(例:5.6.7)。 ただし、第9項にあるように、プレリリースバージョンは「パッチバージョンの直後にハイフンとドット区切りの一連の識別子を付加する」ことで指定することができます(例:5.6.7-a
)。
動作の原因を推測
npmエンドポイントのコードはクローズドソースであるため、問題がどこに起因しているかは推測するしかありません。
各アドバイザリには(サンプルはこちらから)、影響を受けるバージョンの範囲を表す論理式(例: > 6.6.0
)を格納する「影響を受けるバージョン」フィールドがあります。あるバージョンが論理式のいずれかを満たした場合、そのバージョンは脆弱であるとみなされます。
node-semver
の記録によると(そしておそらく他のプログラミング言語でのsemver
の実装も)、プレリリースタグを持つバージョンの比較には特別なルールがあります(npmjpのドキュメントの「Prerelease Tags」セクションを参照)。
例えば、デフォルトでは、
semver.satisfies("1.2.3-a", "> 0")
false
と戻ってきます。アドバイザリの作成者の意図としては全てのバージョンのパッケージをカバーすることだったので、 これは非常に予想外なことかもしれません(例えば悪意のあるパッケージに対するアドバイザリを作成する場合など) 。
また、npmjp上のドキュメントには、「options object」にincludePrerelease
のフラグを設定することで、この動作を抑制できることも記載されています(レンジマッチングの目的で、すべてのプレリリースバージョンを通常版と同じように扱う)。
そこで、フラグを設定した状態で同じチェックを実行すると、
semver.satisfies("1.2.3-a", "> 0", {"includePrerelease":1})
true
と戻ってきます。
Bulk Advisory endpointでこのフラグを有効にすると、通常のnpmパッケージのバージョンとプレリリースバージョンの間の不整合が解消されるはずです。
JFrogの公開した情報を受けて、この機能が期待される動作であることをnpmのメンテナー(管理者)から学んだので、この動作が変更されることはないと考えています。
実証実験(Proof of Concept)
実際に致命的な脆弱性を持っていたパッケージであるcruddl
を例にとってみましょう(CVE-2022-36084)。
cruddl
のバージョン 2.0.0 をインストールすると、想定通り重大な脆弱性が発見されたことがわかります。
上記の代わりに、同じパッケージのプレリリースをインストールすることを選択した場合、
バージョン 2.0.0-update.2
は、 CVE-2022-36084 の影響を受けているにもかかわらず、誤って「脆弱性が見つからなかった」と表示されます(修正版は 2.7.0
)。
攻撃者はどのようにこの動作を悪用するのか
攻撃者は、無害に見えるパッケージに脆弱性や悪意のあるコードを意図的に仕込み、他の開発者が価値のある機能、あるいはtypoSquattingや依存関係の混乱などの感染技術によるミスとして取り込まれるなどの方法でこの動作を悪用することができます(例は、過去のブログ記事をご覧ください)。
上記のように、攻撃者がプレリリースバージョン形式のパッケージを使用している場合、そのコードが悪意のあるコード・脆弱性として報告され、アドバイザリが作成されていたとしても、npm CLIはそのような勧告の存在については報告しないため、悪意のあるコード・脆弱性の含まれたパッケージに依存している開発者は何も知らされないままになります。
問題を回避するために開発者ができることは?
開発者やDevOpsエンジニアに推奨するのは、そのパッケージが極めて信頼できるソースから提供されていると100%確信している場合を除き、プレリリースバージョンのnpmパッケージをインストールしないことです。その場合でも、できるだけ早くプレリリースバージョンでないパッケージに戻すことをお勧めします。
次のコマンドラインを使用して、現在プレリリースバージョンのnpmパッケージがインストールされているかどうかを以下の方法で確認することができます。
Linux用:
npm list -a | grep -E @[0-9]+\.[0-9]+\.[0-9]+-
Windows用:
npm list -a | findstr -r @[0-9]*\.[0-9]*\.[0-9]*-
JFrogセキュリティ・リサーチの最新情報
JFrogセキュリティ・リサーチチームからの最新の情報は、セキュリティ・リサーチのブログ記事やTwitter @JFrogSecurityからご覧ください。