前言
在常见的攻击链中的很多操作都依赖于找到正确的目标进程,本文记录了我在很久前学习的一些基础的进程枚举方法,当然还有很多更复杂的像是通过syscall以及驱动层面的内核枚举,这里简单记录便于自己查阅,希望对读到这篇文章的朋友有所帮助,如果有错误望指正。
CreateToolhelp32Snapshot
最简单的方法,但也最有限的方法
#include<windows.h>#include<iostream>#include<string>#include<tlhelp32.h>#include<processthreadsapi.h>#include<DbgHelp.h>usingnamespacestd;intGetPid(){HRESULT hr;DWORD pid=-1;PROCESSENTRY32 ed;ed.dwSize=sizeof(PROCESSENTRY32);HANDLE snapshot=CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS,NULL);//创建快照存储进snapshotif(Process32First(snapshot,&ed)==TRUE){while(Process32Next(snapshot,&ed)){if(wcscmp(ed.szExeFile,L"lsass.exe")==0){pid=ed.th32ProcessID;}}}CloseHandle(snapshot);if(pid!=0)returnpid;}intmain(){GetPid();//使用时注意系统会存在同名进程的问题,例如avp.exe、svchost.exe等intpid=GetPid();printf("%d\n",pid);return0;}使用CreateToolhelp32Snapshot我们能拿到的是:
PROCESSENTRY32{DWORD th32ProcessID;//PIDDWORD th32ParentProcessID;//PPIDTCHAR szExeFile[MAX_PATH];// 进程名}szExeFile只是文件名,没有:
- Token
- Integrity Level
- Session
- 是否是 SYSTEM
- 是否是 PPL
那么通过字符串比较的问题在哪呢?
典型代码长这样:
if(_wcsicmp(pe.szExeFile,L"svchost.exe")==0){// 命中}问题来了 ,Windows 上的现实情况是:
svchost.exe (User, Medium IL) svchost.exe (SYSTEM, High / System IL) svchost.exe (PPL) svchost.exe (不同 Session)但我们能拿到的只有:
svchost.exe svchost.exe svchost.exe svchost.exe字符串完全一样
在DLL、ShellCode注入,ReadProcessMemory / WriteProcessMemory等等技术中我们都需要依赖目标进程的权限
如果非要使用CreateToolhelp32Snapshot还需要使用OpenProcess进一步判断目标进程的权限,而且高权限的进程还会失败,这在很多的进程身上是很麻烦的
svchost.exe 是最典型的例子:
- 同名
- 多实例
- 不同权限
- 不同服务组
所以:
你可能想注入一个“用户态 svchost”,
却随机选中了一个SYSTEM svchost,
然后所有操作失败。
这就是CreateToolhelp32Snapshot的Toolhelp结构性缺陷,因为它的功能就是给普通的程序列个列表
EnumProcesses
EnumProcesses的优势不在“信息多”,而在于:
它能在权限受限的情况下,稳定、快速、低摩擦地拿到「所有 PID」
而且不会因为某些高权限进程而中断或失败。
EnumProcesses只返回PID,PID 是 Windows 中最“低权限可见”的进程标识
这意味着即使是普通用户权限也几乎能拿到所有包括:
- SYSTEM 进程 PID
- PPL 进程 PID
- Session 0 的 PID
而 Toolhelp 在某些环境(EDR、沙箱、老系统、WOW64 边界)下:
- 可能漏
- 可能被 Hook
- 可能被策略限制
CreateToolhelp32Snapshot在遇到高权限进程时可能会异常,而EnumProcesses会完全无感,不会被“你无权访问的进程”影响
#include<stdio.h>#include<windows.h>#include<tchar.h>#include<psapi.h>// 枚举并打印当前系统中的所有进程BOOLPrintProcesses(){// 用来存放获取到的 PID(进程ID)DWORD adwProcesses[1024*2],// EnumProcesses 返回的字节数dwReturnLen1=0,// EnumProcessModules 返回的字节数dwReturnLen2=0,// 实际获取到的 PID 数量dwNmbrOfPids=0;// 进程句柄HANDLE hProcess=NULL;// 模块句柄(通常第一个模块就是主程序 EXE)HMODULE hModule=NULL;// 用于保存进程名称WCHAR szProc[MAX_PATH];// ----------------------------------------------------// 获取系统中所有进程的 PID// ----------------------------------------------------if(!EnumProcesses(adwProcesses,// 输出缓冲区sizeof(adwProcesses),// 缓冲区大小&dwReturnLen1))// 实际返回的字节数{printf("[!] EnumProcesses Failed With Error : %d\n",GetLastError());returnFALSE;}// 计算获取到了多少个 PID// 返回值是字节数,因此需要除以 DWORD 大小dwNmbrOfPids=dwReturnLen1/sizeof(DWORD);printf("[i] Number Of Processes Detected : %d\n",dwNmbrOfPids);// 遍历所有 PIDfor(inti=0;i<dwNmbrOfPids;i++){// PID 为 0 通常表示 Idle Processif(adwProcesses[i]!=0){// ------------------------------------------------// 尝试打开进程//// PROCESS_QUERY_INFORMATION:// 查询进程信息//// PROCESS_VM_READ:// 读取目标进程内存//// 有些系统进程由于权限不足可能无法打开// ------------------------------------------------hProcess=OpenProcess(PROCESS_QUERY_INFORMATION|PROCESS_VM_READ,FALSE,adwProcesses[i]);// 打开成功if(hProcess!=NULL){// --------------------------------------------// 获取该进程加载的模块信息//// 一般第一个模块就是主程序 EXE// --------------------------------------------if(!EnumProcessModules(hProcess,&hModule,sizeof(HMODULE),&dwReturnLen2)){printf("[!] EnumProcessModules Failed [ At Pid: %d ] With Error : %d \n",adwProcesses[i],GetLastError());}else{// ----------------------------------------// 根据模块句柄获取进程名称//// 例如:// explorer.exe// notepad.exe// chrome.exe// ----------------------------------------if(!GetModuleBaseName(hProcess,hModule,szProc,sizeof(szProc)/sizeof(WCHAR))){printf("[!] GetModuleBaseName Failed [ At Pid: %d ] With Error : %d \n",adwProcesses[i],GetLastError());}else{// 打印进程名称和 PIDwprintf(L"[%0.3d] Process \"%s\" - Of Pid : %d\n",i,szProc,adwProcesses[i]);}}// 使用完句柄后必须关闭CloseHandle(hProcess);}}// 继续处理下一个 PID}returnTRUE;}intmain(){PrintProcesses();return0;}这段代码体现的是:
- 正确理解权限边界
- 不假设自己能访问所有进程
- 不因为失败就中断枚举
NtQuerySystemInformation
NtQuerySystemInformation的作用是获取指定的系统信息,它是一个系统调用,ntdll.dll中导出,所以需要动态调用。
__kernel_entry NTSTATUSNtQuerySystemInformation([in]SYSTEM_INFORMATION_CLASS SystemInformationClass,[in,out]PVOID SystemInformation,[in]ULONG SystemInformationLength,[out,optional]PULONG ReturnLength);- SystemInformationClass - 决定函数返回哪种类型的系统信息
- SystemInformation - 指向用于接收请求信息的缓冲区的指针。返回的信息将以结构体的形式呈现,结构体的类型由 SystemInformationClass 参数指定
- SystemInformationLength - 由 SystemInformation 参数指向的缓冲区的大小,以字节为单位。
- ReturnLength - 指向 ULONG 变量的指针,该变量将接收写入 SystemInformation 信息的实际大小。
因为我们的目的是便利进程,所以我们使用SystemProcessInformation
SYSTEM_PROCESS_INFORMATION的结构如下:
typedefstruct_SYSTEM_PROCESS_INFORMATION{ULONG NextEntryOffset;ULONG NumberOfThreads;BYTE Reserved1[48];UNICODE_STRING ImageName;KPRIORITY BasePriority;HANDLE UniqueProcessId;HANDLE InheritedFromUniqueProcessId;ULONG HandleCount;ULONG SessionId;PVOID Reserved3;SIZE_T PeakVirtualSize;SIZE_T VirtualSize;ULONG Reserved4;SIZE_T PeakWorkingSetSize;SIZE_T WorkingSetSize;PVOID Reserved5;SIZE_T QuotaPagedPoolUsage;PVOID Reserved6;SIZE_T QuotaNonPagedPoolUsage;SIZE_T PagefileUsage;SIZE_T PeakPagefileUsage;SIZE_T PrivatePageCount;LARGE_INTEGER Reserved7[6];}SYSTEM_PROCESS_INFORMATION;由于使用SystemProcessInformation标志调用NtQuerySystemInformation将返回一个大小未知的SYSTEM_PROCESS_INFORMATION数组,因此NtQuerySystemInformation需要调用两次。第一次调用将检索数组大小用于分配缓冲区,然后第二次调用将使用已分配的缓冲区。
我们重点关注包含进程名称的UNICODE_STRING ImageName和进程 ID 的UniqueProcessId。此外,NextEntryOffset将用于移动到返回数组中的下一个元素。
#include<stdio.h>#include<windows.h>#include<tchar.h>#include<psapi.h>#include<winternl.h>// Function pointertypedefNTSTATUS(NTAPI*fnNtQuerySystemInformation)(SYSTEM_INFORMATION_CLASS SystemInformationClass,PVOID SystemInformation,ULONG SystemInformationLength,PULONG ReturnLength);BOOLGetRemoteProcessHandle(LPCWSTR szProcName,DWORD*pdwPid,HANDLE*phProcess){fnNtQuerySystemInformation pNtQuerySystemInformation=NULL;ULONG uReturnLen1=NULL,uReturnLen2=NULL;PSYSTEM_PROCESS_INFORMATION SystemProcInfo=NULL;NTSTATUS STATUS=NULL;PVOID pValueToFree=NULL;pNtQuerySystemInformation=(fnNtQuerySystemInformation)GetProcAddress(GetModuleHandle(L"NTDLL.DLL"),"NtQuerySystemInformation");if(pNtQuerySystemInformation==NULL){printf("[!] GetProcAddress Failed With Error : %d\n",GetLastError());returnFALSE;}pNtQuerySystemInformation(SystemProcessInformation,NULL,NULL,&uReturnLen1);//探测式调用,为例获取缓冲区大小//一个连续内存块,里面是所有 SYSTEM_PROCESS_INFORMATION 链表节点SystemProcInfo=(PSYSTEM_PROCESS_INFORMATION)HeapAlloc(GetProcessHeap(),HEAP_ZERO_MEMORY,(SIZE_T)uReturnLen1);if(SystemProcInfo==NULL){printf("[!] HeapAlloc Failed With Error : %d\n",GetLastError());returnFALSE;}//因为要修改SystemProcInfo 指针本身用于遍历,所以这里保存初始值pValueToFree=SystemProcInfo;//第二次调用,这次是真正的拿取数据STATUS=pNtQuerySystemInformation(SystemProcessInformation,SystemProcInfo,uReturnLen1,&uReturnLen2);if(STATUS!=0x0){printf("[!] NtQuerySystemInformation Failed With Error : 0x%0.8X \n",STATUS);returnFALSE;}while(TRUE){//ImageName.Length - 排除PID为0以及无名字的进程//wcscmp - 精准匹配,区分大小写if(SystemProcInfo->ImageName.Length&&wcscmp(SystemProcInfo->ImageName.Buffer,szProcName)==0){// 获取PID + 打开进程*pdwPid=(DWORD)SystemProcInfo->UniqueProcessId;*phProcess=OpenProcess(PROCESS_ALL_ACCESS,FALSE,(DWORD)SystemProcInfo->UniqueProcessId);break;}// 如果 NextEntryOffset 为 0,则表示已到达数组末尾if(!SystemProcInfo->NextEntryOffset)break;// 移动到数组中的下一个元素 枚举的核心逻辑SystemProcInfo=(PSYSTEM_PROCESS_INFORMATION)((ULONG_PTR)SystemProcInfo+SystemProcInfo->NextEntryOffset);}// Free using the initial addressHeapFree(GetProcessHeap(),0,pValueToFree);// Check if we successfully got the target process handleif(*pdwPid==NULL||*phProcess==NULL)returnFALSE;elsereturnTRUE;}intmain(){DWORD dwPid=0;HANDLE hProcess=NULL;if(!GetRemoteProcessHandle(L"notepad.exe",&dwPid,&hProcess)){printf("[!] Failed to get notepad.exe process handle\n");return-1;}printf("[+] notepad.exe found!\n");printf(" PID : %lu\n",dwPid);printf(" HANDLE : 0x%p\n",hProcess);printf("[*] Press Enter to close handle...\n");getchar();CloseHandle(hProcess);return0;}运行结果如下:
三者的区别
- CreateToolhelp32Snapshot
👉用户态快照,简单、稳定,但最容易被“藏” - EnumProcesses(PSAPI)
👉用户态进程列表,比 Toolhelp 稍“近内核”,但仍然可控 - NtQuerySystemInformation(SystemProcessInformation)
👉内核视角,信息最全、最底层,也是 EDR/Rootkit 的主战场
CreateToolhelp32Snapshot
工作机制
CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS,0)Process32First/Process32Next本质是:
- 内核生成一个进程快照
- 拷贝到用户态
- 你在用户态遍历
特点
优点:
- API 非常稳定
- 代码简单
- 兼容性极好
缺点:
快照是“静态”的
容易被:
- API Hook
- 内核过滤
- Rootkit 隐藏
可见信息
- PID
- PPID
- EXE 名
- 线程数
❌ 没有完整路径
❌ 没有命令行
❌ 没有 PEB / Session 细节
对抗 / 检测视角
- 最容易被隐藏
- 很多 rootkit 只要过滤这一条路径
- EDR 并不依赖它作为“真相来源”
适合场景
- 正常软件
- 工具类程序
- 不追求“绝对准确性”的枚举
EnumProcesses(PSAPI)
工作机制
EnumProcesses(...)→ 返回 PID 列表 → 再 OpenProcess+GetModuleFileNameEx底层依赖:
NtQuerySystemInformation的某些变体- 或内部内核接口(实现细节版本相关)
特点
优点:
比 Toolhelp 稍微“靠近内核”
可以配合:
- OpenProcess
- GetProcessImageFileName
缺点:
- 本身只给 PID
- 后续信息获取容易失败(权限 / PPL)
可见信息
- PID(直接)
- Image Path(间接)
- 模块信息(可选)
❌ 不直接给命令行
❌ 不给创建时间
❌ 不给完整结构信息
对抗 / 检测视角
比 Toolhelp 难藏一点
但:
- Hook psapi.dll
- 内核过滤 PID
仍然可行
适合场景
- 管理工具
- 资源监控
- 老代码(PSAPI 很多遗留)
NtQuerySystemInformation(SystemProcessInformation)
工作机制
NtQuerySystemInformation(SystemProcessInformation,buffer,size,&retLen)返回的是一个内核生成的链表结构:
SYSTEM_PROCESS_INFORMATION ├── UniqueProcessId ├── InheritedFromUniqueProcessId ├── ImageName (UNICODE_STRING) ├── CreateTime ├── SessionId ├── ThreadCount ├── CPU / Memory 统计 └── NextEntryOffset特点
优点:
信息最全
接近内核真实状态
可见:
- PPID
- Session
- 创建时间
- 线程数
- ImageName(不是路径)
缺点:
- 结构不稳定(版本差异)
- 需要自己遍历链表
- 是EDR/AV 强监控 API
可见信息(最全)
✔ PID
✔ PPID
✔ ImageName
✔ 创建时间
✔ SessionId
✔ 线程数
✔ 资源统计
但注意:
- ImageName ≠ 完整路径
- CommandLine 仍需读 PEB
对抗 / 检测视角
EDR 的“事实源”之一
Rootkit 想隐藏进程:
- 必须 hook / patch 这里
安全产品会:
- 对比 Toolhelp vs NtQuerySystemInformation
- 检测差异
适合场景
- 安全产品
- 红队
- Rootkit / Anti-rootkit
- 高级进程枚举
核心差异对比
| 维度 | Toolhelp | EnumProcesses | NtQuerySystemInformation |
|---|---|---|---|
| 数据来源 | 用户态快照 | 用户态接口 | 内核 |
| 实时性 | 低 | 中 | 高 |
| 信息量 | 少 | 中 | 多 |
| 易被隐藏 | 极高 | 高 | 低 |
| 实现复杂度 | 低 | 中 | 高 |
| EDR关注度 | 低 | 中 | 极高 |
一句话终极总结
CreateToolhelp32Snapshot 是“给应用看的”,EnumProcesses 是“给系统工具看的”,NtQuerySystemInformation 是“给安全产品和内核自己看的”。