astraguard 1.1.7

Official AstraGuard SDK - license validation, HWID binding, anti-debug, and offline cache for Rust applications
Documentation
//! Anti-debug detection for Windows.
//!
//! Detection methods used:
//! 1. `IsDebuggerPresent`  -  PEB flag, easiest to clear but still catches most tools
//! 2. `CheckRemoteDebuggerPresent`  -  NtQueryInformationProcess wrapper
//! 3. `NtQueryInformationProcess` with `ProcessDebugPort` (class 7)  -  harder to spoof
//! 4. Timing check  -  detects single-stepping
//! 5. Frida detection  -  pipe names and loaded DLL names
//! 6. Named RE-tool process scan  -  x64dbg, OllyDbg, WinDbg, Cheat Engine, IDA, dnSpy, Process Hacker

use std::sync::OnceLock;

use super::strings;

/// Returns `true` if a debugger is detected by any method.
pub fn is_attached() -> bool {
    check_is_debugger_present()
        || check_remote_debugger_present()
        || check_nt_query_debug_port()
        || check_timing()
        || check_frida()
        || check_debugger_processes()
}

/// Start the background security monitor (singleton  -  safe to call multiple times).
///
/// The first call spawns one thread that checks for both debuggers and VMs
/// every 800-1200 ms and calls `std::process::exit(1)` silently on detection.
/// Subsequent calls are no-ops.
///
/// On detection the exit is NOT instant: the thread re-verifies once more
/// after a randomized 1-4s delay before actually exiting, through the same
/// (already redundant, multi-technique) detection functions. This decorrelates
/// the crash from whatever the cracker just did - attaching a debugger,
/// resuming past a breakpoint, toggling a VM setting - which defeats the
/// common bisection technique of "undo the last change, see if the crash goes
/// away". A patch that silences detection only at the instant it's first
/// observed (e.g. a breakpoint-triggered one-shot patch) still has to survive
/// a second, separately-timed check. Legitimate users are never detected in
/// the first place, so this delay has no effect on real usage.
pub fn start_background_monitor() {
    static STARTED: OnceLock<()> = OnceLock::new();
    STARTED.get_or_init(|| {
        std::thread::spawn(|| loop {
            if is_attached() || super::anti_vm::is_virtual_machine() {
                let delay_jitter = std::time::SystemTime::now()
                    .duration_since(std::time::UNIX_EPOCH)
                    .unwrap_or_default()
                    .subsec_nanos()
                    % 3000;
                std::thread::sleep(std::time::Duration::from_millis(1000 + delay_jitter as u64));
                if is_attached() || super::anti_vm::is_virtual_machine() {
                    // Silent exit  -  no message, no panic, no stack trace for analyst
                    std::process::exit(1);
                }
            }
            // Jitter between 800 and 1200 ms to avoid predictable timing
            let jitter = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap_or_default()
                .subsec_nanos()
                % 400;
            std::thread::sleep(std::time::Duration::from_millis(800 + jitter as u64));
        });
    });
}

// ── Detection implementations ─────────────────────────────────────────────────

fn check_is_debugger_present() -> bool {
    #[cfg(target_os = "windows")]
    {
        // SAFETY: `IsDebuggerPresent` has no preconditions; it reads the PEB
        // flag for the current process and returns a BOOL. Always safe to call.
        unsafe { winapi::um::debugapi::IsDebuggerPresent() != 0 }
    }
    #[cfg(not(target_os = "windows"))]
    {
        false
    }
}

fn check_remote_debugger_present() -> bool {
    #[cfg(target_os = "windows")]
    {
        use winapi::shared::minwindef::BOOL;
        use winapi::um::debugapi::CheckRemoteDebuggerPresent;
        use winapi::um::processthreadsapi::GetCurrentProcess;

        let mut present: BOOL = 0;
        // SAFETY: `GetCurrentProcess` returns a pseudo-handle that never needs
        // to be closed. `CheckRemoteDebuggerPresent` writes one BOOL into
        // `&mut present`; the pointer is valid for the lifetime of this block.
        unsafe {
            CheckRemoteDebuggerPresent(GetCurrentProcess(), &mut present);
        }
        present != 0
    }
    #[cfg(not(target_os = "windows"))]
    {
        false
    }
}

fn check_nt_query_debug_port() -> bool {
    #[cfg(target_os = "windows")]
    {
        use winapi::shared::ntdef::{NTSTATUS, PVOID, ULONG};
        use winapi::um::libloaderapi::{GetModuleHandleA, GetProcAddress};
        use winapi::um::processthreadsapi::GetCurrentProcess;

        type NtQueryFn = unsafe extern "system" fn(
            winapi::um::winnt::HANDLE,
            ULONG,
            PVOID,
            ULONG,
            *mut ULONG,
        ) -> NTSTATUS;

        // SAFETY:
        // - `ntdll.dll` and `NtQueryInformationProcess` are always present on
        //   Windows NT 5.0+ and never unloaded from a running process.
        // - `GetModuleHandleA` with a null-terminated literal is safe.
        // - `GetProcAddress` returns a function pointer we immediately transmute
        //   to the correct ABI (`extern "system"`). The signature matches the
        //   documented NT prototype for class 7 (ProcessDebugPort).
        // - `debug_port` and `return_length` are stack-allocated and their
        //   addresses are valid for the duration of the call.
        unsafe {
            let h_ntdll = GetModuleHandleA(b"ntdll.dll\0".as_ptr() as *const i8);
            if h_ntdll.is_null() {
                return false;
            }
            let proc_addr = GetProcAddress(
                h_ntdll,
                b"NtQueryInformationProcess\0".as_ptr() as *const i8,
            );
            if proc_addr.is_null() {
                return false;
            }
            let nt_query: NtQueryFn = std::mem::transmute(proc_addr);
            let mut debug_port: isize = 0;
            let mut return_length: ULONG = 0;
            const PROCESS_DEBUG_PORT: ULONG = 7;
            let status = nt_query(
                GetCurrentProcess(),
                PROCESS_DEBUG_PORT,
                &mut debug_port as *mut isize as PVOID,
                std::mem::size_of::<isize>() as ULONG,
                &mut return_length,
            );
            status == 0 && debug_port != 0
        }
    }
    #[cfg(not(target_os = "windows"))]
    {
        false
    }
}

/// Timing check: a tight 1000-iteration XOR loop should complete in well under
/// 50 ms on any real CPU. If it takes longer, a debugger is almost certainly
/// single-stepping through the process.
fn check_timing() -> bool {
    let start = std::time::Instant::now();
    let mut acc: u64 = 0xDEAD_BEEF_CAFE_1337;
    for i in 0u64..1000 {
        acc ^= i.wrapping_mul(0x9E37_79B9_7F4A_7C15);
        acc = acc.wrapping_add(acc >> 7);
    }
    // Ensure the optimizer cannot eliminate the loop.
    std::hint::black_box(acc);
    start.elapsed().as_millis() > 50
}

/// Frida detection via named pipe existence and loaded DLL enumeration.
fn check_frida() -> bool {
    #[cfg(target_os = "windows")]
    {
        // SAFETY: `GetCurrentProcessId` is unconditionally safe.
        let pid = unsafe { winapi::um::processthreadsapi::GetCurrentProcessId() };
        let frida_pipe = format!("{}-{}", strings::decode(&strings::PIPE_FRIDA_PREFIX), pid);
        let linjector_pipe = format!(
            "{}-{}",
            strings::decode(&strings::PIPE_LINJECTOR_PREFIX),
            pid
        );
        check_pipe_exists(&frida_pipe)
            || check_pipe_exists(&linjector_pipe)
            || check_modules_for_frida()
    }
    #[cfg(not(target_os = "windows"))]
    {
        false
    }
}

#[cfg(target_os = "windows")]
fn check_pipe_exists(pipe_name: &str) -> bool {
    use std::ffi::CString;
    use winapi::um::fileapi::{CreateFileA, OPEN_EXISTING};
    use winapi::um::handleapi::{CloseHandle, INVALID_HANDLE_VALUE};
    use winapi::um::winnt::{FILE_SHARE_READ, FILE_SHARE_WRITE, GENERIC_READ};

    let Ok(c_name) = CString::new(pipe_name) else {
        return false;
    };
    // SAFETY: `c_name` is a valid null-terminated C string. `CreateFileA`
    // for a pipe path either returns a valid handle or `INVALID_HANDLE_VALUE`.
    // We immediately close the handle if it is valid.
    unsafe {
        let h = CreateFileA(
            c_name.as_ptr(),
            GENERIC_READ,
            FILE_SHARE_READ | FILE_SHARE_WRITE,
            std::ptr::null_mut(),
            OPEN_EXISTING,
            0,
            std::ptr::null_mut(),
        );
        if h != INVALID_HANDLE_VALUE {
            CloseHandle(h);
            return true;
        }
    }
    false
}

/// Scan the running process list for known reverse-engineering tool executables.
/// Matches on the process name's stem (before the first `.`) so `cheatengine-x86_64.exe`
/// and `cheatengine.exe` both match the `cheatengine` constant, `ida64.exe` matches
/// `ida_pro` via a `starts_with("ida")` special case, etc.
fn check_debugger_processes() -> bool {
    #[cfg(target_os = "windows")]
    {
        use winapi::um::handleapi::CloseHandle;
        use winapi::um::tlhelp32::{
            CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W,
            TH32CS_SNAPPROCESS,
        };

        let tool_names = [
            strings::decode(&strings::X64DBG),
            strings::decode(&strings::X32DBG),
            strings::decode(&strings::OLLYDBG),
            strings::decode(&strings::WINDBG),
            strings::decode(&strings::CHEATENGINE),
            strings::decode(&strings::DNSPY),
            strings::decode(&strings::PROCESSHACKER),
        ];
        // IDA_PRO decodes to "ida_pro"; real executables are ida.exe/ida64.exe/
        // idaq.exe/idaq64.exe - all share the "ida" prefix, so match on that
        // rather than the full constant.
        let ida_decoded = strings::decode(&strings::IDA_PRO);
        let ida_prefix = &ida_decoded[..3.min(ida_decoded.len())];

        // SAFETY: identical snapshot/enumeration contract as `check_vm_processes()`
        // in anti_vm.rs - `CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0)` is always
        // safe to call, `PROCESSENTRY32W` is zero-initialized with `dwSize` set before
        // the first `Process32FirstW` call, and the snapshot handle is closed before return.
        unsafe {
            let snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
            if snap == winapi::um::handleapi::INVALID_HANDLE_VALUE {
                return false;
            }

            let mut pe = PROCESSENTRY32W {
                dwSize: std::mem::size_of::<PROCESSENTRY32W>() as u32,
                ..std::mem::zeroed()
            };

            let mut found = false;
            if Process32FirstW(snap, &mut pe) != 0 {
                loop {
                    let name_raw: Vec<u16> = pe
                        .szExeFile
                        .iter()
                        .take_while(|&&c| c != 0)
                        .copied()
                        .collect();
                    let name = String::from_utf16_lossy(&name_raw).to_lowercase();
                    // starts_with (not exact match) on the full lowercased filename -
                    // catches variants like "cheatengine-x86_64.exe" and "cheatengine.exe"
                    // both matching the "cheatengine" constant.
                    if tool_names.iter().any(|t| name.starts_with(t.as_str()))
                        || name.starts_with(ida_prefix)
                    {
                        found = true;
                        break;
                    }
                    if Process32NextW(snap, &mut pe) == 0 {
                        break;
                    }
                }
            }

            CloseHandle(snap);
            found
        }
    }
    #[cfg(not(target_os = "windows"))]
    {
        false
    }
}

#[cfg(target_os = "windows")]
fn check_modules_for_frida() -> bool {
    use winapi::um::handleapi::CloseHandle;
    use winapi::um::processthreadsapi::GetCurrentProcessId;
    use winapi::um::tlhelp32::{
        CreateToolhelp32Snapshot, Module32FirstW, Module32NextW, MODULEENTRY32W, TH32CS_SNAPMODULE,
        TH32CS_SNAPMODULE32,
    };

    let frida_dll = strings::decode(&strings::FRIDA_DLL);
    let gadget_dll = strings::decode(&strings::GADGET_DLL);

    // SAFETY:
    // - `GetCurrentProcessId` is always safe.
    // - `CreateToolhelp32Snapshot` returns either a valid snapshot handle or
    //   `INVALID_HANDLE_VALUE`; we check before use.
    // - `MODULEENTRY32W` is zero-initialized and `dwSize` is set correctly
    //   before the first `Module32FirstW` call, satisfying the API contract.
    // - We close the snapshot handle before returning.
    unsafe {
        let pid = GetCurrentProcessId();
        let snap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE | TH32CS_SNAPMODULE32, pid);
        if snap == winapi::um::handleapi::INVALID_HANDLE_VALUE {
            return false;
        }

        let mut me = MODULEENTRY32W {
            dwSize: std::mem::size_of::<MODULEENTRY32W>() as u32,
            ..std::mem::zeroed()
        };

        let mut found = false;
        if Module32FirstW(snap, &mut me) != 0 {
            loop {
                let name_raw: Vec<u16> = me
                    .szModule
                    .iter()
                    .take_while(|&&c| c != 0)
                    .copied()
                    .collect();
                let name = String::from_utf16_lossy(&name_raw).to_lowercase();
                if name.contains(&frida_dll) || name.contains(&gadget_dll) {
                    found = true;
                    break;
                }
                if Module32NextW(snap, &mut me) == 0 {
                    break;
                }
            }
        }

        CloseHandle(snap);
        found
    }
}