ARTICLE DETAIL

资讯详情

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

基于DLL注入与API Hook的Windows控制台危险命令实时拦截方案

基于DLL注入与API Hook的Windows控制台危险命令实时拦截方案 先说个场景。前阵子一个客户单位的机器上有人随手在控制台敲了一行rd /s /q C:\data结果整个项目目录没了。事后查记录操作记录里明明白白写着这条命令——可问题是没人知道该在哪一步拦住它。Windows 控制台程序cmd、PowerShell、跑控制台应用的脚本默认只负责把键盘输入原样交给进程它从来不校验“这条命令该不该执行”。如果你想做到“不管谁开了多少个控制台窗口只要有人输入了危险命令就当场拦下来”DLL 注入 Hook 控制台读取 API 是眼下最靠谱、最通用的一条路。这篇文章我会把这个项目从原理到代码、从注入器到过滤 DLL、从单个进程到自动监控新进程完整讲一遍适合有一定 Windows 编程基础、正在做权限管控或安全审计工具的朋友参考。1. 需求拆解与方案选型1.1 控制台输入到底是怎么流转的想要“拦输入”得先搞清楚输入走的是哪条链路。用户在控制台窗口里敲键盘时输入并不是直接到达 cmd.exe 或 powershell.exe 的。真实路径是这样的键盘硬件事件先到系统输入队列由 conhost.exe旧版或 OpenConsole.exeWindows Terminal 新版负责把按键组合成一行可编辑的文本当用户按下回车这一行经过输入缓冲区最终由目标进程调用读取 API 拿回去解析执行。进程读取控制台输入最常见的是三个入口ReadConsoleW/ReadConsoleA专门读取控制台输入缓冲区的宽字符或 ANSI 字符版本cmd.exe 和大多数原生控制台程序都走这里。ReadConsoleInputW/ReadConsoleInputA读取原始输入记录按键、鼠标等一般用于需要逐键处理的程序。ReadFile读标准输入句柄STD_INPUT_HANDLE很多跨平台运行时比如 Python 的input()、Go 程序最终会走这个 API 去拿控制台输入。理解了这条链路之后拦截点就清晰了只要能让我们的代码在这些读取函数“把数据返回给调用方”之前先看一眼内容就能决定放行还是拦截。这正好是 Hook 技术最擅长的领域——而让我们的代码进入目标进程靠的就是 DLL 注入。1.2 为什么排除命令行包装器或替换 shell 的方案可能有人会问能不能在 cmd.exe 外面套一个包装程序或者直接把默认 shell 换掉这两个思路我都试过问题都很要命。先说包装器。网上确实有工具能把 cmd 包一层先解释输入再转发。但你没法保证所有人都通过这个包装器进入命令行——程序内部CreateProcess调起一个控制台子进程、别的脚本里硬编码C:\Windows\System32\cmd.exe这些完全绕过了包装器。只要原版 cmd 还在系统里管控就是空谈。替换 shell 更极端。把默认 shell 换成自己的程序表面上所有交互式输入都会经过你但非交互场景比如批处理命令、远程执行、API 调用根本不会经过 shell而且系统组件对 shell 的路径有硬性关联替换后很容易出现权限和兼容性问题。一句话这两种方案治标不治本。真正能覆盖“任意”这两个字的只有把监控代码放进目标进程内部。DLL 注入可以在进程启动后把我们的过滤逻辑加载进去Hook 掉读取 API这样不论是哪个入口进来的输入都逃不过过滤。而且注入行为是动态的不需要改动系统文件卸载也方便。1.3 过滤点选在哪Hook 两个 API 就够用项目标题里说的是“任意 Windows 控制台命令行输入”所以过滤点不能只盯一个 API。我实际验证下来同时 HookReadConsoleW和ReadFile可以覆盖 95% 以上的命令行输入场景。ReadConsoleW负责 cmd.exe、PowerShell、以及所有标准 C 运行时控制台程序的输入。ReadFile负责读标准输入句柄的运行时和自研程序比如 Python、Node.js 的部分输入场景。ReadConsoleInput*要不要 Hook我的建议是初期不要。它返回的是INPUT_RECORD数组里面每个按键都是独立事件你需要在 Hook 内部自行拼接出完整的一行再判断“整条命令”是否该拦截处理逻辑复杂、很容易误判。而ReadConsoleW和ReadFile在行输入模式下一次返回的就是用户按回车提交的整行内容过滤判断非常自然。真遇到极少数走ReadConsoleInput的定制程序等主链路稳定后再单独加一个过滤分支也不迟。2. 关键技术准备2.1 开发环境与依赖我用的是 Visual Studio 2022配置两个工程Injector.exe注入器和InputFilter.dll过滤 DLL。两者语言均为 CDLL 工程的字符集设为 Unicode。这里有个容易踩的坑注入器必须和目标进程位数一致DLL 的位数也必须一致。也就是说要做一个 64 位注入器 64 位 DLL再做一个 32 位版本。后面我会专门讲这个兼容性话题。Hook 库我推荐 MinHook 它封装了 x86/x64 的 inline hook 细节代码干净且支持热卸载比手写jmp跳转要稳得多。MinHook 只需引入minhook.c/minhook.h和buffer、trampoline这两个依赖源文件静态编译即可不依赖外部运行时。2.2 三种注入方式对比DLL 注入不是只有一个办法但适合这个场景的其实不多。我把常见的三种列出来对比注入方式原理优点缺点适用性远程线程注入CreateRemoteThread LoadLibrary在目标进程创建远程线程让它调用 LoadLibrary 加载我们的 DLL定向注入、精确控制目标、可逐个进程注入需要相应进程权限x86/x64 不能跨位注入本项目首选灵活且可控AppInit_DLLs 注册表注入通过注册表让所有加载 user32.dll 的进程自动加载指定 DLL系统全局生效不用显式注入Win8 默认开启 Secure Boot 后失效影响面太大容易把系统搞得不稳定对纯控制台进程不加载 user32 的可能无效不推荐SetWindowsHookEx 全局钩子注入通过安装全局消息钩子让进程加载 DLL对 GUI 进程自动生效控制台进程不处理消息循环钩子很难触发不适合控制台场景远程线程注入是最合适的因为我们可以精确选择要给哪些进程注入注入动作也是可控的。底层逻辑不复杂在目标进程里分配一块内存写上 DLL 的完整路径然后创建一条远程线程线程入口指向kernel32.dll的LoadLibraryW函数。这个函数一执行我们的 DLL 就被加载到目标进程里了DllMain里的初始化逻辑随即运行。2.3 MinHook 的引入方式和 Hook 模型MinHook 的编程模型很直接先用MH_Initialize()初始化全局状态然后用MH_CreateHook把“原始函数地址”和“我们的替换函数地址”绑定绑定的时候它会生成一个 trampoline保存原始函数的前几条指令便于我们后续调用原函数。最后用MH_EnableHook激活。需要注意MH_CreateHook需要拿到原始函数的地址这可以通过GetProcAddress(GetModuleHandleW(Lkernel32.dll), ReadConsoleW)拿到。Hook 生效的时机是MH_EnableHook调用之后所以要把整个过程放在 DLL 注入完成后自动执行的初始化函数里而不是一次性在DllMain里做完——因为DllMain里做复杂操作容易引发死锁。我在DllMain里只创建一个后台线程所有 Hook 逻辑都在那个线程里完成这样最稳。3. 手把手实现注入器与过滤 DLL3.1 注入器实现注入器的工作拆成两步找进程 PID然后注入 DLL。找进程用 Toolhelp32 快照遍历进程列表匹配映像名。注入则走虚拟内存 远程线程的老路子。// injector.cpp #include windows.h #include tlhelp32.h #include string #include iostream DWORD FindProcessByName(const std::wstring name) { HANDLE hSnapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnapshot INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32W pe{}; pe.dwSize sizeof(PROCESSENTRY32W); if (Process32FirstW(hSnapshot, pe)) { do { if (_wcsicmp(pe.szExeFile, name.c_str()) 0) { CloseHandle(hSnapshot); return pe.th32ProcessID; } } while (Process32NextW(hSnapshot, pe)); } CloseHandle(hSnapshot); return 0; } BOOL InjectDllIntoProcess(DWORD pid, const std::wstring dllPath) { HANDLE hProcess OpenProcess( PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid); if (!hProcess) { std::wcerr LOpenProcess failed, error GetLastError() std::endl; return FALSE; } SIZE_T pathSize (dllPath.size() 1) * sizeof(wchar_t); LPVOID remoteBuf VirtualAllocEx(hProcess, NULL, pathSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!remoteBuf) { std::wcerr LVirtualAllocEx failed, error GetLastError() std::endl; CloseHandle(hProcess); return FALSE; } WriteProcessMemory(hProcess, remoteBuf, dllPath.c_str(), pathSize, NULL); HMODULE hKernel32 GetModuleHandleW(Lkernel32.dll); FARPROC pLoadLibraryW GetProcAddress(hKernel32, LoadLibraryW); if (!pLoadLibraryW) { std::wcerr LGetProcAddress(LoadLibraryW) failed std::endl; VirtualFreeEx(hProcess, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProcess); return FALSE; } HANDLE hRemoteThread CreateRemoteThread(hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)pLoadLibraryW, remoteBuf, 0, NULL); if (!hRemoteThread) { std::wcerr LCreateRemoteThread failed, error GetLastError() std::endl; VirtualFreeEx(hProcess, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProcess); return FALSE; } WaitForSingleObject(hRemoteThread, INFINITE); DWORD exitCode 0; GetExitCodeThread(hRemoteThread, exitCode); // LoadLibraryW 返回值是模块句柄为 0 说明加载失败 BOOL success (exitCode ! 0); CloseHandle(hRemoteThread); VirtualFreeEx(hProcess, remoteBuf, 0, MEM_RELEASE); CloseHandle(hProcess); return success; } int wmain(int argc, wchar_t* argv[]) { if (argc 3) { std::wcout LUsage: Injector.exe processName dllPath std::endl; return 1; } std::wstring targetName argv[1]; std::wstring dllPath argv[2]; DWORD pid FindProcessByName(targetName); if (!pid) { std::wcerr LProcess not found: targetName std::endl; return 2; } if (InjectDllIntoProcess(pid, dllPath)) std::wcout LInjected into targetName L (pid pid L) OK std::endl; else std::wcerr LInject failed std::endl; return 0; }几个关键点提一下CreateRemoteThread的线程函数必须声明为LPTHREAD_START_ROUTINE形式这里复用了LoadLibraryW的地址它的签名和线程函数原型恰好兼容这是经典做法。远程缓冲区要VirtualFreeEx释放否则目标进程会一直留着一块没用的内存。GetExitCodeThread拿到的是LoadLibraryW的返回值也就是模块句柄非 0 说明加载成功。3.2 过滤 DLL 的核心代码DLL 的核心是替换ReadConsoleW和ReadFile。先贴ReadConsoleW的版本。// filter_dll.cpp #include windows.h #include minhook.h #include string #include vector #include regex typedef BOOL(WINAPI* ReadConsoleW_t)( HANDLE, LPVOID, DWORD, LPDWORD, PCONSOLE_READCONSOLE_CONTROL); typedef BOOL(WINAPI* ReadFile_t)( HANDLE, LPVOID, DWORD, LPDWORD, LPOVERLAPPED); ReadConsoleW_t fpReadConsoleW nullptr; ReadFile_t fpReadFile nullptr; std::vectorstd::wregex g_regexRules; std::vectorstd::wstring g_keywordRules; BOOL IsInputBlocked(const std::wstring line) { for (auto kw : g_keywordRules) { if (line.find(kw) ! std::wstring::npos) return TRUE; } for (auto re : g_regexRules) { if (std::regex_search(line, re)) return TRUE; } return FALSE; } void ShowNotice(const std::wstring blockedCmd) { HANDLE hOut GetStdHandle(STD_OUTPUT_HANDLE); std::wstring msg L\r\n[InputFilter] blocked: blockedCmd L\r\n ; DWORD written 0; WriteConsoleW(hOut, msg.c_str(), (DWORD)msg.size(), written, NULL); } BOOL WINAPI HookedReadConsoleW( HANDLE hInput, LPVOID lpBuffer, DWORD nCharsToRead, LPDWORD lpCharsRead, PCONSOLE_READCONSOLE_CONTROL pCtrl) { BOOL ok fpReadConsoleW(hInput, lpBuffer, nCharsToRead, lpCharsRead, pCtrl); if (ok lpCharsRead *lpCharsRead 0) { WCHAR* wBuf static_castWCHAR*(lpBuffer); std::wstring line(wBuf, *lpCharsRead); while (!line.empty() (line.back() L\r || line.back() L\n)) { line.pop_back(); } if (IsInputBlocked(line)) { ZeroMemory(lpBuffer, (*lpCharsRead) * sizeof(WCHAR)); *lpCharsRead 0; ShowNotice(line); } } return ok; }这个函数的逻辑很直白先调用原始ReadConsoleW读输入拿到整行命令后做规则匹配。匹配中则清空调用方缓冲区并把读取长度置 0这样调用方拿到的就是一次“用户没输入任何内容”的结果。为了用户体验再向标准输出写一行提示告诉用户命令被拦了。ReadFile的 Hook 稍微要谨慎一点因为它不只服务控制台磁盘文件、管道、套接字都会经过它。所以进去之后第一件事就是用GetConsoleMode判断句柄是不是控制台句柄。GetConsoleMode只有对真正的 console 句柄才会返回成功对普通文件句柄直接失败这个特性正好用来分流。BOOL WINAPI HookedReadFile(HANDLE hFile, LPVOID lpBuffer, DWORD nBytesToRead, LPDWORD lpBytesRead, LPOVERLAPPED lpOverlapped) { DWORD consoleMode 0; if (!GetConsoleMode(hFile, consoleMode)) { // 不是控制台句柄直接放行尽量降低对正常文件IO的影响 return fpReadFile(hFile, lpBuffer, nBytesToRead, lpBytesRead, lpOverlapped); } BOOL ok fpReadFile(hFile, lpBuffer, nBytesToRead, lpBytesRead, lpOverlapped); if (ok lpBytesRead *lpBytesRead 0) { char* buf static_castchar*(lpBuffer); std::string line(buf, buf *lpBytesRead); // 控制台ReadFile拿到的通常是ANSI/UTF-8编码转成宽字符再匹配 std::wstring wline; int wlen MultiByteToWideChar(CP_UTF8, 0, line.c_str(), (int)line.size(), NULL, 0); if (wlen 0) { wline.resize(wlen); MultiByteToWideChar(CP_UTF8, 0, line.c_str(), (int)line.size(), wline[0], wlen); } // 去掉尾部换行 while (!wline.empty() (wline.back() L\r || wline.back() L\n)) { wline.pop_back(); } if (IsInputBlocked(wline)) { ZeroMemory(lpBuffer, *lpBytesRead); *lpBytesRead 0; ShowNotice(wline); } } return ok; }这里有个细节值得展开ReadFile读到的编码取决于控制台代码页。英文系统通常是 CP437中文系统是 CP936GBKWindows Terminal 里可能是 UTF-8。如果一律用CP_UTF8去转在 GBK 环境里非 ASCII 命令会产生乱码。稳妥做法是先用GetConsoleOutputCP()获取当前代码页再传给MultiByteToWideChar。实际项目里我是把这个代码页缓存下来的避免每次转换都查询一次。3.3 Hook 初始化与卸载DllMain里不能做太重的初始化所以我选择在里面开一个工作线程所有 Hook 逻辑都放到线程函数里。等初始化完成再用一个全局标志位告诉外部或者只用于日志调试状态。DWORD WINAPI InitWorker(LPVOID) { if (MH_Initialize() ! MH_OK) return 1; HMODULE hKernel32 GetModuleHandleW(Lkernel32.dll); auto pReadConsoleW reinterpret_castReadConsoleW_t( GetProcAddress(hKernel32, ReadConsoleW)); auto pReadFile reinterpret_castReadFile_t( GetProcAddress(hKernel32, ReadFile)); MH_CreateHook(pReadConsoleW, HookedReadConsoleW, reinterpret_castLPVOID*(fpReadConsoleW)); MH_CreateHook(pReadFile, HookedReadFile, reinterpret_castLPVOID*(fpReadFile)); MH_EnableHook(pReadConsoleW); MH_EnableHook(pReadFile); return 0; } BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID reserved) { if (reason DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); LoadRules(); HANDLE hThread CreateThread(NULL, 0, InitWorker, NULL, 0, NULL); if (hThread) CloseHandle(hThread); } else if (reason DLL_PROCESS_DETACH) { if (fpReadConsoleW) { MH_DisableHook(GetProcAddress( GetModuleHandleW(Lkernel32.dll), ReadConsoleW)); MH_DisableHook(GetProcAddress( GetModuleHandleW(Lkernel32.dll), ReadFile)); MH_Uninitialize(); } } return TRUE; }为什么要放线程而不是直接在DllMain里调 MinHook因为DllMain执行期间会持有 loader lock如果你在里面调用GetModuleHandle、GetProcAddress、VirtualAlloc之外的复杂 API很容易和进程里其他线程的死锁逻辑撞上轻则卡死重则进程崩溃。用工作线程延迟初始化是最稳的经验实测下来没有再出现过注入后进程无响应的情况。规则加载部分我单独拆了一个LoadRules函数它从 DLL 同目录下的filter_rules.txt读取规则每行一条。前 500 行的规则由“关键词”和“正则”两部分组成简单场景用关键词复杂场景用正则。void LoadRules() { wchar_t dllPath[MAX_PATH]; GetModuleFileNameW(reinterpret_castHMODULE(LoadRules), dllPath, MAX_PATH); std::wstring path(dllPath); auto pos path.rfind(L\\); if (pos ! std::wstring::npos) path path.substr(0, pos 1); path Lfilter_rules.txt; FILE* fp nullptr; if (_wfopen_s(fp, path.c_str(), Lr, ccsUTF-8) ! 0 || !fp) return; wchar_t buf[1024]; while (fgetws(buf, 1024, fp)) { std::wstring line(buf); while (!line.empty() (line.back() L\r || line.back() L\n)) { line.pop_back(); } if (line.empty() || line[0] L#) continue; if (line.rfind(LREGEX:, 0) 0) { g_regexRules.emplace_back(line.substr(6), std::regex_constants::icase); } else { g_keywordRules.push_back(line); } } fclose(fp); }规则文件示例# 关键词规则命中即拦截 format c: rd /s /q shutdown /s del /f /q c: reg delete net user administrator # 正则规则REGEX: 开头 REGEX:^del\s.*\\\*\.\* REGEX:wmic\sprocess\s.*delete注意规则匹配统一用icase忽略大小写因为命令行命令在 Windows 上不区分大小写用户可能输FORMAT C:也可能输format c:不忽略大小写等于漏了一半。3.4 自动化监控新进程并注入手动指定一个进程注入只能覆盖已存在的进程。如果目标是“任意控制台程序无论什么时候启动都默认被过滤”那就要加监控。最轻量的做法是轮询进程快照每隔几秒扫一遍发现新的目标进程就注入。void StartMonitor(const std::vectorstd::wstring targetNames, const std::wstring dllPath) { std::setDWORD injected; for (;;) { HANDLE hSnapshot CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (hSnapshot ! INVALID_HANDLE_VALUE) { PROCESSENTRY32W pe{}; pe.dwSize sizeof(PROCESSENTRY32W); if (Process32FirstW(hSnapshot, pe)) { do { for (auto name : targetNames) { if (_wcsicmp(pe.szExeFile, name.c_str()) 0 injected.find(pe.th32ProcessID) injected.end()) { if (InjectDllIntoProcess(pe.th32ProcessID, dllPath)) { injected.insert(pe.th32ProcessID); } } } } while (Process32NextW(hSnapshot, pe)); } CloseHandle(hSnapshot); } Sleep(3000); } }这套轮询方案微不足道但胜在可靠。进程启动和注入之间最多延迟 3 秒对于交互式命令行来说用户输完第一条命令的时间足够我们完成注入了。如果严格要求“进程启动的瞬间就注入”那就要上 WMI 事件订阅或 ETW 进程跟踪复杂度会明显上升我建议先用轮询实际运行效果已经很让人满意。还需要考虑一个常见需求不希望所有控制台程序被注入只想管 cmd.exe、powershell.exe、以及公司自研的某个控制台工具。我在启动监控线程前会维护一个名单名单之外的进程一律跳过避免把过滤 DLL 注入到不该碰的进程里比如某些反作弊系统或拥有自我保护机制的程序注入它反而会引发冲突。4. 实战测试与问题排查4.1 测试场景设计整个链路搭好后我建议按下面这个清单过一遍打开一个新的 cmd.exe输入脚本里配置的关键词命令比如format c:观察是否被拦截。打开 PowerShell输入同样的命令确认拦截是否同样生效。在一个用input()读命令的 Python 脚本里测试确认ReadFile的 Hook 生效。打开 Windows Terminal分别开 cmd 和 PowerShell 标签页测试确认它对新版控制台宿主同样生效。重新启动系统后直接打开 cmd确认监控程序能在进程启动后自动注入并过滤。我在测试时发现Windows Terminal 和经典 conhost 有个细微差别Windows Terminal 的进程树里真正的控制台应用cmd.exe和 UI 宿主是分开的注入到 cmd.exe 进程本身即可完成拦截不需要动 OpenConsole.exe。这个结论很重要因为很多人在 Windows Terminal 上直接找进程列表看到一堆 OpenConsole 就不知道怎么下手了。4.2 常见问题清单问题原因解决方案注入后进程崩溃DLL 初始化太慢或死锁初始化移入工作线程不在 DllMain 里做重操作注入返回失败error5权限不足以管理员身份运行注入器/监控程序UAC 必须放行32 位程序注入 64 位进程失败位数不匹配远程线程入口与目标进程位数不一致分别编译 x86 和 x64 版本的注入器 DLL按目标进程位数选择拦截规则不生效但注入成功Hook 地址不对或目标走的是 ReadConsoleInput先注入后用调试器列出模块确认 DLL 已加载确认 Hook 地址ReadFile Hook 后磁盘文件读取变慢每次读文件都走了 GetConsoleMode 正则匹配先 GetConsoleMode 快速判型非控制台句柄直接放行被杀毒软件拦截远程线程注入被安全软件视为可疑行为加入白名单或改用微软官方推荐的 SetWindowsHookEx 注入方式第一条和第四条是我实际踩过最多的。DllMain 死锁问题我在早期版本踩过一次当时直接在DllMain里调了 MinHook 的初始化一旦目标进程此刻某个线程正持有 loader lock整个进程直接卡死。后来改成工作线程初始化再也没出现过。细节补充一下过滤 DLL 被加载后要定期用“模块列表”确认它确实在目标进程里。你可以用 Process Explorer也可以用一段命令tasklist /m InputFilter.dll这条命令会列出所有加载了该 DLL 的进程能直观看出注入有没有成功。4.3 过滤绕过的边界必须诚实说没有任何用户态方案能做到绝对“不可绕过”。这套方案的定位是“防误操作 统一管控”不是“对抗恶意用户”。了解边界非常重要否则你会产生虚假的安全感。能绕过这套 Hook 的方式大致有直接调用NtReadFile这样的系统调用绕过 kernel32 层。用WriteConsoleInput伪造输入不太现实而且多数场景用不上。注入第三方程序自己实现了ConDrv设备通信不走标准读取 API。这些绕过方式需要深入了解 Windows 内部控制台架构普通操作者不会去碰。如果你的场景是需要对抗恶意人员的强安全防护那就要考虑内核态过滤或者改到 conhost.exe 内部去做钩子了那就是另一个量级的工程。现实中使用中我见过一个比较刁钻的绕过方式用户不直接在 cmd 里输命令而是把命令写进一个.bat文件然后执行这个 bat。因为 bat 的每条命令是通过 cmd.exe 内部解析后逐条执行的如果执行引擎用的是内部管道而不是 ReadConsole我们的过滤点就沾不上边。解决办法是额外 HookCreateProcessInternalW之类的进程创建 API在创建子进程时对命令行参数做校验。这个扩展我给项目加上了但初期建议先只做输入过滤把主链路跑稳。4.4 性能与稳定性调优过滤逻辑是在用户按键后、命令执行前插入的对时延非常敏感。建议做到这几点规则匹配用提前编译好的正则不用std::regex在每次输入时重新构造。关键词优先匹配命中就不再跑正则减少无谓计算。规则数量控制在 50 条以内超过之后每次输入都要遍历大量规则会有可见延迟。ReadFile的 Hook 里非控制台句柄的处理路径要极简理想情况下就是一次GetConsoleMode调用加上原函数跳转实测对文件 IO 影响可以忽略不计。还有一点如果监控程序需要开机自启建议把它注册为计划任务以最高权限运行。计划任务比启动文件夹更可靠因为用户即使调整了启动项也没那么容易把任务禁掉具体禁不禁全看你的场景需求。我目前的部署方式就是一个计划任务每 5 分钟检查一次监控进程是否存活挂了就拉起来。一点实战心得整个项目从零写到现在我最深刻的体会是DLL 注入本身不难难的是把所有边界和异常都照顾到。位数匹配、权限、Hook 时机、死锁、规则编码任何一个细节掉链子表现出来就是“注入成功了但过滤不生效”或者“进程无缘无故卡死”这类令人抓狂的问题。所以做这类工具一定要在最开始就预留好日志DLL 加载成功与否、Hook 是否启用、误拦截的命令原文都打点输出。日志是排查问题的唯一线索。最后再分享一个小技巧过滤规则文件不要写死做成热加载。我每隔 30 秒检查一次规则文件的修改时间发生变化就重新加载。这样遇到临时需要放行某条命令的情况直接编辑规则文件保存即可不需要重启被注入的进程也不需要重新注入。对于生产机器的应急响应来说这 30 秒的刷新间隔能省下大量操作时间。
返回列表