EasyHook与虚拟文件系统:在用户态拦截文件API实现路径映射
发布时间:2026/10/2 16:48:10来源:尧图网络
简介基于 .NET 与 EasyHook 实现虚拟文件系统的完整源码工程面向关注 Windows 文件操作拦截、API Hook 技术的 .NET 开发者。项目可拦截 FindFirstFileW、FindNextFileW、CreateFileW 等 Win32 API实现文件查找、创建等操作的监控与自定义处理并支持将实际路径映射至虚拟路径、注入指定进程及简单日志记录等功能。资源包共 30 个文件含 10 个 C# 源码文件、6 个 DLL 库、4 个配置文件、3 个工程文件及 2 个可执行程序等压缩包整体约 322KB。源码由 HookTest 与 VirtualFile 两个子项目构成目录划分清晰涵盖文件映射、钩子监控等核心模块便于学习进程注入原理与虚拟文件系统设计思路。已有 58 人浏览学习适合初学 Windows Hook 或希望在应用中实现透明文件重定向的读者参考。1. 一个能塞进目标进程的虚拟文件系统先搞清楚它是什么如果你手里有个只认固定路径的老程序又没法改它源码想让它去读另一份数据常规做法是改文件路径或做符号链接但改动面大、还容易污染系统。这个基于 .NET 和 EasyHook 的虚拟文件系统源码包走的是另一条路把一段托管代码注入目标进程在用户态拦截 FindFirstFileW 等 Win32 文件操作 API把「虚拟路径到真实路径」的映射直接塞给目标程序让它自己以为文件就在原地。它不是文件系统过滤驱动不需要内核签名对用 Win32 API 读写文件的普通程序基本通用。适合正在做文件操作监控、虚拟目录映射、老程序兼容性测试的人也适合想搞懂 EasyHook 注入这套链路的人照着源码拆一遍。2. 为什么用 EasyHook 而不是文件系统过滤驱动拦截方案与工程结构2.1 用户态 API Hook 和内核过滤驱动怎么选Windows 上给文件访问「造假」有两条路内核态的文件系统过滤驱动minifilter以及用户态的 API Hook。过滤驱动能拦所有进程、能真正屏蔽底层读写一套做下来杀软和加密盘都在用它但开发门槛高要写内核驱动、要处理签名、调试时蓝屏是家常便饭。EasyHook 属于后者它把传统注入三板斧——OpenProcess、WriteProcessMemory、CreateRemoteThread——封装成了托管 API在目标进程内存里把 FindFirstFileW 这类导出函数的开头字节改成跳转指令让它跳到你的 C# 回调上。对做工具的人来说选 EasyHook 最大的价值是不用碰 C、不用签驱动在 .NET 里声明一个签名一致的委托调一次 LocalHook.Create 就能拦函数。代价是只能拦用户态 API 层的调用而且注入动作本身容易被安全软件盯上这两个边界后面都会讲到。两种方案的取舍我整理成了一张对比表方案实现成本签名要求能拦的范围主要风险文件系统过滤驱动minifilter内核开发需要 DDK 环境需要测试签名或 WHQL所有进程、所有文件操作蓝屏、开发调试成本高EasyHook 用户态 HookC# 托管 DLLNuGet 包直接引无内核签名需求调用 Win32 API 的当前目标进程位数不匹配、被杀软拦截、回调崩溃这个项目要做的是「程序层假文件系统」不是存储层透明加密所以用户态钩子完全够用而且整条链路都能在 Visual Studio 里打断点对新手友好得多。2.2 源码包里的三个项目宿主、核心库、注入 DLL把源码包解开后VirtualFile.sln 解决方案里是三个项目我第一次拆的时候也被这个结构绕了一下先看目录树VirtualFile.sln ├─ HookTest # 宿主测试程序选目标进程、发起注入 ├─ VirtualFile # 虚拟文件系统核心 内嵌资源打包 │ ├─ VirtualFileSystem.cs # 虚拟路径映射与虚拟条目生成 │ └─ Resources # 内置 InjectedLib.dll 与 EasyHook 运行时 └─ InjectedLib # 注入到目标进程的载荷 ├─ Injected.cs # EasyHook 注入入口Run 方法 ├─ HookMonitor.cs # 钩子安装/卸载与回调实现 ├─ FileMapping.cs # 虚拟路径 - 真实路径映射 └─ NativeMethods.cs # P/Invoke 与 WIN32_FIND_DATAW 结构声明三个项目的职责是分开的HookTest 是发起方拿到目标进程 PID 后调 RemoteHooking.Inject把 InjectedLib.dll 注入目标进程InjectedLib 是注入后跑在目标进程里的那部分负责安装钩子VirtualFile 是「大脑」虚拟文件系统的映射规则在 VirtualFileSystem.cs 里定义InjectedLib 的回调最终会调用它做路径解析。注意 InjectedLib 里还有一份 FileMapping.cs说明映射逻辑也可以在注入 DLL 里独立完成核心思路是同一个在虚拟根目录下登记的虚拟文件来自真实磁盘文件。注入链路是这样的HookTest 发起 RemoteHooking.Inject目标进程加载 InjectedLib.dll 并进入 Injected.RunRun 里创建 HookMonitor 并调用 Install 注册回调回调委托再进 VirtualFileSystem 做路径解析。这里有一个容易忽略的细节EasyHook 并不是只靠一个 InjectedLib.dll 就能跑它依赖 EasyHook64.dll、EasyLoad64.dll、EasyHook64Svc.exe 这些运行时文件发布时要把对应位数的运行时和 InjectedLib.dll 放到同一个输出目录缺一个注入就会静默失败。2.3 什么场景能生效什么场景别抱期望先泼一盆冷水免得你复现不顺利就怀疑源码有问题。这套虚拟文件系统能生效的前提是目标程序通过 FindFirstFileW、FindNextFileW、CreateFileW 这组 Win32 API 操作文件。绝大多数 WinForms、WPF、Qt 程序都走这条路所以记事本、控制台程序、老式对话框程序都能拦。但如果目标程序直接调 NtQueryDirectoryFile、ZwCreateFile 这类内核态路径或者自己把文件整个读进内存再处理用户态 Hook 就完全看不到别在这上面浪费时间。还有位数匹配问题注入 64 位进程必须用 x64 版本 InjectedLib32 位进程反之这是 EasyHook 项目最经典的玄学坑。我一般会先开任务管理器确认目标进程的平台再决定编译 x64 还是 x86。部署步骤也很固定用 Visual Studio 打开 VirtualFile.sln还原 NuGet 包确认三个项目都编译通过把 InjectedLib.dll 和 EasyHook 运行时拷到 HookTest 的输出目录启动 HookTest选择目标进程点注入最后在目标程序的文件对话框里访问虚拟根目录验证枚举是否出现虚拟文件。3. 把 FindFirstFileW 拦下来之后虚拟目录是怎么拼出来的3.1 需要拦的 API 清单与回调职责虚拟文件系统要拦的核心是三个 API各自的拦截职责完全不同先列个清单API作用钩子能做什么必须注意的点FindFirstFileW开始目录枚举改写输出的 WIN32_FIND_DATAW塞入虚拟条目返回值是搜索句柄不能改FindNextFileW继续目录枚举继续改写每条 FIND_DATA要与 FindFirst 配合否则枚举中断CreateFileW打开/创建文件把虚拟路径重写成真实路径影响后续 ReadFile/WriteFile 的目标安装钩子的代码在 HookMonitor.cs 里关键就三行注册逻辑// HookMonitor.cs —— 安装三个核心钩子 public void Install() { // EasyHook 用 LocalHook.Create 把回调绑到指定导出函数 _hookFindFirst LocalHook.Create( LocalHook.GetProcAddress(kernel32.dll, FindFirstFileW), new FindFirstFileWDelegate(FindFirstFileWHooked), this); _hookFindNext LocalHook.Create( LocalHook.GetProcAddress(kernel32.dll, FindNextFileW), new FindNextFileWDelegate(FindNextFileWHooked), this); _hookCreate LocalHook.Create( LocalHook.GetProcAddress(kernel32.dll, CreateFileW), new CreateFileWDelegate(CreateFileWHooked), this); // ThreadACL 为 null 表示 hook 所有线程需要放行某线程就加入它的 ID _hookFindFirst.ThreadACL null; _hookFindNext.ThreadACL null; _hookCreate.ThreadACL null; }LocalHook.Create 的第一个参数是函数地址用 GetProcAddress 从 kernel32 模块里取导出函数第二个参数是签名与目标 API 完全一致的委托实例第三个参数是回调上下文对象。ThreadACL 属性是 EasyHook 的线程过滤机制null 代表拦截所有线程如果你只关心某个线程的文件操作就把它设成只包含该线程 ID 的集合可以明显降低并发回调压力。注意目标程序如果是老式 ANSI 程序调的是 FindFirstFileA 而非 W 版本需要按 A 版本再声明一组委托和钩子。.NET 侧默认走 Unicode所以这个项目只拦 W 版本遇到 ANSI 程序会直接漏掉。3.2 VirtualFileSystem 的映射核心真实路径怎么变成虚拟条目虚拟文件系统的「物理核心」是一张映射表。FileMapping.cs 做的事情很朴素把虚拟路径登记为字典的 key真实路径作为 value。重建这套逻辑只需要一个类// FileMapping.cs —— 虚拟路径到真实路径的映射表 public sealed class FileMapping { private readonly string _virtualRoot; private readonly Dictionarystring, string _map; // 参数virtualRoot 是暴露给目标程序的虚拟根目录 public FileMapping(string virtualRoot) { _virtualRoot virtualRoot; // 统一忽略大小写Windows 路径本来就不分大小写 _map new Dictionarystring, string(StringComparer.OrdinalIgnoreCase); } public void Add(string virtualPath, string realPath) { _map[virtualPath] realPath; } public bool TryGetRealPath(string virtualPath, out string realPath) { // 命中映射则返回真实路径没命中就原样返回别乱改 return _map.TryGetValue(virtualPath, out realPath); } }这里有两个细节我要重点说明virtualRoot 必须与目标程序请求的路径前缀一致如果目标打开的是C:\Virtual\*映射表登记的 key 就得是C:\Virtual\some.txt而不是D:\data\some.txtTryGetRealPath 在没命中时返回 false 并且不改动 realPath这一点极其关键否则系统目录、用户目录全被你重写目标进程立刻崩。有了映射表还不够目标程序在枚举目录时回调里需要构造出 WIN32_FIND_DATAW 结构体才能让文件对话框显示虚拟文件。构造逻辑在 VirtualFileSystem.cs 里重新实现的话核心是这样// VirtualFileSystem.cs —— 虚拟目录条目生成重建核心逻辑 internal static WIN32_FIND_DATAW BuildFindData(string displayName, string realPath) { var data new WIN32_FIND_DATAW(); var fi new FileInfo(realPath); // 用真实文件属性填充虚拟条目保证对话框能显示文件大小/时间 data.dwFileAttributes (uint)FileAttributes.Normal; data.nFileSizeHigh (uint)(fi.Length 32); data.nFileSizeLow (uint)(fi.Length 0xFFFFFFFF); data.cFileName displayName; // 目标程序看到的文件名 return data; }WIN32_FIND_DATAW 的字段声明在 NativeMethods.cs 里。nFileSizeHigh 和 nFileSizeLow 是 32 位拆分不能直接把 long 赋进去否则大文件的大小显示会乱cFileName 是定长 wchar 数组赋字符串前要确认不超过 260 字符超长会直接破坏结构体后面的内存这是崩溃的重灾区。如果虚拟条目本身是个目录还要记得给 dwFileAttributes 加上 FILE_ATTRIBUTE_DIRECTORY否则文件对话框不会把它当文件夹展开。3.3 回调里怎么做「先改数据、再透传」FindFirstFileW 的坑第一次写 EasyHook 回调的人最容易在 FindFirstFileW 上翻车因为它返回的是搜索句柄。有人想「既然拦截了那就自己造一个返回」结果目标程序拿到假句柄后 FindNextFileW 全部失败目录枚举立刻中断甚至句柄泄露。正确姿势是先调用原函数拿到真实结果再改写输出参数 lpFindFileData// 正确姿势原调用照常执行只改返回的 FIND_DATA private IntPtr FindFirstFileWHooked( string lpFileName, out WIN32_FIND_DATAW lpFindFileData) { IntPtr handle _nextFindFirstFileW(lpFileName, out lpFindFileData); if (handle ! INVALID_HANDLE_VALUE _fs.IsVirtualDir(lpFileName)) { // 虚拟目录把虚拟映射里的第一个文件填进返回条目 lpFindFileData.cFileName _fs.FirstVirtualFileName(); } return handle; }_nextFindFirstFileW 不是虚拟文件它是 LocalHook.Create 在安装钩子时自动生成的「原函数委托」所有回调都必须先调它一次保证系统原有行为不被破坏。IsVirtualDir 判断请求路径是否落在虚拟根目录下只有命中才改写条目FirstVirtualFileName 返回映射表里的第一个虚拟文件名。整套逻辑的要点是句柄必须原样透传数据按需改写虚拟文件和真实文件混合在同一个枚举序列里返回目标程序根本感知不到中间多了层映射。4. 把注入跑起来宿主程序、注入线程和日志链路4.1 Host 侧RemoteHooking.Inject 的参数怎么给宿主程序 HookTest 的职责很单一拿到目标进程 PID调 RemoteHooking.Inject。很多人在这一步卡住是因为参数含义没搞清四个参数各有各的坑// Program.cs —— Host 侧注入入口HookTest 项目 private static void Inject(int targetPid, string virtualRoot, string logPath) { string channelName null; // 注入 InjectedLib.dll 到目标进程并传入配置参数 RemoteHooking.Inject( targetPid, // 目标进程 PID typeof(Injected).Assembly.Location, // 注入的托管 DLLInjectedLib typeof(Injected).Assembly.Location, // 宿主侧同样需要加载一次 new object[] { virtualRoot, logPath }, // 传给 Injected.Run 的自定义参数 ref channelName); // EasyHook 生成的通道名用于 IPC }第二和第三个参数传同一个 DLL 路径是因为 EasyHook 需要在 Host 和 Target 两侧各加载一份程序集这样才能跨进程传递委托和 IPC 通道第四个参数 object[] 会被序列化后传到注入线程的 Run 方法传路径时不要用相对路径目标进程的当前目录和你 Host 的当前目录大概率不一样我一般直接传 Path.GetFullPath 之后的值。channelName 是 EasyHook 生成的服务端通道名后续可以用来做 Ping 检测宿主是否退出、或者回传统计信息。注入失败时 EasyHook 会抛异常位数不匹配抛 BadImageFormatException权限不足抛 AccessViolationException这两个到避坑章细说。HookTest 这个宿主做成 WinForms 或控制台都可以本质就是个进程选择器。WinForms、WPF 甚至 .NET MAUI 都能当宿主壳核心逻辑不在界面上在 Inject 那几行参数里。4.2 Target 侧Injected.Run 里先装钩子再让线程活着InjectedLib 被注入后EasyHook 会创建一个远程线程线程入口是 Injected 类的 Run 静态方法。这个方法的生命周期管理是整个注入链路里最容易写错的地方// Injected.cs —— 注入线程入口 public static void Run( RemoteHooking.IContext context, string channelName, string virtualRoot, string logPath) { var monitor new HookMonitor(virtualRoot, logPath); monitor.Install(); // 见 3.1注册 FindFirstW/FindNextW/CreateFileW 钩子 try { // 关键点注入线程必须保持运行钩子对象才不会被 GC RemoteHooking.WaitForProcessExit(); } finally { monitor.Uninstall(); // 进程退出前卸载钩子避免 Exit 时异常 } }如果 Run 方法正常返回到末尾注入线程就结束HookMonitor 会被垃圾回收LocalHook 随之失效钩子等于白装。所以要用 RemoteHooking.WaitForProcessExit() 把线程阻塞住直到目标进程退出。context 参数里带进程信息可以写到日志里。finally 里的 Uninstall 是为了在进程退出前干净卸载钩子避免退出阶段的异常掩盖真正的问题。4.3 HookMonitor 回调与日志链路日志记录是这个项目里看起来简单、实际最容易出幺蛾子的部分。目标进程是多线程的FindFirstFileW 的回调可能同时跑在任意线程上File.AppendAllText 本身不是线程安全的不加锁会偶发 IOException// HookMonitor.cs —— 日志链路回调只做判断写日志走带锁方法 private readonly object _logLock new object(); private void Log(string format, params object[] args) { lock (_logLock) { File.AppendAllText(_logPath, DateTime.Now.ToString(yyyy-MM-dd HH:mm:ss.fff) \t Environment.CurrentManagedThreadId \t string.Format(format, args) Environment.NewLine); } }日志格式我建议固定成「时间、线程 ID、API 名、参数、结果」五段式后续用 grep 或者 Excel 透视都方便。日志文件千万别放在虚拟根目录下否则会出现一个死循环回调枚举虚拟目录时看到日志文件往里写日志又触发文件句柄操作一边写一边枚举自己。这个坑我踩过一次日志文件在虚拟目录里越滚越大进程最后卡死。4.4 从监控到自定义处理拦截之后能做的事路径重写只是最基础的一层。Hook 回调拿到的是完整 API 参数做的文章可以更多记录目标进程读了哪些文件、把某次 CreateFileW 的访问权限改成只读、把返回的文件大小改掉制造「假容量」、甚至直接拒绝打开某个文件。项目里 VirtualFileSystem.cs 实现的是「虚拟文件映射」这个最小闭环在这个基础上加行为只需要改回调里的判断逻辑不需要动注入链路。这也是这份源码最适合当脚手架的原因——注入、映射、日志三层已经打通你要做的是往 HookMonitor 的回调里填自己的业务规则。5. 实战避坑注入失败、枚举不到、进程崩溃的排查手册EasyHook 项目的调试体验像对着一个黑匣子错误往往不是直接抛在眼前而是表现为「注入没反应」或「进程秒崩」。下面几条是我照着这份源码复现时遇到过的真实翻车点按「现象 → 原因 → 解决」整理。5.1 注入就报 BadImageFormatException或提示加载不了 EasyHook64.dll现象HookTest 一调 RemoteHooking.Inject 就抛 BadImageFormatException异常信息里能看到 EasyHook64.dll 或 InjectedLib.dll 的字样。原因目标进程是 64 位但你的 InjectedLib 编译成了 x86或者输出目录里同时混着 32 位和 64 位的 EasyHook 运行时EasyLoad 引导器选错了文件。EasyHook 的运行时是分位的EasyHook32.dll 和 EasyHook64.dll 不能互替。解决先在任务管理器「详细信息」标签页确认目标进程的平台再把 InjectedLib 项目平台目标改成和目标一致的 x64 或 x86最后把对应位数的 EasyHook 运行时常与 InjectedLib.dll 放到同一目录。检查 DLL 位数用这条命令# 查看 InjectedLib.dll 的 PE 位数输出里看 machine 字段 dumpbin /headers InjectedLib.dll | findstr machine # 机器码 0x14c 是 x860x8664 是 x645.2 钩子装上去了但目标程序的文件对话框里没有虚拟文件现象注入成功日志里也能看到 FindFirstFileW 被拦截的记录但目标程序的打开对话框里一片空白虚拟文件没有出现。原因最常见的三个。一是回调里改了句柄没改数据枚举直接中断二是虚拟根目录前缀和映射表的 key 对不上目标请求的是C:\Virtual\你登记的是c:\virtual\file.txt虽然在 Windows 里路径不分大小写但如果你用普通字符串比较而不是 OrdinalIgnoreCase就会失配三是目标程序实际调的是 FindFirstFileA 而不是 W。解决第一步先打开日志把被拦截的完整路径打出来对比一下目标程序真正请求的是哪个路径第二步统一用 OrdinalIgnoreCase 做路径比较第三步确认目标程序的字符集。路径规整我一般会写成// 路径规整统一分隔符避免前缀匹配失败 private static string NormalizePath(string path) { return Path.GetFullPath(path).TrimEnd(\\) \\; }5.3 回调里写日志跑一会儿后 IOException 或文件被占用现象目录比较大的时候HookMonitor 抛 IOException日志文件写入失败或者出现日志文件被占用、删除不了的情况。原因多个线程同时进 File.AppendAllText 对同一文件句柄操作另一个更隐蔽的原因是日志文件路径落进了虚拟根目录枚举回调又把日志文件当成虚拟条目返回产生了循环引用。解决日志写入加锁这是必须的日志文件放到虚拟根目录之外如果写入量确实大改成 StringBuilder 攒批落盘减少 IO 频率// 攒批写日志减少 IO 冲突示意 private StringBuilder _buf new StringBuilder(); private void Log(string msg) { lock (_logLock) { _buf.AppendLine(msg); // 每攒够 8KB 刷一次盘避免频繁打开文件 if (_buf.Length 8192) Flush(); } }5.4 注入后目标进程直接崩溃事件查看器里是 0xC0000005现象点完注入按钮目标进程立刻退出系统事件日志里记录 Access Violation异常地址完全随机。原因委托签名和真实 API 不一致尤其是 WIN32_FIND_DATAW 结构体布局不对或者回调里给 cFileName 赋了超长字符串覆盖了结构体后面的内存。EasyHook 的委托是直接内存跳转签名不一致时栈就毁了不会给你温柔的托管异常。解决核对 NativeMethods.cs 里的结构体布局FILETIME 是 8 字节不能声明成 int// NativeMethods.cs —— WIN32_FIND_DATAW 布局节选 [StructLayout(LayoutKind.Sequential, CharSet CharSet.Unicode)] internal struct WIN32_FIND_DATAW { public uint dwFileAttributes; public long ftCreationTime; // FILETIME 是 8 字节long 正好 public long ftLastAccessTime; public long ftLastWriteTime; public uint nFileSizeHigh; public uint nFileSizeLow; public uint dwReserved0; public uint dwReserved1; [MarshalAs(UnmanagedType.ByValTStr, SizeConst 260)] public string cFileName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst 14)] public string cAlternateFileName; }cFileName 赋值前做个长度判断超过 259 字符就截断。改完回调先跑一个最小验证注入记事本只拦不改写确认原调用正常再逐步加映射逻辑。5.5 杀软把注入当木马拦截或 EasyHook 的 Svc.exe 被隔离现象注入过程静默失败Host 侧不报任何异常但目标进程里没有钩子或者发现 EasyHook32Svc.exe 被杀毒软件隔离。原因远程线程注入本身就是 AV/EDR 最敏感的行为特征EasyHook 为了提权启动的 Svc.exe 也常被当成可疑进程。解决开发调试阶段在隔离测试机或把工作目录加入白名单如果要在多台机器上部署别跟杀软硬刚考虑换成「程序启动时随 EXE 加载 DLL」的方式或者改用文件系统过滤驱动。EasyHook 这套方案定位就是开发工具、测试工具、实验室环境生产环境大规模部署时要重新评估信任模型。6. 拿到源码后先做这三步验证让虚拟文件系统自己证明自己源码落地最怕的不是代码看不懂而是复现出来不知道对不对。我建议拿到这份资源后先做三个最小验证把链路验证透了再改自己的逻辑。第一步最小注入验证。打开 HookTest目标进程选记事本注入后打开记事本的「打开」对话框手动输入虚拟根目录的路径。如果目录列表里出现了虚拟文件说明 FindFirstFileW 和 FindNextFileW 的枚举链路通了再点开这个虚拟文件如果能正常打开内容说明 CreateFileW 的路径重写也生效了。这一步通过整个注入链路就没有大问题。第二步计数器验证。在 HookMonitor 里加一个整数计数器FindFirstFileWHooked 进去就加一然后在 Host 侧通过 IPC 通道把计数读回来// Host 侧读取注入 DLL 的统计信息示意需配合 channel 实现 var stats channel.GetStats(); Console.WriteLine($FindFirstW 被拦 {stats.FindFirstCount} 次CreateFileW 被重写 {stats.CreateCount} 次);计数不为零说明回调确实跑在你的代码里而不是 EasyHook 碰巧装了钩子却被系统缓存绕过。第三步回归冒烟。把虚拟映射条目增删各一次的用例固定下来每次改完 VirtualFileSystem.cs 都强制走一遍「注入 → 枚举 → 打开」的完整路径顺便确认目标进程退出时没有异常日志。从那以后我每次动映射核心逻辑都强制先跑一遍这个最小闭环再碰日志和界面代码。EasyHook 项目调试起来像个黑匣子能通过的最小闭环越短后面越省事希望帮到你。本文还有配套的精品资源点击获取
网站建设高端定制企业官网