ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Windows文件句柄枚举:C++实现与命令行工具

Windows文件句柄枚举:C++实现与命令行工具 简介面向Windows中高级开发者的商业编程源码包围绕“获取系统中打开的文件清单”这一系统级功能提供完整编码实现与可运行演示。核心逻辑利用CreateToolhelp32Snapshot、NtQueryObject等系统API枚举进程、遍历句柄并映射到具体文件路径深入剖析底层原理并覆盖权限不足、句柄访问失败等常见问题的处理思路适用于系统监控、故障排查、文件占用分析及安全审计等场景。压缩包共2个文件cpp源文件为核心实现exe为已编译演示程序便于对照运行效果整个包仅14KB轻量紧凑。从源码到可执行文件一目了然既可直接编译学习也可提取句柄枚举逻辑用于二次开发和功能扩展。目前已有123人学习/下载适合希望掌握Windows进程与句柄管理技术、通过商业级代码提升系统编程能力的开发者参考研究。1. 打开的文件清单比任务管理器能看到的要多做 Windows 端工具的人迟早要面对一个问题某台机器上有哪些文件正处于打开状态被哪个进程占着占用的句柄是普通读、写入还是排他锁定。任务管理器只能给你进程列表资源监视器能给你部分句柄但想做成自动化脚本、商用管理工具或者卸载辅助程序就必须拿到原始的“文件句柄 → 进程 → 路径”完整清单。这个需求在系统运维、文件服务器审计、软件安装包制作和中大型商业 C/S 项目里都很常见核心难点不在于“遍历”这个动作而在于 Windows 的对象管理器里文件路径不是给人看的字符串而是一个对象节点的属性。本文用一套能直接编译运行的 C/C 方案解决它同时给出不写代码也能出清单的命令行路径把权限、位数、竞态这几个真正的坑讲透。2. 枚举系统句柄表拿清单的原理与可行性2.1 文件路径藏在对象管理器里文件系统看不到先明确一个事实文件被打开成“句柄”后这个句柄归属于某进程的句柄表最终指向内核对象管理器里的一个 FILE 对象。这个对象的路径是形如\Device\HarddiskVolume2\Users\admin\test.log的设备路径是对象管理器命名空间里的节点不是文件系统 API 直接能枚举的东西。因此“获取打开的文件清单”天然分三步枚举系统所有进程的所有句柄从这些句柄里筛出类型为 File 的通过句柄反查对象路径再做设备路径到盘符路径的换算。第一直觉是用GetFileInformationByHandleEx这类高层的 Win32 API但它们需要你先有一个句柄。双刃剑就在这里你不知道句柄在哪个进程里就不可能凭空打开文件。所以整个实现只能走原生接口也就是 ntdll 导出的三个函数NtQuerySystemInformation枚举全局句柄表NtDuplicateObject把别的进程的句柄复制到自己进程里NtQueryObject查询复制来的句柄对应的对象名称。这三个函数没有列入微软公开文档但二十年来行为基本稳定System Informer原 Process Explorer 的开源继任者的核心钩子也长这样。2.2 用 SYSTEM_HANDLE_TABLE_ENTRY_INFO 拿原始清单NtQuerySystemInformation的第四个参数是信息类别SystemHandleInformation数值 16返回的结构是一串连续的句柄记录。关键点是很多网上旧教程直接给出 32 位时代的结构体定义在 64 位系统上字段对齐会错位枚举结果要么读取越界要么返回一堆看似合理但完全错误的 PID。所以我建议直接用扩展版SystemExtendedHandleInformation数值 64它对应下面这个结构typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX { PVOID Object; // 内核对象地址64位下占8字节 ULONG_PTR UniqueProcessId; // 持有句柄的进程 PID ULONG_PTR HandleValue; // 句柄值用于 NtDuplicateObject ULONG GrantedAccess; // 句柄创建时申请的访问权 USHORT CreatorBackTraceIndex; USHORT ObjectTypeIndex; // 对象类型索引File 类型需校准 ULONG HandleAttributes; // 是否继承、是否受保护等 ULONG Reserved; } SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX;结构体头两个字段是记录总数和保留字段typedef struct _SYSTEM_HANDLE_INFORMATION_EX { ULONG_PTR NumberOfHandles; ULONG_PTR Reserved; SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles[1]; } SYSTEM_HANDLE_INFORMATION_EX;信息类别常量数值用途SystemHandleInformation16经典版32 位布局64 位系统上谨慎使用SystemExtendedHandleInformation64扩展版带 ObjectTypeIndex 和 CreatorBackTraceIndex64 位下推荐ObjectNameInformation1配合 NtQueryObject 查对象名ObjectTypeInformation2配合 NtQueryObject 查对象类型名2.3 复制句柄是读对象名前的标准动作拿到条目后接下来要做的不是直接去查询那个 HandleValue 在你自己的进程里没有任何意义它是目标进程句柄表里的索引值。标准动作是调用NtDuplicateObject把它复制到当前进程。这个动作有两个实际作用一是让句柄在你的进程空间里合法可用二是复制时如果能传DUPLICATE_SAME_ACCESS复制出的句柄会保留原句柄的访问权限即使目标进程不是你的子进程只要拥有PROCESS_QUERY_LIMITED_INFORMATION权限就能继续读名字。复制后再调NtQueryObject(hDup, ObjectNameInformation, ...)拿到的UNICODE_STRING就是这个对象在对象管理器里的全名。提示查对象名的行为有时会被对象管理器拦下返回STATUS_ACCESS_DENIED。常见于受保护进程和系统关键进程遇到就直接跳过这条句柄不要反复重试重试只会拖慢整轮枚举。2.4 把内核设备路径换算成盘符路径NtQueryObject返回的名字是\Device\HarddiskVolume2\...这种形式这对人来说不可读。换算的常见做法是遍历A:到Z:的盘符对每个盘符调用QueryDosDeviceW它会返回对应的设备名如\Device\HarddiskVolume2。把枚举结果里的路径前缀替换成查到的盘符即可。要特别注意的是有些路径不以盘符开头典型的有\Device\Mup\...网络共享映射、\Device\NamedPipe\...命名管道、\FileSystem\NamedPipe\...。它们也算“文件对象”但和用户理解的文件不是一个东西商用工具的过滤逻辑里要把它们单独归类而不是直接丢弃。3. C/C 写一个最小可运行的句柄枚举程序3.1 原生 API 声明与关键结构体定义这一节给出一份可以直接编译的最小完整示例编译环境是 Windows 10/11 x64 MSVC无需第三方库。因为三个原生 API 没有头文件需要手动声明。#include windows.h #include winternl.h #include stdio.h #include vector #include string #include map #pragma comment(lib, ntdll.lib) #pragma comment(lib, advapi32.lib) // 扩展句柄信息64位推荐 typedef struct _SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX { PVOID Object; ULONG_PTR UniqueProcessId; ULONG_PTR HandleValue; ULONG GrantedAccess; USHORT CreatorBackTraceIndex; USHORT ObjectTypeIndex; ULONG HandleAttributes; ULONG Reserved; } SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX; typedef struct _SYSTEM_HANDLE_INFORMATION_EX { ULONG_PTR NumberOfHandles; ULONG_PTR Reserved; SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX Handles[1]; } SYSTEM_HANDLE_INFORMATION_EX; extern C NTSTATUS NTAPI NtQuerySystemInformation( ULONG SystemInformationClass, PVOID SystemInformation, ULONG SystemInformationLength, PULONG ReturnLength); extern C NTSTATUS NTAPI NtDuplicateObject( HANDLE SourceProcessHandle, HANDLE SourceHandle, HANDLE TargetProcessHandle, PHANDLE TargetHandle, ACCESS_MASK DesiredAccess, ULONG HandleAttributes, ULONG Options); extern C NTSTATUS NTAPI NtQueryObject( HANDLE Handle, ULONG ObjectInformationClass, PVOID ObjectInformation, ULONG ObjectInformationLength, PULONG ReturnLength);winternl.h里实际上已经带了一套旧版结构如果直接用它会导致字段偏移错误。这里的关键是不依赖 Windows SDK 里的任何结构全部自己定义并且走 64 位扩展接口绕开对齐问题。3.2 主流程查询句柄表 → 复制句柄 → 读对象名// 开启调试权限避免访问关键进程句柄时直接失败 static bool EnableDebugPrivilege() { HANDLE hToken nullptr; if (!OpenProcessToken(GetCurrentProcess(), TOKEN_ADJUST_PRIVILEGES, hToken)) return false; TOKEN_PRIVILEGES tp; tp.PrivilegeCount 1; LookupPrivilegeValueW(nullptr, SE_DEBUG_NAME, tp.Privileges[0].Luid); tp.Privileges[0].Attributes SE_PRIVILEGE_ENABLED; BOOL ok AdjustTokenPrivileges(hToken, FALSE, tp, 0, nullptr, nullptr); CloseHandle(hToken); return ok; } // 读取一个复制后句柄的对象名失败返回 false static bool QueryObjectName(HANDLE h, std::wstring name) { ULONG len 0; NtQueryObject(h, 1, nullptr, 0, len); // 第一次拿长度 if (!len) return false; std::vectorBYTE buf(len 64); NTSTATUS st NtQueryObject(h, 1, buf.data(), (ULONG)buf.size(), len); if (st ! 0) return false; auto* unicode reinterpret_castUNICODE_STRING*(buf.data()); if (unicode-Length 0) { name.assign(unicode-Buffer, unicode-Length / sizeof(wchar_t)); return !name.empty(); } return false; } int wmain() { EnableDebugPrivilege(); // 句柄表在查询期间一直在变需要按 2 倍扩容重试 ULONG bufLen 64 * 1024; std::vectorBYTE buffer(bufLen); NTSTATUS st; while (true) { st NtQuerySystemInformation(64, buffer.data(), bufLen, bufLen); if (st 0xC0000004L) { // STATUS_INFO_LENGTH_MISMATCH bufLen * 2; buffer.resize(bufLen); continue; } break; } if (st ! 0) { printf(查询失败 0x%08lX\n, st); return 1; } auto* pInfo reinterpret_castSYSTEM_HANDLE_INFORMATION_EX*(buffer.data()); HANDLE curProc GetCurrentProcess(); for (ULONG_PTR i 0; i pInfo-NumberOfHandles; i) { auto entry pInfo-Handles[i]; // 打开目标进程只要受限查询权限即可 HANDLE hProc OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, (DWORD)entry.UniqueProcessId); if (!hProc) continue; HANDLE hDup nullptr; NTSTATUS dupSt NtDuplicateObject(hProc, reinterpret_castHANDLE(entry.HandleValue), curProc, hDup, 0, 0, 0x00000002L /* DUPLICATE_SAME_ACCESS */); CloseHandle(hProc); if (dupSt ! 0 || !hDup) continue; std::wstring name; bool hasName QueryObjectName(hDup, name); CloseHandle(hDup); // 这里只输出带设备路径且不是管道/共享的项 if (hasName name.rfind(L\\Device\\, 0) 0) { wprintf(L%lu\t0x%llX\t%lu\t%ls\n, (DWORD)entry.UniqueProcessId, (unsigned long long)entry.HandleValue, entry.GrantedAccess, name.c_str()); } } return 0; }逻辑上每一段都是可独立裁剪的。EnableDebugPrivilege不是必须调用成功才能继续但推荐调用因为lsass.exe、csrss.exe这类系统进程没有调试权限时OpenProcess会直接返回拒绝。重试循环用0xC0000004L判断缓冲区不足这是STATUS_INFO_LENGTH_MISMATCH注意它只在缓冲区完全不够时出现如果返回成功但ReturnLength比传入长度大表示数据可能被截断工程代码里要再对比一次长度。3.3 你以为的坑权限、位数、缓冲区第一个坑是权限模型。PROCESS_QUERY_LIMITED_INFORMATION是 Vista 以后引入的受限版本普通用户也可以查大多数进程但带SeDebugPrivilege才能保证整轮枚举不中断。商用程序里建议把权限获取失败当作“降级运行”而不是直接退出因为对普通应用场景来说拿不到系统进程的文件清单不影响主要功能。第二个坑是 64 位系统上结构体布局。Windows SDK 自带的SYSTEM_HANDLE_TABLE_ENTRY_INFO在 32 位和 64 位下的Object字段对齐方式不同如果直接reinterpret_cast到新版结构字段错位会导致UniqueProcessId变成乱码。所以上文才全部自定结构体并且只在_WIN64下编译这段代码。第三个坑是缓冲区增长策略。有些资料说第一次调用传入nullptr就能拿到所需长度但SystemExtendedHandleInformation类别的ReturnLength参数在缓冲区不足时并不保证返回完整所需长度最稳定的就是上面这种从 64 KB 开始按 2 倍扩的循环。系统句柄数轻松上万每次枚举分配几十 MB 属于正常情况不要为此惊讶。4. 不想编译用 Restart Manager 和 openfiles 快速出清单4.1 用 Restart Manager 让系统替你筛被占用文件如果目标只是定位“哪些进程占用了指定文件”不需要全局清单那不需要碰 ntdll直接用 Restart Manager。这是 Vista 之后内置的机制专门为软件安装时处理“文件被占用”设计。核心调用是RmStartSession、RmRegisterResources、RmGetList前两个负责注册你要检查的文件第三个返回被这些文件牵制的进程数组。它不给你全部打开的文件但能非常可靠地回答“这个文件被谁占用”。#include restartmanager.h #pragma comment(lib, rstrtmgr.lib) DWORD findProcessesLocking(const wchar_t* path, DWORD pids[64]) { DWORD session 0; wchar_t key[64] {}; if (RmStartSession(session, 0, key) ! ERROR_SUCCESS) return 0; LPCWSTR resources[] { path }; RmRegisterResources(session, 1, resources, 0, nullptr, 0, nullptr); UINT count 64; DWORD reason 0; RM_PROCESS_INFO info[64]; DWORD result RmGetList(session, count, count, info, reason); for (UINT i 0; i count; i) pids[i] info[i].Process.dwProcessId; RmEndSession(session); return (result ERROR_SUCCESS) ? count : 0; }RmGetList的第二个参数是数组容量第三个参数返回实际需要的大小。如果返回的count大于传入的 64说明数组不够要按返回值重新分配。reason里可以看到进程是用什么方式占用文件的比如普通打开、排他打开、映射文件这在判断“能不能删”时比 pids 信息更有价值。4.2 openfiles 本地模式一条命令开挂不想写 C 的话系统自带openfiles命令它本身是给“服务器文件共享审计”设计的但用/local参数开启后就能列出本机所有打开的文件及其进程。注意必须先开启维护模式并重启一次系统openfiles /local on :: 重启后执行 openfiles /query /fo csv /nh/fo csv表示输出 CSV 格式/nh去掉表头方便后续直接交给脚本处理。很多人在这一步遇到“错误 5 拒绝访问”因为openfiles /local on需要管理员权限而且这个开关写进注册表后要重启才生效没法热切换。另外它展示的“打开文件”包含映射文件和控制台句柄输出里的SessionName是 Console 或 Services和普通用户会话不是一个概念脚本里要注意区分。4.3 用 PowerShell 包装成可复用脚本把openfiles的输出套一层 PowerShell就能得到一份可以交给监控系统消费的结构化结果$rows openfiles /query /fo csv /nh | ConvertFrom-Csv -Header ( Session,User,PID,Type,Path ) | Where-Object { $_.Path -match ^[A-Z]:\\ } $rows | Group-Object Path | ForEach-Object { [PSCustomObject]{ Path $_.Name PIDs ($_.Group.PID -join ,) Users ($_.Group.User -join ,) } } | Export-Csv -Path opened-files.csv -NoTypeInformation -Encoding UTF8ConvertFrom-Csv配合-Header是因为openfiles的 CSV 输出在不同 Windows 语言版本里表头文字不一样硬编码中文表头反而脆弱直接跳过头行再自己命名最稳。Type字段区分的是句柄类型而不是进程类型通常能看到 File 和 其他少数类别。这份脚本适合内网批量收集跑一次耗时看机器负载几千个文件句柄大概几秒到几十秒。5. 把清单当判据用权限、竞态和特征过滤的边界5.1 三个必调的判断条件拿到清单后真正的工作才开始以下条件决定这条记录算不算“占用文件”GrantedAccess是否包含DELETE0x00010000只有带删除权限的句柄才可能直接阻止删文件HandleAttributes是否含OBJ_PROTECT_CLOSE由SetHandleInformation设置 PROTECT_FROM_CLOSE 时生效受保护句柄不能用普通DuplicateHandle之外的手段关闭路径是否以\Device\NamedPipe\开头管道对象也走文件对象模型但从来不占用磁盘文件应在统计时剔除。GrantedAccess的典型值是0x0012019FFILE_GENERIC_READ/WRITE 组合和0x00010020后者是典型的“只同步读取”。字符串过滤放在路径换算之前做性能更好因为QueryDosDevice是最耗时的环节能提前丢掉的记录就不该做盘符换算。5.2 处理竞态进程退出后句柄立刻失效枚举期间进程随时可能退出这会导致两种结果NtDuplicateObject返回STATUS_INVALID_HANDLE0xC0000008或者OpenProcess成功但复制句柄失败。这都是正常现象不能当作错误中断整轮枚举。商用代码通常在NtDuplicateObject失败后直接continue并且不对单条失败做日志输出否则一秒钟能刷出几万行。重试窗口设置在 3 到 5 秒比较合理超过这个时间还在失败说明文件系统或进程状态异常适合上报一次警告。5.3 一个能直接抄的类型索引校准技巧SYSTEM_HANDLE_TABLE_ENTRY_INFO_EX.ObjectTypeIndex能用整数精确定位对象类型这个数字在系统启动时固定但不同机器上不一定相同所以不能写死。推荐的校准方式是在程序启动时用CreateFileW打开一个临时文件然后枚举一次全表找到路径和自己刚创建文件完全一致的记录记下它的ObjectTypeIndex作为 File 类型值。全部查询只做一次校准之后过滤句柄只需比较整数不再需要为每条句柄调用NtQueryObject拿类型名。把这段逻辑放在程序初始化处用一个static变量缓存整个枚举过程能快一个数量级系统大版本更新时该值偶发变化遇到校准失败就降级回字符串前缀匹配稳定性和性能两头兼顾。本文还有配套的精品资源点击获取
返回列表