首页
课程
问答
CTF
社区
招聘
峰会
发现
排行榜
知识库
工具下载
看雪20年
看雪商城
证书查询
登录
注册
首页
社区
课程
招聘
发现
问答
CTF
排行榜
知识库
工具下载
峰会
看雪商城
证书查询
社区
编程技术
发新帖
1
0
[原创]Windows 进程枚举NtQuerySystemInformation(SystemProcessInformation
发表于: 2026-7-28 22:30
635
[原创]Windows 进程枚举NtQuerySystemInformation(SystemProcessInformation
WangWei_CM.
2026-7-28 22:30
635
# Windows 进程枚举的 Native API 缓冲区遍历 文章所述内容全部在KswordARK中有良好项目实现,开源地址<mark class="encrypted">9aeK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9i4c8Z5N6h3u0Q4x3X3g2U0L8$3#2Q4x3V1k6%4j5h3&6Y4N6$3g2A6i4K6u0V1j5$3#2Q4x3V1k6C8M7%4N6G2M7X3b7`.</mark>  ## 取得可验证的当前进程快照,需要完成四步 这个程序的目标是读取当前可见进程,并输出每个进程的 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` 重新解释。 `SystemProcessInformation` 是 `SYSTEM_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,本流程传入有效地址 ); ``` `NtQuerySystemInformation` 由 `ntdll.dll` 导出。`GetModuleHandleW` 取得已加载 ntdll 的借用模块句柄,借用句柄不调用 `FreeLibrary`。`GetProcAddress` 从该模块取得函数地址,地址只在模块持续加载期间有效。系统进程的 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_MISMATCH`、`STATUS_BUFFER_TOO_SMALL` 与 `STATUS_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,形成实例键的一部分。 - `UserTime`、`KernelTime`:进程累计 CPU 时间,单位为 100ns。 - `ImageName`:`UNICODE_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` 溢出。 `CreateTime`、`UserTime` 和 `KernelTime` 是 `LARGE_INTEGER` 形式的 64 位 100ns 计数。创建时间用于区分 PID 实例。用户时间与内核时间是进程启动以来的累计 CPU 时间,适合在同一实例的两个采样时刻做差。系统空闲进程和早期系统进程的名称、时间或句柄字段可能使用特殊值,显示层应保留原始数值并标注来源,不能把零值直接解释为读取失败。 Win32 辅助 API 的错误通过线程本地错误槽提供。只在文档要求读取 Win32 错误的失败分支调用 `GetLastError`。`NtQuerySystemInformation` 已经在返回的 `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,也能让查询过程保持只读。 完整可运行程序在附件  <mark class="encrypted">cebK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6%4j5h3&6Y4N6$3g2A6j5$3#2Q4x3X3g2D9j5h3&6*7L8%4g2#2i4K6u0W2j5$3!0E0i4K6u0r3K9f1b7%4K9Y4M7K6P5U0W2@1L8%4g2T1</mark>
传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
上传的附件:
028-Windows进程枚举的Native-API缓冲区遍历.zip
(153.13kb,3次下载)
收藏
・
1
点赞
・
0
打赏
分享
分享到微信
分享到QQ
分享到微博
赞赏记录
参与人
雪币
留言
时间
查看更多
赞赏
×
1 雪花
5 雪花
10 雪花
20 雪花
50 雪花
80 雪花
100 雪花
150 雪花
200 雪花
支付方式:
微信支付
赞赏留言:
快捷留言
感谢分享~
精品文章~
原创内容~
精彩转帖~
助人为乐~
感谢分享~
最新回复
(
0
)
游客
登录
|
注册
方可回帖
回帖
表情
雪币赚取及消费
高级回复
返回
WangWei_CM.
30
发帖
1
回帖
40
RANK
关注
私信
他的文章
[原创] 进程模块枚举、映像身份与线程启动地址关联
214
[原创]Windows 进程 CPU、内存、I/O 与调度指标采样
980
[原创]跨位数进程的映像、命令行与环境块读取
534
[原创]进程实例身份、父子关系与会话归属
293
[原创]Windows 进程枚举NtQuerySystemInformation(SystemProcessInformation
634
关于我们
联系我们
企业服务
看雪公众号
专注于PC、移动、智能设备安全研究及逆向工程的开发者社区
谁下载
×
sixL
laosanls
Fzzz
看原图
赞赏
×
雪币:
+
留言:
快捷留言
为你点赞!
返回
顶部