> Windows Syscalls
ntoskrnl.exeT1083T1497T1106

NtNotifyChangeDirectoryFile

Registers an asynchronous notification request for filesystem changes within an opened directory handle.

Prototype

NTSTATUS NtNotifyChangeDirectoryFile(
  HANDLE             FileHandle,
  HANDLE             Event,
  PIO_APC_ROUTINE    ApcRoutine,
  PVOID              ApcContext,
  PIO_STATUS_BLOCK   IoStatusBlock,
  PVOID              Buffer,
  ULONG              Length,
  ULONG              CompletionFilter,
  BOOLEAN            WatchTree
);

Arguments

NameTypeDirDescription
FileHandleHANDLEinHandle to a directory opened with FILE_LIST_DIRECTORY access.
EventHANDLEinOptional event signaled on completion. NULL when an APC or alertable wait is used.
ApcRoutinePIO_APC_ROUTINEinOptional user-mode APC delivered on completion in an alertable thread.
ApcContextPVOIDinCaller-defined context passed to ApcRoutine.
IoStatusBlockPIO_STATUS_BLOCKoutReceives final status and the number of bytes written to Buffer.
BufferPVOIDoutDWORD-aligned buffer that receives a chain of FILE_NOTIFY_INFORMATION records.
LengthULONGinSize of Buffer in bytes. Records larger than Length cause STATUS_NOTIFY_ENUM_DIR — caller must re-enumerate.
CompletionFilterULONGinBitmask of FILE_NOTIFY_CHANGE_* flags (FILE_NAME, ATTRIBUTES, SIZE, LAST_WRITE, SECURITY, ...).
WatchTreeBOOLEANinTRUE to watch all subdirectories recursively; FALSE to watch only the immediate directory.

Syscall IDs by Windows version

Windows versionSyscall IDBuild
Win10 15070x104win10-1507
Win10 16070x109win10-1607
Win10 17030x10Dwin10-1703
Win10 17090x10Ewin10-1709
Win10 18030x110win10-1803
Win10 18090x111win10-1809
Win10 19030x112win10-1903
Win10 19090x112win10-1909
Win10 20040x117win10-2004
Win10 20H20x117win10-20h2
Win10 21H10x117win10-21h1
Win10 21H20x118win10-21h2
Win10 22H20x118win10-22h2
Win11 21H20x11Ewin11-21h2
Win11 22H20x11Fwin11-22h2
Win11 23H20x11Fwin11-23h2
Win11 24H20x121win11-24h2
Server 20160x109winserver-2016
Server 20190x111winserver-2019
Server 20220x11Dwinserver-2022
Server 20250x121winserver-2025

Kernel module

ntoskrnl.exeNtNotifyChangeDirectoryFile

Related APIs

ReadDirectoryChangesWReadDirectoryChangesExWNtNotifyChangeDirectoryFileExFindFirstChangeNotificationWFindNextChangeNotificationNtQueryDirectoryFile

Syscall stub

4C 8B D1            mov r10, rcx
B8 21 01 00 00      mov eax, 0x121
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

This is the kernel backbone of `ReadDirectoryChangesW` and of every filesystem-watcher framework on Windows — .NET's `FileSystemWatcher`, Node's `fs.watch`, Go's `fsnotify`, Rust's `notify`, etc. The call is **asynchronous**: it returns STATUS_PENDING immediately and completes later through the IO_STATUS_BLOCK, an Event, or an APC. The result buffer is a packed chain of `FILE_NOTIFY_INFORMATION` records (variable-length, linked by `NextEntryOffset`). `WatchTree=TRUE` is comparatively expensive — the IO manager has to walk every directory change through the parent's filter list. NTFS implements the actual change journal at the volume level; for SMB shares the redirector forwards the request.

Common malware usage

Two primary abuse patterns. First, **watchdog implants** open a handle to a directory the operator might use (e.g. `C:\Tools`, `%USERPROFILE%\Downloads`, `C:\Windows\Temp`) and use NtNotifyChangeDirectoryFile with `WatchTree=TRUE` to react the moment an analyst drops Procmon.exe, x64dbg.exe, Wireshark.exe, or an EDR installer — at which point the implant self-deletes, exfils less aggressively, or kills its scheduled task. Second, **trigger-driven C2**: the implant watches a fixed path (often the user's Downloads or a synced OneDrive folder) and treats the appearance of a specially-named file as the next-stage signal — a covert local dead-drop that avoids any C2 traffic until activated. Some ransomware (Royal, Black Basta variants) additionally uses it to detect newly-mounted USB or network drives mid-encryption, so the worker picks up the new volume without restarting.

Detection opportunities

From a defender's perspective, `NtNotifyChangeDirectoryFile` itself is overwhelmingly benign — Explorer, Search Indexer, OneDrive, AV products, IDE file-watchers and antivirus engines call it constantly. The signal is in *which* directories are watched and *who* is watching them: a non-Explorer process holding a recursive watch on `C:\` or `%SystemRoot%` is unusual. Sysmon Event ID 11 (FileCreate) is the orthogonal defender primitive — it lets you see the file events you would otherwise have to derive from this syscall. ETW provider Microsoft-Windows-Kernel-FileFilter / Microsoft-Windows-Kernel-File surfaces the underlying IRP_MJ_DIRECTORY_CONTROL minor IRP_MN_NOTIFY_CHANGE_DIRECTORY operations. EDRs that capture process handle telemetry can pivot from a suspicious directory open (FILE_LIST_DIRECTORY on a sensitive path) to subsequent notify calls.

Direct syscall examples

asmx64 direct stub (Win11 24H2)

; Direct syscall stub for NtNotifyChangeDirectoryFile (SSN 0x121 on Win11 24H2)
NtNotifyChangeDirectoryFile PROC
    mov  r10, rcx          ; syscall convention
    mov  eax, 121h         ; SSN — varies per build, prefer dynamic resolution
    syscall
    ret
NtNotifyChangeDirectoryFile ENDP

cWatchdog implant — watch for analyst tooling

// Open %USERPROFILE%\Downloads and react when known analyst tools land there.
// On hit, the implant flips to dormant mode (skips its next beacon round).
#include <windows.h>
#include <winternl.h>

static const WCHAR* k_targets[] = {
    L"procmon.exe", L"procmon64.exe", L"x64dbg.exe", L"x32dbg.exe",
    L"wireshark.exe", L"fakenet.exe", L"tcpview.exe", L"autoruns.exe"
};

VOID WatchdogThread(VOID) {
    HANDLE hDir = CreateFileW(L"C:\\Users\\Public\\Downloads",
        FILE_LIST_DIRECTORY,
        FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
        NULL, OPEN_EXISTING,
        FILE_FLAG_BACKUP_SEMANTICS, NULL);
    if (hDir == INVALID_HANDLE_VALUE) return;

    BYTE  buf[4096];
    DWORD got = 0;
    while (ReadDirectoryChangesW(hDir, buf, sizeof(buf), TRUE,
               FILE_NOTIFY_CHANGE_FILE_NAME | FILE_NOTIFY_CHANGE_CREATION,
               &got, NULL, NULL)) {
        FILE_NOTIFY_INFORMATION* p = (FILE_NOTIFY_INFORMATION*)buf;
        for (;;) {
            for (int i = 0; i < ARRAYSIZE(k_targets); ++i) {
                if (wcsstr(p->FileName, k_targets[i])) {
                    g_dormant_until = GetTickCount64() + 24ULL*3600*1000;
                    break;
                }
            }
            if (!p->NextEntryOffset) break;
            p = (FILE_NOTIFY_INFORMATION*)((BYTE*)p + p->NextEntryOffset);
        }
    }
}

rustTrigger-driven C2 dead-drop via direct syscall

// Cargo: ntapi = "0.4", windows-sys = "0.59"
// Block on the directory until a file named "runme.bin" appears,
// then read it and pass to stage-2 loader.
use std::ptr::null_mut;
use ntapi::ntioapi::{NtNotifyChangeDirectoryFile, IO_STATUS_BLOCK,
                    FILE_NOTIFY_INFORMATION};
use windows_sys::Win32::Storage::FileSystem::*;

pub unsafe fn wait_for_trigger(h_dir: isize) -> Option<Vec<u16>> {
    let mut buf = vec![0u8; 4096];
    let mut iosb: IO_STATUS_BLOCK = std::mem::zeroed();
    let status = NtNotifyChangeDirectoryFile(
        h_dir as _, null_mut(), None, null_mut(),
        &mut iosb,
        buf.as_mut_ptr() as _, buf.len() as u32,
        FILE_NOTIFY_CHANGE_FILE_NAME, 0); // 0 = no subtree
    if status < 0 { return None; }
    let info = &*(buf.as_ptr() as *const FILE_NOTIFY_INFORMATION);
    let n = (info.FileNameLength as usize) / 2;
    let name: Vec<u16> = std::slice::from_raw_parts(info.FileName.as_ptr(), n).to_vec();
    Some(name)
}

MITRE ATT&CK mappings

Last verified: 2026-05-20