三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

Windows底层进程遍历:NtQuerySystemInformation原理、实战与安全应用

Windows底层进程遍历:NtQuerySystemInformation原理、实战与安全应用

1. 项目缘起:为什么需要绕过常规API获取进程信息?

在Windows平台上,获取进程列表是系统编程、安全分析、性能监控乃至恶意软件检测中最基础也最频繁的操作之一。大多数开发者首先想到的可能是CreateToolhelp32SnapshotEnumProcesses或者WMI(Win32_Process)这些耳熟能详的接口。它们稳定、文档齐全,是官方推荐的做法。然而,在实际的攻防对抗、底层调试或某些需要极高隐蔽性和实时性的场景中,这些“标准答案”往往不够用,甚至会成为被监控和拦截的目标。

这就引出了我们今天要深入探讨的核心:直接调用NtQuerySystemInformation这个位于ntdll.dll中的未公开(或半公开)系统服务,来遍历进程信息。这个项目标题“使用 NtQuerySystemInformation 遍历进程信息”背后,隐藏的是一种更底层、更直接与Windows内核交互的思维方式。它不是为了替代标准API,而是为了理解当标准路径被阻断、需要绕过某些用户层钩子(Hook)、或者需要获取比常规API更丰富、更实时的系统信息时,我们手中还有什么牌可以打。

从技术角度看,NtQuerySystemInformation是Windows Native API的一部分。Native API可以看作是用户模式通往内核模式的“后门”或“快速通道”,它绕过了Win32子系统层,直接与内核中的执行体(Executive)进行交互。因此,通过它获取的信息,往往更“原始”,延迟也可能更低。在恶意软件分析中,许多Rootkit会挂钩(Hook)CreateToolhelp32Snapshot等函数来隐藏自身进程,但相对较少去挂钩更底层的NtQuerySystemInformation,这使得后者成为检测隐藏进程的有力工具。同样,在一些高性能的监控软件或游戏反作弊系统中,为了减少开销和避免被干扰,也会选择直接使用Native API。

所以,这个项目的价值远不止于“获取进程列表”这个简单的功能。它是一次对Windows系统内部运作机制的探索,是理解用户层与内核层交互的绝佳实践,也是构建更强大、更隐蔽的系统工具所必须掌握的一项底层技能。接下来,我将带你从零开始,一步步拆解如何安全、稳定地使用这个强大的函数。

2. 核心工具解析:NtQuerySystemInformation 函数探秘

在开始写代码之前,我们必须先彻底理解手中的工具。NtQuerySystemInformation并非一个普通的Win32 API,你在MSDN官方文档中找不到它的标准说明。它的函数原型通常需要通过逆向工程或查阅Windows Driver Kit (WDK)、Windows Research Kernel (WRK) 等资源来获得。

2.1 函数原型与关键参数

一个典型的函数声明(在C/C++中)如下所示:

typedef NTSTATUS (NTAPI *PNtQuerySystemInformation)( SYSTEM_INFORMATION_CLASS SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength );

我们需要通过GetProcAddressntdll.dll中动态获取这个函数的指针。让我们逐一拆解这四个参数:

  1. SystemInformationClass (系统信息类):这是一个枚举值,告诉函数你想要查询哪一类系统信息。这是整个调用的“钥匙”。对于进程信息,我们主要关注两个值:

    • SystemProcessInformation(值为 5): 这是最常用的,用于获取系统中所有进程及每个进程内所有线程的详细信息列表。
    • SystemExtendedProcessInformation(值可能随系统版本变化,如 57): 提供更扩展的进程信息,例如保护类型(Protected Process)、作业(Job)信息等,通常在较新的Windows版本(如Win8.1+)中可用。

    选择哪个类取决于你的需求。SystemProcessInformation已经包含了进程ID、父进程ID、优先级、句柄数、内存使用、执行时间以及线程列表等核心信息,对于绝大多数场景已经足够。

  2. SystemInformation (系统信息缓冲区):这是一个指向接收数据的缓冲区的指针。这里有一个至关重要的细节:这个缓冲区需要由调用者预先分配,但其大小是未知的。因为系统中的进程和线程数量是动态变化的,我们无法预先知道需要多大的内存来存放所有信息。

  3. SystemInformationLength (缓冲区长度):传入你分配的缓冲区大小(以字节为单位)。

  4. ReturnLength (返回长度):一个指向ULONG变量的指针。函数执行成功后,会通过这个参数告诉我们实际写入缓冲区的数据量是多少。如果缓冲区太小,它会返回STATUS_INFO_LENGTH_MISMATCH(对应NTSTATUS值0xC0000004),并在这个参数中填入所需的最小缓冲区大小。这是实现动态分配缓冲区的关键依据。

函数的返回值是NTSTATUS类型,这是一个NT内核函数通用的状态码,而不是Win32 API常用的BOOLHANDLE。成功时通常返回STATUS_SUCCESS(0)。我们必须检查这个返回值,而不是简单地认为非零即错。

2.2 信息结构体:SYSTEM_PROCESS_INFORMATION

SystemInformationClassSystemProcessInformation时,缓冲区将被填充为一个SYSTEM_PROCESS_INFORMATION结构体的链表。每个结构体代表一个进程,并且内嵌了该进程的线程信息数组。这个结构体同样未公开,但其布局相对稳定,一个常见的定义如下:

typedef struct _SYSTEM_PROCESS_INFORMATION { ULONG NextEntryOffset; // 指向下一个进程结构体的偏移量,0表示链表结束 ULONG NumberOfThreads; // 此进程中的线程数 LARGE_INTEGER WorkingSetPrivateSize; // 工作集的私有部分大小 ULONG HardFaultCount; ULONG NumberOfThreadsHighWatermark; ULONGLONG CycleTime; LARGE_INTEGER CreateTime; // 进程创建时间 LARGE_INTEGER UserTime; // 用户模式CPU时间 LARGE_INTEGER KernelTime; // 内核模式CPU时间 UNICODE_STRING ImageName; // 进程映像名称(可能为空) KPRIORITY BasePriority; HANDLE UniqueProcessId; // 进程PID HANDLE InheritedFromUniqueProcessId; // 父进程PID ULONG HandleCount; ULONG SessionId; ULONG_PTR UniqueProcessKey; SIZE_T PeakVirtualSize; SIZE_T VirtualSize; ULONG PageFaultCount; SIZE_T PeakWorkingSetSize; SIZE_T WorkingSetSize; SIZE_T QuotaPeakPagedPoolUsage; SIZE_T QuotaPagedPoolUsage; SIZE_T QuotaPeakNonPagedPoolUsage; SIZE_T QuotaNonPagedPoolUsage; SIZE_T PagefileUsage; SIZE_T PeakPagefileUsage; SIZE_T PrivatePageCount; LARGE_INTEGER ReadOperationCount; LARGE_INTEGER WriteOperationCount; LARGE_INTEGER OtherOperationCount; LARGE_INTEGER ReadTransferCount; LARGE_INTEGER WriteTransferCount; LARGE_INTEGER OtherTransferCount; SYSTEM_THREAD_INFORMATION Threads[1]; // 线程信息数组,实际长度由NumberOfThreads决定 } SYSTEM_PROCESS_INFORMATION, *PSYSTEM_PROCESS_INFORMATION;

遍历这个链表的核心就是利用NextEntryOffset字段。如果它为0,说明这是最后一个进程节点;否则,将当前指针加上这个偏移量,就能得到下一个进程节点的地址。

注意ImageName字段是一个UNICODE_STRING结构体,它包含一个指向实际字符串缓冲区的指针(Buffer)和字符串长度。这个缓冲区并不在SYSTEM_PROCESS_INFORMATION结构体内部,它位于由NtQuerySystemInformation分配的整体缓冲区的其他位置。这意味着你不能简单地释放整个缓冲区后还去访问ImageName.Buffer,同样,在复制或保存进程名时,需要额外处理这个字符串。

3. 实战步骤:从零构建进程遍历器

理解了原理,我们开始动手实现。整个过程可以清晰地分为几个步骤。

3.1 第一步:动态获取函数地址

由于NtQuerySystemInformation不是标准导出函数,我们不能直接链接。正确的方式是动态加载ntdll.dll并获取函数地址。ntdll.dll是Windows的核心用户态库,几乎每个进程都会加载它。

#include <windows.h> #include <winternl.h> // 可能包含一些NT结构体的定义,但函数原型通常需要自己声明 // 声明函数指针类型 typedef NTSTATUS (NTAPI *PNtQuerySystemInformation)( SYSTEM_INFORMATION_CLASS SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength ); int main() { // 加载 ntdll.dll HMODULE hNtdll = GetModuleHandleW(L"ntdll.dll"); if (hNtdll == NULL) { // 理论上不会失败,因为系统进程都加载了ntdll return -1; } // 获取函数地址 PNtQuerySystemInformation pNtQuerySystemInformation = (PNtQuerySystemInformation)GetProcAddress(hNtdll, "NtQuerySystemInformation"); if (pNtQuerySystemInformation == NULL) { // 函数名获取失败,可能是系统版本极旧或不支持 return -1; } // 现在 pNtQuerySystemInformation 就可以像普通函数一样调用了 // ... 后续步骤 return 0; }

3.2 第二步:动态分配足够大小的缓冲区

这是整个流程中最容易出错的一环。我们不能一次性分配一个“足够大”的缓冲区(比如100MB),因为这不优雅且浪费。正确的做法是遵循“尝试-失败-调整”的循环。

#define INITIAL_BUFFER_SIZE (1024 * 1024) // 初始尝试1MB,一个合理的起始值 PVOID pBuffer = NULL; ULONG uReturnLength = 0; ULONG uBufferSize = INITIAL_BUFFER_SIZE; NTSTATUS status; do { // 释放之前尝试分配的缓冲区(第一次循环时pBuffer为NULL,free是安全的) if (pBuffer) { VirtualFree(pBuffer, 0, MEM_RELEASE); pBuffer = NULL; } // 分配缓冲区 pBuffer = VirtualAlloc(NULL, uBufferSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (pBuffer == NULL) { // 内存分配失败 return -1; } // 调用函数 status = pNtQuerySystemInformation(SystemProcessInformation, pBuffer, uBufferSize, &uReturnLength); if (status == STATUS_INFO_LENGTH_MISMATCH) { // 缓冲区不足,uReturnLength中包含了所需大小 uBufferSize = uReturnLength; // 直接使用返回的所需大小 } else if (!NT_SUCCESS(status)) { // 其他错误 VirtualFree(pBuffer, 0, MEM_RELEASE); return -1; } // 如果 status == STATUS_SUCCESS,循环结束 } while (status == STATUS_INFO_LENGTH_MISMATCH);

这个do-while循环确保了无论系统中有多少进程,我们最终都能分配一个刚好够用的缓冲区。使用VirtualAllocVirtualFree而不是malloc/free,是因为我们处理的数据量可能较大,且VirtualAlloc可以更精细地控制内存属性(尽管这里用默认的即可)。

3.3 第三步:遍历链表并解析信息

成功获取数据后,pBuffer指向的就是一个SYSTEM_PROCESS_INFORMATION链表的头部。遍历它就像遍历一个单链表。

PSYSTEM_PROCESS_INFORMATION pCurrent = (PSYSTEM_PROCESS_INFORMATION)pBuffer; while (pCurrent) { // 1. 提取进程PID和父进程PID DWORD dwPid = (DWORD)(ULONG_PTR)pCurrent->UniqueProcessId; DWORD dwParentPid = (DWORD)(ULONG_PTR)pCurrent->InheritedFromUniqueProcessId; // 2. 提取进程名(需要小心处理) WCHAR szProcessName[MAX_PATH] = {0}; if (pCurrent->ImageName.Buffer && pCurrent->ImageName.Length > 0) { // UNICODE_STRING的长度是字节数,不是字符数 int nameLen = min(pCurrent->ImageName.Length / sizeof(WCHAR), MAX_PATH - 1); wcsncpy_s(szProcessName, MAX_PATH, pCurrent->ImageName.Buffer, nameLen); szProcessName[nameLen] = L'\0'; } else { // 有些系统进程(如Idle, System)的ImageName可能为空 wcscpy_s(szProcessName, MAX_PATH, L"[Unknown]"); } // 3. 提取其他有用信息 SIZE_T workingSetKB = pCurrent->WorkingSetSize / 1024; SIZE_T privateBytesKB = pCurrent->PrivatePageCount * (4096 / 1024); // 假设页大小4KB ULONG threadCount = pCurrent->NumberOfThreads; // 4. 打印或处理信息 wprintf(L"PID: %6u | Parent: %6u | Threads: %3u | WS: %6u KB | Name: %s\n", dwPid, dwParentPid, threadCount, (DWORD)workingSetKB, szProcessName); // 5. 遍历此进程的所有线程(可选) for (ULONG i = 0; i < pCurrent->NumberOfThreads; i++) { // 可以访问 pCurrent->Threads[i] 来获取线程ID、状态、CPU时间等 // DWORD dwTid = (DWORD)(ULONG_PTR)pCurrent->Threads[i].ClientId.UniqueThread; } // 6. 移动到链表中的下一个进程 if (pCurrent->NextEntryOffset == 0) { pCurrent = NULL; // 链表结束 } else { // 关键步骤:通过偏移量计算下一个结构体的地址 pCurrent = (PSYSTEM_PROCESS_INFORMATION)((BYTE*)pCurrent + pCurrent->NextEntryOffset); } }

3.4 第四步:清理资源

遍历完成后,务必释放之前分配的内存。

if (pBuffer) { VirtualFree(pBuffer, 0, MEM_RELEASE); pBuffer = NULL; }

4. 避坑指南与高级技巧

将上述步骤组合起来,一个基础的进程遍历器就完成了。但在实际应用中,你会遇到比示例代码复杂得多的情况。下面是我在多次实践中总结出的关键注意事项和进阶技巧。

4.1 处理进程名中的路径与系统进程

你可能会发现,通过ImageName获取的字符串有时是完整的路径(如\Device\HarddiskVolume3\Windows\System32\svchost.exe),有时只是一个简单的名字(如svchost.exe),有时甚至是空的。对于系统关键进程(如System, PID 4)和空闲进程(PID 0),ImageName通常是空的。一个健壮的程序应该能处理这些情况。

如果需要统一的、不带路径的进程名,可以自己解析字符串:

// 在提取进程名后,可以尝试获取文件名部分 WCHAR* pLastBackslash = wcsrchr(szProcessName, L'\\'); if (pLastBackslash) { // 移动指针到反斜杠之后的位置 wmemmove(szProcessName, pLastBackslash + 1, wcslen(pLastBackslash + 1) + 1); }

4.2 应对多线程环境下的数据“快照”特性

NtQuerySystemInformation返回的数据是调用瞬间的系统“快照”。这意味着,在你遍历链表的过程中,系统中的进程可能已经创建或退出。这通常不会导致程序崩溃,因为缓冲区是静态的,但可能会导致你的列表“不完整”或包含已经终止的进程信息。对于大多数监控场景,这可以接受。如果需要更严格的一致性,你可能需要更复杂的同步机制,或者接受这份快照的瞬时性。

4.3 权限问题与获取更多信息

以普通用户权限运行上述代码,可以获取到系统中大部分进程的信息。但是,对于一些受保护进程(Protected Process)或属于其他用户会话(Session)的进程,你可能会发现无法获取其映像名称,或者某些字段(如父进程ID)为0。要获取更完整的信息,通常需要以Administrator权限运行程序,并启用调试权限(SeDebugPrivilege)。

启用调试权限的代码片段:

BOOL EnableDebugPrivilege() { HANDLE hToken; TOKEN_PRIVILEGES tkp; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken)) { return FALSE; } LookupPrivilegeValue(NULL, SE_DEBUG_NAME, &tkp.Privileges[0].Luid); tkp.PrivilegeCount = 1; tkp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED; BOOL result = AdjustTokenPrivileges(hToken, FALSE, &tkp, 0, NULL, 0); CloseHandle(hToken); return result && (GetLastError() == ERROR_SUCCESS); }

main函数开始时调用此函数。请注意,拥有调试权限是非常强大的,它允许你的进程像调试器一样操作其他进程,请谨慎使用。

4.4 性能考量与缓冲区大小策略

在进程数量非常多(例如数千个)的系统上,分配和填充一个巨大的缓冲区(可能几十MB)会带来短暂但明显的性能开销。如果你需要频繁查询(比如每秒几次),这可能会成为瓶颈。

优化策略1:缓存与增量更新。如果不是每次都需要完全刷新的列表,可以考虑缓存上一次的结果,并只查询自上次以来发生变化的部分。但这需要更复杂的逻辑,SystemProcessInformation类本身不支持增量查询。你可以考虑其他信息类,或者结合事件通知(如进程创建/退出通知)来实现。

优化策略2:调整初始缓冲区大小。示例中我们从1MB开始。你可以根据上一次查询成功的ReturnLength,将其作为下一次查询的初始大小,这样可以减少循环重试的次数。例如,在持续监控的服务中,可以保存一个“上次成功大小”,并在此基础上增加一个安全余量(比如20%)作为新的初始大小。

4.5 跨版本兼容性:结构体可能变化

这是使用未公开API最大的风险。SYSTEM_PROCESS_INFORMATION结构体在不同版本的Windows(甚至不同Service Pack)中可能会增加新的字段。微软没有义务保持其布局稳定。我们的代码依赖于一个固定的结构体定义,这在未来可能会出错。

缓解措施

  1. 使用偏移量访问字段:不要直接使用pCurrent->UniqueProcessId,而是通过计算字段在结构体中的偏移量来访问。这需要你事先知道每个字段在不同系统版本上的偏移量,维护起来非常复杂。
  2. 运行时检测:更实用的方法是,只访问那些已知在所有目标系统版本上都稳定存在的“核心字段”,如NextEntryOffsetNumberOfThreadsUniqueProcessIdImageName等。对于新增的字段,做好可能不存在的心理准备,或者通过系统版本号进行条件编译。
  3. 优先使用公开或半公开的替代方案:如果项目对稳定性要求极高,且不需要NtQuerySystemInformation的特定优势,应优先考虑CreateToolhelp32Snapshot或WMI。本技术主要用于特定场景。

一个简单的版本检测示例:

BOOL IsWindows8OrGreater() { OSVERSIONINFOEXW osvi = { sizeof(osvi) }; DWORDLONG const dwlConditionMask = VerSetConditionMask( VerSetConditionMask( VerSetConditionMask(0, VER_MAJORVERSION, VER_GREATER_EQUAL), VER_MINORVERSION, VER_GREATER_EQUAL), VER_SERVICEPACKMAJOR, VER_GREATER_EQUAL); osvi.dwMajorVersion = HIBYTE(_WIN32_WINNT_WIN8); // 6 osvi.dwMinorVersion = LOBYTE(_WIN32_WINNT_WIN8); // 2 osvi.wServicePackMajor = 0; return VerifyVersionInfoW(&osvi, VER_MAJORVERSION | VER_MINORVERSION | VER_SERVICEPACKMAJOR, dwlConditionMask) != FALSE; } // 根据版本决定是否尝试访问 `SystemExtendedProcessInformation` 或结构体中的新字段

5. 对比分析与应用场景选择

现在我们已经掌握了如何使用NtQuerySystemInformation,是时候回过头来,将它放在更广阔的视野中,与其它方法进行对比,明确它的最佳应用场景。

5.1 与主流API的横向对比

特性NtQuerySystemInformationCreateToolhelp32SnapshotEnumProcessesWMI (Win32_Process)
接口层级最底层 (Native API)用户层,但较底层用户层 (Psapi.dll)最高层 (基于COM/DCOM)
信息丰富度极高(进程+线程详细数据)高 (进程、线程、模块、堆)极低 (仅PID数组)高 (丰富的WMI属性)
性能开销较低(直接内核调用)中等很高(需要初始化COM,跨进程)
隐蔽性(较少被用户层钩子拦截)低 (常见监控/拦截点)
开发复杂度(未文档化,需手动定义)中等 (需处理COM)
稳定性/兼容性(结构体可能变化)
实时性高 (单次快照)高 (单次快照)高 (单次快照)低 (可能有延迟)
典型应用安全软件、反作弊、底层监控、研究通用进程管理、诊断工具快速获取PID列表远程管理、脚本、需要丰富属性的场景

5.2 明确应用场景:何时该用,何时不该用

你应该使用NtQuerySystemInformation当:

  1. 开发安全或反恶意软件工具:你需要检测那些通过挂钩CreateToolhelp32Snapshot来隐藏自身的Rootkit。直接调用更底层的API可以绕过这些用户层的钩子。
  2. 进行高性能系统监控:你需要以极高的频率(如每秒数十次)采样进程信息,对性能极其敏感,无法承受WMI或多次调用标准API的开销。
  3. 需要单次调用获取进程和线程的完整详细信息:你既需要进程的CPU时间、内存细节,也需要其内部所有线程的状态,希望一次调用拿到所有数据,避免多次查询带来的不一致性和额外开销。
  4. 学习Windows内核机制:作为学习者,你想绕过Win32这层“封装”,直接理解用户程序如何与内核对话,这是一个绝佳的实践案例。

你应该避免使用NtQuerySystemInformation当:

  1. 开发通用应用程序或商业软件:对绝大多数普通应用来说,稳定性和跨版本兼容性远比那一点性能提升或隐蔽性重要。使用公开的、文档齐全的API是更负责任的选择。
  2. 需要远程查询其他计算机的进程信息NtQuerySystemInformation只能查询本地系统。WMI或PowerShell Remoting是更好的远程管理选择。
  3. 你的代码需要在从Windows XP到Windows 11的所有系统上运行:维护一个适应所有版本的结构体定义和偏移量将是一场噩梦。
  4. 你只是需要一个简单的进程列表EnumProcessesCreateToolhelp32Snapshot完全够用,且代码简单易懂。

5.3 一个综合示例:快速查找可疑进程链

结合我们学到的知识,这里展示一个稍微高级点的用法:快速扫描进程链表,寻找那些父进程ID(PPID)异常(例如,用户进程的父进程是服务进程,或者出现“进程镂空”现象)的可疑进程。这在安全分析中很常见。

// 假设我们已经成功获取了进程信息链表 pFirstProcess PSYSTEM_PROCESS_INFORMATION pCurrent = pFirstProcess; std::unordered_map<DWORD, DWORD> processParentMap; // PID -> PPID std::vector<DWORD> pids; // 第一遍遍历:建立PID和PPID的映射关系 while (pCurrent) { DWORD pid = HandleToULong(pCurrent->UniqueProcessId); DWORD ppid = HandleToULong(pCurrent->InheritedFromUniqueProcessId); processParentMap[pid] = ppid; pids.push_back(pid); // ... 移动到下一个进程 } // 第二遍分析:检查每个进程的父进程是否在列表中,以及是否合理 for (DWORD pid : pids) { DWORD ppid = processParentMap[pid]; if (ppid == 0) { // PID 0是系统空闲进程,PID 4是System进程,它们的父进程为0是正常的 if (pid != 0 && pid != 4) { wprintf(L"[可疑] 进程 %u 的父进程ID为0(已退出?)\n", pid); } continue; } // 查找父进程是否存在 if (processParentMap.find(ppid) == processParentMap.end()) { // 父进程不在当前快照中,可能在我们获取列表后立即退出了,也可能是异常情况 wprintf(L"[警告] 进程 %u 的父进程 %u 不存在于当前进程列表中。\n", pid, ppid); } // 可以添加更多启发式规则,例如: // - 检查进程名是否常见(白名单/黑名单) // - 检查进程路径是否在系统目录或临时目录 // - 检查进程的创建时间是否异常新 }

这个例子展示了如何将原始的进程数据转化为有意义的分析。NtQuerySystemInformation提供了构建这种分析所需的所有原始材料。

6. 从理论到产品:构建健壮的进程监控模块

掌握了核心原理和代码片段后,我们可以更进一步,思考如何将这些知识封装成一个可以在实际项目中使用的、健壮的进程监控模块。这不仅仅是函数的简单调用,还涉及到错误处理、资源管理、数据封装和设计模式。

6.1 设计一个C++封装类

一个好的封装应该隐藏底层API的复杂性,提供简洁、安全的接口。下面是一个简化版的设计思路:

class ProcessSnapshot { private: std::vector<BYTE> m_buffer; // 使用vector管理内存,自动释放 PSYSTEM_PROCESS_INFORMATION m_pFirstProcess; bool m_initialized; // 内部初始化函数 NTSTATUS CaptureSnapshot() { // ... 动态获取NtQuerySystemInformation地址 // ... 动态分配缓冲区的循环逻辑 // 成功后将数据拷贝到 m_buffer, 并设置 m_pFirstProcess } public: ProcessSnapshot() : m_pFirstProcess(nullptr), m_initialized(false) { if (NT_SUCCESS(CaptureSnapshot())) { m_initialized = true; m_pFirstProcess = reinterpret_cast<PSYSTEM_PROCESS_INFORMATION>(m_buffer.data()); } } bool IsValid() const { return m_initialized; } // 迭代器设计,方便使用范围for循环 class Iterator { PSYSTEM_PROCESS_INFORMATION m_current; public: Iterator(PSYSTEM_PROCESS_INFORMATION p) : m_current(p) {} Iterator& operator++() { if (m_current && m_current->NextEntryOffset != 0) { m_current = (PSYSTEM_PROCESS_INFORMATION)((BYTE*)m_current + m_current->NextEntryOffset); } else { m_current = nullptr; } return *this; } bool operator!=(const Iterator& other) const { return m_current != other.m_current; } PSYSTEM_PROCESS_INFORMATION operator*() const { return m_current; } }; Iterator begin() const { return m_initialized ? Iterator(m_pFirstProcess) : Iterator(nullptr); } Iterator end() const { return Iterator(nullptr); } // 根据PID查找特定进程 PSYSTEM_PROCESS_INFORMATION FindProcessByPid(DWORD pid) const { for (auto pProc : *this) { if (HandleToULong(pProc->UniqueProcessId) == pid) { return pProc; } } return nullptr; } };

使用这个类,遍历进程变得非常简单和安全:

ProcessSnapshot snapshot; if (snapshot.IsValid()) { for (auto pProc : snapshot) { // 处理每个进程 pProc DWORD pid = HandleToULong(pProc->UniqueProcessId); // ... } }

6.2 错误处理与日志记录

在生产环境中,不能仅仅在失败时返回-1。我们需要更细致的错误处理。

  • NTSTATUS解码NTSTATUS是一个32位值,包含严重性、设施、代码等信息。可以编写一个辅助函数将其转换为可读的字符串,类似于FormatMessage对于Win32错误码的作用。
  • 分级日志:在分配内存失败、API调用返回非预期状态时,记录不同级别的日志(错误、警告、信息),便于后期排查。
  • 资源泄漏防护:使用RAII(Resource Acquisition Is Initialization)技术确保在任何路径下(包括异常)缓冲区都能被正确释放。上面的std::vector就是一个简单的例子,更复杂的可以使用自定义的删除器配合std::unique_ptr

6.3 性能优化:定时采样与差异分析

对于持续监控的场景,频繁获取完整快照成本太高。一个优化方案是定时采样(例如每秒一次),并只分析和报告发生变化的部分。

class ProcessMonitor { ProcessSnapshot m_prevSnapshot; std::chrono::steady_clock::time_point m_lastCaptureTime; public: void CheckForChanges() { ProcessSnapshot currentSnapshot; if (!currentSnapshot.IsValid()) return; if (m_prevSnapshot.IsValid()) { // 对比两次快照,找出新进程和已结束的进程 std::set<DWORD> prevPids, currPids; for (auto pProc : m_prevSnapshot) prevPids.insert(HandleToULong(pProc->UniqueProcessId)); for (auto pProc : currentSnapshot) currPids.insert(HandleToULong(pProc->UniqueProcessId)); std::vector<DWORD> newPids, gonePids; std::set_difference(currPids.begin(), currPids.end(), prevPids.begin(), prevPids.end(), std::back_inserter(newPids)); std::set_difference(prevPids.begin(), prevPids.end(), currPids.begin(), currPids.end(), std::back_inserter(gonePids)); for (DWORD pid : newPids) { auto pProc = currentSnapshot.FindProcessByPid(pid); // 报告新进程创建事件 ReportProcessCreated(pProc); } for (DWORD pid : gonePids) { // 报告进程退出事件 ReportProcessExited(pid); } } else { // 第一次运行,报告所有现有进程 for (auto pProc : currentSnapshot) { ReportProcessCreated(pProc); } } m_prevSnapshot = std::move(currentSnapshot); // 移动赋值,效率高 m_lastCaptureTime = std::chrono::steady_clock::now(); } };

这种差异分析可以大幅减少数据处理量,只关注动态变化,非常适合实现进程行为监控或简单的入侵检测系统(IDS)功能。

6.4 安全边界与防御性编程

使用底层API也意味着更大的责任。你的代码必须非常健壮,能够抵御恶意构造的系统状态。

  • 缓冲区溢出防护:在解析UNICODE_STRING进程名时,务必使用安全字符串函数(如wcsncpy_s)并检查长度,防止因结构体被破坏导致的缓冲区溢出。
  • 链表完整性验证:在遍历NextEntryOffset时,添加合理性检查。例如,检查偏移量是否导致指针移动到缓冲区范围之外,或者是否造成了循环链表。可以记录已访问的节点地址来检测循环。
  • 异常处理:在遍历和解析的代码周围使用__try/__except结构化异常处理(SEH),以捕获可能因访问无效内存而引发的异常,防止整个程序崩溃。
  • 最小权限原则:即使你的程序需要调试权限,也应在完成必要操作后尽快将其禁用,减少攻击面。

通过以上这些步骤,我们就把一个简单的技术点“使用 NtQuerySystemInformation 遍历进程信息”,扩展成了一个具备工业强度、考虑周全的解决方案原型。从理解一个未文档化函数的调用,到设计出安全、高效、易用的模块,这中间体现的正是系统编程从入门到精通的思考过程。

← 返回列表