DFIR^:DFIR

Handle Enumeration and Kernel Object Addresses

NtQuerySystemInformation with SystemHandleInformation to list every handle on the host, and confirming the kernel object behind one in WinDBG with !object and !process.

advancedupdated 2026-10-04windbg · process-hacker · handle · visual-studio

What this is

A single call enumerates every open handle on the machine — processes, threads, files, registry keys, sections, mutants, tokens — and for each one gives you the kernel virtual address of the object it refers to. No driver, and on most builds no elevation needed to get the list itself. That makes it the cheapest way to answer “what does this process have hold of”, which is a question that distinguishes injection and credential access from normal behaviour better than almost anything else in user mode.

NtQuerySystemInformation with information class SystemHandleInformation (0x10) returns a SYSTEM_HANDLE_INFORMATION: a count, followed by that many SYSTEM_HANDLE_TABLE_ENTRY_INFO records.

typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO {
    USHORT UniqueProcessId;          // owner PID — truncated to 16 bits
    USHORT CreatorBackTraceIndex;
    UCHAR  ObjectTypeIndex;          // index into the object type table
    UCHAR  HandleAttributes;         // OBJ_INHERIT, OBJ_PROTECT_CLOSE
    USHORT HandleValue;              // the handle as the owner sees it
    PVOID  Object;                   // kernel address of the object
    ULONG  GrantedAccess;            // the access mask the handle was opened with
} SYSTEM_HANDLE_TABLE_ENTRY_INFO, *PSYSTEM_HANDLE_TABLE_ENTRY_INFO;

typedef struct _SYSTEM_HANDLE_INFORMATION {
    ULONG NumberOfHandles;
    SYSTEM_HANDLE_TABLE_ENTRY_INFO Handles[1];
} SYSTEM_HANDLE_INFORMATION, *PSYSTEM_HANDLE_INFORMATION;

Neither structure is documented by Microsoft. Both are stable enough to rely on and have been for twenty years, but UniqueProcessId being a USHORT is a real limitation: PIDs above 65535 wrap. Use SystemExtendedHandleInformation (0x40) instead when you need the full-width PID and object-type handling.

Prerequisites

  • ntdll.dll — resolve the export at runtime with GetProcAddress; there is no import library.
  • A buffer sized by retry, not by guess. The call returns STATUS_INFO_LENGTH_MISMATCH (0xC0000004) when the buffer is too small, and the handle count changes between calls, so the only correct pattern is a loop that grows the buffer until the status is not that.
  • Kernel debugging for the confirmation half — see the kernel debugging lab for the transport setup.
  • Process Hacker for cross-checking without a debugger.

Walkthrough

The call

#define SystemHandleInformation 0x10

using fNtQuerySystemInformation = NTSTATUS(WINAPI*)(
    ULONG SystemInformationClass, PVOID SystemInformation,
    ULONG SystemInformationLength, PULONG ReturnLength);

auto NtQuerySystemInformation = (fNtQuerySystemInformation)GetProcAddress(
    GetModuleHandleW(L"ntdll"), "NtQuerySystemInformation");

ULONG size = 0x10000, needed = 0;
PSYSTEM_HANDLE_INFORMATION info = nullptr;
NTSTATUS status;

do {
    if (info) HeapFree(GetProcessHeap(), 0, info);
    info = (PSYSTEM_HANDLE_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, size);
    status = NtQuerySystemInformation(SystemHandleInformation, info, size, &needed);
    size *= 2;
} while (status == 0xC0000004);

for (ULONG i = 0; i < info->NumberOfHandles; i++) {
    auto& h = info->Handles[i];
    if (h.UniqueProcessId != targetPid) continue;
    printf("handle 0x%04x  object 0x%p  type %u  access 0x%08x\n",
           h.HandleValue, h.Object, h.ObjectTypeIndex, h.GrantedAccess);
}

Two things worth saying about the loop. The records are not grouped by PID in any guaranteed order, so filter rather than break out on the first non-match. And ObjectTypeIndex is an index into the object-type table, whose numbering changes between builds — resolve it against NtQueryObject(ObjectTypeInformation) or a SystemExtendedHandleInformation pass rather than hard-coding “8 means File”.

Run it against PID 4 and you get the System process’s handles, which is a useful smoke test because that set is large and stable.

Console output listing handle values, kernel object addresses and owning PID for every handle held by the System process
Handle value, kernel object address and owner PID for each handle held by PID 4. ired.team · Mantvydas Baranauskas

Cross-check against Process Hacker

Process Hacker’s Handles tab for the same process shows the same handle values, and its Properties pane shows the same kernel object address. Confirming one handle both ways is worth doing before you trust a parser you just wrote, because a buffer-sizing bug produces plausible-looking garbage rather than an error.

Process Hacker handles view for the System process with handle 0x4 selected, showing its object address in kernel memory
The same handle in Process Hacker: handle value, owning PID and object address. ired.team · Mantvydas Baranauskas

Confirm the object in the kernel

The Object field is a kernel virtual address. In a kernel debugging session you can ask what lives there:

!object 0xffff8f077c882300

This resolves the object header and names its type, which is the authoritative answer to “what is handle 0x4 actually a handle to”.

WinDBG !object output for a kernel address reporting a valid object header of type Process
!object confirms the address is a valid object and names its type. ired.team · Mantvydas Baranauskas

For a process object, go further and overlay _EPROCESS to read the identity fields:

!process 0xffff8f077c882300 0
dt _eprocess 0xffff8f077c882300 UniqueProcessId ImageFileName

UniqueProcessId and ImageFileName from _EPROCESS are the kernel’s own record of which process this is — not the PEB’s claim, the kernel’s.

WinDBG dt _eprocess output printing UniqueProcessId 4 and ImageFileName System for the object address
_EPROCESS overlaid on the object address: PID and image name straight from the kernel. ired.team · Mantvydas Baranauskas

What it tells you

GrantedAccess on process handles is the highest-signal field in the whole table. A handle is opened with a specific access mask, and the mask tells you what the opener intended to do. The combinations that matter:

Mask bitsRightWhy it is interesting
0x0008PROCESS_VM_OPERATIONNeeded to allocate or change protection in another process.
0x0010PROCESS_VM_READReading another process’s memory — the credential-dumping precondition.
0x0020PROCESS_VM_WRITEWriting another process’s memory.
0x0002PROCESS_CREATE_THREADNeeded for CreateRemoteThread.
0x0040PROCESS_DUP_HANDLEStealing a handle someone else already holds.
0x1F0FFFPROCESS_ALL_ACCESSThe lazy injector’s mask. Rarely legitimate across process boundaries.

0x0008 | 0x0020 together on a handle to a process the holder has no relationship with is the handle-table shape of classic DLL injection and process hollowing. 0x0010 against lsass.exe is the shape of credential dumping. Enumerating handles gives you this while the handle is still open, which a process-access event log gives you only at open time.

Who holds a handle to what is a graph, and it is a small one. Build the owner-to-target map for process-type handles across the host and the legitimate edges are few and predictable: service hosts to their children, the debugger to its debuggee, antimalware to everything. An edge from an unsigned binary in a user-writable directory to a system process is an anomaly you can find without any signature.

Handles outlive the API call that created them. This is the practical advantage over telemetry. If a process opened lsass with VM_READ an hour ago and kept the handle, the event may have rolled out of your log but the handle is still in the table. Sweeping handles is therefore a good complement to event-based collection, not a substitute for it.

The same enumeration is an attack primitive, which is why it is worth watching. Kernel object addresses are exactly what an exploit against a vulnerable signed driver needs: given an arbitrary kernel read or write, locating the _EPROCESS of a privileged process is the step between “I can write kernel memory” and “I am SYSTEM”. The artefact to look for is not the enumeration itself — it is too common — but a non-administrative process that enumerates handles and then opens a handle to a driver object it has no reason to touch.

Prefer the extended class in anything you keep. SystemExtendedHandleInformation returns a full-width UniqueProcessId and a wider record. The classic class is fine for a lab and quietly wrong on a busy host with high PIDs.

References

On this page

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.