Breaking

Some experiments with Process Hollowing

Process Hollowing is a technique used by various malware families (such as FormBook, TrickBot and Agent Tesla) to hide their malicious code within a benign appearing process. The typical workflow for setting up such a hollowed process is as follows:

  1. Create a new process (victim) using a benign executable, in suspended state.
  2. Unmap the executable from that process.
  3. Allocate memory for the malicious executable at the address of the previously mapped victim executable.
  4. Write the malicious executable to the new memory area and potentially apply relocations.
  5. Adjust the entry point.
  6. Resume process.

We will refer to this as the “normal” Process Hollowing workflow. There are also variants of this technique, one being to not unmap the original executable and to allocate the new memory somewhere else. We will call this one no-unmap. But wait, why does malware not simply overwrite the existing executable but creates a new memory area which stands out due to its characteristics? In this blog post we will have a closer look at this overwrite approach but also on the no-unmap method, their effects on analysis/detection tools and on some tricks to make the detection harder. We are also releasing Proof of Concept implementations of all mentioned tools/plugins (the links are at the end of this post).

Table of Contents
The overwrite/loadEXE method
   – Control Flow Guard
The no-unmap method
Evaluation with Detection Tools
Conclusion

The overwrite/loadEXE method

I’ve asked myself a while now, why all Process Hollowing implementations that I have seen used either the normal or no-unmap method (there are also some other approaches, but those were more uncommon), but do not simply overwrite the existing executable in place. The normal approach requires two suspicious API calls (one for unmapping (e.g., NtUnmapViewOfSection) and one for allocation(e.g.,VirtualAllocEx), and leaves easily identifiable traces in the memory space: No mapped executable anymore and an extra anonymous (no corresponding file object) Read-Write-Execute (RWX) memory area with an executable in it (recognizable by the MZ magic bytes and typical PE header strings):

The first screenshot shows Process Hacker for a benign process without any process hollowing:

As we can see, the executable is still mapped in the process space. The second screenshot shows the result of the “normal” process hollowing:

Now, there is no mapped executable anymore but an anonymous RWX memory area containing an executable (the MZ, a custom flag ERNW to recognize our injected executable, and the DOS stub).

So, let’s get back to the overwrite approach. As it happens only in the process space, there shouldn’t be any privilege issue with overwriting memory, the according pages just have to be writable. I suspected that maybe there is a sanity check, occurring after the process is resumed, which prevents this approach. So I created a PoC with some slight modifications to a standard-Process-Hollowing implementation by John Leitch. The modifications were:

  • Not unmapping the victim executable.
  • Not allocating any new memory.
  • Setting the protection of the whole mapped victim executable to PAGE_EXECUTE_READWRITE.
  • Clearing the whole victim executable with null bytes.
  • Overwriting the cleared victim executable with the new one.

Long story short: It worked:

Executed command (for the executables and more details on the switches see here):

ProcessHollowing.exe C:\Windows\SysWOW64\OneDriveSetup.exe HelloWorld.exe overwrite

For the first PoC we used a Windows 10 x64 1511 and the huge C:\Windows\SysWOW64\OneDriveSetup.exe executable as the victim process (we only use 32 bit applications here, since John Leitch’s tool and hence also our PoC right now only supports 32 bit; 64bit support is planned). Let’s have a look at the result with Process Hacker:

At first sight, everything looks quite normal. No anonymous RWX memory region (VAD) with a PE file and the mapped executable is still there. If we, however, unfold the executable’s memory entry, we are able to spot something suspicious:

Since we marked the whole memory region as RWX, this is something that stands out (compare this to the benign process at the top). But this can be easily obfuscated with VirtualProtectEx. By simply setting the protections of the header and each section, while leaving all data after our injected PE with the protection read-only, our malicious process looks more benign:

Executed command:

ProcessHollowing.exe C:\Windows\SysWOW64\OneDriveSetup.exe HelloWorld.exe overwrite_setSecProt

One could argue that such a small code (RX) section is kind of suspicious for such a big executable, but this can be circumvented by finding a target with a more similar size. We will come back to this later.

At this point I searched around for similar projects and came across one that I’ve in fact used myself before, and which is probably one of the oldest implementations of Process Hollowing: Dynamic Forking of Win32 EXE by Tan Chew Keong back in 2004 (based on the timestamp in the blog post). The described technique there reads exactly the same as current process hollowing implementations, except for step 5:

“If the second EXE has the same base-address as the suspended process and its image-size is <= to the image-size of the suspended process, simply use the WriteProcessMemory function to write the image of the second EXE into the memory space of the suspended process, starting at the base-address."

So the idea of overwriting the existing executable was not at all new and in fact was the default for the so-called “loadEXE” loader. But why isn’t it used more in the wild. The mentioned “same base-address” requirement is a long time now not an issue anymore, and the size constraint might in some cases be one, but given alone the huge amount of executables in the SysWOW64 folder it should be easy to find an appropriate target (e.g., the OneDriveSetup.exe is about 7,5 MB big for Windows 10 1511).

But just for fun, I also tried to overcome size limitation by extending the virtual memory of the executable. While in theory that would be possible by simply calling VirtualAllocEx to allocate memory right after the mapped image file (without having tested, whether or not proceeding with an image that spreads over two memory areas would actually work), the allocation granularity (for more details see here) will prevent us from allocating memory right after the victim executable as long as it doesn’t fit perfectly. So at least for my PoC, that wasn’t an option.

Maybe the reason for not using the overwrite approach is that attackers like to use particular victims, such as svchost.exe, which is quite small. Or the overwrite-idea just simply did not spread. Another possible reason is discussed in the following section.

Control Flow Guard

The next step was to find an executable with a similar size to our malicious executable. To my surprise: The overwrite method didn’t work (the normal approach, however, still did). To compare the behavior with the most current Windows version (back then Windows 10 21h1), I’ve set up a new VM, tried the same and still did not work. Even more: Here, also OneDriveSetup.exe did not work anymore. As it turned out, the reason was Control Flow Guard (CFG). The executables in the SysWOW64, except OneDriveSetup.exe for Windows 1511, are protected by CFG. CFG in essence consists of two ingredients (for more details see here):

  • A bitmap, where each bit marks a corresponding address, which is either a valid or invalid call target.
  • A function that checks right before any indirect call, if the call target is valid (has its bit set in the CFG bitmap).

The CFG functionality, including the check-function call before any indirect call, are added by the compiler when building an executable/library. Since we overwrite the whole victim executable with a new one, which has no CFG checks before any call, I expected the malicious executable to run without any problem. But it does not. One clue for this issue came from this blog post:

“However, when Microsoft implemented CFG, it also added checks to a number of API functions – including CreateRemoteThread”

So my assumption was that ResumeThread might have a similar check too, but even marking the whole PE file a valid call target didn’t solve the problem. After some experimentation, the solution to circumvent this problem was to create an executable with CFG enabled and setting any executable section as a valid call target (via SetProcessValidCallTargets). The exact reason why, however, is up until now not clear to me. Because part of the solution was to create an executable with CFG enabled, I assume that some requirements are not met by an executable without CFG. I did not yet investigate this issue any further, but if somebody knows the answer, I’m happy to hear it! The modified Process Hollowing executable allows to control its behavior (e.g., whether or not to set the CFG bits), so feel free to play around with it.

Note: The “normal” Process Hollowing method (unmapping and allocation of new RWX memory) and also the no-unmap method work fine with a CFG victim and an executable that is not CFG enabled resp. without any explicit CFG bitmap flipping. While I did not expect the CFG bitmap to be a problem (when allocating anonymous executable memory, all CFG bits for that region are set to 1, making every address in that memory a valid execution target), I did, however, expect the PE file without CFG to be a problem. But in these two cases, it was not.

With the fix for the CFG problem we can now choose a victim with a similar size:

Executed command:

ProcessHollowing.exe C:\Windows\SysWOW64\help.exe cfg_hello.exe overwrite_disableCFG_setSecProt

But of course, as long as the attacker chooses a victim without CFG enabled, this whole process is not necessary. A non CFG-enabled victim is typically easy to find on a Windows server/client with third party applications installed.

The no-unmap method

The idea here, again, is not to unmap the victim executable but instead of overwriting, to use a new memory region somewhere else for our malicious executable (in contrast to the “normal” mode, where the new executable is written to a new memory region at the same location as the unmapped executable). The result is a victim process with the original executable still mapped in memory, unmodified, and a new RWX memory region with our malicious executable. I’ve found an implementation from 2016 by “Zer0Mem0ry” named RunPE that does create a new memory area for the malicious executable and leaves the victim mapped in memory. This approach has also already been used by malware, as described by Monnappa K A in his blog post about hollowfind.

Let’s have a look at how this no-unmap method looks like with a benign victim in Process Hacker:

Executed command:

ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap

At the top we have our victim executable (svchost.exe), which has not been modified, and at the bottom (at address 0x3250000) there is the new RWX memory area with our malicious executable. As mentioned earlier, CFG does not play any role here, so no need to use a CFG enabled executable, nor to set any CFG bits.

The problem with this result is the easily recognizable extra memory. It stands out by its protection and the PE characteristics. Luckily, it’s fairly simple to overcome those obvious indicators. First, we can get rid of the RWX indicator by allocating the new memory with an initial protection of e.g., PAGE_READONLY and afterwards changing the protection to PAGE_EXECUTE_READWRITE (see The Art of Memory Forensics or here for more details on this trick):

Executed command:

ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap_nx

The malicious executable hides behind the inconspicuous read-only memory area at 0x3b0000, because Process Hacker shows the VAD protection (the initial protection for this memory region) for the “summary entry”. Only after unfolding this entry, we see the new/actual protection of the respective pages:

But this can be, at least against automatic searches for RWX memory, obfuscated furthermore. Similarly to the overwrite method, we can set the section protections and by that, remove any RWX memory:

Executed command:

ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap_nx_setSecProt

If this is less suspicious to the investigator’s eye, however, is at least questionable.

As a last step we get rid of the PE file’s headers, which can help against both, automatic and manual analysis/detection approaches:

Executed command:

ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap_nx_setSecProt_clearHeader_resetBase

An interesting side note here: Clearing the header before the process is fully set up (this can e.g., happen for Windows Desktop applications or in the context of not yet loaded DLLs), might lead to a slowed-down execution, a crash or a weird process-respawn dance. So it is important to make sure everything is set up before the header is cleared. A good fail-safe seems to be to set the PEB‘s ImageBaseAddress to a valid mapped image file (ideally the actual victim executable, which also will make detection harder; see the next section). The mode switch to do this is resetBase.

Evaluation with detection Tools

After the manual analysis with Process Hacker, we are now going to have a look at various automated tools (both live and memory forensics) for the detection of process hollowing artifacts. These tools are:

  • malfind: Volatility 3 plugin which detects injected code based on memory management characteristics (VAD protections).
  • hollowfind: Volatility 2 plugin which detects different types of process hollowing techniques.
  • dlllist: Volatility plugin that enumerates loaded modules and can help reveal discrepancies.
  • ptemalfind: Volatility 3 plugin which detects executable memory pages based on memory management characteristics (PTE and PFN fields).
  • apisearch: Volatility 3 plugin which can reveal injected PE files by scanning for API pointers.
  • pe-sieve: A live process scanning tool for malicious indicators, which mainly focuses on memory content.
  • Moneta: A live process scanning tool for malicious indicators, which mainly focuses on memory management characteristics and not actual content.

The Process Hollowing setup consists of several processes, created with the following commands:

ProcessHollowing.exe C:\Windows\SysWOW64\help.exe VictimProcess.exe normal
ProcessHollowing.exe C:\Windows\SysWOW64\help.exe VictimProcess.exe normal_nx_setSecProt
ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap
ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap_nx
ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap_nx_setSecProt
ProcessHollowing.exe C:\Windows\SysWOW64\svchost.exe HelloWorld.exe nounmap_nx_setSecProt_clearMZ_resetBase
ProcessHollowing.exe C:\Windows\SysWOW64\pathping.exe mimikatz_x86.exe nounmap_nx_setSecProt_clearHeader_resetBase
ProcessHollowing.exe C:\Windows\SysWOW64\help.exe cfg_hello.exe overwrite_disableCFG
ProcessHollowing.exe C:\Windows\SysWOW64\help.exe cfg_hello.exe overwrite_disableCFG_setSecProt

The executables are part of the Github project (see the Releases page). We will use the mode-string (e.g., nounmap_nx_setSecProt) as a reference, in the following part, for the respective created process.

Starting with malfind, we can at least find two instances: The normal and nounmap process, and as long as the header is not cleared, an injected PE file is quite easy to spot (here even more with our ERNW marker):

6088   help.exe   0xb80000   0xb85fff   VadS   PAGE_EXECUTE_READWRITE
4d 5a 90 45 52 4e 57 00 MZ.ERNW.
04 00 00 00 ff ff 00 00	........
b8 00 00 00 00 00 00 00	........
40 00 00 00 00 00 00 00	@.......
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 f8 00 00 00	........	

As malfind does not consider mapped files or VADs without write and execute permissions in their initial protection, all other Process Hollowing processes remain hidden.

Next, let’s have a look at hollowfind. Since the support for WoW64 in the official version of hollowfind is insufficient (in the sense that it fails to detect every nounmap* process), I’ve created an updated version which you can find here. With that modification the new version detects the first 5 instances in our list (normal, nounmap, nounmap_nx and nounmap_nx_setSecProt). Following the output for the nounmap_nx case:

Hollowed Process Information:
    Process: svchost.exe PID: 2560
    Parent Process: NA PPID: 3888
    Creation Time: 2022-09-22 19:11:47 UTC+0000
    Process Base Name(PEB): svchost.exe
    Command Line(PEB): C:\Windows\SysWOW64\svchost.exe
    Hollow Type: Process Base Address and Memory Protection Discrepancy

VAD and PEB Comparison:
    Base Address(VAD): 0x6b0000
    Process Path(VAD): \Windows\SysWOW64\svchost.exe
    Vad Protection: PAGE_EXECUTE_WRITECOPY
    Vad Tag: Vad

    Base Address(PEB): 0x2d60000
    Process Path(PEB): C:\Windows\SysWOW64\svchost.exe
    Memory Protection: PAGE_READONLY
    Memory Tag: VadS


0x02d60000  4d 5a 90 45 52 4e 57 00 04 00 00 00 ff ff 00 00   MZ.ERNW.........
0x02d60010  b8 00 00 00 00 00 00 00 40 00 00 00 00 00 00 00   ........@.......
0x02d60020  00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00   ................
0x02d60030  00 00 00 00 00 00 00 00 00 00 00 00 e0 00 00 00   ................

While the overwrite* processes are in general not detected by hollowfind, the effect of the resetBase switch becomes apparent, as this simple modification of resetting the PEB‘s ImageBaseAddress back to its original value leads to the no-unmap processes remaining undetected.

One plugin that can help us in finding the two remaining nounmap* processes (nounmap_nx_setSecProt_clearMZ_resetBase and nounmap_nx_setSecProt_clearMZ_resetBase) is dlllist. While the Volatility 3 version does yet not support WoW64 processes, Volatility 2 does and has luckily support up until Windows 10 21H1 (15.19041). The new memory area is located at 0x2b80000 and, as we can see from the vadinfo output below, is a PAGE_READONLY memory area which is not associated with any file object (in contrast to the original svchost.exe, which remained mapped at 0x6b0000):

7080  svchost.exe  0x8007c786d130  0x6b0000   0x6bdfff   Vad   PAGE_EXECUTE_WRITECOPY  2   0  0xffff8007c786c9b0  \Windows\SysWOW64\svchost.exe
...
7080  svchost.exe  0x8007c6ed86e0  0x2b80000  0x2b94fff  VadS  PAGE_READONLY           21  1  0xffff8007c6ed71f0  N/A

When now using Volatility 2’s dlllist plugin, we clearly can spot something odd:

0x00000000006b0000  0xe000   0xffff  2022-09-22 19:11:47  C:\Windows\SysWOW64\svchost.exe
...
0x0000000002b80000  0x15000  0xffff  2022-09-22 19:11:47  C:\Windows\SysWOW64\svchost.exe
...

svchost.exe appears twice in the list of loaded modules, with the second instance being our new malicious memory area. The Windows loader seems to add our new memory area to the list of loaded modules after resuming the thread, which helps us in order to identify this indicator.

The other plugin that can help us in detecting all Process Hollowing processes, in particular the nounmap*resetBase ones, but more importantly also the overwrite* processes, which have so far remained undetected, is ptemalfind.

First, we have a look at the nounmap*resetBase processes. Since the plugin skips any non-executable page for its output and both, nounmap_nx_setSecProt_clearMZ_resetBase and nounmap_nx_setSecProt_clearHeader_resetBase set the header to non-executable (in the second case, the header has been cleared anyways), the output starts at the first executable page which in this case is the beginning of the .text section (for details on this plugin see this post):

7080    svchost.exe 0x2b80000   0x2b94fff   PAGE_READONLY   Private Memory  Disabled
Meta Info:
    10 non empty page(s) (starting at 0x2b81000) with a total size of 0xa000 bytes in this VAD were executable (and for mapped image files also modified).
    Skipping the first 0x1000 bytes, as they are either not modified (only applies for mapped image files), empty, not executable or skipped via cmdline argument.

6a 00 68 ec ec b8 02 68 j.h....h
ec ec b8 02 6a 00 ff 15 ....j...
f4 b0 b8 02 33 c0 c2 10 ....3...
00 3b 0d 00 00 b9 02 75 .;.....u
02 f3 c3 e9 be 01 00 00 ........
e8 a1 12 00 00 e9 00 00 ........
00 00 6a 14 68 f0 ed b8 ..j.h...
02 e8 42 18 00 00 e8 72 ..B....r
0x2b81000:  push    0
0x2b81002:  push    0x2b8ecec
0x2b81007:  push    0x2b8ecec
0x2b8100c:  push    0
0x2b8100e:  call    dword ptr [0x2b8b0f4]
...

The first few assembly instructions seem plausible, so it might actually be machine code and not any other data, but whether or not this is benign or malicious code cannot be easily answered from this output. At this point, the apisearch plugin comes in handy. It searches for any references to APIs and our expectation here is that it shows us multiple API pointers right next to each other, which would be an indicator for a patched Import Address Table. The plugin can either be executed directly or given as an extension to ptemalfind:

vol3 -c mem.json windows.ptemalfind --pid 7080 --plugins apisearch.ApiSearch

7080  svchost.exe  0x2b80000  0x2b94fff  PAGE_READONLY  Private Memory
Meta Info:
    10 non empty page(s) (starting at 0x2b81000) with a total size of 0xa000 bytes in this VAD were executable (and for mapped image files also modified).
    Skipping the first 0x1000 bytes, as they are either not modified (only applies for mapped image files), empty, not executable or skipped via cmdline argument.
	
6a 00 68 ec ec b8 02 68	j.h....h
ec ec b8 02 6a 00 ff 15	....j...
f4 b0 b8 02 33 c0 c2 10	....3...
00 3b 0d 00 00 b9 02 75	.;.....u
02 f3 c3 e9 be 01 00 00	........
e8 a1 12 00 00 e9 00 00	........
00 00 6a 14 68 f0 ed b8	..j.h...
02 e8 42 18 00 00 e8 72	..B....r	
0x2b81000:	push	0
0x2b81002:	push	0x2b8ecec
0x2b81007:	push	0x2b8ecec
0x2b8100c:	push	0
0x2b8100e:	call	dword ptr [0x2b8b0f4]
...

Output from extensions:
    ApiSearch output:
        0x2b8b000: kernel32.dll!TlsSetValue    distance: 0
        0x2b8b004: kernel32.dll!CreateFileW    distance: 0
        0x2b8b008: kernel32.dll!GetCommandLineA    distance: 0
        0x2b8b00c: kernel32.dll!IsDebuggerPresent    distance: 0
        0x2b8b010: kernel32.dll!IsProcessorFeaturePresent    distance: 0
        0x2b8b014: kernel32.dll!GetLastError    distance: 0
        0x2b8b018: kernel32.dll!SetLastError    distance: 0
        0x2b8b01c: kernel32.dll!GetCurrentThreadId    distance: 0
        0x2b8b020: ntdll.dll!RtlEncodePointer    distance: 0
        0x2b8b024: ntdll.dll!RtlDecodePointer    distance: 0
        0x2b8b028: kernel32.dll!ExitProcess    distance: 0
...
        0x2b8b0b4: kernel32.dll!LoadLibraryExW    distance: 0
        0x2b8b0b8: kernel32.dll!RtlUnwind    distance: 0
        0x2b8b0bc: kernel32.dll!OutputDebugStringW    distance: 0
        0x2b8b0c0: ntdll.dll!RtlAllocateHeap    distance: 0
        0x2b8b0c4: ntdll.dll!RtlReAllocateHeap    distance: 0
        0x2b8b0c8: kernel32.dll!GetStringTypeW    distance: 0
        0x2b8b0cc: ntdll.dll!RtlSizeHeap    distance: 0
        0x2b8b0d0: kernel32.dll!LCMapStringW    distance: 0
        0x2b8b0d4: kernel32.dll!FlushFileBuffers    distance: 0
        0x2b8b0d8: kernel32.dll!GetConsoleCP    distance: 0
        0x2b8b0dc: kernel32.dll!GetConsoleMode    distance: 0
        0x2b8b0e0: kernel32.dll!SetStdHandle    distance: 0
        0x2b8b0e4: kernel32.dll!SetFilePointerEx    distance: 0
        0x2b8b0e8: kernel32.dll!WriteConsoleW    distance: 0
        0x2b8b0ec: kernel32.dll!CloseHandle    distance: 0
        0x2b8b0f4: user32.dll!MessageBoxA    distance: 4

The distance tells us how many bytes are between the current and last API pointer and for a patched IAT, the distance mostly resides between 0 and a few bytes. Assuming, this is actually a patched IAT (which, given the large amount of API pointers right next to each other, is very likely), finding such a table in a memory area not associated with a mapped image file is definitely suspicious and could indicate process hollowing or reflective DLL injection (see also here).

Looking at overwrite_disableCFG and overwrite_disableCFG_setSecProt respectively, we get the following two outputs with the easily recognizable DOS header in the first case and again, the .text section in the second (because the header has been marked read-only). The reason why these pages are presented by ptemalfind for a mapped file is that they have been modified (we will cover this topic later in more detail). The inclusion of mapped image files is deactivated by default as it can significantly increase the amount of output, so we have to activate it with the --include-image-files switch:

vol3 -c mem.json windows.ptemalfind --pid 4204 --include-image-files

4204  help.exe  0xb80000  0xb86fff  PAGE_EXECUTE_WRITECOPY  Mapped Image File: \Windows\SysWOW64\help.exe
Meta Info:
    6 non empty page(s) (starting at 0xb80000) with a total size of 0x6000 bytes in this VAD were executable (and for mapped image files also modified).

4d 5a 90 45 52 4e 57 00 MZ.ERNW.
04 00 00 00 ff ff 00 00 ........
b8 00 00 00 00 00 00 00 ........
40 00 00 00 00 00 00 00 @.......
00 00 00 00 00 00 00 00 ........
00 00 00 00 00 00 00 00 ........
00 00 00 00 00 00 00 00 ........
00 00 00 00 f0 00 00 00 ........
0xb80000:   dec ebp
0xb80001:   pop edx

And for the overwrite_disableCFG_setSecProt process:

vol3 -c mem.json windows.ptemalfind --pid 6192 --include-image-files

6192  help.exe  0xb80000  0xb86fff  PAGE_EXECUTE_WRITECOPY  Mapped Image File: \Windows\SysWOW64\help.exe
Meta Info:
    1 non empty page(s) (starting at 0xb81000) with a total size of 0x1000 bytes in this VAD were executable (and for mapped image files also modified).
    Skipping the first 0x1000 bytes, as they are either not modified (only applies for mapped image files), empty, not executable or skipped via cmdline argument.

6a 00 68 28 21 b8 00 68 j.h(!..h
38 21 b8 00 6a 00 ff 15 8!..j...
34 20 b8 00 33 c0 c3 cc 4...3...
cc cc cc cc cc cc cc cc ........
3b 0d 04 30 b8 00 75 01 ;..0..u.
c3 e9 94 02 00 00 cc cc ........
56 6a 01 e8 b2 0b 00 00 Vj......
e8 7a 06 00 00 50 e8 dd .z...P..
0xb81000:   push    0
0xb81002:   push    0xb82128
0xb81007:   push    0xb82138
0xb8100c:   push    0
0xb8100e:   call    dword ptr [0xb82034]
...

While a modified and executable PE file header is obviously suspicious, the single executable page right here is not. One, but probably not the best, option to further analyze this is again the usage of apisearch. The identification of a patched IAT would in this case obviously not be an indicator, but we can use apisearch in order to inspect the imported APIs for suspicious ones (in this case MessageBoxA is our “suspicious” API):

vol3 -c mem.json windows.ptemalfind --pid 6192 --include-image-files

6192  help.exe  0xb80000  0xb86fff  PAGE_EXECUTE_WRITECOPY  Mapped Image File: \Windows\SysWOW64\help.exe
Meta Info:
    1 non empty page(s) (starting at 0xb81000) with a total size of 0x1000 bytes in this VAD were executable (and for mapped image files also modified).
    Skipping the first 0x1000 bytes, as they are either not modified (only applies for mapped image files), empty, not executable or skipped via cmdline argument.

6a 00 68 28 21 b8 00 68 j.h(!..h
38 21 b8 00 6a 00 ff 15 8!..j...
34 20 b8 00 33 c0 c3 cc 4...3...
cc cc cc cc cc cc cc cc ........
3b 0d 04 30 b8 00 75 01 ;..0..u.
c3 e9 94 02 00 00 cc cc ........
56 6a 01 e8 b2 0b 00 00 Vj......
e8 7a 06 00 00 50 e8 dd .z...P..
0xb81000:   push    0
0xb81002:   push    0xb82128
0xb81007:   push    0xb82138
0xb8100c:   push    0
0xb8100e:   call    dword ptr [0xb82034]
...

Output from extensions:
    ApiSearch output:
        0xb82000: kernel32.dll!GetCurrentProcessId    distance: 0
...
        0xb8202c: kernel32.dll!SetUnhandledExceptionFilter    distance: 0
        0xb82034: user32.dll!MessageBoxA    distance: 4
        0xb8203c: vcruntime140.dll!__current_exception    distance: 4
...

To get a better feeling for a real suspicious output, let’s have a last look at the nounmap_nx_setSecProt_clearHeader_resetBase process with mimikatz loaded:

vol3 -c mem.json windows.ptemalfind --pid 3892 --plugins apisearch.ApiSearch

3892  PATHPING.EXE  0x2e00000  0x2f08fff  PAGE_READONLY  Private Memory
Meta Info:
    156 non empty page(s) (starting at 0x2e01000) with a total size of 0x9c000 bytes in this VAD were executable (and for mapped image files also modified).
    Skipping the first 0x1000 bytes, as they are either not modified (only applies for mapped image files), empty, not executable or skipped via cmdline argument.
	
51 a1 ec b2 ef 02 83 25	Q......%
d0 b2 ef 02 00 83 25 d8	......%.
b2 ef 02 00 57 89 0d dc	....W...
...

Output from extensions:
    ApiSearch output:
...
        0x2e9d000: advapi32.dll!CryptSetHashParam    distance: 62891
        0x2e9d004: advapi32.dll!CryptGetHashParam    distance: 0
        0x2e9d008: advapi32.dll!CryptExportKey    distance: 0
        0x2e9d00c: advapi32.dll!CryptAcquireContextW    distance: 0
        0x2e9d010: advapi32.dll!CryptSetKeyParam    distance: 0
...
        0x2e9d128: advapi32.dll!LsaQueryTrustedDomainInfoByName    distance: 0
        0x2e9d12c: advapi32.dll!CryptSignHashW    distance: 0
        0x2e9d130: advapi32.dll!LsaSetSecret    distance: 0
        0x2e9d134: cryptsp.dll!SystemFunction023    distance: 0
        0x2e9d138: advapi32.dll!LsaOpenSecret    distance: 0
        0x2e9d13c: advapi32.dll!LsaQuerySecret    distance: 0
        0x2e9d140: advapi32.dll!LsaRetrievePrivateData    distance: 0
        0x2e9d144: advapi32.dll!LsaEnumerateTrustedDomainsEx    distance: 0
...
        0x2e9d268: kernel32.dll!UnmapViewOfFile    distance: 0
        0x2e9d26c: kernel32.dll!MapViewOfFile    distance: 0
        0x2e9d270: kernel32.dll!WriteProcessMemory    distance: 0
        0x2e9d274: kernel32.dll!VirtualProtect    distance: 0
        0x2e9d278: kernel32.dll!InterlockedExchange    distance: 0
        0x2e9d27c: kernel32.dll!SetFilePointerEx    distance: 0
        0x2e9d280: kernel32.dll!GetProcessId    distance: 0
        0x2e9d284: kernel32.dll!GetComputerNameW    distance: 0
        0x2e9d288: kernel32.dll!ProcessIdToSessionId    distance: 0
        0x2e9d28c: kernel32.dll!VirtualAllocEx    distance: 0
        0x2e9d290: kernel32.dll!VirtualProtectEx    distance: 0
        0x2e9d294: kernel32.dll!VirtualAlloc    distance: 0
...
        0x2e9d5a8: samlib.dll!SamOpenUser    distance: 0
        0x2e9d5ac: samlib.dll!SamLookupDomainInSamServer    distance: 0
        0x2e9d5b0: samlib.dll!SamLookupNamesInDomain    distance: 0
        0x2e9d5b4: samlib.dll!SamLookupIdsInDomain    distance: 0
        0x2e9d5b8: samlib.dll!SamOpenDomain    distance: 0
...
        0x2e9d714: Wldap32.dll!ldap_first_attributeW    distance: 0
        0x2e9d718: Wldap32.dll!ldap_first_entry    distance: 0
        0x2e9d71c: Wldap32.dll!ldap_next_attributeW    distance: 0
        0x2e9d720: Wldap32.dll!ldap_memfreeW    distance: 0
        0x2e9d724: Wldap32.dll!ldap_next_entry    distance: 0
        0x2e9d728: Wldap32.dll!ldap_connect    distance: 0
...
        0x2e9d7c4: msasn1.dll!ASN1_CreateEncoder    distance: 4
        0x2e9d7c8: msasn1.dll!ASN1BERDotVal2Eoid    distance: 0
        0x2e9d7cc: msasn1.dll!ASN1_CloseEncoder    distance: 0
        0x2e9d7d0: msasn1.dll!ASN1_CreateDecoder    distance: 0
...

Depending on the image file, seeing these APIs should raise some warning signs.

The other option to analyze the modifications is to compare the modified VAD content with the original data. There are at least two ways to do this. One is to load the corresponding file from disk and compare it with the memory-mapped data (while applying section alignment, relocations and so on). For the memory forensics area, this approach has e.g., been implemented by Hashtest (see the corresponding paper here). The downside here is that the implementation only supports 32bit and requires you to pre-build hashes for all PE files. For live analysis, there are at least pe-sieve and to a certain degree also Moneta (it compares PE details but not the actual content), which we will see in a bit. The other way to compare the VAD data to its original is to use the cached file resources: ImageSectionObject, DataSectionObject and SharedCacheMap (see e.g., here). These can e.g., be accessed and dumped with Volatility’s dumpfiles plugin and then used for comparison. The ideal candidate here would be the ImageSectionObject dump, if available, as it comes the closest to the unmodified VAD (e.g., it already has relocations applied). Note: dumpfiles rolls back the section alignment, so use a comparison tool that can handle shifts of data.

In order to do the comparison in a more automated way, we can also just implement a plugin that compares the corresponding pages (VAD vs cached file data), if they have been modified, and print the changed bytes. That’s exactly what I’m currently working on. In the following listing we can see the output of my current Proof of Concept for the overwrite_disableCFG_setSecProt process, which shows us the first block of many others that have significantly changed, and in the last line prints the overall amount of bytes that differ from the original ImageSectionObject:

PID   Process    Section name(s)  First modified byte   Modified Module

6192  help.exe       .text            0xb81000          \Windows\SysWOW64\help.exe

Orig Data
	
40 30 b8 00 90 30 b8 00	@0...0..
ac 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 00 00 00 00	........
00 00 00 00 04 30 b8 00	.....0..
40 12 b8 00 01 00 00 00	@.......
b8 40 b8 00 00 00 00 00	.@......
d0 10 b8 00 09 00 00 00	........
00 75 01 00 00 00 00 00	.u......
00 00 00 00 00 00 00 00	........	
0xb81000:	inc	eax
0xb81001:	xor	byte ptr [eax - 0x47cf7000], bh
0xb81007:	add	byte ptr [eax + eax], ch
0xb8100e:	add	byte ptr [eax], al
0xb81010:	add	byte ptr [eax], al
0xb81012:	add	byte ptr [eax], al
...

New Data

6a 00 68 28 21 b8 00 68	j.h(!..h
38 21 b8 00 6a 00 ff 15	8!..j...
34 20 b8 00 33 c0 c3 cc	4...3...
cc cc cc cc cc cc cc cc	........
3b 0d 04 30 b8 00 75 01	;..0..u.
c3 e9 94 02 00 00 cc cc	........
56 6a 01 e8 b2 0b 00 00	Vj......
e8 7a 06 00 00 50 e8 dd	.z...P..
0b 00 00 e8 68 06 00 00	....h...
8b f0 e8 01 0c 00 00 6a	.......j
01 89 30 e8 13 04 00 00	..0.....
83 c4 0c 5e 84 c0 74 73	...^..ts
db e2 e8 a7 08 00 00 68	.......h
40 19 b8 00 e8 87 05 00	@.......	
0xb81000:	push	0
0xb81002:	push	0xb82128
0xb81007:	push	0xb82138
0xb8100c:	push	0
0xb8100e:	call	dword ptr [0xb82034]
...

Total amount of changed bytes for this VAD: 3792

As the current PoC only compares modified executable pages and we only have one executable page with a size of 4096 bytes, a total amount of 3792 changed bytes means roughly 93%. This approach can of course not only be used for detecting the overwrite-method in process hollowing, but any modification in a VAD such as hooks.

In regards to pe-sieve/hollows_hunter and Moneta there is not that much to say other than: They both do a great job in detecting all our malicious processes. Following the output for the nounmap and overwrite_disablecfg_setsecprot processes.

pe-sieve – overwrite_disablecfg_setsecprot:

SUMMARY:

Total scanned:      34
Skipped:            0
-
Hooked:             0
Replaced:           1
Hdrs Modified:      0
IAT Hooks:          0
Implanted:          0
Unreachable files:  0
Other:              0
-
Total suspicious:   1

pe-sieve – nounmap:

SUMMARY:

Total scanned:      34
Skipped:            0
-
Hooked:             0
Replaced:           1
Hdrs Modified:      0
IAT Hooks:          0
Implanted:          1
Implanted PE:       1
Implanted shc:      0
Unreachable files:  0
Other:              1
-
Total suspicious:   3

Moneta – overwrite_disablecfg_setsecprot:

help.exe : 6192 : Wow64 : C:\Windows\SysWOW64\help.exe
  0x0000000000B80000:0x00007000   | EXE Image           | C:\Windows\SysWOW64\help.exe
    0x0000000000B80000:0x00001000 | R        | Header   | 0x00001000 | Primary image base | Modified PE header
    0x0000000000B81000:0x00001000 | RX       | .text    | 0x00001000 | Modified code

Moneta – nounmap:

svchost.exe : 4032 : Wow64 : C:\Windows\SysWOW64\svchost.exe
  0x0000000002C60000:0x00015000   | Private
    0x0000000002C60000:0x00015000 | RWX      | 0x00000000 | Primary image base | Abnormal private executable memory | Non-image primary image base

The lowest level (in the sense of least suspicious) to which we could get both was with our mimikatz-nounmap_nx_setsecprot_clearHeader_resetbase process:

pe-sieve:

SUMMARY:

Total scanned:      62
Skipped:            0
-
Hooked:             0
Replaced:           0
Hdrs Modified:      0
IAT Hooks:          0
Implanted:          0
Unreachable files:  0
Other:              2
-
Total suspicious:   2

Moneta:

pathping.exe : 3892 : Wow64 : C:\Windows\SysWOW64\PATHPING.EXE
  0x0000000002E00000:0x00109000   | Private
    0x0000000002E01000:0x0009c000 | RX       | 0x00000000 | Abnormal private executable memory

The reason why pe-sieve detects more than just executable memory (in contrast to Moneta, malfind or ptemalfind) is that it uses the EnumProcessModulesEx API in order to get all loaded modules, which also returns the duplicate executable module (as we already have seen in the dlllist analysis). As one of both does not have a corresponding file object, it correctly detects that as something suspicious and reports it.

Side note: The reason why Moneta is detecting “modified code” is not that it actually compares the data in memory with the PE file on disk, but it checks the VAD’s pages via QueryWorkingSetEx for being modified by inspecting the Shared field (see the blog post here for more details). This is similar to the approach ptemalfind takes, which checks the PrototypePte flag of the corresponding MMPFN entry (see here and here for more details), which can be translated to a verification of whether or not a page is currently shareable (an unmodified page belonging to a mapped image file should normally be shareable). We will see in a follow-up post, however, that this test is not reliable anymore as modified private pages can get shareable again, rendering the QueryWorkingSetEx and PrototypePte approach useless. We will also see how (at least for the memory forensics area) we still can detect modified pages.

Conclusion

So, to sum up the pros/cons of using the overwrite method (from an attacker’s perspective):

  • Pro: Stealthier during setup: No need to unmap the executable and allocate executable memory.
  • Con: We need, however, the VirtualProtectEx API. If we choose a CFG-enabled victim, we also need an executable with CFG enabled (done by simply setting a flag within VisualStudio) and the SetProcessValidCallTargets API.
  • Pro: Harder to detect in certain cases (e.g., manual inspection with Process Hacker). As we leave the memory layout as is, there are no obvious differences anymore, such as anonymous memory at the address of the expected mapped executable. When also setting the section protections accordingly, the only remaining difference might be section size abnormalities.
  • Con: A comparison of the mapped executable with the original one from disk or with the cached files will show obvious differences.

If you feel like playing around with the tools yourself, you can find the Process Hollowing project here, all used executables here, the Volatility 3 plugins here and the modified hollowfind plugin here.

Cheers,
Frank