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

NtGetWriteWatch

Récupère l'ensemble des pages écrites dans une région MEM_WRITE_WATCH depuis la dernière réinitialisation.

Prototype

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

Arguments

NameTypeDirDescription
ProcessHandleHANDLEinHandle vers le processus cible. Typiquement NtCurrentProcess().
FlagsULONGinWRITE_WATCH_FLAG_RESET (1) pour effacer atomiquement l'état après lecture ; 0 sinon.
BaseAddressPVOIDinAdresse de base de la région MEM_WRITE_WATCH.
RegionSizeSIZE_TinTaille de la région en octets. Doit être incluse dans l'allocation d'origine.
UserAddressArrayPVOID*outTableau fourni par l'appelant qui reçoit les adresses des pages écrites.
EntriesInUserAddressArrayPULONG_PTRin/outEn entrée : capacité du tableau. En sortie : nombre de pages sales réellement retournées.
GranularityPULONGoutReçoit la granularité de page (typiquement PAGE_SIZE, 4096 sur x64).

IDs de syscalls par version de Windows

Version 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

Module noyau

ntoskrnl.exeNtGetWriteWatch

APIs liées

GetWriteWatchResetWriteWatchNtResetWriteWatchVirtualAllocNtAllocateVirtualMemoryNtProtectVirtualMemory

Stub du 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

Notes non documentées

NtGetWriteWatch ne fonctionne que sur des régions précédemment allouées avec MEM_WRITE_WATCH (via NtAllocateVirtualMemory). Le noyau active un suivi dirty au niveau PTE sur ces pages ; chaque écriture suivante bascule un bit dans l'état accessed/dirty par PTE et le noyau le capture. Le wrapper user-mode `GetWriteWatch` (kernel32!GetWriteWatch → ntdll!NtGetWriteWatch) est rarement utilisé par les applications classiques — les seuls consommateurs grand public sont le ramasse-miettes du CLR .NET (optimisation de la card-table), les moteurs JIT type V8/Chakra et le suivi du buffer-pool de SQL Server. Passez WRITE_WATCH_FLAG_RESET pour combiner requête et reset en un seul syscall ; sinon enchaînez avec NtResetWriteWatch.

Usage courant par les malwares

La fonctionnalité phare pour le tradecraft offensif est **les sleep-masks qui ne re-chiffrent que les pages sales**. Des implants comme Ekko, Foliage et l'obfuscateur de sommeil Cronos allouent leurs pages .text / heap avec MEM_WRITE_WATCH (ou les mirorent dans une région surveillée). Quand l'implant se réveille pour traiter un rappel de beacon, il salit seulement un sous-ensemble de pages ; avant de se rendormir il appelle NtGetWriteWatch pour énumérer exactement quelles pages doivent être re-chiffrées, évitant le coût de re-chiffrer des mégaoctets de code inchangé. Cela réduit drastiquement le coût CPU par sommeil tout en préservant le chiffrement complet de la mémoire entre les rappels. Combiné aux chaînes ROP de timer-queue (Ekko) ou au séquencement APC (Foliage), c'est l'état de l'art moderne pour la survie d'implants en mémoire.

Opportunités de détection

Les régions MEM_WRITE_WATCH sont visibles dans la sortie `!vad` (un drapeau `VadWriteWatch` est positionné sur le nœud VAD) et dans `VirtualQueryEx` (le bit `MEM_WRITE_WATCH` dans `Type`). Les EDR user-mode scannent rarement ce signal. Le signal noyau le plus fiable est la *combinaison* inhabituelle de (a) une région MEM_WRITE_WATCH avec protection PAGE_EXECUTE_READWRITE, (b) des appels NtGetWriteWatch répétés corrélés avec des intervalles NtDelayExecution correspondant à des patterns de jitter C2 courants, et (c) des bascules NtProtectVirtualMemory entre RW et RX sur le même VAD. Les EDR à balayage mémoire (Elastic, scanner mémoire CrowdStrike) peuvent attraper le blob re-chiffré pendant la fenêtre de sommeil s'ils horodatent l'entropie des pages.

Exemples de syscalls directs

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

Mappings MITRE ATT&CK

Last verified: 2026-05-20