[{"content":"Welcome to the ninth article in the series on developing KDAMonitor!\nIn this article, I\u0026rsquo;ll cover version v0.9 of the project. In this version, I implement the project\u0026rsquo;s fourth sensor: the registry activity sensor.\nHere are the files involved in this article, and the section that explains each one:\nFile Role Section event_types.h New KDAMON_REGISTRY_EVENT_DATA type + KDAMON_REGISTRY_ACTION enum + union in KDAMON_EVENT What to capture on a registry event? kdamon_config.h Configuration constants for the registry sensor What to capture on a registry event? registry_callback.h Register/unregister declaration Implementing the callback registry_callback.c The callback itself Implementing the callback log_writer.c JSONL serialization specific to registry events Serializing registry events to JSONL driver_entry.c Registering/unregistering the callback on load/unload Integration into driver_entry.c The project can be found in this repository: KDAMonitor.\nWhat to capture on a registry event? # As with every article covering a sensor, we start by listing what our event needs to contain. Here\u0026rsquo;s what was chosen:\nthe ID of the process interacting with the registry (HANDLE ProcessId)\nthe path to the process interacting with the registry (WCHAR ProcessPath[KDAMON_REG_PATH_MAX];)\nthe action performed (KDAMON_REGISTRY_ACTION Action), i.e. what the process did:\nset (created or modified) a value in a registry key deleted a value from a registry key created or opened a registry key The action performed is represented by an enum:\ntypedef enum _KDAMON_REGISTRY_ACTION { KDAMON_REGISTRY_ACTION_SET_VALUE, KDAMON_REGISTRY_ACTION_DELETE_VALUE, KDAMON_REGISTRY_ACTION_CREATE_KEY, } KDAMON_REGISTRY_ACTION; the path of the key involved (WCHAR KeyPath[KDAMON_REG_PATH_MAX];) the name of the value involved (WCHAR ValueName[KDAMON_REG_VALUENAME_MAX];) the type of the value (ULONG ValueType;) the content of the value (UCHAR ValueData[KDAMON_REG_VALUEDATA_MAX];) the size of the value\u0026rsquo;s content (ULONG ValueDataSize;) the status of the operation (NTSTATUS Status;), only filled in for key creation Implementing the callback # All the code shown in this section is in the registry_callback.c file. Unlike the previous sensors, we don\u0026rsquo;t register one routine per event type. Instead, we use CmRegisterCallbackEx, which registers a single routine that receives every registry operation. We then need to decide which operation(s) we want to react to.\nTwo functions are exposed in the header:\nNTSTATUS KdaMonRegistryCallbackRegister(_In_ PDRIVER_OBJECT DriverObject);: registers the callback. It additionally takes the DriverObject, required by CmRegisterCallbackEx VOID KdaMonRegistryCallbackUnregister(VOID);: unregisters the callback Everything else is private:\nKdaMonRegistryCallback: the registered routine, which acts as the dispatcher KdaMonRegistryHandleSetValueKey, KdaMonRegistryHandleDeleteValueKey, KdaMonRegistryHandlePostCreateKeyEx: one handler per action KdaMonRegistryResolveKeyPath, KdaMonRegistryResolveProcessPath: two helpers that resolve paths See the Microsoft documentation for EX_CALLBACK_FUNCTION and CmRegisterCallbackEx.\nRegistering and unregistering the callback # LARGE_INTEGER g_RegistryCookie = { 0 }; ... NTSTATUS KdaMonRegistryCallbackRegister(_In_ PDRIVER_OBJECT DriverObject) { NTSTATUS status; UNICODE_STRING altitude; RtlInitUnicodeString(\u0026amp;altitude, KDAMON_REG_ALTITUDE); status = CmRegisterCallbackEx( KdaMonRegistryCallback, \u0026amp;altitude, DriverObject, NULL, \u0026amp;g_RegistryCookie, NULL ); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: CmRegisterCallbackEx failed: 0x%X\\n\u0026#34;, status)); return status; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Registry callback registered\\n\u0026#34;)); return STATUS_SUCCESS; } KdaMonRegistryCallback: the routine called on every registry operation altitude: a string defining the callback\u0026rsquo;s position in the chain of registry filters (KDAMON_REG_ALTITUDE, 360000) \u0026amp;g_RegistryCookie: an identifier returned by the kernel for this registration The cookie is global because it\u0026rsquo;s used in two places: unregistration, and key path resolution (see below).\nVOID KdaMonRegistryCallbackUnregister(VOID) { if (g_RegistryCookie.QuadPart != 0) { CmUnRegisterCallback(g_RegistryCookie); g_RegistryCookie.QuadPart = 0; KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Registry callback unregistered\\n\u0026#34;)); } } The check on QuadPart prevents unregistering a callback that was never registered.\nThe dispatcher # static NTSTATUS KdaMonRegistryCallback(_In_ PVOID CallbackContext, _In_opt_ PVOID Argument1, _In_opt_ PVOID Argument2) { UNREFERENCED_PARAMETER(CallbackContext); REG_NOTIFY_CLASS notifyClass = (REG_NOTIFY_CLASS)(ULONG_PTR)Argument1; switch (notifyClass) { case RegNtPreSetValueKey: KdaMonRegistryHandleSetValueKey((PREG_SET_VALUE_KEY_INFORMATION)Argument2); break; case RegNtPreDeleteValueKey: KdaMonRegistryHandleDeleteValueKey((PREG_DELETE_VALUE_KEY_INFORMATION)Argument2); break; case RegNtPostCreateKeyEx: KdaMonRegistryHandlePostCreateKeyEx((PREG_POST_OPERATION_INFORMATION)Argument2); break; default: break; } return STATUS_SUCCESS; } Argument1 contains the class of the operation (REG_NOTIFY_CLASS). Argument2 points to a structure whose type depends on that class, hence the cast in each case. The three classes handled are:\nClass Structure Timing RegNtPreSetValueKey REG_SET_VALUE_KEY_INFORMATION Before the value is written RegNtPreDeleteValueKey REG_DELETE_VALUE_KEY_INFORMATION Before the value is deleted RegNtPostCreateKeyEx REG_POST_OPERATION_INFORMATION After the key is created The first two are Pre notifications, meaning the callback is invoked before the operation, with information about the value, but without knowing its outcome. The last one is a Post notification: the operation has already completed, which gives access to its Status.\nThe callback always returns STATUS_SUCCESS. Returning an error from a Pre notification would block the operation.\nRegNtPostCreateKeyEx is fired for every call to ZwCreateKey, which either creates the key or opens an already existing one.\nResolving the key path # static NTSTATUS KdaMonRegistryResolveKeyPath(_In_ PVOID Object, _Out_writes_bytes_(KeyPathBufferSize) PWCHAR KeyPathBuffer, _In_ ULONG KeyPathBufferSize) { NTSTATUS status; PCUNICODE_STRING ObjectName = NULL; KeyPathBuffer[0] = L\u0026#39;\\0\u0026#39;; if (Object == NULL) { return STATUS_INVALID_PARAMETER; } status = CmCallbackGetKeyObjectIDEx( \u0026amp;g_RegistryCookie, Object, NULL, \u0026amp;ObjectName, 0 ); if (!NT_SUCCESS(status) || ObjectName == NULL) { return status; } ULONG charsToCopy = min(ObjectName-\u0026gt;Length / sizeof(WCHAR), KeyPathBufferSize - 1); RtlCopyMemory(KeyPathBuffer, ObjectName-\u0026gt;Buffer, charsToCopy * sizeof(WCHAR)); KeyPathBuffer[charsToCopy] = L\u0026#39;\\0\u0026#39;; CmCallbackReleaseKeyObjectIDEx(ObjectName); return STATUS_SUCCESS; } The structures we receive contain a pointer to the key object, but not its path. CmCallbackGetKeyObjectIDEx returns this path (in the form \\REGISTRY\\MACHINE\\...) in a system-allocated UNICODE_STRING, which must be released with CmCallbackReleaseKeyObjectIDEx once the copy is done.\nThe copy uses the same safe-truncation pattern as in previous articles. The buffer is cleared upfront, so if resolution fails, the event ends up with an empty path.\nResolving the process path # NTKERNELAPI NTSTATUS SeLocateProcessImageName(_In_ PEPROCESS Process, _Out_ PUNICODE_STRING* pImageFileName); SeLocateProcessImageName is exported by the kernel but isn\u0026rsquo;t declared in the WDK headers, so we write the prototype by hand at the top of the file.\nstatic NTSTATUS KdaMonRegistryResolveProcessPath(_Out_writes_z_(Length) PWCHAR Buffer, _In_ ULONG Length) { NTSTATUS status; PUNICODE_STRING imageName = NULL; Buffer[0] = L\u0026#39;\\0\u0026#39;; status = SeLocateProcessImageName(PsGetCurrentProcess(), \u0026amp;imageName); if (!NT_SUCCESS(status) || imageName == NULL) { return status; } ULONG charsToCopy = min(imageName-\u0026gt;Length / sizeof(WCHAR), Length - 1); RtlCopyMemory(Buffer, imageName-\u0026gt;Buffer, charsToCopy * sizeof(WCHAR)); Buffer[charsToCopy] = L\u0026#39;\\0\u0026#39;; ExFreePool(imageName); return STATUS_SUCCESS; } The callback runs in the context of the thread performing the operation, so PsGetCurrentProcess() is the process touching the registry.\nThe three handlers # The three handlers follow the same skeleton:\ncreate the event and its timestamp fill in the PID (PsGetCurrentProcessId()) and the process path fill in the action and the key path fill in the fields specific to the action push the event onto the queue Here\u0026rsquo;s KdaMonRegistryHandleSetValueKey in full, as an example:\nstatic VOID KdaMonRegistryHandleSetValueKey(_In_opt_ PREG_SET_VALUE_KEY_INFORMATION Info) { if (Info == NULL || Info-\u0026gt;ValueName == NULL) { return; } KDAMON_EVENT Event = { 0 }; Event.Type = KdaMonEventRegistry; KeQuerySystemTimePrecise(\u0026amp;Event.Timestamp); Event.Data.Registry.ProcessId = PsGetCurrentProcessId(); KdaMonRegistryResolveProcessPath( Event.Data.Registry.ProcessPath, RTL_NUMBER_OF(Event.Data.Registry.ProcessPath) ); Event.Data.Registry.Action = KDAMON_REGISTRY_ACTION_SET_VALUE; KdaMonRegistryResolveKeyPath( Info-\u0026gt;Object, Event.Data.Registry.KeyPath, RTL_NUMBER_OF(Event.Data.Registry.KeyPath) ); ULONG nameChars = min( Info-\u0026gt;ValueName-\u0026gt;Length / sizeof(WCHAR), RTL_NUMBER_OF(Event.Data.Registry.ValueName) - 1 ); RtlCopyMemory(Event.Data.Registry.ValueName, Info-\u0026gt;ValueName-\u0026gt;Buffer, nameChars * sizeof(WCHAR)); Event.Data.Registry.ValueName[nameChars] = L\u0026#39;\\0\u0026#39;; Event.Data.Registry.ValueType = Info-\u0026gt;Type; ULONG dataSize = min(Info-\u0026gt;DataSize, KDAMON_REG_VALUEDATA_MAX); if (Info-\u0026gt;Data != NULL \u0026amp;\u0026amp; dataSize \u0026gt; 0) { RtlCopyMemory(Event.Data.Registry.ValueData, Info-\u0026gt;Data, dataSize); } Event.Data.Registry.ValueDataSize = dataSize; KdaMonEventQueuePush(\u0026amp;Event); } The value\u0026rsquo;s data is truncated to KDAMON_REG_VALUEDATA_MAX bytes, and ValueDataSize holds the copied size.\nThe other two handlers only differ on step 4:\nKdaMonRegistryHandleDeleteValueKey (PREG_DELETE_VALUE_KEY_INFORMATION): only the value name is copied, ValueType and ValueDataSize are 0 KdaMonRegistryHandlePostCreateKeyEx (PREG_POST_OPERATION_INFORMATION): no value name, and it\u0026rsquo;s the only handler that captures the operation\u0026rsquo;s result with Event.Data.Registry.Status = Info-\u0026gt;Status; The full code for KdaMonRegistryHandleDeleteValueKey and KdaMonRegistryHandlePostCreateKeyEx can be found in KDAMonitor/driver/src/registry_callback.c.\nSerializing registry events to JSONL # Here\u0026rsquo;s the function dedicated to serializing registry events:\nstatic NTSTATUS KdaMonLogWriterWriteRegistryEvent(_In_ const KDAMON_EVENT* Event, _Out_writes_z_(BufferSize) PSTR EventBuffer, _In_ SIZE_T BufferSize) { CHAR EscapedKeyPath[520]; CHAR EscapedValueName[520]; CHAR FormattedValueData[600]; CHAR StatusField[16]; const char* action; if (!KdaMonJsonEscapeW(Event-\u0026gt;Data.Registry.KeyPath, EscapedKeyPath, sizeof(EscapedKeyPath))) { KdPrint((DRIVER_TAG \u0026#34; [WARNING]: KeyPath truncated during JSON escape (event %lu)\\n\u0026#34;, Event-\u0026gt;Id)); } switch (Event-\u0026gt;Data.Registry.Action) { case KDAMON_REGISTRY_ACTION_SET_VALUE: { action = \u0026#34;set_value\u0026#34;; break; } case KDAMON_REGISTRY_ACTION_DELETE_VALUE: { action = \u0026#34;delete_value\u0026#34;; break; } case KDAMON_REGISTRY_ACTION_CREATE_KEY: { action = \u0026#34;create_key\u0026#34;; break; } default: { action = \u0026#34;unknown\u0026#34;; break; } } if (Event-\u0026gt;Data.Registry.Action == KDAMON_REGISTRY_ACTION_CREATE_KEY) { RtlStringCbCopyA(EscapedValueName, sizeof(EscapedValueName), \u0026#34;\u0026#34;); RtlStringCbCopyA(FormattedValueData, sizeof(FormattedValueData), \u0026#34;null\u0026#34;); } else { if (!KdaMonJsonEscapeW(Event-\u0026gt;Data.Registry.ValueName, EscapedValueName, sizeof(EscapedValueName))) { KdPrint((DRIVER_TAG \u0026#34; [WARNING]: ValueName truncated during JSON escape (event %lu)\\n\u0026#34;, Event-\u0026gt;Id)); } if (Event-\u0026gt;Data.Registry.Action == KDAMON_REGISTRY_ACTION_SET_VALUE) { KdaMonRegistryFormatValueData(\u0026amp;Event-\u0026gt;Data.Registry, FormattedValueData, sizeof(FormattedValueData)); } else { RtlStringCbCopyA(FormattedValueData, sizeof(FormattedValueData), \u0026#34;null\u0026#34;); } } if (Event-\u0026gt;Data.Registry.Action == KDAMON_REGISTRY_ACTION_CREATE_KEY) { RtlStringCbPrintfA(StatusField, sizeof(StatusField), \u0026#34;\\\u0026#34;0x%08X\\\u0026#34;\u0026#34;, (ULONG)Event-\u0026gt;Data.Registry.Status); } else { RtlStringCbCopyA(StatusField, sizeof(StatusField), \u0026#34;null\u0026#34;); } return RtlStringCbPrintfA( EventBuffer, BufferSize, \u0026#34;{\\\u0026#34;id\\\u0026#34;:%lu,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;timestamp\\\u0026#34;:%lld,\u0026#34; \u0026#34;\\\u0026#34;pid\\\u0026#34;:%lu,\\\u0026#34;action\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\u0026#34; \u0026#34;\\\u0026#34;key_path\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;value_name\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;value_data\\\u0026#34;:%s,\u0026#34; \u0026#34;\\\u0026#34;status\\\u0026#34;:%s}\\n\u0026#34;, Event-\u0026gt;Id, KdaMonEventTypeToString(Event-\u0026gt;Type), Event-\u0026gt;Timestamp.QuadPart, (ULONG)(ULONG_PTR)Event-\u0026gt;Data.Registry.ProcessId, action, EscapedKeyPath, EscapedValueName, FormattedValueData, StatusField ); } Here\u0026rsquo;s a brief walkthrough of the KdaMonLogWriterWriteRegistryEvent function:\nWe escape the key path with KdaMonJsonEscapeW. We convert the action into a string (set_value, delete_value, or create_key). We prepare the fields that depend on the action: create_key: no value name or content (value_data is null) set_value: the value name is escaped and its content formatted by KdaMonRegistryFormatValueData delete_value: the value name is escaped, value_data is null The status only makes sense for create_key (a Post notification); for the other actions it\u0026rsquo;s null. We fill the EventBuffer with the event\u0026rsquo;s information. KdaMonRegistryFormatValueData picks the format of the value_data field based on ValueType (this function won\u0026rsquo;t be detailed here for simplicity, see log_writer.c):\nType JSON format REG_SZ, REG_EXPAND_SZ escaped string in quotes REG_DWORD, REG_QWORD decimal number everything else (REG_BINARY, REG_MULTI_SZ, \u0026hellip;) hex string in quotes Binary content is truncated to KDAMON_REG_VALUEDATA_MAX bytes, same as at capture time.\nAll that\u0026rsquo;s left is to call this function in the switch of KdaMonLogWriterWriteEvent:\ncase KdaMonEventRegistry: status = KdaMonLogWriterWriteRegistryEvent(Event, EventBuffer, sizeof(EventBuffer)); break; Registry event lines will look like this:\n{\u0026#34;id\u0026#34;:20,\u0026#34;type\u0026#34;:\u0026#34;registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322704667968,\u0026#34;pid\u0026#34;:4836,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\\\REGISTRY\\\\MACHINE\\\\SOFTWARE\\\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} {\u0026#34;id\u0026#34;:21,\u0026#34;type\u0026#34;:\u0026#34;registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322704668353,\u0026#34;pid\u0026#34;:4836,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\\\REGISTRY\\\\MACHINE\\\\SOFTWARE\\\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;StringValue\u0026#34;,\u0026#34;value_data\u0026#34;:\u0026#34;hello\u0026#34;,\u0026#34;status\u0026#34;:null} {\u0026#34;id\u0026#34;:109,\u0026#34;type\u0026#34;:\u0026#34;registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322707340091,\u0026#34;pid\u0026#34;:3584,\u0026#34;action\u0026#34;:\u0026#34;delete_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\\\REGISTRY\\\\MACHINE\\\\SOFTWARE\\\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;StringValue\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:null} By the way, ProcessId in network events was also changed from ULONG to HANDLE in this version, to stay consistent with the other sensors.\nIntegration into driver_entry.c # This sensor is the last one registered, and the first one unregistered.\nSo in DriverUnload:\nvoid DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdPrint((DRIVER_TAG \u0026#34; [INFO]: Driver Unload begin\\n\u0026#34;)); // --- Unregister callbacks --- KdaMonRegistryCallbackUnregister(); KdaMonImageCallbackUnregister(); KdaMonProcessCallbackUnregister(); // --- Stop the log writer --- KdaMonLogWriterStop(); // --- Destroy the event queue --- KdaMonEventQueueDestroy(); // --- Cleanup WFP callout --- KdaMonWfpCalloutUnregister(); // --- Cleanup WFP session --- KdaMonWfpSessionCleanup(); // --- Delete device object --- KdaMonDeleteDevice(\u0026amp;g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [INFO]: Driver Unload complete\\n\u0026#34;)); } In DriverEntry, we register it right after the image load callback:\n// --- Register image load callback --- ... // --- Register registry callback --- status = KdaMonRegistryCallbackRegister(DriverObject); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonRegistryCallbackRegister failed\\n\u0026#34;)); goto cleanup_image; } KdPrint((DRIVER_TAG \u0026#34; [INFO]: Initialized successfully\\n\u0026#34;)); return STATUS_SUCCESS; Validation # The test needs to cover all three actions and the main value types. Here\u0026rsquo;s test_v09.ps1:\n# test_v09.ps1 $Driver = \u0026#34;KDAMonitor\u0026#34; $DriverPath = \u0026#34;$env:USERPROFILE\\Desktop\\$Driver.sys\u0026#34; $TestKey = \u0026#34;HKLM\\SOFTWARE\\KDAMonitorTest\u0026#34; sc.exe stop $Driver sc.exe delete $Driver sc.exe create $Driver type= kernel binPath= $DriverPath sc.exe start $Driver reg add $TestKey /f reg add $TestKey /v StringValue /t REG_SZ /d \u0026#34;hello\u0026#34; /f reg add $TestKey /v ExpandValue /t REG_EXPAND_SZ /d \u0026#34;%TEMP%\u0026#34; /f reg add $TestKey /v DwordValue /t REG_DWORD /d 42 /f reg add $TestKey /v QwordValue /t REG_QWORD /d 42 /f reg add $TestKey /v BinaryValue /t REG_BINARY /d deadbeef /f reg delete $TestKey /v StringValue /f reg delete $TestKey /f sc.exe stop $Driver sc.exe delete $Driver The first reg add on the key alone triggers create_key; the next five trigger set_value, one per value type handled by KdaMonRegistryFormatValueData; the two reg delete cover delete_value, then the deletion of the key itself, which isn\u0026rsquo;t captured by the sensor since it only tracks values.\nHere\u0026rsquo;s a demonstration of this test running:\nFiltering the log for the test key\u0026rsquo;s name, we find the twelve events produced by the script: six create_key (one per reg add command, the key already existing from the second one onward), five set_value with the expected format for each type tested, and one delete_value for the value explicitly deleted at the end of the script.\nSelect-String -Path \u0026#34;C:\\KDAMonitor\\logs\\*.jsonl\u0026#34; -Pattern \u0026#34;KDAMonitorTest\u0026#34; -SimpleMatch C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:10:{\u0026#34;id\u0026#34;:9,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322704282678,\u0026#34;pid\u0026#34;:11028,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:11:{\u0026#34;id\u0026#34;:10,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322704283042,\u0026#34;pid\u0026#34;:11028,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;status\u0026#34;:null} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:21:{\u0026#34;id\u0026#34;:20,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322704667968,\u0026#34;pid\u0026#34;:4836,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:22:{\u0026#34;id\u0026#34;:21,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322704668353,\u0026#34;pid\u0026#34;:4836,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;StringValue\u0026#34;,\u0026#34;value_data\u0026#34;:\u0026#34;hello\u0026#34;,\u0026#34;status\u0026#34;:null} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:32:{\u0026#34;id\u0026#34;:31,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322705186483,\u0026#34;pid\u0026#34;:3796,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:33:{\u0026#34;id\u0026#34;:32,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322705186841,\u0026#34;pid\u0026#34;:3796,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;ExpandValue\u0026#34;,\u0026#34;value_data\u0026#34;:\u0026#34;%TEMP%\u0026#34;,\u0026#34;status\u0026#34;:null} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:77:{\u0026#34;id\u0026#34;:76,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322706094293,\u0026#34;pid\u0026#34;:1048,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:78:{\u0026#34;id\u0026#34;:77,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322706095212,\u0026#34;pid\u0026#34;:1048,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;DwordValue\u0026#34;,\u0026#34;value_data\u0026#34;:42,\u0026#34;status\u0026#34;:null} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:88:{\u0026#34;id\u0026#34;:87,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322706480125,\u0026#34;pid\u0026#34;:608,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:89:{\u0026#34;id\u0026#34;:88,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322706481009,\u0026#34;pid\u0026#34;:608,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;QwordValue\u0026#34;,\u0026#34;value_data\u0026#34;:42,\u0026#34;status\u0026#34;:null} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:99:{\u0026#34;id\u0026#34;:98,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322706957224,\u0026#34;pid\u0026#34;:7816,\u0026#34;action\u0026#34;:\u0026#34;create_key\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:\u0026#34;0x00000000\u0026#34;} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:100:{\u0026#34;id\u0026#34;:99,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322706958245,\u0026#34;pid\u0026#34;:7816,\u0026#34;action\u0026#34;:\u0026#34;set_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;BinaryValue\u0026#34;,\u0026#34;value_data\u0026#34;:\u0026#34;deadbeef\u0026#34;,\u0026#34;status\u0026#34;:null} C:\\KDAMonitor\\logs\\kdamon_20260804_155110.jsonl:110:{\u0026#34;id\u0026#34;:109,\u0026#34;type\u0026#34;:\u0026#34;Registry\u0026#34;,\u0026#34;timestamp\u0026#34;:134303322707340091,\u0026#34;pid\u0026#34;:3584,\u0026#34;action\u0026#34;:\u0026#34;delete_value\u0026#34;,\u0026#34;key_path\u0026#34;:\u0026#34;\\REGISTRY\\MACHINE\\SOFTWARE\\KDAMonitorTest\u0026#34;,\u0026#34;value_name\u0026#34;:\u0026#34;StringValue\u0026#34;,\u0026#34;value_data\u0026#34;:null,\u0026#34;status\u0026#34;:null} Conclusion # The second-to-last sensor is done! KDAMonitor now logs processes, image loads, network connections, and registry activity. We\u0026rsquo;re getting closer to the end of this project bit by bit\u0026hellip;\nHere\u0026rsquo;s the updated architecture:\nThanks for reading all the way through, and see you in the next and tenth article of this series: Monitoring Thread Creation and Termination.\n","date":"28 September 2026","externalUrl":null,"permalink":"/en/posts/09-registry-sensor/","section":"Blog","summary":"Implementing KDAMonitor’s fourth sensor: monitoring the creation, modification, and deletion of registry values.","title":"09 - Monitoring Registry Activity","type":"posts"},{"content":"Articles, technical notes and personal research.\n","date":"28 September 2026","externalUrl":null,"permalink":"/en/posts/","section":"Blog","summary":"","title":"Blog","type":"posts"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/tags/c/","section":"Tags","summary":"","title":"C","type":"tags"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/","section":"Home","summary":"","title":"Home","type":"page"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/series/kdamonitor/","section":"Series","summary":"Building a Windows Kernel driver to monitor system activity: processes, image loads, network connections, registry and threads.","title":"KDAMonitor","type":"series"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/tags/kdamonitor/","section":"Tags","summary":"","title":"KDAMonitor","type":"tags"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/tags/kernel-driver/","section":"Tags","summary":"","title":"Kernel Driver","type":"tags"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"28 September 2026","externalUrl":null,"permalink":"/en/tags/windows-kernel/","section":"Tags","summary":"","title":"Windows Kernel","type":"tags"},{"content":"Welcome to the eighth article in the KDAMonitor development series!\nIn this article, which covers v0.8 of the project, I implement the third sensor of KDAMonitor: the network connection sensor. As a reminder, in the previous article (v0.7), I implemented the WFP session (engine, provider and sublayer) without adding the observation part.\nThe sensor will inspect the IPv4 packets passing through, without blocking them, and extract the relevant data. Unlike the other sensors, there is no routine or callback this time: we will use a callout that is triggered by a filter added to the sublayer.\nHere are the files covered by this article, and the section that explains each one:\nFile Role Section event_types.h New KDAMON_NETWORK_EVENT_DATA type, KDAMON_NETWORK_DIRECTION enum + union in KDAMON_EVENT What to capture from a network connection? guids.c Centralized DEFINE_GUID declarations (session + callouts) Centralizing the GUIDs wfp_callout.h Register/unregister declarations Implementing the callout wfp_callout.c The callouts, their filters, the registration Implementing the callout wfp_session.c / wfp_session.h Shared engine handle, sublayer weight Adapting the WFP session log_writer.c JSONL serialization of network events Serializing network events to JSONL driver_entry.c Callout registration/unregistration Integration in driver_entry.c The project can be found in this repository: KDAMonitor.\nWhat to capture from a network connection? # As with every sensor, I first need to decide what information to keep from a network connection. I kept six items:\nthe PID of the process that initiated the connection the path of that process the protocol used the local IP address and port the remote IP address and port the direction: is the connection inbound or outbound? This gives us the following structures:\ntypedef enum _KDAMON_NETWORK_DIRECTION { KDAMON_NETWORK_DIRECTION_INBOUND, KDAMON_NETWORK_DIRECTION_OUTBOUND, } KDAMON_NETWORK_DIRECTION; typedef struct _KDAMON_NETWORK_EVENT_DATA { ULONG ProcessId; WCHAR ProcessPath[260]; UINT8 Protocol; ULONG LocalIp; USHORT LocalPort; ULONG RemoteIp; USHORT RemotePort; KDAMON_NETWORK_DIRECTION Direction; } KDAMON_NETWORK_EVENT_DATA; For IP addresses and ports, I chose ULONG and USHORT for simplicity, which limits this version to IPv4.\nOf course, it has to be added to the union of the final structure:\ntypedef struct _KDAMON_EVENT { ... union { KDAMON_PROCESS_EVENT_DATA Process; KDAMON_IMAGE_LOAD_EVENT_DATA ImageLoad; KDAMON_NETWORK_EVENT_DATA Network; } Data; } KDAMON_EVENT, * PKDAMON_EVENT; The magic number 260 is the maximum path length in Windows and will be replaced by a macro in a later version :)\nHow does a WFP callout work? # So far, all the sensors worked on the same callback principle: we gave Windows a function, through a routine, and it ran it when the event occurred. The WFP engine does not call our functions directly. It applies filters to the traffic, and if a packet matches a filter, that filter triggers our callout.\nIn the previous article, we opened the session and registered the provider and the sublayer. What is missing now is the part that actually looks at the traffic.\nThe ALE layers # WFP splits network processing into several layers. The ones we care about are the ALE layers (Application Layer Enforcement, documentation), which track connections at the application level and provide the process PID and path in the metadata. I use two of them:\nLayer Direction Triggered by FWPM_LAYER_ALE_AUTH_CONNECT_V4 outbound a TCP connect(), the first UDP packet to a given remote address/port pair, the first outbound ICMP message FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4 inbound an incoming TCP connection, the first inbound UDP packet from a given address/port pair, the first inbound ICMP message This gives us one notification per connection (or per UDP/ICMP flow). For each direction, three objects have to be set up:\nThe kernel callout, with FwpsCalloutRegister2. This is where we provide our functions, in an FWPS_CALLOUT2 structure: classifyFn (called when traffic matches a filter), notifyFn (notifications about filters being added or removed) and flowDeleteFn (unused here, left as NULL). Adding the callout, with FwpmCalloutAdd, which tells the engine that a callout exists for a given layer. The filter, with FwpmFilterAdd, which is placed in our sublayer and points to the callout. Observing without blocking # In our case, the filter is not used to filter anything: it is used to trigger the callout on all the IPv4 traffic of the layer, in order to extract information from it. We give it the FWP_ACTION_CALLOUT_INSPECTION action, which means the callout observes without deciding, and therefore without blocking. In return, classifyFn must return FWP_ACTION_CONTINUE so that the traffic carries on its way.\nCentralizing the GUIDs # The callout, the filter, the provider and the sublayer are all identified by GUIDs. In v0.7, the two session GUIDs were defined at the top of wfp_session.c. With the two callout GUIDs, which are used in wfp_callout.c, there are now four of them spread across several files. So I grouped them in a dedicated file, guids.c.\nINITGUID is defined in guids.c only, and the other files only see the extern const GUID declarations from their headers:\n// guids.c #define INITGUID #include \u0026lt;guiddef.h\u0026gt; #include \u0026lt;ntddk.h\u0026gt; #define NDIS630 #include \u0026lt;ndis.h\u0026gt; #include \u0026lt;fwpmk.h\u0026gt; // --- Session GUIDs --- DEFINE_GUID(KDAMON_WFP_PROVIDER_GUID, ...); DEFINE_GUID(KDAMON_WFP_SUBLAYER_GUID, ...); // --- Callout GUIDs --- DEFINE_GUID(KDAMON_WFP_CALLOUT_OUTBOUND_GUID, ...); DEFINE_GUID(KDAMON_WFP_CALLOUT_INBOUND_GUID, ...); // wfp_callout.h extern const GUID KDAMON_WFP_CALLOUT_OUTBOUND_GUID; extern const GUID KDAMON_WFP_CALLOUT_INBOUND_GUID; This way, wfp_session.c no longer needs to define INITGUID or its own GUIDs, and if a GUID changes, there is only one place to edit.\nImplementing the callout # Everything is in the wfp_callout.c file. Two functions are exposed in the header, and everything else is private:\nNTSTATUS KdaMonWfpCalloutRegister(PDEVICE_OBJECT DeviceObject);: registers the callouts and their filters VOID KdaMonWfpCalloutUnregister(VOID);: removes them The file keeps the identifiers returned by WFP in global variables, which are needed to remove everything later:\nstatic UINT32 g_WpsCalloutIdOutbound = 0; static UINT32 g_WpsCalloutIdInbound = 0; static UINT64 g_FilterIdOutbound = 0; static UINT64 g_FilterIdInbound = 0; Processing the packets # When a packet matches the filter, WFP calls classifyFn with three parameters:\ninFixedValues: the fields of the filtered layer (protocol, addresses, ports) inMetaValues: the metadata (PID, process path) classifyOut: where we tell the engine what we decide The fields of inFixedValues are accessed through an FWPS_FIELD_* index that depends on the layer. The processing is identical for outbound and inbound connections, so there is a common function that receives these indices as parameters:\nstatic VOID KdaMonWfpClassifyCommon( _In_ const FWPS_INCOMING_VALUES0* inFixedValues, _In_ const FWPS_INCOMING_METADATA_VALUES0* inMetaValues, _Inout_ FWPS_CLASSIFY_OUT0* classifyOut, _In_ KDAMON_NETWORK_DIRECTION Direction, _In_ UINT32 FieldProtocol, _In_ UINT32 FieldLocalIp, _In_ UINT32 FieldLocalPort, _In_ UINT32 FieldRemoteIp, _In_ UINT32 FieldRemotePort ) { KDAMON_EVENT Event = { 0 }; Event.Type = KdaMonEventNetwork; KeQuerySystemTimePrecise(\u0026amp;Event.Timestamp); // --- PID --- Event.Data.Network.ProcessId = (ULONG)inMetaValues-\u0026gt;processId; // --- Process path --- if (inMetaValues-\u0026gt;processPath \u0026amp;\u0026amp; inMetaValues-\u0026gt;processPath-\u0026gt;data \u0026amp;\u0026amp; inMetaValues-\u0026gt;processPath-\u0026gt;size \u0026gt; 0) { SIZE_T bytesToCopy = min( inMetaValues-\u0026gt;processPath-\u0026gt;size, (RTL_NUMBER_OF(Event.Data.Network.ProcessPath) - 1) * sizeof(WCHAR) ); RtlCopyMemory(Event.Data.Network.ProcessPath, inMetaValues-\u0026gt;processPath-\u0026gt;data, bytesToCopy); Event.Data.Network.ProcessPath[bytesToCopy / sizeof(WCHAR)] = L\u0026#39;\\0\u0026#39;; } // --- Protocol, IPs, ports --- Event.Data.Network.Protocol = (UINT8)inFixedValues-\u0026gt;incomingValue[FieldProtocol].value.uint8; Event.Data.Network.LocalIp = inFixedValues-\u0026gt;incomingValue[FieldLocalIp].value.uint32; Event.Data.Network.RemoteIp = inFixedValues-\u0026gt;incomingValue[FieldRemoteIp].value.uint32; Event.Data.Network.LocalPort = inFixedValues-\u0026gt;incomingValue[FieldLocalPort].value.uint16; Event.Data.Network.RemotePort = inFixedValues-\u0026gt;incomingValue[FieldRemotePort].value.uint16; Event.Data.Network.Direction = Direction; KdaMonEventQueuePush(\u0026amp;Event); classifyOut-\u0026gt;actionType = FWP_ACTION_CONTINUE; } Here is a summary of what the function does:\nWe create the event, as for the other sensors. The PID comes from the metadata; WFP provides it as a 64-bit value, and we narrow it to a ULONG. The process path is provided as a blob (FWP_BYTE_BLOB) whose size is in bytes. The protocol, addresses and ports are read from inFixedValues and added to the event. We push the event to the queue and return FWP_ACTION_CONTINUE. The path is kept in NT format (\\Device\\HarddiskVolume3\\...) rather than Win32 format (C:\\...).\nThe two classifyFn functions # What WFP actually calls are two functions, one for each direction. They simply indicate the direction and the field indices of their layer:\nstatic VOID KdaMonWfpClassifyFnOutbound( _In_ const FWPS_INCOMING_VALUES0* inFixedValues, _In_ const FWPS_INCOMING_METADATA_VALUES0* inMetaValues, _Inout_opt_ VOID* layerData, _In_opt_ const VOID* classifyContext, _In_ const FWPS_FILTER2* filter, _In_ UINT64 flowContext, _Inout_ FWPS_CLASSIFY_OUT0* classifyOut ) { UNREFERENCED_PARAMETER(layerData); UNREFERENCED_PARAMETER(classifyContext); UNREFERENCED_PARAMETER(filter); UNREFERENCED_PARAMETER(flowContext); KdaMonWfpClassifyCommon( inFixedValues, inMetaValues, classifyOut, KDAMON_NETWORK_DIRECTION_OUTBOUND, FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_PROTOCOL, FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_LOCAL_ADDRESS, FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_LOCAL_PORT, FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_ADDRESS, FWPS_FIELD_ALE_AUTH_CONNECT_V4_IP_REMOTE_PORT ); } The inbound version, KdaMonWfpClassifyFnInbound, is identical: it passes KDAMON_NETWORK_DIRECTION_INBOUND and the FWPS_FIELD_ALE_AUTH_RECV_ACCEPT_V4_* indices. A notifyFn is also required at registration, but we do not use it. So KdaMonWfpNotifyFn is a simple stub that returns STATUS_SUCCESS.\nRegistering the callouts # For each direction, KdaMonWfpCalloutRegister performs the three steps seen above. For the outbound connection, we have:\nNTSTATUS KdaMonWfpCalloutRegister(_In_ PDEVICE_OBJECT DeviceObject) { NTSTATUS status; FWPS_CALLOUT2 callout_s = { 0 }; FWPM_CALLOUT callout_m = { 0 }; FWPM_FILTER filter = { 0 }; // ========================================================= // OUTBOUND — FWPM_LAYER_ALE_AUTH_CONNECT_V4 // ========================================================= RtlCopyMemory(\u0026amp;callout_s.calloutKey, \u0026amp;KDAMON_WFP_CALLOUT_OUTBOUND_GUID, sizeof(GUID)); callout_s.flags = 0; callout_s.classifyFn = KdaMonWfpClassifyFnOutbound; callout_s.notifyFn = KdaMonWfpNotifyFn; callout_s.flowDeleteFn = NULL; status = FwpsCalloutRegister2(DeviceObject, \u0026amp;callout_s, \u0026amp;g_WpsCalloutIdOutbound); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpsCalloutRegister2 (outbound) failed: 0x%X\\n\u0026#34;, status)); return status; } First, we register the kernel-side callout with its functions.\nRtlCopyMemory(\u0026amp;callout_m.calloutKey, \u0026amp;KDAMON_WFP_CALLOUT_OUTBOUND_GUID, sizeof(GUID)); RtlCopyMemory(\u0026amp;callout_m.applicableLayer, \u0026amp;FWPM_LAYER_ALE_AUTH_CONNECT_V4, sizeof(GUID)); callout_m.displayData.name = KDAMON_WFP_CALLOUT_OUTBOUND_NAME; callout_m.displayData.description = KDAMON_WFP_CALLOUT_OUTBOUND_DESCRIPTION; callout_m.flags = 0; status = FwpmCalloutAdd(g_EngineHandle, \u0026amp;callout_m, NULL, NULL); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmCalloutAdd (outbound) failed: 0x%X\\n\u0026#34;, status)); KdaMonWfpCalloutUnregister(); return status; } Then, we add the callout to the engine for the FWPM_LAYER_ALE_AUTH_CONNECT_V4 layer.\nRtlZeroMemory(\u0026amp;filter, sizeof(filter)); filter.displayData.name = KDAMON_WFP_FILTER_OUTBOUND_NAME; filter.displayData.description = KDAMON_WFP_FILTER_OUTBOUND_DESCRIPTION; filter.providerKey = (GUID*)\u0026amp;KDAMON_WFP_PROVIDER_GUID; filter.numFilterConditions = 0; filter.filterCondition = NULL; filter.action.type = FWP_ACTION_CALLOUT_INSPECTION; RtlCopyMemory(\u0026amp;filter.layerKey, \u0026amp;FWPM_LAYER_ALE_AUTH_CONNECT_V4, sizeof(GUID)); RtlCopyMemory(\u0026amp;filter.subLayerKey, \u0026amp;KDAMON_WFP_SUBLAYER_GUID, sizeof(GUID)); RtlCopyMemory(\u0026amp;filter.action.calloutKey, \u0026amp;KDAMON_WFP_CALLOUT_OUTBOUND_GUID, sizeof(GUID)); status = FwpmFilterAdd(g_EngineHandle, \u0026amp;filter, NULL, \u0026amp;g_FilterIdOutbound); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmFilterAdd (outbound) failed: 0x%X\\n\u0026#34;, status)); KdaMonWfpCalloutUnregister(); return status; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: WFP outbound callout registered\\n\u0026#34;)); Finally, the filter is attached to our provider and our sublayer, on the same layer.\nThe inbound part follows exactly the same steps, with KDAMON_WFP_CALLOUT_INBOUND_GUID, KdaMonWfpClassifyFnInbound and the FWPM_LAYER_ALE_AUTH_RECV_ACCEPT_V4 layer.\nUnregistering the callouts # VOID KdaMonWfpCalloutUnregister(VOID) { if (g_FilterIdInbound != 0) { FwpmFilterDeleteById(g_EngineHandle, g_FilterIdInbound); g_FilterIdInbound = 0; } if (g_FilterIdOutbound != 0) { FwpmFilterDeleteById(g_EngineHandle, g_FilterIdOutbound); g_FilterIdOutbound = 0; } if (g_WpsCalloutIdInbound != 0) { FwpmCalloutDeleteByKey(g_EngineHandle, \u0026amp;KDAMON_WFP_CALLOUT_INBOUND_GUID); FwpsCalloutUnregisterById(g_WpsCalloutIdInbound); g_WpsCalloutIdInbound = 0; } if (g_WpsCalloutIdOutbound != 0) { FwpmCalloutDeleteByKey(g_EngineHandle, \u0026amp;KDAMON_WFP_CALLOUT_OUTBOUND_GUID); FwpsCalloutUnregisterById(g_WpsCalloutIdOutbound); g_WpsCalloutIdOutbound = 0; } } We first remove the two filters, then each callout, on the management side with FwpmCalloutDeleteByKey and on the kernel side with FwpsCalloutUnregisterById.\nAdapting the WFP session # Two changes are needed in wfp_session.c so that the callout works with the session from the previous article.\nSharing the engine handle # The callout needs the session handle to call FwpmCalloutAdd and FwpmFilterAdd. Until now, g_EngineHandle was static, and therefore private to wfp_session.c. It becomes global, and the header declares it:\n// wfp_session.h extern HANDLE g_EngineHandle; // wfp_session.c HANDLE g_EngineHandle = NULL; The callout thus reuses the session opened by KdaMonWfpSessionInit.\nThe sublayer priority # The sublayer weight goes from 0 to 0xFFFF, the maximum value:\nsubLayer.weight = (UINT16)0xFFFF; The higher a sublayer\u0026rsquo;s weight, the earlier it is evaluated. Our sublayer is therefore called before the others. Since the sensor only observes, it is preferable for it to be called first rather than depend on the order of the other sublayers.\nSerializing network events to JSONL # Here is the function dedicated to serializing network events:\nstatic NTSTATUS KdaMonLogWriterWriteNetworkEvent( _In_ const KDAMON_EVENT* Event, _Out_writes_z_(BufferSize) PSTR EventBuffer, _In_ SIZE_T BufferSize) { CHAR EscapedPath[520]; ULONG localIp = Event-\u0026gt;Data.Network.LocalIp; ULONG remoteIp = Event-\u0026gt;Data.Network.RemoteIp; if (!KdaMonJsonEscapeW(Event-\u0026gt;Data.Network.ProcessPath, EscapedPath, sizeof(EscapedPath))) { KdPrint((DRIVER_TAG \u0026#34; [WARNING]: Process path truncated during JSON escape (event %lu)\\n\u0026#34;, Event-\u0026gt;Id)); } const char* direction = (Event-\u0026gt;Data.Network.Direction == KDAMON_NETWORK_DIRECTION_OUTBOUND) ? \u0026#34;outbound\u0026#34; : \u0026#34;inbound\u0026#34;; const char* protocol; switch (Event-\u0026gt;Data.Network.Protocol) { case 1: protocol = \u0026#34;ICMP\u0026#34;; break; case 6: protocol = \u0026#34;TCP\u0026#34;; break; case 17: protocol = \u0026#34;UDP\u0026#34;; break; default: protocol = \u0026#34;UNKNOWN\u0026#34;; break; } return RtlStringCbPrintfA( EventBuffer, BufferSize, \u0026#34;{\\\u0026#34;id\\\u0026#34;:%lu,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;timestamp\\\u0026#34;:%lld,\u0026#34; \u0026#34;\\\u0026#34;pid\\\u0026#34;:%lu,\\\u0026#34;process\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\u0026#34; \u0026#34;\\\u0026#34;direction\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;protocol\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\u0026#34; \u0026#34;\\\u0026#34;local_ip\\\u0026#34;:\\\u0026#34;%u.%u.%u.%u\\\u0026#34;,\\\u0026#34;local_port\\\u0026#34;:%u,\u0026#34; \u0026#34;\\\u0026#34;remote_ip\\\u0026#34;:\\\u0026#34;%u.%u.%u.%u\\\u0026#34;,\\\u0026#34;remote_port\\\u0026#34;:%u}\\n\u0026#34;, Event-\u0026gt;Id, KdaMonEventTypeToString(Event-\u0026gt;Type), Event-\u0026gt;Timestamp.QuadPart, Event-\u0026gt;Data.Network.ProcessId, EscapedPath, direction, protocol, (localIp \u0026gt;\u0026gt; 24) \u0026amp; 0xFF, (localIp \u0026gt;\u0026gt; 16) \u0026amp; 0xFF, (localIp \u0026gt;\u0026gt; 8) \u0026amp; 0xFF, localIp \u0026amp; 0xFF, Event-\u0026gt;Data.Network.LocalPort, (remoteIp \u0026gt;\u0026gt; 24) \u0026amp; 0xFF, (remoteIp \u0026gt;\u0026gt; 16) \u0026amp; 0xFF, (remoteIp \u0026gt;\u0026gt; 8) \u0026amp; 0xFF, remoteIp \u0026amp; 0xFF, Event-\u0026gt;Data.Network.RemotePort ); } Here is what the function does:\nWe escape the special characters of the process path with KdaMonJsonEscapeW. We convert the direction to text (outbound or inbound). We convert the protocol number to a name: 1 for ICMP, 6 for TCP, 17 for UDP, and UNKNOWN for anything else. We split each IPv4 address into four bytes with bit shifts to write it as a.b.c.d. We fill the EventBuffer buffer in JSON format. The event line for a network connection looks like this:\n{\u0026#34;id\u0026#34;:151,\u0026#34;type\u0026#34;:\u0026#34;Network\u0026#34;,\u0026#34;timestamp\u0026#34;:134303323937637081,\u0026#34;pid\u0026#34;:10548,\u0026#34;process\u0026#34;:\u0026#34;\\\\device\\\\harddiskvolume3\\\\windows\\\\system32\\\\curl.exe\u0026#34;,\u0026#34;direction\u0026#34;:\u0026#34;outbound\u0026#34;,\u0026#34;protocol\u0026#34;:\u0026#34;TCP\u0026#34;,\u0026#34;local_ip\u0026#34;:\u0026#34;192.168.158.130\u0026#34;,\u0026#34;local_port\u0026#34;:57977,\u0026#34;remote_ip\u0026#34;:\u0026#34;104.20.23.154\u0026#34;,\u0026#34;remote_port\u0026#34;:80} All that remains is to add this function to the switch of KdaMonLogWriterWriteEvent:\ncase KdaMonEventNetwork: status = KdaMonLogWriterWriteNetworkEvent(Event, EventBuffer, sizeof(EventBuffer)); break; Integration in driver_entry.c # The callout is the first producer to be registered, and the last one to be unregistered.\nSo in DriverUnload:\nvoid DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdPrint((DRIVER_TAG \u0026#34; [INFO]: Driver Unload begin\\n\u0026#34;)); // --- Unregister callbacks --- KdaMonImageCallbackUnregister(); KdaMonProcessCallbackUnregister(); // --- Stop the log writer --- KdaMonLogWriterStop(); // --- Destroy the event queue --- KdaMonEventQueueDestroy(); // --- Cleanup WFP callout --- KdaMonWfpCalloutUnregister(); // --- Cleanup WFP session --- KdaMonWfpSessionCleanup(); // --- Delete device object --- KdaMonDeleteDevice(\u0026amp;g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [INFO]: Driver Unload complete\\n\u0026#34;)); } In DriverEntry, it is registered after the WFP session initialization, before the queue:\nstatus = KdaMonWfpCalloutRegister(g_DeviceObject); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonWfpCalloutRegister failed\\n\u0026#34;)); goto cleanup_wfp; } This order is not ideal, because classifyFn pushes to the event queue, yet the callout is registered before the queue is created and unregistered after it is destroyed. I will fix this during the v0.11 refactor.\nValidation # For this version, I need to check:\nthat the KDAMonitor WFP objects are properly registered and then removed that network events actually make it to the log file As in v0.7, I use netsh wfp show state before, during, and after the driver\u0026rsquo;s lifecycle. Between loading and unloading, the script generates outbound and inbound traffic, then reads the log once the driver is stopped (test_v08.ps1):\n# test_v08.ps1 $Driver = \u0026#34;KDAMonitor\u0026#34; $DriverPath = \u0026#34;$env:USERPROFILE\\Desktop\\$Driver.sys\u0026#34; $LogPath = \u0026#34;C:\\KDAMonitor\\logs\\*.jsonl\u0026#34; netsh wfp show state file=wfpstate_before.xml Write-Host \u0026#34;--- Before load ---\u0026#34; (Select-String -Path wfpstate_before.xml -Pattern \u0026#34;\u0026lt;name\u0026gt;KDAMonitor\u0026#34;).Line sc.exe stop $Driver | Out-Null sc.exe delete $Driver | Out-Null sc.exe create $Driver type= kernel binPath= $DriverPath sc.exe start $Driver netsh wfp show state file=wfpstate_after_load.xml Write-Host \u0026#34;--- After load ---\u0026#34; (Select-String -Path wfpstate_after_load.xml -Pattern \u0026#34;\u0026lt;name\u0026gt;KDAMonitor\u0026#34;).Line # --- Outbound traffic: TCP, ICMP and UDP --- curl.exe -s -o NUL http://example.com ping.exe -n 1 1.1.1.1 nslookup.exe example.com 1.1.1.1 # --- Inbound traffic: local listener and a connection to it --- $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Any, 8080) $listener.Start() $client = [System.Net.Sockets.TcpClient]::new(\u0026#34;127.0.0.1\u0026#34;, 8080) $client.Close() $listener.Stop() Start-Sleep -Seconds 2 sc.exe stop $Driver netsh wfp show state file=wfpstate_after_unload.xml Write-Host \u0026#34;--- After unload ---\u0026#34; (Select-String -Path wfpstate_after_unload.xml -Pattern \u0026#34;\u0026lt;name\u0026gt;KDAMonitor\u0026#34;).Line sc.exe delete $Driver Write-Host \u0026#34;--- Network events ---\u0026#34; $Log = Get-ChildItem $LogPath | Sort-Object LastWriteTime | Select-Object -Last 1 (Select-String -Path $Log.FullName -Pattern \u0026#39;\u0026#34;type\u0026#34;:\u0026#34;Network\u0026#34;\u0026#39;).Line | Where-Object { $_ -match \u0026#39;curl\\.exe|ping\\.exe|nslookup\\.exe|_port\u0026#34;:8080\u0026#39; } Before loading, there is no KDAMonitor entry. After loading, we find the six objects: the provider, the sublayer, the two callouts and the two filters. And finally, after unloading, no trace remains.\nOn the log side, we find the outbound TCP connection from curl.exe to example.com, the DNS requests from nslookup.exe over UDP, and the connection to our listener seen from both sides, outbound then inbound.\nThe ping does not appear in the script output because the script filters on process names and the ICMP echo is attributed to the System process (PID 4) rather than ping.exe. It can be found by searching the log directly for the protocol:\nGet-ChildItem C:\\KDAMonitor\\logs\\*.jsonl | Sort-Object LastWriteTime | Select-Object -Last 1 | Select-String -Pattern \u0026#39;\u0026#34;protocol\u0026#34;:\u0026#34;ICMP\u0026#34;\u0026#39; {\u0026#34;id\u0026#34;:179,\u0026#34;type\u0026#34;:\u0026#34;Network\u0026#34;,\u0026#34;timestamp\u0026#34;:134303323940213004,\u0026#34;pid\u0026#34;:4,\u0026#34;process\u0026#34;:\u0026#34;System\u0026#34;,\u0026#34;direction\u0026#34;:\u0026#34;outbound\u0026#34;,\u0026#34;protocol\u0026#34;:\u0026#34;ICMP\u0026#34;,\u0026#34;local_ip\u0026#34;:\u0026#34;192.168.158.130\u0026#34;,\u0026#34;local_port\u0026#34;:8,\u0026#34;remote_ip\u0026#34;:\u0026#34;1.1.1.1\u0026#34;,\u0026#34;remote_port\u0026#34;:0} Conclusion # A third sensor done! KDAMonitor now logs processes, image loads and network connections. There are, however, some limitations:\nIPv6 is not covered. Only the _V4 layers are registered. The filter has no condition. All IPv4 traffic is captured which produces a lot of events: for example. The process path is in NT format (\\Device\\HarddiskVolume3\\...), as provided by WFP. But overall, I am quite happy with the state of the project so far!\nHere is the updated architecture:\nThanks for reading all the way through, and see you in the next and ninth article of this series: Monitoring Registry Activity.\n","date":"24 September 2026","externalUrl":null,"permalink":"/en/posts/08-network-sensor/","section":"Blog","summary":"Third KDAMonitor sensor: capturing inbound and outbound IPv4 connections.","title":"08 - Monitoring Network Connections with the Windows Filtering Platform","type":"posts"},{"content":"Welcome to the third article in the series on building my AArch64 bare-metal kernel!\nIn this article, I\u0026rsquo;ll present v0.2 of the project. The goal of this version is to implement kernel exceptions: get a kernel able to detect and handle a synchronous exception (via svc) as well as an asynchronous hardware interrupt (IRQ), through a vector table, a synchronous handler, an IRQ handler, and the GIC (interrupt controller).\nHere are the files covered in this article, and the section explaining each one:\nFile Role Section src/exceptions/vectors.s Exception vector table Implementing the vector table src/exceptions/handlers.s Synchronous and IRQ handlers The synchronous handler and The IRQ handler src/gic/gic.s, src/gic/gic.inc GIC driver (gic_init) The GIC and the IRQ handler src/uart/uart.s Added uart_put_hex uart_put_hex src/boot/boot.s VBAR_EL1 configuration, gic_init, triggering svc/SGI Triggering the synchronous exception and The GIC and the IRQ handler The project can be found in this repository: aarch64-baremetal-kernel.\nAArch64 exception model # This section is heavily based on ARM\u0026rsquo;s official documentation: Learn the architecture - AArch64 Exception Model.\nSynchronous and asynchronous exceptions # In AArch64, there are two types of exceptions:\nsynchronous exceptions, caused by (or tied to) the instruction currently executing asynchronous exceptions, caused by something external to the instruction flow. A synchronous exception is directly triggered by the currently executing instruction: a system call (svc), an invalid instruction, or a memory fault, for example. The return address has an architecturally defined relationship with the faulting instruction, and this type of exception cannot be masked — in other words, it interrupts the instruction flow.\nAn asynchronous exception comes from an external event, for example: a timer, a peripheral, or another core. This is referred to as an interrupt (IRQ, FIQ, or SError). Unlike synchronous exceptions, interrupts can be masked via the PSTATE.DAIF register, and are handled through the GIC (Generic Interrupt Controller), covered later in this article.\nIn this article, we\u0026rsquo;ll trigger and handle one example of each type: an svc for the synchronous part, and an SGI (Software-Generated Interrupt) via the GIC for the asynchronous part.\nHere are the registers used to handle exceptions:\nRegister Role ELR_ELx Return address after the exception ESR_ELx Cause of the exception (synchronous/SError only) SPSR_ELx PSTATE saved at the time of the exception VBAR_ELx Base address of the vector table The x suffix depends on the current EL. Since this kernel only runs at EL1, we\u0026rsquo;ll only use ELR_EL1, ESR_EL1, SPSR_EL1, and VBAR_EL1.\neret atomically restores PSTATE from SPSR_ELx and makes the CPU jump to the address held in ELR_ELx. For an svc, ELR_EL1 holds the address of the instruction following the svc.\nThe vector table # Each EL has its own vector table, whose address is given by VBAR_ELx. This table holds 16 entries of 128 bytes (32 instructions) each, and must be aligned on 2 KB.\nOffset Type Source +0x000 Synchronous current EL, SP_EL0 +0x080 IRQ current EL, SP_EL0 +0x180 SError current EL, SP_EL0 +0x200 Synchronous current EL, SP_ELx +0x280 IRQ current EL, SP_ELx +0x380 SError current EL, SP_ELx +0x400 to +0x780 (lower EL, AArch64/AArch32) unused This kernel only runs at EL1, with no EL0. So only the two bolded entries (+0x200, +0x280) matter to us; the rest will be stubs.\nImplementing the vector table # To implement the vector table, I took inspiration from the file 11_exceptions_part1_groundwork/src/_arch/aarch64/exception.s in the rust-raspberrypi-OS-tutorials repository.\nThe CALL_HANDLER and UNUSED_VECTOR macros # In a new file, src/exceptions/vectors.s, we\u0026rsquo;ll build a few pieces:\nFirst, a macro that calls the correct handler for the requested exception: .macro CALL_HANDLER handler vector_\\handler: mrs x19, ELR_EL1 mrs x20, SPSR_EL1 mrs x21, ESR_EL1 mov x0, sp bl \\handler .endm This CALL_HANDLER macro saves ELR/SPSR/ESR into callee-saved registers (x19-x21) before calling the actual handler, with sp passed in x0.\nNext, a macro that acts as the handler for every other, unimplemented exception: .macro UNUSED_VECTOR 1: wfe b 1b .endm UNUSED_VECTOR uses a numeric local label so it can be expanded several times in the same file without symbol collisions.\nAlignment and .org # Next, we build the actual vector table:\n.align 11 _exception_vector_table: .org 0x000 UNUSED_VECTOR .org 0x080 UNUSED_VECTOR ... .org 0x200 CALL_HANDLER el_synchronous .org 0x280 CALL_HANDLER el_irq ... .org 0x800 First, the table is aligned on 2048 bytes (2 KiB), as required by VBAR_EL1.\nThen, .org places each entry at its exact expected offset in the table, advancing the assembler\u0026rsquo;s location counter as needed.\nMost entries in this table point to UNUSED_VECTOR. The two entries we actually care about are at offsets 0x200 and 0x280, where CALL_HANDLER el_synchronous and CALL_HANDLER el_irq are placed respectively.\nTriggering the synchronous exception # uart_put_hex # Before adding the synchronous exception, we need to add a function to src/uart/uart.s that prints (via UART) a 64-bit value as hexadecimal:\nuart_put_hex: stp x19, x20, [sp, #-32]! stp x30, xzr, [sp, #16] mov x19, x0 mov x20, #16 hex_loop: lsr x3, x19, #60 and x3, x3, #0xF ldr x2, =hex_chars ldrb w0, [x2, x3] bl uart_putc lsl x19, x19, #4 subs x20, x20, #1 b.ne hex_loop ldp x30, xzr, [sp, #16] ldp x19, x20, [sp], #32 ret hex_chars: .asciz \u0026#34;0123456789ABCDEF\u0026#34; This function is needed because uart_puts only handles ASCII, and the idea here is to print register values on every exception.\nThe synchronous handler # In src/exceptions/handlers.s, we define el_synchronous, the function called by CALL_HANDLER. For now, the handler only needs to print information. It starts by printing Synchronous exception caught!\\n when called, then prints the values of ELR_EL1, SPSR_EL1, and ESR_EL1.\nel_synchronous: stp x29, x30, [sp, #-16]! ldr x0, =message_synchronous bl uart_puts ldr x0, =message_prefix_elr_el1 bl uart_puts mov x0, x19 bl uart_put_hex ldr x0, =message_prefix_spsr_el1 bl uart_puts mov x0, x20 bl uart_put_hex ldr x0, =message_prefix_esr_el1 bl uart_puts mov x0, x21 bl uart_put_hex ldp x29, x30, [sp], #16 eret x19, x20, and x21, filled in by CALL_HANDLER, survive the uart_puts/uart_put_hex calls since these are callee-saved registers. The final eret resumes execution right after the instruction that triggered the exception.\nAll that\u0026rsquo;s left is configuring VBAR_EL1 and triggering the exception. In src/boot/boot.s:\nldr x0, =_exception_vector_table msr vbar_el1, x0 ... svc #0 VBAR_EL1 must be configured before any instruction that could raise an exception. The svc #0 then deliberately triggers a synchronous exception, which gets caught at offset +0x200 of the vector table.\nResult # qemu-system-aarch64 -M virt -cpu cortex-a57 -nographic -kernel build/kernel.elf Hello, AArch64! Synchronous exception caught! ELR_EL1: 0x0000000040000024 SPSR_EL1: 0x0000000040000345 ESR_EL1: 0x0000000056000000 ESR_EL1, bits [31:26] (the EC field) = 0x15, the exception class for SVC in AArch64. See ARM A64 Instruction Set - SVC.\nSPSR_EL1, bits [3:0] = 0x5 = EL1h (EL1, SP_ELx). See ARM AArch64 System Registers - SPSR_EL1.\nELR_EL1 (0x40000024) does indeed match the address of the instruction following svc #0 (0x40000020) in boot.s, verifiable via aarch64-none-elf-objdump -d build/kernel.elf: 0000000040000000 \u0026lt;_start\u0026gt;: ... 4000001c: d50342ff msr daifclr, #0x2 40000020: d4000001 svc #0x0 40000024: 58000160 ldr x0, 40000050 \u0026lt;_start+0x50\u0026gt; ... The GIC and the IRQ handler # Once again, for this part I relied on ARM\u0026rsquo;s official documentation: Arm Generic Interrupt Controller Architecture Specification (GICv2). QEMU virt with cortex-a57 implements a GICv2.\nDistributor and CPU interface # The GIC (Generic Interrupt Controller) is ARM\u0026rsquo;s standard interrupt controller: a centralized resource sitting between every interrupt source and the CPU.\nThe GIC is split into two blocks:\nBlock Role Prefix Distributor Centralizes sources, prioritizes GICD_* CPU interface Per-processor priority masking GICC_* An interrupt\u0026rsquo;s lifecycle follows three steps:\nAcknowledge: reading GICC_IAR, which returns the ID of the pending interrupt and moves it to the active state. Handle: the handler runs. Complete: writing the same value back to GICC_EOIR. Finding GICD_BASE / GICC_BASE # As with the UART, we need to find the base address of these two components (the distributor and the interface):\nqemu-system-aarch64 -M virt -cpu cortex-a57 -machine dumpdtb=virt.dtb -nographic dtc -I dtb -O dts virt.dtb | grep -A 5 intc We get:\nintc@8000000 { phandle = \u0026lt;0x8002\u0026gt;; reg = \u0026lt;0x00 0x8000000 0x00 0x10000 0x00 0x8010000 0x00 0x10000\u0026gt;; compatible = \u0026#34;arm,cortex-a15-gic\u0026#34;; ranges; #size-cells = \u0026lt;0x02\u0026gt;; Per the standard arm,gic device tree binding, the Distributor is listed first in reg:\nRegister Address GICD_BASE 0x08000000 GICC_BASE 0x08010000 These addresses are confirmed directly in QEMU\u0026rsquo;s source code: hw/arm/virt.c defines VIRT_GIC_DIST as 0x08000000 and VIRT_GIC_CPU as 0x08010000.\ngic_init # The register offsets used are grouped in a separate file, src/gic/gic.inc, so they can be shared across several .s files:\n.equ GICD_BASE, 0x08000000 .equ GICC_BASE, 0x08010000 .equ GICD_CTLR, 0x000 .equ GICD_ISENABLER0, 0x100 .equ GICD_SGIR, 0xF00 .equ GICC_CTLR, 0x000 .equ GICC_PMR, 0x004 .equ GICC_IAR, 0x00C .equ GICC_EOIR, 0x010 In src/gic/gic.s:\ngic_init: ldr x0, =GICD_BASE mov w1, #0x1 str w1, [x0, #GICD_CTLR] // enable Group 0 forwarding ldr x0, =GICD_BASE mov w2, #0x1 str w2, [x0, #GICD_ISENABLER0] // enable SGI 0 ldr x0, =GICC_BASE mov w3, #0xFF str w3, [x0, #GICC_PMR] // let all priorities through ldr x0, =GICC_BASE mov w4, #0x1 str w4, [x0, #GICC_CTLR] // enable Group 0 signaling ret .include pastes the content of gic.inc directly into the file at assembly time. This is needed because an offset used as an immediate ([x0, #GICD_CTLR]) must be known to the assembler at assembly time, not just at link time. The Makefile passes -Isrc so .include \u0026quot;gic/gic.inc\u0026quot; resolves relative to src/.\ngic_init therefore configures four things:\nenabling Group 0 interrupt forwarding at the Distributor enabling SGI 0 the CPU interface\u0026rsquo;s priority mask (0xFF = let everything through) and enabling Group 0 signaling on the CPU interface side. This function is called once, early in _start, in src/boot/boot.s:\nbl gic_init The IRQ handler and triggering the SGI # The IRQ handler does roughly the same thing as the synchronous handler (print a message and a register in hexadecimal, then eret). However, there\u0026rsquo;s only one register to print (GICC_IAR), and it needs to go through the GIC\u0026rsquo;s Acknowledge/Complete cycle seen above:\nel_irq: stp x22, x30, [sp, #-16]! ldr x0, =GICC_BASE ldr w22, [x0, #GICC_IAR] // moves the interrupt to active ldr x0, =message_irq bl uart_puts ldr x0, =message_prefix_iar bl uart_puts mov x0, x22 bl uart_put_hex ldr x0, =GICC_BASE str w22, [x0, #GICC_EOIR] // write back the exact value read ldp x22, x30, [sp], #16 eret x22 is used instead of x19-x21 (already reserved by CALL_HANDLER for ELR_EL1/SPSR_EL1/ESR_EL1) so the value of GICC_IAR survives the calls to uart_puts/uart_put_hex.\nWe start by unmasking IRQs, masked by default at reset. Still in src/boot/boot.s:\nmsr daifclr, #2 See ARM AArch64 System Registers - DAIF.\nThen, in src/boot/boot.s, write to GICD_SGIR to trigger the SGI:\nldr x0, =GICD_BASE mov w1, #0x2000000 str w1, [x0, #GICD_SGIR] 0x2000000 encodes TargetListFilter = 0b10 (bits [25:24], targets the current processor only) combined with SGI ID 0 (bits [3:0]). This write immediately triggers the interrupt, which is caught at offset +0x280 of the vector table.\nSee the GICv2 Architecture Specification for the GICD_SGIR encoding (TargetListFilter in bits [25:24], SGI ID in bits [3:0]).\nBuild and verify # We start by building and checking that the expected symbols are present:\nmake clean \u0026amp;\u0026amp; make aarch64-none-elf-nm build/kernel.elf | grep -E \u0026#34;exception_vector_table|el_irq|el_synchronous|gic_init\u0026#34; 0000000040000800 T _exception_vector_table 00000000400000a4 T el_irq 0000000040000058 T el_synchronous 0000000040001000 T gic_init 0000000040000a80 t vector_el_irq 0000000040000a00 t vector_el_synchronous _exception_vector_table sits at address 0x40000800, a multiple of 0x800 (2048), confirming the linker honored the 2 KB alignment required by VBAR_EL1.\nWe also find vector_el_synchronous and vector_el_irq, the local labels generated respectively by CALL_HANDLER el_synchronous and CALL_HANDLER el_irq, at 0x40000800 + 0x200 = 0x40000a00 and 0x40000800 + 0x280 = 0x40000a80. Both entries are correctly routed to their expected offsets in the table, so everything checks out.\nFinally, we launch the kernel with QEMU:\nConclusion # The kernel now has its own vector table (still to be fully populated) along with basic handlers for the implemented vectors.\nThanks for reading all the way through, and see you in the next article: Setting up the ARM timer.\n","date":"22 September 2026","externalUrl":null,"permalink":"/en/posts/03-exceptions/","section":"Blog","summary":"Setting up the AArch64 exception vector table, the synchronous handler, the GIC and the IRQ handler.","title":"03 - Exceptions: building the vector table","type":"posts"},{"content":"","date":"22 September 2026","externalUrl":null,"permalink":"/en/tags/aarch64/","section":"Tags","summary":"","title":"AArch64","type":"tags"},{"content":"","date":"22 September 2026","externalUrl":null,"permalink":"/en/series/aarch64-bare-metal-kernel/","section":"Series","summary":"Building a small bare-metal kernel in AArch64 assembly, from a minimal boot up to a minimal working operating system.","title":"AArch64 Bare-Metal Kernel","type":"series"},{"content":"","date":"22 September 2026","externalUrl":null,"permalink":"/en/tags/arm/","section":"Tags","summary":"","title":"ARM","type":"tags"},{"content":"","date":"22 September 2026","externalUrl":null,"permalink":"/en/tags/assembly/","section":"Tags","summary":"","title":"Assembly","type":"tags"},{"content":"","date":"22 September 2026","externalUrl":null,"permalink":"/en/tags/kernel/","section":"Tags","summary":"","title":"Kernel","type":"tags"},{"content":"Welcome to the seventh article in the series on developing KDAMonitor!\nIn this article, I\u0026rsquo;ll cover version v0.7 of the project. This version doesn\u0026rsquo;t filter any network traffic yet, it sets up the WFP infrastructure (provider, sublayer) that the network sensor from v0.8 will hook into. One section of this article will cover the second crash I ran into.\nHere are the files involved in this article, and the section that explains each one:\nFile Role Section wfp_session.h Provider/sublayer GUIDs, declarations Implementing the WFP session wfp_session.c Opening the engine, adding provider/sublayer, cleanup Implementing the WFP session driver_entry.c Integration into DriverEntry/DriverUnload Integration into driver_entry.c device.c / driver_entry.c Crash #2 (PAGE_FAULT_IN_NONPAGED_AREA) and its fix Crash #2: the device object deleted twice The project can be found in this repository: KDAMonitor.\nWFP in brief # As stated in Microsoft\u0026rsquo;s documentation, the Windows Filtering Platform is a set of APIs and services for filtering network traffic.\nTo open a filtering session, we use FwpmEngineOpen, which establishes the connection with the WFP engine. Once the session is open, we can declare a provider with FwpmProviderAdd. A provider is the identity of the application (or driver) with the WFP engine. It allows, for example, direct interaction with objects, such as sublayers, registered by KDAMonitor.\nSublayers are reserved spaces used to store KDAMonitor\u0026rsquo;s filters and the network callout (planned for v0.8). For this version, we simply open the session and register the provider and the sublayer. So for now, no callout or filter.\nImplementing the WFP session # Before getting into the actual code, we first need to define GUIDs, their names and descriptions, we need two of them:\nfor the provider: DEFINE_GUID(KDAMON_WFP_PROVIDER_GUID, 0x16821234, 0xd300, 0x42f1, 0xbc, 0xe8, 0xd2, 0x23, 0x1a, 0xf3, 0x25, 0xc3); #define KDAMON_WFP_PROVIDER_NAME L\u0026#34;KDAMonitor Provider\u0026#34; #define KDAMON_WFP_PROVIDER_DESCRIPTION L\u0026#34;KDAMonitor - Kernel Driver Activity Monitor\u0026#34; and for the sublayer: DEFINE_GUID(KDAMON_WFP_SUBLAYER_GUID, 0xa141444c, 0x7f15, 0x4a05, 0xa2, 0x95, 0x07, 0xca, 0x38, 0xc2, 0x3c, 0xb1); #define KDAMON_WFP_SUBLAYER_NAME L\u0026#34;KDAMonitor Sublayer\u0026#34; #define KDAMON_WFP_SUBLAYER_DESCRIPTION L\u0026#34;KDAMonitor sublayer for network event monitoring\u0026#34; To use the DEFINE_GUID macro, the file must start with INITGUID.\nWe also define a handle for the engine (HANDLE g_EngineHandle), representing the connection to the WFP engine. The NTSTATUS KdaMonWfpSessionInit(void) function is responsible for opening the connection to the WFP engine, then registering KDAMonitor\u0026rsquo;s provider and sublayer. We start by declaring the structures used by all three steps before opening the session with FwpmEngineOpen:\nNTSTATUS status; FWPM_SESSION wfpSession = { 0 }; FWPM_PROVIDER provider = { 0 }; FWPM_SUBLAYER subLayer = { 0 }; status = FwpmEngineOpen(NULL, RPC_C_AUTHN_WINNT, NULL, \u0026amp;wfpSession, \u0026amp;g_EngineHandle); if (status != STATUS_SUCCESS) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmEngineOpen failed with status 0x%X\\n\u0026#34;, status)); return STATUS_UNSUCCESSFUL; } We then register the provider with FwpmProviderAdd:\nprovider.providerKey = KDAMON_WFP_PROVIDER_GUID; provider.displayData.name = KDAMON_WFP_PROVIDER_NAME; provider.displayData.description = KDAMON_WFP_PROVIDER_DESCRIPTION; provider.flags = 0; provider.serviceName = NULL; status = FwpmProviderAdd(g_EngineHandle, \u0026amp;provider, NULL); if (status != STATUS_SUCCESS) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmProviderAdd failed with status 0x%X\\n\u0026#34;, status)); FwpmEngineClose(g_EngineHandle); g_EngineHandle = NULL; return STATUS_UNSUCCESSFUL; } providerKey and displayData (name + description) identify the provider with the WFP engine, and in tools such as netsh wfp show providers. serviceName stays NULL since the provider isn\u0026rsquo;t tied to a specific Windows service.\nAnd finally the sublayer, with FwpmSubLayerAdd:\nsubLayer.subLayerKey = KDAMON_WFP_SUBLAYER_GUID; GUID providerKey = KDAMON_WFP_PROVIDER_GUID; subLayer.providerKey = \u0026amp;providerKey; subLayer.displayData.name = KDAMON_WFP_SUBLAYER_NAME; subLayer.displayData.description = KDAMON_WFP_SUBLAYER_DESCRIPTION; subLayer.flags = 0; subLayer.weight = (UINT16)0; status = FwpmSubLayerAdd(g_EngineHandle, \u0026amp;subLayer, NULL); if (status != STATUS_SUCCESS) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmSubLayerAdd failed with status 0x%X\\n\u0026#34;, status)); FwpmProviderDeleteByKey(g_EngineHandle, \u0026amp;KDAMON_WFP_PROVIDER_GUID); FwpmEngineClose(g_EngineHandle); g_EngineHandle = NULL; return STATUS_UNSUCCESSFUL; } return STATUS_SUCCESS; subLayer.providerKey links the sublayer to the provider created just above. weight sets the relative priority between sublayers, a parameter that doesn\u0026rsquo;t matter as long as only one sublayer exists.\nAt each step, a failure undoes what the previous step built, in reverse order: if FwpmProviderAdd fails, we simply close the session with FwpmEngineClose; if FwpmSubLayerAdd fails, we go one step further by first deleting the freshly created provider (FwpmProviderDeleteByKey) before closing the session.\nTo clean up/remove the WFP session, we call VOID KdaMonWfpSessionCleanup(void), which is the counterpart of KdaMonWfpSessionInit and therefore deletes the sublayer (FwpmSubLayerDeleteByKey), deletes the provider (FwpmProviderDeleteByKey), closes the WFP engine (FwpmEngineClose), and resets the engine\u0026rsquo;s handle to NULL (g_EngineHandle):\nVOID KdaMonWfpSessionCleanup(void) { NTSTATUS status; if (g_EngineHandle == NULL) return; status = FwpmSubLayerDeleteByKey(g_EngineHandle, \u0026amp;KDAMON_WFP_SUBLAYER_GUID); if (status != STATUS_SUCCESS) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmSubLayerDeleteByKey failed with status 0x%X\\n\u0026#34;, status)); } status = FwpmProviderDeleteByKey(g_EngineHandle, \u0026amp;KDAMON_WFP_PROVIDER_GUID); if (status != STATUS_SUCCESS) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmProviderDeleteByKey failed with status 0x%X\\n\u0026#34;, status)); } status = FwpmEngineClose(g_EngineHandle); if (status != STATUS_SUCCESS) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: FwpmEngineClose failed with status 0x%X\\n\u0026#34;, status)); } g_EngineHandle = NULL; KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: WFP session closed successfully\\n\u0026#34;)); } Integration into driver_entry.c # In the code, the WFP session is initialized between the device and the queue: since the WFP session isn\u0026rsquo;t a sensor, it\u0026rsquo;s placed near the beginning.\nThe initialization order in DriverEntry is therefore:\nthe device the WFP session the queue the log writer and the callbacks (process and image) We add this to the function:\n// --- Initialize WFP session --- status = KdaMonWfpSessionInit(); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonWfpSessionInit failed\\n\u0026#34;)); goto cleanup_device; } Tearing these elements down in DriverUnload happens in reverse order. Here are both complete functions:\nvoid DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdPrint((DRIVER_TAG \u0026#34; [INFO]: Driver Unload begin\\n\u0026#34;)); // --- Unregister callbacks --- KdaMonImageCallbackUnregister(); KdaMonProcessCallbackUnregister(); // --- Stop the log writer --- KdaMonLogWriterStop(); // --- Destroy the event queue --- KdaMonEventQueueDestroy(); // --- Cleanup WFP session --- KdaMonWfpSessionCleanup(); // --- Delete device object --- KdaMonDeleteDevice(\u0026amp;g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [INFO]: Driver Unload complete\\n\u0026#34;)); } NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); KdPrint((DRIVER_TAG \u0026#34; [INFO]: DriverEntry begin\\n\u0026#34;)); NTSTATUS status; DriverObject-\u0026gt;DriverUnload = DriverUnload; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CREATE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CLOSE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_DEVICE_CONTROL] = KdaMonDeviceControl; // --- Create device object --- status = KdaMonCreateDevice(DriverObject, \u0026amp;g_DeviceObject); if (!NT_SUCCESS(status)) { goto cleanup_none; } // --- Initialize WFP session --- status = KdaMonWfpSessionInit(); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonWfpSessionInit failed\\n\u0026#34;)); goto cleanup_device; } // --- Initialize the event queue --- if (!KdaMonEventQueueInitialize()) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: EventQueueInitialize failed\\n\u0026#34;)); status = STATUS_UNSUCCESSFUL; goto cleanup_wfp; } // --- Start the log writer thread --- if (!KdaMonLogWriterStart(DriverObject)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonLogWriterStart failed\\n\u0026#34;)); status = STATUS_UNSUCCESSFUL; goto cleanup_queue; } // --- Register process creation callback --- status = KdaMonProcessCallbackRegister(); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonProcessCallbackRegister failed\\n\u0026#34;)); goto cleanup_logwriter; } // --- Register image load callback --- status = KdaMonImageCallbackRegister(); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonImageCallbackRegister failed\\n\u0026#34;)); goto cleanup_process; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); return STATUS_SUCCESS; cleanup_process: KdaMonProcessCallbackUnregister(); cleanup_logwriter: KdaMonLogWriterStop(); cleanup_queue: KdaMonEventQueueDestroy(); cleanup_wfp: KdaMonWfpSessionCleanup(); cleanup_device: KdaMonDeleteDevice(\u0026amp;g_DeviceObject); cleanup_none: return status; } The cleanup structure for objects on initialization failure in DriverEntry from the previous version was changed to use goto, which makes the code much cleaner.\nCrash #2: the device object deleted twice # Context # This crash showed up while testing the DriverEntry refactor presented above. The dump is kept in the repo, in docs/dumps/2_PAGE_FAULT_IN_NONPAGED_AREA.dmp.\nThe bugcheck recorded is PAGE_FAULT_IN_NONPAGED_AREA (0x50), with a read access (Arg2 = 0) to an invalid address. The faulting instruction is in nt!ObQueryNameStringMode, called from nt!IoDeleteDevice, itself called from KdaMonDeleteDevice (device.c) inside DriverUnload (driver_entry.c):\nnt!ObQueryNameStringMode+a8 fffff802`7a369ad8 488b81a0000000 mov rax,qword ptr [rcx+0A0h] IoDeleteDevice internally calls ObQueryNameString to resolve the object\u0026rsquo;s name before removing it — here, on a DEVICE_OBJECT that had already been freed.\nDiagnosis # The faulty code was in DriverEntry, which was missing a return STATUS_SUCCESS; right after the success log:\nKdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); // missing: return STATUS_SUCCESS; cleanup_process: KdaMonProcessCallbackUnregister(); cleanup_wfp: KdaMonWfpSessionCleanup(); cleanup_logwriter: KdaMonLogWriterStop(); cleanup_queue: KdaMonEventQueueDestroy(); cleanup_device: KdaMonDeleteDevice(g_DeviceObject); cleanup_none: return status; // the driver reaches this point even without an error! Without this return, execution fell straight through into the cleanup cascade even after a successful initialization, before returning STATUS_SUCCESS.\nKdaMonDeleteDevice received g_DeviceObject by value. It could therefore free the DEVICE_OBJECT, but couldn\u0026rsquo;t set g_DeviceObject back to NULL.\nSo after the first call, g_DeviceObject still held the address of the now-freed object (a dangling pointer).\nAt unload time, DriverUnload called KdaMonDeleteDevice(g_DeviceObject) again, which then tried to use this invalid pointer, causing the crash.\nFix # The fix works on two levels:\nthe missing return status;, added right after the success log and KdaMonDeleteDevice, which now takes a PDEVICE_OBJECT* and resets the caller\u0026rsquo;s pointer to NULL after deletion, as a safeguard against any future double-cleanup: void KdaMonDeleteDevice(_Inout_ PDEVICE_OBJECT* DeviceObject) { UNICODE_STRING symLink = RTL_CONSTANT_STRING(KDAMON_SYMLINK_NAME); IoDeleteSymbolicLink(\u0026amp;symLink); if (*DeviceObject != NULL) { IoDeleteDevice(*DeviceObject); *DeviceObject = NULL; } ... } Callers now pass \u0026amp;g_DeviceObject instead of g_DeviceObject.\nValidation # For this version, validation consists of checking that KDAMonitor\u0026rsquo;s provider and sublayer do appear in the WFP engine\u0026rsquo;s state after the driver loads, and disappear after it unloads. To do this, I used netsh wfp show state before, during, and after the driver\u0026rsquo;s lifecycle, using the following script (test_v07.ps1):\n# test_v07.ps1 $Driver = \u0026#34;KDAMonitor\u0026#34; $DriverPath = \u0026#34;$env:USERPROFILE\\Desktop\\$Driver.sys\u0026#34; netsh wfp show state file=wfpstate_before.xml sc.exe stop $Driver sc.exe delete $Driver sc.exe create $Driver type= kernel binPath= $DriverPath sc.exe start $Driver netsh wfp show state file=wfpstate_after_load.xml sc.exe stop $Driver netsh wfp show state file=wfpstate_after_unload.xml sc.exe delete $Driver Before loading, no KDAMonitor entry:\nAfter loading, the provider and sublayer do appear in the WFP engine\u0026rsquo;s state:\nAnd after unloading, no trace of either one remains:\nConclusion # In this version, the WFP session was opened, and KDAMonitor\u0026rsquo;s provider and sublayer registered, waiting for the first filter.\nAlthough this version wasn\u0026rsquo;t very exciting (a bit boring, honestly :)), it\u0026rsquo;s a necessary step toward the part I find most interesting: the network sensor.\nThanks for reading all the way through, and see you soon for the next, eighth article in this series: Monitoring Network Connections with the Windows Filtering Platform.\n","date":"21 September 2026","externalUrl":null,"permalink":"/en/posts/07-wfp-session/","section":"Blog","summary":"Setting up KDAMonitor’s WFP session (provider, sublayer).","title":"07 - Preparing Network Monitoring: Setting Up the WFP Session","type":"posts"},{"content":"Welcome to the sixth article in the series on building KDAMonitor!\nIn this article, I\u0026rsquo;ll cover version v0.6 of the project. In this version, I implement the project\u0026rsquo;s second sensor: the image load sensor.\nHere are the files involved in this article, and the section that covers each one:\nFile Role Section event_types.h New KDAMON_IMAGE_LOAD_EVENT_DATA type + union in KDAMON_EVENT What to capture when an image is loaded? image_callback.h Register/unregister declarations Implementing the callback image_callback.c The callback itself Implementing the callback log_writer.c JSONL serialization specific to image load events Serializing image load events to JSONL driver_entry.c Registering/unregistering the callback on load/unload Wiring it into driver_entry.c The project can be found in this repository: KDAMonitor.\nWhat to capture when an image is loaded? # When an image is loaded, the callback can retrieve a fair amount of information. I chose to keep the following, which felt like the most useful:\nthe ID of the process the image is mapped into (ProcessId) the base address and size of the mapping (ImageBase, ImageSize) the raw image properties (Properties) three flags extracted from those properties: SystemModeImage, ImageMappedToAllPids, ImagePartialMap the image\u0026rsquo;s signature level and type (SignatureLevel, SignatureType) the image\u0026rsquo;s full path (ImageFileName) Which gives the following structure in event_types.h:\ntypedef struct _KDAMON_IMAGE_LOAD_EVENT_DATA { HANDLE ProcessId; PVOID ImageBase; SIZE_T ImageSize; ULONG Properties; ULONG SystemModeImage; ULONG ImageMappedToAllPids; ULONG ImagePartialMap; ULONG SignatureLevel; ULONG SignatureType; WCHAR ImageFileName[260]; } KDAMON_IMAGE_LOAD_EVENT_DATA; The magic number 260 corresponds to the maximum path length on Windows and will be turned into a macro in a later version :)\nImplementing the callback # This part covers the driver-side implementation of the callback, all of it in image_callback.c. Just like the process sensor, there are three functions, two of which are exposed in the header. These two are strictly identical to the ones described in the previous article, apart from their names:\nNTSTATUS KdaMonImageCallbackRegister(VOID);: the function used in driver_entry.c to register the callback\nVOID KdaMonImageCallbackUnregister(VOID);: the function used in driver_entry.c to unregister the callback\nAnd the private function called on every image load:\nstatic VOID KdaMonImageNotifyRoutine( _In_opt_ PUNICODE_STRING FullImageName, _In_ HANDLE ProcessId, _In_ PIMAGE_INFO ImageInfo ); This function\u0026rsquo;s signature follows this shape:\nPLOAD_IMAGE_NOTIFY_ROUTINE LoadImageNotifyRoutine; VOID LoadImageNotifyRoutine( [in, optional] PUNICODE_STRING FullImageName, [in] HANDLE ProcessId, [in] PIMAGE_INFO ImageInfo ) {...} See the Microsoft documentation for PLOAD_IMAGE_NOTIFY_ROUTINE, the routine used by PsSetLoadImageNotifyRoutine.\nThe notification routine # Let\u0026rsquo;s start by defining the routine called on every image load. As shown above, this routine\u0026rsquo;s signature gives us three pieces of information:\nPUNICODE_STRING FullImageName: A pointer to the (Unicode) string containing the image\u0026rsquo;s name HANDLE ProcessId: The ID of the process the image is mapped into PIMAGE_INFO ImageInfo: A pointer to the IMAGE_INFO structure, which gives information about the image Here\u0026rsquo;s the documentation for IMAGE_INFO: IMAGE_INFO structure (filter.h).\nWe start by creating the event:\nKDAMON_EVENT Event = { 0 }; Event.Type = KdaMonEventImageLoad; KeQuerySystemTimePrecise(\u0026amp;Event.Timestamp); // ProcessId is already given in the function\u0026#39;s signature! Event.Data.ImageLoad.ProcessId = ProcessId; Unlike FullImageName, ImageInfo isn\u0026rsquo;t documented as optional (annotated _In_, not _In_opt_). We still keep a check before using it, as a precaution:\nif (ImageInfo) { Event.Data.ImageLoad.ImageBase = ImageInfo-\u0026gt;ImageBase; Event.Data.ImageLoad.ImageSize = ImageInfo-\u0026gt;ImageSize; Event.Data.ImageLoad.Properties = ImageInfo-\u0026gt;Properties; Event.Data.ImageLoad.SystemModeImage = ImageInfo-\u0026gt;SystemModeImage; Event.Data.ImageLoad.ImageMappedToAllPids = ImageInfo-\u0026gt;ImageMappedToAllPids; Event.Data.ImageLoad.ImagePartialMap = ImageInfo-\u0026gt;ImagePartialMap; Event.Data.ImageLoad.SignatureLevel = ImageInfo-\u0026gt;ImageSignatureLevel; Event.Data.ImageLoad.SignatureType = ImageInfo-\u0026gt;ImageSignatureType; } FullImageName, on the other hand, is explicitly documented as optional: it needs to be checked before use, in case it\u0026rsquo;s NULL or points to an empty buffer. The copy follows the same safe-truncation approach as ImageFileName in article 05. Finally, we push the event onto the queue:\nif (FullImageName \u0026amp;\u0026amp; FullImageName-\u0026gt;Buffer != NULL) { SIZE_T MaxCopyLength = sizeof(Event.Data.ImageLoad.ImageFileName) - sizeof(WCHAR); SIZE_T ImageFileNameLength = (FullImageName-\u0026gt;Length \u0026lt; MaxCopyLength) ? FullImageName-\u0026gt;Length : MaxCopyLength; RtlCopyMemory(Event.Data.ImageLoad.ImageFileName, FullImageName-\u0026gt;Buffer, ImageFileNameLength); Event.Data.ImageLoad.ImageFileName[ImageFileNameLength / sizeof(WCHAR)] = L\u0026#39;\\0\u0026#39;; } else { Event.Data.ImageLoad.ImageFileName[0] = L\u0026#39;\\0\u0026#39;; } KdaMonEventQueuePush(\u0026amp;Event); Here\u0026rsquo;s the complete code for KdaMonImageNotifyRoutine:\nstatic VOID KdaMonImageNotifyRoutine( _In_opt_ PUNICODE_STRING FullImageName, _In_ HANDLE ProcessId, _In_ PIMAGE_INFO ImageInfo ) { KDAMON_EVENT Event = { 0 }; Event.Type = KdaMonEventImageLoad; KeQuerySystemTimePrecise(\u0026amp;Event.Timestamp); Event.Data.ImageLoad.ProcessId = ProcessId; if (ImageInfo) { Event.Data.ImageLoad.ImageBase = ImageInfo-\u0026gt;ImageBase; Event.Data.ImageLoad.ImageSize = ImageInfo-\u0026gt;ImageSize; Event.Data.ImageLoad.Properties = ImageInfo-\u0026gt;Properties; Event.Data.ImageLoad.SystemModeImage = ImageInfo-\u0026gt;SystemModeImage; Event.Data.ImageLoad.ImageMappedToAllPids = ImageInfo-\u0026gt;ImageMappedToAllPids; Event.Data.ImageLoad.ImagePartialMap = ImageInfo-\u0026gt;ImagePartialMap; Event.Data.ImageLoad.SignatureLevel = ImageInfo-\u0026gt;ImageSignatureLevel; Event.Data.ImageLoad.SignatureType = ImageInfo-\u0026gt;ImageSignatureType; } if (FullImageName \u0026amp;\u0026amp; FullImageName-\u0026gt;Buffer != NULL) { SIZE_T MaxCopyLength = sizeof(Event.Data.ImageLoad.ImageFileName) - sizeof(WCHAR); SIZE_T ImageFileNameLength = (FullImageName-\u0026gt;Length \u0026lt; MaxCopyLength) ? FullImageName-\u0026gt;Length : MaxCopyLength; RtlCopyMemory(Event.Data.ImageLoad.ImageFileName, FullImageName-\u0026gt;Buffer, ImageFileNameLength); Event.Data.ImageLoad.ImageFileName[ImageFileNameLength / sizeof(WCHAR)] = L\u0026#39;\\0\u0026#39;; } else { Event.Data.ImageLoad.ImageFileName[0] = L\u0026#39;\\0\u0026#39;; } KdaMonEventQueuePush(\u0026amp;Event); } Registering and unregistering the callback # For this callback, we use PsSetLoadImageNotifyRoutine to register it, and PsRemoveLoadImageNotifyRoutine to unregister it:\nNTSTATUS KdaMonImageCallbackRegister(VOID) { NTSTATUS status = PsSetLoadImageNotifyRoutine(KdaMonImageNotifyRoutine); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34;[ERROR] PsSetLoadImageNotifyRoutine failed: 0x%08X\\n\u0026#34;, status)); } return status; } VOID KdaMonImageCallbackUnregister(VOID) { NTSTATUS status = PsRemoveLoadImageNotifyRoutine(KdaMonImageNotifyRoutine); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34;[ERROR] PsRemoveLoadImageNotifyRoutine failed: 0x%08X\\n\u0026#34;, status)); } } Serializing image load events to JSONL # Here\u0026rsquo;s the function dedicated to serializing image load events:\nstatic NTSTATUS KdaMonLogWriterWriteImageEvent(_In_ const KDAMON_EVENT* Event, _Out_writes_z_(BufferSize) PSTR EventBuffer, _In_ SIZE_T BufferSize) { CHAR EscapedImage[520]; if (!KdaMonJsonEscapeW(Event-\u0026gt;Data.ImageLoad.ImageFileName, EscapedImage, sizeof(EscapedImage))) { KdPrint((DRIVER_TAG \u0026#34; [WARNING]: Image path truncated during JSON escape (event %lu)\\n\u0026#34;, Event-\u0026gt;Id)); } return RtlStringCbPrintfA( EventBuffer, BufferSize, \u0026#34;{\\\u0026#34;id\\\u0026#34;:%lu,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;timestamp\\\u0026#34;:%lld,\u0026#34; \u0026#34;\\\u0026#34;pid\\\u0026#34;:%lu,\\\u0026#34;image_base\\\u0026#34;:\\\u0026#34;%p\\\u0026#34;,\\\u0026#34;image_size\\\u0026#34;:%llu,\u0026#34; \u0026#34;\\\u0026#34;system_mode_image\\\u0026#34;:%s,\\\u0026#34;image_mapped_to_all_pids\\\u0026#34;:%s,\u0026#34; \u0026#34;\\\u0026#34;image_partial_map\\\u0026#34;:%s,\\\u0026#34;signature_level\\\u0026#34;:%u,\\\u0026#34;signature_type\\\u0026#34;:%u,\u0026#34; \u0026#34;\\\u0026#34;image\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;}\\n\u0026#34;, Event-\u0026gt;Id, KdaMonEventTypeToString(Event-\u0026gt;Type), Event-\u0026gt;Timestamp.QuadPart, (ULONG)(ULONG_PTR)Event-\u0026gt;Data.ImageLoad.ProcessId, Event-\u0026gt;Data.ImageLoad.ImageBase, (unsigned long long)Event-\u0026gt;Data.ImageLoad.ImageSize, Event-\u0026gt;Data.ImageLoad.SystemModeImage ? \u0026#34;true\u0026#34; : \u0026#34;false\u0026#34;, Event-\u0026gt;Data.ImageLoad.ImageMappedToAllPids ? \u0026#34;true\u0026#34; : \u0026#34;false\u0026#34;, Event-\u0026gt;Data.ImageLoad.ImagePartialMap ? \u0026#34;true\u0026#34; : \u0026#34;false\u0026#34;, Event-\u0026gt;Data.ImageLoad.SignatureLevel, Event-\u0026gt;Data.ImageLoad.SignatureType, EscapedImage ); } Here\u0026rsquo;s, briefly, what the function does:\nIt escapes the special characters in the image path using the KdaMonJsonEscapeW function. This function won\u0026rsquo;t be detailed here for the sake of simplicity. It can still be checked out in log_writer.c.\nIt fills the EventBuffer buffer with the information gathered from the event, in JSON format. An image load event line will look like this:\n{\u0026#34;id\u0026#34;:137,\u0026#34;type\u0026#34;:\u0026#34;image_load\u0026#34;,\u0026#34;timestamp\u0026#34;:134025123456789012,\u0026#34;pid\u0026#34;:1234,\u0026#34;image_base\u0026#34;:\u0026#34;0x00007FFA12340000\u0026#34;,\u0026#34;image_size\u0026#34;:45056,\u0026#34;system_mode_image\u0026#34;:false,\u0026#34;image_mapped_to_all_pids\u0026#34;:false,\u0026#34;image_partial_map\u0026#34;:false,\u0026#34;signature_level\u0026#34;:8,\u0026#34;signature_type\u0026#34;:1,\u0026#34;image\u0026#34;:\u0026#34;C:\\\\PATH\\\\TO\\\\DLL.dll\u0026#34;} Wiring it into driver_entry.c # We start by adding the unregister call in DriverUnload:\nvoid DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdaMonImageCallbackUnregister(); // Unregistering the callback KdaMonProcessCallbackUnregister(); KdaMonLogWriterStop(); KdaMonEventQueueDestroy(); KdaMonDeleteDevice(g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Driver Unload called\\n\u0026#34;)); } For now, the image load callback is the last one to be registered, so it\u0026rsquo;s the first one to be unregistered.\nIn DriverEntry, we add the registration at the end of the function, right after the process callback\u0026rsquo;s registration:\nNTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); ... // --- Register process creation callback --- if (!NT_SUCCESS(KdaMonProcessCallbackRegister())) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonProcessCallbackRegister failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } // --- Register image load callback --- if (!NT_SUCCESS(KdaMonImageCallbackRegister())) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonImageCallbackRegister failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); return STATUS_SUCCESS; } For this version, the validation test is to check that the sensor properly captures the image loads of a regular process (its dependency DLLs), as well as a single, easily identifiable DLL load. For this, I wrote the following script (test_v06.ps1):\n# test_v06.ps1 $Driver = \u0026#34;KDAMonitor\u0026#34; $DriverPath = \u0026#34;$env:USERPROFILE\\Desktop\\$Driver.sys\u0026#34; sc.exe stop $Driver sc.exe delete $Driver sc.exe create $Driver type= kernel binPath= $DriverPath sc.exe start $Driver Start-Process notepad.exe -Wait rundll32.exe user32.dll,MessageBeep sc.exe stop $Driver sc.exe delete $Driver Here\u0026rsquo;s a demo of this test in action:\nConclusion # A second sensor down! Not much new in this article, it\u0026rsquo;s pretty close to the previous one, but the next one will be quite different :).\nThanks for reading all the way through, see you for the next, seventh article in this series: Getting Ready for Network Monitoring: Setting Up a WFP Session.\n","date":"17 September 2026","externalUrl":null,"permalink":"/en/posts/06-image-sensor/","section":"Blog","summary":"Implementing KDAMonitor’s second sensor: monitoring image loads.","title":"06 - The Second Sensor: Tracking Image and DLL Loads","type":"posts"},{"content":"","date":"15 September 2026","externalUrl":"https://github.com/HalfTimeOfLife/mirai-arm64-analysis","permalink":"/en/projects/mirai-arm64-analysis/","section":"Projects","summary":"Static and dynamic analysis of an ARM64 ELF sample from the Mirai/Gafgyt family.","title":"ARM64 Mirai Malware Analysis","type":"projects"},{"content":"","date":"15 September 2026","externalUrl":null,"permalink":"/en/tags/ghidra/","section":"Tags","summary":"","title":"Ghidra","type":"tags"},{"content":"","date":"15 September 2026","externalUrl":null,"permalink":"/en/tags/malware-analysis/","section":"Tags","summary":"","title":"Malware Analysis","type":"tags"},{"content":"","date":"15 September 2026","externalUrl":null,"permalink":"/en/tags/mirai/","section":"Tags","summary":"","title":"Mirai","type":"tags"},{"content":"Tools, analyses and published work.\n","date":"15 September 2026","externalUrl":null,"permalink":"/en/projects/","section":"Projects","summary":"","title":"Projects","type":"projects"},{"content":"","date":"15 September 2026","externalUrl":null,"permalink":"/en/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":"Welcome to the second article of the series on developing my AArch64 bare-metal kernel!\nIn this article, I\u0026rsquo;m going to present version v0.1 of this project. The goal of this version is to build a basic kernel that will contain:\nthe linker the boot and the UART driver code By the end, the kernel should boot correctly in QEMU and print a simple string, Hello, AArch64!.\nHere are the files involved in this article, and the section that explains each one:\nFile Role Section linker.ld Linker script: where the code sits in memory, defines _stack_bottom and _stack_top The linker script src/boot/boot.s Kernel entry point (_start), stack initialization Boot: initializing the stack src/uart/uart.s Minimal UART driver: uart_putc and uart_puts The UART driver Makefile Compiles the assembly files and generates kernel.elf Building and running the kernel The project can be found in this repository: aarch64-baremetal-kernel.\nQEMU virt and basic AArch64 concepts # What is QEMU virt? # First, let me quickly explain what QEMU and the virt machine are.\nQEMU is an emulator that lets us run our AArch64 kernel without owning any ARM hardware.\nThe virt machine is a generic virtual platform designed to run guest systems. It lets us bypass the constraints of any specific hardware.\nSimplified memory layout # Here\u0026rsquo;s the simplified memory layout relevant to this project:\n_______________ 0x00000000 | Flash | |_______________| | | | | | | | | | | |_______________|0x40000000 | RAM | |_______________| Registers and instructions used in this article # In this section, I\u0026rsquo;m just listing the instructions and registers used; they\u0026rsquo;ll be explained in more detail as the code is walked through.\nInstructions # Instruction Type Role ldr Load Loads a value from memory, or loads an address with ldr xN, =... ldrb Load Loads 1 byte from memory str Store Writes a value to memory stp Store Pair Saves two registers to memory ldp Load Pair Restores two registers from memory mov Data movement Copies a value from one register to another tst Test Performs a logical AND and updates the flags b Branch Performs an unconditional jump b.ne Conditional branch Jumps if the previous result was not zero cbz Conditional branch Jumps if a register equals zero bl Branch with Link Calls a function and saves the return address in x30 ret Return Returns to the address held in x30 Registers # Register Usage in the code x0 / w0 Function argument / character sent to the UART x1 UART base address w2 Content of the UART_FR register x19 Pointer to the character string x30 Link Register (LR), return address after bl sp Stack Pointer The linker script # Before executing a single instruction, the CPU needs to be told where our code lives in memory. That\u0026rsquo;s the job of the linker script, written in the linker command language.\nSee: Linker Scripts\nWhy a custom linker script? # Without a custom linker script, ld would use its default script, designed for a hosted system. The problem in our case is that it has no idea where RAM sits on the QEMU virt machine — the code would end up placed anywhere, potentially at an address the CPU can\u0026rsquo;t even execute at boot.\nHere\u0026rsquo;s the linker script used:\nENTRY(_start) SECTIONS { . = 0x40000000; .text : { *(.text*) } .rodata : { *(.rodata*) } .data : { *(.data*) } .bss : { *(.bss*) } . = ALIGN(16); .stack : { _stack_bottom = .; . += 0x10000; _stack_top = .; } } So we need to explicitly tell it where to place our code. According to the QEMU virt machine documentation, RAM starts at address 0x40000000. That\u0026rsquo;s why the linker script starts with . = 0x40000000;.\n. represents the current memory address.\nSee: ‘virt’ generic virtual platform (virt) - Hardware configuration information for bare-metal programming\nSections and alignment # Once the starting address is fixed, the linker script organizes the binary into several sections:\n.text: the executable code .rodata: read-only data .data: initialized data .bss: uninitialized data Each block uses a * character, for example *(.text*) for .text. This is necessary because source files don\u0026rsquo;t always declare a plain .text section. For example boot.s uses .section .text.boot. The * after .text groups every sub-section whose name starts with .text into a single final section, regardless of the source file it came from.\nRight before .stack, there\u0026rsquo;s . = ALIGN(16);. The AArch64 calling convention requires the stack pointer (sp) to always be aligned on 16 bytes. By explicitly aligning the linker\u0026rsquo;s cursor before defining the stack area, _stack_top is guaranteed to respect this constraint from the very first instruction in boot.s.\n_stack_bottom and _stack_top # The .stack : { ... } block of the linker script defines the memory area reserved for the stack. It first places the _stack_bottom symbol at the current address (the bottom of the stack), then moves the linker\u0026rsquo;s \u0026ldquo;cursor\u0026rdquo; forward by 0x10000 (64 KB) with . += 0x10000;, before placing _stack_top at the new position (the top of the stack).\nThe 64 KB size is completely arbitrary, but plenty for this stage of the project.\nBoot: initializing the stack # Once the linker script is in place, we can write the very first code executed by the CPU at boot: _start.\n.section .text.boot .global _start _start: ldr x0, =_stack_top mov sp, x0 b . .section .text.boot places this code in a sub-section of .text, which lets it be picked up by the *(.text*) from the linker script seen earlier. .global _start makes the symbol visible outside this file, which is necessary since the linker script references this same symbol through ENTRY(_start). The ENTRY() command tells the CPU where to start.\nldr x0, =_stack_top / mov sp, x0 # These two instructions initialize the stack:\nldr x0, =_stack_top mov sp, x0 ldr x0, =_stack_top loads into x0 the address of the _stack_top symbol, defined by the linker script (the top of the reserved stack area). mov sp, x0 then copies that address into the sp register, the stack pointer.\nWhy this exact form? # Why ldr x0, =_stack_top and not a plain mov? The mov instruction with an immediate value can only encode a limited number of bits directly in the instruction. Since _stack_top\u0026rsquo;s address is a full 64-bit address, it doesn\u0026rsquo;t always fit in that space. The ldr x0, =... pseudo-instruction asks the assembler to generate whatever code is needed to load the full address.\nWhy go through x0 instead of writing directly to sp? The AArch64 instruction set doesn\u0026rsquo;t allow sp as a destination register for this form of ldr. So x0 is used as an intermediate.\nThe UART driver # Now that our kernel boots, it\u0026rsquo;s time to do something with it. The goal now is to let our kernel print characters to the screen. For that we\u0026rsquo;ll use a UART.\nAll the code described in this part can be found in src/uart/uart.s.\nWhat is UART, and why MMIO? # First, a UART (Universal Asynchronous Receiver Transmitter) is a hardware peripheral used for serial communication.\nMMIO (Memory-mapped I/O) means the UART\u0026rsquo;s registers are mapped to specific memory addresses. So reading and writing at those addresses lets us talk directly to the hardware. This means we can use the ldr and str instructions directly.\nUART_BASE, UART_FR, UART_DR # The UART\u0026rsquo;s base address (UART_BASE) is 0x09000000, fixed by QEMU virt. UART_FR is the flag register, in other words the device\u0026rsquo;s status: if the TXFF bit (mask 0x20) is set in this register, then the transmit FIFO is full and we can\u0026rsquo;t send data to the device yet. It\u0026rsquo;s at offset 0x18.\nUART_DR is the data register, in other words where the device receives data — this is where we\u0026rsquo;re going to write a character. It\u0026rsquo;s at offset 0x00.\nSo we\u0026rsquo;ll add this to our file:\n.equ UART_BASE, 0x09000000 .equ UART_FR, 0x18 .equ UART_DR, 0x00 .equ UART_TXFF, 0x20 uart_putc # First, we\u0026rsquo;re going to write a single character. For that we build a wait: loop that loads UART_FR\u0026rsquo;s content into w2, tests the TXFF bit with tst, and loops back while the FIFO is full (b.ne wait).\nOnce the FIFO is free, we write the character to UART_DR (str w0, [x1, #UART_DR]); per the AArch64 calling convention, x0 is the first argument, and w0 is its lower half (the first 32 bits).\nFinally, we return to the caller with ret.\nHere\u0026rsquo;s the complete function:\nuart_putc: ldr x1, =UART_BASE wait: ldr w2, [x1, #UART_FR] tst w2, #UART_TXFF b.ne wait str w0, [x1, #UART_DR] ret uart_puts # Now we need to be able to print a whole string. So we\u0026rsquo;ll write a uart_puts function that receives a pointer to a null-terminated string in x0 and loops over that string, calling uart_putc for each character.\nHere\u0026rsquo;s the function\u0026rsquo;s code:\nuart_puts: stp x19, x30, [sp, #-16]! mov x19, x0 loop: ldrb w0, [x19], #1 cbz w0, done bl uart_putc b loop done: ldp x19, x30, [sp], #16 ret First, we save x19 and x30 on the stack (see Why save x19?). We copy x0\u0026rsquo;s content into x19, then load the current character from x19 into w0, and increment x19 by one byte.\nFor each character, we check whether it\u0026rsquo;s the string\u0026rsquo;s terminator: cbz w0, done. If so, we exit the loop (branch to done); otherwise we call uart_putc on the character and go back to the start of the loop.\nOnce the whole string has been printed, we restore x19 and x30 and return to the caller with ret.\nWhy save x19? # In AArch64, x19 is a callee-saved register, so uart_puts must save it before using it, since uart_putc could modify it, then restore it before returning. The stp x19, x30, [sp, #-16]! instruction saves both x19 and the return address x30 in 16 bytes, while respecting the stack\u0026rsquo;s 16-byte alignment.\nBuilding and running the kernel # Now that the code is written, it\u0026rsquo;s time to build and run the kernel.\nThe Makefile # I won\u0026rsquo;t go through the whole Makefile here, just the important parts.\nThe project\u0026rsquo;s complete Makefile can be found in the repository: aarch64-baremetal-kernel.\nLet\u0026rsquo;s go through this bit of the Makefile:\nSRC := $(wildcard src/*/*.s) OUTPUT_DIR := build OBJ := $(patsubst src/%.s,$(OUTPUT_DIR)/%.o,$(SRC)) $(OUTPUT_DIR)/%.o: src/%.s mkdir -p $(@D) $(AS) -c $\u0026lt; -o $@ $(wildcard src/*/*.s): looks for every .s file in any subfolder of src/ (so src/boot/boot.s, src/uart/uart.s, etc.) $(patsubst src/%.s,$(OUTPUT_DIR)/%.o,$(SRC)): rebuilds the same path under build/ for each matching object file mkdir -p $(@D) creates the necessary subfolders under build/ on the fly The Makefile will therefore create the following tree:\n├── Makefile └── build/ ├── boot/ │ └── boot.o ├── uart/ │ └── uart.o └── kernel.elf Checking symbols with nm # Before running the kernel with QEMU, we check that the binary contains what we expect. For that, we use aarch64-none-elf-nm build/kernel.elf, which lets us confirm the symbols exist and are resolved — we should find the following symbols:\n_start uart_putc uart_puts _stack_bottom _stack_top objdump -d build/kernel.elf can also be used to see the binary\u0026rsquo;s disassembly.\nRunning with QEMU # Here\u0026rsquo;s the complete command to run our kernel with QEMU:\nqemu-system-aarch64 \\ -M virt \\ -cpu cortex-a57 \\ -nographic \\ -kernel build/kernel.elf Here\u0026rsquo;s what each option does:\n-M virt: use the virt virtual machine -cpu cortex-a57: the emulated AArch64 CPU model -nographic: no graphical window, everything goes through the terminal (UART redirected to stdout) -kernel build/kernel.elf: loads our ELF directly Result # Here\u0026rsquo;s what we get:\nConclusion # By the end of this first version, the project now has a linker script that places the code exactly where the CPU expects to find it, a correctly initialized stack, and a first working hardware driver.\nThanks for reading all the way through, and see you in the next article: Taming exceptions: building the vector table.\n","date":"11 September 2026","externalUrl":null,"permalink":"/en/posts/02-boot-uart/","section":"Blog","summary":"Set up the linker script, boot code, and the first UART driver.","title":"02 - First boot: linker script, stack and first UART message","type":"posts"},{"content":"Welcome to this new series on my blog! It\u0026rsquo;ll serve as a development journal for a small bare-metal AArch64 kernel, written entirely in assembly and running on QEMU\u0026rsquo;s virt machine.\nThe project can be found in this repository: aarch64-baremetal-kernel.\nWhat is this project about? # This project is supposed to be simple, I have to build a small kernel capable of:\nbooting on a virtual AArch64 board (QEMU virt) talking to the outside world through UART handling exceptions and interrupts setting up a timer and multitasking and, eventually, looking like a (very small) working operating system I\u0026rsquo;m keeping my expectations in check about this operating system. I started this project without really knowing where it\u0026rsquo;s going to end up :)\nOn top of that, I\u0026rsquo;m only going to use assembly (no C).\nWhy this project? # The main reason that pushed me to start this project is to learn ARM assembly language as well as to understand in depth the AArch64 architecture and how it works at a low level (registers, exception levels, MMU, etc.).\nNote that v0.1 is already done at the time I\u0026rsquo;m writing this article: the kernel boots and already prints a message over UART.\nTarget environment # Emulator QEMU Machine virt Architecture AArch64 (ARMv8-A) CPU cortex-a57 Language AArch64 assembly Toolchain aarch64-none-elf QEMU virt exposes a minimal but sufficient board for this project: some RAM, an AArch64 CPU, and a PL011-compatible UART, mapped in MMIO at address 0x09000000.\nPrerequisites # To follow this series comfortably, I\u0026rsquo;d recommend already having some basic notions of assembly (any architecture works). No prior knowledge of AArch64 is required: I\u0026rsquo;m starting from scratch myself and I\u0026rsquo;ll explain every instruction used as we go.\nDetailed development plan # Here\u0026rsquo;s the current roadmap of the project, version by version:\nVersion Feature v0.1 Boot and UART v0.2 Exceptions and interrupts v0.3 ARM timer v0.4 Memory management and MMU v0.5 Exception levels and user mode v0.6 Multitasking and scheduler v0.7 Drivers and peripherals v1.0 Minimal operating system Upcoming articles # Here are, in order, the articles planned for this series:\nArticle Version(s) Title Main content 01 - Introduction Presentation of the project, its goals and its target environment. 02 v0.1 First boot: linker script, stack and first UART message Setting up the linker script, the boot code, stack initialization and the first UART driver to print a message. 03 v0.2 Exceptions: building the vector table Building the exception vector table, configuring VBAR_EL1, handling synchronous exceptions and IRQs. 04 v0.3 Setting up the ARM timer Configuring the ARM generic timer, setting up periodic interrupts and the basics of timekeeping. 05 v0.4 Managing physical and virtual memory Setting up a simple physical allocator, page tables, MMU configuration and the switch to virtual addressing. 06 v0.5 First program in user mode Detecting and handling exception levels, transitioning to EL0 and running the first user-mode program. 07 v0.6 Running several tasks Context save/restore, task structure, round-robin scheduler and running multiple kernel tasks. 08 v0.7 New drivers and peripherals Improving the UART driver, adding GPIO support and additional peripherals from the virt machine. 09 v1.0 Wrap-up Consolidating all the previous components into a small usable operating system. Thanks in advance to everyone who follows this series. If you have questions, feedback, or just want to chat about the project, feel free to reach out.\nSee you in the next article, where we\u0026rsquo;ll really dive into the code: boot and the first UART driver!\n","date":"8 September 2026","externalUrl":null,"permalink":"/en/posts/01-introduction-aarch64-kernel/","section":"Blog","summary":"Introduction to the AArch64 bare-metal kernel project and its goals.","title":"01 - Introducing my AArch64 bare-metal kernel on QEMU virt","type":"posts"},{"content":"Welcome to the fifth article in the KDAMonitor development series!\nIn this article, I\u0026rsquo;ll cover version v0.5 of the project. In this version, I implement the first of the project\u0026rsquo;s 5 sensors/callbacks: the process sensor.\nHere are the files involved in this article, and the section that explains each one:\nFile Role Section event_types.h New KDAMON_PROCESS_EVENT_DATA type + union in KDAMON_EVENT What to capture on process creation or termination? process_callback.h Register/unregister declarations Implementing the callback process_callback.c The callback itself + creation/termination logic Implementing the callback log_writer.c JSONL serialization specific to process events Serializing process events to JSONL driver_entry.c Registering/unregistering the callback on load/unload Wiring it into driver_entry.c The project can be found in this repository: KDAMonitor.\nWhat to capture on process creation or termination? # As explained in article 04, the driver uses a generic structure for events, with a union that provides the information specific to each event type. Each event\u0026rsquo;s structure lives in event_types.h and follows this shape:\ntypedef struct _KDAMON_\u0026lt;TYPE_OF_EVENT\u0026gt;_EVENT_DATA { // The event\u0026#39;s data } KDAMON_\u0026lt;TYPE_OF_EVENT\u0026gt;_EVENT_DATA; What\u0026rsquo;s actually worth capturing about a process event?\nI chose to keep four pieces of information:\nthe process ID (ProcessId): a HANDLE for the process being created or terminated the parent process ID (ParentProcessId): a HANDLE for the process that created or terminated the current one the process image name (ImageFileName): a string (of WCHAR) containing the full image name of the current process the process status (IsCreate): a BOOLEAN telling whether the process is being created (TRUE) or terminated (FALSE) These four fields are enough to describe and distinguish a process.\nHere\u0026rsquo;s the resulting structure representing a process event in the driver:\ntypedef struct _KDAMON_PROCESS_EVENT_DATA { HANDLE ProcessId; HANDLE ParentProcessId; BOOLEAN IsCreate; WCHAR ImageFileName[260]; } KDAMON_PROCESS_EVENT_DATA; The magic number 260 corresponds to Windows\u0026rsquo; maximum path length, and will be turned into a proper macro in a later version :)\nWhat is a callback? # In article 2, I implemented the client and its communication with the driver. Here, though, what we\u0026rsquo;re after is different: we want the driver to automatically detect certain events on its own — in this case, process creation and termination.\nTo do this, the Windows kernel provides callbacks. The principle is simple: you give the kernel a pointer to a function, asking it to run that function automatically whenever a certain condition occurs.\nFor process creation/termination, the dedicated function is PsSetCreateProcessNotifyRoutineEx, which will be covered in detail in the next section.\nImplementing the callback # This section covers the driver\u0026rsquo;s callback implementation, entirely contained in process_callback.c. There are three functions in this file, two of which are exposed in the header. Let\u0026rsquo;s start with those two:\nNTSTATUS KdaMonProcessCallbackRegister(VOID);: the function called from driver_entry.c to register the callback\nVOID KdaMonProcessCallbackUnregister(VOID);: the function called from driver_entry.c to unregister the callback\nAnd the private function invoked whenever a process is created or terminated:\nstatic VOID KdaMonProcessNotifyRoutine( _Inout_ PEPROCESS Process, _In_ HANDLE ProcessId, _Inout_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo ); This function\u0026rsquo;s signature follows this shape:\nPCREATE_PROCESS_NOTIFY_ROUTINE_EX PcreateProcessNotifyRoutineEx; VOID PcreateProcessNotifyRoutineEx( [_Inout_] PEPROCESS Process, [in] HANDLE ProcessId, [in, out, optional] PPS_CREATE_NOTIFY_INFO CreateInfo ) {...} See the Microsoft documentation for PCREATE_PROCESS_NOTIFY_ROUTINE_EX, the routine type used by PsSetCreateProcessNotifyRoutineEx\nThe notification routine # First, we need to define the routine called on process creation and termination. Its signature gives us three pieces of information:\nPEPROCESS Process: a pointer to the structure representing the process HANDLE ProcessId: the process ID PPS_CREATE_NOTIFY_INFO CreateInfo: a pointer to the PS_CREATE_NOTIFY_INFO structure, which gives information about the process CreateInfo is only populated when the process is created: on termination, this pointer is simply NULL.\nAt the start of this routine, we need to build the event of type KdaMonEventProcess:\nKDAMON_EVENT Event = { 0 }; Event.Type = KdaMonEventProcess; KeQuerySystemTimePrecise(\u0026amp;Event.Timestamp); // ProcessId is already given in the function signature! Event.Data.Process.ProcessId = ProcessId; We then need to distinguish two cases: process creation and process termination.\nProcess creation # On process creation, we can fill in the entire event structure (KDAMON_EVENT Event). We just need to pull the available information from the structures passed as arguments:\nif (CreateInfo) { // --- Process creation case --- Event.Data.Process.ParentProcessId = CreateInfo-\u0026gt;ParentProcessId; Event.Data.Process.IsCreate = TRUE; PCUNICODE_STRING ImageFileName = CreateInfo-\u0026gt;ImageFileName; if (ImageFileName \u0026amp;\u0026amp; ImageFileName-\u0026gt;Buffer != NULL) { SIZE_T MaxCopyLength = sizeof(Event.Data.Process.ImageFileName) - sizeof(WCHAR); SIZE_T ImageFileNameLength = (ImageFileName-\u0026gt;Length \u0026lt; MaxCopyLength) ? ImageFileName-\u0026gt;Length : MaxCopyLength; RtlCopyMemory(Event.Data.Process.ImageFileName, ImageFileName-\u0026gt;Buffer, ImageFileNameLength); Event.Data.Process.ImageFileName[ImageFileNameLength / sizeof(WCHAR)] = L\u0026#39;\\0\u0026#39;; } } Process termination # On process termination, the only thing we can get is the ProcessId, so IsCreate is set to FALSE. In code:\nelse { // --- Process termination case --- Event.Data.Process.ParentProcessId = NULL; Event.Data.Process.IsCreate = FALSE; Event.Data.Process.ImageFileName[0] = L\u0026#39;\\0\u0026#39;; } In both cases, once the Event structure is filled in, it\u0026rsquo;s pushed to the queue with KdaMonEventQueuePush(\u0026amp;Event);.\nFull code for KdaMonProcessNotifyRoutine # static VOID KdaMonProcessNotifyRoutine(_Inout_ PEPROCESS Process, _In_ HANDLE ProcessId, _Inout_opt_ PPS_CREATE_NOTIFY_INFO CreateInfo) { UNREFERENCED_PARAMETER(Process); KDAMON_EVENT Event = { 0 }; Event.Type = KdaMonEventProcess; KeQuerySystemTimePrecise(\u0026amp;Event.Timestamp); Event.Data.Process.ProcessId = ProcessId; if (CreateInfo) { // --- Process creation case --- Event.Data.Process.ParentProcessId = CreateInfo-\u0026gt;ParentProcessId; Event.Data.Process.IsCreate = TRUE; PCUNICODE_STRING ImageFileName = CreateInfo-\u0026gt;ImageFileName; if (ImageFileName \u0026amp;\u0026amp; ImageFileName-\u0026gt;Buffer != NULL) { SIZE_T MaxCopyLength = sizeof(Event.Data.Process.ImageFileName) - sizeof(WCHAR); SIZE_T ImageFileNameLength = (ImageFileName-\u0026gt;Length \u0026lt; MaxCopyLength) ? ImageFileName-\u0026gt;Length : MaxCopyLength; RtlCopyMemory(Event.Data.Process.ImageFileName, ImageFileName-\u0026gt;Buffer, ImageFileNameLength); Event.Data.Process.ImageFileName[ImageFileNameLength / sizeof(WCHAR)] = L\u0026#39;\\0\u0026#39;; } } else { // --- Process termination case --- Event.Data.Process.ParentProcessId = NULL; Event.Data.Process.IsCreate = FALSE; Event.Data.Process.ImageFileName[0] = L\u0026#39;\\0\u0026#39;; } KdaMonEventQueuePush(\u0026amp;Event); } Registering and unregistering the callback # As explained in What is a callback?, PsSetCreateProcessNotifyRoutineEx is used both to register and to unregister the callback (the Remove parameter simply flips the direction of the call):\nNTSTATUS KdaMonProcessCallbackRegister(VOID) { NTSTATUS status = PsSetCreateProcessNotifyRoutineEx(KdaMonProcessNotifyRoutine, FALSE); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34;[ERROR] PsSetCreateProcessNotifyRoutineEx failed: 0x%08X\\n\u0026#34;, status)); } return status; } VOID KdaMonProcessCallbackUnregister(VOID) { NTSTATUS status = PsSetCreateProcessNotifyRoutineEx(KdaMonProcessNotifyRoutine, TRUE); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34;[ERROR] PsSetCreateProcessNotifyRoutineEx failed: 0x%08X\\n\u0026#34;, status)); } } Serializing process events to JSONL # As explained in article 04, each event type has its own JSONL serialization function. Here\u0026rsquo;s the one for process events:\nstatic NTSTATUS KdaMonLogWriterWriteProcessEvent(_In_ const KDAMON_EVENT* Event, _Out_writes_z_(BufferSize) PSTR EventBuffer, _In_ SIZE_T BufferSize) { CHAR EscapedImage[520]; CHAR PpidField[16]; if (!KdaMonJsonEscapeW(Event-\u0026gt;Data.Process.ImageFileName, EscapedImage, sizeof(EscapedImage))) { KdPrint((DRIVER_TAG \u0026#34; [WARNING]: Image path truncated during JSON escape (event %lu)\\n\u0026#34;, Event-\u0026gt;Id)); } if (Event-\u0026gt;Data.Process.IsCreate \u0026amp;\u0026amp; Event-\u0026gt;Data.Process.ParentProcessId != NULL) { RtlStringCbPrintfA(PpidField, sizeof(PpidField), \u0026#34;%lu\u0026#34;, (ULONG)(ULONG_PTR)Event-\u0026gt;Data.Process.ParentProcessId); } else { RtlStringCbCopyA(PpidField, sizeof(PpidField), \u0026#34;null\u0026#34;); } return RtlStringCbPrintfA( EventBuffer, BufferSize, \u0026#34;{\\\u0026#34;id\\\u0026#34;:%lu,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;timestamp\\\u0026#34;:%lld,\u0026#34; \u0026#34;\\\u0026#34;pid\\\u0026#34;:%lu,\\\u0026#34;ppid\\\u0026#34;:%s,\\\u0026#34;is_create\\\u0026#34;:%s,\\\u0026#34;image\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;}\\n\u0026#34;, Event-\u0026gt;Id, KdaMonEventTypeToString(Event-\u0026gt;Type), Event-\u0026gt;Timestamp.QuadPart, (ULONG)(ULONG_PTR)Event-\u0026gt;Data.Process.ProcessId, PpidField, Event-\u0026gt;Data.Process.IsCreate ? \u0026#34;true\u0026#34; : \u0026#34;false\u0026#34;, EscapedImage ); } Here\u0026rsquo;s what this function does, step by step:\nIt escapes special characters using the KdaMonJsonEscapeW function This function won\u0026rsquo;t be covered in detail here for the sake of simplicity. It can, however, be found in the log_writer.c source file.\nIt writes the ParentProcessId into the PpidField array (Parent Process Id Field) if it exists, or null otherwise. It fills the EventBuffer buffer with the information gathered from the event. A process event line looks like this:\n{\u0026#34;id\u0026#34;:42,\u0026#34;type\u0026#34;:\u0026#34;process\u0026#34;,\u0026#34;timestamp\u0026#34;:134025123456789012,\u0026#34;pid\u0026#34;:1234,\u0026#34;ppid\u0026#34;:856,\u0026#34;is_create\u0026#34;:true,\u0026#34;image\u0026#34;:\u0026#34;C:\\\\Windows\\\\System32\\\\notepad.exe\u0026#34;} Wiring it into driver_entry.c # First, the callback needs to be registered in DriverEntry, and unregistered in DriverUnload:\nvoid DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdaMonProcessCallbackUnregister(); // Unregister the callback KdaMonLogWriterStop(); KdaMonEventQueueDestroy(); KdaMonDeleteDevice(g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Driver Unload called\\n\u0026#34;)); } NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); DriverObject-\u0026gt;DriverUnload = DriverUnload; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CREATE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CLOSE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_DEVICE_CONTROL] = KdaMonDeviceControl; NTSTATUS status = KdaMonCreateDevice(DriverObject, \u0026amp;g_DeviceObject); if (!NT_SUCCESS(status)) { return status; } // --- Initialize the event queue --- if (!KdaMonEventQueueInitialize()) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: EventQueueInitialize failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } // --- Start the log writer thread --- if (!KdaMonLogWriterStart(DriverObject)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonLogWriterStart failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } // --- Register process creation callback --- if (!NT_SUCCESS(KdaMonProcessCallbackRegister())) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonProcessCallbackRegister failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); return STATUS_SUCCESS; } For this version, the validation test is to create a process and check that an event is properly created and written to the log file with the right type. So right after starting the driver, I open Notepad, then stop the driver once Notepad is open. To do this, I wrote the following script (test_v05.ps1):\n# test_v05.ps1 $Driver = \u0026#34;KDAMonitor\u0026#34; $DriverPath = \u0026#34;$env:USERPROFILE\\Desktop\\$Driver.sys\u0026#34; sc.exe stop $Driver sc.exe delete $Driver sc.exe create $Driver type= kernel binPath= $DriverPath sc.exe start $Driver Start-Process notepad.exe -Wait sc.exe stop $Driver sc.exe delete $Driver Here\u0026rsquo;s a demonstration of this test running:\nConclusion # The first sensor is now implemented. This version also confirmed that the previous building blocks (the event queue and the jsonl logging) work as intended.\nFor this release (v0.5), I also added an architecture diagram of the current project to the README.md:\nThe next versions (up to v0.10) will focus on implementing new callbacks and callouts, so I\u0026rsquo;ll spend less time explaining event serialization to jsonl going forward.\nThanks for reading all the way through, and see you in the next article — the sixth in this series: Second Sensor: Tracking Image and DLL Loading.\n","date":"7 September 2026","externalUrl":null,"permalink":"/en/posts/05-process-sensor/","section":"Blog","summary":"Implementing KDAMonitor’s first sensor: monitoring process creation and termination.","title":"05 - First Sensor: Monitoring Process Creation and Termination","type":"posts"},{"content":"Welcome to the fourth article in the series on developing KDAMonitor!\nIn this article, I\u0026rsquo;ll cover version v0.4 of the project, along with the first crash encountered along the way. Version v0.4 is mainly about implementing logging via .jsonl files. This logging is what finally gives a purpose to the queue implemented previously.\nHere are the files covered in this article, and the section that explains each of them:\nFile Role Section kdamon_config.h New centralized constants (log paths) Why a Dedicated Thread for Writing? log_writer.h Public interface of the log writer (Start/Stop) The Dedicated Writer Thread (log_writer.c) log_writer.c Opening/closing the file, JSONL serialization, thread, start/stop controllers The Dedicated Writer Thread (log_writer.c) / JSONL Logging event_queue.c Added WakeEvent to KDAMON_EVENT_QUEUE, signaling in Push, new getter Wake Events driver_entry.c Removed test code, wired up KdaMonLogWriterStart/Stop Integration into driver_entry.c docs/crashes.md Documentation of crash #1 The First Crash: IRQL_NOT_LESS_OR_EQUAL (0xA) The project can be found in this repository: KDAMonitor.\nWhy a Dedicated Thread for Writing? # There are two reasons for using a dedicated thread for writing rather than relying on the callbacks:\nIt clearly separates what each object (device, queue, \u0026hellip;) is responsible for, without piling too much responsibility onto a single one. It avoids the slow, blocking parts related to the file (opening, then writing). More details in the next part. The Producer/Consumer Coupling Problem # What I call a producer (or supplier) will be the callbacks that will soon be implemented. Each of these callbacks will supply events to the queue, and when they push events into the queue, they will be running at DISPATCH_LEVEL. However, at DISPATCH_LEVEL the scheduler cannot intervene, meaning no blocking I/O is allowed (no disk read/write, no waiting on an object that can sleep).\nTo solve this problem, a new object dedicated to this task is needed. That way, the entire slow and blocking part (opening a file, writing to it) is offloaded to a separate system thread, which itself runs at PASSIVE_LEVEL.\nNew Centralized Constants (kdamon_config.h) # This is a good time to introduce the new constants:\nKDAMON_DIR: the path of the folder assigned to the driver (the whole project) L\u0026quot;\\\\??\\\\C:\\\\KDAMonitor\\\\\u0026quot;. KDAMON_LOG_DIR: the path of the folder assigned to the driver\u0026rsquo;s log files L\u0026quot;\\\\??\\\\C:\\\\KDAMonitor\\\\logs\\\\\u0026quot;. KDAMON_LOG_FILE_PREFIX and KDAMON_LOG_FILE_EXTENSION: respectively, the prefix and suffix given to the log file. Why the \\??\\C:\\ notation?\nThis symbolic link belongs to the kernel\u0026rsquo;s object namespace (Object Manager namespace); it bridges over to the C: drive, a concept specific to the Win32 space that this object namespace doesn\u0026rsquo;t natively know about.\n\\??\\ is also known as \\DosDevices\\.\nWhile at it, kdamon_config.h also gets a compilation fix: the STATUS_* identifiers (used, among others, by ZwCreateFile) weren\u0026rsquo;t declared. \u0026lt;ntstatus.h\u0026gt; has to be included before \u0026lt;ntddk.h\u0026gt;, with WIN32_NO_STATUS defined in between to avoid macro conflicts. Here\u0026rsquo;s the final file:\n#pragma once #include \u0026lt;ntstatus.h\u0026gt; #define WIN32_NO_STATUS #include \u0026lt;ntddk.h\u0026gt; #undef WIN32_NO_STATUS #define DRIVER_TAG \u0026#34;[KDAMonitor]\u0026#34; #define KDAMON_DEVICE_NAME L\u0026#34;\\\\Device\\\\KDAMonitor\u0026#34; #define KDAMON_SYMLINK_NAME L\u0026#34;\\\\DosDevices\\\\KDAMonitor\u0026#34; #define KDAMON_DIR L\u0026#34;\\\\??\\\\C:\\\\KDAMonitor\\\\\u0026#34; #define KDAMON_LOG_DIR L\u0026#34;\\\\??\\\\C:\\\\KDAMonitor\\\\logs\\\\\u0026#34; #define KDAMON_LOG_FILE_PREFIX L\u0026#34;kdamon_\u0026#34; #define KDAMON_LOG_FILE_EXTENSION L\u0026#34;.jsonl\u0026#34; Wake Events # The Polling Problem # Without a wake-up mechanism, the thread would have to poll the queue in a loop to know whether new events had arrived → wasted CPU, added latency.\nThe WakeEvent Added to KDAMON_EVENT_QUEUE # A new KEVENT WakeEvent field was added to the queue\u0026rsquo;s structure (event_queue.c), of type SynchronizationEvent.\nIn KdaMonEventQueuePush, right before releasing the spinlock: KeSetEvent(\u0026amp;g_EventQueue.WakeEvent, IO_NO_INCREMENT, FALSE) — so it\u0026rsquo;s signaled on every successful push, still under the spinlock, at DISPATCH_LEVEL. KeSetEvent is specifically designed to be callable up to DISPATCH_LEVEL, unlike waiting primitives on the caller\u0026rsquo;s side — consistent with the choice of spinlock made in article 03.\nA new getter, KdaMonEventQueueGetWakeEvent, is exposed in event_queue.h and used by log_writer.c to retrieve a pointer to this event without exposing the entire queue structure.\nDistinction from the Stop Event # It\u0026rsquo;s worth being clear that there are two distinct events: WakeEvent (carried by the queue, signaled on every new event) and g_StopEvent (local to log_writer.c, signaled only once, on shutdown). The thread waits on both simultaneously via KeWaitForMultipleObjects, which lets it react immediately to a shutdown request without waiting for a hypothetical next event.\nThe Dedicated Writer Thread (log_writer.c) # Before going further in this part, let me introduce a few functions that won\u0026rsquo;t be detailed in this article:\nKdaMonLogWriterOpenFile takes care of creating (or verifying the existence of) C:\\KDAMonitor\\, then C:\\KDAMonitor\\logs\\, then building a timestamped file name (kdamon_YYYYMMDD_HHMMSS.jsonl) and opening it for writing. KdaMonLogWriterCloseFile closes the log file\u0026rsquo;s handle if it\u0026rsquo;s open (ZwClose), then resets it to NULL. The KdaMonLogWriterWriteEvent function writes a line into the .jsonl file representing an event, and it will be covered in detail in the JSONL Logging section.\nWith no callbacks implemented yet, this function writes a basic event with no meaningful information. The event contains only: the ID, the event type, and the timestamp.\nOn top of that, here\u0026rsquo;s the global state maintained by this module:\ng_ThreadObject: the pointer to the kernel thread object, kept around so it can be waited on at shutdown (see below). g_StopEvent: the event signaled to request the thread\u0026rsquo;s shutdown. g_LogFileHandle: the handle to the currently open log file. One last object is used but doesn\u0026rsquo;t belong to this module: the WakeEvent, part of the event queue\u0026rsquo;s own structure (event_queue.c), signaled on every Push. It was covered in full detail in the Wake Events section, just before.\nCreating the Thread: IoCreateSystemThread # Let\u0026rsquo;s start with the function that launches the logger\u0026rsquo;s dedicated thread, KdaMonLogWriterStart.\nIt begins by initializing g_StopEvent as a NotificationEvent, which will let us wake up the waiting thread:\nKeInitializeEvent(\u0026amp;g_StopEvent, NotificationEvent, FALSE); NotificationEvent means that once signaled, the event stays signaled indefinitely. Once shutdown has been requested, it must not \u0026ldquo;auto-consume\u0026rdquo; itself — it has to remain signaled permanently. The last parameter, FALSE, sets the initial state to non-signaled.\nNext, the function checks that the log file exists and opens it (KdaMonLogWriterOpenFile). Right after that, the system thread is created with IoCreateSystemThread:\nstatus = IoCreateSystemThread( DriverObject, // Driver object to associate the thread with \u0026amp;threadHandle, // A handle to the thread (output) THREAD_ALL_ACCESS, // Access mask requested on this handle NULL, // ObjectAttributes NULL, // ProcessHandle NULL, // ClientId KdaMonLogWriterThread, // The thread\u0026#39;s entry routine NULL // StartContext ); The first parameter, DriverObject, is what sets this function apart from PsCreateSystemThread. By passing it here, the I/O Manager associates the created thread with the driver and increments an internal counter of active threads for this driver. In practice, this prevents Windows from unloading the driver until that counter goes back to zero — in other words, as long as the thread is still running. This adds a layer of protection against a premature unload that PsCreateSystemThread doesn\u0026rsquo;t offer natively.\nThe following parameters are left as NULL:\nObjectAttributes: no object name to associate. ProcessHandle: the thread is created in the context of the System process by default. ClientId: there\u0026rsquo;s no need to retrieve its PID/TID since we\u0026rsquo;ll work directly with an object pointer (see below). KdaMonLogWriterThread is the thread\u0026rsquo;s entry routine (see The Main Thread Loop (KdaMonLogWriterThread)). StartContext stays NULL: the routine doesn\u0026rsquo;t need to receive anything as a parameter, it retrieves the WakeEvent of the queue itself.\nIf this step fails, the function calls KdaMonLogWriterCloseFile.\nHere\u0026rsquo;s the final code of this function:\nBOOLEAN KdaMonLogWriterStart(_In_ PDRIVER_OBJECT DriverObject) { NTSTATUS status; HANDLE threadHandle; KeInitializeEvent(\u0026amp;g_StopEvent, NotificationEvent, FALSE); status = KdaMonLogWriterOpenFile(); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonLogWriterStart: failed to open log file (0x%08X)\\n\u0026#34;, status)); return FALSE; } status = IoCreateSystemThread( DriverObject, \u0026amp;threadHandle, THREAD_ALL_ACCESS, NULL, NULL, NULL, KdaMonLogWriterThread, NULL ); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: IoCreateSystemThread failed (0x%08X)\\n\u0026#34;, status)); KdaMonLogWriterCloseFile(); return FALSE; } status = ObReferenceObjectByHandle( threadHandle, THREAD_ALL_ACCESS, NULL, KernelMode, \u0026amp;g_ThreadObject, NULL ); ZwClose(threadHandle); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: ObReferenceObjectByHandle failed (0x%08X)\\n\u0026#34;, status)); return FALSE; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Log writer started\\n\u0026#34;)); return TRUE; } The Main Thread Loop (KdaMonLogWriterThread) # Once launched by IoCreateSystemThread, the thread runs KdaMonLogWriterThread in a loop until it\u0026rsquo;s asked to stop:\nstatic VOID KdaMonLogWriterThread(_In_ PVOID StartContext) { UNREFERENCED_PARAMETER(StartContext); PRKEVENT WakeEvent = KdaMonEventQueueGetWakeEvent(); PVOID WaitObjects[WAIT_OBJECT_COUNT]; NTSTATUS WaitStatus; KDAMON_EVENT Event; WaitObjects[0] = \u0026amp;g_StopEvent; WaitObjects[1] = WakeEvent; KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Log writer thread started\\n\u0026#34;)); for (;;) { WaitStatus = KeWaitForMultipleObjects( WAIT_OBJECT_COUNT, WaitObjects, WaitAny, Executive, KernelMode, FALSE, NULL, NULL ); if (WaitStatus == STATUS_WAIT_0) { break; } while (KdaMonEventQueuePop(\u0026amp;Event)) { KdaMonLogWriterWriteEvent(\u0026amp;Event); } } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Log writer thread exiting\\n\u0026#34;)); PsTerminateSystemThread(STATUS_SUCCESS); } The thread first retrieves the queue\u0026rsquo;s WakeEvent field via KdaMonEventQueueGetWakeEvent (see Wake Events). It then places these two objects into an array (WaitObjects): g_StopEvent at index 0 and the WakeEvent at index 1.\nThe for (;;) loop then waits on both objects simultaneously with KeWaitForMultipleObjects in WaitAny mode. This mode lets the thread go to sleep and wake up as soon as either object becomes signaled.\nThe return value is used to decide what the code should do next:\nIf the return value is STATUS_WAIT_0, that corresponds to index 0 of WaitObjects, so the signaled object is g_StopEvent. If that\u0026rsquo;s the case, the loop is immediately broken out of. In every other case, the thread drains the queue entirely via the while (KdaMonEventQueuePop(\u0026amp;Event)) loop, which pops and writes, via KdaMonLogWriterWriteEvent, the events one by one, until the queue is empty, before going back to waiting for the next wake-up. Once out of the main loop, PsTerminateSystemThread(STATUS_SUCCESS) terminates the thread.\nLifecycle and Clean Shutdown # The thread is stopped via KdaMonLogWriterStop, called from DriverUnload, before KdaMonEventQueueDestroy:\nVOID KdaMonLogWriterStop(VOID) { if (g_ThreadObject == NULL) { return; } KeSetEvent(\u0026amp;g_StopEvent, IO_NO_INCREMENT, FALSE); KeWaitForSingleObject(g_ThreadObject, Executive, KernelMode, FALSE, NULL); ObDereferenceObject(g_ThreadObject); g_ThreadObject = NULL; KdaMonLogWriterCloseFile(); KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Log writer stopped\\n\u0026#34;)); } KeSetEvent(\u0026amp;g_StopEvent, ...) signals the shutdown; the thread waiting in KeWaitForMultipleObjects wakes up with STATUS_WAIT_0 and exits its loop.\nBy using KeWaitForSingleObject(g_ThreadObject, ...), a thread object becomes signaled exactly when the thread actually finishes (at the moment of the PsTerminateSystemThread call). This call therefore blocks until the thread has actually finished executing — not just until it\u0026rsquo;s been asked to. Without this wait, DriverUnload could carry on, and the driver could be unloaded, while the thread is still running.\nOnce the thread is guaranteed to have terminated, ObDereferenceObject releases the reference taken in KdaMonLogWriterStart, and g_ThreadObject is reset to NULL. Finally, KdaMonLogWriterCloseFile closes the log file.\nJSONL Logging # Why JSONL? # The JSONL format (JSON Lines) consists of writing one valid JSON object per line, rather than a single JSON array wrapping all events. I chose this format for two reasons:\neach event can be written independently as a simple append, without ever needing to rewrite or close off a wrapping structure; the strict \u0026ldquo;one line = one event\u0026rdquo; correspondence makes the file trivial to parse afterward. Another point that justified this choice, which I discovered afterward, is that if the process is interrupted abruptly (crash, forced shutdown), the lines already written remain usable as-is.\nSerializing an Event (KdaMonLogWriterWriteEvent) # In this part, the format shown is the base serialization common to all event types. The JSON line is built with RtlStringCbPrintfA:\n{\u0026#34;id\u0026#34;:...,\u0026#34;type\u0026#34;:\u0026#34;...\u0026#34;,\u0026#34;timestamp\u0026#34;:...}\\n In code, this gives:\nNTSTATUS status = RtlStringCbPrintfA( EventBuffer, sizeof(EventBuffer), \u0026#34;{\\\u0026#34;id\\\u0026#34;:%lu,\\\u0026#34;type\\\u0026#34;:\\\u0026#34;%s\\\u0026#34;,\\\u0026#34;timestamp\\\u0026#34;:%lld}\\n\u0026#34;, Event-\u0026gt;Id, KdaMonEventTypeToString(Event-\u0026gt;Type), Event-\u0026gt;Timestamp.QuadPart ); KdaMonEventTypeToString: a small function that maps the KDAMON_EVENT_TYPE enum to a readable string (\u0026quot;Process\u0026quot;, \u0026quot;Network\u0026quot;, etc.).\nRtlStringCbPrintfA is used instead of a classic sprintf. It\u0026rsquo;s a function from the kernel\u0026rsquo;s safe strings library (ntstrsafe.h), which explicitly takes the size of the destination buffer (sizeof(EventBuffer)) and guarantees it will never write beyond it.\nOnce the line is built, its exact length is retrieved with RtlStringCbLengthA:\nstatus = RtlStringCbLengthA(EventBuffer, sizeof(EventBuffer), \u0026amp;Length); This length (without the final \\0) is needed to tell ZwWriteFile exactly how many bytes to write:\nstatus = ZwWriteFile( g_LogFileHandle, NULL, // Event NULL, // ApcRoutine NULL, // ApcContext \u0026amp;IoStatusBlock, EventBuffer, (ULONG)Length, NULL, // ByteOffset NULL // Key ); ZwWriteFile is the kernel equivalent of WriteFile. It writes to the file using the log file\u0026rsquo;s handle: g_LogFileHandle. The Event and ApcRoutine parameters, left as NULL, aren\u0026rsquo;t used here — the call is synchronous, thanks to the FILE_SYNCHRONOUS_IO_NONALERT flag set when the file was opened. IoStatusBlock receives, as output, the number of bytes actually written along with the status of the operation.\nThis function\u0026rsquo;s code isn\u0026rsquo;t shown in full here, since it\u0026rsquo;s only a prototype that will be replaced by the fill-in functions assigned to the callbacks. It can, however, be found in the project\u0026rsquo;s v0.4 release: v0.4 - Log Writer.\nIntegration into driver_entry.c # First, the log writer\u0026rsquo;s start (KdaMonLogWriterStart) needs to be added to DriverEntry, and its stop (KdaMonLogWriterStop) to DriverUnload:\nvoid DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdaMonLogWriterStop(); KdaMonEventQueueDestroy(); KdaMonDeleteDevice(g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Driver Unload called\\n\u0026#34;)); } NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { ... if (!KdaMonEventQueueInitialize()) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: EventQueueInitialize failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } if (!KdaMonLogWriterStart(DriverObject)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonLogWriterStart failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); ... return STATUS_SUCCESS; } Next, a simple test is added to DriverEntry: two events are created and pushed into the queue. If our log writer works properly, the events should be pulled off the queue in the order they arrived and written into a .jsonl file. Here\u0026rsquo;s the final code of DriverEntry:\nNTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); DriverObject-\u0026gt;DriverUnload = DriverUnload; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CREATE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CLOSE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_DEVICE_CONTROL] = KdaMonDeviceControl; NTSTATUS status = KdaMonCreateDevice(DriverObject, \u0026amp;g_DeviceObject); if (!NT_SUCCESS(status)) { return status; } if (!KdaMonEventQueueInitialize()) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: EventQueueInitialize failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } if (!KdaMonLogWriterStart(DriverObject)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: KdaMonLogWriterStart failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); // --- BEGIN TEST QUEUE --- KDAMON_EVENT testEvent1 = { 0 }; testEvent1.Type = KdaMonEventProcess; KeQuerySystemTimePrecise(\u0026amp;testEvent1.Timestamp); KdaMonEventQueuePush(\u0026amp;testEvent1); KDAMON_EVENT testEvent2 = { 0 }; testEvent2.Type = KdaMonEventNetwork; KeQuerySystemTimePrecise(\u0026amp;testEvent2.Timestamp); KdaMonEventQueuePush(\u0026amp;testEvent2); // --- END TEST QUEUE --- return STATUS_SUCCESS; } Unlike the test in article 03, there\u0026rsquo;s no manual popping here: it\u0026rsquo;s the log thread that will consume these two events on its own, as soon as it\u0026rsquo;s woken up by the WakeEvent.\nHere\u0026rsquo;s a demonstration of this test running:\nThe First Crash: IRQL_NOT_LESS_OR_EQUAL (0xA) # Context # The VM produced a BSOD (Blue Screen Of Death). The dump generated after the crash (found in C:\\Windows\\Minidump\\) was kept in the docs/dumps/ folder of the repository and analyzed with WinDbg (!analyze -v).\nThe bugcheck reported is IRQL_NOT_LESS_OR_EQUAL (0xA):\nIRQL_NOT_LESS_OR_EQUAL (a) An attempt was made to access a pageable (or completely invalid) address at an interrupt request level (IRQL) that is too high. This is usually caused by drivers using improper addresses. If a kernel debugger is available get the stack backtrace. Arguments: Arg1: 0000000000000000, memory referenced Arg2: 0000000000000002, IRQL Arg3: 0000000000000000, bitfield : bit 0 : value 0 = read operation, 1 = write operation bit 3 : value 0 = not an execute operation, 1 = execute operation (only on chips which support this level of status) Arg4: fffff807cdc7274f, address which referenced memory Arg1 confirms that the memory address referenced was NULL, and Arg2 confirms an IRQL of 2, i.e. DISPATCH_LEVEL.\nThe faulting instruction itself is located in nt!KeSetEvent:\nIP_IN_PAGED_CODE: nt!KeSetEvent+1af fffff807`cdc7274f 4d8b2424 mov r12,qword ptr [r12] And the call stack confirms the entry point in the driver:\nSTACK_TEXT: fffffb8a`2cac8338 fffff807`ce0bece9 : 00000000`0000000a 00000000`00000000 00000000`00000002 00000000`00000000 : nt!KeBugCheckEx fffffb8a`2cac8340 fffff807`ce0b9fa8 : 00000000`00000000 00000000`00000000 fffff807`6496d0c0 00000000`00000000 : nt!KiBugCheckDispatch+0x69 fffffb8a`2cac8480 fffff807`cdc7274f : fffffb8a`00000003 fffff807`cdccb2ba 00000000`00000000 00000000`00000000 : nt!KiPageFault+0x468 fffffb8a`2cac8610 fffff807`6496162c : ffffbf82`00000000 00000000`00000000 00000001`89e45800 00000001`8521b4e3 : nt!KeSetEvent+0x1af fffffb8a`2cac86a0 ffffbf82`00000000 : 00000000`00000000 00000001`89e45800 00000001`8521b4e3 ffffffff`80003500 : KDAMonitor+0x162c fffffb8a`2cac86a8 00000000`00000000 : 00000001`89e45800 00000001`8521b4e3 ffffffff`80003500 ffffbf82`ee180000 : 0xffffbf82`00000000 KDAMonitor+0x162c corresponds to the call to KeSetEvent made inside KdaMonEventQueuePush (event_queue.c), so right after a new event was added to the queue.\nDiagnosis # The faulty code was in KdaMonEventQueueInitialize:\nBOOLEAN KdaMonEventQueueInitialize(VOID) { // Initialized BEFORE the zero-out KeInitializeEvent(\u0026amp;g_EventQueue.WakeEvent, SynchronizationEvent, FALSE); KeInitializeSpinLock(\u0026amp;g_EventQueue.Lock); RtlZeroMemory(\u0026amp;g_EventQueue, sizeof(g_EventQueue)); // \u0026lt;- FAULTY return TRUE; } The RtlZeroMemory call that followed KeInitializeEvent wiped out the entire g_EventQueue structure, including the WakeEvent that had just been initialized — its self-referencing pointer became NULL instead of continuing to point to itself. As a result, the event object was corrupted before it had ever been used.\nThe crash only happens on the first Push. It\u0026rsquo;s KeSetEvent that tries to walk this internal wait list, dereferences the NULL pointer left by the zeroing-out, and triggers the bugcheck.\nFix # The fix simply consists of reversing the order of operations: RtlZeroMemory first, then initializing the kernel objects.\nBOOLEAN KdaMonEventQueueInitialize(VOID) { RtlZeroMemory(\u0026amp;g_EventQueue, sizeof(g_EventQueue)); KeInitializeEvent(\u0026amp;g_EventQueue.WakeEvent, SynchronizationEvent, FALSE); KeInitializeSpinLock(\u0026amp;g_EventQueue.Lock); return TRUE; } Conclusion # Version v0.4 finally gives a real outlet to the queue built in v0.3: the log writer\u0026rsquo;s thread now automatically drains the queue and writes the retrieved events directly into a .jsonl file.\nThe next version, v0.5, will finally start filling the queue for real, with the first sensor: process creation and termination.\nThanks for reading all the way through, and see you in the next, fifth article of this series: The First Sensor: Monitoring Process Creation and Termination.\n","date":"1 September 2026","externalUrl":null,"permalink":"/en/posts/04-log-writer/","section":"Blog","summary":"Writing logs to disk and the first kernel crash of the KDAMonitor driver.","title":"04 - Writing Events to Disk: JSONL Logging, Wake Events and the First Crash","type":"posts"},{"content":"","date":"23 August 2026","externalUrl":"https://github.com/HalfTimeOfLife/aarch64-baremetal-kernel","permalink":"/en/projects/aartch64baremetal/","section":"Projects","summary":"Minimal bare-metal kernel for AArch64, built from scratch and executed on QEMU’s virt machine.","title":"AArch64 Bare-Metal Kernel","type":"projects"},{"content":"","date":"23 August 2026","externalUrl":null,"permalink":"/en/tags/armv8-a/","section":"Tags","summary":"","title":"ARMv8-A","type":"tags"},{"content":"","date":"23 August 2026","externalUrl":null,"permalink":"/en/tags/qemu/","section":"Tags","summary":"","title":"QEMU","type":"tags"},{"content":"Welcome to the third article in the series on developing KDAMonitor!\nIn this article, I\u0026rsquo;ll cover version v0.3 of the project. In this version, I focused on implementing the data structure (a queue, in this case) that will store, transmit, and remove the \u0026ldquo;events\u0026rdquo; (see Defining an Event in the Context of KDAMonitor) collected by the sensors.\nHere are the files covered in this article, along with the section that explains each one:\nFile Role Section event_types.h Event types and the KDAMON_EVENT structure Defining an Event in the Context of KDAMonitor event_queue.h Event queue interface and function declarations Implementing the Queue event_queue.c Event queue implementation and synchronization Implementing the Queue driver_entry.c Driver entry point, event queue initialization, and test Example: Testing the Event Queue The project can be found in this repository: KDAMonitor.\nDefining an Event in the Context of KDAMonitor # Before explaining in detail the data structure I used, let\u0026rsquo;s look at what I consider an event. Here\u0026rsquo;s how the structure is defined in the code:\ntypedef struct _KDAMON_EVENT { KDAMON_EVENT_TYPE Type; LARGE_INTEGER Timestamp; ULONG Id; // future members for event data //union //{ // //} Data; } KDAMON_EVENT, * PKDAMON_EVENT; Let\u0026rsquo;s start with the first three fields of the KDAMON_EVENT structure:\nType: This field holds a value corresponding to a type defined in the KDAMON_EVENT_TYPE enum. Timestamp: The timestamp of when the associated callback/callout received the event, which also marks the moment the event was created. Id: A unique identifier, scoped to the capture session, assigned to the event. In this version (v0.3), events only contain Type, Timestamp, and Id. Also, since no callback/callout is implemented yet in this version, the timestamp is set manually.\nThe Type field distinguishes events from one another. Here\u0026rsquo;s the enum it\u0026rsquo;s tied to:\ntypedef enum _KDAMON_EVENT_TYPE { KdaMonEventImageLoad, // TODO: implemented in v0.6 KdaMonEventNetwork, // TODO: implemented in v0.8 KdaMonEventProcess, // TODO: implemented in v0.5 KdaMonEventRegistry, // TODO: implemented in v0.9 KdaMonEventThread // TODO: implemented in v0.10 } KDAMON_EVENT_TYPE; The last field, a union named Data, will hold the event\u0026rsquo;s structure depending on its type. As a reminder, the supported events will be:\nProcess creation/termination Image load Network connection Registry key creation/deletion/modification Thread creation/termination Each of these types will get a corresponding structure with the relevant details. Events will share a common base, but a good number of details will differ. For example, for a DLL load event (a KdaMonEventImageLoad), the DLL\u0026rsquo;s path will be captured. Similarly, for a process creation event, it\u0026rsquo;s the executable\u0026rsquo;s path that will be kept. On the other hand, a destination IP address only concerns KdaMonEventNetwork, just as a registry key path only concerns KdaMonEventRegistry.\nThe specific content of these events (i.e., the fields of their corresponding structures) will be detailed in the articles covering each associated sensor.\nData Structure Used # What Is a Queue? # A queue is a data structure \u0026hellip; that behaves, well, like a queue :-). More precisely, it\u0026rsquo;s what\u0026rsquo;s known as a FIFO data structure — First In, First Out — just like a line at a store, where the first person to arrive is the first to be served.\nIn programming, a queue keeps, in practice, a reference to the last element added (the tail) and the first one (the head). Adding an element to this structure means placing it at the tail of the queue.\nThere are other types of data structures:\nA stack, which is a LIFO structure — Last In, First Out — like a stack of plates: the last one placed on top is also the first one removed. A linked list (or simply a list) is a structure that allows elements to be added at the beginning, the end, or anywhere in the middle. Here\u0026rsquo;s the structure we use to represent the queue:\ntypedef struct _KDAMON_EVENT_QUEUE { KDAMON_EVENT Buffer[KDAMON_EVENT_QUEUE_SIZE]; ULONG Head; ULONG Tail; ULONG Count; ULONG DroppedEvents; ULONG NextId; KSPIN_LOCK Lock; } KDAMON_EVENT_QUEUE; static KDAMON_EVENT_QUEUE g_EventQueue; Here\u0026rsquo;s an explanation of every field in this structure:\nBuffer: Array of events (KDAMON_EVENT) currently in the queue Head: The head of the queue (oldest element in the queue) Tail: The tail of the queue (last element added to the queue) Count: Number of elements in the queue DroppedEvents: Number of elements the queue failed to keep NextId: ID to assign to the next event Lock: Covered in the Synchronization section Why Use a Queue? # Let\u0026rsquo;s consider the following example:\nWe choose a stack as the data structure for this project. A first event arrives, and we place it at the top of the stack. A second event arrives, and we place it at the top of the stack, above the first event. And so on, until we reach the 1000th event. There are two scenarios: Scenario 1: we popped (removed the top element) every time an event arrived -\u0026gt; costly operation, and at that point, what\u0026rsquo;s the actual difference from having no data structure at all? Scenario 2: we popped nothing at all. In that case, the first element we\u0026rsquo;d retrieve would actually be the most recently captured one, so we\u0026rsquo;d have to reconstruct the chronological order using the timestamps. We can conclude that a stack isn\u0026rsquo;t the right choice. Especially since several callbacks will be feeding into the structure, we need one designed for chronological order.\nA queue fits this role perfectly. We define a maximum size for our queue, and for each event, we push it onto the queue (push) and later remove it from the head of the queue (pop), in the order it was added. One downside of this implementation is that the queue has a fixed size, meaning that if too many events arrive while the queue is already full, the new events get rejected instead of added.\nBut what happens if two sensors add an event to the queue at the same time? To solve this, we\u0026rsquo;re going to need synchronization.\nSynchronization # Concretely, here\u0026rsquo;s the problem:\nThe network callback and the process creation callback both add an event at the same time. Without protection, both could read the same value of NextId before either one increments it, thus assigning the same ID to two different events.\nAnother problem: what happens if a producer (a callback) is in the middle of adding an event to the queue (and therefore modifying Tail and Count) while a consumer removes one at the same time, reading those same fields? The consumer could then read Count or Tail in an intermediate, inconsistent state, which could corrupt the queue\u0026rsquo;s order or cause it to read an event that hasn\u0026rsquo;t been fully written yet.\nTo solve this problem, we need mutual exclusion over the fields of our queue\u0026rsquo;s structure. In other words, when one component of our driver modifies the buffer, the Head/Tail/Count indices, or the NextId counter, no one else should be able to modify them at the same time.\nSo we\u0026rsquo;re going to use a synchronization primitive.\nSynchronization Primitives: The Spinlock # The synchronization primitive I chose is the spinlock, for two reasons:\nI had never implemented this primitive before. It matched a real technical constraint of the project. But concretely, what is a spinlock?\nTo put it simply, a spinlock is a bit like a fitting room: if someone\u0026rsquo;s already inside, the door is locked. If a new person shows up, they have no choice but to wait right outside, constantly checking whether the door has unlocked. They won\u0026rsquo;t go sit somewhere else and wait to be notified that the room is free.\nThat\u0026rsquo;s exactly what a spinlock does: a thread that can\u0026rsquo;t acquire it stays active, \u0026ldquo;checking\u0026rdquo; in a loop (busy-wait), instead of going to sleep — unlike a mutex, where the waiting thread would instead be notified once the resource becomes available. However, the waiting thread consumes CPU for the entire duration of the wait. A spinlock is therefore only suited to very short critical sections.\nHere\u0026rsquo;s an example using the KeAcquireSpinLock and KeReleaseSpinLock functions:\nKIRQL OldIrql; KeAcquireSpinLock(\u0026amp;g_EventQueue.Lock, \u0026amp;OldIrql); // critical section: exclusive access to g_EventQueue, which represents our queue KeReleaseSpinLock(\u0026amp;g_EventQueue.Lock, OldIrql); KeAcquireSpinLock raises the current IRQL to DISPATCH_LEVEL and saves the previous IRQL in OldIrql, so that KeReleaseSpinLock can restore it once the critical section is done.\nNow, why choose a spinlock over a mutex to protect g_EventQueue? The answer lies in the IRQL (Interrupt Request Level), which represents the interrupt priority level the processor is currently executing code at.\nA mutex can only be acquired at PASSIVE_LEVEL, the lowest level. That\u0026rsquo;s because when a thread fails to acquire a mutex, it\u0026rsquo;s put to sleep by the scheduler while it waits for the resource to become available. But this sleep is only possible if the scheduler itself is able to intervene, which is no longer the case once you go above PASSIVE_LEVEL.\nA spinlock doesn\u0026rsquo;t have this limitation. As explained above, a thread waiting on a spinlock busy-waits instead of going to sleep, so it can be acquired at any IRQL, up to and including DISPATCH_LEVEL.\nThe driver\u0026rsquo;s future sensors (process creation, image load, registry access, network via WFP) will each be implemented through a kernel callback or callout, and these callbacks don\u0026rsquo;t all run at the same IRQL. If g_EventQueue had been protected by a mutex, a callback running at DISPATCH_LEVEL would have triggered a bugcheck when attempting to acquire it.\nNow that the theoretical groundwork is laid, let\u0026rsquo;s move on to the implementation!\nImplementing the Queue # We already introduced the KDAMON_EVENT_QUEUE structure we\u0026rsquo;ll be using for the queue earlier (see What Is a Queue?). Let\u0026rsquo;s move on to the functions that let us interact with it.\nAll the functions (and the structure) shown here live in the event_queue.c file.\nInitialization (and Destruction of the Queue) # To create the queue, we implement a KdaMonEventQueueInitialize function with only two responsibilities:\nzeroing out the KDAMON_EVENT_QUEUE structure via the global object: static KDAMON_EVENT_QUEUE g_EventQueue; initializing the structure\u0026rsquo;s spinlock with KeInitializeSpinLock: KeInitializeSpinLock(\u0026amp;g_EventQueue.Lock); This function returns TRUE.\nThe KdaMonEventQueueDestroy function exists but doesn\u0026rsquo;t do anything for now (it\u0026rsquo;s empty), for a simple reason: the entire structure is static, so there\u0026rsquo;s nothing to manually free. Here\u0026rsquo;s the code for both functions:\nBOOLEAN KdaMonEventQueueInitialize(VOID) { RtlZeroMemory(\u0026amp;g_EventQueue, sizeof(g_EventQueue)); KeInitializeSpinLock(\u0026amp;g_EventQueue.Lock); return TRUE; } VOID KdaMonEventQueueDestroy(VOID) { } Adding and Removing Events # To interact with the queue, there are 3 functions:\nEventQueueNextIndex: Computes the next index in the circular buffer, wrapping back to 0 once the end of the buffer is reached (KDAMON_EVENT_QUEUE_SIZE). Used by both Push and Pop to advance Tail and Head. KdaMonEventQueuePush: Adds an element to the queue. KdaMonEventQueuePop: Removes an element from the queue. EventQueueNextIndex is fairly self-explanatory:\nstatic ULONG EventQueueNextIndex(_In_ ULONG Index) { Index++; if (Index == KDAMON_EVENT_QUEUE_SIZE) { Index = 0; } return Index; } The code for KdaMonEventQueuePush and KdaMonEventQueuePop is more involved. Here\u0026rsquo;s KdaMonEventQueuePush:\nBOOLEAN KdaMonEventQueuePush(_In_ KDAMON_EVENT* Event) { KIRQL OldIrql; if (Event == NULL) { return FALSE; } KeAcquireSpinLock(\u0026amp;g_EventQueue.Lock, \u0026amp;OldIrql); if (g_EventQueue.Count == KDAMON_EVENT_QUEUE_SIZE) { g_EventQueue.DroppedEvents++; KeReleaseSpinLock(\u0026amp;g_EventQueue.Lock, OldIrql); return FALSE; } Event-\u0026gt;Id = g_EventQueue.NextId++; g_EventQueue.Buffer[g_EventQueue.Tail] = *Event; g_EventQueue.Tail = EventQueueNextIndex(g_EventQueue.Tail); g_EventQueue.Count++; KeReleaseSpinLock(\u0026amp;g_EventQueue.Lock, OldIrql); return TRUE; } The function starts with a check: if Event is NULL, it returns FALSE immediately without touching the lock.\nThe spinlock is then acquired, and everything that follows runs inside the critical section we introduced earlier (see Synchronization Primitives: The Spinlock).\nThere are two cases to handle:\nFirst case: the queue is full (Count == KDAMON_EVENT_QUEUE_SIZE). In this case, the event isn\u0026rsquo;t added. DroppedEvents is incremented, and the function returns FALSE. This way, the caller knows the event wasn\u0026rsquo;t recorded, without ever risking putting a kernel callback to sleep. Second case: the queue isn\u0026rsquo;t full, so the event can be added. The first thing that happens is assigning the Id: Event-\u0026gt;Id = g_EventQueue.NextId++. The event is then copied into the buffer at position Tail, the Tail index is advanced via EventQueueNextIndex, and Count is incremented to reflect the queue\u0026rsquo;s new state. The lock is released, and the function returns TRUE. Here\u0026rsquo;s KdaMonEventQueuePop:\nBOOLEAN KdaMonEventQueuePop(_Out_ KDAMON_EVENT* Event) { KIRQL OldIrql; if (Event == NULL) { return FALSE; } KeAcquireSpinLock(\u0026amp;g_EventQueue.Lock, \u0026amp;OldIrql); if (g_EventQueue.Count == 0) { KeReleaseSpinLock(\u0026amp;g_EventQueue.Lock, OldIrql); return FALSE; } *Event = g_EventQueue.Buffer[g_EventQueue.Head]; g_EventQueue.Head = EventQueueNextIndex(g_EventQueue.Head); g_EventQueue.Count--; KeReleaseSpinLock(\u0026amp;g_EventQueue.Lock, OldIrql); return TRUE; } This function is essentially a mirror of KdaMonEventQueuePush. The beginning is strictly identical. But the cases to handle are different:\nFirst case: the queue is empty (Count == 0), in which case the spinlock is released and the function returns FALSE. There\u0026rsquo;s nothing to remove from the queue. Second case: the queue has at least one event. The event at g_EventQueue.Buffer[g_EventQueue.Head] is copied into *Event, the output parameter provided by the caller. The next index in the buffer is then computed using EventQueueNextIndex and stored in g_EventQueue.Head. Finally, the total number of elements in the queue is decremented (g_EventQueue.Count--). Current Size of the Queue # The last function implemented is KdaMonEventQueueCount. This function lets us safely check (using the spinlock) how many elements are currently in the queue:\nULONG KdaMonEventQueueCount(VOID) { KIRQL OldIrql; ULONG EventCount; KeAcquireSpinLock(\u0026amp;g_EventQueue.Lock, \u0026amp;OldIrql); EventCount = g_EventQueue.Count; KeReleaseSpinLock(\u0026amp;g_EventQueue.Lock, OldIrql); return EventCount; } Example: Testing the Event Queue # To validate that the queue works as intended, I added a test directly inside DriverEntry, between the // --- BEGIN TEST QUEUE --- and // --- END TEST QUEUE --- markers:\n#include \u0026#34;driver.h\u0026#34; #include \u0026#34;device.h\u0026#34; #include \u0026#34;ioctl.h\u0026#34; #include \u0026#34;kdamon_config.h\u0026#34; #include \u0026#34;event_queue.h\u0026#34; PDEVICE_OBJECT g_DeviceObject = NULL; void DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdaMonEventQueueDestroy(); KdaMonDeleteDevice(g_DeviceObject); KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Driver Unload called\\n\u0026#34;)); } NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); DriverObject-\u0026gt;DriverUnload = DriverUnload; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CREATE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CLOSE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_DEVICE_CONTROL] = KdaMonDeviceControl; NTSTATUS status = KdaMonCreateDevice(DriverObject, \u0026amp;g_DeviceObject); if (!NT_SUCCESS(status)) { return status; } if (!KdaMonEventQueueInitialize()) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: EventQueueInitialize failed\\n\u0026#34;)); return STATUS_UNSUCCESSFUL; } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); // --- BEGIN TEST QUEUE --- KDAMON_EVENT testEvent1 = { 0 }; testEvent1.Type = KdaMonEventProcess; KeQuerySystemTimePrecise(\u0026amp;testEvent1.Timestamp); KdaMonEventQueuePush(\u0026amp;testEvent1); KDAMON_EVENT testEvent2 = { 0 }; testEvent2.Type = KdaMonEventNetwork; KeQuerySystemTimePrecise(\u0026amp;testEvent2.Timestamp); KdaMonEventQueuePush(\u0026amp;testEvent2); KdPrint((DRIVER_TAG \u0026#34; [TEST]: Queue count after 2 pushes = %lu\\n\u0026#34;, KdaMonEventQueueCount())); KDAMON_EVENT popped; while (KdaMonEventQueuePop(\u0026amp;popped)) { KdPrint((DRIVER_TAG \u0026#34; [TEST]: Popped event Id=%lu Type=%d\\n\u0026#34;, popped.Id, popped.Type)); } KdPrint((DRIVER_TAG \u0026#34; [TEST]: Queue count after pops = %lu\\n\u0026#34;, KdaMonEventQueueCount())); // --- END TEST QUEUE --- return STATUS_SUCCESS; } The flow is simple:\nTwo events are pushed onto the queue (a KdaMonEventProcess and a KdaMonEventNetwork) KdaMonEventQueueCount is called to confirm the queue holds 2 events. A loop pops every event one by one until KdaMonEventQueuePop returns FALSE (empty queue), logging the Id and Type of each retrieved event. We\u0026rsquo;d expect to first retrieve the Process event (Id = 0), then the Network event (Id = 1) — in the exact order they were added, confirming the queue\u0026rsquo;s FIFO behavior. Finally, KdaMonEventQueueCount is called one last time to confirm the queue is back to 0.\nHere\u0026rsquo;s a demonstration of this test running:\nConclusion # KDAMonitor now has a generic event structure (KDAMON_EVENT) and a circular queue capable of storing it, protected by a spinlock compatible with any IRQL up to DISPATCH_LEVEL.\nThe DroppedEvents counter, which tracks the number of events rejected due to a full queue, is correctly incremented but isn\u0026rsquo;t exposed or checked anywhere yet.\nThe next version (v0.4) will give this queue an actual purpose: a dedicated thread will drain it automatically to a log file. Once that piece is in place, the following versions (v0.5 and beyond) will finally be able to start filling the queue via kernel callbacks/callouts (process creation, image load, registry, network, and thread).\nThanks for reading all the way through, and see you in the next, fourth article of this series: Writing Events to Disk: JSONL Logging, Wake Events and the First Crash.\n","date":"21 August 2026","externalUrl":null,"permalink":"/en/posts/03-event-queue/","section":"Blog","summary":"Building the event queue for the KDAMonitor driver.","title":"03 - The Event Queue: Structure and Synchronization in the Kernel","type":"posts"},{"content":"Welcome to the second article in the KDAMonitor development series!\nIn this article, I\u0026rsquo;ll cover versions v0.1 and v0.2 of the project. As a reminder:\nv0.1: Driver basics (DriverEntry) and a short example v0.2: Adding a device and IOCTL communication, with a sample client Here are the files covered in this article, along with the section explaining each one:\nFile Role Section driver_entry.c Driver entry point, routine registration What is a driver? device.c Device and symbolic link creation How do you communicate with the driver? ioctl.c IRP dispatch and echo IOCTL handling IOCTL kdamon_shared.h IOCTL code and driver/client shared structures Anatomy of an IOCTL code kdamon_config.h Centralized constants (device name, symbolic link name, log tag) - client/src/client.c Usermode test client, validates the echo exchange Example: the test client The project can be found in this repository: KDAMonitor.\nWhat is a driver? # A driver is a program that lets the operating system communicate with a machine\u0026rsquo;s components. Without drivers, the operating system wouldn\u0026rsquo;t know how to talk to the graphics card, the network card, the keyboard, the mouse, etc. On Windows, these programs use the .sys extension.\nThat said, not all drivers exist solely to handle communication between components and the system. In fact, there are several types of drivers:\nhardware drivers, described above software drivers, which don\u0026rsquo;t depend on a specific component For example, KDAMonitor is a software driver: it doesn\u0026rsquo;t depend on any physical component of the machine it\u0026rsquo;s installed on.\nThis raises a natural question: what\u0026rsquo;s the point of a driver compared to a regular executable (.exe)? The main advantage of a driver is that it runs in kernel space, which grants it far higher privileges, direct access to physical memory and hardware, and (almost) none of the security boundaries that normally isolate usermode processes from one another. Here\u0026rsquo;s a diagram illustrating communication between user-mode and kernel-mode components:\nSource: Microsoft — User Mode and Kernel Mode\nConcretely, for KDAMonitor, this level of privilege lets us observe system events (process creation, network connections, etc.) that a standard (usermode) application cannot observe.\nHowever, while a driver brings a lot of advantages, it also comes with a few downsides — particularly when a crash occurs. In a regular executable, most of the time, if the program hits an error or crashes, the system simply carries on. In contrast, an error in a driver (a crash, a bad memory access) can bring down the entire system (a Blue Screen Of Death (BSOD)).\nIn an upcoming article, I\u0026rsquo;ll walk through the first issue I ran into that crashed my test VM :-)\nOn top of that, unlike an executable that you simply click to launch, a driver requires more steps. It\u0026rsquo;s dynamically loaded into kernel memory space by the Windows I/O Manager, via the Service Control Manager (SCM).\nNow that the concept of a driver is clearer, I\u0026rsquo;ll show how to create one — but first, we need to understand the structure of a driver\u0026rsquo;s program.\nDriverEntry and DriverUnload # DriverEntry is a driver\u0026rsquo;s entry point, roughly equivalent to a main(), though with some key differences. Its standard signature is:\nNTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath); Here\u0026rsquo;s what its arguments mean:\nDriverObject: the structure representing the driver within the system RegistryPath: the registry path associated with the driver The _In_ annotations are part of the Source (Code) Annotation Language (SAL). They\u0026rsquo;re transparent to the compiler, but provide useful metadata for human readers and static analysis tools. For more information on SAL, see the official Microsoft documentation: Understanding SAL.\nDriverEntry MUST return an NTSTATUS, which can take on many different values, the most important being STATUS_SUCCESS. If DriverEntry doesn\u0026rsquo;t return STATUS_SUCCESS, the driver fails to load.\nSee 2.3.1 NTSTATUS Values for the full list of possible NTSTATUS values.\nAnd what if we want to properly remove the driver? That\u0026rsquo;s the role of the DriverUnload routine on the DriverObject:\nDriverObject-\u0026gt;DriverUnload = ...; This routine is optional, but strongly recommended so the driver can be unloaded cleanly.\nExample: displaying the Windows version # In Pavel Yosifovich\u0026rsquo;s book, Windows Kernel Programming, an exercise is proposed. Starting from the following DriverEntry skeleton, the goal is to make the driver print the Windows version (major, minor, and build number) via KdPrint, using the RtlGetVersion function:\n#include \u0026#34;driver.h\u0026#34; // DRIVER_TAG is defined in driver.h : #define DRIVER_TAG \u0026#34;[KDAMonitor]\u0026#34; void DriverUnload(_In_ PDRIVER_OBJECT DriverObject) { UNREFERENCED_PARAMETER(DriverObject); KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Driver Unload called\\n\u0026#34;)); } NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); DriverObject-\u0026gt;DriverUnload = DriverUnload; KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); return STATUS_SUCCESS; } To solve this exercise, we need RtlGetVersion, which fills in an RTL_OSVERSIONINFOW structure containing the requested version information. One important detail not to forget: the dwOSVersionInfoSize field of this structure must be set before calling RtlGetVersion, otherwise the function fails.\nHere\u0026rsquo;s DriverEntry completed with this logic, right before the return STATUS_SUCCESS:\nNTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject, _In_ PUNICODE_STRING RegistryPath) { UNREFERENCED_PARAMETER(RegistryPath); DriverObject-\u0026gt;DriverUnload = DriverUnload; KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Initialized successfully\\n\u0026#34;)); RTL_OSVERSIONINFOW lpVersionInformation = { 0 }; lpVersionInformation.dwOSVersionInfoSize = sizeof(lpVersionInformation); NTSTATUS status = RtlGetVersion(\u0026amp;lpVersionInformation); if (NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Windows %lu.%lu Build %lu\\n\u0026#34;, lpVersionInformation.dwMajorVersion, lpVersionInformation.dwMinorVersion, lpVersionInformation.dwBuildNumber )); } else { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: Windows version not found\\n\u0026#34;)); } return STATUS_SUCCESS; } For more information on the RtlGetVersion function, see the Microsoft documentation: RtlGetVersion function (wdm.h).\nNote the use of the NT_SUCCESS macro, which checks whether an NTSTATUS represents a success — a macro we\u0026rsquo;ll use throughout this project.\nFinally, one last important detail: messages sent via KdPrint are not displayed in a regular console. They\u0026rsquo;re only visible through a kernel debugger like WinDbg, or a tool like DebugView (Sysinternals). By default, KdPrint only works in debug builds. For the rest of the project, I\u0026rsquo;ll be using DebugView.\nHow do you communicate with the driver? # Because of the user/kernel separation that exists in the system, the driver remains unreachable from user space.\nFor now, this isn\u0026rsquo;t a problem for our driver, but once a client is added to the project, it will become necessary to let the driver communicate with it. To do that, we need to create a device.\nA device is the object the driver exposes to the rest of the system, through which messages (between driver and client) will flow.\nThese messages exchanged between the client and the driver take the form of an I/O Request Packet (IRP), the standard data structure Windows uses to transmit any I/O request to a driver. Every action (opening the device, sending a command, closing it) generates a different IRP, which the driver must know how to handle.\nIn code, a device is an instance of the DEVICE_OBJECT structure, and we\u0026rsquo;ll now see how to create one.\nCreating a device with IoCreateDevice # To create a device, we need the IoCreateDevice function. Here are its most important parameters:\nDriverObject: the driver the device will be \u0026ldquo;attached\u0026rdquo; to DeviceName: the device\u0026rsquo;s kernel name (for KDAMonitor, L\u0026quot;\\\\Device\\\\KDAMonitor\u0026quot;) DeviceType: the device type — FILE_DEVICE_UNKNOWN in our case, since KDAMonitor isn\u0026rsquo;t tied to any specific hardware Exclusive: if TRUE, only one client can open a handle at a time; FALSE allows multiple simultaneous connections DeviceObject: receives the newly created DEVICE_OBJECT as output Like most kernel functions, it returns an NTSTATUS to check.\nIoCreateDevice leaves the DO_DEVICE_INITIALIZING flag set on the newly created device, which prevents any client from opening it. It must be explicitly cleared once initialization is complete:\n(*DeviceObject)-\u0026gt;Flags \u0026amp;= ~DO_DEVICE_INITIALIZING; The device must eventually be destroyed with IoDeleteDevice once it\u0026rsquo;s no longer needed — this is the role of KdaMonDeleteDevice, called from DriverUnload.\nMaking the device accessible: IoCreateSymbolicLink # Even once created, the device is only identified by its kernel name (\\Device\\KDAMonitor). To let a client open this device with a simple CreateFileW call, we need to bridge the kernel namespace and the usermode namespace. That\u0026rsquo;s the role of IoCreateSymbolicLink.\nThis function takes the following parameters:\nSymbolicLinkName: the name accessible from user space (for example \\DosDevices\\KDAMonitor, which a client will open as \\\\.\\KDAMonitor) DeviceName: the kernel name of the target device, the same one given earlier to IoCreateDevice As usual, it returns an NTSTATUS to check.\nMicrosoft notes that this function isn\u0026rsquo;t generally recommended for WDM drivers: a proper WDM driver should expose its device via IoRegisterDeviceInterface.\nWithout this symbolic link, the device would still exist in memory, but would remain completely unreachable from any usermode program.\nBuffered I/O vs Direct I/O # This section touches a bit on the IRP structure, covered in detail in the next part. Feel free to skip it and read the next section first.\nWhen a client sends or receives data through the device, that data has to travel somehow between user space and kernel space. Windows offers several methods for this, and our driver uses the DO_BUFFERED_IO flag.\nWith Buffered I/O, the I/O Manager allocates an intermediate buffer in kernel memory, copies the client\u0026rsquo;s data into it (or the reverse), then makes this buffer available to the driver via Irp-\u0026gt;AssociatedIrp.SystemBuffer (a field of the IRP structure I\u0026rsquo;ll detail in the next section). The driver never directly accesses the client\u0026rsquo;s memory.\nThe alternative is Direct I/O (DO_DIRECT_IO), which uses Memory Descriptor Lists (MDLs) to let the driver access the client buffer\u0026rsquo;s physical pages directly, without an intermediate copy. It\u0026rsquo;s faster for large volumes of data (since it avoids the copy), but more complex to implement (an example is given in Pavel Yosifovich\u0026rsquo;s book, Windows Kernel Programming, Chapter 7).\nFor KDAMonitor, the exchanges stay small (an echo for now, JSON events later on), so Buffered I/O is more than sufficient and much simpler to implement.\nPutting it all together: device.c # Here\u0026rsquo;s what KdaMonCreateDevice and KdaMonDeleteDevice look like once all these pieces come together:\n#include \u0026#34;device.h\u0026#34; #include \u0026#34;kdamon_config.h\u0026#34; // KDAMON_DEVICE_NAME is defined in kdamon_config.h : L\u0026#34;\\\\Device\\\\KDAMonitor\u0026#34; // KDAMON_SYMLINK_NAME is defined in kdamon_config.h : L\u0026#34;\\\\DosDevices\\\\KDAMonitor\u0026#34; NTSTATUS KdaMonCreateDevice(_In_ PDRIVER_OBJECT DriverObject, _Outptr_ PDEVICE_OBJECT* DeviceObject) { UNICODE_STRING devName = RTL_CONSTANT_STRING(KDAMON_DEVICE_NAME); UNICODE_STRING symLink = RTL_CONSTANT_STRING(KDAMON_SYMLINK_NAME); NTSTATUS status = IoCreateDevice( DriverObject, 0, \u0026amp;devName, FILE_DEVICE_UNKNOWN, 0, FALSE, DeviceObject ); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: IoCreateDevice failed (0x%08X)\\n\u0026#34;, status)); return status; } (*DeviceObject)-\u0026gt;Flags |= DO_BUFFERED_IO; status = IoCreateSymbolicLink(\u0026amp;symLink, \u0026amp;devName); if (!NT_SUCCESS(status)) { KdPrint((DRIVER_TAG \u0026#34; [ERROR]: IoCreateSymbolicLink failed (0x%08X)\\n\u0026#34;, status)); IoDeleteDevice(*DeviceObject); *DeviceObject = NULL; return status; } (*DeviceObject)-\u0026gt;Flags \u0026amp;= ~DO_DEVICE_INITIALIZING; KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Device object and symbolic link created\\n\u0026#34;)); return STATUS_SUCCESS; } void KdaMonDeleteDevice(_In_opt_ PDEVICE_OBJECT DeviceObject) { UNICODE_STRING symLink = RTL_CONSTANT_STRING(KDAMON_SYMLINK_NAME); IoDeleteSymbolicLink(\u0026amp;symLink); if (DeviceObject != NULL) { IoDeleteDevice(DeviceObject); } KdPrint((DRIVER_TAG \u0026#34; [SUCCESS]: Device object and symbolic link deleted\\n\u0026#34;)); } IOCTL # Having an open device isn\u0026rsquo;t enough on its own: we still need a way for a client to send a command to the driver, and for the driver to respond. That\u0026rsquo;s the role of IOCTLs (I/O Control) — generic requests a usermode program sends to a driver via the Win32 DeviceIoControl call, outside the usual read/write operations (ReadFile/WriteFile). This is the mechanism that lets each driver define its own \u0026ldquo;commands.\u0026rdquo; In our case, that\u0026rsquo;ll be a simple echo, to start with.\nWhat is an IRP? # Every time a client interacts with the device (opening it, sending a command, closing it), Windows wraps that request in a structure called an I/O Request Packet (IRP).\nFor more information on this structure, see the official documentation: IRP structure (wdm.h).\nThe I/O Manager creates the IRP and sends it to the driver via the IoCallDriver function. Once the request has been processed, the driver signals completion via IoCompleteRequest.\nAn IRP is never alone: it\u0026rsquo;s always accompanied by at least one I/O Stack Location structure (IO_STACK_LOCATION), which holds the parameters specific to the request (the requested IOCTL code, buffer size, etc.). To access it, the driver uses the IoGetCurrentIrpStackLocation macro.\nThis is precisely where the SystemBuffer field mentioned in the previous section lives: when the IOCTL code uses METHOD_BUFFERED, it\u0026rsquo;s through Irp-\u0026gt;AssociatedIrp.SystemBuffer that the driver accesses the data sent by the client.\nIRP dispatch (CREATE, CLOSE, DEVICE_CONTROL) # Every IRP carries a major function code (IRP_MJ_XXX), which tells the driver what operation to perform. For each code the driver wants to handle, it must register a corresponding dispatch routine — a function automatically called by the system whenever an IRP with that code arrives. All dispatch routines share the same signature:\nNTSTATUS DriverDispatch(PDEVICE_OBJECT DeviceObject, PIRP Irp); This registration happens in DriverEntry, via the DriverObject-\u0026gt;MajorFunction[...] array. In KDAMonitor\u0026rsquo;s case, here\u0026rsquo;s what needs to be added to DriverEntry:\nDriverObject-\u0026gt;MajorFunction[IRP_MJ_CREATE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_CLOSE] = KdaMonCreateClose; DriverObject-\u0026gt;MajorFunction[IRP_MJ_DEVICE_CONTROL] = KdaMonDeviceControl; IRP_MJ_CREATE corresponds to a CreateFile call on the client side. Most drivers simply complete the IRP with a success status, which is the case for KDAMonitor at this stage IRP_MJ_CLOSE is the opposite, triggered by CloseHandle IRP_MJ_DEVICE_CONTROL is the real entry point for communication: every DeviceIoControl request from the client goes through this code, with the requested IOCTL code stored in the IRP\u0026rsquo;s IO_STACK_LOCATION There are other dispatch routines as well — see the official documentation: DRIVER_DISPATCH callback function (wdm.h).\nOnce a dispatch routine decides to handle an IRP, it must always complete it via IoCompleteRequest.\nAnatomy of an IOCTL code # An IOCTL code is simply a numeric value that identifies a specific command for the driver. This code isn\u0026rsquo;t arbitrary — it\u0026rsquo;s built using the CTL_CODE macro, which packs several pieces of information into a single 32-bit integer:\n#define CTL_CODE(DeviceType, Function, Method, Access) \\ (((DeviceType) \u0026lt;\u0026lt; 16) | ((Access) \u0026lt;\u0026lt; 14) | ((Function) \u0026lt;\u0026lt; 2) | (Method)) DeviceType: the target device type. Values 0–32767 are reserved for Microsoft, while 32768 (0x8000) and above are free for third-party developers — exactly the value used by KDAMON_DEVICE_TYPE Function: the internal code for the requested operation. Values 0–2047 are reserved for Microsoft, while 2048 (0x800) and above are free — again, the starting value chosen for IOCTL_KDAMON_ECHO Method: the buffer transfer method — METHOD_BUFFERED, already covered in the previous section, or the Direct I/O variants (METHOD_IN_DIRECT, METHOD_OUT_DIRECT), or METHOD_NEITHER, where the driver receives raw pointers directly and must validate them itself Access: the access level required to send this IOCTL — FILE_ANY_ACCESS in our case, which doesn\u0026rsquo;t impose any particular restriction Here\u0026rsquo;s how these pieces combine in kdamon_shared.h to define our first IOCTL, a simple echo:\n#define KDAMON_DEVICE_TYPE 0x8000 #define IOCTL_KDAMON_ECHO CTL_CODE(KDAMON_DEVICE_TYPE, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS) This file also defines the request and reply structures associated with this IOCTL:\ntypedef struct _KDAMON_ECHO_REQUEST { ULONG Value; } KDAMON_ECHO_REQUEST, * PKDAMON_ECHO_REQUEST; typedef struct _KDAMON_ECHO_REPLY { ULONG Value; } KDAMON_ECHO_REPLY, * PKDAMON_ECHO_REPLY; This kdamon_shared.h file is meant to be shared between the driver and the client — the only way to guarantee both sides agree on the same IOCTL code and the same data structures.\nPutting it all together: ioctl.c # Here\u0026rsquo;s how all these pieces (IRP dispatch, IRP_MJ_CREATE/CLOSE/DEVICE_CONTROL, I/O Stack Location, SystemBuffer, IOCTL code) come together in ioctl.c:\n#include \u0026#34;ioctl.h\u0026#34; #include \u0026#34;kdamon_shared.h\u0026#34; #include \u0026#34;kdamon_config.h\u0026#34; NTSTATUS KdaMonCreateClose(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp) { UNREFERENCED_PARAMETER(DeviceObject); Irp-\u0026gt;IoStatus.Status = STATUS_SUCCESS; Irp-\u0026gt;IoStatus.Information = 0; IoCompleteRequest(Irp, IO_NO_INCREMENT); return STATUS_SUCCESS; } NTSTATUS KdaMonDeviceControl(_In_ PDEVICE_OBJECT DeviceObject, _In_ PIRP Irp) { UNREFERENCED_PARAMETER(DeviceObject); PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp); NTSTATUS status = STATUS_SUCCESS; ULONG_PTR information = 0; switch (stack-\u0026gt;Parameters.DeviceIoControl.IoControlCode) { case IOCTL_KDAMON_ECHO: { ULONG inLen = stack-\u0026gt;Parameters.DeviceIoControl.InputBufferLength; ULONG outLen = stack-\u0026gt;Parameters.DeviceIoControl.OutputBufferLength; if (inLen \u0026lt; sizeof(KDAMON_ECHO_REQUEST) || outLen \u0026lt; sizeof(KDAMON_ECHO_REPLY)) { status = STATUS_BUFFER_TOO_SMALL; break; } PKDAMON_ECHO_REQUEST request = (PKDAMON_ECHO_REQUEST)Irp-\u0026gt;AssociatedIrp.SystemBuffer; KDAMON_ECHO_REPLY reply; reply.Value = request-\u0026gt;Value; RtlCopyMemory(Irp-\u0026gt;AssociatedIrp.SystemBuffer, \u0026amp;reply, sizeof(reply)); information = sizeof(reply); break; } default: status = STATUS_INVALID_DEVICE_REQUEST; KdPrint((DRIVER_TAG \u0026#34; [ERROR]: Unknown IOCTL 0x%08X\\n\u0026#34;, stack-\u0026gt;Parameters.DeviceIoControl.IoControlCode)); break; } Irp-\u0026gt;IoStatus.Status = status; Irp-\u0026gt;IoStatus.Information = information; IoCompleteRequest(Irp, IO_NO_INCREMENT); return status; } A few things worth noting about this code:\nKdaMonCreateClose handles both IRP_MJ_CREATE and IRP_MJ_CLOSE KdaMonDeviceControl first retrieves the current IO_STACK_LOCATION via IoGetCurrentIrpStackLocation, to access the requested IOCTL code (stack-\u0026gt;Parameters.DeviceIoControl.IoControlCode) Before processing the request, we check that the input and output buffers are large enough We access the data sent by the client via Irp-\u0026gt;AssociatedIrp.SystemBuffer Since Buffered I/O uses the same buffer for both input and output, we write the reply directly over the received request with RtlCopyMemory If the received IOCTL code doesn\u0026rsquo;t match anything known, we return STATUS_INVALID_DEVICE_REQUEST In all cases, the request is completed with IoCompleteRequest, as seen in the dispatch section Example: the test client # This client is deliberately minimal. Its only purpose is to validate that the full chain works: opening the device, sending an IOCTL request, receiving the reply. It\u0026rsquo;s not the project\u0026rsquo;s final client, which will be developed in detail in article 12 (v0.12).\n#include \u0026lt;windows.h\u0026gt; #include \u0026lt;stdio.h\u0026gt; #include \u0026#34;..\\include\\client.h\u0026#34; #include \u0026#34;..\\..\\driver\\include\\kdamon_shared.h\u0026#34; // CLIENT_TAG is defined in client.h as : \u0026#34;[KDAMonitor-Client]\u0026#34; int Error(const char* message) { printf(CLIENT_TAG \u0026#34; [ERROR]: %s (error=%lu)\\n\u0026#34;, message, GetLastError()); return 1; } int main(int argc, const char* argv[]) { HANDLE hDevice = CreateFileW( L\u0026#34;\\\\\\\\.\\\\KDAMonitor\u0026#34;, GENERIC_READ | GENERIC_WRITE, 0, NULL, OPEN_EXISTING, 0, NULL ); if (hDevice == INVALID_HANDLE_VALUE) { return Error(\u0026#34;Failed to open device\u0026#34;); } printf(CLIENT_TAG \u0026#34; [SUCCESS]: Device opened successfully\\n\u0026#34;); KDAMON_ECHO_REQUEST request; request.Value = 42; KDAMON_ECHO_REPLY reply; DWORD bytesReturned = 0; BOOL success = DeviceIoControl( hDevice, IOCTL_KDAMON_ECHO, \u0026amp;request, sizeof(request), \u0026amp;reply, sizeof(reply), \u0026amp;bytesReturned, NULL ); if (!success) { CloseHandle(hDevice); return Error(\u0026#34;DeviceIoControl failed\u0026#34;); } printf(CLIENT_TAG \u0026#34; [INFO]: Sent %lu, received %lu (bytes returned: %lu)\\n\u0026#34;, request.Value, reply.Value, bytesReturned); if (reply.Value == request.Value) { printf(CLIENT_TAG \u0026#34; [SUCCESS]: Echo matches\\n\u0026#34;); } else { printf(CLIENT_TAG \u0026#34; [ERROR]: Echo mismatch\\n\u0026#34;); } CloseHandle(hDevice); return 0; } The flow is simple:\nOpening the device via CreateFileW on \\\\.\\KDAMonitor — this is where the symbolic link created in device.c comes into play, triggering the IRP_MJ_CREATE IRP on the driver side Preparing the request: a KDAMON_ECHO_REQUEST structure with an arbitrary value (42) Sending it via DeviceIoControl, with the IOCTL_KDAMON_ECHO code — which triggers IRP_MJ_DEVICE_CONTROL on the driver side, bringing the whole dispatch mechanism seen earlier into play Checking the result: if reply.Value matches the value sent, the full round trip from usermode to kernel and back worked correctly Closing the handle via CloseHandle, which triggers IRP_MJ_CLOSE This small program is enough to validate the entire mechanism built in this article: device, symbolic link, IRP dispatch, and IOCTL handling. Below is a GIF demonstrating the project\u0026rsquo;s functionality:\nNote: the driver is compiled in Debug configuration (so that KdPrint works), while the client is compiled in Release. In Debug, the client compiles fine but fails to launch (some DLLs can\u0026rsquo;t be found at startup). Building it in Release works around the issue.\nConclusion # With these first two versions, KDAMonitor now has the strict essentials needed to exist as a driver: an entry point (DriverEntry), a device accessible from user space, and a working first IOCTL exchange.\nThis article turned out longer than planned — it might get trimmed down at some point.\nThe next ones will be shorter :-)\nThanks for reading all the way through, and see you in the third article of this series: The Event Queue: Structure and Synchronization in the Kernel.\n","date":"11 August 2026","externalUrl":null,"permalink":"/en/posts/02-driver-foundation/","section":"Blog","summary":"Building the KDAMonitor driver foundation: device object and the first IOCTL communication with a test client.","title":"02 - Building the Driver Foundation: DriverEntry, Device Object and IOCTL Communication","type":"posts"},{"content":"Welcome to the first series on my blog! It will serve both as an experiment for my upcoming article series and as a development journal for this project.\nThe project can be found in this repository: KDAMonitor.\nWhat is KDAMonitor? # KDAMonitor stands for Kernel Driver Activity Monitor; the goal of this project is to roughly replicate what Sysmon from Microsoft does. This project will be made up of two components:\nThe driver, which will collect events, log them to a .jsonl file, and send them to the client. Here is the list of events handled by the driver: process creation/termination image loading network connections registry modification/creation/deletion thread creation/termination The client, which will be an interface (a console app at first) for the user to observe events in real time The choice of these events is partly arbitrary, but it also reflects the basics of what\u0026rsquo;s typically monitored in malware analysis: which process was launched, which DLLs were loaded, who the process communicates with over the network, etc.\nWhy this project? # There are two main reasons why I decided to build this project rather than something else:\nLearning to develop a driver for the Windows kernel Building a useful tool for malware analysis that I can reuse myself (though nowhere near as capable as Sysmon) This project stems from a simple desire to learn and discover something new. I figured that to monitor system activity, what better than a kernel driver, which has the ability to see everything?\nNote that while writing this article I\u0026rsquo;m already at version 0.7 of the project, so I already have some hindsight on it.\nWithout further ado, let\u0026rsquo;s move on to the architecture I had in mind for this project at the start.\nPlanned Architecture # By the end of this project (at v1.0), the architecture will look like this:\nIn summary:\nAn event occurs (process, image, network connection, etc.). A sensor (callbacks or callouts) captures the event. The relevant sensor also adds the event to the queue. The event is dequeued and dispatched to two destinations: the log writer, which writes it to the .jsonl file the client, for real-time display Technologies Used # Language C IDE / Build Visual Studio 2026 Driver model WDM Test environment Windows 11 VM (VirtualBox), test signing disabled The technology choices will be explained throughout the series.\nPrerequisites # To properly follow this series, I recommend readers have a basic understanding of C and how the Windows kernel works internally. I\u0026rsquo;m learning right along with you :-), so I\u0026rsquo;ll do my best to make the articles as clear as possible.\nDetailed Development Plan # Below is a table outlining a detailed plan for each release of the project:\nVersion File(s) involved Feature v0.1 driver_entry.c Driver skeleton (load/unload) v0.2 device.c, ioctl.c Device + IOCTL v0.3 event_queue.c Kernel event queue v0.4 log_writer.c Logging v0.5 process_callback.c Process creation/termination monitoring v0.6 image_callback.c Image/DLL load monitoring v0.7 wfp_session.c WFP session setup v0.8 wfp_callout.c Network connection monitoring v0.9 registry_callback.c Registry activity monitoring v0.10 thread_callback.c Thread creation/termination monitoring v0.11 - Codebase structure cleanup v0.12 client/ Usermode client v1.0 - Stabilization + release Upcoming Articles # You can skip this section if you\u0026rsquo;d rather discover the articles as I write them. Also note that this schedule may change as development progresses.\nThis article serves as an introduction to the series; I\u0026rsquo;ll now briefly outline the content of the upcoming articles. Below, in order, are the articles to come along with a short description of their content:\nArticle Version(s) Title Main content 02 v0.1 – v0.2 Building the Driver Foundation: DriverEntry, Device Object and IOCTL Communication Creating the driver skeleton, DriverEntry/DriverUnload, DEVICE_OBJECT, symbolic link, IOCTL, and a first test usermode client. 03 v0.3 The Event Queue: Structure and Synchronization in the Kernel Designing the generic event structure, ring buffer, spinlock, queue, FIFO, unique identifiers, and first internal test. 04 v0.4 Writing Events to Disk: JSONL Logging, Wake Events and the First Crash Implementing the logging thread, creating JSONL files, using KEVENT to remove polling, the first crash (IRQL) and its resolution. 05 v0.5 The First Sensor: Monitoring Process Creation and Termination Using PsSetCreateProcessNotifyRoutineEx, integration into the event pipeline, JSON serialization, and the first real events. 06 v0.6 The Second Sensor: Tracking Image and DLL Loads Adding PsSetLoadImageNotifyRoutine, retrieving information about loaded DLLs/EXEs, integration with the existing system. 07 v0.7 Preparing Network Monitoring: Setting Up the WFP Session Introduction to the Windows Filtering Platform, opening the WFP session, creating the provider/sublayer, a second crash encountered and its fix. 08 v0.8 Monitoring Network Connections with Windows Filtering Platform Developing the WFP callout, intercepting outbound network connections, collecting PIDs, IP addresses, ports, and protocols. 09 v0.9 Monitoring Registry Activity Implementing registry callbacks (CmRegisterCallbackEx), monitoring key/value creation, modification, and deletion. 10 v0.10 Monitoring Thread Creation and Termination Adding the thread callback (PsSetCreateThreadNotifyRoutine), collecting thread creation and termination events. 11 v0.11 Refactoring KDAMonitor: Organizing the Codebase for Scalability Reorganizing the project\u0026rsquo;s folder structure, separating components, improving maintainability, and preparing for future growth. 12 v0.12 Building a Usermode Client for Real-Time Event Monitoring Developing a console client that communicates with the driver via IOCTL to display events in real time. 13 v1.0 KDAMonitor v1.0: Stabilization, Validation and Lessons Learned Validation against real samples in a VM, performance, project limitations, final documentation, development wrap-up, and future prospects. Thank you in advance to everyone who follows this series. If you have any questions, feedback, or just want to chat about the project, feel free to reach out by email or on LinkedIn.\nSee you in the next article, where we\u0026rsquo;ll actually start building the driver!\n","date":"7 August 2026","externalUrl":null,"permalink":"/en/posts/01-introduction-kdamonitor/","section":"Blog","summary":"Introduction to the KDAMonitor project and its goals.","title":"01 - Introduction to KDAMonitor: Building a Windows Kernel Driver","type":"posts"},{"content":"","date":"24 July 2026","externalUrl":null,"permalink":"/en/tags/driver/","section":"Tags","summary":"","title":"Driver","type":"tags"},{"content":"","date":"24 July 2026","externalUrl":"https://github.com/HalfTimeOfLife/KDAMonitor","permalink":"/en/projects/kdamonitor/","section":"Projects","summary":"Windows kernel driver for malware analysis, logging process, image load, network, and registry activity in real time.","title":"KDAMonitor","type":"projects"},{"content":"","date":"16 July 2026","externalUrl":null,"permalink":"/en/tags/misp/","section":"Tags","summary":"","title":"MISP","type":"tags"},{"content":"","date":"16 July 2026","externalUrl":"https://github.com/HalfTimeOfLife/mispSK","permalink":"/en/projects/mispsk/","section":"Projects","summary":"Collection of Python scripts automating operations on MISP instances through PyMISP.","title":"mispSK","type":"projects"},{"content":"","date":"16 July 2026","externalUrl":null,"permalink":"/en/tags/threat-intelligence/","section":"Tags","summary":"","title":"Threat Intelligence","type":"tags"},{"content":"","date":"25 June 2026","externalUrl":"https://github.com/HalfTimeOfLife/attackmap","permalink":"/en/projects/attackmap/","section":"Projects","summary":"CLI tool that generates MITRE ATT\u0026CK heatmaps from ATT\u0026CK Navigator JSON layers.","title":"attackmap","type":"projects"},{"content":"","date":"25 June 2026","externalUrl":null,"permalink":"/en/tags/cti/","section":"Tags","summary":"","title":"CTI","type":"tags"},{"content":"","date":"25 June 2026","externalUrl":null,"permalink":"/en/tags/mitre-attck/","section":"Tags","summary":"","title":"MITRE ATT\u0026CK","type":"tags"},{"content":"","date":"25 June 2026","externalUrl":null,"permalink":"/en/tags/visualization/","section":"Tags","summary":"","title":"Visualization","type":"tags"},{"content":"","date":"19 June 2026","externalUrl":"https://github.com/HalfTimeOfLife/scowl","permalink":"/en/projects/scowl/","section":"Projects","summary":"Discord bot for static file triage and analysis.","title":"scOWL","type":"projects"},{"content":"","date":"19 June 2026","externalUrl":null,"permalink":"/en/tags/static-analysis/","section":"Tags","summary":"","title":"Static Analysis","type":"tags"},{"content":"","date":"18 February 2026","externalUrl":"https://github.com/HalfTimeOfLife/GhidraMAT","permalink":"/en/projects/ghidramat/","section":"Projects","summary":"Ghidra script framework for automated static detection of malware behaviors: anti-debug, anti-VM, packing, C2 indicators, process injection, persistence and defense impairment.","title":"GhidraMAT","type":"projects"},{"content":"","date":"2 January 2026","externalUrl":null,"permalink":"/en/tags/automation/","section":"Tags","summary":"","title":"Automation","type":"tags"},{"content":"","date":"2 January 2026","externalUrl":null,"permalink":"/en/tags/dynamic-analysis/","section":"Tags","summary":"","title":"Dynamic Analysis","type":"tags"},{"content":"","date":"2 January 2026","externalUrl":"https://github.com/HalfTimeOfLife/malauto-windbg","permalink":"/en/projects/malauto/","section":"Projects","summary":"WinDbg extension for automated Windows malware analysis. Sets breakpoints, dumps memory regions in PE format and generates structured reports.","title":"Malauto","type":"projects"},{"content":"","date":"2 January 2026","externalUrl":null,"permalink":"/en/tags/windbg/","section":"Tags","summary":"","title":"WinDbg","type":"tags"},{"content":"","date":"29 August 2025","externalUrl":"https://eshard.com/posts/windows-anti-vm-detection-bypass","permalink":"/en/publications/anti-emulation-article/","section":"Publications","summary":"Article explaining the main emulation detection techniques used by Windows malware, along with bypass strategies.","title":"Anti-Emulation Techniques on Windows","type":"publications"},{"content":"","date":"29 August 2025","externalUrl":null,"permalink":"/en/tags/anti-vm/","section":"Tags","summary":"","title":"Anti-VM","type":"tags"},{"content":"","date":"29 August 2025","externalUrl":null,"permalink":"/en/tags/article/","section":"Tags","summary":"","title":"Article","type":"tags"},{"content":"","date":"29 August 2025","externalUrl":null,"permalink":"/en/tags/eshard/","section":"Tags","summary":"","title":"EShard","type":"tags"},{"content":"Theses, articles and public resources.\n","date":"29 August 2025","externalUrl":null,"permalink":"/en/publications/","section":"Publications","summary":"","title":"Publications","type":"publications"},{"content":"","date":"22 August 2025","externalUrl":null,"permalink":"/tags/m%C3%A9moire/","section":"Tags","summary":"","title":"Mémoire","type":"tags"},{"content":"","date":"22 August 2025","externalUrl":null,"permalink":"/en/tags/pdf/","section":"Tags","summary":"","title":"PDF","type":"tags"},{"content":"","date":"22 August 2025","externalUrl":null,"permalink":"/en/tags/thesis/","section":"Tags","summary":"","title":"Thesis","type":"tags"},{"content":"","date":"22 August 2025","externalUrl":"https://halftimeoflife.github.io/assets/publications/master-thesis-anti-emulation-en.pdf","permalink":"/en/publications/master-thesis-anti-vm/","section":"Publications","summary":"In-depth research on anti-emulation techniques and their bypass in the context of Time Travel Debugging (TTD) in full system emulation.","title":"Thesis – Detection of Anti-VM Protections Used by Malware","type":"publications"},{"content":"","date":"22 August 2025","externalUrl":null,"permalink":"/en/tags/ttd/","section":"Tags","summary":"","title":"TTD","type":"tags"},{"content":"","date":"18 July 2025","externalUrl":null,"permalink":"/en/tags/apt29/","section":"Tags","summary":"","title":"APT29","type":"tags"},{"content":"","date":"18 July 2025","externalUrl":"https://github.com/HalfTimeOfLife/Analysis_wine_APT29_2025","permalink":"/en/projects/wine-apt29/","section":"Projects","summary":"Analysis of the WINE malware attributed to APT29 in 2025, including a proof of concept.","title":"WINE Malware Analysis – APT29 (2025)","type":"projects"},{"content":"","date":"6 June 2025","externalUrl":null,"permalink":"/en/tags/dll-injection/","section":"Tags","summary":"","title":"DLL Injection","type":"tags"},{"content":"","date":"6 June 2025","externalUrl":null,"permalink":"/en/tags/hooking/","section":"Tags","summary":"","title":"Hooking","type":"tags"},{"content":"","date":"6 June 2025","externalUrl":"https://github.com/HalfTimeOfLife/Panoptiv","permalink":"/en/projects/panoptiv/","section":"Projects","summary":"DLL using hooking techniques to bypass anti-VM protections.","title":"Panoptiv","type":"projects"},{"content":"","date":"28 March 2025","externalUrl":null,"permalink":"/en/tags/educational/","section":"Tags","summary":"","title":"Educational","type":"tags"},{"content":"","date":"28 March 2025","externalUrl":"https://www.youtube.com/playlist?list=PLM4pMV6TkMl3bdBKI0eIP6roX72TX72HT","permalink":"/en/publications/anti-vm-youtube/","section":"Publications","summary":"Educational video series presenting virtualization detection mechanisms and their implications in malware analysis.","title":"Introduction to Anti-VM Protections","type":"publications"},{"content":"","date":"28 March 2025","externalUrl":null,"permalink":"/tags/vulgarisation/","section":"Tags","summary":"","title":"Vulgarisation","type":"tags"},{"content":"","date":"28 March 2025","externalUrl":null,"permalink":"/en/tags/youtube/","section":"Tags","summary":"","title":"YouTube","type":"tags"},{"content":"","date":"15 January 2025","externalUrl":null,"permalink":"/en/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":"","date":"15 January 2025","externalUrl":"https://halftimeoflife.github.io/assets/publications/linux-kernel-rootkit-2025.pdf","permalink":"/en/publications/linux-rootkit/","section":"Publications","summary":"Study of the state of Linux rootkits in 2025 through the example of the KoviD rootkit. Analysis of stealth, persistence and kernel security bypass techniques.","title":"Linux Kernel Rootkit in 2025","type":"publications"},{"content":"","date":"15 January 2025","externalUrl":null,"permalink":"/en/tags/rootkit/","section":"Tags","summary":"","title":"Rootkit","type":"tags"},{"content":"","date":"15 May 2024","externalUrl":"https://halftimeoflife.github.io/assets/publications/pir-paillier.pdf","permalink":"/en/publications/pir-paillier/","section":"Publications","summary":"Implementation of a PIR (Private Information Retrieval) protocol in C, based on Paillier homomorphic encryption to enable anonymous database querying.","title":"Anonymous Database Query Protocol","type":"publications"},{"content":"","date":"15 May 2024","externalUrl":null,"permalink":"/tags/cryptographie/","section":"Tags","summary":"","title":"Cryptographie","type":"tags"},{"content":"","date":"15 May 2024","externalUrl":null,"permalink":"/en/tags/cryptography/","section":"Tags","summary":"","title":"Cryptography","type":"tags"},{"content":"","date":"15 May 2024","externalUrl":null,"permalink":"/en/tags/pir/","section":"Tags","summary":"","title":"PIR","type":"tags"},{"content":" Master\u0026rsquo;s graduate in Cryptology and Computer Security, specialising in reverse engineering and malware analysis. Download my resume Technical Skills # Languages\nPython · C · x86/x64 Assembly · LaTeX Static Analysis\nGhidra · Binary Ninja Dynamic Analysis\nGDB · WinDbg · Reven TTD Virtualisation\nQEMU · VMware · VirtualBox Pentest\nNmap · Burp Suite · Wireshark · Metasploit CTI\nYARA · MITRE ATT\u0026CK Systems\nLinux · Windows Experience # Reverse Engineering Internship — eShard March – August 2025 Pessac, France Implementation, detection and bypass of anti-emulation techniques for Windows. Work on Time Travel Debugging (TTD) Reven in full system emulation. Implemented a wide range of anti-VM techniques (CPUID, RDTSC, Windows API, ...) in x86 assembly and C Designed an automated binary generation pipeline embedding anti-VM techniques, used to validate detection coverage Reverse-engineered binaires and malware samples to identify anti-emulation markers (Ghidra, Binary Ninja) Automated detection of suspicious behaviours through TTD scripting (Python) Bypassed anti-VM protections via Windows API patching (WinDbg) and QEMU configuration tuning Produced educational content (YouTube playlist) and a technical article published on eShard's blog Documented all research under Jupyter Notebooks Education # Master\u0026#39;s in Cryptology \u0026amp; Computer Security 2023 – 2025 University of Bordeaux mastercsi.labri.fr SemesterCourses S7Arithmetic · Programming · Formal Computation · Linear Algebra · Operating Systems S8Cryptology · Software Security · Information Theory · Complexity Theory · Parallel Architecture Programming S9Cryptanalysis · Post Quantum Cryptography · Smart Cards · Network Security · System Security S10Industry internship Bachelor\u0026#39;s in Mathematics \u0026amp; Computer Science 2020 – 2023 University of Bordeaux Certifications # BTL1 – Blue Team Level 1 July 2026 Security Blue Team Score: 85% - Silver Challenge Coin Phishing Analysis · SIEM · Digital Forensics · Incident Response · SOC Verify credential Download certificate CTF \u0026amp; Competitions # Year Competition Rank 2025 TUCTF 208th 2024 EKOPARTY CTF 18th 2024 HKCERT CTF 94th 2024 1337UP Live CTF 189th Contact # Email\nec.charbonnier@gmail.com GitHub\nHalfTimeOfLife LinkedIn\nelouan-charbonnier RootMe\nAzralt ","externalUrl":null,"permalink":"/en/about/","section":"Home","summary":"","title":"About","type":"page"},{"content":"","externalUrl":null,"permalink":"/en/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":"","externalUrl":null,"permalink":"/en/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"}]