Chapter 16

Process Enumeration

The APIs for listing running processes — Toolhelp32, NtQuerySystemInformation, and WMI — what each returns, how rootkits hide from each, and how to detect injected processes through anomaly analysis

Scenario

Your EDR reports a process tree, but a memory forensics scan finds an EPROCESS not in that tree. The process is hidden — it unlinked itself from ActiveProcessLinks. The standard enumeration APIs all walk that list. To detect DKOM-hidden processes, you need to understand where each enumeration API gets its data and what alternative data sources exist outside the process list.

Toolhelp32 API

The Toolhelp32 API (tlhelp32.h) is the classic Win32 process enumeration interface. It takes a snapshot of the system state and lets you iterate it.

#include <windows.h>
#include <tlhelp32.h>

void ListProcesses() {
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (hSnap == INVALID_HANDLE_VALUE) return;

    PROCESSENTRY32W pe = { sizeof(pe) };
    if (Process32FirstW(hSnap, &pe)) {
        do {
            wprintf(L"PID=%5lu  PPID=%5lu  %ws\n",
                pe.th32ProcessID, pe.th32ParentProcessID,
                pe.szExeFile);
        } while (Process32NextW(hSnap, &pe));
    }
    CloseHandle(hSnap);
}
// PROCESSENTRY32 gives: PID, parent PID, process name, thread count
// Does NOT give: full image path, command line, image base
// Data source: NtQuerySystemInformation internally
Limitation

Toolhelp32 ultimately calls NtQuerySystemInformation internally. Both APIs walk the kernel's ActiveProcessLinks list. A DKOM rootkit that unlinks its EPROCESS from that list is invisible to both.

NtQuerySystemInformation

The more powerful, lower-level API is NtQuerySystemInformation with SystemProcessInformation. This returns a linked list of SYSTEM_PROCESS_INFORMATION structures, one per process, with significantly more data than Toolhelp32:

#include <winternl.h>
#pragma comment(lib, "ntdll.lib")

void ListProcessesFull() {
    ULONG bufLen = 1024 * 1024;  // 1MB initial buffer
    PVOID buf = malloc(bufLen);
    NTSTATUS status;

    while ((status = NtQuerySystemInformation(
        SystemProcessInformation, buf, bufLen, &bufLen)) == STATUS_INFO_LENGTH_MISMATCH) {
        buf = realloc(buf, bufLen += 4096);
    }
    if (status != STATUS_SUCCESS) { free(buf); return; }

    SYSTEM_PROCESS_INFORMATION* spi = (SYSTEM_PROCESS_INFORMATION*)buf;
    while (true) {
        wprintf(L"PID=%5llu  Threads=%2lu  %wZ\n",
            (ULONG64)spi->UniqueProcessId,
            spi->NumberOfThreads,
            &spi->ImageName);

        for (ULONG i = 0; i < spi->NumberOfThreads; i++) {
            SYSTEM_THREAD_INFORMATION* sti = &spi->Threads[i];
            wprintf(L"  TID=%5llu  StartAddress=%p\n",
                (ULONG64)sti->ClientId.UniqueThread,
                sti->StartAddress);
        }

        if (!spi->NextEntryOffset) break;
        spi = (SYSTEM_PROCESS_INFORMATION*)((BYTE*)spi + spi->NextEntryOffset);
    }
    free(buf);
}
// Returns: PID, PPID, image name, thread list with start addresses,
// memory stats, handle count, session ID, working set size

The thread start addresses in SYSTEM_THREAD_INFORMATION are particularly valuable for detecting injection — a thread whose start address falls outside any loaded module's address range is highly suspicious.

WMI Process Query

WMI's Win32_Process class provides process information via COM, including the full command line and executable path. Its data ultimately comes from the same kernel source but through an additional layer.

import subprocess, json

def list_processes_wmi():
    # PowerShell is the easiest WMI interface from Python
    cmd = ["powershell", "-NoProfile", "-Command",
           "Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine,ExecutablePath | ConvertTo-Json"]
    result = subprocess.run(cmd, capture_output=True, text=True)
    processes = json.loads(result.stdout)
    for p in processes:
        pid = p.get("ProcessId")
        ppid = p.get("ParentProcessId")
        name = p.get("Name", "")
        cmd_line = p.get("CommandLine", "") or ""
        exe_path = p.get("ExecutablePath", "") or ""
        print(f"{pid:6d} {ppid:6d}  {name:25s}  {cmd_line[:60]}")

WMI also supports event subscriptions: __InstanceCreationEvent with Win32_ProcessStartTrace can fire a notification when any process starts — though with some latency compared to ETW.

OpenProcess and Handle Rights

After enumerating a process by PID, you obtain a handle with OpenProcess. The handle's access rights determine what you can do with it:

Access RightValueAllows
PROCESS_QUERY_INFORMATION0x0400Read PID, exit code, token, basic info; needed for OpenProcessToken
PROCESS_QUERY_LIMITED_INFORMATION0x1000Subset of query rights; works on protected processes
PROCESS_VM_READ0x0010ReadProcessMemory — read memory from the process
PROCESS_VM_WRITE0x0020WriteProcessMemory — write memory to the process
PROCESS_VM_OPERATION0x0008VirtualAllocEx, VirtualProtectEx — memory operations
PROCESS_CREATE_THREAD0x0002CreateRemoteThread — create threads in the process
PROCESS_ALL_ACCESS0x1FFFFFAll rights — requires SeDebugPrivilege to use on protected processes

Enumerating Modules in a Process

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

void ListModules(DWORD pid) {
    HANDLE hProc = OpenProcess(
        PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, FALSE, pid);
    if (!hProc) return;

    HMODULE mods[1024]; DWORD needed;
    if (EnumProcessModulesEx(hProc, mods, sizeof(mods), &needed, LIST_MODULES_ALL)) {
        DWORD count = needed / sizeof(HMODULE);
        for (DWORD i = 0; i < count; i++) {
            WCHAR path[MAX_PATH];
            GetModuleFileNameExW(hProc, mods[i], path, MAX_PATH);
            MODULEINFO mi;
            GetModuleInformation(hProc, mods[i], &mi, sizeof(mi));
            wprintf(L"  Base=%p  Size=0x%lX  %ws\n",
                mi.lpBaseOfDll, mi.SizeOfImage, path);
        }
    }
    CloseHandle(hProc);
}
// Anomaly: a module base with no path (manually mapped DLL)
// Anomaly: a module at an address outside expected range

Process Anomaly Detection

Combining process enumeration with cross-validation reveals injected and hidden processes:

Anomaly CheckHow to Detect
Thread with start address outside any module NtQuerySystemInformation returns thread start addresses; check each against VirtualQueryEx — if it's in a MEM_PRIVATE region, it's likely injected shellcode
Process with hollow image Read the PE header from the process's image base via ReadProcessMemory; compare the hash with the PE hash on disk — mismatch = hollowed
Loaded DLL not in PEB module list EnumProcessModulesEx returns DLLs loaded in the process; compare against PEB InLoadOrderModuleList — extra modules not in PEB may be manually mapped
ImageFileName vs full path mismatch EPROCESS.ImageFileName (15 chars) vs full path from QueryFullProcessImageName — truncation vs deliberate spoofing
PPID anomaly Parent no longer exists or is an unexpected process type for the child

Q & A

Why does SeDebugPrivilege allow OpenProcess on protected processes?

SeDebugPrivilege is one of the most powerful privileges in Windows — it allows a process to open handles with full access to any process, bypassing the normal access checks that would otherwise prevent opening processes owned by other users or running at higher integrity levels. The privilege exists to support debuggers: a developer needs to attach a debugger to any process including system services. When you call OpenProcess(PROCESS_ALL_ACCESS, ..., targetPid) on a process you don't own, the security reference monitor checks if your token has SeDebugPrivilege enabled. If it does, the check passes regardless of the target process's security descriptor. This is why malware running with admin rights often enables SeDebugPrivilege first — it then has unrestricted access to inject into any process, including LSASS (for credential dumping). PPL (Protected Process Light) processes, like LSASS on modern Windows with Credential Guard, resist even SeDebugPrivilege for certain operations, but this is enforced in the kernel separately from the standard access check.

How can you detect a process hidden via DKOM without a kernel driver?

From user mode, you can't directly walk ActiveProcessLinks. But you can cross-reference multiple data sources that a rootkit can't all hide simultaneously: (1) PID brute force: call OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pid) for every PID from 4 to 65536 in steps of 4. If the call succeeds, the process exists even if it doesn't appear in NtQuerySystemInformation. A rootkit that unlinks from ActiveProcessLinks still has a valid EPROCESS that the handle mechanism uses. (2) Handle table scan: NtQuerySystemInformation(SystemHandleInformation) returns all open handles in the system — a hidden process's handles still appear. (3) CSR/CSRSS: Windows processes are registered with the CSRSS (Client/Server Runtime Subsystem). Querying CSRSS's handle table indirectly reveals registered processes. (4) ETW audit trail: process creation events in ETW Security Audit log (Event ID 4688) were captured at creation time and persist in the event log even after DKOM. Reconciling the event log against current process list reveals hidden processes. Memory forensics tools like Volatility use approach (1) combined with pool tag scanning, which is the most reliable method.