简介:《LSP注入(原理及其实现代码)》是一份面向Windows底层开发、COM组件编程与安全技术学习者的技术资料,围绕LSP注入的工作机制与代码实现展开,适合需要理解系统钩子、文件处理拦截及COM接口扩展的中高级读者。压缩包共38个文件、约150KB,包含15个cpp与7个h源码文件,以及vcxproj、sln工程文件、bat注册与卸载脚本、dll和exe编译产物、def导出定义及txt说明等,基本覆盖从源码组织到工程构建、组件注册的完整环节。已有1583人学习,说明该主题在相关技术社区具有一定关注度。读者可结合源码梳理LSP组件的创建、注册、注入与请求拦截流程,重点查看IDispatch、IUnknown、ILanguageService等接口实现,以及SetWindowLong、GetWindowLong等注入相关API的调用方式,从而搭建可编译的实验工程,对照原理复现关键步骤,并补充对注册表配置和卸载处理的理解。
1. 从一次网络异常排查说起:LSP 注入到底在做什么
去年在一台测试机上查一个网络异常,netsh winsock show catalog 敲下去,协议链里多出一条没见过的条目,位置还排在 mswsock 前面。那台机器每一次 socket 创建都要先过它,可进程列表干干净净,看不到任何可疑进程。这就是 lsp 注入最典型的表现:代码不在任何可见进程里,却能在每一次 connect 之前先跑一遍。
这篇从 SPI 的分层结构讲起,拆开三个问题:系统为什么愿意主动把这个 DLL 加载进目标进程、最小可用的实现代码长什么样、真出问题的时候怎么把它从协议目录里揪出来。写给想亲手复现一遍原理的安全研究者和被这类问题坑过的运维,环境建议放在自己可控的虚拟机里,别拿生产机试。
2. Winsock SPI 的骨架:LSP 究竟挂在哪一层
2.1 从 ws2_32 到服务提供者的调用链
Windows 上的网络调用有个特点:应用程序写 socket()、connect()、send(),但真正编译进二进制的是对 ws2_32.dll 的导入。ws2_32 自己不碰网卡,它更像一个分发器,手里握着一张“协议目录”,按协议类型把调用转发给具体的服务提供程序 DLL。真正干活的是 mswsock.dll,再往下走到 afd.sys 和内核。
这条链有两级。上面一级是应用和 ws2_32 之间,下面一级是 ws2_32 和服务提供程序之间。微软给下面这一级定了一套标准接口叫 SPI,规定了每个服务提供程序 DLL 必须导出什么函数、函数表怎么填、上层怎么调用它。SPI 的设计初衷是让第三方可以扩展协议栈,比如做加密传输或者协议转换,这个“可扩展”就是全部立足点。
关键在于,ws2_32 决定调用哪个 DLL,依据是注册表里的协议目录,不是代码里写死的。只要在目录里插一条自己的条目,并且让它排在基础提供程序前面,那么凡是走这个协议的进程,ws2_32 都会先加载你的 DLL、调你的入口函数。这个过程完全由系统完成,不需要 CreateRemoteThread,不需要写进程内存,也不需要 ApcInject。
所以 lsp 注入的本质是“改配置 + 写一个合规的 DLL”,注入动作由系统自己执行。这也是它和内存注入类手法最根本的区别。
2.2 分层服务提供程序在协议目录里的位置
协议目录落在注册表里,64 位系统上分成两套,32 位进程看的是 WOW6432Node 下的那份。路径大致是:
HKLM\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries\000000000001 HKLM\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries\000000000002 ...每个条目对应一个 WSAPROTOCOL_INFOW 结构,里面几个字段最要紧:szProtocol 是显示名,ProviderId 是 GUID,dwCatalogEntryId 是目录内唯一编号,ProtocolChain 是链式结构。ProtocolChain.ChainLen 等于 1 表示这是基础提供程序,直接对内核;大于 1 表示这是分层提供程序,要沿着 ChainEntries 往下走。
ChainEntries 是个数组。ChainLen 为 2 时,ChainEntries[0] 存本层的 catalog id,ChainEntries[1] 存下一层基础提供程序的 catalog id。ChainLen 为 3 时就是三层叠加。ws2_32 加载时按数组顺序依次调每个 DLL 的 WSPStartup,每一层拿到的 lpProcTable 都由自己填写,然后交给下一层去填真正的实现。上层 DLL 只要把不需要拦截的项原样指向下层函数表,整条链就通了。
在命令行里可以直接看这张目录:
netsh winsock show catalog输出里能看到每条协议的名称、描述和路径。如果某条目的 ChainLen 显示为 2 而名字你没见过,那就是有分层提供程序挂上去了。这是排查时最快的一眼。
有一个容易被忽略的细节:32 位进程和 64 位进程读的是不同的目录,加载的也是不同的 DLL。一个只编了 32 位版本的分层提供程序,装上去之后只有 32 位程序受影响,64 位程序完全无感。反过来也一样。这一点在后面讲避坑的时候还会再提一次。
2.3 和 IAT Hook、AppInit_DLLs 的差别
同样是把代码塞进别人的进程,这几条路的手感完全不一样。把关键维度摊开对比,选哪条路基本就有答案了。
| 维度 | LSP 注入 | IAT Hook | AppInit_DLLs |
|---|---|---|---|
| 触发时机 | 进程首次调用 Winsock 时 | 代码执行到被改写的调用点时 | user32.dll 被加载时 |
| 作用范围 | 所有使用 Winsock 的进程 | 单个进程的单个函数 | 所有加载 user32 的进程 |
| 持久化 | 写注册表,重启后依然生效 | 进程退出即失效 | 写注册表,重启后依然生效 |
| 所需权限 | 管理员(写 HKLM) | 取决于目标进程权限 | 管理员 |
| 卸载成本 | WSCRemoveProvider + 重启 | 重启目标进程 | 删注册表 + 重启 |
| 可见性 | netsh catalog 直接列出 | 需要比对 IAT 表 | 注册表两个键可查 |
从这张表能看出取舍。IAT Hook 最轻,改完立即生效,但目标进程一重启就没了,而且每个要影响的可执行文件都得单独处理。AppInit_DLLs 覆盖面广,可它依赖 user32,纯服务进程根本不加载 user32,而且新版本系统对这个键的限制越来越严,光靠它做实验经常连加载都看不到。
LSP 的位置比较特殊:它站在 ws2_32 和基础提供程序中间,任何调用 Winsock 的进程都绕不开,包括以 SYSTEM 权限跑的服务。代价是必须写注册表、必须重启相关进程、必须保证 DLL 不出错——一旦出错,整台机器的网络会一起出问题。做实验的时候这点要有心理准备。
3. 注入路径拆解:三个动作把 DLL 挂进协议链
3.1 第一步:把 DLL 编译成合格的分层服务提供程序
一个能被 ws2_32 认下来的分层提供程序 DLL,硬性要求只有一个:导出名为 WSPStartup 的函数,签名固定。ws2_32 加载 DLL 之后就是靠 GetProcAddress 找这个名字,找不到就认为这个提供程序坏了,直接报 WSAEPROVIDERFAILEDINIT。
签名长这样:
int WSPAPI WSPStartup( WORD wVersionRequested, // 上层请求的 SPI 版本,一般是 2.2 LPWSPDATA lpWSPData, // 回填本层支持的版本和实现信息 LPWSAPROTOCOL_INFOW lpProtocolInfo, // 本层在协议目录里的完整条目 WSPUPCALLTABLE UpcallTable, // 上层回调表,转发时要原样传下去 LPWSPPROC_TABLE lpProcTable); // 最重要的一项:本层的函数表,由本层填除了 WSPStartup,DLL 里最好还导出 WSPNotify 用于处理目录变更通知,但不是必需。真正提供给上层的 30 多个 WSP* 函数,全部通过 lpProcTable 这张表暴露,不需要逐个导出。这一点和很多人想象的不一样,也是新手第一次写 LSP 最容易搞混的地方。
DllMain 里不要做任何重活。这个 DLL 会被加载进几十上百个进程,包括一些启动很早的系统服务,如果在 DllMain 里去初始化 Winsock、读文件、等锁,很容易造成加载顺序死锁,表现是开机卡在登录界面。稳妥做法是 DllMain 只返回 TRUE,所有初始化推迟到 WSPStartup 里做。
3.2 第二步:用 WSCInstallProvider 写进协议目录
目录不能手改注册表,微软提供了专门的 API,内部会做一致性校验和计数维护。手改的后果是 Winsock 目录被标记为损坏,之后所有网络调用直接失败,只能靠 netsh winsock reset 救回来。
安装的核心是构造一个 WSAPROTOCOL_INFOW。最省事的做法是先枚举出 TCP/IP 的基础条目当模板,把其中的字段搬过来,再改几个关键项:
// 从协议目录里取一条基础 TCP/IP 条目当模板 WSAPROTOCOL_INFOW info = {}; info.dwServiceFlags1 = XP1_IFS_HANDLES | XP1_CONNECTIONLESS | XP1_MESSAGE_ORIENTED; info.dwServiceFlags2 = 0; info.iAddressFamily = AF_INET; info.iSocketType = SOCK_STREAM; info.iProtocol = IPPROTO_TCP; info.iProtocolMaxOffset = 0; // 关键:声明这是两层链,第 0 位放自己(由系统回填),第 1 位放基础提供程序 info.ProtocolChain.ChainLen = 2; info.ProtocolChain.ChainEntries[0] = 0; // 占位,安装成功后系统会写回 info.ProtocolChain.ChainEntries[1] = dwBaseId; // 基础提供程序的 catalog id wcscpy_s(info.szProtocol, L"ResearchLSP"); // netsh 里显示的名字 INT iErr = 0; if (SOCKET_ERROR == ::WSCInstallProvider( &ProviderGuid, // 自己生成的 GUID,卸载时要靠它 szLspDllPath, // 本 DLL 的完整路径,必须绝对路径 &info, 1, // 条目数量 &iErr)) { // 常见失败:权限不足(非管理员)、路径不可访问、GUID 冲突 wprintf(L"install failed, errno=%d\n", iErr); }参数里最容易出错的是 szLspDllPath。这里必须写绝对路径,而且不能用环境变量,因为加载它的可能是 SYSTEM 权限的服务,工作目录和用户态完全不是一回事。另外 dwBaseId 要事先用 WSCEnumProtocols 枚举出来,找 szProtocol 为 “MSAFD Tcpip [TCP/IP]” 且 ChainLen 为 1 的那条。
安装成功后,WSCInstallProvider 会返回新的 catalog id,并自动把 ChainEntries[0] 填上这个 id。这时目录里就多了一条 ChainLen 为 2 的记录,但顺序排在末尾,暂时还轮不到它被调用。
3.3 第三步:用 WSCWriteProviderOrder 控制加载位置
协议目录是有顺序的。ws2_32 创建套接字时,会从目录头部开始找第一个匹配的条目。新装的条目默认排在后面,所以装完不调顺序,等于白装。
WSCWriteProviderOrder 就是干这个的,它接受一个 catalog id 数组,按数组顺序重排整个目录。实际操作不是只把自己挪到第一,而是要把完整的 id 列表按新的顺序整体传进去——先枚举全量目录,把自己的 id 插到目标位置,其余保持原有相对次序,然后整体提交。
// 先枚举全量目录,拿到所有 catalog id DWORD ids[MAX_PROTOCOLS] = {0}; DWORD dwCount = 0; EnumerateAllCatalogIds(ids, &dwCount); // 内部调 WSCEnumProtocols // 把自己插到基础提供程序前面 DWORD dwNewOrder[MAX_PROTOCOLS] = {0}; DWORD n = 0; for (DWORD i = 0; i < dwCount; ++i) { if (ids[i] == dwMyId) continue; // 先跳过自己 if (ids[i] == dwBaseId) { dwNewOrder[n++] = dwMyId; // 在基础提供程序之前插入 } dwNewOrder[n++] = ids[i]; } INT iErr = 0; if (SOCKET_ERROR == ::WSCWriteProviderOrder(dwNewOrder, n, &iErr)) wprintf(L"reorder failed, errno=%d\n", iErr);顺序调完之后,新建的套接字才会走新链路。已经存在的进程不受影响,它们手里握着旧的函数表。所以验证的时候一定要开新进程,最好是重启一次,别拿当前命令行窗口直接测——这一点非常容易造成“装了没反应”的错觉。
3.4 验证:netsh 与 WSCEnumProtocols 双确认
调完顺序,先看目录:
netsh winsock show catalog | findstr /i "ResearchLSP"能看到自己的协议名,并且描述里显示 ChainLen 为 2,说明注册成功。再确认顺序,看自己是不是排在 MSAFD 前面。目录顺序在 netsh 输出里是从上到下依次排列的,位置靠前就是先被调用。
光看目录不够,还要确认 DLL 真被加载了。开一个新的 cmd 窗口,随便 ping 一个地址或者 telnet 一个端口,然后在进程里看模块列表。用 Process Explorer 之类的工具看目标进程的模块,能找到自己的 DLL 就说明链路生效了。如果目录里有、模块里没有,多半是顺序没调对,或者这个进程在安装之前就已经初始化过 Winsock 了。
4. 关键代码:一个最小可用 LSP 的骨架实现
4.1 导出函数与 WSPStartup 的入口逻辑
WSPStartup 干三件事:保存上层传来的回调表、找到下一层提供程序并调它的 WSPStartup、用自己的实现覆盖函数表里的若干项。顺序不能乱,因为覆盖之前函数表必须已经被下一层填满。
// 全局保存:下一层的函数表、上层回调表、下一层 DLL 句柄 static WSPPROC_TABLE g_NextProcTable; static WSPUPCALLTABLE g_UpcallTable; static HMODULE g_hNextDll = nullptr; static LONG g_lInitOnce = 0; extern "C" __declspec(dllexport) int WSPAPI WSPStartup( WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { g_UpcallTable = UpcallTable; // 本层必须在链中间,ChainLen 为 1 说明配置错了,直接失败 if (lpProtocolInfo->ProtocolChain.ChainLen <= 1) return WSAEPROVIDERFAILEDINIT; // 加载下一层并拿到它的函数表;失败就整体失败,不能返回半成品 if (!LoadNextProvider(lpProtocolInfo, &g_NextProcTable)) return WSAEPROVIDERFAILEDINIT; // 先把下层的表整体拷过来,再覆盖需要拦截的项 *lpProcTable = g_NextProcTable; lpProcTable->lpWSPConnect = MyWSPConnect; lpProcTable->lpWSPSend = MyWSPSend; // 回填版本信息,告诉上层本层支持的 SPI 版本 lpWSPData->wVersion = MAKEWORD(2, 2); lpWSPData->wHighVersion = MAKEWORD(2, 2); return 0; }这里有个原则:任何一步失败都返回错误码,绝不返回“部分可用”的状态。返回错误最坏的结果是这个进程的网络调用失败,返回半成品的结果是整个系统网络异常,后者排查成本高一个数量级。
4.2 函数表转发:把 NextProcTable 存好
加载下一层提供程序是个两段式操作:先从协议目录里拿到它的 DLL 路径,再 LoadLibrary 加 GetProcAddress。
static bool LoadNextProvider(LPWSAPROTOCOL_INFOW lpProtocolInfo, LPWSPPROC_TABLE lpNext) { // ChainEntries[1] 是下一层基础提供程序的 catalog id DWORD dwNextId = (DWORD)lpProtocolInfo->ProtocolChain.ChainEntries[1]; // 枚举协议目录,找到这个 id 对应的完整条目 INT iLen = 0, iErr = 0; ::WSCEnumProtocols(nullptr, nullptr, &iLen, &iErr); // 先问长度 std::vector<BYTE> buf(iLen); auto pList = reinterpret_cast<LPWSAPROTOCOL_INFOW>(buf.data()); int nCount = ::WSCEnumProtocols(nullptr, pList, &iLen, &iErr); if (nCount == SOCKET_ERROR) return false; LPWSAPROTOCOL_INFOW pNext = nullptr; for (int i = 0; i < nCount; ++i) if ((DWORD)pList[i].dwCatalogEntryId == dwNextId) { pNext = &pList[i]; break; } if (!pNext) return false; // 用 ProviderId 换真实 DLL 路径,szProtocol 只是显示名,不是路径 WCHAR szPath[MAX_PATH * 2] = {0}; INT iPathLen = MAX_PATH * 2; if (SOCKET_ERROR == ::WSCGetProviderPath(&pNext->ProviderId, szPath, &iPathLen, &iErr)) return false; g_hNextDll = ::LoadLibraryW(szPath); if (!g_hNextDll) return false; auto pfnStartup = (LPWSPSTARTUP)::GetProcAddress(g_hNextDll, "WSPStartup"); if (!pfnStartup) return false; // 注意版本号要传自己的,别把上层请求的版本原样透传下去 WSAPROTOCOL_INFOW info = *pNext; return pfnStartup(MAKEWORD(2, 2), &g_WspData, &info, g_UpcallTable, lpNext) == 0; }最容易踩的是拿 szProtocol 当路径用。它只是显示名,真正的路径要通过 WSCGetProviderPath 用 ProviderId 换。另外 WSCEnumProtocols 是两次调用模式,第一次传空指针问缓冲区大小,第二次才真正枚举,这个套路在 Winsock 里很常见。
4.3 在 WSPConnect 和 WSPSend 上插入自己的处理
拦截函数的第一原则是:不管记录什么,最终必须原样调用下层函数,参数一个都不能改,除非你明确知道改了什么后果。
int WSPAPI MyWSPConnect( SOCKET s, const struct sockaddr FAR *name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, LPINT lpErrno) { // 只做记录:把目标地址和端口写进日志,不做任何改写 if (name && namelen >= (int)sizeof(SOCKADDR_IN)) { auto* pIn = reinterpret_cast<const SOCKADDR_IN*>(name); LogToFile(L"connect %u.%u.%u.%u:%u", pIn->sin_addr.S_un.S_un_b.s_b1, pIn->sin_addr.S_un.S_un_b.s_b2, pIn->sin_addr.S_un.S_un_b.s_b3, pIn->sin_addr.S_un.S_un_b.s_b4, ntohs(pIn->sin_port)); } // 原样转发。返回值和 lpErrno 都要透传,否则上层拿到的错误码是错的 return g_NextProcTable.lpWSPConnect(s, name, namelen, lpCallerData, lpCalleeData, lpSQOS, lpGQOS, lpErrno); }日志函数要特别小心。如果 LogToFile 内部用了 CreateFile、WriteFile,这些是内核调用,不经过 Winsock,没问题。但如果用 fopen 或者更麻烦的,在某些 CRT 实现下可能触发网络相关初始化,一不留神就重入到自己的 WSPStartup 里,形成递归。稳妥做法是在入口加一个线程局部的重入标志,检测到自己调自己就直接短路。
另外 WSPConnect 可能被并发调用,日志写入必须加锁或者用无锁队列,别在多线程环境下直接往文件里追加,尤其是短连接密集的程序,很容易出现日志交错或者句柄竞争。
5. 排查与避坑:五条踩过的坑
5.1 安装与注册阶段的三条
现象:装完重启,整台机器所有网络程序都连不上,浏览器一直在转圈。原因基本只有一个:WSPStartup 返回了非零值,或者函数表没填全就返回了 0。ws2_32 拿到一个坏掉的表之后,后续的 socket 调用全部失败,而且它不会自动回退到基础提供程序。 解法是回到代码里检查三条:ChainLen 是不是真的大于 1、LoadNextProvider 有没有可能返回 false、函数表是不是先整体拷贝了 g_NextProcTable 再改的。如果已经装坏了,用管理员权限跑 netsh winsock reset 然后重启,能把目录恢复到默认状态。
现象:32 位程序一切正常,64 位程序完全没反应,或者反过来。原因是只注册了一套。32 位进程读 WOW6432Node 下的目录,64 位进程读主目录,两个目录完全独立。DLL 也要分别编两个版本。 解决办法是把同一个工程编成 Win32 和 x64 两份,用对应位数的安装程序分别调 WSCInstallProvider。GUID 最好也区分开,方便卸载时分别处理。
现象:目录里能看到自己的条目,顺序也在前面,但目标进程的模块列表里就是没有自己的 DLL。原因是这个进程在安装之前就已经初始化过 Winsock 了,函数表已经建立好,不会再看目录。另外一类是进程用了自己带的网络库,绕过了 ws2_32。 确认方法是重启目标进程或重启系统,再重新看模块列表。开发期最省事的做法是每次改完都重启一次虚拟机,别指望热更新。
5.2 运行与卸载阶段的两条
现象:进程启动就崩,或者代码里断点怎么都进不去,栈显示无限递归。原因是在拦截函数里调用了 Winsock。比如在 MyWSPConnect 里为了记日志用了 gethostbyname,这个调用本身又要走 ws2_32,又会被路由回自己,一层层下去直接爆栈。 解法是加线程局部变量做重入保护:进入拦截函数时置位,退出时清除,检测到已置位就直接调下层函数,不做任何自己的逻辑。更简单的办法是把所有拦截里的辅助操作限制在文件 IO 和内存操作上。
现象:卸载之后目录里还有残留条目,或者卸载报错说提供程序正在使用。原因是卸载顺序错了。正确顺序是先 WSCWriteProviderOrder 把自己的 id 排到列表末尾,再 WSCDeinstallProvider 删除条目,最后重启。反过来先删条目,目录里的引用关系就断了,系统会认为目录损坏。 另外卸载前要确保没有进程还持有这个 DLL,虽然 LoadLibrary 的引用计数会处理,但目录条目删除和 DLL 文件删除是两回事,文件可以稍后再删。
6. 检测与清理:从一条可疑条目到完整还原
真遇到这类问题,排查路径是固定的。先用 netsh winsock show catalog 导出全量条目,肉眼看有没有陌生名字、有没有 ChainLen 大于 1 却不是微软自带的那几条。重点看路径字段,指向用户目录、临时目录、没有签名的路径的,基本可以直接标记。
命令行不方便的话,直接查注册表更快:
reg query "HKLM\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries" /s /v LibraryPath reg query "HKLM\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries64" /s /v LibraryPath两条分别对应 32 位和 64 位目录,输出里每一条路径都对应一个提供程序。正常的系统里这些路径应该都指向 System32,出现别的路径就要看清楚。找到可疑项后,记录它的 catalog id 和 ProviderId,调 WSCWriteProviderOrder 把它挪到末尾,再用 WSCDeinstallProvider 按 GUID 删除,最后重启。
如果目录已经被改坏、连 netsh 都报错,netsh winsock reset 是最后一招,它会把整个协议目录重置到系统默认状态并重建,代价是之前所有第三方分层提供程序全部丢失。用之前先把可疑 DLL 拷出来留样本,否则重置完就只剩注册表里一串孤儿 GUID 了。
回过头看,这套机制最值得记住的不是怎么装,而是“装之前先想好怎么卸”。我现在每次改注册表目录之前,都会先把原始目录完整导出一份,改完立刻验证卸载路径能不能走通,不能走通就不往下做。这个习惯是从一次把测试机网络彻底搞死、只能重装系统之后才养成的,血泪经验,希望帮到你。
本文还有配套的精品资源,点击获取