Welcome to this new Medium post, today we'll explore a technique to detect which functions inside ntdll.dll have been patched by an EDR or AV product, and which ones have been left clean. This gives us a clear picture of what the security solution is monitoring, and what it is not

EDR and AV products intercept system calls and API behavior by placing inline hooks inside ntdll.dll. These hooks are small patches, usually a JMP instruction at the very beginning of a function, that redirect execution to the security product's own code before letting the original function run

By scanning those first bytes of every exported function in ntdll.dll and comparing them against a clean copy read from disk, we can build a list of exactly which functions are being monitored and which ones are not. This is directly useful for choosing syscall paths that avoid detection

None

Courses: Learn how offensive development works on Windows OS from beginner to advanced taking our courses, all explained in C++.

Technique Database: Access 70+ real offensive techniques with weekly updates, complete with code, PoCs, and AV scan results:

Modules: Dive deep into essential offensive topics with our modular text-training program! Get a new module every 14 days. Start at just $1.99 per module, or unlock lifetime access to all modules for $100.

Methodology

To detect which ntdll functions have been patched by an EDR, we follow these logical steps:

  1. Load ntdll twice: First, we get a pointer to the already-mapped ntdll.dll in memory using GetModuleHandleA. Then we read the raw file from disk (System32\ntdll.dll) into a buffer. This gives us two copies: the one in memory (potentially patched) and the clean one from disk
  2. Walk the export table: We parse the IMAGE_EXPORT_DIRECTORY structure from the in-memory image to get the name and address of every exported function. This is done manually, without using GetProcAddress, so we can iterate all exports at once
  3. Filter executable sections only: Before comparing, we check that the function's RVA (Relative Virtual Address) points to an executable section (.text). This filters out data exports like NLS tables and global variables, which would always show differences and produce false positives
  4. Compare bytes: For each function, we read the first 8 bytes from both the in-memory copy and the on-disk copy, then compare them with memcmp. If they differ, something has modified that function in memory, that is a hook
  5. Classify the hook type: If a difference is found, we check the first bytes against known hook signatures (0xE9 for JMP rel32, 0xFF 0x25 for JMP [RIP+offset], etc.) to identify the technique the EDR used
ntdll.dll (disk)           ntdll.dll (memory)
─────────────────          ──────────────────
NtOpenProcess:             NtOpenProcess:
  4C 8B D1 B8 ...    ≠       E9 AB CD EF 12 ...
  (original bytes)           (EDR JMP hook)

NtCreateFile:              NtCreateFile:
  4C 8B D1 B8 ...    =       4C 8B D1 B8 ...
  (original bytes)           (clean, not monitored)

Implementation

Now, let's look at how to translate that logic into C++ code. I have broken down the most important parts.

Reading ntdll from disk

Before we can compare anything, we need a clean reference copy of ntdll.dll. We read it straight from System32 into a std::vector<BYTE> buffer:

// GetSystemDirectoryA gives us the real System32 path,
std::vector<BYTE> ReadDiskNtdll() {
    char path[MAX_PATH];
    GetSystemDirectoryA(path, MAX_PATH);
    strcat_s(path, "\\ntdll.dll");

    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};

    std::streamsize size = f.tellg();
    f.seekg(0, std::ios::beg);

    std::vector<BYTE> buf(size);
    f.read(reinterpret_cast<char*>(buf.data()), size);
    return buf;
}

This raw buffer is a PE file, not a mapped image. The sections are not yet mapped to their virtual addresses, so we need a helper to translate RVAs into file offsets before we can read any function's bytes from it

Filtering Data Exports

Not every export in ntdll is a function. Some are global variables, NLS tables, or internal data structures. Their bytes will always differ between disk and memory because the Windows loader writes runtime values into them. We must skip these to avoid false positives.

The correct filter is to check whether the export's RVA falls inside a section with the IMAGE_SCN_MEM_EXECUTE flag:

// If the RVA does not point to an executable section,
// this export is data, not code, skip it
BOOL IsInExecutableSection(PBYTE base, DWORD rva) {
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
    PIMAGE_NT_HEADERS nt  = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        DWORD start = sec[i].VirtualAddress;
        DWORD end   = start + sec[i].Misc.VirtualSize;
        if (rva >= start && rva < end)
            return (sec[i].Characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
    }
    return FALSE;
}

This single check eliminates exports like NlsAnsiCodePage, RtlNtdllName, KiUserInvertedFunctionTable, and RtlpFreezeTimeBias, which are all data and would generate noise in our results

Safe Memory Read

Reading arbitrary function pointers can cause an access violation if a page is not readable. We wrap the memcpy inside a dedicated function that only contains POD types, because MSVC does not allow __try in functions that have C++ objects with destructors:

// This function has no C++ objects, MSVC can build the SEH frame correctly
// If the page is not readable, we return FALSE and skip this export
BOOL SafeReadBytes(PVOID src, BYTE* dst, SIZE_T len) {
    __try {
        memcpy(dst, src, len);
        return TRUE;
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        return FALSE;
    }
}

The same isolation pattern is applied to reading function names from the export table, using strnlen to avoid reading past a null terminator into garbage memory

The Comparison Loop

With all helpers in place, the core logic is straightforward. For each export that passes the executable-section filter, we read 8 bytes from both the in-memory image and the disk buffer, then compare:

// memBytes  = first 8 bytes of the function as it exists in memory right now
// diskBytes = first 8 bytes of the same function in the untouched file on disk
// If they differ, an EDR has patched this function
entry.differs = diskFunc && (memcmp(entry.memBytes, entry.diskBytes, 8) != 0);

If differs is true and the function is not in our whitelist (which covers DbgBreakPoint and the NtdllDefWindowProc thunks), we classify the hook type from the first byte and report it

You can learn more about userland hooking for just $1.99 in our AV & EDR Hooking Evasion module:

Code

The full scanner is structured around four components: the disk reader, the PE RVA resolver, the executable section filter, and the main export walkk loop

The export table is parsed manually from the IMAGE_EXPORT_DIRECTORY, which gives us three parallel arrays: function names, ordinals, and function RVAs. For each name, we resolve its ordinal to get the function RVA, check it is in an executable section, read 8 bytes from both sources, and compare. The output separates clean functions from hooked ones, and for hooked functions it prints both the in-memory bytes and the original disk bytes side by side, so you can see exactly what was replaced

// NtdllHookScanner.cpp
// Author: 0x12 Dark Development

#include <Windows.h>
#include <iostream>
#include <vector>
#include <string>
#include <iomanip>
#include <fstream>

#define RED     "\033[31m"
#define GREEN   "\033[32m"
#define YELLOW  "\033[33m"
#define CYAN    "\033[36m"
#define RESET   "\033[0m"

static const char* WHITELIST[] = {
    "DbgBreakPoint",
    "DbgUserBreakPoint",
    "NtdllDefWindowProc_A",
    "NtdllDefWindowProc_W",
    "NtdllDialogWndProc_A",
    "NtdllDialogWndProc_W",
    nullptr
};

bool IsWhitelisted(const std::string& name) {
    for (int i = 0; WHITELIST[i]; i++)
        if (name == WHITELIST[i]) return true;
    return false;
}

std::vector<BYTE> ReadDiskNtdll() {
    char path[MAX_PATH];
    GetSystemDirectoryA(path, MAX_PATH);
    strcat_s(path, "\\ntdll.dll");

    std::ifstream f(path, std::ios::binary | std::ios::ate);
    if (!f) return {};

    std::streamsize size = f.tellg();
    f.seekg(0, std::ios::beg);

    std::vector<BYTE> buf(size);
    f.read(reinterpret_cast<char*>(buf.data()), size);
    return buf;
}

PBYTE RvaToPtr(const std::vector<BYTE>& buf, DWORD rva) {
    if (buf.empty()) return nullptr;

    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)buf.data();
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(buf.data() + dos->e_lfanew);
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        DWORD vStart = sec[i].VirtualAddress;
        DWORD vEnd = vStart + sec[i].Misc.VirtualSize;
        if (rva >= vStart && rva < vEnd) {
            DWORD offset = rva - vStart + sec[i].PointerToRawData;
            if (offset + 8 <= (DWORD)buf.size())
                return (PBYTE)buf.data() + offset;
        }
    }
    return nullptr;
}

// ── Fix: filter out exports that live in non-executable sections ──
BOOL IsInExecutableSection(PBYTE base, DWORD rva) {
    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)base;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(base + dos->e_lfanew);
    PIMAGE_SECTION_HEADER sec = IMAGE_FIRST_SECTION(nt);

    for (WORD i = 0; i < nt->FileHeader.NumberOfSections; i++) {
        DWORD start = sec[i].VirtualAddress;
        DWORD end = start + sec[i].Misc.VirtualSize;
        if (rva >= start && rva < end)
            return (sec[i].Characteristics & IMAGE_SCN_MEM_EXECUTE) != 0;
    }
    return FALSE;
}

std::string GetHookType(const BYTE* p) {
    if (p[0] == 0xE9)                 return "JMP rel32 (E9)";
    if (p[0] == 0xFF && p[1] == 0x25) return "JMP [RIP+offset] (FF 25)";
    if (p[0] == 0x48 && p[1] == 0xB8) return "MOV RAX trampoline (48 B8)";
    if (p[0] == 0x68 && p[5] == 0xC3) return "PUSH+RET (68...C3)";
    if (p[0] == 0xCC)                  return "INT3 (CC)";
    return "";
}

void PrintBytes(const BYTE* b, int n) {
    for (int i = 0; i < n; i++)
        std::cout << std::hex << std::uppercase
        << std::setw(2) << std::setfill('0')
        << (int)b[i] << " ";
    std::cout << std::dec;
}

BOOL SafeReadBytes(PVOID src, BYTE* dst, SIZE_T len) {
    __try {
        memcpy(dst, src, len);
        return TRUE;
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        return FALSE;
    }
}

// ── Fix: safe name read using strnlen to avoid reading past null ──
BOOL SafeReadName(PVOID src, char* dst, SIZE_T maxLen) {
    __try {
        SIZE_T len = strnlen((const char*)src, maxLen - 1);
        memcpy(dst, src, len);
        dst[len] = '\0';
        return TRUE;
    }
    __except (EXCEPTION_EXECUTE_HANDLER) {
        return FALSE;
    }
}

struct FuncEntry {
    std::string name;
    PVOID       address;
    BYTE        memBytes[8];
    BYTE        diskBytes[8];
    bool        differs;
    std::string hookType;
};

int main() {
    HANDLE hOut = GetStdHandle(STD_OUTPUT_HANDLE);
    DWORD mode = 0;
    GetConsoleMode(hOut, &mode);
    SetConsoleMode(hOut, mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING);

    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    if (!hNtdll) { std::cerr << "[-] GetModuleHandleA failed\n"; return 1; }
    PBYTE memBase = (PBYTE)hNtdll;

    std::vector<BYTE> diskBuf = ReadDiskNtdll();
    if (diskBuf.empty()) { std::cerr << "[-] Could not read ntdll from disk\n"; return 1; }

    PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)memBase;
    PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(memBase + dos->e_lfanew);

    DWORD expRVA = nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress;
    if (!expRVA) { std::cerr << "[-] No export directory\n"; return 1; }

    PIMAGE_EXPORT_DIRECTORY expDir = (PIMAGE_EXPORT_DIRECTORY)(memBase + expRVA);
    PDWORD nameRVAs = (PDWORD)(memBase + expDir->AddressOfNames);
    PWORD  nameOrdinals = (PWORD)(memBase + expDir->AddressOfNameOrdinals);
    PDWORD funcRVAs = (PDWORD)(memBase + expDir->AddressOfFunctions);

    std::vector<FuncEntry> cleanList, hookedList;

    for (DWORD i = 0; i < expDir->NumberOfNames; i++) {
        DWORD nameRVA = nameRVAs[i];
        if (nameRVA == 0 || nameRVA >= nt->OptionalHeader.SizeOfImage) continue;

        char nameBuf[256] = {};
        if (!SafeReadName(memBase + nameRVA, nameBuf, sizeof(nameBuf))) continue;
        std::string name(nameBuf);
        if (name.empty()) continue;

        WORD  ordinal = nameOrdinals[i];
        DWORD funcRVA = funcRVAs[ordinal];
        if (funcRVA == 0) continue;

        if (!IsInExecutableSection(memBase, funcRVA)) continue;

        PVOID memFunc = memBase + funcRVA;

        FuncEntry entry;
        entry.name = name;
        entry.address = memFunc;
        memset(entry.memBytes, 0, 8);
        memset(entry.diskBytes, 0, 8);

        if (!SafeReadBytes(memFunc, entry.memBytes, 8)) continue;

        PBYTE diskFunc = RvaToPtr(diskBuf, funcRVA);
        if (diskFunc)
            memcpy(entry.diskBytes, diskFunc, 8);

        entry.differs = diskFunc && (memcmp(entry.memBytes, entry.diskBytes, 8) != 0);
        entry.hookType = GetHookType(entry.memBytes);

        if (!entry.differs) {
            cleanList.push_back(entry);
        }
        else {
            if (IsWhitelisted(name)) continue;
            if (entry.hookType.empty()) entry.hookType = "UNKNOWN patch";
            hookedList.push_back(entry);
        }
    }

    std::cout << GREEN << "[+] CLEAN (" << cleanList.size() << "):\n" << RESET
        << std::string(72, '-') << "\n";
    for (auto& e : cleanList) {
        std::cout << GREEN << "  [CLEAN]  " << RESET
            << std::left << std::setw(40) << e.name
            << "  bytes: ";
        PrintBytes(e.memBytes, 8);
        std::cout << "\n";
    }

    std::cout << "\n" << RED << "[!] HOOKED (" << hookedList.size() << "):\n" << RESET
        << std::string(72, '-') << "\n";
    for (auto& e : hookedList) {
        std::cout << RED << "  [HOOKED] " << RESET
            << std::left << std::setw(40) << e.name
            << " @ " << e.address << "\n"
            << "           mem:  ";
        PrintBytes(e.memBytes, 8);
        std::cout << "\n           disk: ";
        PrintBytes(e.diskBytes, 8);
        std::cout << "  [" << YELLOW << e.hookType << RESET << "]\n";
    }

    std::cout << "\n" << CYAN
        << "Scanned: " << (cleanList.size() + hookedList.size())
        << "  |  Hooked: " << hookedList.size()
        << "  |  Clean: " << cleanList.size()
        << RESET << "\n";

    return 0;
}

Proof of Concept

None

This memset is just a false positive.

We build custom C2 agents and implants for red teams, giving full control and stealthy operation in real-world tests.

Detection

Now it's time to see if the defenses are detecting this as a malicious threat

Kleenscan API

[*] Antivirus Scan Results:

  - alyac                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - amiti                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - arcabit              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - avast                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - avg                  | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - avira                | Status: scanning   | Flag: Scanning results incomplete    | Updated: 2026-05-16
  - bullguard            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - clamav               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - comodolinux          | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - crowdstrike          | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - drweb                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - emsisoft             | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - escan                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - fprot                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - fsecure              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - gdata                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - ikarus               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - immunet              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - kaspersky            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - maxsecure            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - mcafee               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - microsoftdefender    | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - nano                 | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - nod32                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - norman               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - secureageapex        | Status: ok         | Flag: Unknown                        | Updated: 2026-05-16
  - seqrite              | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - sophos               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - threatdown           | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - trendmicro           | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - vba32                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - virusfighter         | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - xvirus               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - zillya               | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - zonealarm            | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16
  - zoner                | Status: ok         | Flag: Undetected                     | Updated: 2026-05-16

YARA

Here a YARA rule to detect this technique:

rule Ntdll_Hook_Scanner_Technique
{
    meta:
        description = "Detects tools that scan ntdll.dll exports by comparing in-memory bytes against the on-disk PE to identify inline hooks placed by EDR/AV products"
        author      = "0x12 Dark Development"
        date        = "2026-05-17"
        technique   = "T1562.001 - Impair Defenses: Disable or Modify Tools"
        severity    = "high"
        category    = "defense_evasion"

    strings:
        // PE export table structures accessed by name
        $str_ntdll          = "ntdll.dll"              ascii wide
        $str_system32       = "System32"               ascii wide nocase
        $str_system32_2     = "system32\\ntdll.dll"    ascii wide nocase

        // Export directory field names or string references in debug builds
        $str_export_dir     = "IMAGE_EXPORT_DIRECTORY" ascii wide
        $str_addr_names     = "AddressOfNames"         ascii wide
        $str_addr_funcs     = "AddressOfFunctions"     ascii wide

        // Hook signature byte patterns searched in memory
        // JMP rel32 first byte — 0xE9
        $hook_sig_e9        = { E9 ?? ?? ?? ?? }
        // JMP [RIP+offset] — 0xFF 0x25
        $hook_sig_ff25      = { FF 25 ?? ?? ?? ?? }
        // MOV RAX trampoline — 0x48 0xB8
        $hook_sig_48b8      = { 48 B8 ?? ?? ?? ?? ?? ?? ?? ?? FF E0 }

        // GetModuleHandleA("ntdll.dll") pattern — common in hook scanners
        $api_getmodule      = "GetModuleHandleA"       ascii wide
        $api_getconsole     = "GetConsoleMode"         ascii wide

        // Section characteristic flag IMAGE_SCN_MEM_EXECUTE = 0x20000000
        $scn_exec_flag      = { 00 00 00 20 }

        // memcmp used to compare memory vs disk bytes
        $api_memcmp         = "memcmp"                 ascii wide

        // ifstream binary open of a PE file from disk
        $str_binary_flag    = "binary"                 ascii wide
        $str_ios_ate        = { 08 00 00 00 }          // std::ios::ate value

    condition:
        uint16(0) == 0x5A4D and     // MZ header
        filesize < 3MB and

        // Must reference ntdll and System32 — loading disk copy
        $str_ntdll and
        ($str_system32 or $str_system32_2) and

        // Must use GetModuleHandleA to get in-memory base
        $api_getmodule and

        // Must contain at least two hook byte signatures being searched
        2 of ($hook_sig_e9, $hook_sig_ff25, $hook_sig_48b8) and

        // Must use memcmp — the core comparison primitive
        $api_memcmp and

        // Must reference the executable section flag or export table fields
        (
            $scn_exec_flag or
            any of ($str_export_dir, $str_addr_names, $str_addr_funcs)
        )
}

Here you have my collection of YARA rules:

Conclusions

This scanner shows how to use the Windows PE format itself against EDR products. By loading ntdll.dll twice, once from memory and once from disk, and comparing the first bytes of every executable export, we get an accurate map of which functions are being monitored and which are not, without triggering any monitored API and without relying on fragile byte signatures. The result is a reliable and noise-free list of clean syscall candidates.

📌 Follow me: YouTube | 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter

S12.