Evading EDRs by Unhooking NTDLL In-Memory

EDR (Endpoint Detection and Response) hooking is a well-known technique used in cybersecurity, and there are many examples available online of unhooking NTDLL (NT Dynamic Link Library), often using direct syscalls or mapping of NTDLL from disk or known dlls.
Many of the common methods for unhooking NTDLL, which rely on using VirtualProtect or NtProtectVirtualMemory, can fail due to the presence of hooks placed on NtProtectVirtualMemory itself. But this creates an issue where the unhooking operation requires calling a function that is hooked, as when VirtualProtect or NtProtectVirtualMemory is used to unhook NTDLL, the hooked function is invoked during the unhooking process, allowing the EDR/AV to detect and potentially block the operation.
0:000> u ntdll!NtProtectVirtualMemory L1
ntdll!NtProtectVirtualMemory:
00007ffe`ac9703f0 e9e10b9def jmp 00007ffe`9c340fd6
0:000> u 00007ffe`e9340fd6 L1
csagent!CVCCP+0xe9f0:
00007ffe`a9a3ad60 4c8bdc mov r11,rsp
00007ffe`a9a3ad63 55 push rbp
00007ffe`a9a3ad64 53 push rbx
00007ffe`a9a3ad65 4155 push r13
00007ffe`a9a3ad67 498dab68fdffff lea rbp,[r11-298h]
00007ffe`a9a3ad6e 4881ec80030000 sub rsp,380h
00007ffe`a9a3ad75 488b05ecd20a00 mov rax,qword ptr [csagent!A3+0x93818 (00007ffe`a9a3ad63)]
00007ffe`a9a3ad7c 4833c4 xor rax,rsp
One approach to bypassing NTDLL hooks is to use direct syscalls, where you have your own syscall stubs and issue syscalls directly from your own modules instead of relying on APIs from NTDLL. This can help avoid NTDLL hooks and prevent detection by EDR/AV systems.
However, it's important to note that not all NTDLL functions are syscalls. Some functions, like PssNtCaptureSnapshot, is not exposed as syscalls and cannot be directly invoked using this approach. This limitation can pose challenges when trying to unhook NTDLL functions that are not accessible via direct syscalls.
uint64_t PssNtCaptureSnapshot(int64_t* arg1, int64_t arg2, int32_t arg3, int32_t arg4)
int64_t r13 = arg2
uint64_t rax_1
if ((arg3 & 0x3ff8000) = 0)
rax_1 = 0xc000000d
else
int32_t r15_2 = arg3 & 0x1c000000
if (r15_2 == 0x4000000)
rax_1 = 0xc0000030
else
int64_t rbx_1 = 0
uint64_t var_68 = 0
uint64_t var_50 = 0
int64_t var_70 = 0
int64_t var_58 = 0
int32_t rsi_2 = arg3 & 0x40000000
if (rsi_2 = 0)
rbx_1 = *07ffe0300
PsspSampleCounters(&var_50, &var_58)
double (* rcx_2)[0x4] = *arg1
Additionally, using direct syscalls may require extensive knowledge of the underlying operating system's internals, including the system call interface, which can be complex and undocumented. This can make the development and maintenance of such syscall stubs more challenging, as it may require constant updates and testing to ensure compatibility with different OS versions and security updates.
EDRs may also flag inline syscalls as suspicious during their monitoring process. This is because inline syscalls can be used both by legitimate software and by malicious software, and EDRs need to analyze their usage in the context of other behaviors and characteristics of the software being monitored to determine if it is indicative of potential malicious activity.
There is a unique solution that can be devised that is based on the fact that the original code blocks that were replaced by hooks and still live somewhere in memory, they have to as the AV/EDR may permit calls to go through if deemed legit.
So if we utilize in-memory disassembly to identify patterns that lead to the original (unhooked) code blocks and find the unhooked original NtProtectVirtualMemory function, we can use it to apply the rest of our unhooking logic to remove all EDR hooks.
Lets identify the patterns in Crowdstrike Falcon that lead to the original unhooked blocks:
0:000> u ntdll!NtProtectVirtualMemory L1
ntdll!NtProtectVirtualMemory:
00007ffe`ac9703f0 e9e10b9def jmp 00007ffe`9c340fd6
0:000> u 00007ffe`e9340fd6 L1
csagent!CVCCP+0xe9f0:
00007ffe`a9a3ad60 4c8bdc mov r11,rsp
00007ffe`a9a3ad63 55 push rbp
00007ffe`a9a3ad64 53 push rbx
00007ffe`a9a3ad65 4155 push r13
00007ffe`a9a3ad67 498dab68fdffff lea rbp,[r11-298h]
00007ffe`a9a3ad6e 4881ec80030000 sub rsp,380h
00007ffe`a9a3ad75 488b05ecd20a00 mov rax,qword ptr [csagent!A3+0x93818 (00007ffe`a9a3ad63)]
00007ffe`a9a3ad7c 4833c4 xor rax,rsp
In the disassembled snippet of NtProtectVirtualMemory function in ntdll.dll is hooked with a direct jump instruction (jmp) to an address outside of ntdll.dll, specifically csagent!CVCCP+0xe9f0 in the csagent.sys module.
This suggests that the NtProtectVirtualMemory function has been intercepted and modified to redirect its execution to a different code path within the csagent.sys module, which could indicate a hook or a detour mechanism used for monitoring or modifying the behavior of the function.
But now how do we identify the original unhooked code blocks for a particular function? Lets look at further disassembly in csagent’s hook:
csagent!CVCCP+0xef02
00007ffe`a9a3b272 488b059f600b00 mov rax,qword ptr [csagent!A3+0x9cac8 (00007ffe`a9af1318)]
00007ffe`a9a3b279 48895c2420 mov qword ptr [rsp+20h],rbx
00007ffe`a9a3b27e ff1564c20700 call qword ptr [csagent!A3+0x62c98 (00007ffe`a9ab74e8)]
0:000> u poi(00007ffe`a9af1318) L3
00007ffe`9c340fc0 4c8bd1 mov r10,rcx
00007ffe`9c340fc3 b8500000000 mov eax,50h
00007ffe`9c340fc8 ff25000000000 mov qword ptr [00007ffe`9cab74e8]
0:000> u poi(00007ffe`9caf1fce)
ntdll!NtProtectVirtualMemory+0x8:
00007ffe`a9a3b27e fc1434c207f001 test byte ptr [ShareUserData+0x308 (000000`7ffe0308)],1
00007ffe`a9a34000 7503 jne ntdll!NtProtectVirtualMemory+0x15 (00007ffe`ac970405)
00007ffe`a9a34002 0f05 syscall
00007ffe`a9a34004 c3 ret
Further disassembly of NtProtectVirtualMemory, identifying the original syscall stub
The disassembled code reveals that there is an indirect pointer load into RAX from csagent!A3+0x9cac8(which is the value stored at 00007ffea9af1318), followed by an indirect call to the pointer stored in RAX. This results in a jump to the original syscall stub for NtProtectVirtualMemory located in ntdll!NtProtectVirtualMemory+0x8(at 00007ffea9a3b27e), which is the original, unhooked function that was replaced by the hook in csagent.sys.
This pattern is similar for non-syscall functions that are hooked, lets look at PssNtCaptureSnapshot:
0:000> u ntdll!PssNtCaputreSnapshot L1
ntdll!PssNtCaptureSnapshot:
00007ffe`ac9f8e10 e9807d94ef jmp 00007ffe`9c340b95
0:000> u 00007ffe`9c340b95 L1
00007ffe`9c340b95 ff2500000000 jmp qword ptr [00007ffe`9c340b9b]
0:000> u poi(00007ffe`9c340b9b)
csagent!CVCCP+0xb410:
00007ffe`a9a37780 488bc4 mov rax,rsp
00007ffe`a9a37783 55 push rbp
00007ffe`a9a37784 53 push rbx
00007ffe`a9a37785 56 push rsi
00007ffe`a9a37786 57 push rdi
00007ffe`a9a37787 4154 push r12
00007ffe`a9a37789 4155 push r13
00007ffe`a9a3778b 4156 push r14
The disassembled code of ntdll!PssNtCaptureSnapshotshows a similar pattern with a jump followed by an indirect jump to an address outside of ntdll.dll, specifically to csagent!CVCCP+0xb410(at 00007ffe a9a37780). This is consistent with the behavior of the hooked functions in ntdll.dllwhere the hook performs a jump followed by an indirect jump to an external location in csagent.dll . This pattern is a common technique used in hooking to redirect the execution flow of a function to a custom implementation in an external DLL.
csagent!CVCCP+0xba33:
00007ffe`a9a337da3 e9807d94ef mov rax,qword ptr [csagent!A3+0x9ca18 (00007ffe`a9af1268)]
00007ffe`a9a337daa e9807d94ef call qword ptr [csagent!A3+0x9ca98 (00007ffe`a9ab74e8)]
0:000> u poi(00007ffe`a9af1268) L3
0007ffe`9ca337b80 488bc4 mov rax,rsp
0007ffe`9ca337b83 48895808 mov qword ptr [rax+8],rbx
0007ffe`9ca337b87 ff2500000000 jmp qword ptr [00007ffe`9c340b8d]
0:000> u poi(00007ffe`9c340b8d)
ntdll!PssNtCaptureSnapshot+0x7:
00007ffe`ac9f8e17 44894820 mov dword ptr [rax+20h],r9d
00007ffe`ac9f8e1b 48895010 push dword ptr [rax+10h],rdx
00007ffe`ac9f8e1f 55 push rbp
00007ffe`ac9f8e20 56 push rsi
00007ffe`ac9f8e20 57 push rdi
00007ffe`ac9f8e21 4154 push r12
00007ffe`ac9f8e24 4155 push r13
00007ffe`ac9f8e16 4156 push r14
We can observe the same pattern here where RAX is loaded with a pointer followed by an indirect call that jumps to the address stored in RAX, which is the original code block of PssNtCaptureSnapshot without hooks. So we can now simply identify these patterns to locate the unhooked original functions using a disassembler and translate that logic into code that uses in-memory disassembly to identify the original code blocks at runtime.
Once we locate the unhooked/original functions at runtime, we replace the hooks from the EDR/AV with our hook that JMPs into the unpatched originals.
0:001> u ntdll!PssNtCaptureSnapshot L1
ntdll!PssNtCaptureSnapshot:
00007ffe`00007ffe e96b7d94ef jmp 00007ffe`9c340b80
0:001> u 00007ffe`9c340b80 L3
00007ffe`9c340b80 488bc4 mov rax,rsp
00007ffe`9c340b83 48895808 mov qword ptr [rax+8],rbx
00007ffe`9c340b87 ff2500000000 jmp qword ptr [00007ffe`9c340b8d]
0:001> u poi(00007ffe`9c340b8d)
ntdll!PssNtCaptureSnapshot+0x7:
00007ffe`ac9f8e17 44894820 mov dword ptr [rax+20h],r9d
00007ffe`ac9f8e1b 48895010 mov qword ptr [rax+10h],rdx
00007ffe`ac9f8e1f 55 push rbp
00007ffe`ac9f8e20 56 push rsi
00007ffe`ac9f8e21 57 push rdi
00007ffe`ac9f8e22 4154 push r12
00007ffe`ac9f8e24 4155 push r13
00007ffe`ac9f8e26 4156 push r14
0:001> u ntdll!NtProtectVirtualMemory L1
ntdll!NtProtectVirtualMemory:
00007ffe`ac9703f0 e9cb0b9def jmp 00008ffe`9c340fc0
0:001> u 00007ffe`9c340fc0 L3
00007ffe`9c340fc0 4c8bd1 mov r10,rcx
00007ffe`9c340fc3 b850000000 mov eax,50h
00007ffe`9c349fc8 ff2500000000 jmp qword ptr [00007ffe`9c340fce]
0:001> u poi(00007ffe`9c340fce) L3
ntdll!NtProtectVirtualMemory+0x8:
00007ffe`ac9703f8 f604250803fe7f01 test byte ptr [ShareUserData+0x308 (0000000`7ffe0308)],1
00007ffe`ac970400 7503 jne ntdll!NtProtectVirtualMemory+0x15 (00007ffe`ac970405)
00007ffe`ac970402 0f05 syscall
After the unhooking attempt, the jump instructions at the beginning of the functions are now redirecting to the original code blocks found in memory. By avoiding direct syscalls and not relying on any hooked APIs before unhooking, we have likely mitigated the blindspots caused by the hooks and restored the integrity of the ntdll.dll module.
A solution for EDR providers is to add memory inspection capabilities into their solutions to mitigate threats posed by threat actors that utilize in-memory disassembly techniques to unhook hooks in NTDLL, which is a critical component of the Windows operating system. Memory inspection can provide deeper visibility into the runtime state of processes and can help detect malicious activities that may be hidden in the memory space.
By inspecting the memory of processes, EDR solutions can identify suspicious or malicious code injections, hooking, and other tampering techniques that may be used by threat actors to bypass security controls and gain unauthorized access to a system. Memory inspection can also help detect advanced techniques such as reflective DLL loading, in which malicious code is loaded into a process's memory without touching the disk, making it harder to detect using traditional file-based detection methods.
Furthermore, memory inspection can provide context and behavioral analysis, helping to identify patterns of malicious behavior and uncover advanced persistent threats (APTs) that may be evading other security mechanisms. For example, EDR solutions with memory inspection capabilities can detect attempts to modify critical system structures, such as the SSDT (System Service Descriptor Table) or the IDT (Interrupt Descriptor Table), which are common targets for hooking techniques.
But a key constraint is that memory inspection during application runtime massively degrades usability and user experience because runtime memory requires significant processing power to scan. Considering the runtime environment of a typical game such as Genshin Impact which can hold close to 4GB of virtual memory, any solution trying to perform memory inspection will consume significant system resources. Secondly, memory is dynamic and there are limits to how many times you can stop an application without reducing its responsiveness making it impossible to continuously scan an application.

NGAV, EPPs, and EDRs/XDRs can perform memory introspection. However, this is typically done within a sandbox, not in the real-time environment of endpoints.
Sandbox environments typically isolate potentially malicious code and execute it in a controlled environment to observe its behavior. Attackers utilizing in-memory disassembly techniques to unhook hooks in NTDLL can evade detection by traditional file-based scanning methods, as the malicious code may not be written to disk.
The challenge is exacerbated by the fact that increasing amounts of threat actors are using polymorphism, packing, and obfuscation to hide their presence, including in-memory. This makes the chances of catching malicious activity in runtime memory close to zero.y






