Process Hollowing (a.k.a. RunPE) is probably the oldest, and the most popular process impersonation technique (it allows to run a malicious executable under the cover of a benign process). It is used in variety of PE loaders, PoCs, and offensive tooling. It was also used in one of the demos involving my library, libPEconv. Recently I’ve got a github issue from a user complaining that the demo no longer works on the latest Windows 11, 24H2 . This Windows release was published October 1, 2024, so it is still fresh, but slowly gaining popularity. Searching for the solution I found out, that many people encountered the same problem with different implementations of RunPE, and it is a problem with the technique itself. Still, the answers that I found were not really reaching the root of the problem, so I decided to investigate it deeper. In this short blog I describe my findings, in hopes that it will help other people who experienced the same issue.
After the PE was implanted into the newly created, suspended process, we resume the process, and the implant is supposed to load, using the typical Windows loader mechanism. However, when we resume the 64-bit process on Windows 11 24H2, the loading will get interrupted with an error: 0xC0000141.

This problem comes from changes that were implemented in the Windows loader. However, unlike some people suspected, it is not related to Control Flow Guard. It turns out the reason is much simpler.
The implementation of Run PE involves loading the payload into the newly allocated memory. Depending on the variant of the technique, it may be implemented in two ways:
In both cases, the new PE is stored in the private memory (MEM_PRIVATE), unlike the normally mapped PE, which will be stored as image (MEM_IMAGE). This is going to make a big difference further on.
During the loading process, the subsequent functions are called:
LdrpInitializeProcess -> LdrpProcessMappedModule -> RtlpInsertOfRemoveScpCfgFunctionTable -> ZwQueryVirtualMemory
The function ZwQueryVirtualMemory is meant to retrieve the properties of each module in memory. It is called with the new argument MemoryImageExtensionInformation that can be used only on images (MEM_IMAGE). Since the implanted PE is not an image, but MEM_PRIVATE, the function will failed will the error (STATUS_INVALID_ADDRESS).

This further causes the loading to terminate with an error.
There are two approaches with which we can solve this problem:
While RunPE is still the most known and popular process impersonation technique, in the meantime, multiple alternatives evolved, using which we can map our implant as MEM_IMAGE, not as MEM_PRIVATE.
There is a group of techniques that create a section first (using NtCreateSection), and then create the process from the section, using the native API NtCreateProcessEx. This group contains the following techniques:
However, this group of techniques is not as convenient to use as the classic RunPE. It involves filling a lot of structures manually. Another problem is, the process will distinguish itself from the normally created one, since it is created from an unnamed module (GetProcessImageFileName returns an empty string). This does not happen in case of RunPE. So, although they are a nice addition to the arsenal of techniques, they don’t make a perfect replacement of the classic.
With time more options appeared to replace the Run PE. Process Doppelganging and Process Ghosting inspired hybrid techniques, that are closer in their implementation to the Process Hollowing, yet, contain the major improvement of using the PE mapped as MEM_IMAGE. Those hybrids are:
In case of those techniques, GetProcessImageFileName returns the target’s path, and the process resembles more the one that is loaded normally. The payload is mapped as unnamed MEM_IMAGE.
Later, I came up with one more variant of the loader, that would map the payload as named MEM_IMAGE, making it yet more similar to a legitimately loaded PE. Details of the implementation, and comparison to other techniques, can be found in the repository:
According to my latest tests, Transacted/Ghostly Hollowing, as well as Process Overwriting, successfully loaded PEs on Windows 11 24H2, without the need of any additional changes or patches.
Demo (Process Overwriting on Windows 11 24H2):
If, for whatever reason, we insist to use the original RunPE, and run our payload from MEM_PRIVATE, it is still possible to achieve it. However, it will require patching of the function that causes the error (ZwQueryVirtualMemory). Of course we want the patch to have a minimal impact on the rest of the execution, so it has to filter only one particular case when we are making a query about the specific memory region containing our payload.
First, we check if our loaded is running on Windows 11 24H2 or higher, because lower versions don’t have this problem. Also, only the 64-bit processes should be affected.
The functionality of the patch can be described by the following pseudocode:
ZwQueryVirtualMemoryZwQueryVirtualMemoryThe full implementation of the patch can be found here:
https://github.com/hasherezade/libpeconv/blob/master/run_pe/patch_ntdll.cpp
As a result, loading of our implant won’t be interrupted, and we can enjoy having Process Hollowing on Windows 11 24H2!