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
| Name | Type | Dir | Description |
|---|---|---|---|
| ProcessHandle | HANDLE | in | Handle auf den Zielprozess. Üblicherweise NtCurrentProcess(). |
| Flags | ULONG | in | WRITE_WATCH_FLAG_RESET (1), um den Zustand nach dem Lesen atomar zu löschen; sonst 0. |
| BaseAddress | PVOID | in | Basisadresse der MEM_WRITE_WATCH-Region. |
| RegionSize | SIZE_T | in | Größe der Region in Bytes. Muss innerhalb der ursprünglichen Allokation liegen. |
| UserAddressArray | PVOID* | out | Vom Aufrufer bereitgestelltes Array, das die Adressen beschriebener Seiten aufnimmt. |
| EntriesInUserAddressArray | PULONG_PTR | in/out | Eingabe: Kapazität des Arrays. Ausgabe: tatsächliche Anzahl zurückgegebener Dirty-Pages. |
| Granularity | PULONG | out | Empfängt die Seitengranularität (typischerweise PAGE_SIZE, 4096 auf x64). |
Syscall-IDs pro Windows-Version
| Windows-Version | Syscall-ID | Build |
|---|---|---|
| Win10 1507 | 0xEC | win10-1507 |
| Win10 1607 | 0xEF | win10-1607 |
| Win10 1703 | 0xF2 | win10-1703 |
| Win10 1709 | 0xF3 | win10-1709 |
| Win10 1803 | 0xF4 | win10-1803 |
| Win10 1809 | 0xF5 | win10-1809 |
| Win10 1903 | 0xF6 | win10-1903 |
| Win10 1909 | 0xF6 | win10-1909 |
| Win10 2004 | 0xFB | win10-2004 |
| Win10 20H2 | 0xFB | win10-20h2 |
| Win10 21H1 | 0xFB | win10-21h1 |
| Win10 21H2 | 0xFC | win10-21h2 |
| Win10 22H2 | 0xFC | win10-22h2 |
| Win11 21H2 | 0x101 | win11-21h2 |
| Win11 22H2 | 0x102 | win11-22h2 |
| Win11 23H2 | 0x102 | win11-23h2 |
| Win11 24H2 | 0x104 | win11-24h2 |
| Server 2016 | 0xEF | winserver-2016 |
| Server 2019 | 0xF5 | winserver-2019 |
| Server 2022 | 0x100 | winserver-2022 |
| Server 2025 | 0x104 | winserver-2025 |
Kernel-Modul
Verwandte APIs
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.
Erkennungsmö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 ENDPrustGC-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