资讯详情

EasyHook用户态文件虚拟化实战:NTFS底层API拦截方案

📅 2026/10/31 17:19:46 | 华诺云谱 👁 阅读
EasyHook用户态文件虚拟化实战:NTFS底层API拦截方案
简介本资源是一套基于 .NET 平台与 EasyHook 库实现的虚拟文件系统源码工程面向 Windows 底层开发、API 钩子技术学习者及安全/逆向方向进阶开发者解决文件操作行为监控、虚拟路径映射与进程级文件拦截等核心问题。压缩包共30个文件含10个C#源码如VirtualFileSystem.cs、Injected.cs、6个关键DLLEasyHook32/64.dll、InjectedLib.dll等、4个配置文件App.config、packages.config等、3个项目文件.csproj、2个可执行程序HookTest.exe、EasyLoad*.exe及解决方案文件.sln总大小322KB结构完整便于编译调试与模块化分析。已有58人学习下载。读者可直接获取可运行的钩子注入框架、完整的Win32 API拦截逻辑FindFirstFileW/CreateFileW等、虚拟路径映射机制实现、进程注入与日志记录功能代码特别适合深入理解用户态API Hook原理与虚拟文件系统设计实践。1. 为什么用 EasyHook 拦截 NTFS 底层调用比写 Minifilter 驱动更适配企业级虚拟文件系统落地你手头有个需求让某款老旧 ERP 软件“以为”它正在读写本地 D:\data\ 目录实际所有读写全部路由到加密云存储或内存缓存——但又不能改 ERP 源码、不能装驱动、不能要求用户开管理员权限。这时候.NET EasyHook构建的用户态虚拟文件系统VFS就成了少有的可落地解法。它不碰内核、不依赖 Windows Driver Kit、不触发 UAC 弹窗靠 Hookntdll.dll中的NtCreateFile/NtReadFile/NtWriteFile等关键函数在应用层透明劫持 I/O 调用链。EasyHook 是目前 .NET 生态中唯一稳定支持 x64 进程远程注入 函数重定向的开源库注意不是 Detours不是 Microsoft Detours 的 C# 封装是独立实现的跨进程 Hook 框架其RemoteHooking模块能将你的 .NET 托管代码注入目标进程并在原生 API 调用前/后插入自定义逻辑。这个方案不是玩具——它被用于某银行终端安全沙箱、某医疗影像 PACS 的离线缓存模块、以及多个国产 CAD 软件的许可证虚拟化组件。适合场景很明确需要对已有 .NET 或 Win32 应用零改造接入虚拟文件路径且部署环境受限如无管理员权限、禁用驱动签名、终端策略锁死 WDK 工具链。如果你正卡在“想做文件虚拟化但不敢碰驱动”这篇就是为你写的实操笔记。2. 从零构建可注入的 Hook Server编译、注入与基础拦截框架2.1 准备开发环境与 EasyHook 版本选型EasyHook 在 2021 年后已停止维护但 v2.7.7009GitHub 最后 release仍是当前最稳定的版本必须使用该版本。v3.x 分支虽有 .NET Core 支持但存在严重内存泄漏尤其在高频小文件读写场景下30 分钟后目标进程句柄数暴涨至 10w 导致崩溃而 v2.7.7009 经过大量金融级生产验证。不要试图用 NuGet 安装EasyHook包——它默认拉取的是 v2.7.6xxx缺少关键修复如 x64 进程注入时LhInstallHook返回ERROR_INVALID_HANDLE的问题。正确做法是# 下载官方源码包非 NuGet wget https://github.com/EasyHook/EasyHook/releases/download/v2.7.7009/EasyHook-2.7.7009-src.zip unzip EasyHook-2.7.7009-src.zip cd EasyHook-2.7.7009-src # 用 Visual Studio 2019或 VS2022 兼容模式打开 EasyHook.sln # 编译 ConfigurationRelease, Platformx64 # 输出目录bin\x64\Release\提示编译前务必确认项目属性 → Build → Platform target x64不是 AnyCPU且Allow unsafe code勾选。EasyHook 的核心是EasyHook32.dll和EasyHook64.dll它们是纯原生 DLL必须与目标进程位数严格一致。你的 Hook Server即注入逻辑所在程序也必须是 x64否则无法注入 64 位进程如 Office 2016、Chrome、.NET 5 应用。2.2 编写最小可运行的 Hook ServerC#Hook Server 是注入逻辑的宿主它不直接处理文件只负责把你的 VFS 逻辑“塞进”目标进程。以下是最简结构Program.csusing System; using EasyHook; namespace VfsHookServer { internal class Program { private static void Main(string[] args) { try { // 启动注入目标进程 PID 从命令行传入 if (args.Length 1 || !int.TryParse(args[0], out int targetPid)) { Console.WriteLine(Usage: VfsHookServer.exe PID); return; } // 注入参数传递给远程线程的字符串可序列化任意配置 string channelName VfsChannel_ Guid.NewGuid().ToString(N); RemoteHooking.Inject( targetPid, InjectionOptions.Default, typeof(InjectionEntryPoint).Assembly.Location, // 注入的程序集路径必须是绝对路径 typeof(InjectionEntryPoint).Assembly.Location, // 同上 channelName); // IPC 通道名用于 Server ↔ Injected 通信 Console.WriteLine($Injected into PID {targetPid}, waiting for connection...); // 等待注入完成并建立 IPC 连接 var ipc new IpcClient(channelName); ipc.WaitForConnection(); // 阻塞直到注入体连接上来 Console.WriteLine(IPC connected. Hook ready.); Console.ReadLine(); // 保持进程存活 } catch (Exception ex) { Console.WriteLine($Inject failed: {ex}); } } } }关键点说明RemoteHooking.Inject()是 EasyHook 的核心入口它执行三件事① 将EasyHook64.dll注入目标进程② 在目标进程中加载你指定的程序集InjectionEntryPoint.dll③ 在目标进程中启动InjectionEntryPoint的Main方法。channelName是命名管道名用于 Hook Server 与注入体之间的双向通信。必须全局唯一否则多实例会冲突。建议用Guid生成。typeof(InjectionEntryPoint).Assembly.Location必须是绝对路径。相对路径在注入后会找不到程序集报FileNotFoundException。常见翻车点调试时用AppDomain.CurrentDomain.BaseDirectory获取路径但发布后需硬编码或通过配置传入。2.3 实现注入体InjectionEntryPoint接管 NtCreateFile注入体是真正运行在目标进程地址空间里的代码。它必须继承IEntryPoint并在Execute方法中注册 Hookusing System; using System.Runtime.InteropServices; using EasyHook; namespace VfsHookServer { public class InjectionEntryPoint : IEntryPoint { private readonly LocalHook _createFileHook; private readonly string _ipcChannel; public InjectionEntryPoint(RemoteHooking.IContext context, string channelName) { _ipcChannel channelName; // 获取 NtCreateFile 的原始地址Windows 10/11 下为 ntdll!NtCreateFile IntPtr ntCreateFileAddr LocalHook.GetProcAddress(ntdll.dll, NtCreateFile); // 创建 Hook将调用重定向到 MyNtCreateFile _createFileHook LocalHook.Create( ntCreateFileAddr, new NtCreateFileDelegate(MyNtCreateFile), this); // 设置 Hook 过滤只 Hook 当前进程避免影响其他进程 _createFileHook.SetExclusiveACL(new Int32[] { 0 }); } public void Execute() { // 启动 IPC 客户端连接到 Server using (var ipc new IpcClient(_ipcChannel)) { ipc.Connect(); // 启用 Hook _createFileHook.Enabled true; Console.WriteLine(NtCreateFile hook enabled.); // 保持线程存活Hook 是异步生效的 while (true) { System.Threading.Thread.Sleep(1000); } } } // 原始 NtCreateFile 函数签名WinAPI 标准 [UnmanagedFunctionPointer(CallingConvention.StdCall, SetLastError true, CharSet CharSet.Unicode)] private delegate int NtCreateFileDelegate( out IntPtr FileHandle, uint DesiredAccess, ref OBJECT_ATTRIBUTES ObjectAttributes, out IO_STATUS_BLOCK IoStatusBlock, ref long AllocationSize, uint FileAttributes, uint ShareAccess, uint CreateDisposition, uint CreateOptions, IntPtr EaBuffer, uint EaLength); // 我们的 Hook 处理函数 private int MyNtCreateFile( out IntPtr FileHandle, uint DesiredAccess, ref OBJECT_ATTRIBUTES ObjectAttributes, out IO_STATUS_BLOCK IoStatusBlock, ref long AllocationSize, uint FileAttributes, uint ShareAccess, uint CreateDisposition, uint CreateOptions, IntPtr EaBuffer, uint EaLength) { // 解析 ObjectAttributes-ObjectName-Buffer 获取文件路径 string fullPath GetUnicodeStringFromObjectAttributes(ref ObjectAttributes); Console.WriteLine($[HOOK] NtCreateFile: {fullPath}); // TODO此处加入你的 VFS 路径映射逻辑如 D:\data\ → 内存缓存 key // 如果是虚拟路径返回 STATUS_SUCCESS 伪造句柄否则调用原函数 FileHandle IntPtr.Zero; IoStatusBlock.Status 0; // STATUS_SUCCESS IoStatusBlock.Information 0; return 0; // STATUS_SUCCESS } // 辅助方法从 OBJECT_ATTRIBUTES 解析 Unicode 字符串 private string GetUnicodeStringFromObjectAttributes(ref OBJECT_ATTRIBUTES objAttr) { if (objAttr.ObjectName IntPtr.Zero) return ; var unicodeStr Marshal.PtrToStructureUNICODE_STRING(objAttr.ObjectName); if (unicodeStr.Buffer IntPtr.Zero) return ; var buffer Marshal.PtrToStructurechar[](unicodeStr.Buffer, unicodeStr.Length / 2); return new string(buffer); } } // WinAPI 结构体定义必须与 ntdll 一致 [StructLayout(LayoutKind.Sequential)] public struct OBJECT_ATTRIBUTES { public int Length; public IntPtr RootDirectory; public IntPtr ObjectName; // UNICODE_STRING* public uint Attributes; public IntPtr SecurityDescriptor; public IntPtr SecurityQualityOfService; } [StructLayout(LayoutKind.Sequential)] public struct UNICODE_STRING { public ushort Length; public ushort MaximumLength; public IntPtr Buffer; } [StructLayout(LayoutKind.Sequential)] public struct IO_STATUS_BLOCK { public int Status; public IntPtr Information; } }逻辑说明MyNtCreateFile是 Hook 的入口它接收原始 API 参数。关键在于解析OBJECT_ATTRIBUTES.ObjectName获取文件路径——这是 Windows 内核对象模型的标准方式比解析CreateFileW的lpFileName更底层、更可靠绕过 Win32 层的路径规范化。GetUnicodeStringFromObjectAttributes是血泪经验OBJECT_ATTRIBUTES中的ObjectName是UNICODE_STRING*其Buffer字段指向宽字符数组长度单位是字节必须用unicodeStr.Length / 2计算字符数。常见坑直接Marshal.PtrToStringUni会读到\0截断导致路径丢失后半段。FileHandle和IoStatusBlock必须按约定填充。若返回STATUS_SUCCESS0则 Windows 认为文件打开成功后续NtReadFile等调用会基于此句柄进行。若要拒绝访问需返回STATUS_OBJECT_NAME_NOT_FOUND0xC0000034等状态码。3. 实现虚拟文件系统核心路径映射、内存缓存与文件句柄管理3.1 设计 VFS 路径映射规则从物理路径到虚拟资源 ID虚拟文件系统的核心是“欺骗”。ERP 软件调用CreateFileW(LD:\\data\\config.xml, ...)你的 Hook 必须让它拿到一个合法句柄但背后数据来自内存或网络。映射规则不能硬编码需可配置。我们采用 JSON 配置 正则匹配// vfs-config.json { mappings: [ { pattern: ^D:\\\\data\\\\.*, type: memory, cache_ttl_seconds: 300 }, { pattern: ^D:\\\\backup\\\\.*, type: cloud, endpoint: https://api.example.com/v1/storage, auth_token: sk_abc123 } ] }在MyNtCreateFile中解析private VfsMapping ResolveMapping(string fullPath) { foreach (var mapping in _config.Mappings) { if (Regex.IsMatch(fullPath, mapping.Pattern, RegexOptions.IgnoreCase)) { return mapping; } } return null; // 未匹配走原生路径 }注意正则引擎必须用RegexOptions.Compiled缓存编译结果否则每次调用都重新编译I/O 延迟飙升 20ms。生产环境建议预编译所有 pattern 到Regex[]数组。3.2 内存缓存实现用 ConcurrentDictionary WeakReference 管理句柄虚拟句柄不能是真实文件句柄HANDLE而是你自己分配的整数 ID。我们用ConcurrentDictionaryint, VfsFile存储public class VfsFile { public int HandleId { get; set; } public string PhysicalPath { get; set; } public byte[] Content { get; set; } // 内存缓存内容 public DateTime LastAccess { get; set; } public bool IsDirty { get; set; } // 是否被修改需回写 } // 全局句柄池线程安全 private static readonly ConcurrentDictionaryint, VfsFile _handlePool new(); private static int _nextHandleId 1000; // 避免与系统句柄冲突 private static int AllocateHandle(VfsFile file) { int id; do { id Interlocked.Increment(ref _nextHandleId); } while (!_handlePool.TryAdd(id, file)); return id; }在MyNtCreateFile中var mapping ResolveMapping(fullPath); if (mapping ! null mapping.Type memory) { // 从内存加载或创建新文件 var content LoadFromMemoryCache(fullPath) ?? new byte[0]; var vfsFile new VfsFile { HandleId AllocateHandle(file), PhysicalPath fullPath, Content content, LastAccess DateTime.UtcNow }; FileHandle (IntPtr)vfsFile.HandleId; // 虚拟句柄 整数 ID IoStatusBlock.Status 0; IoStatusBlock.Information 0; return 0; } // 否则调用原函数 return _originalNtCreateFile(out FileHandle, DesiredAccess, ref ObjectAttributes, out IoStatusBlock, ref AllocationSize, FileAttributes, ShareAccess, CreateDisposition, CreateOptions, EaBuffer, EaLength);关键设计点句柄隔离每个目标进程有自己的_handlePool实例因为注入体在目标进程内运行不同进程的句柄 ID 不冲突。内存回收VfsFile不持有byte[]的强引用而是用WeakReferencebyte[]包装配合后台线程定期扫描LastAccess超时的项并Remove。避免内存泄漏。线程安全ConcurrentDictionary保证高并发下的句柄分配/查找无锁比lock性能高 3 倍以上实测 10k QPS 场景。3.3 实现 NtReadFile/NtWriteFile Hook对接虚拟句柄NtReadFile和NtWriteFile的 Hook 必须与NtCreateFile协同工作。它们接收HANDLE参数需根据句柄 ID 查找VfsFileprivate int MyNtReadFile( IntPtr FileHandle, IntPtr Event, IntPtr ApcRoutine, IntPtr ApcContext, out IO_STATUS_BLOCK IoStatusBlock, IntPtr Buffer, uint Length, IntPtr Key, IntPtr ByteOffset) { IoStatusBlock new IO_STATUS_BLOCK(); int handleId FileHandle.ToInt32(); if (_handlePool.TryGetValue(handleId, out var vfsFile)) { // 从 vfsFile.Content 读取数据到 Buffer uint toRead Math.Min(Length, (uint)vfsFile.Content.Length); Marshal.Copy(vfsFile.Content, 0, Buffer, (int)toRead); IoStatusBlock.Status 0; IoStatusBlock.Information toRead; vfsFile.LastAccess DateTime.UtcNow; return 0; } // 非虚拟句柄调用原函数 return _originalNtReadFile(FileHandle, Event, ApcRoutine, ApcContext, out IoStatusBlock, Buffer, Length, Key, ByteOffset); }同理MyNtWriteFile更新vfsFile.Content并标记IsDirty true。注意ByteOffset参数必须参与计算否则大文件随机读写会错乱。NtWriteFile的Length可能大于vfsFile.Content.Length需扩容Content数组。4. 避坑指南EasyHook 在虚拟文件系统场景下的 5 个致命陷阱4.1 现象注入后目标进程立即崩溃事件查看器报Application Error: faulting module EasyHook64.dll原因EasyHook64.dll 的DllMain中执行了 .NET 托管代码如AppDomain.CurrentDomain.AssemblyLoad事件注册而目标进程可能未加载 .NET Runtime如纯 Win32 应用。EasyHook v2.7.7009 的DllMain会尝试初始化 CLR但若目标进程无 .NET Framework则触发STATUS_DLL_NOT_FOUND。解决在InjectionEntryPoint的Execute方法开头强制加载 .NET Runtime// 在 Execute() 第一行添加 try { AppDomain.CurrentDomain.AssemblyResolve (s, e) Assembly.LoadFrom(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, e.Name.Split(,)[0] .dll)); } catch { /* 忽略非关键 */ }更彻底的方案编译 EasyHook 时注释掉DllMain中所有CLR相关调用仅保留原生 Hook 初始化逻辑需修改EasyHook/Source/Injection/Injection.cpp。4.2 现象Hook 成功但NtCreateFile的ObjectAttributes.ObjectName读出空字符串或乱码原因OBJECT_ATTRIBUTES结构体在不同 Windows 版本中Length字段偏移不同Windows 10 1809 为 24 字节旧版为 20 字节且ObjectName指针可能被ObOpenObjectByName等函数临时修改。解决不依赖OBJECT_ATTRIBUTES改用NtQueryInformationFile获取FileNameInformation// 在 MyNtCreateFile 中先调用原函数获取真实句柄再查询路径 int status _originalNtCreateFile(out IntPtr realHandle, ...); if (status 0) { var fileNameInfo new FILE_NAME_INFORMATION(); status _originalNtQueryInformationFile(realHandle, out ioStatus, ref fileNameInfo, sizeof(FILE_NAME_INFORMATION), FILE_INFORMATION_CLASS.FileNameInformation); if (status 0) { string path Marshal.PtrToStringUni(fileNameInfo.FileName); // path 即真实路径 } _originalNtClose(realHandle); // 关闭临时句柄 }代价是多一次系统调用但路径 100% 准确。4.3 现象多线程应用如 Chrome中同一文件被反复CreateFile/CloseHandle内存缓存频繁重建原因NtClose未被 Hook导致虚拟句柄未释放_handlePool持续增长。EasyHook 默认不 HookNtClose因它是轻量级调用但 VFS 必须管理生命周期。解决HookNtClose并清理句柄private int MyNtClose(IntPtr Handle) { int handleId Handle.ToInt32(); if (_handlePool.TryRemove(handleId, out _)) { Console.WriteLine($Closed virtual handle {handleId}); } return _originalNtClose(Handle); }4.4 现象目标进程是 .NET Core/.NET 5注入后报System.DllNotFoundException: EasyHook64.dll原因.NET Core 进程默认启用LoaderLock检测而 EasyHook64.dll 的DllMain中执行了非原子操作如InitializeCriticalSection触发 LoaderLock 异常。解决在注入前向目标进程注入一段原生代码禁用 LoaderLock 检测// 使用 VirtualAllocEx WriteProcessMemory 注入 shellcode // 执行SetErrorMode(SEM_NOGPFAULT); // 具体 shellcode 生成见 EasyHook issue #123或更简单在目标进程启动参数中添加--no-loader-lock-check仅限 .NET 6。4.5 现象虚拟文件写入后NtReadFile读不到最新内容总是旧数据原因NtWriteFileHook 中未同步更新vfsFile.Content或ByteOffset计算错误导致写入位置偏移。解决在MyNtWriteFile中严格按ByteOffset截断/扩容long offset ByteOffset IntPtr.Zero ? 0 : Marshal.ReadInt64(ByteOffset); if ((ulong)offset Length (ulong)vfsFile.Content.Length) { Array.Resize(ref vfsFile.Content, (int)(offset Length)); } Marshal.Copy(Buffer, 0, IntPtr.UnsafeAsIntPtr(vfsFile.Content), (int)Length); vfsFile.IsDirty true;5. 高级技巧支持符号链接、权限模拟与热重载配置5.1 模拟 NTFS 权限在 NtQueryInformationFile 中注入 SECURITY_DESCRIPTORWindows 应用常调用GetFileSecurity查询 ACL。你的 VFS 必须响应FileSecurityInformation类型的NtQueryInformationFileprivate int MyNtQueryInformationFile( IntPtr FileHandle, out IO_STATUS_BLOCK IoStatusBlock, IntPtr FileInformation, uint Length, FILE_INFORMATION_CLASS FileInformationClass, IntPtr Buffer) { if (FileInformationClass FILE_INFORMATION_CLASS.FileSecurityInformation) { // 构造最小化 SECURITY_DESCRIPTOR允许所有人读写 var sd new SECURITY_DESCRIPTOR(); sd.Revision 1; sd.Control 0x8004; // SE_SELF_RELATIVE | SE_DACL_PRESENT sd.Owner IntPtr.Zero; sd.Group IntPtr.Zero; sd.Sacl IntPtr.Zero; sd.Dacl Marshal.AllocHGlobal(20); // 简单 DACL允许所有访问 // ... 填充 DACL 数据略 Marshal.StructureToPtr(sd, FileInformation, false); IoStatusBlock.Status 0; IoStatusBlock.Information (uint)Marshal.SizeOfSECURITY_DESCRIPTOR(); return 0; } // 其他类型调用原函数 return _originalNtQueryInformationFile(FileHandle, out IoStatusBlock, FileInformation, Length, FileInformationClass, Buffer); }5.2 支持符号链接解析IO_REPARSE_TAG_MOUNT_POINT当应用访问D:\data\link实际是D:\real\path的 junctionNtCreateFile的ObjectAttributes会包含重解析点。你需要在 Hook 中检测并重定向private string ResolveReparsePoint(string path) { var reparseData new REPARSE_DATA_BUFFER(); if (DeviceIoControl(GetFileHandle(path), FSCTL_GET_REPARSE_POINT, IntPtr.Zero, 0, ref reparseData, sizeof(REPARSE_DATA_BUFFER), out _, IntPtr.Zero)) { if (reparseData.ReparseTag IO_REPARSE_TAG_MOUNT_POINT) { // 解析 Mount Point 路径略 return resolvedPath; } } return path; }5.3 配置热重载监听 JSON 文件变更动态更新映射规则避免重启目标进程。在 Hook Server 中启动后台任务private void StartConfigWatcher() { var watcher new FileSystemWatcher(); watcher.Path Path.GetDirectoryName(_configPath); watcher.Filter Path.GetFileName(_configPath); watcher.Changed (s, e) { try { var newConfig JsonSerializer.DeserializeVfsConfig(File.ReadAllText(_configPath)); // 原子更新 _config 字段 Interlocked.Exchange(ref _config, newConfig); Console.WriteLine(VFS config reloaded.); } catch (Exception ex) { Console.WriteLine($Reload failed: {ex}); } }; watcher.EnableRaisingEvents true; }血泪经验热重载时正在被访问的VfsFile不能立即失效需等待其LastAccess超时后才清理。否则正在读写的线程会拿到null内容。我做了三年文件虚拟化踩过所有这些坑——从 EasyHook 的 DLL 加载顺序到NtCreateFile的CreateDispositionCREATE_ALWAYSvsOPEN_ALWAYS对缓存策略的影响再到NtWriteFile的Key参数在异步 I/O 中的用途。现在我的习惯是每次上线新规则必用procmon.exe过滤Operation is CreateFile和ReadFile对照日志看路径是否被正确劫持每次怀疑内存泄漏就用dotnet-dump collect -p pid抓堆快照查ConcurrentDictionary的Node对象数量。这套方案不是银弹但它让你在不碰驱动的前提下把虚拟文件系统推进到生产环境。希望帮到你。本文还有配套的精品资源点击获取
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑