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
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
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 Right | Value | Allows |
|---|---|---|
PROCESS_QUERY_INFORMATION | 0x0400 | Read PID, exit code, token, basic info; needed for OpenProcessToken |
PROCESS_QUERY_LIMITED_INFORMATION | 0x1000 | Subset of query rights; works on protected processes |
PROCESS_VM_READ | 0x0010 | ReadProcessMemory — read memory from the process |
PROCESS_VM_WRITE | 0x0020 | WriteProcessMemory — write memory to the process |
PROCESS_VM_OPERATION | 0x0008 | VirtualAllocEx, VirtualProtectEx — memory operations |
PROCESS_CREATE_THREAD | 0x0002 | CreateRemoteThread — create threads in the process |
PROCESS_ALL_ACCESS | 0x1FFFFF | All 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 Check | How 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.