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:
- Create a new process (victim) using a benign executable, in suspended state.
- Unmap the executable from that process.
- Allocate memory for the malicious executable at the address of the previously mapped victim executable.
- Write the malicious executable to the new memory area and potentially apply relocations.
- Adjust the entry point.
- 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).
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
- 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:
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
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:
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:
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):
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
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:
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
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
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.
malfind, we can at least find two instances: The
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
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 ........
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 (
nounmap_nx_setSecProt). Following the output for the
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 ................
overwrite* processes are in general not detected by
hollowfind, the effect of the
resetBase switch becomes apparent, as this simple modification of resetting the
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 (
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
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
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_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
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
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).
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
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
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:
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
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
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
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
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
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-
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
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,
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
PrototypePte approach useless. We will also see how (at least for the memory forensics area) we still can detect modified pages.
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
VirtualProtectExAPI. 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
- 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.