Kernel UDP socket server via WSK: covert comms with no IOCTL surface
Most driver auditing tools start by enumerating device objects and walking the IOCTL dispatch table. If there’s no IoCreateDevice call and no IRP_MJ_DEVICE_CONTROL handler, a lot of tools just stop. That’s the property WSK-based comms exploits.
This post covers how it works, grounded in the sparrow implementation. The WSK wrapper (ksocket.c) is the interesting part; main.c is about 60 lines.
Why no IOCTL surface matters for detection
A standard kernel driver that wants to talk to user mode registers a device object, creates a symbolic link to \??\SomeName, and services DeviceIoControl calls through IRP_MJ_DEVICE_CONTROL. The side effect is an enumerable object in the kernel object namespace.
Tools like WinObj enumerate \Device\ and \??\. IRP-interception filter drivers hook IRP_MJ_DEVICE_CONTROL on all stacks they can find. EDRs that enumerate loaded drivers and cross-reference them against device objects will flag a discrepancy when a driver has no device.
WSK operates entirely below that layer. The socket sits inside the kernel networking stack, not the I/O manager’s device tree. No DEVICE_OBJECT gets created by your driver. Nothing in the object namespace. No symbolic link. No dispatch table your driver owns. The only entry point is a UDP port, same as any user-mode server.
WSK overview
WSK (Windows Sockets for Kernel) is the kernel-mode socket API. It’s been in Windows since Vista. The API is async by design: every WSK call takes an IRP and signals completion either inline (return code isn’t STATUS_PENDING) or via an IRP completion routine.
For UDP you want a datagram socket. The dispatch table is WSK_PROVIDER_DATAGRAM_DISPATCH, which exposes WskSendTo and WskReceiveFrom. The ksocket.c wrapper hides all of this behind a Berkeley-style API (recvfrom, sendto, etc.) so main.c looks like POSIX socket code.
The three-phase WSK lifecycle:
- Register as a WSK client (
WskRegister) - Capture the provider NPI (
WskCaptureProviderNPI) - Create sockets, do work, then release (
WskReleaseProviderNPI+WskDeregister)
Initializing WSK: the provider NPI registration dance
WSK uses the Network Module Registrar (NMR) under the hood. You register as a client, then capture the provider interface. The provider gives you a dispatch table (WskProvider.Dispatch) from which every subsequent socket operation is called.
WSK_REGISTRATION WskRegistration;
WSK_PROVIDER_NPI WskProvider;
WSK_CLIENT_DISPATCH WskDispatch = { MAKE_WSK_VERSION(1, 0), 0, NULL };
NTSTATUS KsInitialize(VOID)
{
WSK_CLIENT_NPI WskClient;
WskClient.ClientContext = NULL;
WskClient.Dispatch = &WskDispatch;
NTSTATUS Status = WskRegister(&WskClient, &WskRegistration);
if (!NT_SUCCESS(Status))
return Status;
return WskCaptureProviderNPI(
&WskRegistration,
WSK_INFINITE_WAIT,
&WskProvider);
}MAKE_WSK_VERSION(1, 0) is the only supported version. WSK_INFINITE_WAIT tells WskCaptureProviderNPI to block until the WSK subsystem is ready. WSK_INFINITE_WAIT blocks the calling thread. Using it from DriverEntry delays system startup — Microsoft guidance is to spawn a worker thread from DriverEntry and call WskCaptureProviderNPI from there.
WskProvider.Dispatch is a WSK_PROVIDER_DISPATCH *. It contains WskSocket, WskGetAddressInfo, WskFreeAddressInfo, and a few others. Every call goes through this pointer.
Async context: the IRP reuse pattern
Every WSK operation is async. The pattern throughout ksocket.c is to allocate one IRP per socket, set a completion routine that fires KeSetEvent, then wait on the event:
typedef struct _KSOCKET_ASYNC_CONTEXT {
KEVENT CompletionEvent;
PIRP Irp;
} KSOCKET_ASYNC_CONTEXT, *PKSOCKET_ASYNC_CONTEXT;
static NTSTATUS KspAsyncContextCompletionRoutine(
_In_ PDEVICE_OBJECT DeviceObject,
_In_ PIRP Irp,
_In_ PKEVENT CompletionEvent)
{
UNREFERENCED_PARAMETER(DeviceObject);
UNREFERENCED_PARAMETER(Irp);
KeSetEvent(CompletionEvent, IO_NO_INCREMENT, FALSE);
return STATUS_MORE_PROCESSING_REQUIRED;
}
static NTSTATUS KspAsyncContextWaitForCompletion(
_In_ PKSOCKET_ASYNC_CONTEXT AsyncContext,
_Inout_ PNTSTATUS Status)
{
if (*Status == STATUS_PENDING) {
KeWaitForSingleObject(
&AsyncContext->CompletionEvent,
Executive,
KernelMode,
FALSE,
NULL);
*Status = AsyncContext->Irp->IoStatus.Status;
}
return *Status;
}The completion routine returns STATUS_MORE_PROCESSING_REQUIRED to stop the I/O manager from touching the IRP after the routine returns. The caller owns the IRP and will free it. This is the required pattern when you allocate the IRP yourself and want to reuse it across multiple WSK calls.
Before reusing an IRP, KspAsyncContextReset calls IoReuseIrp and re-installs the completion routine. IoReuseIrp reinitialises the IRP fields while preserving the allocation.
Creating and binding the socket
Socket creation goes through WskProvider.Dispatch->WskSocket. The flag that selects the socket category is WSK_FLAG_DATAGRAM_SOCKET:
NTSTATUS KsCreateDatagramSocket(
_Out_ PKSOCKET* Socket,
_In_ ADDRESS_FAMILY AddressFamily,
_In_ USHORT SocketType,
_In_ ULONG Protocol)
{
PKSOCKET NewSocket = ExAllocatePoolWithTag(
PagedPool, sizeof(KSOCKET), 'sK ');
KspAsyncContextAllocate(&NewSocket->AsyncContext);
NTSTATUS Status = WskProvider.Dispatch->WskSocket(
WskProvider.Client,
AddressFamily,
SocketType,
Protocol,
WSK_FLAG_DATAGRAM_SOCKET,
NULL, /* SocketContext */
NULL, /* Dispatch */
NULL, /* OwningProcess */
NULL, /* OwningThread */
NULL, /* SecurityDescriptor */
NewSocket->AsyncContext.Irp);
KspAsyncContextWaitForCompletion(&NewSocket->AsyncContext, &Status);
if (NT_SUCCESS(Status)) {
NewSocket->WskSocket = (PWSK_SOCKET)
NewSocket->AsyncContext.Irp->IoStatus.Information;
NewSocket->WskDispatch = (PVOID)NewSocket->WskSocket->Dispatch;
*Socket = NewSocket;
}
return Status;
}The created PWSK_SOCKET comes back in Irp->IoStatus.Information after the wait. The socket’s dispatch table pointer lives at WskSocket->Dispatch. For a datagram socket that’s a WSK_PROVIDER_DATAGRAM_DISPATCH *, which the code stores in the union field WskDatagramDispatch.
Bind follows the same IRP pattern, calling WskDatagramDispatch->WskBind.
In main.c, this is all hidden behind the Berkeley wrapper:
int socket_descriptor = socket_datagram(AF_INET, SOCK_DGRAM, IPPROTO_UDP);
struct sockaddr_in server_address = { 0 };
server_address.sin_family = AF_INET;
server_address.sin_addr.s_addr = INADDR_ANY;
server_address.sin_port = htons(7153);
bind(socket_descriptor, (struct sockaddr*)&server_address, sizeof(server_address));socket_datagram calls KsCreateDatagramSocket and stashes the PKSOCKET in a flat array (KsArray[128]), returning an integer file descriptor. bind looks the socket up by fd and calls KsBind.
htons and htonl in kernel mode are RtlUshortByteSwap and RtlUlongByteSwap. The berkeley.c wrapper handles the conversion.
Verify with WinObj: Open WinObj (Sysinternals) → navigate to
\Device\→ search for any object from your driver’s name. After loading the driver, nothing should appear. Compare against a standard driver that callsIoCreateDevice; its entry is immediately visible.
The server loop: blocking in DriverEntry
main.c runs the recv loop directly in DriverEntry. There’s no PsCreateSystemThread call in this implementation. The loop runs on the thread that called DriverEntry, which blocks it indefinitely:
while (TRUE) {
struct Packet packet = { 0 };
socklen_t client_address_length = sizeof(client_address);
int n = recvfrom(socket_descriptor, &packet, sizeof(struct Packet),
0, (struct sockaddr*)&client_address,
&client_address_length);
if (n > 0) {
PEPROCESS process = NULL;
PsLookupProcessByProcessId((HANDLE)packet.process_id, &process);
SIZE_T bytes = 0;
if (NT_SUCCESS(MmCopyVirtualMemory(
process,
(void*)packet.address,
PsGetCurrentProcess(),
&packet.response,
packet.size,
KernelMode,
&bytes)))
{
sendto(socket_descriptor, &packet, sizeof(struct Packet),
0, (struct sockaddr*)&client_address,
sizeof(client_address));
}
}
}Blocking DriverEntry works when the driver is loaded via a manual mapper (kdmapper in this case; note the FxDriverEntry signature taking PDRIVER_OBJECT kdmapperParam1). The loading thread is a separate system thread, so blocking it is fine. A driver loaded through the standard service manager would block the SCM, which is not fine.
If you wanted the loop on its own thread, PsCreateSystemThread with a PKSTART_ROUTINE is the standard approach. KeWaitForSingleObject on the thread object in DriverEntry if you need to join.
The communication protocol
The packet structure is defined in ksocket.h:
struct Packet {
DWORD process_id;
DWORD64 address;
SIZE_T size;
PVOID response;
};User mode sends: {target_pid, virtual_address, bytes_to_read, NULL}.
The driver reads packet.size bytes from packet.address in packet.process_id’s virtual address space using MmCopyVirtualMemory, stores the result in packet.response, and sends the whole struct back.
MmCopyVirtualMemory is an undocumented export from ntoskrnl.exe. The function copies between two arbitrary process virtual address spaces. It has been stable since at least Windows 7. The calling convention:
NTSTATUS MmCopyVirtualMemory(
PEPROCESS SourceProcess,
PVOID SourceAddress,
PEPROCESS TargetProcess,
PVOID TargetAddress,
SIZE_T BufferSize,
KPROCESSOR_MODE PreviousMode,
PSIZE_T ReturnSize);Passing PsGetCurrentProcess() as the target puts the copied bytes in the driver’s context, then into packet.response, which is just a PVOID field (8 bytes on x64). For reads larger than 8 bytes the caller would need to provide a buffer via pointer; this implementation only works for reads up to 8 bytes. For full buffer reads you’d pass an allocated buffer address in response and adjust the protocol.
PsLookupProcessByProcessId bumps the reference count on the EPROCESS. The code doesn’t call ObDereferenceObject after, which leaks the reference on every read. In a real implementation you’d dereference after the copy.
Architecture
Detection surface that remains
The IOCTL surface is gone. What’s left:
Network layer. The driver listens on UDP port 7153 (INADDR_ANY). netstat -ano from user mode shows open UDP ports including those held by kernel drivers. The process column shows 4 (System) for kernel sockets, which stands out since System doesn’t normally listen on arbitrary high ports.
WFP. Windows Filtering Platform callbacks fire on kernel-originated traffic too. An EDR with a WFP callout driver sees the UDP flow. The local address is 0.0.0.0:7153, the remote is whoever sent the packet. It’s filterable and loggable.
Loaded module list. The driver still shows in PsLoadedModuleList. Nothing about WSK changes that. System Informer, PoolMon with driver tags, or a kernel debugger listing modules will all find it. The evasion is specifically against device-object enumeration and IRP-level monitoring, not against all driver-visible techniques.
WSK_REGISTRATION in pool. WskRegister allocates internal state tagged under the network stack’s pool tags, not under a tag you control. You can’t easily filter this out from general NDK/WSK pool activity.
The KsArray allocation. berkeley.c allocates KsArray[128] of PKSOCKET pointers as a global; it’s in the driver’s .data section, not a pool allocation. The KSOCKET structs themselves are ExAllocatePoolWithTag with tag ' sK' (reversed: Ks ). PoolMon would show this tag if you knew to look.
Verify with PoolMon: Run
poolmon.exefiltered to tagsK(note: pool tags display reversed). After loading the driver, one or moreKsallocations should appear in the non-paged or paged pool, corresponding to theKSOCKETstruct per open socket.
The technique trades one detection surface (device objects, IOCTL surface) for a smaller one (network port, WFP). In environments where network monitoring is lighter than driver/IRP monitoring, that’s a useful trade. In environments with full WFP coverage, it’s not.
IOCTL versus WSK: what changes for the auditor
The post on walking an IOCTL dispatch table covers the standard approach: enumerate devices, find the dispatch table, decode CTL_CODE. None of that applies here. An auditor looking for this pattern needs to:
- Check
PsLoadedModuleListfor drivers with no corresponding device object. - Run network enumeration in kernel context (or use a WFP callout) to correlate open UDP/TCP endpoints back to the owning driver.
- Look for
WskRegisterandWskCaptureProviderNPIimports in the driver’s import table. A driver that imports fromnetio.sysand has no device object is worth looking at.
Checking imports is fast. netio.sys is where WskRegister lives. A driver that imports it and doesn’t have a device object is a narrow category.