
Windows 进程枚举的 Native API 缓冲区遍历文章所述内容全部在KswordARK中有良好项目实现开源地址https://github.com/wangwei-cm/ksword取得可验证的当前进程快照需要完成四步这个程序的目标是读取当前可见进程并输出每个进程的 PID、创建时间、父 PID、会话、线程数、句柄数和映像名。要完成这个目标需要依次完成四步确认进程实例身份并定位 Native 查询入口。用有限增长的缓冲区取得原始快照。沿可变长记录的偏移逐条读取进程信息。输出已验证字段并说明快照的证据范围。进程是 Windows 内核创建的执行对象。它保存进程标识、创建时间、线程集合、地址空间、句柄表和安全上下文。NtQuerySystemInformation(SystemProcessInformation)请求内核把当前可见的进程状态复制到调用方缓冲区ntdll 将这段原始数据返回给用户程序。读取器只在确认缓冲区长度、记录偏移和字符串边界后才读取 PID、父 PID、创建时间、会话、线程数和映像名。完整关系是Windows 内核的进程对象集合 -ntdll!NtQuerySystemInformation- 调用方拥有的连续BYTE缓冲区 -SYSTEM_PROCESS_INFORMATION记录链 - 经验证的进程实例记录。快照是查询期间得到的观察结果。进程可以在查询前、查询中或查询后创建和退出任何枚举方法都会遇到时间差。一次成功查询提供一段内部一致的返回缓冲区。不同查询之间的条目数量、父 PID 和名称发生变化属于正常现象。把多个查询结果合并前应保留每批的采样时间和来源。1. 确认进程实例身份并定位 Native 查询入口PIDProcess Identifier进程标识符是 Windows 在进程存活期间分配的数字。进程退出后该数字可分配给后来的新进程。创建时间是进程对象建立时写入的时间值在同一实例生命期内稳定。调查记录使用(PID, CreateTime)作为进程实例键能够区分“同一 PID 的旧实例”和“PID 被复用后的新实例”。父 PID 是子进程创建时记录的父进程标识。它描述创建关系无法单独证明当前父进程仍存在。父进程退出后当前快照可找不到同一 PID 的父实例。会话 IDSession ID描述服务、控制台用户或远程桌面用户所在的 Windows 会话范围它帮助解释交互隔离却不能替代创建时间验证。Native API、ntdll 与 NTSTATUSNative API 是 ntdll 提供、与 Windows 内核系统服务协作的一层接口。NtQuerySystemInformation的返回类型是NTSTATUS成功值为零。NTSTATUS与 Win32GetLastError记录的错误属于两套状态Native 调用返回失败时直接检查NTSTATUS不要用无关的GetLastError重新解释。SystemProcessInformation是SYSTEM_INFORMATION_CLASS中用于请求进程快照的类别。其返回数据是连续字节缓冲区API 不分配这块内存也不返回需要释放的内核句柄。调用方分配BYTE容器并在容器离开作用域时释放。ReturnLength是可选输出容量不足时通常给出建议字节数成功时可给出实际字节数。// 作用查询系统范围信息。本篇传入 SystemProcessInformation 获取进程记录链。// 返回NTSTATUS0 成功。STATUS_INFO_LENGTH_MISMATCH 等表示当前缓冲区容量不足。NTSTATUS NTAPINtQuerySystemInformation(SYSTEM_INFORMATION_CLASS SystemInformationClass,// 输入查询类别使用 SystemProcessInformationPVOID SystemInformation,// 输出调用方可写缓冲区。测量阶段可为 nullptrULONG SystemInformationLength,// 输入缓冲区容量单位字节最大为 ULONGPULONG ReturnLength// 输出实际或建议字节数。可为 nullptr本流程传入有效地址);NtQuerySystemInformation由ntdll.dll导出。GetModuleHandleW取得已加载 ntdll 的借用模块句柄借用句柄不调用FreeLibrary。GetProcAddress从该模块取得函数地址地址只在模块持续加载期间有效。系统进程的 ntdll 生命周期覆盖当前进程一般可以安全用于本次同步查询。// 作用取得当前进程已经加载模块的借用句柄。// 返回非 nullptr 成功。nullptr 失败读取 GetLastError。返回句柄不转移所有权。HMODULEGetModuleHandleW(LPCWSTR lpModuleName// 输入UTF-16 模块名例如 Lntdll.dll。nullptr 表示当前可执行模块);// 作用取得模块导出函数地址。// 返回非 nullptr 成功。nullptr 失败读取 GetLastError。返回地址不需要也不能释放。FARPROCGetProcAddress(HMODULE hModule,// 输入有效已加载模块句柄LPCSTR lpProcName// 输入ASCII 导出名称或序号。本篇使用 NtQuerySystemInformation);完成这一步后读取器已经知道怎样区分两个 PID 相同但创建时间不同的进程也取得了调用系统查询的函数地址。接下来需要准备一块大小合适的内存让内核能够写入完整快照。2. 用有限增长缓冲区取得原始快照进程数量和线程数量会改变因此首次给出的容量可能不足。STATUS_INFO_LENGTH_MISMATCH、STATUS_BUFFER_TOO_SMALL与STATUS_BUFFER_OVERFLOW表示需要更大缓冲区。正确流程每轮清空旧字节调用 API成功时停止。可增长状态时使用ReturnLength或倍增策略扩大容量。其他状态、容量不增长、超过安全上限和重试次数耗尽时停止并记录 NTSTATUS。constexprNTSTATUS StatusSuccess0x00000000L;constexprNTSTATUS StatusInfoLengthMismatchstatic_castNTSTATUS(0xC0000004L);constexprNTSTATUS StatusBufferTooSmallstatic_castNTSTATUS(0xC0000023L);constexprNTSTATUS StatusBufferOverflowstatic_castNTSTATUS(0x80000005L);std::vectorBYTEbuffer(256*1024);ULONG returnedBytes0;NTSTATUS statusStatusInfoLengthMismatch;for(intattempt0;attempt8;attempt){returnedBytes0;statusquery(SystemProcessInformation,buffer.data(),static_castULONG(buffer.size()),returnedBytes);if(statusStatusSuccess)break;boolcanGrowstatusStatusInfoLengthMismatch||statusStatusBufferTooSmall||statusStatusBufferOverflow;if(!canGrow||buffer.size()64*1024*1024)return;size_t nextreturnedBytesbuffer.size()?size_t(returnedBytes)64*1024:buffer.size()*2;if(nextbuffer.size()||next64*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。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 等前缀字段并把名称状态标为异常。整条偏移链异常时停止后续字节没有可信起点。size_t offset0;while(offsetvalidBytes){if(validBytes-offsetsizeof(SystemProcessInformationPrefix))break;constauto*itemreinterpret_castconstSystemProcessInformationPrefix*(buffer.data()offset);// 复制 PID、创建时间和 UNICODE_STRING。所有读取仍在已验证前缀范围内。if(item-NextEntryOffset0)break;if(item-NextEntryOffsetsizeof(SystemProcessInformationPrefix)||item-NextEntryOffsetvalidBytes-offset)break;offsetitem-NextEntryOffset;}快照结构的布局会随 Windows 版本演进。稳定前缀以外的字段、线程子结构大小和私有信息类都需要匹配该系统版本的定义。调查工具应记录操作系统版本、进程架构、信息类和解析结构版本不能把一次测试得到的私有偏移推广到所有系统。完成这一步后读取器已经从连续字节中分离出经过范围检查的记录和名称。进程状态仍会随时间变化单次快照的含义和异常条目的处理范围还需要明确才能正确使用这些输出。4. 输出已验证字段并说明快照的证据范围Native 查询成功意味着内核已把一份可解析的结果复制进当前缓冲区。它不意味着查询后这些进程仍在运行也不意味着父 PID 指向的实例仍在系统中。查询失败时旧缓冲区属于上一次调用不能继续显示为本次结果。单项名称异常时已通过边界验证的 PID、创建时间和会话仍可保存。偏移链异常时本批后续条目应停止解析。Toolhelp 是一个独立的 Win32 快照接口可在 Native 路径无法获取时提供回退结果。两种快照的采样时刻和字段来源不同比较时记录“Native”或“Toolhelp”来源。相同 PID 的差异要结合创建时间和采样时间分析不能把它直接写成隐藏进程或枚举失败。// 作用创建 Toolhelp 进程快照。// 返回成功为 HANDLE。INVALID_HANDLE_VALUE 失败读取 GetLastError。成功后调用 CloseHandle。HANDLECreateToolhelp32Snapshot(DWORD dwFlags,// 输入TH32CS_SNAPPROCESS 请求进程记录DWORD th32ProcessID// 输入进程快照使用 0);// 作用读取快照中下一条进程记录。// 返回非零成功。0 且 ERROR_NO_MORE_FILES 表示正常结束其它错误表示枚举中断。BOOLProcess32NextW(HANDLE hSnapshot,// 输入有效快照 HANDLELPPROCESSENTRY32W lppe// 输入/输出调用方结构。dwSize 必须预设为 sizeof(PROCESSENTRY32W));最小错误示范以下写法跳过缓冲区检查损坏或截断的偏移会把下一次访问带到数组外auto*itemreinterpret_castSYSTEM_PROCESS_INFORMATION*(buffer.data()offset);offsetitem-NextEntryOffset;// 错误零、过小和越界值都未处理。以下写法把长度显式字符串当作普通 NUL 字符串std::wstringname(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 错误码避免被后续调用覆盖。// 作用返回当前线程最近一次失败 Win32 API 设置的错误码。// 返回DWORD 错误值。没有参数、没有缓冲区、没有资源所有权。仅在相应 API 失败后立即读取。DWORDGetLastError(void);// 作用关闭调用方拥有的普通内核对象 HANDLE。// 返回非零成功。0 失败读取 GetLastError。调用后 hObject 失效。BOOLCloseHandle(HANDLE hObject// 输入CreateToolhelp32Snapshot、OpenProcess、CreateFileW 等成功返回的真实 HANDLE);本篇 Native 查询没有生成需要CloseHandle的对象ntdll 模块句柄是借用引用导出函数地址也是借用地址进程快照字节由std::vector管理。区分“真实 HANDLE”“借用模块句柄”“函数指针”“容器内存”可以避免对伪对象调用错误的释放 API也能让查询过程保持只读。完整可运行程序在附件https://wangweicm.lanzouu.com/iD7jw3z9toub