Getting a Process Handle Without Calling OpenProcess
Welcome to this new Medium post, in this one we will see how to obtain a valid process handle without ever calling OpenProcess. We will use NtGetNextProcess, an undocumented NT API that lets us…

Welcome to this new Medium post, in this one we will see how to obtain a valid process handle without ever calling OpenProcess. We will use NtGetNextProcess, an undocumented NT API that lets us enumerate processes and get handles to them in a single call, bypassing one of the most monitored syscalls in the Windows API

Want to go deeper into Windows offensive development?
Video-based courses from beginner to advanced, and text-based modules (mini courses) with new releases constantly. Plus a technique database with 100+ real techniques updated weekly, and custom C2 agents and consulting for teams.
Introduction
Before starting, you need to understand a few things:
OpenProcess is the standard Win32 API to get a handle to a running process. Internally it calls NtOpenProcess, which is a syscall. Basically it returns the handle to the process with your desired access
NtGetNextProcess is an undocumented NT function exported by ntdll.dll. Its purpose is to iterate over all running processes sequentially, returning a handle to each one. You pass NULL to get the first process, then pass the previous handle to advance. It has the following signature:
NTSTATUS NtGetNextProcess(
HANDLE ProcessHandle, // previous handle, NULL to start
ACCESS_MASK DesiredAccess, // access rights for the new handle
ULONG HandleAttributes, // usually 0
ULONG Flags, // usually 0
PHANDLE NewProcessHandle // output: handle to next process
);
The handle it returns is fully functional and carries the DesiredAccess you requested, exactly like what OpenProcess would give you. The difference is in how it got there
Why does this matter for detection? NtGetNextProcess goes through a different kernel code path than NtOpenProcess. As a result, the ETW provider (Microsoft-Windows-Kernel-Audit-API-Calls) does not instrument it. Userland hooks on NtOpenProcess are also irrelevant here because that function is never called
The main problem with this technique is that we are not evading the kernel callback notifications from ObRegisterCallbacks. The EDR driver still receives a notification every time OpenProcess or NtGetNextProcess is used to obtain a handle to a running process
Methodology
The implementation is pretty simple:
- Resolve the functions at runtime: We get function pointers for NtGetNextProcess and NtGetNextThread
- Enumerate processes with NtGetNextProcess: We iterate the process list starting with NULL. On each successful iteration we have a handle with our requested access already set
- Enumerate threads with NtGetNextThread: Given a process handle from the previous step, we can iterate its threads using NtGetNextThread in the same pattern
The NtGetNextThread is exactly the same but for threads, evading OpenThread function call
NTSTATUS
NTAPI
NtGetNextThread(
_In_ HANDLE ProcessHandle,
_In_opt_ HANDLE ThreadHandle,
_In_ ACCESS_MASK DesiredAccess,
_In_ ULONG HandleAttributes,
_In_opt_ _Reserved_ ULONG Flags,
_Out_ PHANDLE NewThreadHandle
);
Implementation
Resolving the functions
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
_NtGetNextProcess = (fnNtGetNextProcess)GetProcAddress(ntdll, "NtGetNextProcess");
_NtGetNextThread = (fnNtGetNextThread)GetProcAddress(ntdll, "NtGetNextThread");
We resolve both functions from the ntdll.dll
Enumerating processes
HANDLE hProc = nullptr;
while (true) {
NTSTATUS st = _NtGetNextProcess(hProc, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, 0, &hProc);
if (st == STATUS_NO_MORE_ENTRIES || !NT_SUCCESS(st))
break;
PROCESS_BASIC_INFORMATION pbi = {};
NtQueryInformationProcess(hProc, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
WCHAR name[MAX_PATH] = {};
DWORD size = MAX_PATH;
QueryFullProcessImageNameW(hProc, 0, name, &size);
DWORD pid = (DWORD)(ULONG_PTR)pbi.UniqueProcessId;
std::wcout << L" [" << pid << L"] " << name << L"\n";
}
Each call to _NtGetNextProcess advances the iterator and gives us a valid handle. We pass the previous handle as the first argument and receive the next one in hProc
The access mask we request (PROCESS_QUERY_INFORMATION | PROCESS_VM_READ) is applied to the returned handle directly
Enumerating threads for a given process
void EnumThreads(HANDLE hProcess, DWORD pid) {
HANDLE hThread = nullptr;
while (true) {
HANDLE hNextThread = nullptr;
NTSTATUS st = _NtGetNextThread(hProcess, hThread, THREAD_QUERY_INFORMATION, 0, 0, &hNextThread);
if (hThread) CloseHandle(hThread);
hThread = hNextThread;
if (st == STATUS_NO_MORE_ENTRIES || !NT_SUCCESS(st))
break;
THREAD_BASIC_INFORMATION tbi = {};
NtQueryInformationThread(hThread, ThreadBasicInformation, &tbi, sizeof(tbi), nullptr);
std::cout << " TID: " << (ULONG_PTR)tbi.ClientId.UniqueThread << "\n";
}
}NtGetNextThread follows the same pattern as NtGetNextProcess but scoped to a single process
Full Code
#include <windows.h>
#include <winternl.h>
#include <iostream>
#include <vector>
#include <string>
#pragma comment(lib, "ntdll.lib")
using fnNtGetNextProcess = NTSTATUS(NTAPI*)(HANDLE, ACCESS_MASK, ULONG, ULONG, PHANDLE);
using fnNtGetNextThread = NTSTATUS(NTAPI*)(HANDLE, HANDLE, ACCESS_MASK, ULONG, ULONG, PHANDLE);
typedef struct _THREAD_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PTEB TebBaseAddress;
CLIENT_ID ClientId;
KAFFINITY AffinityMask;
KPRIORITY Priority;
KPRIORITY BasePriority;
} THREAD_BASIC_INFORMATION, *PTHREAD_BASIC_INFORMATION;
#define STATUS_NO_MORE_ENTRIES ((NTSTATUS)0x8000001A)
fnNtGetNextProcess _NtGetNextProcess = nullptr;
fnNtGetNextThread _NtGetNextThread = nullptr;
struct ProcessEntry {
DWORD pid;
HANDLE handle;
std::wstring name;
};
void EnumThreads(HANDLE hProcess, DWORD pid) {
std::cout << "\nThreads of PID " << pid << ":\n";
HANDLE hThread = nullptr;
while (true) {
HANDLE hNextThread = nullptr;
NTSTATUS st = _NtGetNextThread(hProcess, hThread, THREAD_QUERY_INFORMATION, 0, 0, &hNextThread);
if (hThread) CloseHandle(hThread);
hThread = hNextThread;
if (st == STATUS_NO_MORE_ENTRIES || !NT_SUCCESS(st))
break;
THREAD_BASIC_INFORMATION tbi = {};
NtQueryInformationThread(hThread, (THREADINFOCLASS)0, &tbi, sizeof(tbi), nullptr);
std::cout << " TID: " << (ULONG_PTR)tbi.ClientId.UniqueThread << "\n";
}
}
int main() {
HMODULE ntdll = GetModuleHandleW(L"ntdll.dll");
_NtGetNextProcess = (fnNtGetNextProcess)GetProcAddress(ntdll, "NtGetNextProcess");
_NtGetNextThread = (fnNtGetNextThread)GetProcAddress(ntdll, "NtGetNextThread");
std::vector<ProcessEntry> processes;
HANDLE hProc = nullptr;
std::cout << "Processes\n";
while (true) {
NTSTATUS st = _NtGetNextProcess(hProc, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ, 0, 0, &hProc);
if (st == STATUS_NO_MORE_ENTRIES || !NT_SUCCESS(st))
break;
PROCESS_BASIC_INFORMATION pbi = {};
NtQueryInformationProcess(hProc, ProcessBasicInformation, &pbi, sizeof(pbi), nullptr);
WCHAR name[MAX_PATH] = {};
DWORD size = MAX_PATH;
QueryFullProcessImageNameW(hProc, 0, name, &size);
DWORD pid = (DWORD)(ULONG_PTR)pbi.UniqueProcessId;
std::wcout << L" [" << pid << L"] " << name << L"\n";
processes.push_back({ pid, hProc, name });
}
std::cout << "\nEnter a PID to list its threads: ";
DWORD targetPid;
std::cin >> targetPid;
bool found = false;
for (auto& p : processes) {
if (p.pid == targetPid) {
EnumThreads(p.handle, p.pid);
found = true;
}
CloseHandle(p.handle);
}
if (!found)
std::cout << "PID not found.\n";
return 0;
}
Proof of Concept
Running the PoC on a standard Windows 11 system produces output like this:
Processes
[1640] C:\ProgramData\Microsoft\Windows Defender\Platform\4.18.26080.4-0\DefenderSessionHelper.exe
[4792] C:\Windows\System32\sihost.exe
[2812] C:\Windows\System32\svchost.exe
[1020] C:\Windows\System32\svchost.exe
[1672] C:\Windows\System32\svchost.exe
[1496] C:\Windows\System32\taskhostw.exe
[5792] C:\Program Files\MiniTool ShadowMaker\SMMonitor.exe
[6116]
[1792] C:\Windows\explorer.exe
[4784] C:\Windows\System32\ShellHost.exe
[1044] C:\Windows\SystemApps\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\CrossDeviceResume.exe
[3868] C:\Windows\System32\svchost.exe
[6440] C:\Windows\SystemApps\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\SearchHost.exe
[6476] C:\Windows\SystemApps\Microsoft.Windows.StartMenuExperienceHost_cw5n1h2txyewy\StartMenuExperienceHost.exe
[6676] C:\Windows\System32\RuntimeBroker.exe
[6700] C:\Windows\System32\svchost.exe
[6792] C:\Program Files\WindowsApps\MicrosoftWindows.Client.WebExperience_526.21100.40.0_x64__cw5n1h2txyewy\WidgetBoard.exe
[6840] C:\Program Files\WindowsApps\MicrosoftWindows.CrossDevice_1.26072.116.0_x64__cw5n1h2txyewy\CrossDeviceService.exe
[6972] C:\Windows\System32\oobe\UserOOBEBroker.exe
[7044] C:\Program Files\WindowsApps\Microsoft.WidgetsPlatformRuntime_1.6.19.0_x64__8wekyb3d8bbwe\WidgetService\WidgetService.exe
[7644] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\153.0.4234.48\msedgewebview2.exe
[7716] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\153.0.4234.48\msedgewebview2.exe
[8092] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\153.0.4234.48\msedgewebview2.exe
[8100] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\153.0.4234.48\msedgewebview2.exe
[7240] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\153.0.4234.48\msedgewebview2.exe
[7280] C:\Windows\System32\ctfmon.exe
[7288] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\153.0.4234.48\msedgewebview2.exe
[2092] C:\Windows\SystemApps\MicrosoftWindows.Client.CBS_cw5n1h2txyewy\TextInputHost.exe
[5344] C:\Program Files\WindowsApps\Microsoft.StartExperiencesApp_1.380.2.0_x64__8wekyb3d8bbwe\MicrosoftStartFeedProvider\MicrosoftStartFeedProvider.exe
[8888] C:\Windows\System32\RuntimeBroker.exe
[9136] C:\Program Files\WindowsApps\Microsoft.YourPhone_1.26072.257.0_x64__8wekyb3d8bbwe\PhoneExperienceHost.exe
[4468] C:\Windows\System32\SecurityHealthSystray.exe
[1348] C:\Windows\System32\VBoxTray.exe
[9124] C:\Program Files\Adobe\Acrobat DC\Acrobat\AdobeCollabSync.exe
[2932] C:\Program Files\Adobe\Acrobat DC\Acrobat\AdobeCollabSync.exe
[2380] C:\Windows\SystemApps\ShellExperienceHost_cw5n1h2txyewy\ShellExperienceHost.exe
[4812] C:\Windows\System32\RuntimeBroker.exe
[6804]
[6040] C:\Windows\System32\cmd.exe
[7052] C:\Windows\System32\conhost.exe
[4472] C:\Program Files\SystemInformer\SystemInformer.exe
[2436] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\devenv.exe
[9232] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\PerfWatson2.exe
[6552] C:\Windows\System32\RuntimeBroker.exe
[3804] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\ServiceHub\Hosts\ServiceHub.Host.Extensibility.amd64\net10.0-windows10.0.19041.0\DevHub.exe
[5488] C:\Windows\ImmersiveControlPanel\SystemSettings.exe
[4904] C:\Windows\System32\ApplicationFrameHost.exe
[10092] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\Extensions\Microsoft\Copilot\service\dist\copilot-language-server.exe
[7984] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\ServiceHub\Hosts\ServiceHub.Host.netfx.x64\ServiceHub.IntellicodeModelService.exe
[9488] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\vshost.exe
[5688]
[1884] C:\Windows\System32\conhost.exe
[3164]
[3424]
[7676]
[6124]
[4156]
[10464]
[10260]
[9340] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\CommonExtensions\Platform\Debugger\VsDebugConsole.exe
[3920] C:\Windows\System32\conhost.exe
[9036] C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.24.11911.0_x64__8wekyb3d8bbwe\OpenConsole.exe
[6504] C:\Program Files\WindowsApps\Microsoft.WindowsTerminal_1.24.11911.0_x64__8wekyb3d8bbwe\WindowsTerminal.exe
[4076] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[4636] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[2848] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[7020] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[11244] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[1836] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[6556] C:\Program Files (x86)\Microsoft\EdgeWebView\Application\154.0.4258.37\msedgewebview2.exe
[4992] C:\Program Files\Microsoft Visual Studio\18\Community\Common7\IDE\VC\vcpackages\x86\vcpkgsrv.exe
[2928] C:\Program Files\Microsoft Visual Studio\18\Community\VC\Tools\MSVC\14.51.36231\bin\Hostx64\x64\vctip.exe
[5448] C:\Users\s12de\Documents\Github\cppclasses\Utils\ProcessUtils\NtGetNextProcessandThread\x64\Debug\NtGetNextProcessandThread.exe
Enter a PID to list its threads: 2928
[*] Threads of PID 2928:
TID: 10412
TID: 8860
TID: 9968
TID: 4280
TID: 5184
TID: 4464
TID: 2388
TID: 10300
TID: 4092
TID: 4324
TID: 10984
TID: 5928
TID: 9184
TID: 2844
TID: 10176
TID: 5264
TID: 10496
TID: 676
TID: 4912
TID: 7128
TID: 7956
TID: 1692
TID: 4292
TID: 9164
TID: 3468
TID: 5284
TID: 3396
Conclusions
NtGetNextProcess is a function that happens to produce process handles through a code path that avoids the most commonly monitored handle acquisition route. It reduces the ETW and userland hook telemetry compared to OpenProcess, while still being detectable at the kernel callback level.
📌 Follow me: 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter | YouTube
S12.