Intro
This article attempts to analyse a specific function or detection mechanism of a particular EDR through debugging and reverse engineering. My primary goal was not to delve into the details of the reverse engineering process. Rather, I was interested in developing a solid understanding of how the newly implemented detection mechanism in the EDR works, exploring the functions of this mechanism, and questioning the reasons for its possible implementation.
Furthermore, I think it is important to understand that any commercial EDR is ultimately a black box. You can try to learn more about how the EDR works through various methods such as static and dynamic analysis, but the inner workings and logic in particular remain largely a black box. Hypotheses can be formulated about the inner workings and logical construction of EDRs, which can then be analysed to validate these hypotheses. However, it is often difficult to achieve complete clarity and unambiguous statements about their functionality.
Detection Mechanisms
Endpoint Detection and Response (EDR) systems use different mechanisms to detect malware depending on the phase. In the static phase, scanners are often used to analyse files for known hash values, signatures and specific byte sequences. If an attacker manages to bypass this static detection, many EDRs resort to sandboxing. This involves running a potentially suspicious file in a virtualised environment to analyse its behaviour. In addition, EDRs implement methods such as user-mode hooking (e.g. inline API hooking or IAT hooking), the Antimalware Scan Interface (AMSI), event tracing for Windows, as well as threat intelligence and kernel callbacks for behaviour-based detection.
In some of my previous articles I have mentioned the concept of inline API hooking, which is used by some EDRs to actively interfere with the execution of code and parameters of Windows APIs. For more information on this, see my recent blog post Syscalls via Vectored Exception Handling.
Unconventional Detection
In this article, however, I would like to discuss a rather unconventional but very interesting detection mechanism used in conjunction with EDR, based on a combination of process environment block (PEB) modification, the use of fake DLLs and guard pages, and the use of vectored exception handling.
Fake DLLs
When a process is initialised on Windows, the required DLLs are loaded into virtual memory. This happens in a specific order, depending on the dependencies and requirements of each process. For system-wide DLLs, such as ntdll.dll
and kernel32.dll
, the operating system stores references (pointers) in the process's virtual memory. These pointers point to the actual physical locations of the DLLs in shared memory.
The order in which the DLLs are loaded is determined by the double linked list InLoadOrderModuleList
within the structure PEB_LDR_DATA
in the Process Environment Block (PEB). The ntdll.dll
and kernel32.dll
are essential key components for any Windows process and are always loaded.
A special observation arises when analysing an active process, such as cmd.exe
, on a system equipped with an EDR to be analysed. We use the Process Hacker tool to analyse the active process cmd.exe
in more detail, focusing in particular on the Modules tab to check the modules loaded in cmd.exe
. At first glance, it appears that kernel32.dll
and ntdll.dll
have been loaded twice. However, a closer look reveals that the spelling of these seemingly duplicate DLLs is different, as one version of each is written in Leetspeak. A closer examination of these duplicate DLL versions with Process Hacker shows that the file sizes of the fakes are exactly the same as those of the original DLLs. It is also noticeable that the module descriptions are missing.
Attempts to use Process Hacker to investigate these suspected knock-offs do not produce any results. They cannot be analysed and the corresponding images of these files cannot be found on the hard drive (error message: Unable to load the PE file: The object name was not found). Obviously, these imitations are some kind of fake versions of the corresponding DLLs, which is why we want to call them "fake DLLs". A closer look at the fake DLL in the context of ntdll.dll
with Process Hacker reveals that it is actually a manually mapped version of ntdll.dll
, albeit with a different name in Leetspeek. A closer look at the memory protection reveals another interesting feature: the memory allocated as RX
(read-execute) is also provided with a guard page (RX+G
). We will note this detail and take a closer look at what it is about later.
What we can already say is that the "Fake DLL" is not a real standalone DLL that can be found on the disc, but a manually mapped version of ntdll.dll
that has been manually renamed to a name similar to ntdll.dll
.
May I Check Your P3B?
To gain a better understanding of the role of this fake DLLs in the context of the EDR system being analysed, we turn our attention to the Process Environment Block (PEB) of an active process - e.g. cmd.exe
- on a system with EDR installed.
The PEB is a crucial memory structure in any Windows process, responsible for managing process-specific data such as the program base address, heap, environment variables and command line information. Within its structure, which contains numerous fields and pointers, the Ldr
structure is particularly noteworthy, as it is responsible for managing loaded modules, especially DLLs. The PEB is unique to each process and plays a key role in process management and the DLL loading mechanism. It provides the process with the necessary information and resources for efficient execution and management.
However, a full discussion of the PEB is beyond the scope of this article. For a more in-depth understanding, I recommend that those interested delve into the Windows Internals. However, our primary goal is to learn more about the use of fake DLLs by the EDR.
To investigate exactly when the fake DLLs are mapped into memory, we use WinDbg. After attaching to an active cmd.exe
process, we try to access the double linked list InLoadOrderModuleList
within the Process Environment Block (PEB). This step allows us to analyse the load times and order of modules in memory, including the identification of fake DLLs.
The first step is to use the !peb
command in WinDbg to access the PEB of cmd.exe
. As can be seen in the following figure, the second position in the InMemoryOrderModuleList
is the fake version of ntdll.dll
and the third position is the fake version of kernel32.dll
. This confirms that these have been successfully mapped into the process's memory. It is important to note that the difference between the InMemoryOrderModuleList
and the InLoadOrderModuleList
is that the InMemoryOrderModuleList
lists the modules in the order they are in the process's virtual memory. The InLoadOrderModuleList
, on the other hand, specifies the order in which the DLLs are loaded.
To verify this observation, we want to access the InLoadOrderModuleList
within the Ldr
structure and check the modules listed in second and third place. To do this, we first need to find the address of Ldr
(in this case 00007ffa61ebc4c0
). The structure PEB_LDR_DATA
is then accessed in WinDbg with the command dt nt!_PEB_LDR_DATA 00007ffa61ebc4c0
. The following figure shows how to access the PEB_LDR_DATA
structure. Starting at the base address with an offset of 0x10
, there is the entry InLoadOrderModuleList
, which gives us clear information as to whether the fake ntdll.dll
was actually loaded at position 2 and the fake kernel32.dll
at position 3 during process initiation.
The next step is to access the first module in this list using the start address of InLoadOrderModuleList
(in this case 0x000001d5`eb302650
). This is done with the command dt nt!_LDR_DATA_TABLE_ENTRY 0x000001d5`eb302650
in WinDbg. The following figure shows that the first module is the image of cmd.exe
itself. This is to be expected, as the image of the executing process must always be first in the sequence of modules.
To access the second module in the order, we again use the previous command in WinDbg, but update the address to the start address of InLoadOrderLinks
, which in this case is 0x000001d5`eb313d10
. The following screenshot after running this command shows that the fake version of ntdll.dll
is actually loaded as the second module in the order.
To examine the third module in the module load order, we repeat the previous step in WinDbg, but update the address accordingly to the start address of the InLoadOrderLinks
for the third module, in this case 0x000001d5`eb317c20
. The following figure confirms our earlier findings: The fake kernel32.dll
is loaded as the third module in the list.
To complete our analysis, we repeat this step twice in WinDbg to determine the positions of the other modules in the load order. The resulting figure shows that the real ntdll.dll
is in fourth place and the real kernel32.dll
is in fifth place in the module load order. This information will be important later.
Note that InLoadOrderModuleList
is also used to determine where the EDR hooking DLL is loaded. In this case, the hooking DLL is loaded as the seventh module during process initialisation, after the kernelbase.dll
is loaded as the sixth module.
Where is the catch?
If the same analysis is performed with WinDbg on an endpoint without an installed EDR system or on an endpoint with an alternative EDR system, a significantly different picture emerges regarding the order in which the modules are loaded within the InLoadOrderModuleList
. The analysis results show that there are no fake DLLs in these scenarios. It is also clear that ntdll.dll
is loaded as usual in second place and kernel32.dll
in third place in the module list. This direct comparison underlines the uniqueness of the EDR system originally investigated, which differs from standard configurations by the use of fake DLLs and a different module loading order.
Our analysis results and comparison with WinDbg make it clear that the Process Environment Block (PEB), or more specifically the InLoadOrderModuleList
of the process - here using the example of cmd.exe
- is specifically modified on the endpoint with the specific EDR system.
Guard Pages
By manipulating the InLoadOrderModuleList
in the PEB, EDR ensures that when a process is initialised in user mode, the fake versions of ntdll.dll
and kernel32.dll
are loaded in second and third place, before the real ntdll.dll
and kernel32.dll
follow in fourth and fifth place. But what is the purpose of this manipulation by the EDR?
To answer this question, let's take another look at the corrupted versions of ntdll.dll
and kernel32.dll
. As we have already established, these are versions of the original DLLs that have been manually mapped into memory, supplemented by a guard page in memory that is committed as RX
(read-execute). According to Microsoft documentation, the guard page can trigger a STATUS_GUARD_PAGE_VIOLATION (0x80000001)
exception.
These observations suggest that the manipulation of the Process Environment Block (PEB) using fake DLLs and guard pages is likely to be used to activate a Vectored Exception Handler (VEH) registered by the EDR. A closer look at EDR's x64 Hooking DLL supports this theory: The Windows APIs AddVectoredExceptionHandler
and RemoveVectoredExceptionHandler
required to implement a VEH are imported from kernel32.dll
. If the EDR actually registers a VEH, this would mean that when an exception is thrown via a guard page, the EDR takes control of the program flow via the VEH instead of passing the exception to the Structured Exception Handler (SEH).
From another perspective, after the guard page is triggered in the fake DLL of the EDR, the exception STATUS_GUARD_PAGE_VIOLATION (0x80000001)
must be handled by a vectored exception handler or the structured exception handler. Given the effort required by the EDR, it seems unlikely that it would be passed to the SEH, whereas it seems much more plausible and sensible to pass it to a specially registered VEH. This means that the EDR can actively intervene in the rest of the program flow after the exception has been triggered, and thus prevent malware if necessary. Ultimately, this leads to a redirection of the application's flow, which can be described as hooking. More specifically, guard page hooking or page guard hooking, as described in this article.
The following code shows how page guard hooking can be implemented in conjunction with vectored exception handling in C. It is important to emphasise that this code is only an example and a basic framework and does not contain the specific logic of EDR for responding to exceptions. However, it would be conceivable for EDR to restore the guard page in the corresponding fake DLL after it has been triggered, in order to be able to continue monitoring using the guard page.
#include <windows.h>
#include <stdio.h>
// Vectored Exception Handler function
// This function is called when an exception occurs, such as a guard page violation
LONG CALLBACK GuardPageExceptionHandler(PEXCEPTION_POINTERS pExceptionInfo) {
// Check if the exception is a guard page violation
if (pExceptionInfo->ExceptionRecord->ExceptionCode == STATUS_GUARD_PAGE_VIOLATION) {
printf("Guard Page Access Detected!\n");
// Here you can add logic to log the violation, analyze the access pattern,
// or take any other appropriate action based on your EDR's requirements.
// Optional: Restore the guard page here if you want continuous monitoring
// Continue execution after handling the exception
return EXCEPTION_CONTINUE_EXECUTION;
}
// If it's not a guard page violation, continue searching for other handlers
return EXCEPTION_CONTINUE_SEARCH;
}
int main() {
// Set up a sensitive area of memory to monitor
// This could represent a critical section of memory you want to protect
SYSTEM_INFO si;
GetSystemInfo(&si); // Get system information, including page size
LPVOID pMemory = VirtualAlloc(NULL, si.dwPageSize, MEM_COMMIT, PAGE_READWRITE);
if (pMemory == NULL) {
printf("Memory allocation failed\n");
return 1;
}
// Protect the sensitive memory with a guard page
// Any access to this page will trigger the guard page violation
DWORD oldProtect;
if (!VirtualProtect(pMemory, si.dwPageSize, PAGE_GUARD | PAGE_READWRITE, &oldProtect)) {
printf("Failed to set guard page\n");
VirtualFree(pMemory, 0, MEM_RELEASE); // Clean up if setting the guard page fails
return 1;
}
// Register the Vectored Exception Handler
// This handler will be invoked for exceptions, including guard page violations
PVOID handler = AddVectoredExceptionHandler(1, GuardPageExceptionHandler);
if (handler == NULL) {
printf("Failed to add Vectored Exception Handler\n");
VirtualFree(pMemory, 0, MEM_RELEASE); // Clean up if handler registration fails
return 1;
}
// Your application logic goes here
// This is where you would implement the rest of your EDR's functionality
// ...
// Clean up before exiting the application
// This includes unregistering the exception handler and freeing allocated memory
RemoveVectoredExceptionHandler(handler);
VirtualFree(pMemory, 0, MEM_RELEASE);
return 0;
}
It is also interesting to note that the Windows API AddVectoredExceptionHandler
appears within the hooking DLL together with the API LdrEnumerateLoadedModules
, the ntdll.dll
and the term PEBTrap
. This may be an indication of the interaction between the modification of the PEB, the fake DLLs, Guard Pages and the Vectored Exception Handler.
To better understand the conditions under which the Vectored Exception Handler (VEH) of the EDR is activated by STATUS_GUARD_PAGE_VIOLATION (0x80000001)
, a closer look at the hooking DLL provides interesting insights. It appears that there is a special comparison operation within this DLL that checks whether the corresponding exception with code 0x80000001
occurs after a call to a native API due to the guard page being triggered in the fake ntdll.dll
. Specifically, if an exception of value 0x80000001
is thrown, the EDR's vectored exception handler becomes active (and maybe terminates the process if necessary). However, if no exception of value 0x80000001
follows the call to the corresponding native API, e.g. NtProtectVirtualMemory
, then further execution of the native API is allowed. However, this is currently a hypothesis and not a definitive statement. But, it is interesting to note that this comparison is made in the hooking DLL for about 25 native APIs, including NtAllocateVirtualMemory
, NtWriteVirtualMemory
, NtProtectVirtualMemory
etc., which are often used in the context of malware execution.
DLL Base vs. Original Base
The main problem from a malware developer's point of view is when the malware relies on dynamically retrieving information from the PEB or from ntdll.dll
or kernel32.dll
. A concrete example of this is the use of shellcode loaders or shellcode that directly or indirectly uses syscalls, for example, without anchoring the System Service Numbers (SSNs
) in the code. Instead, it attempts to obtain these SSNs
dynamically at runtime within ntdll.dll
using a combination of PEB walk and Export Address Table (EAT) parsing.
To access the base address of ntdll.dll
via PEB-Walk, offset 0x30
for DLLBase
is usually used. However, in the context of our EDR, this results in you ending up in the memory of the fake version of ntdll.dll
, thus triggering the EDR's vectored exception handler via the page guard hook.
To verify the accuracy of this statement, we plan an experiment using the following C code. Our aim is to use a PEB walk and access the base address of ntdll.dll
via offset 0x30
DllBase
to determine and output its memory address. We then want to compare this address with the memory addresses of the real and fake ntdll.dll
in the cmd.exe
memory.
#include <windows.h>
#include <stdio.h>
UINT_PTR NtdllDllBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);
// Traverse to the second module in the list (typically ntdll.dll)
UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);
// Uncomment the following line to advance to the third module
// secondModule = *(UINT_PTR*)(secondModule);
// Access the base address of the module by using DLL Base
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0x30);
return baseAddress;
}
int main() {
UINT_PTR ntdllBase = NtdllDllBase();
printf("Base address (offset 0x30) of the loaded ntdll.dll: %p\n", (void*)ntdllBase);
printf("Press any key to exit...");
getchar(); // wait for keypress
return 0;
}
The following figure shows that the base address found using the 0x30
DllBase
offset does not match the real ntdll.dll
in memory. However, if you examine the fake ntdll.dll
implemented by the EDR system, you will see that the memory addresses match. This means that during a PEB walk, when using the 0x30
DllBase
offset, the memory area accessed is not that of the real ntdll.dll
, but that of the fake ntdll.dll
used by the EDR system for page guard hooking.
In the next step of our experiment, we want to adapt our C code to not only access and output the base address of ntdll.dll
via offset 0x30
(DllBase
), but also to determine and output the base address of the same DLL via offset 0xF8
(OriginalBase
). The OriginalBase
provides an alternative method of accessing the base address of a module in the InLoadOrderModuleList
. By extending our code in this way, we can compare the two addresses found with the addresses of the real and fake ntdll.dll
in memory.
#include <windows.h>
#include <stdio.h>
UINT_PTR NtdllDllBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);
// Traverse to the second module in the list (typically ntdll.dll)
UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);
// Uncomment the following line to advance to the third module
// secondModule = *(UINT_PTR*)(secondModule);
// Access the base address of the module
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0x30);
return baseAddress;
}
UINT_PTR NtdllOriginalDLLBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);
// Traverse to the second module in the list (typically ntdll.dll)
UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);
// Uncomment the following line to advance to the third module
// secondModule = *(UINT_PTR*)(secondModule);
// Access the base address of the module by using Original Base
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);
return baseAddress;
}
int main() {
UINT_PTR ntdllBase = NtdllDllBase();
UINT_PTR ntdllOriginalBase = NtdllOriginalDLLBase();
printf("Base address (offset 0x30) of the loaded ntdll.dll: %p\n", (void*)ntdllBase);
printf("Original base address (offset 0xF8) of the loaded ntdll.dll: %p\n", (void*)NtdllOriginalDLLBase());
printf("Press any key to exit...");
getchar(); // wait for keypress
return 0;
}
After running our extended code on the endpoint with EDR installed, the following figure shows a revealing result: The memory address found using offset 0xF8
for OriginalBase
corresponds to the address of the real ntdll.dll
. This confirms that a PEB walk using offset 0x30
(DllBase
) will take us to the memory area of the fake ntdll.dll
inserted by EDR. However, if we choose the offset 0xF8
for OriginalBase
, we will get to the memory area of the real ntdll.dll
instead.
One key question remains unanswered: Why do we get the memory address of the legitimate ntdll.dll
when we use offset 0xF8
for OriginalBase
, and the memory address of the fake ntdll.dll
when we use offset 0x30
for DllBase
?
This is because the EDR overwrites the memory address or pointer of DllBase
with the address pointing to the memory area of the fake ntdll.dll
. This can be checked in a virtual machine with EDR installed. If you look at the structure of the ntdll.dll
that EDR is manipulating with WinDbg, you will see that the DllBase
pointer is replaced with the memory address that points to the fake ntdll.dll
from EDR. The memory address for OriginalBase
, on the other hand, still points to the correct, legitimate ntdll.dll
. The following figure illustrates this.
Vectored Exception Handling
Before we take a closer look at the internal logic of the detection chain we are going to analyse in the next section, I would like to look in more detail at how the theory that the EDR we are going to analyse uses Vectored Exception Handling in the context of certain processes can be substantiated. We have already seen that EDR imports the AddVectoredExceptionHandler
and RemoveVectoredExceptionHandler
functions from kernel32.dll
. However, to prove that a process is using a Vectored Exception Handler or is being monitored by a registered Vectored Exception Handler, let's take a closer look at the Process Environment Block (PEB). Using Windbg for debugging, we want to look at the value for CrossProcessFlags
(offset 0x50
for 64bit) within the PEB. Based on the following article by Olllie Whitehouse and the documentation by Geoff Chapell, the CrossProcessFlags
entry can tell us if a process is using a VEH. In other words, if the CrossProcessFlags
entry has a decimal value of 4, then the process is using a VEH. On the other hand, if the CrossProcessFlags
entry has a decimal value of 0, then the process is not using a VEH.
This can be clearly seen in the following illustration: a notepad.exe
was started on a VM with the EDR to analyse and the value for the CrossProcessFlags
entry was checked using Windbg within the PEB. You can clearly see that CrossProcessFlags
has a decimal value of 4 (in hex 0x00000004
) and is therefore using a VEH, or presumably the VEH of the EDR (but this will be examined in more detail later). However, in the comparison on the right-hand side of the figure, we see the same process on a VM with no EDR installed. As expected, the CrossProcessFlags
entry here is 0, i.e. the notepad.exe
process is not using the VEH.
So examining the CrossProcessFlags
entry in the PEB tells us if a process is using Vectored Exception Handling, but it would also be interesting to know which module is responsible for registering the VEH. In other words, we want to prove that the VEH was registered by the EDR. To provide this proof, we load the image notepad.exe
with a debugger such as x64dbg and set a breakpoint on the native API RtlAddVectoredExceptionHandler
. When the breakpoint is triggered, we look at the call stack and check from which address or module the RtlAddVectoredExceptionHandler
function was called. In other words, if the registration of the VEH is done by the EDR, the address on the call stack before the call to RtlAddVectoredExceptionHandler
is expected to be an address associated with the EDR.
The following figure underlines this expectation; it can be seen that after the breakpoint was triggered in the context of RtlAddVectoredExceptionHandler
, the stack frame was previously called in the context of the user mode hooking DLL of the EDR. This shows that the function call for RtlAddVectoredExceptionHandler
was made by the EDR user mode hooking DLL.
These investigations have enabled us to check whether a process is using a VEH using the CrossProcessFlags
entry in the PEB, and also to prove that the call to the native function RtlAddVectoredExceptionHandler
, which is required to register the VEH, is made by the EDR user mode hooking DLL.
EDR DLL - Internal Logic
Before we get to the summary and possible workarounds, let us try to better understand how the EDR checks whether the GUARD_PAGE
flag has been triggered in the context of one of the fake DLLs. As we already know, the fake DLLs are not real DLLs, they are just manually mapped versions, for example in the context of ntdll.dll
. However, the EDR still needs an in-memory part to handle the logic or to check when the GUARD_PAGE
was triggered in the context of one of the fake DLLs. By looking at the EDR modules in memory, we can identify the DLL or module of the EDR that is used for the inline hooking part. In the context of this EDR, this is the only real DLL besides the fake DLLs that the EDR uses in memory of a process in user mode, so we want to take a closer look at the hooking DLL from the EDR to see if we can find some important connections in the context of our research into fake DLLs etc.
I wanted to find out where the logical part is inside the EDR to check if the exception is related toSTATUS_GUARD_PAGE_VIOLATION
or more specifically to the related exception code 0x80000001
. So I had a simple idea with x64dbg. We open any application like notepad.exe
or cmd.exe
, attach to it with x64dbg and in the first step search the memory map for the base address of the hooking DLL. In the second step, we create a search pattern that can be used to try to identify compare operations against the value 0x80000001
. In other words, we are looking inside the hooking DLL for compare operations against the exception code in the context of STATUS_GUARD_PAGE_VIOLATION
. So we want to search for the pattern cmp eax, 80000001h
, based on little endian we have to convert our pattern to 3D 01 00 00 80
. This is our search pattern and after using this in x64dbg pattern search, we are able to observe several compare operations against 80000001h
within the memory of the hook DLL. I think the following illustration is a plausible indicator that the logic from the EDR that checks whether the STATUS_GUARD_PAGE_VIOLATION
0x80000001
has been triggered in the context of one of the fake DLLs is placed inside the hooking DLL from the EDR, and then depending on the scenario takes further steps inside the hooking DLL.
The figure above shows, based on the comparison operation cmp eax, 80000001h
, if the register eax
is not equal to the value 80000001h
, the function 7FFE77F5AE51
is called. Otherwise, if the value for eax is 80000001h
, function 7FFE77F56230
is called. In other words, if the GUARD_PAGE
flag in memory of one of the fake DLLs is hit, the function 7FFE77F56230
is called.
Summary
Before we look at potential evasion techniques, let's briefly summarise our analysis of the EDR system. Our investigation revealed that the EDR performs a targeted modification of the InLoadOrderModuleList
within the Process Environment Block (PEB) by deploying fake DLLs. Notably, these fake DLLs are not standalone entities, but manually mapped versions of the original DLLs, such as ntdll.dll
. A key aspect of these fake DLLs is that their RX
(read-execute) committed memory region is equipped with a guard page. When this memory region is accessed - either read or execute - the guard page throws a STATUS_GUARD_PAGE_VIOLATION (0x80000001)
exception, a mechanism also known as page guard hooking. This exception then activates the EDR's Vectored Exception Handler (VEH), allowing the EDR to actively influence the application's execution flow. We were also able to identify the compare operation that is used within the hooking DLL from the EDR to check whether or not a STATUS_GUARD_PAGE_VIOLATION
(0x80000001)
exception has been thrown. However, the exact actions taken after the EDR's VEH has been activated remain unclear at this stage and would require further examination of the EDR system.
In summary, this technique provides the EDR with the ability to monitor and potentially mitigate malicious activity by controlling the execution flow of (potentially malicious) applications.
Possible Evasion Strategies
Finally, we consider some possible evasion strategies (in this context, evasion is defined as an activity that is not prevented and detected) in the context of a PEB walk, e.g. for dynamically querying System Service Numbers (SSNs
) to perform direct or indirect syscalls.
Offset OriginalBase
As we found in our analysis, the detection mechanism of the EDR is activated when the offset 0x30
(DllBase
) is used during the PEB walk, the guard page is triggered and finally the VEH of the EDR is triggered. A possible workaround strategy could be to use offset 0xF8
for OriginalBase
to determine the base address of the real DLL, e.g. ntdll.dll
, instead of accessing the fake DLL. However, this may cause problems depending on the version of Windows, as the offset for OriginalBase
may be different. More detailed research into this has not yet been carried out.
UINT_PTR NtdllOriginalDLLBase() {
// Read the PEB Offset from the GS Register
UINT_PTR pebAddress = __readgsqword(0x60);
// Access the PEB_LDR_DATA field within PEB
UINT_PTR ldr = *(UINT_PTR*)(pebAddress + 0x18);
// Access the first entry in the InInitializationOrderModuleList
UINT_PTR inInitOrderModuleList = *(UINT_PTR*)(ldr + 0x10);
// Traverse to the second module in the list (typically ntdll.dll)
UINT_PTR secondModule = *(UINT_PTR*)(inInitOrderModuleList);
// Uncomment the following line to advance to the third module
// secondModule = *(UINT_PTR*)(secondModule);
// Access the base address of the module by using Original Base
UINT_PTR baseAddress = *(UINT_PTR*)(secondModule + 0xF8);
return baseAddress;
}
Access Correct Module via Flink
Another strategy could be to use the PEB walk to specifically access the fourth module in the double linked list InLoadOrderModuleList
, which in this case corresponds to the correct ntdll.dll
and can therefore bypass the EDR's fake dll. However, for reliable operation in practice (e.g. during preparation in a red teaming you do not know which EDR is running in the target environment), additional checks may need to be implemented, e.g. a loop that compares the DLL names. This can, for example, prevent the fourth module in the InLoadOrderModuleList
from being accessed unless the PEB has been modified by the EDR.
// Get base address from ntdll.dll on machine with default InLoadOrderModuleList in PEB
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink - 0x10);
// Get base address from ntdll.dll on machine with modified InLoadOrderModuleList in PEB in context of the analysed EDR
PLDR_DATA_TABLE_ENTRY pLdrDataEntry = (PLDR_DATA_TABLE_ENTRY)((PBYTE)pCurrentPeb->LoaderData->InMemoryOrderModuleList.Flink->Flink->Flink->Flink - 0x10);
Windows APIs
Another possible workaround is to use the Windows API function GetModuleHandleA
to determine the base address of ntdll.dll
. The following code shows how GetModuleHandleA
can be used to determine this base address. After running the code on a system with the EDR to be analysed, it can be checked whether the memory area of the real ntdll.dll
or the fake ntdll.dll
is being accessed. In this way it can be analysed to what extent the specific modification of the PEB by the EDR also affects the use of GetModuleHandleA
.
#include <windows.h>
#include <stdio.h>
UINT_PTR NtdllGetModul(){
UINT_PTR baseAddress = GetModuleHandleA("ntdll.dll");
return baseAddress;
}
int main() {
UINT_PTR ntdllGetModul = NtdllGetModul();
printf("Base address from ntdll via GetModuleHandleA: %p\n", (void*)NtdllGetModul());
printf("Press any key to exit...");
getchar(); // wait for keypress
return 0;
}
The result of our experiment, as shown in the following figure, is that by using the Windows API GetModuleHandleA
on the endpoint with the EDR under investigation, we actually end up in the memory area of the real ntdll.dll
and not that of the fake ntdll.dll
. This indicates that accessing the base address of a module or DLL using GetModuleHandleA
provides a way to bypass the fake DLL implemented by the EDR and thus the page guard hook.
However, this strategy is not really recommended as the Windows APIs GetModuleHandleA
, GetProcAddress
, LoadLibrary
etc. are often monitored by EDRs using inline API hooking. These APIs should therefore always be avoided, e.g. in a shellcode loader or in the shellcode itself.
PEB Iteration and String Comparison
I would like to point out one last evasion possibility. To optimise the determination of the base address of the correct ntdll.dll
and to ensure that you do not accidentally get the base address of the fake ntdll.dll
, the following C code can be used. This approach uses a combination of iteration and string comparison within the InLoadOrderModuleList
in the PEB
to identify the correct ntdll.dll
. Specifically, the code iterates through the list of loaded modules, compares the module names exactly with "ntdll.dll
" and extracts the base address of the correct module if there is an exact match. This method is a precise and quite reliable solution to distinguish the real ntdll.dll
from potentially fake versions and to correctly determine its base address.
// Resources:
// Hiding in Plain Sight: Unlinking Malicious DLLs from the PEB
#include <stdio.h>
#include <windows.h>
#include <psapi.h>
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, * PUNICODE_STRING;
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, * PPEB_LDR_DATA;
typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} LDR_DATA_TABLE_ENTRY, * PLDR_DATA_TABLE_ENTRY;
typedef struct _PEB {
BYTE Reserved1[2];
BYTE BeingDebugged;
BYTE Reserved2[1];
PVOID Reserved3[2];
PPEB_LDR_DATA Ldr;
} PEB, * PPEB;
typedef struct _MY_LDR_DATA_TABLE_ENTRY
{
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderLinks;
LIST_ENTRY InInitializationOrderLinks;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING ignored;
ULONG Flags;
SHORT LoadCount;
SHORT TlsIndex;
LIST_ENTRY HashTableEntry;
ULONG TimeDateStamp;
} MY_LDR_DATA_TABLE_ENTRY;
// Returns a pointer to the PEB by reading the FS or GS registry
PEB* get_peb() {
#ifdef _WIN64
return (PEB*)__readgsqword(0x60);
#else
return (PEB*)__readfsdword(0x30);
#endif
}
// Get the base address of reall ntdll.dll by comparing the name of the DLL with "ntdll.dll" string and returning the base address of the DLL if the name contains "ntdll.dll"
PVOID get_ntdll_base_via_name_comparison() {
PEB* peb = get_peb(); // Get a pointer to the PEB
LIST_ENTRY* current = &peb->Ldr->InMemoryOrderModuleList; // Get the first entry in the list of loaded modules
do {
current = current->Flink; // Move to the next entry
MY_LDR_DATA_TABLE_ENTRY* entry = CONTAINING_RECORD(current, MY_LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks);
char dllName[256]; // Buffer to store the name of the DLL
// Assuming FullDllName is a UNICODE_STRING, conversion to char* may require more than snprintf, consider proper conversion
snprintf(dllName, sizeof(dllName), "%wZ", entry->FullDllName);
if (strstr(dllName, "ntdll.dll")) { // Check if dllName contains "ntdll.dll"
return entry->DllBase; // Return the base address of ntdll.dll
}
} while (current != &peb->Ldr->InMemoryOrderModuleList); // Loop until we reach the first entry again
return NULL;
}
int main() {
// Get the base address of ntdll.dll by comparing the name of the DLL with "ntdll.dll" string
PVOID ntdll_base = get_ntdll_base_via_name_comparison();
if (ntdll_base == NULL) {
printf("ntdll.dll not found\n");
}
else {
printf("Base address from real ntdll.dll based on string or dll name comparison: %p\n", ntdll_base);
}
printf("Press any key to continue\n");
(void)getchar();
return 0;
}
The following figure shows how we use the C code to avoid the fake ntdll.dll
of the EDR and successfully determine the base address of the real ntdll.dll
.
Interpretation
I would like to conclude my analysis of the EDR system with a brief interpretation. In direct comparison with other EDR solutions, the described detection mechanism, based on fake DLLs, guard pages and vectored exception handling, is characterised as a rather unconventional method, more likely to be found in the field of game hacking. However, it has proven to be very effective in practice. From my own experience, I can say that the time and energy required for a successful bypass - defined as a situation where malware is neither prevented nor detected - is significantly higher compared to other EDR systems. The complexity and coherent structure of this approach makes it seem like a "trap". As I currently understand it, the guard page in the fake ntdll.dll
is only triggered if an attempt is made to access ntdll.dll
via the DLLBase
offset 0x30
during process initialisation using the PEB walk, but you actually end up in the memory of the fake ntdll.dll
. However, if an application or malware uses APIs such as GetModuleHandleA
or LoadLibrary
to obtain a handle to ntdll.dll
, the fake ntdll.dll
, Guard Page and VEH mechanism will not work. However, from a malware perspective, there is a relatively high probability of inline API hooking by the EDR in such cases, as GetModuleHandleA
and LoadLibrary
are often provided with inline hooks.
While the process of modifying the Process Environment Block (PEB), the use of fake DLLs in combination with page guard hooking and vectored exception handling is relatively well understood, the question of what exactly happens after the handover to the EDR's VEH remains unanswered. A possible but unconfirmed hypothesis is that the affected thread or process is either killed directly, or that a handover is made to the EDR's hooking DLL, which decides whether or not to kill the process. However, it is important to emphasise that these are only speculations at this stage and cannot be taken as definitive facts.
It would also be instructive to investigate the impact of the described EDR mechanism on system performance, especially in comparison to other EDR systems that do not follow this particular approach. Given the process of page guard hooking and vectored exception handling, it might be expected that this approach would be more system intensive. Similar to inline API hooking, EDRs could be restricted to specific APIs to minimise the system load. This assumption is supported by the observation that comparison operations have been identified for approximately 25 native APIs in IDA, which could indicate that EDR is focused on selected, critical APIs to optimise efficiency and not unduly impact system performance.
I hope this article has given you a little insight into a rather unconventional EDR detection mechanism and thank you for reading. Until the next article.
Happy Hacking!
Daniel Feichter @VirtualAllocEx