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"]