简介:这是一份基于 .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 API
2.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 至少要拦截的 API:CreateFileW、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.GetDelegateForFunctionPointer<CreateFileWDelegate>( 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_DEBUG=1 时把每次调用的原始路径、判定结果、转调目标写入日志,VFS_DEBUG=0 时静默放行。我第一次写这类钩子时靠这个开关排查出一个低级问题:规则里的 target 目录父目录没创建,CreateFileW 每次都因路径不完整失败,看起来却像 hook 完全没有生效。
进阶方向是扩展 FindFirstFileW / FindNextFileW。如果确实需要让目录枚举虚构出文件,需要重写 WIN32_FIND_DATA 里的文件属性、文件名、文件大小字段。建议先把虚拟文件数量限制在几百个以内,因为每次枚举调用都要在真实结果和虚拟结果之间合并,最容易出现重复项和错位。一个更省事的变通方案是:不伪造目录项,而是把虚拟文件作为一个真实存在的小文件放到 target 目录里,让枚举函数自然看到它,这是我在试错之后更推荐的路径。
最后记住这个方案的边界。它只能影响已注入进程的 Win32 层 API,管不了内核态文件系统请求,也管不了直接用 Nt 系统调用绕过 kernel32 的少数程序。如果有一天需求膨胀成“全系统所有进程都看到同一个虚拟磁盘”,那就去用文件系统过滤驱动或 Dokan,不要在这个 hook 方案上硬撑。我踩过最深的一个坑就是试图把路径重定向方案强行扩展成全局盘符映射,结果被各种把底层绕过搞到怀疑人生。先分清产品边界,再决定把这条路走到第几层。
希望这次的方案和踩坑记录能帮到正打算做进程级文件重定向的你。
本文还有配套的精品资源,点击获取