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

NtGetWriteWatch

Recupera el conjunto de páginas escritas en una región MEM_WRITE_WATCH desde el último reinicio.

Prototipo

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

Argumentos

NameTypeDirDescription
ProcessHandleHANDLEinHandle al proceso objetivo. Habitualmente NtCurrentProcess().
FlagsULONGinWRITE_WATCH_FLAG_RESET (1) para borrar atómicamente el estado tras leer; 0 en otro caso.
BaseAddressPVOIDinDirección base de la región MEM_WRITE_WATCH.
RegionSizeSIZE_TinTamaño de la región en bytes. Debe estar contenido en la asignación original.
UserAddressArrayPVOID*outArreglo proporcionado por el llamante que recibe las direcciones de las páginas escritas.
EntriesInUserAddressArrayPULONG_PTRin/outEn entrada: capacidad del arreglo. En salida: número de páginas sucias devueltas.
GranularityPULONGoutRecibe la granularidad de página (típicamente PAGE_SIZE, 4096 en x64).

IDs de syscalls por versión de Windows

Versión de WindowsID de syscallBuild
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

Módulo del kernel

ntoskrnl.exeNtGetWriteWatch

APIs relacionadas

GetWriteWatchResetWriteWatchNtResetWriteWatchVirtualAllocNtAllocateVirtualMemoryNtProtectVirtualMemory

Stub del syscall

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

Notas no documentadas

NtGetWriteWatch solo funciona sobre regiones previamente asignadas con MEM_WRITE_WATCH (vía NtAllocateVirtualMemory). El kernel arma un seguimiento dirty a nivel de PTE sobre esas páginas; cada escritura subsiguiente cambia un bit en el estado accessed/dirty por PTE y el kernel lo captura. El wrapper user-mode `GetWriteWatch` (kernel32!GetWriteWatch → ntdll!NtGetWriteWatch) rara vez es usado por aplicaciones típicas — los únicos consumidores principales son el garbage collector del CLR .NET (optimización de card-table), motores JIT estilo V8/Chakra y el seguimiento del buffer-pool de SQL Server. Pase WRITE_WATCH_FLAG_RESET para combinar consulta y reset en un solo syscall; de lo contrario encadene con NtResetWriteWatch.

Uso común por malware

La característica estrella para el tradecraft ofensivo son los **sleep-masks que re-encriptan solo páginas sucias**. Implantes como Ekko, Foliage y el ofuscador de sueño Cronos asignan sus páginas .text / heap con MEM_WRITE_WATCH (o las espejan en una región vigilada). Cuando el implante despierta para procesar una llamada de beacon ensucia solo un subconjunto de páginas; antes de dormir de nuevo llama a NtGetWriteWatch para enumerar exactamente qué páginas hay que re-encriptar, evitando el coste de re-encriptar megabytes de código sin cambios. Esto reduce drásticamente el coste CPU por sueño preservando el cifrado completo de memoria entre llamadas. Combinado con cadenas ROP de timer-queue (Ekko) o secuenciación APC (Foliage), es el estado del arte moderno para la supervivencia de implantes en memoria.

Oportunidades de detección

Las regiones MEM_WRITE_WATCH son visibles en la salida `!vad` (se establece un flag `VadWriteWatch` en el nodo VAD) y en `VirtualQueryEx` (el bit `MEM_WRITE_WATCH` en `Type`). Los EDR user-mode rara vez escanean esto. La señal de kernel más fiable es la *combinación* inusual de (a) una región MEM_WRITE_WATCH con protección PAGE_EXECUTE_READWRITE, (b) llamadas repetidas a NtGetWriteWatch correlacionadas con intervalos NtDelayExecution que coinciden con patrones de jitter C2 comunes, y (c) cambios NtProtectVirtualMemory entre RW y RX sobre el mismo VAD. Los EDR con escaneo de memoria (Elastic, escáner de memoria de CrowdStrike) pueden cazar el blob re-encriptado durante la ventana de sueño si registran la marca temporal de entropía de página.

Ejemplos de syscalls directos

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);
            }
        }
    }
}

Mapeos MITRE ATT&CK

Last verified: 2026-05-20