NtNotifyChangeDirectoryFile
Enregistre une demande de notification asynchrone des changements de fichiers dans un répertoire ouvert.
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
| Name | Type | Dir | Description |
|---|---|---|---|
| FileHandle | HANDLE | in | Handle vers un répertoire ouvert avec l'accès FILE_LIST_DIRECTORY. |
| Event | HANDLE | in | Événement optionnel signalé à l'achèvement. NULL si un APC ou une attente alertable est utilisé. |
| ApcRoutine | PIO_APC_ROUTINE | in | APC usermode optionnel délivré à l'achèvement dans un thread alertable. |
| ApcContext | PVOID | in | Contexte défini par l'appelant transmis à ApcRoutine. |
| IoStatusBlock | PIO_STATUS_BLOCK | out | Reçoit le statut final et le nombre d'octets écrits dans Buffer. |
| Buffer | PVOID | out | Buffer aligné DWORD qui reçoit une chaîne d'enregistrements FILE_NOTIFY_INFORMATION. |
| Length | ULONG | in | Taille de Buffer en octets. Des enregistrements plus grands que Length provoquent STATUS_NOTIFY_ENUM_DIR — l'appelant doit ré-énumérer. |
| CompletionFilter | ULONG | in | Masque de bits FILE_NOTIFY_CHANGE_* (FILE_NAME, ATTRIBUTES, SIZE, LAST_WRITE, SECURITY, ...). |
| WatchTree | BOOLEAN | in | TRUE pour surveiller récursivement tous les sous-répertoires ; FALSE pour surveiller uniquement le répertoire immédiat. |
IDs de syscalls par version de Windows
| Version de Windows | ID de syscall | Build |
|---|---|---|
| Win10 1507 | 0x104 | win10-1507 |
| Win10 1607 | 0x109 | win10-1607 |
| Win10 1703 | 0x10D | win10-1703 |
| Win10 1709 | 0x10E | win10-1709 |
| Win10 1803 | 0x110 | win10-1803 |
| Win10 1809 | 0x111 | win10-1809 |
| Win10 1903 | 0x112 | win10-1903 |
| Win10 1909 | 0x112 | win10-1909 |
| Win10 2004 | 0x117 | win10-2004 |
| Win10 20H2 | 0x117 | win10-20h2 |
| Win10 21H1 | 0x117 | win10-21h1 |
| Win10 21H2 | 0x118 | win10-21h2 |
| Win10 22H2 | 0x118 | win10-22h2 |
| Win11 21H2 | 0x11E | win11-21h2 |
| Win11 22H2 | 0x11F | win11-22h2 |
| Win11 23H2 | 0x11F | win11-23h2 |
| Win11 24H2 | 0x121 | win11-24h2 |
| Server 2016 | 0x109 | winserver-2016 |
| Server 2019 | 0x111 | winserver-2019 |
| Server 2022 | 0x11D | winserver-2022 |
| Server 2025 | 0x121 | winserver-2025 |
Module noyau
APIs liées
Stub du syscall
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
Notes non documentées
C'est l'épine dorsale noyau de `ReadDirectoryChangesW` et de tous les frameworks de surveillance de fichiers sous Windows — `FileSystemWatcher` de .NET, `fs.watch` de Node, `fsnotify` de Go, `notify` de Rust, etc. L'appel est **asynchrone** : il renvoie STATUS_PENDING immédiatement et complète plus tard via l'IO_STATUS_BLOCK, un Event ou un APC. Le buffer résultat est une chaîne compacte d'enregistrements `FILE_NOTIFY_INFORMATION` (longueur variable, liés par `NextEntryOffset`). `WatchTree=TRUE` est relativement coûteux — l'IO manager doit faire remonter chaque changement de répertoire dans la liste de filtres du parent. NTFS implémente le journal de changements au niveau du volume ; pour les partages SMB, le redirecteur transmet la requête.
Usage courant par les malwares
Deux patterns d'abus principaux. D'abord, les **implants watchdog** ouvrent un handle vers un répertoire que l'opérateur risque d'utiliser (ex. `C:\Tools`, `%USERPROFILE%\Downloads`, `C:\Windows\Temp`) et utilisent NtNotifyChangeDirectoryFile avec `WatchTree=TRUE` pour réagir dès qu'un analyste pose Procmon.exe, x64dbg.exe, Wireshark.exe ou un installeur EDR — à ce moment-là l'implant s'auto-supprime, exfiltre moins agressivement, ou tue sa tâche planifiée. Ensuite, **C2 déclenché** : l'implant surveille un chemin fixe (souvent les téléchargements ou un dossier OneDrive synchronisé) et traite l'apparition d'un fichier nommé spécifiquement comme le signal de l'étape suivante — un dead-drop local discret qui évite tout trafic C2 jusqu'à activation. Certains ransomwares (Royal, variantes Black Basta) l'utilisent en plus pour détecter les nouveaux volumes USB ou réseau montés en cours de chiffrement, afin que le worker ramasse le nouveau volume sans redémarrage.
Opportunités de détection
Du point de vue défensif, `NtNotifyChangeDirectoryFile` est en très grande majorité bénin — Explorer, Search Indexer, OneDrive, les AV, les IDE et les moteurs antivirus l'appellent en permanence. Le signal réside dans *quels* répertoires sont surveillés et *qui* les surveille : un processus non-Explorer qui maintient une surveillance récursive sur `C:\` ou `%SystemRoot%` est inhabituel. Sysmon Event ID 11 (FileCreate) est la primitive défensive orthogonale — elle expose les événements fichier qu'il faudrait sinon dériver de ce syscall. Le provider ETW Microsoft-Windows-Kernel-FileFilter / Microsoft-Windows-Kernel-File expose les opérations sous-jacentes IRP_MJ_DIRECTORY_CONTROL mineur IRP_MN_NOTIFY_CHANGE_DIRECTORY. Les EDR capturant la télémétrie des handles peuvent pivoter d'une ouverture suspecte de répertoire (FILE_LIST_DIRECTORY sur un chemin sensible) vers les appels notify suivants.
Exemples de syscalls directs
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 ENDPcWatchdog 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)
}Mappings MITRE ATT&CK
Last verified: 2026-05-20