Direct circular buffer injection in mouclass.sys
A mouse packet spends part of its trip through Windows waiting in a queue. mouclass.sys, the mouse class driver, keeps that queue in its device extension until the Raw Input Thread asks for the next batch. The interesting part is what happens if a kernel caller writes there directly.
I found that holding mouclass’s own spinlock was enough. A caller could place a MOUSE_INPUT_DATA entry in the circular buffer or complete a pending read Input/Output Request Packet, usually shortened to IRP. Neither route called MouseClassServiceCallback or passed through the Human Interface Device stack, or HID stack. Windows consumed a byte-identical input packet with no field saying where it had really come from.
That was the reason to investigate the queue. SendInput has a well-known syscall path. Virtual HID devices and filter drivers leave objects in the device stack. MouseClassServiceCallback is a common place for anti-cheats to hook or inspect the call stack. Each conventional route gives a monitor somewhere to look. The internal queue sits after those interfaces, and mouclass does not check who is writing to it.
Where the input waits
mouclass.sys sits between the port drivers that talk to physical hardware and the Win32 subsystem that delivers input to applications. Every physical pointing device feeds into it through MouseClassServiceCallback. The callback puts MOUSE_INPUT_DATA structures in a circular buffer for that device, and the Raw Input Thread in win32k.sys drains it by issuing read IRPs against \Device\PointerClass0.
The buffer itself is an ordinary pool allocation. There are no special page protections, guard pages, or integrity metadata. A KSPIN_LOCK in the device extension protects the buffer pointers and queue state. It keeps concurrent writers from corrupting the queue, which is a different job from deciding whether a writer belongs there.
These are the relevant fields in the device extension attached to \Device\PointerClass0. I verified the offsets across Windows 10 and Windows 11 through 23H2.
+0x54 InputCount - queued entry count
+0x68 DataQueueBase - base address of the circular buffer
+0x70 WritePointer - next write position
+0x78 ReadPointer - next read position
+0x88 QueueSize - total buffer size in bytes
+0x90 SpinLock - KSPIN_LOCK protecting all fields
+0x98 PendingIrpQueue - LIST_ENTRY head for waiting read IRPs
During normal delivery, MouseClassServiceCallback acquires the spinlock at +0x90, copies an entry to WritePointer, advances the pointer with wraparound against QueueSize, and increments InputCount. If a read IRP is already waiting, it skips the buffer and completes that IRP inline. In either case, the lock establishes exclusive access. None of this logic validates the caller’s identity.
Why there are two paths
I needed to handle both delivery paths because they have different timing. When the Raw Input Thread asks for data and the queue is empty, mouclass holds its read IRP in PendingIrpQueue. A writer can dequeue that IRP, copy input into its buffer, and call IoCompleteRequest. The consumer receives it with zero buffering delay, matching a physical device interrupt that completes a waiting read synchronously.
If no reader is waiting, there is nothing to complete. The writer stores the MOUSE_INPUT_DATA entry at WritePointer, wraps the pointer at the buffer boundary, and increments InputCount. The next read drains that entry.
It would be simpler to always use the circular buffer. I do not do that because queuing data while the Raw Input Thread is already waiting introduces a measurable latency gap. The packet would look right, but its delivery would depart from the normal cadence. That gap could become a detection signal on its own.
Both paths use mouclass’s synchronization contract. KeAcquireSpinLock raises the Interrupt Request Level, or IRQL, to DISPATCH_LEVEL and acquires the driver’s lock. Holding it while changing queue state prevents torn writes and inconsistent pointers. Mouclass uses KeAcquireSpinLockAtDpcLevel because it already runs at DISPATCH_LEVEL in Deferred Procedure Call context during Interrupt Service Routine dispatch completion. The starting IRQL differs; the lock protects the same state.
The implementation
The routine starts with IoGetDeviceObjectPointer on \\Device\\PointerClass0, which returns the topmost device object in the stack. Without an upper filter, that is mouclass itself. With an upper filter, the driver walks down to mouclass before reading DeviceExtension.
Once it has the extension, the routine acquires the spinlock and checks whether a read IRP is waiting. That check decides which delivery path to take.
NTSTATUS injectMouseInput(LONG deltaX, LONG deltaY, USHORT buttonFlags, USHORT buttonData) {
if (!g_ctx.initialized || !g_ctx.mouseDeviceExtension)
return STATUS_DEVICE_NOT_READY;
PUCHAR devExt = (PUCHAR)g_ctx.mouseDeviceExtension;
PKSPIN_LOCK spinLock = (PKSPIN_LOCK)(devExt + MOUCLASS_DEVEXT_SPINLOCK);
KIRQL oldIrql;
MOUSE_INPUT_DATA inputData = { 0 };
inputData.UnitId = 0;
inputData.Flags = MOUSE_MOVE_RELATIVE;
inputData.ButtonFlags = buttonFlags;
inputData.ButtonData = buttonData;
inputData.LastX = deltaX;
inputData.LastY = deltaY;
KeAcquireSpinLock(spinLock, &oldIrql);
PLIST_ENTRY pendingQueue = (PLIST_ENTRY)(devExt + MOUCLASS_DEVEXT_PENDING_IRP);
if (!IsListEmpty(pendingQueue)) {
// pending read IRP exists, complete it directly
PLIST_ENTRY entry = RemoveHeadList(pendingQueue);
PIRP pendingIrp = CONTAINING_RECORD(entry, IRP, Tail.Overlay.ListEntry);
PDRIVER_CANCEL oldCancelRoutine = IoSetCancelRoutine(pendingIrp, NULL);
if (oldCancelRoutine == NULL) {
// cancellation in progress, the cancel routine owns this IRP
// fall through to the circular buffer path below
} else {
PIO_STACK_LOCATION irpStack = IoGetCurrentIrpStackLocation(pendingIrp);
PVOID irpBuffer = pendingIrp->MdlAddress
? MmGetSystemAddressForMdlSafe(pendingIrp->MdlAddress, NormalPagePriority)
: pendingIrp->AssociatedIrp.SystemBuffer;
if (irpBuffer && irpStack->Parameters.Read.Length >= sizeof(MOUSE_INPUT_DATA)) {
RtlCopyMemory(irpBuffer, &inputData, sizeof(MOUSE_INPUT_DATA));
KeReleaseSpinLock(spinLock, oldIrql);
pendingIrp->IoStatus.Status = STATUS_SUCCESS;
pendingIrp->IoStatus.Information = sizeof(MOUSE_INPUT_DATA);
IoCompleteRequest(pendingIrp, IO_MOUSE_INCREMENT);
return STATUS_SUCCESS;
}
}
}
// no pending IRP or completion failed, queue in circular buffer
PVOID queueBase = *(PVOID*)(devExt + MOUCLASS_DEVEXT_DATA_QUEUE_BASE);
PVOID writePtr = *(PVOID*)(devExt + MOUCLASS_DEVEXT_WRITE_POINTER);
ULONG queueSize = *(PULONG)(devExt + MOUCLASS_DEVEXT_QUEUE_SIZE);
PULONG inputCount = (PULONG)(devExt + MOUCLASS_DEVEXT_INPUT_COUNT);
ULONG maxEntries = queueSize / sizeof(MOUSE_INPUT_DATA);
if (*inputCount >= maxEntries - 1) {
KeReleaseSpinLock(spinLock, oldIrql);
return STATUS_INSUFFICIENT_RESOURCES;
}
*(PMOUSE_INPUT_DATA)writePtr = inputData;
PVOID newWritePtr = (PUCHAR)writePtr + sizeof(MOUSE_INPUT_DATA);
if (newWritePtr >= (PUCHAR)queueBase + queueSize)
newWritePtr = queueBase;
*(PVOID*)(devExt + MOUCLASS_DEVEXT_WRITE_POINTER) = newWritePtr;
(*inputCount)++;
KeReleaseSpinLock(spinLock, oldIrql);
return STATUS_SUCCESS;
}
The cancellation check is easy to overlook. Between dequeuing the IRP and clearing its cancel routine, the I/O manager may have started cancellation on another processor. If IoSetCancelRoutine returns NULL, the cancel routine owns the IRP. My code must leave it alone and fall through to the circular buffer path.
This is also why the queue matters beyond being a place to put packets. It gives the routine a delivery path when there is no waiting reader, including when cancellation has taken ownership of the IRP it found.
What the usual monitors see
MouseClassServiceCallback never runs for the injected entry. An inline hook on that function sees no call, and a stack walk triggered there has no injected call stack to inspect. The code never enters the callback.
The HID stack is equally uninvolved. mouhid.sys, hidusb.sys, and the filter drivers layered onto the mouse device stack remain idle. No IRP carrying this input travels through them, so monitoring those layers produces no signal.
By the time win32k.sys reads the input, it receives a MOUSE_INPUT_DATA structure with the same layout, field values, and completion semantics as one produced by physical hardware. The structure has no way to tell those origins apart. A convincing packet and a convincing movement are separate problems, though, and the second one survives all of this work.
What can still catch it
I would not call this undetectable. The usual driver interfaces are quiet because the write happens after them. Monitoring the memory itself can still reveal it.
A hypervisor can mark the circular buffer’s physical pages non-writable in its Extended Page Tables, or EPT. Writes from outside MouseClassServiceCallback then cause an EPT violation for the monitor to inspect. The maintenance cost is keeping track of every mouclass device instance’s buffer address across driver reloads and system sessions.
An anti-cheat could also sample InputCount and WritePointer and compare their changes with physical mouse interrupts. Queue state advancing without a matching interrupt would indicate injection. High-polling-rate mice make that less tidy than it sounds. Their legitimate batched input has to fit the check too, or the monitor starts accusing real hardware.
Behavioral analysis is the strongest option in my view because it does not depend on finding the writer. Polling rate consistency, sub-pixel movement distributions, acceleration curves, and inter-sample timing jitter remain visible in the output. Bypassing the input stack does nothing to make a synthetic movement more human.
At the time of writing, no shipping anti-cheat product monitors mouclass’s internal buffer state.
The offsets are still an internal detail
These are the offset definitions I verified across Windows 10 and Windows 11 through 23H2.
#define MOUCLASS_DEVEXT_INPUT_COUNT 0x54
#define MOUCLASS_DEVEXT_DATA_QUEUE_BASE 0x68
#define MOUCLASS_DEVEXT_WRITE_POINTER 0x70
#define MOUCLASS_DEVEXT_READ_POINTER 0x78
#define MOUCLASS_DEVEXT_QUEUE_SIZE 0x88
#define MOUCLASS_DEVEXT_SPINLOCK 0x90
#define MOUCLASS_DEVEXT_PENDING_IRP 0x98
mouclass.sys changes infrequently, and this layout has survived multiple feature updates. That is useful for a controlled test environment. It is not a promise from Microsoft. A servicing update can move an internal field without notice, so for production use outside that environment I would resolve the offsets dynamically by pattern scanning the driver binary.
The part I keep coming back to is the lock. Mouclass uses it to keep its queue consistent, and my write follows that rule. The driver then treats the entry like any other input. That explains both the technique and its limit. The packet carries no evidence of its origin, but a monitor watching the write, its relationship to hardware interrupts, or the resulting movement can still find evidence elsewhere.