Evading EDRs by Unhooking NTDLL In-Memory

Evading EDRs by Unhooking NTDLL In-Memory

·

8 min read

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.

https://pimages.toolbox.com/wp-content/uploads/2023/04/03114908/image2-1024x379.png

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