ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

52pojie 的 Windows 系统编程帖:枚举进程、读指标、查连接为什么都要调用两次

52pojie 的 Windows 系统编程帖:枚举进程、读指标、查连接为什么都要调用两次 副题从 NT 层的变长结构 API看一类反复出现的“先问尺寸、再取数据”范式摘要吾爱破解主站本次恢复可达连续七轮 502 之后因此本文首次把证据源升级为站点实时抓取并按计划换了板块——来自**软件调试区fid59**的一组 Windows 系统机制帖进程枚举的 Native API 缓冲区遍历、进程 CPU/内存/IO 与调度指标采样、会话与 Session 0、Token 隔离身份、R3 网络 API 研究。这些帖子看似各自独立底层却是同一套 NT 范式变长数据结构必须调用两次 API。本文用微软官方文档与实机代码验证这件事——第一次故意只给 1 字节缓冲区系统返回0xC0000004并告知“需要 716,512 字节”第二次才真正取回358 个进程条目。标签Windows系统编程内核C/C逆向工程实践目录目录目录一、选题站点恢复可达本轮换板块二、第一课NT 层的“两次调用”范式三、第二课变长结构链表的遍历四、第三课指标采样——CPU 占用率是怎么算出来的五、第四课会话与身份——Session 0 与 Token六、第五课把“进程”和“连接”对上——R3 网络 API七、这套范式为什么反复出现八、把它沉淀成一份系统编程清单九、三个反直觉的结论十、资料来源与取舍说明一、选题站点恢复可达本轮换板块先交代一个对流程影响很大的变化。吾爱破解主站本次可以访问了。前七轮里index.php一直返回502 Bad Gateway本文写作时实测已正常返回页面底部时间戳2026-10-10 17:00 GMT8今日发帖时间连续。因此本轮做两件事证据源升级不再只用本地历史索引而是直接从站点抓取当前列表页换板块前七篇全部来自原创工具区同质化已经明显。本轮改从软件调试区fid59取材。抓取方式是本地urllib直连站点为 GBK 编码。下面是从该板块列表页抓到的部分主题标题与作者为站点原文回复数为页面上的em计数回复数标题作者1395Windows 进程枚举的 Native API 缓冲区遍历—2199Windows 进程 CPU、内存、I/O 与调度指标采样—1595Windows 会话、Session 0 与进程 Token 隔离身份—2318Windows 网络 API R3 研究—6109火绒剑驱动逆向—10261【CVE-2023-2008】Linux kernel dma-buf 子系统 udmabuf 页面越界读写漏洞分析—12526当游戏越来越漂亮外挂却越来越丑作弊技术摸底2026—275ESXi 勒索软件运行时取证与防御体系构建基于三起真实事件的实战分析—我看中的是前四条。它们明显出自同一作者、围绕同一主题展开的一组系统性写作进程怎么枚举、指标怎么采样、会话与身份怎么隔离、网络连接怎么归属到进程。这不是四篇互不相干的技巧文而是一套 Windows 系统编程的连续教程——主题连贯、由浅入深这在社区内容里相当少见。更值得说的是把这四篇的问题放在一起会发现它们撞上了同一个 API 设计范式。而这正是本文要讲的东西。四维筛选口径四者同时成立聚集度——四条同主题线索互相印证构成一条完整链路可验证——每个机制都能对到微软官方文档并且本文用实机代码交叉验证可迁移——结论能写成一份系统编程清单合规——产出物是“把系统状态读清楚”的系统编程能力不涉及注入、绕过或攻击。二、第一课NT 层的“两次调用”范式从最基础的“枚举系统里的所有进程”开始。Win32 给了我们CreateToolhelp32Snapshot这样的便利接口但它只覆盖用户态能看到的部分。要拿更完整的信息线程数、会话 ID、创建时间、私有工作集就得走Native APINtQuerySystemInformation。这个函数有个非常典型的用法——必须调用两次0xC0000004STATUS_INFO_LENGTH_MISMATCH0x00000000第一次调用缓冲区故意给 1 字节系统返回读取 RequiredLength 实际需要多少字节按需分配 留余量第二次调用拿到完整数据按 NextEntryOffset遍历变长链表数据已完整罕见但需处理为什么不能一次搞定因为这类数据的长度事先不可知——系统里有几个进程、每个进程的结构体有多长只有内核知道。所以这个 API 的契约是你给多大缓冲区我就最多填多少不够就告诉我需要多少。看实测输出。第一次调用时故意只给 1 个字节第一次调用: NTSTATUS0xC0000004 需要长度716512 字节 第二次调用: NTSTATUS0x00000000 实际长度716512 字节0xC0000004就是STATUS_INFO_LENGTH_MISMATCH而RequiredLength被回填为716,512 字节。第二次按这个长度分配调用成功。这段代码的核心只有十几行但它体现了 NT API 的三个“反直觉”之处importctypesfromctypesimportwintypes ntdllctypes.WinDLL(ntdll,use_last_errorTrue)SystemProcessInformation5STATUS_INFO_LENGTH_MISMATCH0xC0000004NtQuerySystemInformationntdll.NtQuerySystemInformation NtQuerySystemInformation.argtypes[wintypes.ULONG,ctypes.c_void_p,wintypes.ULONG,ctypes.POINTER(wintypes.ULONG)]NtQuerySystemInformation.restypewintypes.LONGdeftwo_call_snapshot():retwintypes.ULONG(0)bufctypes.create_string_buffer(1)# 故意只给 1 字节stNtQuerySystemInformation(SystemProcessInformation,buf,1,ctypes.byref(ret))sizeret.value65536# 留余量见下文bufctypes.create_string_buffer(size)st2NtQuerySystemInformation(SystemProcessInformation,buf,size,ctypes.byref(ret))returnst,size,buf反直觉之处一返回值不是 Win32 错误码而是 NTSTATUS。判断成功不能说“非零即失败”而要判断高位——STATUS_SUCCESS是0x00000000但整个0x8000_0000段与0xC000_0000段都是“信息性 / 错误”状态。用ctypes.c_ulong(st).value取无符号值再比较比用有符号整数稳妥。反直觉之处二必须留余量。上面的65536不是随手加的。第一次调用与第二次调用之间存在时间差这期间系统里的进程数可能已经变化——新进程启动、旧进程退出都会让实际需要的大小超过刚才回填的值。如果不留余量第二次仍然可能返回0xC0000004。健壮的实现应该把“两次调用”写成循环只要还是长度不足就按新值重新分配、再试一次。反直觉之处三官方文档明确说了它“不保证”。微软的文档在NtQuerySystemInformation条目里写得很直白这个函数在未来的 Windows 版本中可能被更改或不可用应用程序应使用文档中列出的替代函数。这句话决定了这类代码的工程定位——它能用但属于“不受支持”的依赖。用在诊断工具、学习项目、内部监控上没问题用在需要长期兼容的商业产品里就必须准备好降级路径例如退回CreateToolhelp32SnapshotEnumProcesses。三、第二课变长结构链表的遍历拿到缓冲区只是第一步真正的活儿是把这块连续的字节切开。因为返回的不是结构体数组而是一条单链表节点是变长的每个节点开头有一个NextEntryOffset字段告诉内核“下一个节点从当前位置往后多少字节开始”当它为 0 时表示链表结束。实测输出可以直接印证这个结构缓冲区遍历得到 358 个进程条目 PID ImageName 线程 会话 私有工作集KB 2864 msedge.exe 72 1 947344 2252 CodeBuddy CN.exe 33 1 530392 28204 msedge.exe 36 1 406820 ... 3352 Memory Compression 54 0 219124 结构体大小 104 字节NextEntryOffset 偏移 0这里有三条实战经验都是被坑出来的。第一不要用固定步长。结构体开头只有几个字段是定长的后面的ImageName是变长字符串所以每个节点的大小都不一样。按sizeof(struct)步进是最常见的错误——会立刻读到垃圾数据。正确做法是offset NextEntryOffset一直到它为 0。第二UNICODE_STRING不是以 NUL 结尾的。NT 层的字符串类型是UNICODE_STRING它由三个字段组成Length字节数、MaximumLength、Buffer指针。取文件名必须用Length来切片不能靠找 NUL 终止符——因为 NT 层的字符串并不保证以\0结尾。这是 C 程序员最容易踩的一个坑用wcslen()去量一旦后面没有终止符就会越界读到缓冲区之外。第三IDLE 进程没有ImageName。实测里 358 个条目中PID 0 的ImageName.Buffer是空的因为没有对应的用户态映像。如果不判空就直接解引用程序会在第一行就崩。任何遍历系统对象列表的代码都必须假定“有些字段可能是空的”。再看第三节里那两个值得注意的条目msedge.exe私有工作集 947,344 KB约 925 MBMemory Compression的会话 ID 是 0——后者直接引出了下一课。四、第三课指标采样——CPU 占用率是怎么算出来的拿到进程列表后下一个问题是“它占了多少资源”。这里有一个被长期误解的点“CPU 占用率”从来不是读出来的而是算出来的。GetProcessTimes返回四个FILETIME创建时间、退出时间、内核态时间、用户态时间。注意后两个是累积值——一个进程启动至今一共烧了多少 CPU 时间不是当前的瞬时速度。所以单次调用无法得出占用率必须在 T0 时刻取一次(内核时间 用户时间)隔一段时间在 T1 时刻再取一次占用率 ≈ΔCPU时间 / (Δ墙钟时间 × 逻辑处理器数)。这里的“× 逻辑处理器数”就是那个经典的歧义来源同一条数据除以 1 还是除以全机核数会得到相差 N 倍的两个“CPU 占用率”。任务管理器显示的是“占整机的百分比”而很多小工具显示的是“占一个核的百分比”——两者的数字可以完全不一样但双方的用户都以为对方算错了。这不是 bug是口径没对齐。内存这边同样有三套口径绝不能混用工作集WorkingSetSize进程当前驻留在物理内存里的页数私有工作集WorkingSetPrivateSize只属于该进程、不与别人共享的那部分——这才是任务管理器里那一列。实测里msedge.exe显示的 947,344 KB 就是这个字段提交大小PrivateUsage / Commit已承诺的虚拟内存包含尚未落到物理页的部分。三者的关系可以用一句话记住共享的算在工作集里、不算在私有工作集里承诺了但没用的算在提交里、不算在工作集里。一个浏览器有几十个进程共享代码页所以它的“工作集总和”会明显大于“私有工作集总和”——这正是很多“内存统计工具”数字对不上的原因。I/O 指标还有一路GetProcessIoCounters返回六个计数——读操作数、写操作数、其他操作数以及对应的三个字节数。注意“操作数”与“字节数”是分开的两个维度一个进程可能只有几次写操作却写了几个 GB。只看操作次数或只看字节数都会得出错误结论。这一课把“采样”的本质讲透了资源占用是一组需要多次采样、并且必须声明口径才能比较的派生量。任何直接给一个百分比的工具都应该在界面上明确写出它的分母是什么。五、第四课会话与身份——Session 0 与 Token第三课的实测里藏着一个很容易被忽略的细节Memory Compression的会话 ID 是 0而所有用户程序都是 1。这不是巧合而是一条从 Vista 开始的分界线。Session 0 隔离解决的问题很具体在更早的系统上服务与用户程序跑在同一个会话里于是一个服务可以往用户桌面上弹窗、发消息、抓输入。这既混乱又危险——恶意服务可以劫持用户桌面用户的输入也可能被服务截获。Vista 起服务被统一放进Session 0用户登录则从Session 1开始。从此服务与用户桌面之间多了一道由内核维护的墙服务看不到用户的窗口也无法直接与它交互。这条规则解释了大量“为什么”为什么服务里弹出来的对话框用户永远看不到它画在 Session 0 的桌面上没有显示器为什么“服务 托盘图标”这种组合天然别扭——托盘属于用户会话的 explorer服务要显示托盘图标必须跨会话协作为什么第 6 篇里讲过的托盘图标在 explorer 重启后会失效本质是“宿主进程的会话对象被重建”。会话之下还有一层窗口站Window Station与桌面Desktop。交互式登录的默认组合是WinSta0\Default而服务用的是各自独立的窗口站。再往下是进程令牌Token——它携带用户 SID、所属组、权限集以及一个关键的完整性级别Integrity LevelLow / Medium / High / System。把这三层串起来就能解释一大批“操作不了”的现象会话决定“能不能看到对方”窗口站/桌面决定“能不能画在上面”Token 与完整性级别决定“能不能动对方”。这也是安全软件、沙箱、以及任何跨权限边界的工具都必须先搞清楚的坐标系。六、第五课把“进程”和“连接”对上——R3 网络 API第四个主题是“网络 API R3 研究”它解决的是一个很实际的问题屏幕上有一堆 TCP 连接怎么知道哪条属于哪个进程答案藏在 IP Helper API 里GetExtendedTcpTable与GetExtendedUdpTable。它们返回的表每个条目都带有 owning PID于是“连接 → 进程”的映射就成立了。这个 API 的调用形态和第一课一模一样先传一个dwSize甚至可以为 0长度不够时返回ERROR_INSUFFICIENT_BUFFER并把dwSize回填为实际需要的大小按新大小重新分配、再调用一次这次拿到完整的表。同一个范式换了一个模块、换了一个错误码结构完全一致。这不是巧合——它来源于 NT 的一个底层约定内核对“长度事先未知的变长数据”统一采用“调用方提供缓冲、系统回填所需长度”的契约。UDP 那边要额外注意一点语义差异TCP 是有连接的一条表项天然对应一个连接UDP 是无连接的表项对应的是“某个进程绑定的本地端点”。所以“这条 UDP 流量属于谁”在语义上比 TCP 更弱——看到 UDP 表项只能说明“这个进程占着这个本地端口”不代表当前有流量、更不代表远端是谁。把两者等同处理就会得出“某进程正在上传”的错误结论。七、这套范式为什么反复出现把五课放在一起看会发现它们其实都在解决同一个问题从内核里把“长度事先未知的变长数据”搬到用户态。而 Windows 上几乎每一类这样的数据都遵循同一套模式。除了本文实测的NtQuerySystemInformation与GetExtendedTcpTable同类例子还有API长度不足的信号备注NtQuerySystemInformationSTATUS_INFO_LENGTH_MISMATCH(0xC0000004)返回变长链表GetExtendedTcpTable/GetExtendedUdpTableERROR_INSUFFICIENT_BUFFERdwSize双向使用EnumProcesses返回的字节数等于缓冲区大小需要“扩容重试”RegQueryValueExERROR_MORE_DATA注册表值长度未知GetAdaptersAddressesERROR_BUFFER_OVERFLOW网卡列表变长EnumWindows无错误码靠回调枚举期间系统状态可能变化这张表本身就是一条可迁移的知识。当你遇到一个“数据量可能很大”的系统 API 时先问三个问题它用什么信号告诉我缓冲区不够会不会把需要的大小回填给我数据在两次调用之间会不会变想清楚这三个问题一半的系统编程坑就提前避开了。八、把它沉淀成一份系统编程清单关于“两次调用”按“循环”而非“固定两次”实现只要仍提示长度不足就按新值重分配再试一定留余量本文实测留了 64 KiB因为两次调用之间数据可能变化明确长度不足的信号是什么NTSTATUS、Win32 错误码还是返回值本身失败路径也要处理权限不足、参数错误与“长度不足”是不同的分支关于变长结构遍历用NextEntryOffset步进不要用sizeof(struct)固定步长以NextEntryOffset 0作为链表结束条件NT 层字符串用Length切片不要用 NUL 结尾假设每个字段都当作“可能为空”处理IDLE 进程没有映像名是最典型例子遍历前校验指针是否仍在缓冲区内避免越界读关于指标采样CPU 占用率必须两次采样做差单次调用只能拿到累积值明确分母占一个核还是占整机要先取逻辑处理器数内存区分工作集 / 私有工作集 / 提交大小界面上写清用的是哪一个I/O 区分“操作数”与“字节数”两者不能互相替代关于身份与隔离服务在 Session 0用户程序在 Session 1 起跨会话交互要显式设计需要操作目标窗口时先确认会话、窗口站/桌面与完整性级别三层都满足低完整性进程无法向高完整性窗口发消息UIPI这是设计而非缺陷关于兼容性Nt*系列官方明确“可能在未来版本变更或不可用”必须准备降级路径优先使用文档推荐的替代函数把 Native API 限定在诊断/学习场景九、三个反直觉的结论第一“两次调用”不是 API 的缺陷而是“长度未知”这个前提下的唯一解。只要系统里有多少进程、有多少条连接事先不可知调用方就不可能一次给出正确大小的缓冲区。所以这个范式会永远存在——它出现在进程枚举、连接表、注册表、网卡列表上未来还会出现在新的 API 上。学会识别的价值远大于背下某个具体函数的用法。第二系统里的资源数字几乎都是“算出来的派生量”不是“读出来的事实”。CPU 占用率要两次采样做差、还要除以一个你自己选的分母内存有工作集、私有工作集、提交大小三套口径。这意味着两个都“正确”的工具给出的数字可以相差数倍。判断一个监控工具是否专业不看它界面多漂亮而看它有没有在界面上声明口径。第三同一台机器上“看不到”和“动不了”是两回事而且分层。会话决定可见性窗口站/桌面决定绘制权Token 与完整性级别决定操作权。排查“为什么我的程序读不到/改不了那个进程”时按这三层自下而上问一遍比盲目加SeDebugPrivilege有效得多。十、资料来源与取舍说明本文证据分三层请读者注意区分。第一层站点实时抓取社区侧证据。本轮站点恢复可达标题与回复数来自对52pojie.cn软件调试区fid59列表页的实时 HTTP 抓取本地urllib直连UTF-8 输出、站点为 GBK 编码抓取时间为 2026-10-10。板块fid映射由首页forum-fid-1.html链接反查得到软件调试区 59、脱壳破解区 5、编程语言区 24、病毒分析区 32 等。能力边界与口径声明本轮的抓取范围是板块列表页标题、作者、回复数未抓取帖子正文。因此本文引用这些帖子的方式仍然是“标题级”——我只把它们当作选题线索说明“这些主题在社区里被持续讨论、且构成一组连贯写作”并未复述其内部的任何技术结论。文中的全部技术论断均来自下面的第二层与第三层。上表中部分格子的“作者”列未填因为我未从列表页稳定提取到该字段不臆造作者名。合规取舍声明该板块中同时存在大量涉及软件授权绕过、外挂对抗的条目。本文只选取系统机制方向的四条线索作为选题依据进程枚举、指标采样、会话与身份、网络 API并把内容严格限定在“如何正确读取系统状态”这一侧不涉及、不展开任何绕过授权或对抗检测的方法。表中出现的若干条目仅为呈现该板块的真实面貌不作为本文论证依据。第二层微软官方文档技术侧锚点NtQuerySystemInformationwinternl.h——官方条目明确提示该函数在 Windows 未来版本中“可能已更改或不可用”应用应使用其列出的替代函数SystemProcessInformation的返回结构以NextEntryOffset构成变长链表UNICODE_STRING结构Length/MaximumLength/Buffer——NT 层字符串不保证以 NUL 结尾GetProcessTimes创建/退出/内核态/用户态四个FILETIME、GetProcessIoCounters六个计数、GetProcessMemoryInfo、GetSystemInfo逻辑处理器数GetExtendedTcpTable/GetExtendedUdpTableIP Helper API——dwSize传入/传出长度不足返回ERROR_INSUFFICIENT_BUFFER条目携带 owning PID会话与隔离Windows Vista 起的Session 0 隔离、窗口站Window Station与桌面Desktop、进程访问令牌与完整性级别同类“缓冲不足”错误码ERROR_MORE_DATA注册表、ERROR_BUFFER_OVERFLOWGetAdaptersAddresses。第三层本文实测代码。全部实测数值来自本次在本机运行的 Python ctypes 代码Windows64 位 Python第一次调用NtQuerySystemInformation(SystemProcessInformation)返回NTSTATUS 0xC0000004、RequiredLength 716,512字节第二次返回0x00000000遍历缓冲区得到358 个进程条目SYSTEM_PROCESS_INFORMATION在本机为104 字节、NextEntryOffset偏移为 0按私有工作集排序的头部为msedge.exe72 线程、会话 1、947,344 KB等其中Memory Compression的会话 ID 为0。示例数据声明上述数字与具体机器强相关——进程数量、内存占用、甚至结构体大小都会随系统版本与运行状态变化。请以自己环境的实测为准不要把它们当作通用常量。一句话收束这一组帖子真正教给人的不是某个函数的参数怎么填而是一个判断习惯——当你要从系统里取“长度事先未知”的东西时先想清楚它用什么信号告诉你不够、会不会回填尺寸、以及两次调用之间数据会不会变。想清楚这三件事NT 层的 API 就从“神秘”变成了“可预测”。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进