Windows 进程枚举的 Native API 缓冲区遍历

Windows 进程枚举的 Native API 缓冲区遍历

文章所述内容全部在KswordARK中有良好项目实现,开源地址github.com/wangwei-cm/...

取得可验证的当前进程快照,需要完成四步

这个程序的目标是读取当前可见进程,并输出每个进程的 PID、创建时间、父 PID、会话、线程数、句柄数和映像名。要完成这个目标,需要依次完成四步:

  1. 确认进程实例身份并定位 Native 查询入口。
  2. 用有限增长的缓冲区取得原始快照。
  3. 沿可变长记录的偏移逐条读取进程信息。
  4. 输出已验证字段,并说明快照的证据范围。

进程是 Windows 内核创建的执行对象。它保存进程标识、创建时间、线程集合、地址空间、句柄表和安全上下文。NtQuerySystemInformation(SystemProcessInformation) 请求内核把当前可见的进程状态复制到调用方缓冲区,ntdll 将这段原始数据返回给用户程序。读取器只在确认缓冲区长度、记录偏移和字符串边界后,才读取 PID、父 PID、创建时间、会话、线程数和映像名。

完整关系是:Windows 内核的进程对象集合 -> ntdll!NtQuerySystemInformation -> 调用方拥有的连续 BYTE 缓冲区 -> SYSTEM_PROCESS_INFORMATION 记录链 -> 经验证的进程实例记录。

快照是查询期间得到的观察结果。进程可以在查询前、查询中或查询后创建和退出,任何枚举方法都会遇到时间差。一次成功查询提供一段内部一致的返回缓冲区。不同查询之间的条目数量、父 PID 和名称发生变化属于正常现象。把多个查询结果合并前,应保留每批的采样时间和来源。

1. 确认进程实例身份并定位 Native 查询入口

PID(Process Identifier,进程标识符)是 Windows 在进程存活期间分配的数字。进程退出后,该数字可分配给后来的新进程。创建时间是进程对象建立时写入的时间值,在同一实例生命期内稳定。调查记录使用 (PID, CreateTime) 作为进程实例键,能够区分"同一 PID 的旧实例"和"PID 被复用后的新实例"。

父 PID 是子进程创建时记录的父进程标识。它描述创建关系,无法单独证明当前父进程仍存在。父进程退出后,当前快照可找不到同一 PID 的父实例。会话 ID(Session ID)描述服务、控制台用户或远程桌面用户所在的 Windows 会话范围,它帮助解释交互隔离,却不能替代创建时间验证。

Native API、ntdll 与 NTSTATUS

Native API 是 ntdll 提供、与 Windows 内核系统服务协作的一层接口。NtQuerySystemInformation 的返回类型是 NTSTATUS,成功值为零。NTSTATUS 与 Win32 GetLastError 记录的错误属于两套状态:Native 调用返回失败时直接检查 NTSTATUS,不要用无关的 GetLastError 重新解释。

SystemProcessInformationSYSTEM_INFORMATION_CLASS 中用于请求进程快照的类别。其返回数据是连续字节缓冲区,API 不分配这块内存,也不返回需要释放的内核句柄。调用方分配 BYTE 容器并在容器离开作用域时释放。ReturnLength 是可选输出,容量不足时通常给出建议字节数,成功时可给出实际字节数。

cpp 复制代码
// 作用:查询系统范围信息。本篇传入 SystemProcessInformation 获取进程记录链。
// 返回:NTSTATUS,0 成功。STATUS_INFO_LENGTH_MISMATCH 等表示当前缓冲区容量不足。
NTSTATUS NTAPI NtQuerySystemInformation(
    SYSTEM_INFORMATION_CLASS SystemInformationClass, // 输入:查询类别,使用 SystemProcessInformation
    PVOID SystemInformation,                          // 输出:调用方可写缓冲区。测量阶段可为 nullptr
    ULONG SystemInformationLength,                    // 输入:缓冲区容量,单位字节,最大为 ULONG
    PULONG ReturnLength                               // 输出:实际或建议字节数。可为 nullptr,本流程传入有效地址
);

NtQuerySystemInformationntdll.dll 导出。GetModuleHandleW 取得已加载 ntdll 的借用模块句柄,借用句柄不调用 FreeLibraryGetProcAddress 从该模块取得函数地址,地址只在模块持续加载期间有效。系统进程的 ntdll 生命周期覆盖当前进程,一般可以安全用于本次同步查询。

cpp 复制代码
// 作用:取得当前进程已经加载模块的借用句柄。
// 返回:非 nullptr 成功。nullptr 失败,读取 GetLastError。返回句柄不转移所有权。
HMODULE GetModuleHandleW(
    LPCWSTR lpModuleName // 输入:UTF-16 模块名,例如 L"ntdll.dll"。nullptr 表示当前可执行模块
);

// 作用:取得模块导出函数地址。
// 返回:非 nullptr 成功。nullptr 失败,读取 GetLastError。返回地址不需要也不能释放。
FARPROC GetProcAddress(
    HMODULE hModule,     // 输入:有效已加载模块句柄
    LPCSTR lpProcName    // 输入:ASCII 导出名称或序号。本篇使用 "NtQuerySystemInformation"
);

完成这一步后,读取器已经知道怎样区分两个 PID 相同但创建时间不同的进程,也取得了调用系统查询的函数地址。接下来需要准备一块大小合适的内存,让内核能够写入完整快照。

2. 用有限增长缓冲区取得原始快照

进程数量和线程数量会改变,因此首次给出的容量可能不足。STATUS_INFO_LENGTH_MISMATCHSTATUS_BUFFER_TOO_SMALLSTATUS_BUFFER_OVERFLOW 表示需要更大缓冲区。正确流程每轮清空旧字节,调用 API,成功时停止。可增长状态时使用 ReturnLength 或倍增策略扩大容量。其他状态、容量不增长、超过安全上限和重试次数耗尽时停止并记录 NTSTATUS。

cpp 复制代码
constexpr NTSTATUS StatusSuccess = 0x00000000L;
constexpr NTSTATUS StatusInfoLengthMismatch = static_cast<NTSTATUS>(0xC0000004L);
constexpr NTSTATUS StatusBufferTooSmall = static_cast<NTSTATUS>(0xC0000023L);
constexpr NTSTATUS StatusBufferOverflow = static_cast<NTSTATUS>(0x80000005L);

std::vector<BYTE> buffer(256 * 1024);
ULONG returnedBytes = 0;
NTSTATUS status = StatusInfoLengthMismatch;
for (int attempt = 0; attempt < 8; ++attempt) {
    returnedBytes = 0;
    status = query(SystemProcessInformation, buffer.data(),
        static_cast<ULONG>(buffer.size()), &returnedBytes);
    if (status == StatusSuccess) break;
    bool canGrow = status == StatusInfoLengthMismatch || status == StatusBufferTooSmall ||
        status == StatusBufferOverflow;
    if (!canGrow || buffer.size() >= 64 * 1024 * 1024) return;
    size_t next = returnedBytes > buffer.size() ? size_t(returnedBytes) + 64 * 1024 : buffer.size() * 2;
    if (next <= buffer.size() || next > 64 * 1024 * 1024) return;
    buffer.assign(next, 0); // 旧快照字节作废,不与下一轮结果混用。
}
if (status != StatusSuccess) return;

成功后,returnedBytes 为零或大于容器容量时,使用容器容量作为可访问范围。在其它成功情况使用 returnedBytes。这条规则避免把未初始化尾部当作记录,也避免某些系统未填写长度时把有效缓冲区误判为空。

完成这一步后,调用方拥有了一段来自同一次查询的有效字节。缓冲区只是连续内存,尚未分成进程条目。接下来必须按内核给出的偏移定位每条记录,不能假定所有条目长度相同。

3. 沿可变长记录的偏移逐条读取进程信息

SYSTEM_PROCESS_INFORMATION 的开头字段在进程记录间保持相同布局,后面紧随 NumberOfThreads 个线程记录。不同 Windows SDK 公开的字段范围可能不同,用户态解析器可定义本篇需要的稳定前缀,并只访问前缀内字段。NextEntryOffset 是从当前记录起点到下一条记录起点的字节数。零表示当前记录为最后一项。

本篇读取的字段含义如下:

  • NextEntryOffset:下一记录的相对字节偏移,用于遍历链。
  • NumberOfThreads:当前记录包含的线程数量。
  • CreateTime:进程创建时间,单位为 100ns,形成实例键的一部分。
  • UserTimeKernelTime:进程累计 CPU 时间,单位为 100ns。
  • ImageNameUNICODE_STRING 格式的进程名称。
  • UniqueProcessId:以 HANDLE 形态承载的 PID 数值,不构成可关闭的进程 HANDLE。
  • InheritedFromUniqueProcessId:父 PID 数值。
  • HandleCount:快照中内核报告的进程句柄数量。
  • SessionId:进程所属会话标识。

UNICODE_STRING 是长度显式的 UTF-16 字符串描述符。Length 是实际内容字节数,MaximumLength 是缓冲区容量字节数,Buffer 是字符地址。Length 不包含结尾 NUL,也不保证字符串有 NUL。快照里的 Buffer 由返回缓冲区拥有,当前调用方不能释放它。需要长期保存名称时,用 Length / sizeof(wchar_t) 复制到自己的 std::wstring

验证记录偏移与字符串边界

记录遍历的每一轮先确认"从当前 offset 到有效缓冲区最后的剩余字节"至少等于前缀大小。读取 NextEntryOffset 后,零表示结束。非零值至少覆盖前缀大小,且不能大于剩余字节。偏移增加使用已经验证的值,因此下一轮仍位于缓冲区内。

ImageName.Buffer 的地址验证使用无符号地址数值比较:字符串起始地址需要大于等于缓冲区起始地址、小于等于结束地址,Length 需要小于等于剩余字节,且 Length 必须是 wchar_t 大小的整数倍。任一条件失败时,保留 PID 等前缀字段并把名称状态标为异常。整条偏移链异常时停止,后续字节没有可信起点。

cpp 复制代码
size_t offset = 0;
while (offset < validBytes) {
    if (validBytes - offset < sizeof(SystemProcessInformationPrefix)) break;
    const auto* item = reinterpret_cast<const SystemProcessInformationPrefix*>(buffer.data() + offset);
    // 复制 PID、创建时间和 UNICODE_STRING。所有读取仍在已验证前缀范围内。
    if (item->NextEntryOffset == 0) break;
    if (item->NextEntryOffset < sizeof(SystemProcessInformationPrefix) ||
        item->NextEntryOffset > validBytes - offset) break;
    offset += item->NextEntryOffset;
}

快照结构的布局会随 Windows 版本演进。稳定前缀以外的字段、线程子结构大小和私有信息类都需要匹配该系统版本的定义。调查工具应记录操作系统版本、进程架构、信息类和解析结构版本,不能把一次测试得到的私有偏移推广到所有系统。

完成这一步后,读取器已经从连续字节中分离出经过范围检查的记录和名称。进程状态仍会随时间变化,单次快照的含义和异常条目的处理范围还需要明确,才能正确使用这些输出。

4. 输出已验证字段,并说明快照的证据范围

Native 查询成功意味着内核已把一份可解析的结果复制进当前缓冲区。它不意味着查询后这些进程仍在运行,也不意味着父 PID 指向的实例仍在系统中。查询失败时,旧缓冲区属于上一次调用,不能继续显示为本次结果。单项名称异常时,已通过边界验证的 PID、创建时间和会话仍可保存。偏移链异常时,本批后续条目应停止解析。

Toolhelp 是一个独立的 Win32 快照接口,可在 Native 路径无法获取时提供回退结果。两种快照的采样时刻和字段来源不同,比较时记录"Native"或"Toolhelp"来源。相同 PID 的差异要结合创建时间和采样时间分析,不能把它直接写成隐藏进程或枚举失败。

cpp 复制代码
// 作用:创建 Toolhelp 进程快照。
// 返回:成功为 HANDLE。INVALID_HANDLE_VALUE 失败,读取 GetLastError。成功后调用 CloseHandle。
HANDLE CreateToolhelp32Snapshot(
    DWORD dwFlags,      // 输入:TH32CS_SNAPPROCESS 请求进程记录
    DWORD th32ProcessID // 输入:进程快照使用 0
);

// 作用:读取快照中下一条进程记录。
// 返回:非零成功。0 且 ERROR_NO_MORE_FILES 表示正常结束,其它错误表示枚举中断。
BOOL Process32NextW(
    HANDLE hSnapshot,          // 输入:有效快照 HANDLE
    LPPROCESSENTRY32W lppe     // 输入/输出:调用方结构。dwSize 必须预设为 sizeof(PROCESSENTRY32W)
);

最小错误示范

以下写法跳过缓冲区检查,损坏或截断的偏移会把下一次访问带到数组外:

cpp 复制代码
auto* item = reinterpret_cast<SYSTEM_PROCESS_INFORMATION*>(buffer.data() + offset);
offset += item->NextEntryOffset; // 错误:零、过小和越界值都未处理。

以下写法把长度显式字符串当作普通 NUL 字符串:

cpp 复制代码
std::wstring name(item->ImageName.Buffer);
// 错误:Buffer 可能为空,Length 才是唯一可信内容边界,缺少 NUL 时会越过快照缓冲区。

正确流程先验证当前记录前缀、再验证 NextEntryOffset、最后验证 UNICODE_STRING 的地址和字节长度。进程快照提供的是一个时刻的观察证据。映像路径、访问令牌、命令行和父实例关系需要其它查询和相同的实例键继续验证。

线程数组、时间字段与错误状态的进一步边界

进程记录的 NumberOfThreads 表示前缀之后跟随的线程记录数量。线程记录属于同一条可变长度进程记录,线程结构大小会受信息类别和 Windows 版本影响。只读取进程前缀时,解析器通过 NextEntryOffset 跳过整个线程数组。需要读取线程时,应先为选定信息类别定义匹配的线程结构,再验证 NumberOfThreads * sizeof(线程结构) 不超过当前记录的 NextEntryOffset。乘法前还要检查数量是否会造成 size_t 溢出。

CreateTimeUserTimeKernelTimeLARGE_INTEGER 形式的 64 位 100ns 计数。创建时间用于区分 PID 实例。用户时间与内核时间是进程启动以来的累计 CPU 时间,适合在同一实例的两个采样时刻做差。系统空闲进程和早期系统进程的名称、时间或句柄字段可能使用特殊值,显示层应保留原始数值并标注来源,不能把零值直接解释为读取失败。

Win32 辅助 API 的错误通过线程本地错误槽提供。只在文档要求读取 Win32 错误的失败分支调用 GetLastErrorNtQuerySystemInformation 已经在返回的 NTSTATUS 中给出状态。格式化文字、写日志或重复调用 API 前先保存 Win32 错误码,避免被后续调用覆盖。

cpp 复制代码
// 作用:返回当前线程最近一次失败 Win32 API 设置的错误码。
// 返回:DWORD 错误值。没有参数、没有缓冲区、没有资源所有权。仅在相应 API 失败后立即读取。
DWORD GetLastError(void);

// 作用:关闭调用方拥有的普通内核对象 HANDLE。
// 返回:非零成功。0 失败,读取 GetLastError。调用后 hObject 失效。
BOOL CloseHandle(
    HANDLE hObject // 输入:CreateToolhelp32Snapshot、OpenProcess、CreateFileW 等成功返回的真实 HANDLE
);

本篇 Native 查询没有生成需要 CloseHandle 的对象:ntdll 模块句柄是借用引用,导出函数地址也是借用地址,进程快照字节由 std::vector 管理。区分"真实 HANDLE""借用模块句柄""函数指针""容器内存"可以避免对伪对象调用错误的释放 API,也能让查询过程保持只读。

完整可运行程序在附件

wangweicm.lanzouu.com/iD7jw3z9tou...

相关推荐
王维同学13 小时前
Winsock 协议与名称空间 Provider 目录
网络·c++·windows·安全
热爱生活的五柒1 天前
如何关闭服务主机:Windows更新 这个进程
windows
也要大步向前呀1 天前
Windows文件内容快速查找
windows
寒水馨1 天前
Windows下载、安装neovim-v0.12.4(附安装包nvim-win64.msi)
windows·编辑器·vim·lua·终端·lsp·neovim
码农学院1 天前
GEO团队SOP、绩效考核与知识沉淀:技术团队管理体系化工程实践
运维·人工智能·windows
招财猫_Martin1 天前
Console的Code Page说明和设置方法
windows
崖边看雾1 天前
Python学习——函数
开发语言·windows·python·学习·pycharm
深念Y1 天前
Windows幽灵端口占用:HNS如何无声偷走你的端口
windows·python·bug·环境·端口·特权
未知违规用户1 天前
大模型项目:RAG项目实战与FlagEmbedding模型
人工智能·windows·python·深度学习