Introduction

Note: If you are not comfortable with C/C++ pointers, I highly recommend to watch this first. This is for me the best video to understand pointers.

The main reason I’m writing this is because before taking sektor7 courses, there were some advanced concepts that I didn’t know of. I usually looked at ired.team , but whenever they were parsing NTDLL or PEB (Process Environment Block) I couldn’t followed. I tried to understand it by using our best friend Google but I didn’t find any blog which explains all the details I needed and that’s why I decided to go through Sektor7 courses.

In this post I am going to show you the structures of a PE file which is very important to understand in order to resolve the functions in NTDLL. I am also going to walk you through the PEB using WinDbg in order to find the address of NTDLL in the memory of our executable during runtime.

Setup

If you are a beginner, it’s easier to use Visual Studio IDE.

  1. Download Microsoft Visual Studio 2022 here.
  2. After installing VS 2022, open Visual Studio Installer, click on Modify and make sure to install the Desktop devlopment with C++ workload.

During the blog I’ll give you the links to all the tools I used to understand these concepts.

Some Definitions

A Dynamic Link Library (DLL) is like an EXE but it’s not directly executable. It’s similar to .so files in Linux/Unix. A DLL contains functions, classes, variables, UIs and resources (such as icons, images, files, …) that an EXE, or other DLL uses. When you execute a program, the first dll loaded in memory is NTDLL. The program then loads subsequent dll’s via the Win32 API LoadLibrary and use GetProcAddress to access function inside those libraries. For more information about NTDLL click here.

The PEB (Process Environment Block) is a data structure which contains important information about the process that is being executed. This structure is initialized by NtCreateUserProcess() API call. We are going to parse the PEB in order to find the base address of NTDLL at runtime. For more information about PEB, Geoff Chappell got your back.

A Relative virtual address (RVA) is an image file, this is the address of an item after it is loaded into memory, with the base address of the image file subtracted from it. Meaning, when you have the RVA, you always need to add the base address to it in order to get the exact memory location.

Why parse NTDLL and PEB?

I’ve seen some people trying to avoid the hooks in Kernel32.dll and User32.dll by using NTDLL.dll but then they use GetModuleHandle() and GetProcAddress(). This is bad OPSEC, and it will tell the AV what we are doing. Instead of using those API calls, we can manually parse the PEB and retrieve the base address of NTDLL, and then we will walk through the exported functions to use the ones we want. This is stealthier and we get to learn something new in the process :).

Parsing PEB

After installing Windows SDK, in C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\um directory you have a file called winternl.h, it contains all the necessary structures we are going to use in our code to walk through the PEB.

You will need to download WinDbg from here.

Retrieving DllBase in WinDbg

Just to remember, we are interested in PEB because we want to avoid using GetModuleHandle() and we need to find the base address of NTDLL at runtime.

This is the struct definition of the PEB in winternl.h and we are interested at the LDR_DATA element: peb_struct You can view a more detailed structure online here. To undertstand it, it’s better to visualize it in WinDbg. Launch WinDbg, go to File -> Open Executable and choose any executable like notepad.exe, located in C:\Windows\sytem32. Run !peb and the result is the PEB completely parsed showing all the modules that are loaded in the memory of notepad when it’s running. peb_parsed

In order to visualize the PEB definitions we can execute dt _PEB. We can store our PEB object to a variable by running r $peb. This is useful as we can now map the previous structure definitions to our PEB object with this dt _PEB @$peb. And now we can just click on Ldr and we will be able to see his definitions. peb_mapping

After that, just click on InMemoryOrderModuleList which gives us a list of pointers, pointing forward (Flink) and backward (Blink). ldr_mapping

In order to access the objects in Flink we need to use the _LDR_DATA_TABLE_ENTRY structure. We can view the first Flink module in WinDbg with dt _LDR_DATA_TABLE_ENTRY 0x22ced342fc0.

Note: You will probably have a different address.

flink_object Look at the FullDllName parameter and it should say “notepad.exe”. Now in order to access the 2nd module, just click on Flink and it will give you a new list with a different address. From there, we just have to use the same command as before in order to look at the properties of that module. In my case it will be dt _LDR_DATA_TABLE_ENTRY 0x22ced342df0. ntdll_name

Looking at the FullDllName parameter again, we see “ntdll.dll”. Since we are interested in his base address, just look at the parameter called DllBase which is above the dll name.

Retrieving DllBase in C

This function is just a copy paste from sektor7 course (with their permission). I tried to rewrite it but since I already had this one in my head it was very difficult to write something different, and I don’t think there’s anything to improve at all in their code. After playing with WinDbg, it was very easy to understand.

Note: You have to copy the PEB and PEB_LDR_DATA structures, save them in a file with “.h” extension and import the file into your visual studio project as a header file. Then to use it in your program don’t forget to add #include “filename.h”.

First, we need to find the offset of the PEB in the TEB. In x64, it is located at 0x60 while in x32 it is at 0x30. You can verify it in WinDbg by running dt _TEB. teb

So our function should start like this:

1
2
3
4
5
6
7
8
9
HMODULE WINAPI hlpGetModuleHandle(LPCWSTR sModuleName) {

	// get the offset of Process Environment Block
#ifdef _M_IX86 
	PEB * ProcEnvBlk = (PEB *) __readfsdword(0x30);
#else
	PEB * ProcEnvBlk = (PEB *)__readgsqword(0x60);
#endif
}

Next we access the Ldr inside the PEB structure. The Ldr is define with a PPEB_LDR_DATA datatype, which means it holds a pointer to a PEB_LDR_DATA structure. After getting inside the PEB_LDR_DATA struct, we enter in the InMemoryOrderModuleList and from there, we reach the Flink.

1
2
3
4
5
6
7
8
9
// return base address of a calling module
	if (sModuleName == NULL) 
		return (HMODULE) (ProcEnvBlk->ImageBaseAddress);

	PEB_LDR_DATA * Ldr = ProcEnvBlk->Ldr;
	LIST_ENTRY * ModuleList = NULL;
	
	ModuleList = &Ldr->InMemoryOrderModuleList;
	LIST_ENTRY *  pStartListEntry = ModuleList->Flink;

Finally, we loop through the module list and we check the Dll base name, and if it is a match we return the base address. The final function looks like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
HMODULE WINAPI hlpGetModuleHandle(LPCWSTR sModuleName) {

	// get the offset of Process Environment Block
#ifdef _M_IX86 
	PEB * ProcEnvBlk = (PEB *) __readfsdword(0x30);
#else
	PEB * ProcEnvBlk = (PEB *)__readgsqword(0x60);
#endif

	// return base address of a calling module
	if (sModuleName == NULL) 
		return (HMODULE) (ProcEnvBlk->ImageBaseAddress);

	PEB_LDR_DATA * Ldr = ProcEnvBlk->Ldr;
	LIST_ENTRY * ModuleList = NULL;
	
	ModuleList = &Ldr->InMemoryOrderModuleList;
	LIST_ENTRY *  pStartListEntry = ModuleList->Flink;

	for (LIST_ENTRY *  pListEntry  = pStartListEntry;  		// start from beginning of InMemoryOrderModuleList
					   pListEntry != ModuleList;	    	// walk all list entries
					   pListEntry  = pListEntry->Flink)	{
		
		// get current Data Table Entry
		LDR_DATA_TABLE_ENTRY * pEntry = (LDR_DATA_TABLE_ENTRY *) ((BYTE *) pListEntry - sizeof(LIST_ENTRY));

		// check if module is found and return its base address
		if (strcmp((const char *) pEntry->BaseDllName.Buffer, (const char *) sModuleName) == 0)
			return (HMODULE) pEntry->DllBase;
	}

	// otherwise:
	return NULL;

}

Parsing NTDLL

we parse NTDLL to avoid using GetProcAddress() so that we can find the address of the NT functions we want at runtime. Those functions are exported wihtin NTDLL, so we have to loop through the headers until we reach the Export Address Table (EAT). To better understand this I highly recommend you to download PE-bear, which help us to get a view on the internal structure of PE file. Below is an image when we open NTDLL inside PE-Bear:

ntdll

In C:\Program Files (x86)\Windows Kits\10\Include\10.0.19041.0\um there’s a file called winnt.h, which holds the necessary structures we are going to use in order to access the EAT.

we should already have the base address of NTDLL by using GetModuleHandle() or by parsing the PEB as we did in the previous module. So our next step is going inside the DOS header.

Note: The DOS Header occupies the first 64 bytes of a PE file.

1
2
3
4
5
6
7
8
9
FARPROC WINAPI GetProcAddressFunc(const char* funcName) {
	HMODULE hMod = NULL;
	void* pTargetFunc = NULL;
	hMod = GetModuleHandleA("NTDLL.DLL");//We get the base address of ntdll
	char* pBaseAddr = (char*)hMod;// typecast
	IMAGE_DOS_HEADER* imageDosHeader = (IMAGE_DOS_HEADER*)hMod;
	//if (imageDosHeader->e_magic == IMAGE_DOS_SIGNATURE)
	//    printf("[+] Valid file!!\n");
	}

The if statement is just a way to made sure you have a valid PE file as the e_magic field is called magic number and should be equal to 5A4D. You probably know it as MZ. e_magic

Next we going inside the NT headers. Looking at this structure in winnt.h, you will see it holds 2 others headers, File Header, and Optional Header. In order to reach the NT headers, we need to read the last 4 bytes of DOS Header, which are represented by a field called e_lfanew. This field hold a value which corresponds to the location of NT header. After we retrieve that value, we just add it to the base address of NTDLL and we get into the NT Header.

nt_header
1
2
3
4
5
char* pBaseAddr = (char*)hMod;// typecast
	IMAGE_DOS_HEADER* imageDosHeader = (IMAGE_DOS_HEADER*)hMod;
	//if (imageDosHeader->e_magic == IMAGE_DOS_SIGNATURE)
	//    printf("[+] Valid file!!\n");
IMAGE_NT_HEADERS* imageNtHeader = (IMAGE_NT_HEADERS*)(pBaseAddr + imageDosHeader->e_lfanew);

We continue by entering into the Optional Header. It contains a Data Directory array, which holds the virtual address and size of the Export Directory we are looking for.

1
2
3
IMAGE_OPTIONAL_HEADER* imageOptHeader = &imageNtHeader->OptionalHeader; // we are in the Optional header
IMAGE_DATA_DIRECTORY* imageDataDir = &(imageOptHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]);// we are inside the data directory array
IMAGE_EXPORT_DIRECTORY* imageExpDir = (IMAGE_EXPORT_DIRECTORY*)(pBaseAddr + imageDataDir->VirtualAddress); // we grab the virtual address of the export directory
data_directory

Now we just have to loop through the export directory and retrieve the function we want. This part is a bit confusing but it consists in 3 steps:

  • We loop through the AddressOfNames and we grab the index corresponding to the function we are looking for.
  • We look into AddressOfNameOrdinals at the specify index we found before and we grab the value stored at that location (hint).
  • We finally enter AddressOfFunctions at the specify index stored in our previous hint. The final result looks like this:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
FARPROC WINAPI GetProcAddressFunc(const char* funcName) {
	HMODULE hMod = NULL;
	void* pTargetFunc = NULL;
	hMod = GetModuleHandleA("NTDLL.DLL");
	char* pBaseAddr = (char*)hMod;// typecast
	IMAGE_DOS_HEADER* imageDosHeader = (IMAGE_DOS_HEADER*)hMod;
	//if (imageDosHeader->e_magic == IMAGE_DOS_SIGNATURE)
	//    printf("[+] Valid file!!\n");
	IMAGE_NT_HEADERS* imageNtHeader = (IMAGE_NT_HEADERS*)(pBaseAddr + imageDosHeader->e_lfanew);
	IMAGE_OPTIONAL_HEADER* imageOptHeader = &imageNtHeader->OptionalHeader;
	IMAGE_DATA_DIRECTORY* imageDataDir = &(imageOptHeader->DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT]);
	IMAGE_EXPORT_DIRECTORY* imageExpDir = (IMAGE_EXPORT_DIRECTORY*)(pBaseAddr + imageDataDir->VirtualAddress);
	DWORD* pAddrFunc = (DWORD*)(pBaseAddr + imageExpDir->AddressOfFunctions);
	DWORD* pAddrNames = (DWORD*)(pBaseAddr + imageExpDir->AddressOfNames);
	WORD* pAddrOrdinals = (WORD*)(pBaseAddr + imageExpDir->AddressOfNameOrdinals);
	for (DWORD i = 0; i < imageExpDir->NumberOfNames; i++) {
		char* loopNames = (char*)pBaseAddr + (DWORD_PTR)pAddrNames[i];
		if (strcmp(funcName, loopNames) == 0) {
			pTargetFunc = (FARPROC)(pBaseAddr + (DWORD_PTR)pAddrFunc[pAddrOrdinals[i]]);
			printf("Function %s found at address 0x%p\n", loopNames, pTargetFunc);

		}

	}
	return (FARPROC)pTargetFunc;
}

References

In case this blog wasn’t enough, I will list down usefull resources to understand this concept.

Credits

Thanks to @GrahamHelton3 who created a nice tutorial about deploying a blog with Hugo and AWS.

Also, I have to thank Sektor7 for their amazing courses. They covered the basics and advanced stuff in malware development and AV/EDR bypass. The instructor explains really well and their chat support was amazing at explaining some concepts more in depth.