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 withGetProcAddress; 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.
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.
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”.
!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.
_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 bits | Right | Why it is interesting |
|---|---|---|
0x0008 | PROCESS_VM_OPERATION | Needed to allocate or change protection in another process. |
0x0010 | PROCESS_VM_READ | Reading another process’s memory — the credential-dumping precondition. |
0x0020 | PROCESS_VM_WRITE | Writing another process’s memory. |
0x0002 | PROCESS_CREATE_THREAD | Needed for CreateRemoteThread. |
0x0040 | PROCESS_DUP_HANDLE | Stealing a handle someone else already holds. |
0x1F0FFF | PROCESS_ALL_ACCESS | The 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
- NtQuerySystemInformation — Microsoft Learn
- SystemHandleInformation — Geoff Chappell
- SYSTEM_HANDLE_INFORMATION — Process Hacker docs
- Process access rights
- The kernel debugging lab — getting a session in which
!objectworks