资讯详情

DLL注入与API Hook:实现Windows控制台命令行输入过滤与审计

📅 2026/9/30 4:51:13 | 华诺云谱 👁 阅读
DLL注入与API Hook:实现Windows控制台命令行输入过滤与审计
你可能也遇到过这种场景手上一批Windows服务器运维团队各敲各的命令偶尔一不留神就执行了危险操作。或者是你在做企业内部终端安全审计想记录每个用户在控制台窗口里到底输入过什么结果发现常规方案根本拿不到完整、带语义的命令行。我当时的解决方案就是Dll注入过滤任意Windows控制台命令行输入写一个DLL注入到目标控制台进程在API层挂上钩子把输入事件拦截、过滤、改写或记录一遍。这篇文章就围绕这个思路展开梳理我从设计到落地踩过的坑也给你一套可以直接参考的架构。坦率讲这个需求听起来简单真正做起来很碎。全局键盘钩子只能看到按键看不到程序读取输入后形成的“命令”用管道重定向stdin又覆盖不到那些直接调用控制台API的程序改源码更不现实因为目标程序可能是个古董商业软件。最后我确认了“进程内API Hook”这个路线DLL注入目标进程后在它读取控制台输入的公共API上做拦截这样无论用户敲什么、粘贴什么、输入法拼出什么最终都能在进程内部被我们看见和修改。1. 为什么控制台输入过滤是个“被低估”的难题1.1 表面简单实际到处是三层障碍很多人第一反应是记录控制台输入不就是勾一下键盘吗我最初也这么想直到发现全局键盘钩子的表现完全不行。首先是输入法问题。中文用户输入命令时经常切到拼音输入法钩子拿到的是一串组合字符状态真正上屏的文字和处理逻辑都发生在输入法进程里全局钩子很难干净地提取“用户最终确认的那条命令”。其次是复制粘贴问题。用户在控制台窗口里右键粘贴一段命令这个动作可能完全不经过我们熟悉的按键消息。第三个更麻烦并发控制台窗口。同一个系统可能同时开多个cmd、PowerShell或者第三方终端全局钩子被所有窗口共享你怎么判断这次按键属于哪个进程、哪个控制台所以在“输入过滤”这个需求上键盘钩子属于看起来能用、实际很脆弱的方案。1.2 常规替代方案为什么都差一口气我还认真评估过另外三条路各有利弊但都不完美。第一条是重定向标准输入。启动子进程时把它的stdin管道接到我们这边看起来能控制输入流。但Windows控制台程序很“鸡贼”很多程序在启动后会重新获取控制台输入缓冲区句柄然后直接调用ReadConsoleInputW读取按键根本不走你给它的stdin管道。于是命令照样进程序你只截获了一个寂寞。第二条是用Shell包装器。比如把用户登录后的Shell从cmd.exe换成我们写的代理程序启动子进程时做参数白名单。这种方式只能拦截命令行启动参数完全管不住程序跑起来之后的交互式输入——大多数该过滤的风险恰恰发生在交互阶段。第三条是直接用调试器API附加进程拦截输入函数。理论上可行但调试器附加太重性能开销大而且很多程序有简单的反调试机制。再加上一个目标进程一个调试器的限制根本不适合做常态化监控。1.3 DLL注入的核心价值在哪里DLL注入的价值在于“进入进程内部”。一旦DLL在目标进程的地址空间里运行我们就有机会在它读取输入的那一层做手脚比任何外部观察方式都更接近数据源头。控制台输入在Windows里本质是输入事件缓冲区进程通过ReadConsoleInputW等API把键盘事件取走。有一个进程内DLL就可以在这些API的入口和出口之间插入过滤逻辑看到指定的按键“回车”之前积累一条完整命令判断命令是否命中危险清单命中就把这串字符丢弃或改写让目标进程读到的是另一条命令不命中就放行同时写一条审计日志。这种方式不会依赖输入法状态不用处理窗口焦点也不怕多个控制台并发。因为目标进程自己通过什么API读输入我们就钩什么API数据在同一个上下文里流转。当然DLL注入属于比较底层的技术抛开合法用途去谈它就容易滑向恶意软件的套路。我这篇文章里的场景只限定在你拥有或已获得书面授权的系统上做运维风险控制、安全审计、企业终端管理这类事情。2. 一条命令的旅程控制台输入到底流经哪些环节2.1 从键盘敲击到ReadFile返回的完整链路Windows 10开始控制台窗口由conhost.exe负责。你按下一个键键盘驱动把中断转换成扫描码交给Windows输入系统最终到达conhost.exe的窗口消息。conhost.exe根据哪个控制台窗口拥有焦点把按键转成一条KEY_EVENT_RECORD写入对应进程的Console Input Buffer。这个缓冲区就是控制台进程读取输入数据的源头。目标进程启动后它的C运行时库会通过GetStdHandle(STD_INPUT_HANDLE)拿到输入句柄然后根据程序代码逻辑选择下面某条路读取数据调用ReadConsoleInputW或ReadConsoleInputA读取原始输入事件记录结构体是INPUT_RECORD调用ReadConsoleW/ReadConsoleA读取按行处理的文本更底层地直接调用ReadFile从给定句柄读取字节流比如scanf、getchar这类函数最终会走这条路径。命令行程序一般会把输入缓冲区的字符慢慢拼成一行直到收到回车键才把这一行当作命令去解析执行。所以我们过滤的核心目标就是在这几个API里对“输入的字符事件”或“读到的文本数据”做统计、清洗和改写。2.2 过滤应该放在哪一层ConHost内部还是进程内部你可能会想既然数据都在conhost.exe里直接注入conhost.exe不是最底层吗理论上能拿到所有控制台进程的输入但我劝你不要这么做。conhost.exe是系统关键进程多个控制台进程共享一个实例。在你动它之前它还要处理鼠标事件、窗口大小变化、菜单事件等等过滤逻辑一旦出bug可能影响整个桌面的控制台操作。更严重的是微软对conhost.exe的保护越来越强注入进去的稳定性和兼容性都是大坑。进程内部的API Hook就温和很多。目标进程被注入DLLDLL只影响该进程自己的输入读取流程。即使Hook函数挂掉顶多这一个进程崩溃不会拖累系统。从权限上讲只要进程以相同或更低权限运行我们能拿到该进程的句柄就足够了。所以哪怕是在“任意控制台命令行输入”这个题目下我也坚持做进程内Hook。2.3 为什么进程内API层最实用API层是用户态和内核态的交界处。在API层做过滤既能拿到结构化的INPUT_RECORD又能拿到最终的文本流同时还能控制返回值。你要的功能无论是要“拦一条命令”还是“把命令A替换成命令B”又或者是“只记录命令不去干预”都能在这一层实现。还有一点很关键不同程序对输入API的偏好相对固定。尽管Console API很多但真正会被普通命令行程序用到的只有两三个函数。Hook三个函数基本能覆盖绝大多数场景维护成本可控。我后续方案里的核心工作其实就是确认目标进程用了哪个API然后把对应函数挂钩子再在钩子函数里做规则处理。3. 实战方案注入器DLLAPI Hook的三层架构3.1 注入器怎么写经典的LoadLibrary远程线程注入先把架构拆成三层注入器负责把DLL塞进目标进程DLL负责在进程里安装HookHook函数执行过滤规则。三者职责分离调试时也更容易定位问题。注入器最稳定的入门方案是CreateRemoteThread加LoadLibrary。步骤如下用CreateToolhelp32Snapshot遍历进程找到目标进程PID。OpenProcess打开目标进程申请需要的权限。VirtualAllocEx在目标进程里分配一块内存写入DLL的绝对路径字符串。取当前进程kernel32.dll里LoadLibraryW的地址用CreateRemoteThread让目标进程执行这个函数。给一段核心代码我精简掉了错误处理方便看逻辑#include windows.h #include tlhelp32.h #include iostream DWORD FindProcessId(const wchar_t* name) { HANDLE snap CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); if (snap INVALID_HANDLE_VALUE) return 0; PROCESSENTRY32W pe { sizeof(pe) }; DWORD pid 0; if (Process32FirstW(snap, pe)) { do { if (_wcsicmp(pe.szExeFile, name) 0) { pid pe.th32ProcessID; break; } } while (Process32NextW(snap, pe)); } CloseHandle(snap); return pid; } bool InjectDll(DWORD pid, const wchar_t* dllPath) { HANDLE hProcess OpenProcess( PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION | PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ, FALSE, pid); if (!hProcess) { std::cerr OpenProcess failed: GetLastError() std::endl; return false; } size_t pathBytes (wcslen(dllPath) 1) * sizeof(wchar_t); void* remoteMem VirtualAllocEx(hProcess, nullptr, pathBytes, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); if (!remoteMem) { CloseHandle(hProcess); return false; } if (!WriteProcessMemory(hProcess, remoteMem, dllPath, pathBytes, nullptr)) { VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); CloseHandle(hProcess); return false; } HMODULE kernel32 GetModuleHandleW(Lkernel32.dll); FARPROC loadLibraryW GetProcAddress(kernel32, LoadLibraryW); HANDLE hThread CreateRemoteThread(hProcess, nullptr, 0, (LPTHREAD_START_ROUTINE)loadLibraryW, remoteMem, 0, nullptr); if (!hThread) { VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); CloseHandle(hProcess); return false; } WaitForSingleObject(hThread, 5000); CloseHandle(hThread); VirtualFreeEx(hProcess, remoteMem, 0, MEM_RELEASE); CloseHandle(hProcess); return true; } int wmain() { DWORD pid FindProcessId(Lcmd.exe); if (!pid) { std::wcerr Lcmd.exe not found std::endl; return 1; } if (InjectDll(pid, LC:\\Tools\\ConsoleFilter.dll)) { std::wcout LInjected successfully std::endl; } return 0; }这段代码只负责“把DLL送进去”真正的逻辑在DLL里。注意OpenProcess的权限要按最小必要来给不要一上来就PROCESS_ALL_ACCESS有些高版本Windows对全部权限有额外限制尽量精确指定。3.2 被注入DLL的入口点设计DLL入口点必须克制。DllMain里做太多事情很容易触发Loader Lock死锁。正确做法是在PROCESS_ATTACH事件里立刻创建一个新线程把Hook初始化、API地址解析、规则装载全部丢到后台线程执行DllMain马上返回。示例BOOL APIENTRY DllMain(HMODULE module, DWORD reason, LPVOID reserved) { if (reason DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(module); HANDLE hThread CreateThread(nullptr, 0, HookInitThread, nullptr, 0, nullptr); if (hThread) CloseHandle(hThread); } return TRUE; }为什么不直接在DllMain挂钩子因为DetourAttach这类Hook操作在一部分版本上会因为进程内其他线程同时加载DLL而卡死。把Hook放在独立线程里等操作系统把进程初始化流程跑稳了再安装成功率和稳定性明显上升。3.3 Hook哪些API看场景取舍控制台输入相关的API不少但不能全钩也用不着全钩。我一般根据目标程序的行为来选API典型调用者数据形态适合过滤方式ReadConsoleInputW交互式命令行工具、游戏控制台INPUT_RECORD带按键编码和Unicode字符能拦按键、能改字符适合做精细规则ReadFile用C标准库scanf/gets、大量脚本解释器原始文本字节流直接在流上匹配、替换ReadConsoleW老式控制台程序文本行场景不如前两者多ReadConsoleA需要ANSI代码页的兼容程序按当前代码页编码的文本一般转成Unicode后再过滤这里特别提一句很多程序员以为ReadFile只负责文件读写其实控制台句柄也能用ReadFile读取。你用C语言写一个gets()编译后底层很可能是ReadFile(handle_to_stdin, ...)。所以做这类项目前最稳的做法是先用一个诊断DLL空跑记录目标进程到底调用了哪些读取输入函数再决定最终要Hook哪个。3.4 过滤规则示例拦截、替换、审计三种模式DLL里维护一个简单的规则引擎至少支持三种模式。拦截模式适合处理明确的危险命令。比如发现命令里出现format、rd /s /q、shutdown这些危险片段就在回车前把命令内容全部吞掉。在钩子函数里你需要自己维护一个“当前输入行缓冲区”。每当收到KEY_EVENT_RECORD的字符事件就累加遇到VK_RETURN时把累积的行拿去匹配规则。替换模式更灵活适合把不可执行的命令改成可执行的。比如用户在旧系统里习惯性敲ipconfig你想让他执行ipconfig /all可以把字符串替换后原样放回输入缓冲区。实现时要注意输入法带来的Unicode代理对建议全部按wchar_t处理不要随便截断。审计模式最简单也最实用只记录数据不干预数据。记录时要把INPUT_RECORD里所有字符拼成行再带上进程ID、线程ID、时间戳写文件或者走共享内存。下面是一个简化版的Hook函数示意BOOL WINAPI HookedReadConsoleInputW( HANDLE hConsoleInput, PINPUT_RECORD lpBuffer, DWORD nLength, LPDWORD lpNumEventsRead) { BOOL ok RealReadConsoleInputW(hConsoleInput, lpBuffer, nLength, lpNumEventsRead); if (!ok || !lpNumEventsRead || *lpNumEventsRead 0) { return ok; } for (DWORD i 0; i *lpNumEventsRead; i) { if (lpBuffer[i].EventType ! KEY_EVENT) continue; if (!lpBuffer[i].Event.KeyEvent.bKeyDown) continue; wchar_t ch lpBuffer[i].Event.KeyEvent.uChar.UnicodeChar; if (ch L\r) { // 检查累积的命令行缓冲区 g_commandLine if (IsBlockedCommand(g_commandLine)) { // 把这次回车吞掉 *lpNumEventsRead 0; ClearCommandLine(); return ok; } // 否则记录并清空缓冲区 LogCommandLine(g_commandLine); ClearCommandLine(); } else if (ch 32) { g_commandLine ch; } } return ok; }这只是演示逻辑真正工程上要加锁、加超长行边界、处理退格键、方向键和历史记录。如果只在最后回车时过滤忽略退格键就会出现“用户想敲shutdown删了一半缓冲区里还存着完整命令”的错判。所以过滤脚本必须同步处理VK_BACK删除缓冲区末尾字符。4. 我踩过的坑稳定性和兼容性细节4.1 32位/64位位数错位注入静默失败这份项目我第一版只编译了32位注入器结果在64位Windows上注入64位的cmd.exe看起来注入成功了DLL一点反应没有。排查半天才想到GetProcAddress(GetModuleHandleW(Lkernel32.dll), LoadLibraryW)拿到的地址是32位版本的把这个地址硬塞给64位进程的CreateRemoteThread系统不会直接报错但线程启动后根本执行不了。解决方案很直白编译两个版本的注入器和DLL一个x86一个x64。注入前用IsWow64Process判断目标进程是原生64位还是32位再选择对应版本的注入器和DLL。这个话题值得刻在脑门上凡是用远程线程方式注入位数不匹配是头号坑。4.2 高权限进程拒绝对话OpenProcess打不开有些时候目标进程明明存在但注入器OpenProcess返回失败。原因通常是权限不足。如果被注入的cmd.exe是以管理员权限运行的注入器也必须以管理员权限运行尤其在UAC开启的机器上普通权限进程无法打开高完整性级别进程的句柄。我做测试时习惯把注入器用“以管理员身份运行”启动。但是在交付给企业终端安全产品时会改用计划任务配合SYSTEM权限运行服务再由服务进程注入到目标进程。这里我没有去讨论绕过杀软或者强提权因为合规的监控工具不需要绕直接以系统权限做管理动作即可。4.3 Hook太早程序还没创建控制台窗口还有一个坑是注入时机。有些程序启动后先做初始化、申请内存过几百毫秒才真正绑定控制台窗口。如果你一检测到进程存在就立刻注入DLL虽然加载成功但Hook的API函数还没有被目标进程调用看起来没事问题是目标程序可能先调用了GetStdHandle拿到一个句柄然后把它缓存起来。等实际输入时它发现输入句柄还是旧的而控制台状态已经变了就会出现输入失效。最实用的办法是注入前做一次“控制台窗口可见性”判断或者固定等待一小段时间再注入。比如针对cmd.exe注入器可以先调用EnumWindows找目标进程的主窗口确认IsWindowVisible为真后再注入。这比盲目Sleep更可靠。4.4 钩子函数抛异常整条进程陪葬Hook函数是运行在目标进程里的一旦内存越界、空指针解引用、或者调用被卸载的模块整个进程会直接崩溃。生产环境里这是不可接受的。我后来给每个Hook函数都裹了一层__try/__exceptBOOL WINAPI HookedReadConsoleInputW(...) { __try { return HookHandler(...); } __except(EXCEPTION_EXECUTE_HANDLER) { // 出错时放行原始调用保证目标进程不死 return RealReadConsoleInputW(...); } }这个“出错就放行原始调用”的设计虽然意味着过滤规则会偶尔失效但至少不会让目标进程崩溃。对于安全审计产品来说过滤功能失效可以被感知并告警但进程崩溃会造成业务中断两者优先级完全不同。4.5 热卸载DLL要谨慎如果DLL里有Hook函数正在执行你一调用FreeLibrary把它卸载那瞬间执行流还在DLL代码里DLL被释放后代码段直接变成非法内存进程必崩。每次做DLL热更新之前我都得设计一个“禁止再进入Hook等待当前调用结束”的机制用引用计数或者手写读写锁。但没有必要的话就别做热卸载。目标进程退出时DLL会随系统自然卸载根本不需要我们操心。5. 这个方案能走多远替代方案与适用范围对比5.1 几种方案的横向对比我给自己做过一张评估表执行完一遍之后对什么时候选什么方案就更有谱了方案实现难度稳定性覆盖范围性能开销部署要求全局键盘钩子低一般只能键盘按键多窗口难归属极低无需管理员但输入法/粘贴问题多标准输入重定向低中只覆盖老老实实读stdin的程序低需要创建子进程DLL注入API Hook中高中高覆盖绝大多数Console API读取场景低管理员权限、DLL与目标进程位数匹配驱动层键盘过滤高高全局但控制台语义还原困难中驱动签名、系统版本兼容、开发周期长我的结论是如果只做简单按键记录全局钩子够用如果目标程序是你自己控制的直接改代码如果要处理任意第三方控制台程序且要求看到结构化输入事件DLL注入API Hook是性价比最高的选择。5.2 什么时候应该直接用原生控制台APIDLL注入本质上是一种“外挂式”方案它总会有兼容性风险。如果程序是自己在维护完全不需要注入。你要做的只是在主循环读取输入之后加一个函数调用比如FilterConsoleInput(events, count)内部做同样的危险命令匹配和审计。原生API的性能最好也没有位数错位、进程崩溃的问题。但现实是很多遗留业务工具只有编译后的exe没有源码改不了这时候DLL注入才有不可替代的价值。比如企业里某个老旧的数据库管理工具运维人员经常在里面输入DROP TABLE你没法改它只能在它前面加一层“输入保险”这就是DLL注入发挥威力的典型场景。5.3 完整性保护环境下的处理思路有些进程带强完整性保护普通注入会被Windows拒绝。做项目规划时就要评估这个目标进程是否允许注入如果不允许就不要用注入方案。退而求其次的做法是终端安全代理从进程创建早期介入或者在用户会话初始化阶段抢先挂上钩子。这里我不会展开讨论如何绕过进程保护因为绕过系统完整性机制属于攻防对抗领域不是安全工具的正常用法。企业终端安全产品通常有自己的驱动签名和系统接口不需要走旁门左道。5.4 测试骨架用cmd.exe当靶子最后说说怎么验证这套东西真的可用。我的测试靶子永远是cmd.exe因为它启动快、控制台行为标准、容易观察。第一轮测试只做审计不拦任何命令。注入后打开一个cmd窗口输入几条命令检查日志文件里是否完整记录到了echo hello、dir、cd这些指令。如果echo hello没有记录很可能是这个版本的cmd.exe读取输入不走ReadConsoleInputW而是走了ReadFile那就要补挂钩子。第二轮测试拦截规则。在过滤规则里把del /f设为危险片段然后在cmd里尝试输入del /f test.txt观察目标程序是否收不到这条命令。如果命令仍然执行说明拦截时机太晚没有在回车事件处理前吞掉缓冲区需要把规则判断提前到累积字符的同时做。第三轮测试连续高强度输入。写个脚本自动打开20次cmd.exe每次随机拼命令持续几个小时监控目标进程有没有崩溃、内存增长是否异常。如果发现内存涨得很凶就要注意Hook函数里的缓冲区是否有上限。6. 最后再分享一点我自己的体会从有这个想法到把DLL注入过滤控制台输入跑通我大概花了两个完整工作日后面又用了一周时间处理稳定性问题。真正总结下来核心不是注入器怎么写而是“在目标进程的生命周期里选一个合适的时机用最少的Hook覆盖最关键的输入链路”。如果你要把这套东西用在工作里我建议先把这个项目拆成两个独立模块一个是只做审计的被动版本另一个是带拦截和替换的主动版本。审计版本上线跑几天把真实命令分布统计出来再针对Top命令设计过滤规则比一开始就拍脑袋列危险关键词靠谱得多。这套方案的边界也别忘了它只能覆盖通过Windows控制台API读取输入的程序。你要是遇到一个程序直接读/dev/input级别的物理键盘设备或者自己画UI捕获键盘那它就超出了“控制台命令行输入”的范畴别指望DLL注入能照顾到。做工程最大的忌讳之一就是把一个技术方案硬幻想成万能的银弹。最后还是要认认真真提一句边界DLL注入、API Hook这些技术有很强的双面性。包括CreateRemoteThread、SetWindowsHookEx、MiniHook在内这些工具用在授权维护、企业安全审计、个人实验环境里是正当的。但如果把它用到别人机器上做恶意监控、盗取信息那性质就完全变了。技术文档写再多也要守住这条底线。
📝

华诺云谱内容团队

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

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

你可能需要的服务

订阅华诺云谱资讯周报

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

↑