ExecutionContext.sys IoctlExecutionContextQueueTask kernel pointer injection

Windows eBPF lets a user-mode program define small programs or tasks the kernel will later execute — on packet hooks, on system calls, on socket operations, or as one-shot queued work items. The whole point of the surface is that the caller is user-mode and untrusted while the executor is the kernel, and the boundary between the two is the user/kernel virtual-address split. On amd64 every address >= MmSystemRangeStart (0xffff800000000000) names kernel memory; everything below names user memory. A user-mode caller can legitimately name only the lower half, which is why ProbeForRead / ProbeForWrite exist — so a kernel routine that receives a pointer from user mode can reject kernel-space addresses before touching them. Skip that check and the caller has handed the kernel a kernel-mode virtual address to operate on, which is a direct arbitrary-address primitive.

IoctlExecutionContextQueueTask on this driver is exactly that shape, with one extra wrinkle that makes it worse rather than better. It accepts a 0x40-byte input buffer from the request, packages it into an _EXECUTION_CONTEXT_TASK, and queues the task for deferred kernel execution:

// IoctlExecutionContextQueueTask - vulnerable build
Context = Request->GetExecutionContext();           /* WDF typed-context lookup */
if (Context == NULL) return STATUS_NOT_FOUND;       /* 0xC00002F0 */
Context->RefCount++;                                /* +0x180, hold a ref across this call */

status = WdfRequestRetrieveInputBuffer(Request, 0x40, &input, NULL);
if (status != STATUS_SUCCESS) { /* drop ref, return status */ }

task = ExAllocatePool2(POOL_FLAG_NON_BLOCKING, 0x40, 'EcTk');
if (task == NULL) { /* drop ref, return STATUS_INSUFFICIENT_RESOURCES */ }

task->Param0          = input->Param0;              /* +0x0  */
task->Param1          = input->Param1;              /* +0x8  verbatim copy, NO boundary check */
task->ListEntry.Flink = &task->ListEntry;           /* +0x10 self-loop, empty LIST_ENTRY */
task->ListEntry.Blink = &task->ListEntry;           /* +0x18 */
task->Owner           = task;                       /* +0x20 back-pointer to the task itself */
RtlZeroMemory(&task->WorkState, 0x18);              /* +0x28 .. +0x40 zeroed */

KernelModeExecutionContext::QueueTask(Context, task, TRUE);

QueueTask hands the task to a worker, which later wakes up and treats task->Param1 as a pointer/parameter — dereferencing it, copying through it, or branching on its content. By the time that dereference happens the worker has no memory that the value came from a user request; it just sees a void* field on a queued task. The user/kernel boundary is not consulted again on the dequeue side, so any check has to happen at the ingress point — between the input-buffer read and the queue call — before the value is buried inside a structure another code path will trust.

There is no subtlety to the trigger — no race, no interleaving. The caller supplies a kernel address; the function stores it; the queue later dereferences it. The bug is the missing one-line check, full stop. This is the same family as classic METHOD_NEITHER IOCTL pointer-injection bugs, just on a deferred-execution path, and the deferral makes the boundary check more important, not less: the consuming code path has lost the original “this came from user mode” tagging.

Depending on what the worker does with task->Param1, the caller lands one of three primitives. If the worker copies from task->Param1 back to a caller-visible buffer, that is an arbitrary kernel read — read the EPROCESS.Token of a privileged process, write it over the caller’s, done. If the worker writes to task->Param1 (or to an offset from it), that is an arbitrary kernel write with chosen content at a chosen address. If the worker branches on the content of task->Param1, the caller steers kernel control flow. Notably, none of this needs an info-leak chain first, because the caller supplies the address directly — the boundary check that should have rejected the kernel address is precisely the check that would have forced the caller to need a leak.

The execution-context device is the kernel side of Windows eBPF and is typically admin-restricted. “Admin but not kernel” is still a privilege boundary, and on the eBPF surface the boundary is enforced precisely by checks like the one this patch adds. Letting an admin plant a kernel address into a queued task pointer collapses the admin-to-kernel boundary.

The patch

The fix adds the missing boundary check between the input buffer read and the task allocation:

// IoctlExecutionContextQueueTask - patched
Context = Request->GetExecutionContext();
if (Context == NULL) return STATUS_NOT_FOUND;
Context->RefCount++;

status = WdfRequestRetrieveInputBuffer(Request, 0x40, &input, NULL);
if (status != STATUS_SUCCESS) { /* drop ref, return status */ }

if (input->Param1 >= *MmSystemRangeStart) {         /* NEW: kernel/user check on Param1, +0x8 */
    if (--Context->RefCount == 0) {                 /* RefCount @ +0x180 */
        KernelModeExecutionContext::~KernelModeExecutionContext(Context);
        ExFreePoolWithTag(Context, 'Disp');         /* 'Disp' */
    }
    return STATUS_INVALID_PARAMETER;                 /* 0xC000000D */
}

task = ExAllocatePool2(POOL_FLAG_NON_BLOCKING, 0x40, 'EcTk');
if (task == NULL) { /* drop ref, return STATUS_INSUFFICIENT_RESOURCES */ }

task->Param0          = input->Param0;              /* +0x0  */
task->Param1          = input->Param1;              /* +0x8  now known to be a user-mode address */
task->ListEntry.Flink = &task->ListEntry;           /* +0x10 */
task->ListEntry.Blink = &task->ListEntry;           /* +0x18 */
task->Owner           = task;                       /* +0x20 */
RtlZeroMemory(&task->WorkState, 0x18);              /* +0x28 .. +0x40 */

KernelModeExecutionContext::QueueTask(Context, task, TRUE);

One comparison against the published MmSystemRangeStart value: any caller-supplied value in the kernel half of the address space is rejected with STATUS_INVALID_PARAMETER before any task is allocated or queued.

The shape generalises across the eBPF and “user-supplied program/task” surfaces — anything where the user defines work the kernel will later execute (eBPF programs, task IOCTLs, filter commands, scheduled worker items) must treat every embedded address as hostile until it passes a boundary check. ProbeForRead / ProbeForWrite cover the in-place-pointer case; the copy-the-value-into-a-deferred-structure case needs the same idea expressed as a single comparison against MmSystemRangeStart at the enqueue side. Deferred dereference is the smell to grep for — worker queues, DPCs, timers, task items that carry caller-supplied pointer fields all need the check before the value leaves the syscall frame.

Attack path

flowchart TD
    A["caller opens the ExecutionContext device via the setup IOCTL"] --> B["send IoctlExecutionContextQueueTask with input.Param1 set to a kernel VA"]
    B --> C["no boundary check, task.Param1 = kernel VA"]
    C --> D["worker pulls the task, dereferences Param1 as a pointer or parameter"]
    D --> E["controlled kernel read or write at an attacker-named address, local EoP"]