The End of PPLdump
A few days ago, an issue was opened for PPLdump on GitHub, stating that it no longer worked on Windows 10 21H2 Build 19044.1826. I was skeptical at first so I fired up a new VM and started investigating. Here is what I found…
PPLdump in a nutshell
If you are reading this, I would assume that you already know what PPLdump is and what it does. But just in case you do not, here is a very brief summary.
PPLdump is a tool written in C/C++ that implements a Userland exploit for injecting arbitrary code into a PPL as an administrator. This technique is one of the many findings of thorough research conducted by Alex Ionescu and James Forshaw about Protected Processes (PPs and PPLs).
As a reminder, it works like this:
- The API
DefineDosDevice
is invoked to trick the CSRSS service into creating a Symbolic Link in\KnownDlls
that points to an arbitrary location. - A new Section object (pointed to by the previous Symbolic Link) is created to host the content of a custom DLL that contains the code we want to inject.
- A DLL imported by an executable running as a PPL is hijacked and our code is executed.
The most important thing to keep in mind here is that the whole exploit relies on a weakness that exists in PPLs but not in PPs. Indeed, PPLs can load DLLs from the \KnownDlls
directory, whereas PPs always load DLLs from the disk. This is a key difference because the digital signature of a DLL is only checked when it is initially read from the disk to create a new Section object. It is not checked afterward when it is mapped in the virtual address space of the Process.
What is going on with build 19044.1826?
The debug output of PPLdump was already provided in the GitHub issue but I reproduced it in a Windows 10 VM with the July 2022 update pack (Windows 10 21H2 Build 19044.1826).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
C:\WINDOWS\system32>c:\Temp\PPLdump.exe -d lsass lsass.dmp
[lab-admin] [*] Found a process with name 'lsass' and PID 740
[DEBUG][lab-admin] Check requirements
[DEBUG][lab-admin] Target process protection level: 4 - PsProtectedSignerLsa-Light
[lab-admin] [*] Requirements OK
[...]
[DEBUG][lab-admin] Call DefineDosDevice to create '\KnownDlls\EventAggregation.dll' -> '\KernelObjects\EventAggregation.dll'
[lab-admin] [*] DefineDosDevice OK
[...]
[DEBUG][SYSTEM] Check whether the symbolic link was really created in '\KnownDlls\'
[SYSTEM] [+] The symbolic link was successfully created: '\KnownDlls\EventAggregation.dll' -> '\KernelObjects\EventAggregation.dll'
[...]
[DEBUG][SYSTEM] Create protected process with command line: C:\WINDOWS\system32\services.exe 740 "lsass.dmp" 2f2e0a5f-40d4-4034-ba27-81498c6869b -d
[SYSTEM] [*] Started protected process, waiting...
[DEBUG][SYSTEM] Unmap section '\KernelObjects\EventAggregation.dll'...
[DEBUG][SYSTEM] Process exit code: 0
[-] The DLL was not loaded. :/
Overall, the output looks pretty good, the symbolic link is properly created in \KnownDlls
so, at first sight, the DefineDosDevice
trick is still working fine. This can easily be confirmed with WinObj because the symbolic link cannot be deleted without the ability to execute code in a PPL at the “Windows TCB” level.
Then a new section is created with the content of our custom DLL but the tool ultimately fails with the error [-] The DLL was not loaded.
after attempting to hijack EventAggregation.dll
, which is normally loaded by services.exe
.
In such a situation, the obvious thing to do is to fire up Process Monitor and see if we can spot anything that does not seem right.
From the very first events, we can already see that something is not going as planned. Since services.exe
is executed as a PPL, we should not see any file operation (e.g. CreateFile
or CreateFileMapping
) on DLLs such as kernel32.dll
and KernelBase.dll
because these are Known DLLs. Instead, they should be loaded directly from their respective sections \KnownDlls\kernel32.dll
and \KnownDlls\kernelbase.dll
.
The conclusion is that PPLs now appear to be behaving just like PPs and therefore no longer rely on Known DLLs.
A patch in NTDLL?
Something has evidently been changed in the way PPL processes are created. I already know where to look but for the sake of this post, I will do this the proper way through binary diffing.
I first got my hands on the last two versions of ntdll.dll
for Windows 10 21H2 on Winbindex and I downloaded the public symbols using symchk.exe
from the Windows SDK.
1
2
3
4
C:\WINDOWS\System32>"C:\Program Files (x86)\Windows Kits\10\Debuggers\x64\symchk.exe" /s srv*C:\symbols*https://msdl.microsoft.com/download/symbols C:\Temp\ntdll_*.dll
SYMCHK: FAILED files = 0
SYMCHK: PASSED + IGNORED files = 2
After loading the files and analyzing them, I simply used the BinDiff extension for Ghidra to export the result in the appropriate format.
The two “BinExport” files can then be imported in BinDiff to compare the two versions of ntdll.dll
. By sorting the functions by “similarity”, we can immediately see that there are some slight differences in 7 functions but one really stands out: LdrpInitializeProcess
. This is exactly the place where I expected to find some changes.
We can also see that there is one unmatched function, which was added in the newest version: Feature_Servicing_2206c_38427506__private_IsEnabled
.
Known DLL handling in the loader
Initially, when a new process is created, only NTDLL is loaded. The image loader implemented in NTDLL is responsible for loading other DLLs (among a lot of other things). To determine whether it should use the Known DLLs or not, it simply checks a couple of flags in the Process Environment Block (PEB
).
This check is highlighted in the following screenshot (build version 10.0.19044.1741
).
The PEB
structure is partially documented but we won’t find the information we need in the official documentation. Process Hacker on the other hand contains a way more detailed definition.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// phnt/include/ntpebteb.h
typedef struct _PEB
{
BOOLEAN InheritedAddressSpace; // Byte at (byte*)peb+0
BOOLEAN ReadImageFileExecOptions; // Byte at (byte*)peb+1
BOOLEAN BeingDebugged; // Byte at (byte*)peb+2
union
{
BOOLEAN BitField; // Byte at (byte*)peb+3
struct
{
BOOLEAN ImageUsesLargePages : 1;
BOOLEAN IsProtectedProcess : 1;
BOOLEAN IsImageDynamicallyRelocated : 1;
BOOLEAN SkipPatchingUser32Forwarders : 1;
BOOLEAN IsPackagedProcess : 1;
BOOLEAN IsAppContainer : 1;
BOOLEAN IsProtectedProcessLight : 1;
BOOLEAN IsLongPathAwareProcess : 1;
};
};
// ...
}
At the offset 3 (peb + 3
in the if
statement), we can find a byte value that holds a set of 8-bit flags. The least significant bit holds the value of the ImageUsesLargePages
flag whilst the most significant bit holds the value of the IsLongPathAwareProcess
flag.
With that knowledge, we can translate the code *(byte *)(peb + 3)
to peb->BitField
. Then, the value 0x42
is a mask that allows the loader to isolate and check the flags IsProtectedProcess
and IsProtectedProcessLight
. Therefore, the decompiled code if ((*(byte *)(peb + 3) & 0x42) == 2)
can be interpreted as follows.
1
2
3
4
5
if (peb->IsProtectedProcess && !peb->IsProtectedProcessLight) {
// Do NOT use Known DLLs
} else {
// Use Known DLLs
}
In other words, Known DLLs are ignored only if the process is a PP and thus PPLs behave just like normal processes. This is a confirmation of what we already know so let’s find out what changed in the build version 10.0.19044.1806
.
If we search for the same line of code, we immediately realize that there is an additional check that depends on the value returned by Feature_Servicing_2206c_38427506__private_IsEnabled()
. What a coincidence!
In the else
block, we can see the following check.
The decompiled code generated by Ghidra can therefore be summarized as follows.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool bFeatureEnabled = Feature_Servicing_2206c_38427506__private_IsEnabled();
if (bFeatureEnabled == 0) {
if ((*(byte *)(peb + 3) & 0x42) != 2) {
// Use Known DLLs
} else {
// Do NOT use Known DLLs
}
} else {
if ((*(byte *)(peb + 3) & 2) != 0) {
// Do NOT use Known DLLs
} else {
// Use Known DLLs
}
}
If we apply the same logic I detailed earlier, we can translate the above code into this more readable version.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool bFeatureEnabled = Feature_Servicing_2206c_38427506__private_IsEnabled();
if (bFeatureEnabled == FALSE) {
if (peb->IsProtectedProcess && !peb->IsProtectedProcessLight) {
// Do NOT use Known DLLs
} else {
// Use Known DLLs
}
} else {
if (peb->IsProtectedProcess) {
// Do NOT use Known DLLs
} else {
// Use Known DLLs
}
}
The patch seems pretty clear now. First, there is a check on a “feature servicing” value. If this feature is disabled, the loader falls back to the previous version of the code and thus PPLs load Known DLLs. On the other hand, if this feature is enabled, the loader simply checks whether the flag peb->IsProtectedProcess
is set or not. So, a protected process (be it a PP or a PPL) will not use Known DLLs.
A new check in the loader
In the previous part, we saw that the result of Feature_Servicing_2206c_38427506__private_IsEnabled()
determines the logic that the loader will use regarding Protected Processes and Known DLLs. At first glance, this function does not seem that complex so let’s see what we can learn about it.
According to the decompiled code generated by Ghidra, it seems that the function first retrieves the value of the global variable Feature_Servicing_2206c_38427506__private_featureState
, initializes it if it was not already and returns the value of its fourth bit (uVar1 >> 3 & 1
).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
DWORD Feature_Servicing_2206c_38427506__private_IsEnabled() {
DWORD dwFeatureServicingState;
BOOL bIsEnabled;
dwFeatureServicingState = Feature_Servicing_2206c_38427506__private_featureState;
if ((dwFeatureServicingState & 1) == 0) {
// The global variable is not yet initialized, initialize it.
dwFeatureServicingState = wil_details_FeatureStateCache_ReevaluateCachedFeatureEnabledState(...);
}
// Extract the fourth bit
bIsEnabled = dwFeatureServicingState >> 3 & 1;
// ...
return bIsEnabled;
}
So, it looks like the global variable Feature_Servicing_..._featureState
holds a set of bit flags that determine whether particular features are enabled or not. This is something we can quite easily verify with the help of a few lines of C/C++ and a debugger.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <Windows.h>
typedef UINT(NTAPI* _FeatureIsEnabled)();
int wmain(int argc, wchar_t* argv[])
{
DWORD dwOffsetFeatureIsEnabled = 0x0009b360;
DWORD dwOffsetFeatureServicingState = 0x0016d288;
PDWORD pFeatureServicingState = NULL;
_FeatureIsEnabled FeatureIsEnabled = NULL;
BOOL bFeatureIsEnabled = FALSE;
// Get NTDLL base address
HMODULE ntdll = LoadLibraryW(L"ntdll.dll");
// Calculate address of Feature_Servicing_..._featureState
pFeatureServicingState = (PDWORD)((PBYTE)ntdll + dwOffsetFeatureServicingState);
// Calculate address of Feature_Servicing_..._IsEnabled()
FeatureIsEnabled = (_FeatureIsEnabled)((PBYTE)ntdll + dwOffsetFeatureIsEnabled);
wprintf(L"Feature_Servicing_2206c_38427506__private_featureState: 0x%08x\r\n", *pFeatureServicingState);
bFeatureIsEnabled = FeatureIsEnabled();
wprintf(L"Feature enabled: %d\r\n", bFeatureIsEnabled);
wprintf(L"----\r\n");
wprintf(L"Setting the fourth bit to 0\r\n");
*pFeatureServicingState = *pFeatureServicingState & 0xfffffff7;
wprintf(L"Feature_Servicing_2206c_38427506__private_featureState: 0x%08x\r\n", *pFeatureServicingState);
bFeatureIsEnabled = FeatureIsEnabled();
wprintf(L"Feature enabled: %d\r\n", bFeatureIsEnabled);
return 0;
}
Running the above code yields the following output.
1
2
3
4
5
6
7
C:\WINDOWS\system32>C:\Temp\FeatureServicing.exe
Feature_Servicing_2206c_38427506__private_featureState: 0x0000001b
Feature enabled: 1
----
Setting the fourth bit to 0
Feature_Servicing_2206c_38427506__private_featureState: 0x00000013
Feature enabled: 0
The value of Feature_Servicing_..._featureState
is 0x0000001b
, which translates to 0001 1011
in binary. As the fourth bit is set, the return value is 1
. In the second part, I manually unset the fourth bit using a bitwise AND operation with the mask 1111 0111
(i.e. 0xf7
). In this case, the return value is 0
, which tends to confirm my interpretation of the code.
Finally, and for good measure, we can also manually set the value of Feature_Servicing_..._featureState
to 0
and check the value returned by wil_..._ReevaluateCachedFeatureEnabledState(...)
to make sure it is 0x1b
.
The return value (see RAX
) is 0x7ff700000000001b
but the EAX
register (i.e. the first 32 bits of RAX
) is used in the following operations (mov ebx,eax
) so the effective value is indeed 0x0000001b
.
Conclusion
I’m not sure what motivated Microsoft to differentiate PPs and PPLs regarding Known DLLs in the first place. Perhaps it was a matter of performance, I don’t know. Anyhow, they were already aware of this potential weakness, otherwise, they wouldn’t have made an exception for PPs I guess. The thing is, this security hole is now patched and that’s a good step forward. I like to think I played a little role in this change although I’m aware that all the work had already been done by Alex and James.
In conclusion, this is truly The End of PPLdump. However, this tool leveraged only one weakness of PPLs, but there is a couple of other Userland issues we can probably still exploit. So, from my standpoint, it is also an opportunity to start working on another bypass…
Links & Resources
- Windows Exploitation Tricks: Exploiting Arbitrary Object Directory Creation for Local Elevation of Privilege
https://googleprojectzero.blogspot.com/2018/08/windows-exploitation-tricks-exploiting.html - Do You Really Know About LSA Protection (RunAsPPL)?
https://itm4n.github.io/lsass-runasppl/ - Bypassing LSA Protection in Userland
https://blog.scrt.ch/2021/04/22/bypassing-lsa-protection-in-userland/