Mastering the Windows Kernel: A Deep Dive into Process Structure (PS) Routines
Introduction
When you cross the boundary from User Mode into Kernel Mode in Windows, the rules of engagement change entirely. For driver developers—particularly those working in security (EDR, Anti-Cheat, AV) or system monitoring—understanding how the operating system manages execution units is non-negotiable.
Enter the Process Structure (PS) routines.
Exported by Ntoskrnl.exe, the PS API set allows drivers to manage, monitor, and interact with processes and threads. Unlike the Win32 API, which provides a sanitized view of the system, PS functions interact directly with the kernel's executive objects. In this article, we will dissect the most critical PS functions, explore the opaque structures behind them, and implement a robust process monitoring mechanism.
---
The Opaque Giants: `PEPROCESS` and `PETHREAD`
Before writing code, we must understand the data structures we are manipulating. In the Windows Kernel, a process is represented by the EPROCESS block, and a thread by the ETHREAD block.
Microsoft strictly defines these as opaque structures. Their layout changes between Windows versions (and sometimes even between monthly patch Tuesdays). As a result, you will rarely access members of these structures directly (e.g., Process->ImageFileName). Instead, the PS API provides accessor functions using pointers to these objects, typed as PEPROCESS and PETHREAD.
Resolving Process IDs
One of the most common tasks is converting a numeric Process ID (PID) into a pointer to the actual kernel object (PEPROCESS). This is done via PsLookupProcessByProcessId.
NTSTATUS GetProcessObjectExample(HANDLE ProcessId)
{
PEPROCESS ProcessObject = NULL;
NTSTATUS Status;
// Look up the process. This increments the reference count on the object.
Status = PsLookupProcessByProcessId(ProcessId, &ProcessObject);
if (NT_SUCCESS(Status))
{
// Do work with ProcessObject...
DbgPrint("Found Process Object at: 0x%p\n", ProcessObject);
// CRITICAL: We must dereference the object when done.
// Failing to do so causes a resource leak (Zombie Processes).
ObDereferenceObject(ProcessObject);
}
return Status;
}*Note: The call to ObDereferenceObject is vital. PsLookupProcessByProcessId increments the reference count. If you don't decrement it, the process object will remain in memory even after the process terminates, leading to a memory leak.*
---
The Observer Pattern: System-Wide Notifications
The true power of the PS routines lies in their ability to notify your driver when specific system events occur. This is the backbone of modern Endpoint Detection and Response (EDR) systems.
1. Monitoring Process Creation
To detect when a process starts or ends, we use PsSetCreateProcessNotifyRoutineEx. This registers a callback that the kernel invokes whenever a process is created or destroyed.
Here is a complete implementation pattern:
// Forward declaration of the callback
void CreateProcessNotifyRoutineEx(
_Inout_ PEPROCESS Process,
_In_ HANDLE ProcessId,
_In_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo
);
// In DriverEntry
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath)
{
// Register the callback
NTSTATUS Status = PsSetCreateProcessNotifyRoutineEx(CreateProcessNotifyRoutineEx, FALSE);
if (!NT_SUCCESS(Status))
{
DbgPrint("Failed to register process notify routine: 0x%08X\n", Status);
return Status;
}
// ... rest of initialization
return STATUS_SUCCESS;
}
// The Callback Implementation
void CreateProcessNotifyRoutineEx(
_Inout_ PEPROCESS Process,
_In_ HANDLE ProcessId,
_In_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo
)
{
// If CreateInfo is NULL, the process is exiting.
if (CreateInfo == NULL)
{
DbgPrint("Process Exiting: PID %lu\n", HandleToULong(ProcessId));
return;
}
// If CreateInfo is NOT NULL, a process is being created.
DbgPrint("Process Creating: PID %lu | Image: %wZ\n",
HandleToULong(ProcessId),
CreateInfo->ImageFileName);
// SECURITY FEATURE: Blocking a process
// We can inspect the command line or image path and block execution.
if (IsMalicious(CreateInfo->CommandLine))
{
CreateInfo->CreationStatus = STATUS_ACCESS_DENIED;
DbgPrint("Blocked malicious process launch!\n");
}
}Key Takeaways:
NULL, the process is dying. If valid, it's starting.CreateInfo->CreationStatus to a failure code.PASSIVE_LEVEL, allowing pageable code interaction, but inside a critical system path. Do not block this thread for long.2. Monitoring Image Loading (DLL Injection Detection)
Malware often hides by injecting DLLs into legitimate processes. PsSetLoadImageNotifyRoutine allows us to see every DLL and Executable mapped into memory.
void LoadImageNotifyRoutine(
_In_opt_ PUNICODE_STRING FullImageName,
_In_ HANDLE ProcessId,
_In_ PIMAGE_INFO ImageInfo
)
{
// Check if this is a driver or user-mode image
if (ImageInfo->SystemModeImage)
{
DbgPrint("Driver Loaded: %wZ\n", FullImageName);
}
else
{
DbgPrint("DLL/EXE Loaded into PID %lu: %wZ\n",
HandleToULong(ProcessId),
FullImageName);
}
}---
Context Management: Who am I?
When writing a filter driver or a filesystem mini-filter, you often execute in the context of the thread that requested the I/O. Knowing *who* is calling you is essential.
`PsGetCurrentProcess` and `PsGetCurrentThread`
These functions return the PEPROCESS and PETHREAD of the currently executing context.
void AnalyzeCurrentContext()
{
PEPROCESS CurrentProc = PsGetCurrentProcess();
HANDLE CurrentPid = PsGetCurrentProcessId();
// Get the name of the current process (Use with caution, strict length limits)
UCHAR* ProcessName = PsGetProcessImageFileName(CurrentProc);
DbgPrint("Function called by: %s (PID: %lu)\n", ProcessName, HandleToULong(CurrentPid));
}Warning regarding `PsGetProcessImageFileName`: This function is technically deprecated or considered "undocumented but exported" depending on the Windows version. It returns a generic UCHAR* pointer to the 16-byte buffer in the EPROCESS structure. It is not the full path, only the filename (truncated to 15 chars). For full paths, use the Notification routines or ZwQueryInformationProcess.
---
Advanced: APCs and Context Switching
Wait, if we are in a driver, aren't we just "in the kernel"?
Yes, but the kernel is preemptive. The PS API allows us to manage Asynchronous Procedure Calls (APCs). While deep APC manipulation is dangerous, understanding PsIsThreadTerminating is useful when queuing work items.
If you are holding a reference to a thread or processing a queue, you should ensure the thread isn't already tearing down:
if (PsIsThreadTerminating(ThreadObj))
{
// Abort operation, the thread is dying.
return STATUS_THREAD_IS_TERMINATING;
}---
Best Practices and Pitfalls
PASSIVE_LEVEL or APC_LEVEL. Calling PsLookupProcessByProcessId at DISPATCH_LEVEL is a bug check waiting to happen (usually IRQL_NOT_LESS_OR_EQUAL).PEPROCESS structures. Every Lookup must have a matching Dereference.PsRemoveCreateProcessNotifyRoutineEx). If you don't, the kernel will try to call code that no longer exists, resulting in an immediate Blue Screen of Death (BSOD).Conclusion
The Process Structure routines are the eyes and ears of a kernel driver. Whether you are building the next generation antivirus or simply debugging a complex race condition, mastering PsSetCreateProcessNotifyRoutineEx and object resolution is fundamental.
Start by experimenting with the notification routines in a VM (Virtual Machine). The kernel is unforgiving, but the level of control it offers is unparalleled.