ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

VC++ IAT Hook拦截文件读写实现透明加密防拷贝

VC++ IAT Hook拦截文件读写实现透明加密防拷贝 简介这是一份面向VC开发者的API Hook实战工程演示如何通过截获CreateFile与CloseHandle实现对Word/DOC文件的透明加解密及防拷贝风格偏底层文件过滤与钩子机制适合具备C/C基础并对Windows内核机制感兴趣的读者。压缩包共24个文件约56KB以6个头文件和5个CPP源文件为核心搭配dsw/dsp工程文件、DLL动态库、可执行演示程序及rc/ico界面资源目录划分为Exe主程序与API_HOOK钩子模块结构清晰便于对照学习。目前已有1225人学习下载。工程内含可直接编译运行的Demo并在txt说明中指出关键坑点——调用CreateFileW时第3个参数未指定写访问权会导致旧方案失效这些细节能帮助读者快速掌握API Hook的挂载流程理解钩子DLL注入、文件句柄拦截以及如何在应用层实现文档保护是一份兼顾原理与排错经验的参考代码。1. 用 VC 做 Hook 之前先想清楚为什么要在 CreateFile 和 CloseHandle 上动手先想象一个场景Word 文档在磁盘上好好放着谁拷贝一份走都能打开。所谓防拷贝不是在 NTFS 权限上较劲也不是把文件属性设成只读就算完事而是要把“打开文件”这个动作本身拦下来。标题里的做法本质上是写一个 VC 的 DLL 注入目标进程挂钩 CreateFile 和 CloseHandle 两个 API有人在读文件时决定放行还是拒绝句柄关闭时再把磁盘上的文件加密回去让落盘的始终是不可直接阅读的密文。这个方案能解决的真实问题是合法用户在公司内部正常打开 Word 文档而文件一旦被复制到 U 盘、网盘或外部目录就是一堆无法识别的内容。要做到这一点最关键的就是卡住 CreateFile 和 CloseHandle 这两个时间点因为一个管句柄的诞生一个管句柄的消亡。适合的人群也很明确做文档安全、防数据泄露、或者自己研究 Windows Hook 机制的开发人员。下面从原理一步步落到可编译的代码。2. Hook 技术原理消息钩子、IAT Hook、Inline Hook 与 Chorus 的区别2.1 先避开最常见的误区SetWindowsHookEx 拦不住 CreateFile搜 VC Hook 时大量资料会导向 SetWindowsHookEx 和 WH_KEYBOARD_LL。这里先划一条界线SetWindowsHookEx 一族的钩子挂在消息队列和事件链路上可以在键盘消息到达窗口过程前截住但截不住 Kernel32 里的函数调用。要让 CreateFileW 和 CloseHandle 进我们的代码必须用 API 层面的 Hook而不是消息层面的 Hook。很多初次动手的人把两者混为一谈DLL 编译通过了却毫无拦截效果问题基本都出在这。提示区分消息钩子和 API 钩子直接看目标。SetWindowsHookEx 管的是 Windows 消息IAT Hook 和 Inline Hook 管的才是 Win32 API 调用。API Hook 主流的实现路线有 IAT Hook 和 Inline Hook 两种。我一般先按场景选只想拦进程内部对 Kernel32 的导入调用IAT Hook 足够要拦所有调用方包括那些通过 GetProcAddress 动态拿函数指针的模块就得用到 Inline Hook。下面分别说清楚。2.2 IAT Hook 的原理改一张导入地址表让调用先经过自己PE 文件里每个模块都有一张导入地址表也就是常说的 IAT。模块里所有来自外部 DLL 的函数调用最终都通过这张表里的“真正函数地址”间接跳转。这意味着不用改函数入口字节只要把表中 CreateFileW 那一项替换成自己函数的地址进程里所有经 IAT 发出的 CreateFileW 调用就会先进自定义代码再由我们转发给原函数。这套机制有几个特点每个 DLL 和 EXE 各有一张 IAT改哪张只影响哪个模块的调用路径。它对 GetProcAddress 动态取得的函数地址无效因为动态地址不经过 IAT。WORD 主程序 WinWord.exe 对 Kernel32 的调用绝大多数走 IAT所以实际拦截效率很高。IAT Hook 的优势在于只改一个指针不碰代码段风险可控对 VC 6 到 VS2022 的编译环境都友好。真正落地时唯一要处理的是指针宽度和内存页写保护后面代码里会逐一体现。2.3 Inline Hook 的适用场景当对方缓存了函数指针时Inline Hook 修改的是函数本体入口原理是把 CreateFile 函数头部的几个字节改成一条跳转指令让执行流进入替换函数。它能拦截到模块内任何位置的调用因为改的是 Kernel32 里的函数代码而不是某个模块的 IAT。代价也随之而来必须处理指令长度对齐、代码段写保护、以及系统更新后函数入口字节的变化。最经典的翻车是把一条多字节指令拦腰截断目标进程直接崩溃。对于 Word 防拷贝这种“少改稳跑”的需求我做技术选型时不会一上来就上 Inline Hook。2.3.1 什么情况下必须从 IAT 升级到 Inline Hook如果目标进程里存在第三方模块例如输入法、杀软、监控类 DLL它们完全可能自己通过 GetProcAddress 拿到 CreateFileW 地址复制文件动作绕开了 WinWord 的 IAT这时 IAT Hook 就会漏。想彻底接管相关调用路径要么对 CreateFileW 做 Inline Hook要么下沉到文件系统过滤驱动。对绝大多数防拷贝场景先把 IAT 覆盖做好已经能拦住 90% 的普通复制动作。2.4 Chorus 与 Hook 的本质区别旁路协同和调用接管把 Chorus 和 Hook 放在一起比较时这两者并不是同一种技术的不同版本而是两条完全不同的实现路线。Chorus 在这里指代不修改目标进程控制流的旁路协同方案外部有一个独立监控线程周期性扫描目录、比较时间戳和句柄占用在检测到文件被复制后做删除或报警。多个监测点像合唱团各声部一样按一个节拍协同动作这就是 Chorus 名字的来源。两者的差异从需求端看非常明显维度Hook调用接管Chorus旁路协同拦截方式改写 IAT 或函数入口截住 API 调用外部轮询目录和句柄不碰目标进程能否阻止复制可以在 CreateFile 阶段直接返回拒绝无法阻止只能事后删除或告警加密时机在 CloseHandle 前后精确控制依赖定时扫描存在明文窗口期对进程运行影响需处理递归和写保护配置不当会崩溃独立进程工作对 Word 影响小典型用途透明加密、打开即拦截文件审计、泄露后追溯对应到标题里的场景加密 WORD 文件和防拷贝需要的是在文件被打开前把它处理掉所以正选是 Hook。Chorus 更适合做辅助比如部署初期怕误伤正常编辑流程先用旁路方式记录文件访问日志校准进程白名单和目录过滤规则。实际项目中我常把两者叠加使用Hook 做第一道闸门Chorus 留作事后追查的证据链。3. 在 VC 工程里截获 CreateFileW 和 CloseHandle 的最小 IAT Hook 实现3.1 工程准备DLL 工程要开的编译选项从 VC 6 到 VS2022 都能做这件事核心是新建一个 Win32 DLL 工程输出一个 FileGuard.dllDllMain 里处理 DLL_PROCESS_ATTACH。编译时有三个细节会直接影响 Hook 是否生效开启 /O2 优化避免编译器把替换函数里的调用内联成直接调用导致钩子旁路。用__declspec(dllexport)导出 InstallHook 和 UninstallHook 两个函数方便加载器或注入器调用。发布时带上对应的 VC 运行库旧机器缺运行库时 DLL 根本加载不进去现象是“代码没坏但 Hook 没生效”最容易误导排错方向。3.2 定义原函数类型和替换函数转发时只调原地址先写出函数类型、全局原函数指针和替换函数。这里用 void* 保存原始地址调用时再转成具体类型可以避免在 Patch 代码里直接强制转换指针造成的类型问题。#include windows.h #include cstdio #include vector typedef HANDLE(WINAPI* CreateFileW_t)( LPCWSTR, DWORD, DWORD, LPSECURITY_ATTRIBUTES, DWORD, DWORD, HANDLE); typedef BOOL(WINAPI* CloseHandle_t)(HANDLE); void* g_pOriginalCreateFileW nullptr; void* g_pOriginalCloseHandle nullptr; HANDLE WINAPI Hook_CreateFileW( LPCWSTR lpFileName, DWORD dwDesiredAccess, DWORD dwShareMode, LPSECURITY_ATTRIBUTES lpSecurityAttributes, DWORD dwCreationDisposition, DWORD dwFlagsAndAttributes, HANDLE hTemplateFile) { if (g_pOriginalCreateFileW nullptr) return INVALID_HANDLE_VALUE; HANDLE hFile ((CreateFileW_t)g_pOriginalCreateFileW)( lpFileName, dwDesiredAccess, dwShareMode, lpSecurityAttributes, dwCreationDisposition, dwFlagsAndAttributes, hTemplateFile); if (hFile ! INVALID_HANDLE_VALUE) { // 第 4 章在这里登记文件路径和进程身份 } return hFile; } BOOL WINAPI Hook_CloseHandle(HANDLE hObject) { if (g_pOriginalCloseHandle ! nullptr) { // 第 4 章在这里做 FlushFileBuffers 和加密动作 return ((CloseHandle_t)g_pOriginalCloseHandle)(hObject); } return FALSE; }这里有一个第一次实现必踩的坑替换函数内部不能直接写 CreateFileW因为 CreateFileW 是静态导入最终也会通过被修改的 IAT 跳回 Hook_CreateFileW形成无限递归。所以必须通过 g_pOriginalCreateFileW 保存的原地址转发这就是“先保存原地址再替换 IAT”这个顺序的由来。3.3 遍历导入表替换 IAT 中的函数地址下面这段 PatchImport 是整套方案的骨架它遍历主模块的导入描述符在 Kernel32.dll 的导入表中找到指定 API 名称然后把地址替换为替换函数#include stdint.h BOOL PatchImport(const char* dllName, const char* apiName, void* hookFn, void** originalFn) { HMODULE hMod GetModuleHandleW(NULL); if (!hMod) return FALSE; PIMAGE_DOS_HEADER dos (PIMAGE_DOS_HEADER)hMod; if (dos-e_magic ! IMAGE_DOS_SIGNATURE) return FALSE; PIMAGE_NT_HEADERS nt (PIMAGE_NT_HEADERS)((BYTE*)hMod dos-e_lfanew); if (nt-Signature ! IMAGE_NT_SIGNATURE) return FALSE; IMAGE_DATA_DIRECTORY impDir nt-OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT]; if (impDir.VirtualAddress 0) return FALSE; IMAGE_IMPORT_DESCRIPTOR* desc (IMAGE_IMPORT_DESCRIPTOR*) ((BYTE*)hMod impDir.VirtualAddress); for (; desc-Name ! 0; desc) { const char* moduleName (const char*)((BYTE*)hMod desc-Name); if (_stricmp(moduleName, dllName) ! 0) continue; IMAGE_THUNK_DATA* orgThunk nullptr; ULONG_PTR* funcThunk nullptr; if (desc-OriginalFirstThunk ! 0) orgThunk (IMAGE_THUNK_DATA*)((BYTE*)hMod desc-OriginalFirstThunk); funcThunk (ULONG_PTR*)((BYTE*)hMod desc-FirstThunk); if (orgThunk nullptr) orgThunk (IMAGE_THUNK_DATA*)funcThunk; for (DWORD i 0; funcThunk[i] ! 0; i) { if (IMAGE_SNAP_BY_ORDINAL(orgThunk[i].u1.Ordinal)) continue; IMAGE_IMPORT_BY_NAME* ibn (IMAGE_IMPORT_BY_NAME*) ((BYTE*)hMod orgThunk[i].u1.AddressOfData); if (strcmp((char*)ibn-Name, apiName) ! 0) continue; DWORD oldProtect; VirtualProtect(funcThunk[i], sizeof(ULONG_PTR), PAGE_EXECUTE_READWRITE, oldProtect); *originalFn (void*)funcThunk[i]; funcThunk[i] (ULONG_PTR)hookFn; VirtualProtect(funcThunk[i], sizeof(ULONG_PTR), oldProtect, oldProtect); FlushInstructionCache(GetCurrentProcess(), funcThunk[i], sizeof(ULONG_PTR)); return TRUE; } } return FALSE; }这段代码里几个关键点的含义OriginalFirstThunk 是“按名称查找”的原始索引FirstThunk 是 loader 填充真实函数地址的表两者必须同步遍历。IMAGE_SNAP_BY_ORDINAL 用于跳过序号导入项序号导入没有名称可比较直接 continue。VirtualProtect 把导入表所在页改成可写改完立刻恢复原属性否则后续模块的写操作会异常。FlushInstructionCache 保证指令缓存里不残留旧地址。提示在 x64 工程里记住用 ULONG_PTR 保存地址DWORD 只有 32 位存不下函数指针。3.4 在 DllMain 里安装钩子顺手处理 ANSI 版本PatchImport 的调用放在 DLL_PROCESS_ATTACH 里。同时不要把 CreateFileA 漏掉低版本的外部拷贝工具可能用 ANSI API 绕过BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID lpReserved) { if (reason DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); PatchImport(KERNEL32.dll, CreateFileW, (void*)Hook_CreateFileW, g_pOriginalCreateFileW); PatchImport(KERNEL32.dll, CreateFileA, (void*)Hook_CreateFileA, g_pOriginalCreateFileA); PatchImport(KERNEL32.dll, CloseHandle, (void*)Hook_CloseHandle, g_pOriginalCloseHandle); } return TRUE; }Hook_CreateFileA 的签名只需要把 W 版本里的 LPCWSTR 换成 LPCSTR内部先用 MultiByteToWideChar 转成宽字符再复用 W 版本的过滤和登记逻辑。这样 Word 的原生调用和外部老工具都能被兜住。4. 在钩子里实现 WORD 文件透明加密与防拷贝路径过滤、临时明文目录和句柄登记4.1 过滤规则文件后缀、目录白名单、进程白名单生产级逻辑不能对“所有 .doc 文件”一视同仁。Word 会在 %TEMP% 里生成大量临时文件如果这些文件也被加密文档编辑过程中会出现莫名损坏。我一般会给 NeedGuard 加上四层过滤受保护目录只处理 C:\SafeDocs\ 前缀匹配成功的路径。文件后缀.doc、.docx、.docm注意 .docx 后缀匹配要避开 .doct 等相似名。进程白名单只有 WINWORD.EXE 可以读取明文其他进程在 Hook_CreateFileW 中直接返回 INVALID_HANDLE_VALUE并设置 ERROR_ACCESS_DENIED。访问标志dwDesiredAccess 不包含 GENERIC_READ 的打开请求不拦截例如单独修改属性的调用。这四条规则在排错时非常有价值。命中规则时把路径、进程名和访问模式写到 DbgView比在加密函数里猜“为什么这个文件没进来”高效得多。4.2 双文件切换进程内是明文磁盘上是密文透明加密最容易破坏 Word 的读写顺序。Word 打开一个 docx 并不是只调一次 CreateFile 然后读完全部内容它会频繁打开、关闭句柄做 OLE 复合文档的随机读写。直接在原句柄上“打开时解密、关闭时加密”Word 可能读到一半文件被换掉轻则乱码重则崩溃。实际项目里更稳妥的方案是双文件切换正常情况下 C:\SafeDocs\ 下只存在密文文件 report.docx.enc不存在 report.docx。别人拷走密文文件没有密钥解不开。当 Hook_CreateFileW 收到 WINWORD 读取 C:\SafeDocs\report.docx 的请求时先解密 report.docx.enc 为 %TEMP%\SafeDocsCache\report.docx再调用原始 CreateFileW 打开这个临时明文文件返回给 Word 的句柄因此指向临时文件。当这个临时文件的最后一个句柄关闭时Hook_CloseHandle 把内容加密回 C:\SafeDocs\report.docx.enc再删除临时明文。这么做规避了在已打开句柄上原地改写的风险也让文件系统缓存的头部指纹始终是安全状态。代价是 Word 的另存为对话框会显示临时目录部署时需要提示用户把另存位置同样引导到受保护目录。4.2.1 句柄到路径的映射表CreateFile 返回的只有一个 HANDLE要回查原始路径必须登记映射struct FileRecord { std::wstring tempPath; // 临时明文路径 std::wstring encPath; // 最终密文路径 int refCount; // 句柄引用计数 }; std::unordered_mapHANDLE, FileRecord g_openFiles;每次 Hook_CreateFileW 放行受保护文件后把新句柄和对应路径写入 g_openFiles。Hook_CloseHandle 关闭句柄前先查映射refCount 减到 0 时认为 Word 对该文件的读写已结束才调度加密任务。4.3 递归防护加解密函数必须绕开自己的 Hook加密落盘时进程内仍然存在我们的 Hook。如果加密函数内部直接调用 CreateFileW 打开 .enc 文件这次调用会再次进入 Hook_CreateFileW如果过滤规则不够严谨紧接着又会尝试解密、写临时文件直接形成死循环。我用两个手段消解这个问题。第一个是线程局部变量 g_inHook进入自己的加解密逻辑时置为 trueHook 函数入口先判断它为 true 就直接调用原函数。第二个是把加解密逻辑独立到一个辅助 DLL它不对外 Hook只导出 EncryptFileToDestination 和 DecryptFileToCache主 Hook DLL 调用时不经过被修改的 IAT。下面是精简后的句柄关闭处理逻辑BOOL WINAPI Hook_CloseHandle(HANDLE hObject) { BOOL ok TRUE; auto it g_openFiles.find(hObject); if (it ! g_openFiles.end()) { if (--it-second.refCount 0) { // 先保证内容真正落盘 FlushFileBuffers(hObject); // 把加密任务放进队列由工作线程延迟执行 g_pendingEncrypt.push_back(it-second); g_openFiles.erase(it); } } if (g_pOriginalCloseHandle ! nullptr) ok ((CloseHandle_t)g_pOriginalCloseHandle)(hObject); return ok; }FlushFileBuffers 是必要的否则关闭句柄时文件内容可能还在缓存里工作线程加密读到的是旧数据。放进 g_pendingEncrypt 而不是立刻加密是为了避免 CloseHandle 调用路径上堆积 IO 操作这个设计会在第 5 章具体说明参数。4.4 防拷贝的三种拦截姿势按优先级排列第一优先级是进程白名单。在 Hook_CreateFileW 里直接拒绝非白名单进程的读取请求让复制、压缩、命令行 copy 在打开文件的第一瞬间就失败。这个动作要放在调用原函数之前否则文件已经被打开再拒绝就晚了。第二优先级是句柄级共享模式。Word 自己打开文件后如果文件的 dwShareMode 允许其他进程共享读取复制工具可能通过既有句柄读内容。受保护目录内的文件建议把共享模式限制为 FILE_SHARE_READ | FILE_SHARE_DELETE在逻辑上关闭共享写的通道。第三优先级是关闭句柄后的最终校验。在加密任务执行完成后检查文件头部指纹docx 本质是 ZIP 容器明文头部应为 PK。如果检测到文件被外部进程改写成明文立即恢复密文并记录日志。这个动作有些像 Chorus 方案里的事后补救但和 Hook 配合能兜住那些绕过 IAT 的边角路径。5. 验证 Hook 是否真正生效断点、DbgView 和防止递归调用的排错手段5.1 先用 DbgView 确认 CreateFile 和 CloseHandle 都进了自己的钩子部署 DLL 后第一个验证动作不是马上看加密效果而是确认调用路径。在 Hook_CreateFileW 入口加一行 OutputDebugStringW用 DbgView 抓输出。双击 report.docx 能稳定看到多条 CreateFile 记录说明 DLL 注入成功如果一条都没有先查注入时机和过滤条件而不是去调加密算法。5.2 断点位置与调用栈看 IAT 项是否等于真正地址用 VC 调试 DLL 时把 FileGuard.dll 设为启动项目调试会话类型选可执行文件并指定 WINWORD.EXE 路径。断点打在 PatchImport 里 VirtualProtect 前后观察 funcThunk[i] 是否等于 GetProcAddress(GetModuleHandleW(LKERNEL32.dll), CreateFileW)。如果两者相等说明 IAT 项确实指向原函数不相等则说明 WinWord 可能已经从 api-ms-win-core-file-l1-2-0.dll 或其他 API Schema 模块导出了这个函数。这时不要只 patch KERNEL32.dll改为遍历所有导入模块、按函数名匹配兼容性会好很多。5.3 三个高频坑递归、句柄泄漏、明文残留递归问题靠 g_inHook 标记解决但调不出来时看不到标记。可以在替换函数入口加一个计数器连续调用超过 100 次就主动返回并在 DbgView 里打错误码比死循环后看转储高效。句柄泄漏多出在 g_openFiles 映射表。Hook_CloseHandle 里如果先 return 或抛异常原关闭逻辑没执行句柄就漏了。排错时在 DbgView 打印 GetLastError同时统计映射表容量正常情况应该是“有开必有关”表大小不会线性增长。明文残留是防拷贝方案最怕的问题。加密前删除临时明文文件时如果进程中途被杀明文稿件会留在 %TEMP%。最后的收尾手段是启动时对缓存目录做全量扫描发现任何明文 docx 立即加密归位。5.4 延迟加密的实用参数关闭句柄后隔 200ms 再落盘快速验证法是在 Hook_CloseHandle 里先调用原 CloseHandle再让工作线程延迟 200ms 执行加密。这个 200ms 不是拍脑门写的它让文件系统把最后一个引用计数彻底释放干净同时避免在同一目录下撞上 Word 尚未释放的缓存句柄。加密完成后随即删除临时明文再在 DbgView 里确认密文落盘时间整个防拷贝链路就可以收口了。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进