> Windows Syscalls
ntoskrnl.exeT1027.011T1027.013T1106

NtGetWriteWatch

Retrieves the set of pages written to within a MEM_WRITE_WATCH region since the last reset.

Prototype

NTSTATUS NtGetWriteWatch(
  HANDLE      ProcessHandle,
  ULONG       Flags,
  PVOID       BaseAddress,
  SIZE_T      RegionSize,
  PVOID       *UserAddressArray,
  PULONG_PTR  EntriesInUserAddressArray,
  PULONG      Granularity
);

Arguments

NameTypeDirDescription
ProcessHandleHANDLEinHandle to the target process. Typically NtCurrentProcess().
FlagsULONGinWRITE_WATCH_FLAG_RESET (1) to atomically clear the watch state after reading; 0 otherwise.
BaseAddressPVOIDinBase address of the MEM_WRITE_WATCH region.
RegionSizeSIZE_TinSize of the region in bytes. Must lie within the original allocation.
UserAddressArrayPVOID*outCaller-supplied array that receives the addresses of pages that were written to.
EntriesInUserAddressArrayPULONG_PTRin/outOn input: capacity of the array. On output: number of dirty pages actually returned.
GranularityPULONGoutReceives the page granularity (typically PAGE_SIZE, 4096 on x64).

Syscall IDs by Windows version

Windows versionSyscall IDBuild
Win10 15070xECwin10-1507
Win10 16070xEFwin10-1607
Win10 17030xF2win10-1703
Win10 17090xF3win10-1709
Win10 18030xF4win10-1803
Win10 18090xF5win10-1809
Win10 19030xF6win10-1903
Win10 19090xF6win10-1909
Win10 20040xFBwin10-2004
Win10 20H20xFBwin10-20h2
Win10 21H10xFBwin10-21h1
Win10 21H20xFCwin10-21h2
Win10 22H20xFCwin10-22h2
Win11 21H20x101win11-21h2
Win11 22H20x102win11-22h2
Win11 23H20x102win11-23h2
Win11 24H20x104win11-24h2
Server 20160xEFwinserver-2016
Server 20190xF5winserver-2019
Server 20220x100winserver-2022
Server 20250x104winserver-2025

Kernel module

ntoskrnl.exeNtGetWriteWatch

Related APIs

GetWriteWatchResetWriteWatchNtResetWriteWatchVirtualAllocNtAllocateVirtualMemoryNtProtectVirtualMemory

Syscall stub

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

NtGetWriteWatch only works on regions previously allocated with MEM_WRITE_WATCH (via NtAllocateVirtualMemory). The kernel arms PTE-level dirty tracking on those pages; each subsequent write flips a bit in the per-PTE accessed/dirty state and the kernel snapshots it. The user-mode wrapper `GetWriteWatch` (kernel32!GetWriteWatch → ntdll!NtGetWriteWatch) is rarely used by typical applications — the only mainstream consumers are the .NET CLR's garbage collector (for card-table optimization), V8/Chakra-style JIT engines, and SQL Server's buffer-pool tracking. Pass WRITE_WATCH_FLAG_RESET to combine the query and reset into one syscall; otherwise follow with NtResetWriteWatch.

Common malware usage

The killer feature for offensive tradecraft is **sleep-mask designs that re-encrypt only dirty pages**. Implants like Ekko, Foliage, and the Cronos sleep obfuscator allocate their .text / heap pages with MEM_WRITE_WATCH (or shadow-mirror them in a watched region). When the implant wakes from a sleep cycle to process a beacon callback it dirties only a subset of pages; before sleeping again it calls NtGetWriteWatch to enumerate exactly which pages need to be re-encrypted, avoiding the cost of re-encrypting megabytes of unchanged code. This dramatically reduces the per-sleep CPU cost while preserving full memory encryption between callbacks. Combined with timer-queue ROP chains (Ekko) or APC sequencing (Foliage), this is the modern state-of-the-art for in-memory implant survival.

Detection opportunities

MEM_WRITE_WATCH regions are visible in `!vad` output (a `VadWriteWatch` flag is set on the VAD node) and in `VirtualQueryEx` (the `MEM_WRITE_WATCH` bit in `Type`). User-mode EDRs rarely scan for this. The most reliable kernel signal is the unusual *combination* of (a) a MEM_WRITE_WATCH region with PAGE_EXECUTE_READWRITE protection, (b) repeated NtGetWriteWatch calls correlated with NtDelayExecution intervals matching common C2 jitter patterns, and (c) NtProtectVirtualMemory toggles between RW and RX on the same VAD. Memory-scanning EDRs (e.g. Elastic, CrowdStrike memory scanner) can catch the re-encrypted blob during the sleep window if they timestamp page entropy.

Direct syscall examples

cSleep-mask dirty-page tracking (Ekko-style)

// Allocate the implant's runtime heap with MEM_WRITE_WATCH, then track which
// pages were dirtied during the awake window so we only re-encrypt those.
#include <windows.h>

#define PAGE_COUNT 1024
#define REGION_SIZE (PAGE_COUNT * 4096)

static PVOID g_region;
static PVOID g_dirty[PAGE_COUNT];

void implant_init(void) {
    g_region = VirtualAlloc(NULL, REGION_SIZE,
                            MEM_RESERVE | MEM_COMMIT | MEM_WRITE_WATCH,
                            PAGE_READWRITE);
}

void implant_sleep_and_encrypt(DWORD ms) {
    ULONG_PTR count = PAGE_COUNT;
    ULONG     gran  = 0;

    // Collect dirty pages and atomically reset the watch state.
    if (GetWriteWatch(WRITE_WATCH_FLAG_RESET, g_region, REGION_SIZE,
                      g_dirty, &count, &gran) == 0) {
        for (ULONG_PTR i = 0; i < count; ++i) {
            encrypt_page(g_dirty[i], gran);  // XOR / RC4 / ChaCha20 per page
        }
    }
    Sleep(ms);
    for (ULONG_PTR i = 0; i < count; ++i) {
        decrypt_page(g_dirty[i], gran);
    }
}

asmx64 direct stub (Win11 24H2, SSN 0x104)

NtGetWriteWatch PROC
    mov  r10, rcx
    mov  eax, 104h
    syscall
    ret
NtGetWriteWatch ENDP

rustGC-style card table

// Treat a MEM_WRITE_WATCH region as a coarse card table — sweep dirty pages
// per minor GC cycle without scanning the whole heap.
use windows_sys::Win32::System::Memory::{GetWriteWatch, WRITE_WATCH_FLAG_RESET};

pub fn collect_dirty(region: *mut u8, size: usize, sink: &mut Vec<*mut u8>) {
    let mut buf: Vec<*mut core::ffi::c_void> = vec![core::ptr::null_mut(); size / 4096];
    let mut count: usize = buf.len();
    let mut gran: u32 = 0;
    unsafe {
        let rc = GetWriteWatch(WRITE_WATCH_FLAG_RESET, region as _, size,
                               buf.as_mut_ptr(), &mut count, &mut gran);
        if rc == 0 {
            for i in 0..count {
                sink.push(buf[i] as *mut u8);
            }
        }
    }
}

MITRE ATT&CK mappings

Last verified: 2026-05-20