ARTICLE DETAIL

资讯详情

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

.NET+EasyHook实现进程级虚拟文件系统:API钩子与路径重定向

.NET+EasyHook实现进程级虚拟文件系统:API钩子与路径重定向 简介这是一份基于 .NET 与 EasyHook 的虚拟文件系统实现源码面向对 Windows 文件操作拦截、API Hook 与进程注入感兴趣的开发人员。项目通过对 FindFirstFileW、FindNextFileW、CreateFileW 等 Win32 API 的挂钩把真实路径映射为虚拟路径并支持向指定进程注入监控模块同时附带日志记录。压缩包共 30 个文件约 322KB以 C# 源码cs、项目文件csproj/sln、配置文件config为主并包含 EasyHook 动态库dll与示例可执行程序exe结构清晰适合直接打开解决方案研读。资源提供 HookTest、VirtualFile 与 InjectedLib 三个模块分别承担测试入口、虚拟文件系统核心和注入库实现便于对照理解钩子注册、文件映射与进程注入流程。已有 58 人学习下载对想在不修改目标程序的前提下扩展文件操作行为的读者具有参考价值。1. 为什么拿 .NET 和 EasyHook 拼一个虚拟文件系统先弄清楚它能骗过程序什么某个第三方工具坚持在运行时往安装目录写日志和配置你想让它完全不碰系统盘就能跑通测试。没有源码可改只能从操作系统层面下手。虚拟文件系统这个标题给出的路线是在目标进程拿到真实文件句柄之前把 CreateFileW 这一层的“目的地”悄悄换成另一个路径——程序全程无感知。用 .NET 和 EasyHook 做这件事优势在于开发路径短EasyHook 负责把托管 DLL 注入目标进程.NET 负责写 hook 逻辑和重定向规则整个方案不需要碰驱动模型。这个思路最适合三类人做软件绿色化和免安装化的工程师、做游戏素材替换的 mod 作者、做私有功能验证的测试开发。需要先说清边界它不是一个通用文件系统驱动不会接管整块磁盘。它只对“被成功注入且调用了 Win32 文件 API 的进程”生效。理解了这个边界后面所有设计决策都有了解释。2. 虚拟文件系统的最小骨架EasyHook 注入链路、三进程模型与必须挂钩的 Win32 API2.1 三进程模型宿主、注入器、被注入的 Hook DLL用 EasyHook 做文件系统重定向工程上需要先分清三个角色。第一个是注入器Injector一个 .NET 控制台程序负责找到目标进程 PID调用 RemoteHooking.Inject 把 hook 库塞进去。第二个是被注入的 Hook DLL这是真正干活的模块本质上是一个 C# 类库入口类里含 EasyHook 约定的构造函数和 Run 方法进程启动后这个方法在目标进程内部执行。第三个是宿主Host通常就是注入器所在的进程它持有重定向规则、提供 IPC 服务Hook DLL 通过 IPC 向宿主查询“这个路径该指向哪”。三条链路合起来是这样注入器发现目标进程已启动把 HookDLL 连同 EasyHook 的 native 引导文件注入目标进程HookDLL 在目标进程内通过 LocalHook.Create 挂钩 kernel32 导出函数此后目标进程每次调用 CreateFileW都会先进入我们写的托管委托委托决定放行还是改路径。宿主进程退出后HookDLL 里的 IPC 通道会断开这个直接关系到稳定性避坑章节会专门展开。值得强调的设计原则是被注入的 DLL 自身不加载配置文件只通过 IPC 向宿主要答案。原因是 HookDLL 活在目标进程的上下文里目标进程一旦崩溃里面缓存的状态全没了而规则放在宿主进程可以独立保存也支持后续做配置热更新。2.2 至少要拦截的 APICreateFileW、GetFileAttributesW、FindFirstFileW只挂 CreateFileW 是新手最容易掉进去的坑。多数程序拿到文件句柄之前会先做一次“文件是否存在”检查如果文件不存在后续打开、读取、写入的调用链根本不会发生。这个检查走的是 GetFileAttributesW程序用文件对话框或目录遍历加载素材时还会走 FindFirstFileW / FindNextFileW删除和移动文件则对应 DeleteFileW、MoveFileW。这些 API 不挂钩虚拟文件系统的效果就会在各种奇奇怪怪的环节上夭折。对照 Win32 API一个完整的最小覆盖集合是这样的API作用不挂钩的后果CreateFileW打开/创建文件获得句柄无法改变读写目的地GetFileAttributesW查询文件/目录是否存在及属性程序认为虚拟文件不存在FindFirstFileW / FindNextFileW枚举目录内容目录列表里看不到虚拟文件DeleteFileW / MoveFileW删除和移动文件操作落到真实文件上ReadFile / WriteFile基于句柄读写内容若想拦截内容必须一并挂钩起步阶段建议先只做 CreateFileW 和 GetFileAttributesW 两个。它们覆盖了“能否打开、是否存在”这两个高频判断能解决绝大多数路径重定向需求。FindFirstFileW 那组放到最后再加因为目录枚举涉及结构体对齐和结果合并处理不好会出现重复文件和死循环。2.3 系统调用被换了路径程序为什么无感知EasyHook 的底层原理和常见的 inline hook 是同一路线。它在目标进程内找到 CreateFileW 在 kernel32.dll 中的入口地址把函数前几个字节改成跳转指令跳转到我们提供的托管委托同时把原始指令保留到 trampoline 区域供转调原函数时使用。这个过程对调用方完全透明程序以为自己还在跟 kernel32 对话实际每一步都经过了修整。这套机制决定了两个重要边界。第一hook 只对已注入的进程生效新启动的进程需要重新注入除非用等待模式或父进程拉起的方式来兜住进程启动窗口。第二hook 对跨进程句柄无效如果目标进程把句柄传给另一个进程去读写读写路径就不经过我们的修整。EasyHook 的 native 引导文件会把 CLR 加载进目标进程因此即便目标程序完全不是 .NET 写的也能执行托管代码钩子这也是选它做绿色化工具的一个重要原因。3. 用 EasyHook 在目标进程里挂上 CreateFileW最小可跑代码3.1 HookDLL从 LocalHook.Create 到委托签名这一节给出一个能跑的最小 HookDLL读者建一个 C# 类库工程引用 EasyHook 包目标框架用 .NET Framework 4.7.2 或兼容版本即可。// FileSystemHook.cs —— 被注入目标进程的托管类 public class FileSystemHook { // 必须与 kernel32!CreateFileW 签名一一对应 private delegate IntPtr CreateFileWDelegate( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile); private CreateFileWDelegate _originalCreateFileW; // EasyHook 约定的入口构造函数参数由注入器传进来 public FileSystemHook(RemoteHooking.IContext context, string channelName) { // 真实项目里在这里连接宿主进程的 IPC拉取重定向规则 // 这一步先留空规则暂不存在默认全部放行 } public void Run(RemoteHooking.IContext context, string channelName) { // 取 kernel32 导出函数地址 IntPtr procAddress LocalHook.GetProcAddress( kernel32.dll, CreateFileW); // 挂钩第一个参数是目标地址第二个参数是我们的委托 LocalHook hook LocalHook.Create( procAddress, new CreateFileWDelegate(CreateFileWHook), this); // 只让主线程走钩子其他线程放行降低并发风险 hook.ThreadACL.SetExclusiveACL(new Int32[] { 0 }); // 从 trampoline 取出原始函数入口供放行时调用 // 不同 EasyHook 版本获取原始委托的方式略有差异 _originalCreateFileW Marshal.GetDelegateForFunctionPointerCreateFileWDelegate( HookRuntimeInfo.GetFunctionPointer()); // EasyHook 注入时会挂起目标进程必须显式恢复 RemoteHooking.WakeUpProcess(); // Run 方法不能退出否则钩子被卸载目标进程崩溃 for (;;) { Thread.Sleep(1000); } } private IntPtr CreateFileWHook( string lpFileName, uint dwDesiredAccess, uint dwShareMode, IntPtr lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, IntPtr hTemplateFile) { // 调试阶段只打点完全放行 if (lpFileName.Contains(vfs_test)) { File.AppendAllText(C:\vfs_debug.log, $[{DateTime.Now:HH:mm:ss}] {lpFileName}\r\n); } // 转调原始 API这是唯一正确的放行方式 return _originalCreateFileW( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); } }核心逻辑在 Run 方法里。LocalHook.Create 接受三个参数目标 API 地址、委托实例、上下文对象。ThreadACL.SetExclusiveACL 把主线程设为唯一授权线程目标进程多线程并发读写时这个名单需要调整否则只有主线程的文件操作会被拦截。RemoteHooking.WakeUpProcess 必须调用它把目标进程从 EasyHook 的挂起状态恢复过来。委托签名是最容易翻车的部分。lpFileName 是 LPCWSTR对应 string封送由 .NET 自动完成句柄类型必须用 IntPtr不能用 int在 64 位进程里 int 会截断句柄的高 32 位dwDesiredAccess 这类 DWORD 参数统一用 uint。Run 方法最后留一个死循环不是偷懒是被注入 DLL 的生命周期就靠这个线程撑着一旦 Run 返回钩子被清理目标进程大概率直接异常退出。3.2 注入器把 HookDLL 塞进目标进程注入器是独立控制台项目引用 HookDLL 项目顺带承担宿主角色。这段代码负责找到目标进程并完成注入。// Program.cs —— 注入器 简易宿主 class Program { static void Main(string[] args) { // 用法: VfsInjector.exe 目标进程名 [通道名] string processName Path.GetFileNameWithoutExtension(args[0]); string channelName args.Length 1 ? args[1] : vfs_default_channel; Process target Process.GetProcessesByName(processName).FirstOrDefault(); if (target null) { Console.WriteLine(目标进程未运行先启动目标程序再注入); return; } // 把 HookDLL 的路径和入口类名传给 EasyHook RemoteHooking.Inject( target.Id, InjectionOptions.DoNotRequireStrongName, typeof(FileSystemHook).Assembly.Location, typeof(FileSystemHook).FullName, channelName); Console.WriteLine(注入完成HookDLL 运行于 PID {0}, target.Id); } }注入器有两个隐性要求。第一必须以管理员权限运行因为向别的进程注入模块属于跨进程写操作目标进程完整性级别更高时会直接被拒。第二注入器、HookDLL、目标进程三者的位宽必须匹配x64 目标只能用编译为 x64 的 HookDLL不要指望 AnyCPU 一把梭。RemoteHooking.Inject 的第五个参数是传给 HookDLL 构造函数的参数EasyHook 会原样传递。最小示例里它只是一个字符串到下一章它会是宿主进程监听的 IPC 通道名。提示先用记事本做首个验证目标成功后再换业务程序。记事本打开文件时也会走 CreateFileW日志里能看到它实际读取了哪些路径。3.3 转调原函数验证“App 真的看不出来”转调原函数是整条链路的核心动作。上面代码里 CreateFileWHook 末尾调用 _originalCreateFileW这个委托指向 trampoline 里的原始代码而不是直接再进 kernel32 的导出表。如果这里写成了“自己再调用一次 CreateFileW”会出现无限递归目标进程直接栈溢出。验证步骤可以这样走准备一个目录放一个 test.txt打开记事本注入 HookDLL再从记事本里打开 test.txt。观察 C:\vfs_debug.log 确认打点触发。无异常后把 CreateFileWHook 里的判断逻辑改成路径替换将原文件路径指向另一个内容相同的文件记事本看到的内容应该被替换。此时整个 hook 链路已经闭环目标进程无感知这个结论才算立住。4. 把重定向规则做成配置映射表、内存文件与回写策略4.1 路径映射表前缀匹配、优先级与不区分大小写把上一章的替换逻辑抽成独立配置层虚拟文件系统才勉强算有“系统”的样子。映射表本质上是一个从原始路径前缀到目标路径前缀的字典我一般把它放在宿主进程的 appsettings.json 里HookDLL 启动时通过 IPC 拉取一份快照。{ rules: [ { source: C:\\Program Files\\MyApp\\config.ini, target: D:\\VFS\\MyApp\\config.ini }, { source: C:\\Program Files\\MyApp\\logs, target: D:\\VFS\\MyApp\\logs }, { source: C:\\Users\\test\\AppData\\Local\\MyApp, target: D:\\VFS\\MyApp\\AppData } ] }规则加载进内存后是字典 key-value。匹配时注意三点大小写不敏感是必须的Windows 文件系统本来就区分大小写不敏感匹配顺序按最长前缀优先比如 source 同时存在 C:\Program Files\MyApp 和 C:\Program Files 时必须先匹配更长那条否则 config.ini 会被错误映射到 D:\VFS\Program Files\MyApp第三点容易漏目标程序传进来的可能是相对路径映射前先用 Path.GetFullPath 归一化成绝对路径再匹配。public bool TryRedirect(string originalPath, out string redirectedPath) { // 按目标进程自己的工作目录展开相对路径 string fullPath Path.GetFullPath(originalPath); foreach (var rule in _rules) { if (fullPath.StartsWith(rule.Source, StringComparison.OrdinalIgnoreCase)) { // 用 target 前缀替换 source 前缀保留后面剩余部分 redirectedPath rule.Target fullPath.Substring(rule.Source.Length); return true; } } redirectedPath originalPath; return false; }上面这个实现假设 _rules 已经按长度降序排好完整工程里要在加载阶段做排序和去重。还有一点容易被忽略Path.GetFullPath 拿到的是目标进程自己的工作目录不是宿主的所以需要在被注入库内调用才准确在宿主进程里做字符串归一化是错误上下文。4.2 三类重定向场景配置漂移、只读覆盖、临时文件暂存映射规则不能拍脑袋配先想清楚要解决哪一类问题再决定映射粒度和放行策略。第一类是配置漂移。常见于没有源码的旧软件它把配置和运行状态写回安装目录在 Program Files 下会被 UAC 挡得半死。方案是把安装目录整体映射到一个可写目录config.ini、日志、缓存目录全部重定向。注意 target 目录自身可以不存在但它的父目录必须真实存在否则 CreateFileW 会因父目录缺失而失败。第二类是只读覆盖。素材、贴图、音频、DLL 这类文件程序从资源目录读取你想在不动原文件的前提下替换成新版本。把规则的 target 指向 override 目录原文件完全不碰。这种场景下必须补上 GetFileAttributesW 的钩子程序在决定加载哪个资源时经常先查文件长度和存在性。只读覆盖还有个隐蔽问题override 目录缺文件时程序会回退到真实目录读取最终出现“一半替换一半原版”的混合状态设计时要保留这个回退逻辑的日志。第三类是临时文件暂存。程序在 %TEMP% 或安装目录下频繁创建小文件生命周期短、量又大每次都真实落盘对固态硬盘也是一种无谓损耗。把这部分路径映射到一个专用临时目录能显著减少对工作目录的污染。如果数据不需要跨重启保留直接把 target 指向内存盘即可。4.3 写回策略同步落盘、延迟落盘与“不想落盘的虚拟文件”重定向并不一定都要真实落盘写回策略按“这份数据丢了会怎样”来分级。最简单的做法是同步落盘。一旦映射确定之后的 ReadFile / WriteFile 操作依然发生在目标句柄上目标进程崩溃或断电不会导致数据不一致开销也最小适合配置漂移场景。延迟落盘适合临时文件暂存这需要 HookDLL 在 CreateFileW 后不创建真实文件改为在宿主进程内存里维护一个流对象再把句柄关联到内存流。这个做法的工程量比路径重定向大很多因为 ReadFile 和 WriteFile 也必须同步挂钩句柄必须自洽。更务实的做法是“目标指向系统临时目录”。启动时生成一个随机子目录需要视为虚拟的文件落在这里用完即删。这样既有真实文件兜底又避免内存句柄导致的各种状态错乱。我在实际项目里一贯的取舍是能重定向路径就不做句柄仿造路径重定向边界清晰出了故障日志一眼能看懂。4.4 补钩 GetFileAttributesW程序在打开文件之前会先问“在不在”如果只挂了 CreateFileW很多程序会表现得很怪异日志上看不到任何 CreateFileW 打点但程序确确实实访问了那个文件。原因通常是它在打开前先调用了 GetFileAttributesW 判断存在性一旦返回文件不存在就直接走了另一条逻辑分支。解决方法是把 GetFileAttributesW 也挂上。挂钩方式和 CreateFileW 一样委托签名如下private delegate uint GetFileAttributesWDelegate(string lpFileName); private uint GetFileAttributesWHook(string lpFileName) { if (_mapper.TryRedirect(lpFileName, out string redirected)) { // 目标文件真实存在时返回普通文件属性 if (File.Exists(redirected)) { return (uint)FileAttributes.Normal; } // 目标路径是一个目录时返回目录属性 if (Directory.Exists(redirected)) { return (uint)FileAttributes.Directory; } } return _originalGetFileAttributesW(lpFileName); }这段代码有一个隐蔽陷阱File.Exists 和 Directory.Exists 底层也会调用 CreateFileW 或 GetFileAttributesW如果这两个 API 已经被挂上就会触发递归调用目标进程最终栈溢出。正确做法是在判断分支里直接调用保存好的原始委托或通过 kernel32 的非托管函数绕过托管封装。补上 GetFileAttributesW 后程序的行为链条才是完整的先问在不在得到存在再 CreateFileW 打开文件最后重定向到实际位置。5. 避坑指南EasyHook 虚拟文件系统在真实机器上的五个坑5.1 注入 x64 进程失败把句柄声明成 int 的后果现象注入 x64 目标进程时一切正常HookDLL 也进去了但事务进程真正执行到 CreateFileW 时立刻崩溃异常信息是访问无效内存地址有时伴随 AccessViolation。原因64 位进程里句柄 HANDLE 是 64 位整数如果委托签名把句柄声明成 int封送层会截断高 32 位。返回值被截断后目标进程拿着不完整的句柄去执行后续操作自然会崩。32 位进程上这个 bug 不会暴露因为那里的句柄本来就是 32 位所以不少人是到了 64 位机器上才踩到。解决委托签名里所有句柄相关的参数和返回值全部使用 IntPtr包括 hTemplateFile 和返回值。dwDesiredAccess、dwShareMode 这些 DWORD 参数统一用 uint 声明避免符号扩展问题。每次声明签名后拿 64 位记事本验证一次打点正常再上目标程序。5.2 钩子被无限重入别在钩子函数里调用被拦 API现象HookDLL 刚注入目标进程卡死CPU 单核打满系统日志大量出现 stack-overflow 记录。原因钩子函数内没有转调原始委托而是又调用了同一个 API。可能是直接调用也可能是间接调用比如在 GetFileAttributesWHook 里调用了 File.Exists而 File.Exists 底层又走 GetFileAttributesW。调用链重新进入被挂钩的入口每次进入都再跳转回来栈空间迅速耗尽。解决遵守两条纪律。第一钩子函数内所有需要真实行为的分支只调用保存好的原始委托不要调托管封装 API。第二在托管代码内部如果确实要判断文件是否存在使用递归标记位做保护或在加载规则阶段把判断结果缓存到本地变量。这个坑的隐蔽性在于问题往往不在你写的路径判断逻辑上而在平台库内部重新进入了 hook。5.3 IPC 通道断开导致程序卡死降级为直通的配置现象宿主进程被手动关闭或重启后又启动了第二个宿主实例目标程序所有文件操作完全卡死连只读打开都转圈。原因HookDLL 每次文件操作前都向宿主 IPC 查询规则宿主退出后通道断开。如果查询是同步阻塞且没有异常处理每次调用都在等待一个永远不会来的响应。解决正确设计是让 HookDLL 在启动时从宿主拉取一次完整映射快照之后的读操作只在本地内存查询不再每次问宿主。宿主崩溃后钩子还在最多是规则不更新不会卡住业务逻辑。支持热更新可以加一个配置版本号字段周期性刷新连接到宿主失败时静默降级为直通。这个设计把宿主的故障半径限制在“规则不刷新”级别目标程序可以继续运行。5.4 杀软拦截注入签名、白名单和“只有你自己能用”现象注入器执行时没有报错但目标进程里没有出现日志文件HookDLL 根本没被加载。有些杀软会直接弹窗提示有程序尝试注入其他进程。原因远程线程注入属于典型的敏感行为绝大多数安全软件默认阻止。由于 EasyHook 的引导 DLL 是公开知名组件有些环境会明示拦截有些环境会静默隔离。解决这个方案本质上只适合自己的机器或可控测试环境。应对手段依次为给 HookDLL 做数字签名降低误报概率在杀软里把注入器和目标进程加白名单生产环境部署前在小规模机器上验证安全策略。不要期待找到能绕过拦截的办法那触及安全对抗边界能干但代价远大于收益。5.5 只挂 CreateFile 不挂目录枚举程序找不到你的虚拟文件现象路径重定向都配好了CreateFileW 日志也正常但程序界面上就是看不到虚拟目录里的文件。原因程序打开文件对话框、加载目录列表时走的是 FindFirstFileW / FindNextFileW 这一组枚举函数。它们没被挂钩程序从真实目录枚举拿到文件列表自然看不到虚拟出来的内容。解决如果需求是“让目录列表里多出文件”必须把 FindFirstFileW 和 FindNextFileW 一并挂钩并在枚举结果里合并虚拟文件项。这块工作量比前几个 API 加起来还大涉及 WIN32_FIND_DATA 结构体对齐、通配符过滤、属性拼接。如果虚拟文件本身在目标目录有真实载体只是路径被改了那程序枚举到的还是真实文件可以绕开这一组钩子。6. 验证与进阶用 Process Monitor 确认行为向 FindFirstFile 扩展动手改映射规则之前先花十分钟建立验证基线。最简单的方法是让目标程序固定打开一个文件用 Process Monitor 捕获它实际访问的路径和操作结果。Process Monitor 会列出每个进程的文件操作序列包括成功、失败、路径重试。对比修改前后两轮捕获确认新增的 CreateFile 请求是否落在 target 前缀下这是一个黑匣子的验证法比只看自己日志可靠。代码层面建议给 HookDLL 加一个环境变量开关VFS_DEBUG1 时把每次调用的原始路径、判定结果、转调目标写入日志VFS_DEBUG0 时静默放行。我第一次写这类钩子时靠这个开关排查出一个低级问题规则里的 target 目录父目录没创建CreateFileW 每次都因路径不完整失败看起来却像 hook 完全没有生效。进阶方向是扩展 FindFirstFileW / FindNextFileW。如果确实需要让目录枚举虚构出文件需要重写 WIN32_FIND_DATA 里的文件属性、文件名、文件大小字段。建议先把虚拟文件数量限制在几百个以内因为每次枚举调用都要在真实结果和虚拟结果之间合并最容易出现重复项和错位。一个更省事的变通方案是不伪造目录项而是把虚拟文件作为一个真实存在的小文件放到 target 目录里让枚举函数自然看到它这是我在试错之后更推荐的路径。最后记住这个方案的边界。它只能影响已注入进程的 Win32 层 API管不了内核态文件系统请求也管不了直接用 Nt 系统调用绕过 kernel32 的少数程序。如果有一天需求膨胀成“全系统所有进程都看到同一个虚拟磁盘”那就去用文件系统过滤驱动或 Dokan不要在这个 hook 方案上硬撑。我踩过最深的一个坑就是试图把路径重定向方案强行扩展成全局盘符映射结果被各种把底层绕过搞到怀疑人生。先分清产品边界再决定把这条路走到第几层。希望这次的方案和踩坑记录能帮到正打算做进程级文件重定向的你。本文还有配套的精品资源点击获取
返回列表