Injecting code into PPL processes without vulnerable drivers on Windows 11

While reading this article from James Forshaw on leveraging COM to inject code into a process, I discovered a syscall that I didn’t known about: NtSystemDebugControl.

Being unfamiliar with this syscall, I decided to look for more information. I found this PowerShell snippet, which worked well to dump kernel-mode memory. Looking for more, I found that the Flags argument was an enum that contained a IncludeUserSpaceMemoryPages. As the name suggests, if this flag is specified, the memory dump will include user-mode pages that are not swapped out.

From what I could gather, this flag used to be available to userland, but was later restricted to drivers only in an earlier Windows version, up until Windows 11. I decided to give it a try anyways, and, to my surprise, it actually worked! The memory dump did contain user-mode memory pages. Digging deeper, I found that this only worked on Windows 11.

Immediately, I wanted to see if this could be abused to bypass the protection of Protected Process Light (PPL).

If you are unfamiliar with Protected Process Light, I would recommend reading this article. In short, some processes can be run as Protected Process Light, which tries to protect them even from a malicious privileged user. To do this, only Windows-signed libraries and executable files can (theoretically) be loaded within the PPL, and standard processes cannot get a HANDLE to the process with full access to the PPL process, to prevent code injection.

Windows 11 vs Windows 10

The difference can be found by comparing the syscall in a Windows 11 and Windows 10 installation: when the IncludeUserSpaceMemoryPages flag is specified, the calls ends up in DbgkCaptureLiveKernelDump

Here is the relevant code of this function on Windows 10:

IsFullLiveDumpDisabled = DbgkpWerIsFullLiveDumpDisabled();
if (IsFullLiveDumpDisabled != '\0') {
  DbgPrintEx(5, 1, "DBGK: Full Live Kernel Dumps are disabled. Failing request.\n");
if ((((PreviousMode == '\x01') && ((*(uint *)(param_1 + 0x38) & 4) != 0)) &&
    (KdPitchDebugger != '\0')) && (KdLocalDebugEnabled == '\0')) {

However, on Windows 11 (23H2), the code is the following :

IsFullLiveDumpDisabled = DbgkpWerIsFullLiveDumpDisabled();
if (IsFullLiveDumpDisabled != '\0') {
  DbgPrintEx(5, 1, "DBGK: Full Live Kernel Dumps are disabled. Failing request.\n");
IsFeatureEnabled = Feature_LivedumpProcessFiltering__private_IsEnabled();
if ((((IsFeatureEnabled == 0) && (PreviousMode == '\x01')) && ((param_1[0xe] & 4) != 0)) &&
   ((KdPitchDebugger != '\0' && (KdLocalDebugEnabled == '\0')))) {

A new check for a feature named LivedumpProcessFiltering is present in the Windows 11 version, which permits the capture of user-mode pages in the dump. Previously, this fonction was restricted to drivers, or system with debugging enabled.

A check for the SeDebugPrivilege is still required to call this syscall from userland.

This ability to create kernel dump with user-mode pages is actually used by the Task Manager, and has been published in a Microsoft article.

Dumping LSASS

The first offensive use case for this feature is to use this dump to read memory pages inside lsass.exe (regardless of whether RunAsPPL is configured for lsass.exe), to retrieve credentials for active Logon Sessions.

This is already implemented in Mimikatz’s plugin for WinDbg:

0: kd> .load C:\\path\\to\\mimilib.dll

  .#####.   mimikatz 2.2.0 (x64) built on Aug 10 2021 02:01:09
 .## ^ ##.  "A La Vie, A L'Amour" - Windows build 22631
 ## / \ ##  /* * *
 ## \ / ##   Benjamin DELPY `gentilkiwi` ( )
 '## v ##'             (oe.eo)
  '#####'                                  WinDBG extension ! * * */

#         * Kernel mode *         #
# Search for LSASS process
0: kd> !process 0 0 lsass.exe
# Then switch to its context
0: kd> .process /r /p <EPROCESS address>
# And finally :
0: kd> !mimikatz
#          * User mode *          #
0:000> !mimikatz

0: kd> !process 0 0 lsass.exe
PROCESS ffffa1834530d080
    SessionId: 0  Cid: 0574    Peb: b9abf5a000  ParentCid: 0508
    DirBase: 018a8000  ObjectTable: ffff910463f91680  HandleCount: 1267.
    Image: lsass.exe

Page 3eb169 not present in the dump file. Type ".hh dbgerr004" for details
0: kd> .process /r /p  ffffa1834530d080
Implicit process is now ffffa183`4530d080
Loading User Symbols
0: kd> !mimikatz



	msv : 
	 [00000003] Primary
	 * Username : Administrator
	 * Domain   : DOMAIN
	 * NTLM     : <NT_HASH>

This method is unlikely to raise any EDR detection, since this does not rely on traditionnal code injection/cross-process memory reads: no handle to lsass.exe is required.

Credentials will only be retrieved on systems with Credential Guard disabled. (Shoutout to Antonio Cocomazzi to who also tweeted about this new feature a few weeks ago!)

Code injection

While the ability to dump lsass.exe is useful on its own, I wanted to see if I could leverage this to gain code execution within a PPL process, ideally running at the highest level, Win-TCB.

There are several offensive use cases to getting actual code execution within a PPL. The first one, as demonstrated by angryorchard, is the ability to get an arbitrary kernel pointer decrement, if you can inject code into the csrss.exe process, which runs at the PPL-WinTCB level. While the ability to decrement the PreviousMode value will reportedly be mitigated in a future Windows version, this primitive probably can be leveraged in some other ways, crossing the admin-to-kernel boundary.

On systems with Credentials Guard enabled, being able to inject code inside lsass.exe can help retrieve NetNTLMv1 hashes, and can also be used to reactivate Wdigest, which will capture credentials in cleartext, and not within VTL1.

This can also be used to tamper with EDR usermode processes, which usually run at the PPL-ELAM level.

For the rest of this blogpost, the focus will be on injecting code inside the services.exe process, which runs as PPL, at the WinTCB level, which is the highest level.

Existing techniques and history

As far as I know, there has been 3 public tools that resulted in code execution within a PPL process. These are PPLdump, PPLmedic, and PPLFault.

PPLFault abused a time-of-check/time-of-use vulnerability in the verification of the signature of DLL that are mapped inside PPL processes. I would recommend watching the presentation, which contains addition context about others attacks on PPL processes.

The main flaw abused by the other two, was first reported in this presentation, is how the signature verification works when loading a DLL inside a PPL process. When a DLL is loaded from disk, NtCreateSection is first called with SEC_IMAGE, and the resulting section object is then mapped via NtMapViewOfSection. However, the signature verification only occurs when NtCreateSection is called, and not when the section is mapped within the process.

This was mainly abused through the KnownDll mechanism, which is a cache containing section objects of commonly used DLL. When such a DLL is loaded within a process, the section object is first opened via NtOpenSection, and then mapped by calling NtMapViewOfSection. This means that the signature verification process is bypassed. And although a non PLL cannot directly add a section object of a DLL in KnownDLLs, it is possible to trick csrss.exe to add it for us - csrss.exe runs at PPL-WinTCB.

To mitigate these, Microsoft implemented two things. First, ntdll!LdrpKnownDllDirectoryHandle, which holds a handle to KnownDll inside a normal process, is now unintialized by default inside PPL processes, and has been moved inside the .mrdata section. This section is read-only most of the time, and when a variable within this section needs to be changed, LdrProtectMrdata is called to toggle the memory permissions of the .mrdata section, make the change, and then toggled back.

Looking at a memory dump of a PPL process, we can see the following, which confirms the mitigations are present:

0:000> !address ntdll!LdrpKnownDllDirectoryHandle

Usage:                  Image
Base Address:           00007ffc`ebddb000
End Address:            00007ffc`ebe66000
Region Size:            00000000`0008b000 ( 556.000 kB)
State:                  00001000          MEM_COMMIT
Protect:                00000002          PAGE_READONLY
Type:                   01000000          MEM_IMAGE
Allocation Base:        00007ffc`ebc50000
Allocation Protect:     00000080          PAGE_EXECUTE_WRITECOPY
Image Path:             ntdll.dll
Module Name:            ntdll
Loaded Image Name:      C:\Windows\SYSTEM32\ntdll.dll
Mapped Image Name:      
More info:              lmv m ntdll
More info:              !lmi ntdll
More info:              ln 0x7ffcebdea2b0
More info:              !dh 0x7ffcebc50000

0:000> dp ntdll!LdrpKnownDllDirectoryHandle
00007ffc`ebdea2b0  00000000`00000000 00000000`00000000

Aside from these tools, James Forshaw published an article which demonstrated how to use COM remoting to overwrite ntdll!LdrpKnownDllDirectoryHandle with a valid handle to then load into a Protect Process an unsigned DLL, using the mechanism described above (at the time, ntdll!LdrpKnownDllDirectoryHandle was not placed inside the .mrdata section).

This technique was later described in details in MDSec’s blog, and implemented in a proof of concept to inject code into a remote process.

The short version is, if you can read the target process’s COM secret and context, as well as the IPID of the IRundown interface within the target process, it is possible to remotely invoke the IRundown::DoCallback method within the PPL process. This method takes a XAptCallback structure as an argument, which holds a function pointer to invoke in the remote process, as well as single argument to pass to the function. The function pointer is validated by Control Flow Guard (CFG), so it can’t be any pointer.

Getting arbitrary function call

Since we can read the memory of the usermode pages of a PPL process such as services.exe through the livedump generated by NtSystemDebugControl, the exploitation of IRundown::DoCallback is possible.

However, whereas the COM process secret and context of services.exe was always present within the dump, the IPID was rarely present - most likely due to being paged out (I haven’t investigated the reason why). To avoid this issue, it is possible to read the memory of the RPCSS service, which maintains a list of all IPID on a machine, since it is responsible for dispatching remote COM calls to the appropriate process where the actual object lives. RPCSS does not run as PPL, so it is possible to directly use ReadProcessMemory - it could also be parsed from the memory dump.

With this, it’s possible to call any CFG-valid target, with a single argument within services.exe.

Getting an arbitrary memory write

In his writeup, James Forshaw uses this primitive to call SetProcessDefaultLayout and GetProcessDefaultLayout, to overwrite LdrpKnownDllDirectoryHandle with a valid handle value.

Since it’s not possible anymore to directly overwrite ntdll!LdrpKnownDllDirectoryHandle, I’ll try to get the ability to call any function, without limitation for the arguments. And for that, an arbitrary write will make it much easier.

The combination of GetProcessDefaultLayout and SetProcessDefaultLayout only writes a value in the form of 0x0?0?0?0?, which isn’t so easy to manipulate, so I searched for other APIs.

Instead of searching for a function that would let me precisely control the value written, I looked for arbitrary increment.

Looking for a “AddRef” function was fruitful. I settled on combase!CStdStubBuffer_AddRef:

0:017> uf combase!CStdStubBuffer_AddRef
combase!CStdStubBuffer_AddRef [onecore\com\combase\ndr\ndrole\stub.cxx @ 878]:
  878 00007ff9`31d4e580 b801000000      mov     eax,1
  880 00007ff9`31d4e585 f00fc14108      lock xadd dword ptr [rcx+8],eax
  880 00007ff9`31d4e58a ffc0            inc     eax
  881 00007ff9`31d4e58c c3              ret

This function takes a single argument in the RCX register, and increments the dword pointed by RCX+8, atomically.

This function is also a valid CFG target, so this works perfectly. Now, to get an arbitrary write using this arbitrary increment, it’s simply a matter of incrementing a memory region, byte by byte, to get the desired memory buffer.

In the next steps, we will need to craft multiple arbitrary buffers, so, where should we write these buffers ? I opted to use the end of the .data section of various DLL that are already loaded in the PPL process. In memory, the sections of a PE file are page size-aligned, with zero-padding between sections. Since the pages of the .data section are allocate as RW, this gives us zero-initialized memory that is unused and writeable - overwriting this data won’t risk crashing the process.

RPC to the rescue!

Now, we have two primitives: arbitrary write, and a one argument function call. The goal is to transform this into the ability to call any function without limitation on the arguments.

To do so, I reused an idea from this browser exploitation technique.

The idea is relatively simple, with the arbitrary write, it’s possible to setup a NDR message, which is the representation of RPC messages. Then, it is possible to call rpcrt4!NdrServerCall2, with a single argument being a pointer to a RPC_MESSAGE structure, containing the NDR. This function is responsible for unmarshalling the NDR, which includes informations on a function to invoke, as well as the number of arguments to pass to the function.

By carefully cratfing the structure, it is possible to call an arbitrary function, with as many arguments as we want.

From there, injecting code into services.exe seems to be simply a matter of allocating RWX memory, and creating a thread.


Unfortunately, this won’t work for services.exe. Here’s the list of mitigations active on the process:

0: kd> dx @$curprocess.KernelObject.MitigationFlagsValues
@$curprocess.KernelObject.MitigationFlagsValues                 [Type: <unnamed-tag>]
    [+0x000 ( 0: 0)] ControlFlowGuardEnabled : 0x1 [Type: unsigned long]
    [+0x000 ( 1: 1)] ControlFlowGuardExportSuppressionEnabled : 0x0 [Type: unsigned long]
    [+0x000 ( 2: 2)] ControlFlowGuardStrict : 0x0 [Type: unsigned long]
    [+0x000 ( 3: 3)] DisallowStrippedImages : 0x0 [Type: unsigned long]
    [+0x000 ( 4: 4)] ForceRelocateImages : 0x0 [Type: unsigned long]
    [+0x000 ( 5: 5)] HighEntropyASLREnabled : 0x1 [Type: unsigned long]
    [+0x000 ( 6: 6)] StackRandomizationDisabled : 0x0 [Type: unsigned long]
    [+0x000 ( 7: 7)] ExtensionPointDisable : 0x1 [Type: unsigned long]
    [+0x000 ( 8: 8)] DisableDynamicCode : 0x1 [Type: unsigned long]
    [+0x000 ( 9: 9)] DisableDynamicCodeAllowOptOut : 0x0 [Type: unsigned long]
    [+0x000 (10:10)] DisableDynamicCodeAllowRemoteDowngrade : 0x0 [Type: unsigned long]
    [+0x000 (11:11)] AuditDisableDynamicCode : 0x1 [Type: unsigned long]
    [+0x000 (12:12)] DisallowWin32kSystemCalls : 0x0 [Type: unsigned long]
    [+0x000 (13:13)] AuditDisallowWin32kSystemCalls : 0x0 [Type: unsigned long]
    [+0x000 (14:14)] EnableFilteredWin32kAPIs : 0x0 [Type: unsigned long]
    [+0x000 (15:15)] AuditFilteredWin32kAPIs : 0x0 [Type: unsigned long]
    [+0x000 (16:16)] DisableNonSystemFonts : 0x1 [Type: unsigned long]
    [+0x000 (17:17)] AuditNonSystemFontLoading : 0x0 [Type: unsigned long]
    [+0x000 (18:18)] PreferSystem32Images : 0x0 [Type: unsigned long]
    [+0x000 (19:19)] ProhibitRemoteImageMap : 0x0 [Type: unsigned long]
    [+0x000 (20:20)] AuditProhibitRemoteImageMap : 0x0 [Type: unsigned long]
    [+0x000 (21:21)] ProhibitLowILImageMap : 0x0 [Type: unsigned long]
    [+0x000 (22:22)] AuditProhibitLowILImageMap : 0x0 [Type: unsigned long]
    [+0x000 (23:23)] SignatureMitigationOptIn : 0x1 [Type: unsigned long]
    [+0x000 (24:24)] AuditBlockNonMicrosoftBinaries : 0x0 [Type: unsigned long]
    [+0x000 (25:25)] AuditBlockNonMicrosoftBinariesAllowStore : 0x0 [Type: unsigned long]
    [+0x000 (26:26)] LoaderIntegrityContinuityEnabled : 0x0 [Type: unsigned long]
    [+0x000 (27:27)] AuditLoaderIntegrityContinuity : 0x0 [Type: unsigned long]
    [+0x000 (28:28)] EnableModuleTamperingProtection : 0x0 [Type: unsigned long]
    [+0x000 (29:29)] EnableModuleTamperingProtectionNoInherit : 0x0 [Type: unsigned long]
    [+0x000 (30:30)] RestrictIndirectBranchPrediction : 0x0 [Type: unsigned long]
    [+0x000 (31:31)] IsolateSecurityDomain : 0x0 [Type: unsigned long]

Arbitrary Code Guard is enabled on services.exe, which prevents the allocation of a RWX memory section, or changing from RW to RX using our arbitrary call primitive.

Failed attempt

My first idea to bypass this mitigation, was to abuse a well-known limitation of Arbitrary Code Guard. Although the process itself cannot allocate new executable memory, a different process with a handle to the first process with VM_OPERATION - and without the mitigation - can allocate new executable code within the original process which has Arbitratry Code Guard.

This is due to the MiArbitraryCodeBlocked function, which is called in the context of the remote process, for instance when calling NtProtectVirtualMemory:

NTSTATUS MiArbitraryCodeBlocked(_EPROCESS *pEprocess)
  if (((pEprocess->MitigationFlags & DisableDynamicCode) == 0) ||
     (KeGetCurrentThread()->CrossThreadFlags & DisableDynamicCodeOptOut) != 0)) {
    if (((pEprocess->MitigationFlags & AuditDisableDynamicCode) != 0) &&
       ((KeGetCurrentThread()->CrossThreadFlags & DisableDynamicCodeOptOut) == 0)) {
      EtwTimLogProhibitDynamicCode(1, pEprocess);
    return STATUS_SUCCESS;
  else {
    EtwTimLogProhibitDynamicCode(2, pEprocess);

Since the EPROCESS passed to this function is the one of the current process in the context of NtAllocateVirtualMemory or NtProtectVirtualMemory, and not the one of the remote process, a process without the mitigation can allocate RWX memory in a process with the mitigation, given a handle with enough right.

My idea was to call NtDuplicateObject using the arbitrary function call primitive, to give a handle with full permissions to a process without Arbitrary Code Guard, and then perform code injection using this handle.

However, this doesn’t work as I expected: our remote process receives a valid handle to the services.exe process, but this handle only has the Query Limited Informations right.

The reason for that is that I didn’t fully understand how the checks for PPL are implemented.

The checks are actually implemented by the PsTestProtectedProcessIncompatibility function, called within the PspProcessOpen:

PspProcessOpen is registered as the OpenProcedure for the ObjectType associated with processes objects.

0: kd> dt nt!_OBJECT_TYPE ffff9a8d`468c1bc0
   +0x000 TypeList         : _LIST_ENTRY [ 0xffff9a8d`468c1bc0 - 0xffff9a8d`468c1bc0 ]
   +0x010 Name             : _UNICODE_STRING "Process"
   +0x020 DefaultObject    : (null) 
   +0x028 Index            : 0x7 ''
   +0x02c TotalNumberOfObjects : 0x9b
   +0x030 TotalNumberOfHandles : 0x5e1
   +0x034 HighWaterNumberOfObjects : 0x9b
   +0x038 HighWaterNumberOfHandles : 0x633
   +0x040 TypeInfo         : _OBJECT_TYPE_INITIALIZER
   +0x0b8 TypeLock         : _EX_PUSH_LOCK
   +0x0c0 Key              : 0x636f7250
   +0x0c8 CallbackList     : _LIST_ENTRY [ 0xffffaf04`f583ee00 - 0xffffaf04`f583ee00 ]

0: kd> dx -id 0,0,ffff9a8d4bc750c0 -r1 (*((ntkrnlmp!_OBJECT_TYPE_INITIALIZER *)0xffff9a8d468c1c00))
(*((ntkrnlmp!_OBJECT_TYPE_INITIALIZER *)0xffff9a8d468c1c00))                 [Type: _OBJECT_TYPE_INITIALIZER]
    [+0x000] Length           : 0x78 [Type: unsigned short]
    [+0x002] ObjectTypeFlags  : 0xca [Type: unsigned short]
    [+0x020] RetainAccess     : 0x101000 [Type: unsigned long]
    [+0x024] PoolType         : NonPagedPoolNx (512) [Type: _POOL_TYPE]
    [+0x028] DefaultPagedPoolCharge : 0x1000 [Type: unsigned long]
    [+0x02c] DefaultNonPagedPoolCharge : 0xbd8 [Type: unsigned long]
    [+0x030] DumpProcedure    : 0x0 : 0x0 [Type: void (__cdecl*)(void *,_OBJECT_DUMP_CONTROL *)]
    [+0x038] OpenProcedure    : 0xfffff807173e61f0 : ntkrnlmp!PspProcessOpen+0x0 [Type: long (__cdecl*)(_OB_OPEN_REASON,char,_EPROCESS *,void *,unsigned long *,unsigned long)]
    [+0x040] CloseProcedure   : 0xfffff8071732ad50 : ntkrnlmp!PspProcessClose+0x0 [Type: void (__cdecl*)(_EPROCESS *,void *,unsigned __int64,unsigned __int64)]
    [+0x048] DeleteProcedure  : 0xfffff807173df330 : ntkrnlmp!PspProcessDelete+0x0 [Type: void (__cdecl*)(void *)]
    [+0x050] ParseProcedure   : 0x0 : 0x0 [Type: long (__cdecl*)(void *,void *,_ACCESS_STATE *,char,unsigned long,_UNICODE_STRING *,_UNICODE_STRING *,void *,_SECURITY_QUALITY_OF_SERVICE *,void * *)]

As such, when NtDuplicateObject is called, it calls ObDuplicateObject, which, if everything is valid, calls ObpIncrementHandleCountEx, triggering the OpenProcedure for the corresponding Object Type, in our case, PspProcessOpen - which will strip the rights of the handle.

To solve this issue, a first option is to spawn a new services.exe, but without the mitigation. Afterwards, since Arbitrary Call Guard is not enabled on this new services.exe, it is possible to allocate RWX memory and write a shellcode to it.

The other option is to abuse the fact that the signature is not verified during the mapping of Section objects.

The complete chain

Now, let’s see how we can assemble all these primitives to get code execution within services.exe.

  • First, we create a Kernel LiveDump, specifying the IncludeUserSpaceMemoryPages flag.

This will create a livedump on disk. This may slow down the system for a few seconds.

  • Next, we parse the livedump to retreive the necessary values

To be able to parse the livedump file, I made a quick reimplementation of kdmp-parser in Rust (while doing this project, the original author made his own port to Rust, which is probably much better but I’m too lazy to change).

First, we locate the PsActiveProcessHead, which points to an entry in the doubly-linked of processes in kernel memory containing the EPROCESS structure of all processes. When the target PID is found, we read the DirectoryTableBase field of the EPROCESS, which corresponds to the PML4 entry for the virtual memory of services.exe.

  • We also parse the memory of services.exe within the livedump

To find the COM process secret and context, the easiest way is to use symbols. In their article, MDSec showed how to find the address without symbols, so this can work as well.

There are two values that we are interested in: the COM context and process secret.

  • Within the memory of RPCSS, we locate an IPID and OXID associated with the IRundown interface in the services.exe process

Since this process does not run as PPL, its memory can be read as a process having SeDebugPrivilege using ReadProcessMemory and similar APIs.

The symbol gpServerOxidTable within rpcss.dll points to a structure, which contains a number of CServerOXID entries.

0:000> dp rpcss!gpServerOxidTable
00007ffe`20c5ed08  00000284`dd6412d0 00000284`dd641410

0:000> dp 00000284`dd6412d0
00000284`dd6412d0  0000011f`00000200 00000284`de1d9750
                      ^        ^             ^
                      |        |             |
                      |        |             |
                      |    Array Max Size    |
                      |                      |
              Current Array Size      Array Address

0:000> dp 00000284`de1d9750
00000284`de1d9750  00000284`de0ba250 00000284`dddcc250
00000284`de1d9760  00000000`00000000 00000284`de797490
00000284`de1d9770  00000284`de7f89e0 00000284`de058150

Although the CServerOXID structure is undocumented, there are three fields we are interested in: one is the IPID of the IRundown interface, one is the OXID of the object. The other is a pointer to the associated CProcess structure, which holds the PID of the process where the object lives.

0:000> dt nt!_GUID 00000284`de0ba250 + 0x60
 {0000bc0e-2370-1478-2908-8cd7ff79d45f} <-- IPID of the IRundown interface

0:000> dp 00000284`de0ba250 + 0x20
00000284`de0ba270  00000284`de7df650 00000012`00000000
                    CProcess entry

0:000> du poi(00000284`de7df650 + 0x180)
00000284`de06e740  "C:\Program Files\WindowsApps\Mic"
00000284`de06e780  "rosoft.WindowsTerminal_1.19.1121"
00000284`de06e7c0  "3.0_x64__8wekyb3d8bbwe\WindowsTe"
00000284`de06e800  "rminal.exe"

0:000> dd 00000284`de7df650+0x58
00000284`de7df6a8  00002370 <-- PID of the process, here WindowsTerminal.exe
  • Finally, we use the IRundown::DoCallback method repeatedly, to invoke combase!CStdStubBuffer_AddRef.

Using the arbitrary increment, we craft valid RPC_MESSAGEs for the next function calls, with valid arguments.

If the target services.exe is a new process without Arbitrary Code Guard, the chain of RPC_MESSAGE can be used to allocate a RWX buffer, copy a shellcode and execute it.

Otherwise, if the services.exe is the original one, the payload must be mapped via NtMapViewOfSection in the process. The easiest way would be to change the protection of ntdll!LdrpKnownDllDirectoryHandle, write a valid handle to a Directory object containing section ojects, then call LoadLibraryA. However, I decided it would be interesting to explore how to do it without leveraging ntdll!LdrpKnownDllDirectoryHandle.

In the exploit process, we call NtCreateSection on an unsigned DLL. Then, using the ability to call functions within services.exe, open the exploit process using NtOpenProcess, and duplicate the section handle of the unsigned DLL using NtDuplicateObject. With this, we have a valid handle to a Section object inside services.exe, without having performed the signature verification. This Section object can then be mapped with NtMapViewOfSection. The downside of this approach is although the DLL is mapped, relocations are not fixed, as they are usually done in the call to LdrLoadDll and not in the NtMapViewOfSection syscall.

I have implemented a proof of concept for this strategy, available on Github.

Detections ideas and takeaways

Regarding detection, the Kernel-LiveDump log source generates events when such a dump is created, in particular, event IDs 2, 3, and 4 seem relevant.

PS> Get-WinEvent -LogName Microsoft-Windows-Kernel-LiveDump/Operational -MaxEvents 3

   ProviderName: Microsoft-Windows-Kernel-LiveDump

TimeCreated                      Id LevelDisplayName Message
-----------                      -- ---------------- -------
5/20/2024 1:09:57 PM              2 Information      Live Dump Capture Dump Data API ended. NT Status: STATUS_SUCCESS.  BugcheckCode: 353. BugcheckParameter1: 0x0. BugcheckParameter2: 0x0. BugcheckParameter3: 0x0. BugcheckParameter4: 0x0. AbortIfMemoryPressure: 0. DumpCaptureDuration: 2313ms. SelectiveDump: 0. DynamicLowMemoryThreshold: 0 bytes.  AvailablePhysicalMemory: 7430266880 bytes. TotalPhysicalMemory: 16849256448 bytes.  IOSpaceEnabled: false.
5/20/2024 1:09:57 PM              4 Information      Writing dump file ended. NT Status: 0x0. Total 3116253184 bytes (Header|Primary|Secondary: 1802240|3114450944|0 bytes). DumpWriteDuration: 1712ms.
5/20/2024 1:09:55 PM              3 Information      Writing dump file started.

This might also help detect the misuse of legitimate RAM acquisition tools for offensive purposes, though I haven’t investigated whether these drivers leverage the same kernel API or perform the dump without it.

In combination, Arbitrary Code Guard and PPL should work well together to prevent code injection, somewhat similarily to HVCI, but due to the limitations of each, they fall short of the goal. In particular, the absence of verification during the mapping is a design flaw that has remained unfixed since the first report about it. It would be interesting to require PPL processes to be started with Arbitrary Code Guard enabled, but this might have side effects I haven’t considered.

Finally, this method could be leveraged to inject into Protected Process as well, but there seems to be little reason to do so as far as I know.