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

NtGetWriteWatch

Liefert die seit dem letzten Reset beschriebenen Seiten einer MEM_WRITE_WATCH-Region.

Prototyp

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

Argumente

NameTypeDirDescription
ProcessHandleHANDLEinHandle auf den Zielprozess. Üblicherweise NtCurrentProcess().
FlagsULONGinWRITE_WATCH_FLAG_RESET (1), um den Zustand nach dem Lesen atomar zu löschen; sonst 0.
BaseAddressPVOIDinBasisadresse der MEM_WRITE_WATCH-Region.
RegionSizeSIZE_TinGröße der Region in Bytes. Muss innerhalb der ursprünglichen Allokation liegen.
UserAddressArrayPVOID*outVom Aufrufer bereitgestelltes Array, das die Adressen beschriebener Seiten aufnimmt.
EntriesInUserAddressArrayPULONG_PTRin/outEingabe: Kapazität des Arrays. Ausgabe: tatsächliche Anzahl zurückgegebener Dirty-Pages.
GranularityPULONGoutEmpfängt die Seitengranularität (typischerweise PAGE_SIZE, 4096 auf x64).

Syscall-IDs pro 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-Modul

ntoskrnl.exeNtGetWriteWatch

Verwandte 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

Undokumentierte Hinweise

NtGetWriteWatch funktioniert nur auf Regionen, die zuvor mit MEM_WRITE_WATCH (über NtAllocateVirtualMemory) allokiert wurden. Der Kernel aktiviert PTE-Level-Dirty-Tracking auf diesen Seiten; jede nachfolgende Schreiboperation kippt ein Bit im Accessed/Dirty-Zustand pro PTE und der Kernel speichert es. Der User-Mode-Wrapper `GetWriteWatch` (kernel32!GetWriteWatch → ntdll!NtGetWriteWatch) wird von typischen Anwendungen kaum benutzt — die einzigen Mainstream-Konsumenten sind der Garbage Collector der .NET CLR (Card-Table-Optimierung), JIT-Engines im Stil von V8/Chakra und das Buffer-Pool-Tracking von SQL Server. WRITE_WATCH_FLAG_RESET übergeben, um Abfrage und Reset in einem Syscall zu kombinieren; andernfalls mit NtResetWriteWatch nachziehen.

Häufige Malware-Nutzung

Das Killer-Feature für offensives Tradecraft sind **Sleep-Mask-Designs, die nur Dirty Pages neu verschlüsseln**. Implants wie Ekko, Foliage und der Cronos-Sleep-Obfuscator allokieren ihre .text-/Heap-Seiten mit MEM_WRITE_WATCH (oder spiegeln sie in einer überwachten Region). Wenn der Implant aus einem Schlafzyklus erwacht, um einen Beacon-Callback zu verarbeiten, beschmutzt er nur eine Teilmenge der Seiten; vor dem erneuten Schlafen ruft er NtGetWriteWatch auf, um exakt aufzulisten, welche Seiten neu verschlüsselt werden müssen — und vermeidet so die Kosten, Megabyte unveränderten Codes erneut zu verschlüsseln. Das senkt die CPU-Kosten pro Schlafzyklus drastisch und erhält gleichzeitig die vollständige Speicherverschlüsselung zwischen Callbacks. Kombiniert mit Timer-Queue-ROP-Ketten (Ekko) oder APC-Sequenzierung (Foliage) ist das der heutige Stand der Technik für In-Memory-Implant-Überleben.

Erkennungs­möglichkeiten

MEM_WRITE_WATCH-Regionen sind in der `!vad`-Ausgabe sichtbar (ein `VadWriteWatch`-Flag wird am VAD-Knoten gesetzt) und in `VirtualQueryEx` (das `MEM_WRITE_WATCH`-Bit in `Type`). User-Mode-EDRs scannen darauf selten. Das verlässlichste Kernel-Signal ist die ungewöhnliche *Kombination* aus (a) einer MEM_WRITE_WATCH-Region mit PAGE_EXECUTE_READWRITE-Schutz, (b) wiederholten NtGetWriteWatch-Aufrufen korreliert mit NtDelayExecution-Intervallen, die typischen C2-Jitter-Mustern entsprechen, und (c) NtProtectVirtualMemory-Wechseln zwischen RW und RX auf demselben VAD. Memory-Scanning-EDRs (Elastic, CrowdStrike-Memory-Scanner) können den neu verschlüsselten Blob während des Schlaffensters erwischen, wenn sie Seiten-Entropie zeitlich verfolgen.

Direkte Syscall-Beispiele

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