简介:《lsp注入(原理及其实现代码)》围绕LSP(语言服务提供者)在Windows系统中的COM组件机制,讲解从创建自定义DLL、注册到系统、运行时注入到拦截文件请求的完整链路,面向具备C++与COM基础、希望理解底层钩子和安全检测实现的学习者,并讨论安全检测、数据恢复、调试工具及恶意滥用等典型场景。压缩包共38个文件,约150KB,以15个cpp、7个h源码为核心,辅以vcxproj工程、sln解决方案、bat注册脚本、def导出文件及已编译dll/exe等,便于编译、注册和调试。已有1583人学习。内容覆盖LSP组件创建、注册表注册、进程注入、请求拦截等关键环节,涉及IDispatch、IUnknown与ILanguageService接口的实现思路,以及组件注册与卸载、注入与移除代码的组织方式;配合工程与脚本可辅助搭建实验环境、梳理注入流程和排错路径。适合在合法授权环境下研究该技术,也需注意使用不当可能影响系统稳定性并带来安全风险。
1. LSP 注入不是往进程里塞 shellcode:先看清它站在哪一层
很多人第一次听到 lsp注入,脑子里浮现的是远程线程、APC、内存马那一套。实际拆开看,LSP 注入走的是完全不同的路子:它不去碰别人进程的内存,而是把自己注册进系统的 Winsock 协议目录,从此每个调用socket()的进程在WSAStartup阶段都会被加载器顺带把这份 DLL 映射进去。写一个 LSP,本质上是写一个符合 Winsock 2 SPI 规范的分层服务提供者,让系统认为"这个协议栈的入口应该先经过它"。它解决的问题很具体:在不改动业务代码、不挂钩子、不打补丁的前提下,拿到进程内每一次 socket 创建、连接、收发的一手上下文。适合做协议调试、连接行为统计、长连接生命周期观测的工程师,也适合想搞懂 SPI 这套目录机制的人。门槛不在代码量,而在对协议链、句柄绑定和加载时序的理解。
2. 协议目录与协议链:LSP 能被加载的底层依据
2.1 协议目录里到底存了什么
Winsock 2 之后,系统把所有可用的传输提供者登记在一份叫协议目录的东西里。这份目录不是简单的列表,每个条目是一个WSAPROTOCOL_INFOW结构,记录了提供者 GUID、协议名、地址族、套接字类型、支持的语义标志,以及最关键的ProtocolChain字段。进程启动网络时,WSAStartup会读这份目录,按协议名匹配到条目,再顺着ProtocolChain里的编号逐个把对应的 DLL 加载进来。
这就解释了为什么 LSP 不需要"注入"动作:你只要在目录里新增一条记录,并且让它的协议链把自己的条目编号排在基础提供者前面,系统就会在匹配到该协议时主动加载你的 DLL。所谓注入,是目录机制的自然结果,不是外力强塞。
目录分两套:64 位进程读 64 位目录,32 位进程读 32 位目录(重定向到Wow6432Node路径下)。这是后面很多"一半程序好使一半不好使"问题的根源,第 5 章会细说。
ProtocolChain的结构本身很简单,三个字段:
| 字段 | 含义 | 分层提供者的取值 |
|---|---|---|
ChainLen | 链上节点个数 | 分层为 2 及以上,基础提供者为 1 |
ChainEntries[0] | 第一个节点,即最外层 | 自己的目录条目编号 |
ChainEntries[1] | 第二个节点 | 下层提供者的目录条目编号 |
ChainLen等于 1 时表示这是基础提供者,系统直接调它的WSPStartup。等于 2 时,系统先加载ChainEntries[0]指向的 DLL,把ChainEntries[1]的信息作为参数传进去,由外层自己决定怎么把请求转下去。多层链理论上可以继续加长,但实际维护成本很高,一般一层就够。
2.2 关键字段怎么填,填错就加载不起来
WSAPROTOCOL_INFOW字段多,真正决定成败的就那么几个。我一般是以一条现成的 TCP 基础提供者条目为模板,拷贝一份再逐项改,比从零构造靠谱得多。
ProviderId换成自己生成的 GUID,安装时靠它做唯一标识,卸载也靠它定位。szProtocol是显示名,会出现在WSCEnumProtocols的结果里,起个能认出来的名字,调试时省事。dwCatalogEntryId不要手填,安装时由系统分配,装完之后要重新枚举一次把自己的真实编号拿回来,再回写到ChainEntries[0]。
语义标志位是最容易翻车的地方。dwServiceFlags1里如果原模板带了PFL_HIDDEN,必须清掉,否则条目在枚举里看不见,业务侧也匹配不到。XP1_IFS_HANDLES要保留,它表示该提供者返回的套接字句柄是真正的内核句柄,可以被WaitForSingleObject之类的接口直接等待;丢了这一位,一些依赖句柄等待的程序会出现莫名其妙的超时。XP1_CONNECTIONLESS、XP1_MESSAGE_ORIENTED这些要跟下层保持一致,否则协议行为对不上。
还有一点常被忽略:模板里如果有dwProviderReserved之类的私有字段,拷贝过来之后要清零,别把别人保留的东西带进自己的条目。
2.3 加载时序:WSPStartup 是被谁、在什么时候调的
理解调用时序,才能理解后面所有排错手段。完整的链路是这样:进程调用WSAStartup,ws2_32 读目录匹配协议条目,发现ChainLen大于 1,于是按ChainEntries[0]找到 DLL 路径,LoadLibrary加载,然后GetProcAddress("WSPStartup"),调用它,并把一个填好的WSPUPCALLTABLE和一个待填充的WSPPROC_TABLE一起传进去。
外层WSPStartup的职责就三件事:一是找到下层提供者的 DLL 路径并加载;二是调用下层的WSPStartup,拿到下层的函数表;三是把下层的每个函数指针抄进自己的WSPPROC_TABLE,把需要观测的那几个换成自己的实现,然后返回 0。
注意这个顺序:WSPStartup是在目标进程里、在WSAStartup的调用栈上执行的。这意味着此时进程的加载器锁状态、当前线程上下文都很特殊,任何重量级操作都可能出问题。第 5 章第一条坑讲的就是这个。
还有一个隐蔽点:找下层 DLL 路径不能凭感觉拼。正确做法是拿lpProtocolInfo->ProtocolChain.ChainEntries[1]这个编号,去枚举协议目录找到对应条目,再调WSCGetProviderPath取路径。如果直接写死mswsock.dll或者ws2_32.dll,在非 TCP 协议或者被其他厂商接管的环境里会立刻失败。
3. 写一个能挂上去的 LSP:导出表、句柄绑定与透传骨架
3.1 导出清单:少一个函数就是空指针崩溃
SPI 规定了三十个左右的入口函数,加上WSPStartup和几个辅助入口,全部要用__declspec(dllexport)导出,调用约定统一是WSPAPI(即__stdcall)。函数名不能改,系统是按名字GetProcAddress的。
工程上我一般开一个.def文件集中声明,比在头文件里一个个写dllexport清晰:
; SampleLsp.def —— 集中导出,避免漏写某个入口 LIBRARY SampleLsp EXPORTS WSPStartup WSPAccept WSPBind WSPCleanup WSPCloseSocket WSPConnect WSPCreateSocket WSPGetPeerName WSPGetSockName WSPGetSockOpt WSPIoctl WSPRecv WSPRecvFrom WSPSend WSPSendTo WSPSetSockOpt WSPShutdown WSPSocket ; 其余入口按同样方式补齐,一个都不能缺逻辑说明:.def只负责导出名字,不产生代码。WSPPROC_TABLE里每个字段都必须指向一个有效函数,哪怕它只是原样转给下层。如果某个入口没导出,GetProcAddress拿到空指针,系统填进表里,业务侧第一次调用到它时就是访问违例,现象是程序毫无征兆地闪退,栈里还看不出跟你的 DLL 有直接关系。
参数说明:LIBRARY后面的名字要和 DLL 文件名一致,否则某些加载路径下会找不到导出。
3.2 WSPStartup 里的三步:找路径、调下层、填表
这是整个 LSP 的核心,也是最容易写得不对的地方。下面是我一般会用的骨架:
// 全局保存下层的函数表,所有透传都从这里转 static WSPPROC_TABLE g_next; static HMODULE g_hNextDll = NULL; int WSPAPI WSPStartup( WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { if (lpProtocolInfo == NULL || lpProtocolInfo->ProtocolChain.ChainLen <= 1) return WSAEPROVIDERFAILEDINIT; // 第一步:拿下一层的目录编号,枚举出它的条目 DWORD nextId = lpProtocolInfo->ProtocolChain.ChainEntries[1]; WSAPROTOCOL_INFOW nextInfo; DWORD bufLen = sizeof(nextInfo); if (WSCEnumProtocols(&nextId, &nextInfo, &bufLen, NULL) == SOCKET_ERROR) return WSAEPROVIDERFAILEDINIT; // 第二步:由下一层的 ProviderId 取 DLL 路径,加载并调用它的 WSPStartup WCHAR dllPath[MAX_PATH] = {0}; int pathLen = MAX_PATH; if (WSCGetProviderPath(&nextInfo.ProviderId, dllPath, &pathLen, NULL) == SOCKET_ERROR) return WSAEPROVIDERFAILEDINIT; if (g_hNextDll == NULL) { g_hNextDll = LoadLibraryW(dllPath); if (g_hNextDll == NULL) return WSAEPROVIDERFAILEDINIT; } typedef int (WSPAPI *PFN_WSPSTARTUP)( WORD, LPWSPDATA, LPWSAPROTOCOL_INFOW, WSPUPCALLTABLE, LPWSPPROC_TABLE); PFN_WSPSTARTUP pfnStartup = (PFN_WSPSTARTUP)GetProcAddress(g_hNextDll, "WSPStartup"); if (pfnStartup == NULL) return WSAEPROVIDERFAILEDINIT; // 注意:传给下层的是 nextInfo,不是 lpProtocolInfo int rc = pfnStartup(wVersionRequested, lpWSPData, &nextInfo, UpcallTable, &g_next); if (rc != 0) return rc; // 第三步:整表抄过来,之后按需替换 *lpProcTable = g_next; lpProcTable->lpWSPConnect = WSPConnect; // 观测连接 lpProcTable->lpWSPCloseSocket = WSPCloseSocket; // 观测关闭 return 0; }逻辑说明:第一步用WSCEnumProtocols把下层条目查出来,是因为协议链里只存了编号,没有存路径。第二步调WSCGetProviderPath拿真实 DLL 路径,这样不依赖任何写死的文件名。第三步先把整张表原样拷过来,保证所有入口都是可用的,再挑几个覆盖成自己的实现。
参数说明:WSCEnumProtocols的第一个参数是输入输出参数,传入起始编号,返回时更新为下一个编号;bufLen传结构大小,返回时更新为实际需要的大小,如果缓冲区不够会返回SOCKET_ERROR且错误码是WSAEFAULT。WSCGetProviderPath的路径缓冲区必须用宽字符,长度按字符数传。
一个必须强调的点:传给下层WSPStartup的第三个参数必须是下层自己的WSAPROTOCOL_INFOW,不能把lpProtocolInfo原样递下去。递错了,下层会认为自己是分层提供者,继续往下找链,形成自引用,表现是WSAStartup卡住或者直接崩。
3.3 透传函数:每一行都要原样传递 errno
被替换的入口函数写法极其机械,但有一条铁律不能破:下层返回的lpErrno必须原样带回去。
int WSPAPI WSPConnect( SOCKET s, const struct sockaddr FAR *name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, LPINT lpErrno) { // 这里可以拿到连接目标和当前线程,做只读观测 // 观测代码必须短、必须不抛异常、必须不阻塞 int rc = g_next.lpWSPConnect(s, name, namelen, lpCallerData, lpCalleeData, lpSQOS, lpGQOS, lpErrno); // 不要再改 lpErrno,下层写什么就是什么 return rc; }逻辑说明:函数体里只做两件事——可选的只读观测,以及对下层的原样转发。返回值直接透传,lpErrno交给下层去写。
参数说明:lpErrno是 SPI 的错误输出约定,跟 BSD 风格的全局errno不同,必须由实现方显式写入。如果我们在这层自己改写了错误码,上层的业务代码就会拿到错误的错误值,排查时会被误导到完全不相干的方向。
WSPCloseSocket也是常被替换的一个,用来统计连接寿命。注意它的语义跟closesocket不同,接收两个参数:SOCKET s和LPINT lpErrno。顺带一提,WSPCreateSocket的签名里带着lpProtocolInfo,可以在创建时就把协议上下文记下来,为后面按连接聚合日志做准备。
4. 注册、排序与卸载:让协议目录真正生效
4.1 安装:一次调用要准备好三样东西
代码编译成 DLL 只是第一步,不注册进目录,系统根本不知道它存在。
// 安装示例:以现有 TCP 条目为模板改写 int InstallLsp(const wchar_t* dllPath) { GUID guid = SAMPLE_LSP_GUID; // 自己生成的固定 GUID,卸载要用同一个 // 1) 先枚举出一条 TCP 基础提供者当模板 WSAPROTOCOL_INFOW proto[64]; DWORD bufLen = sizeof(proto); int count = WSCEnumProtocols(NULL, proto, &bufLen, NULL); if (count == SOCKET_ERROR) return -1; WSAPROTOCOL_INFOW mine = {0}; int found = 0; for (int i = 0; i < count; ++i) { // 找 IPv4、流式、基础(ChainLen==1)的 TCP 条目 if (proto[i].iAddressFamily == AF_INET && proto[i].iSocketType == SOCK_STREAM && proto[i].ProtocolChain.ChainLen == 1) { mine = proto[i]; found = 1; break; } } if (!found) return -1; // 2) 改写关键字段 mine.ProviderId = guid; mine.dwCatalogEntryId = 0; // 交给系统分配 mine.dwServiceFlags1 &= ~PFL_HIDDEN; // 必须可见 mine.dwServiceFlags1 |= XP1_IFS_HANDLES; // 保留句柄语义 wcscpy_s(mine.szProtocol, L"Sample LSP over TCP"); mine.ProtocolChain.ChainLen = 2; mine.ProtocolChain.ChainEntries[0] = 0; // 占位,装完回填 mine.ProtocolChain.ChainEntries[1] = proto[i].dwCatalogEntryId; // 3) 安装 INT err = 0; if (WSCInstallProvider(&guid, dllPath, &mine, 1, &err) == SOCKET_ERROR) return err; // 4) 重新枚举,把自己真实的编号写回 ChainEntries[0] bufLen = sizeof(proto); count = WSCEnumProtocols(NULL, proto, &bufLen, NULL); for (int i = 0; i < count; ++i) { if (IsEqualGUID(proto[i].ProviderId, guid)) { mine = proto[i]; mine.ProtocolChain.ChainEntries[0] = proto[i].dwCatalogEntryId; WSCUpdateProvider(&guid, dllPath, &mine, 1, &err); break; } } return 0; }逻辑说明:整个流程是"拷贝模板 → 改身份 → 装进去 → 回填自引用编号"。第四步不能省,因为ChainEntries[0]必须指向自己的目录编号,而编号只有系统知道。少这一步,协议链的首节点指向 0,加载时找不到 DLL,WSAStartup直接失败。
参数说明:WSCInstallProvider的第二个参数是 DLL 的完整路径,必须是系统能访问到的绝对路径,放在用户目录下在某些服务账户里会读不到。最后一个参数是错误输出,失败时先看它。
4.2 排序:装了不等于用得上
安装只是把条目加进目录,能不能被业务命中,取决于协议匹配时的优先级。这一步用WSCWriteProviderOrder完成,传入一个目录编号数组,顺序即优先级。
// 把我们的条目排到 TCP 之前 int PromoteLsp(DWORD myCatalogId, DWORD tcpCatalogId) { DWORD order[2] = { myCatalogId, tcpCatalogId }; INT err = 0; if (WSCWriteProviderOrder(order, 2, &err) == SOCKET_ERROR) return err; return 0; }逻辑说明:数组里靠前的编号优先被匹配。如果只想影响特定协议,就把目标协议的基础条目编号也放进数组,其余保持原顺序。
参数说明:这个调用需要管理员权限,普通用户下会返回WSAEACCES。写完顺序后,新起的进程立即生效,已经在跑的进程不会重新读目录,所以测试时记得重启目标程序。
4.3 卸载:不清理干净,下次重装就是灾难
卸载要按相反顺序做:先从顺序表里摘掉、再删条目、最后删 DLL 文件。
int UninstallLsp(DWORD myCatalogId, DWORD tcpCatalogId) { INT err = 0; // 1) 先把顺序恢复成 TCP 优先 DWORD order[1] = { tcpCatalogId }; WSCWriteProviderOrder(order, 1, &err); // 2) 按 GUID 删除条目 GUID guid = SAMPLE_LSP_GUID; if (WSCDeinstallProvider(&guid, &err) == SOCKET_ERROR) return err; return 0; }逻辑说明:顺序表里如果还留着已经删掉的编号,后续枚举会出问题,所以先改顺序再删条目。删除后建议重启一次再验证,因为有些进程还持有旧 DLL 的映射,文件删不掉。
参数说明:WSCDeinstallProvider只认 GUID,所以 GUID 必须固化在代码里,不能用运行时随机生成的值——随机 GUID 装上去,重启后你自己都找不回来。
5. 避坑与排查:五类现场和它们的成因
5.1 现象:程序一联网就闪退,事件日志里只有模块名
原因:WSPPROC_TABLE里有字段是空指针。最常见的是某个入口忘了导出,或者*lpProcTable = g_next这行写在了替换语句之后,把已经替换好的指针又覆盖回去了。
解决:写一个自检函数,在WSPStartup返回前遍历整张表,逐个判断是否为空,为空就返回WSAEPROVIDERFAILEDINIT。宁可加载失败,也不要让业务进程在随机时刻崩。返回失败至少现象是"网络不可用",比闪退好定位得多。
5.2 现象:一调WSAStartup就卡死,或者进程挂起在加载器里
原因:在DllMain或者WSPStartup里做了LoadLibrary之外的重量级操作,比如起线程、等锁、写文件、调CreateProcess。WSPStartup处在加载器锁的保护范围内,此时再去加载别的模块或者等待其它线程,很容易形成死锁。
解决:把所有初始化推迟到第一次真正的 SPI 调用里做,用一次性的原子标志位控制。WSPStartup里只允许做三件事:查路径、LoadLibrary下层、填表。日志也别在这里写,改成内存环形缓冲区,后面再落盘。
5.3 现象:连接能建,但收发的数据对不上,或者程序报句柄无效
原因:在实现里直接调了 ws2_32 的send/recv,而不是走g_next的函数表。这会形成递归:你的WSPSend调send,send又走协议链回到你的WSPSend,栈很快溢出。另一个可能是XP1_IFS_HANDLES被清掉了,业务侧拿句柄去做等待时行为异常。
解决:所有对外操作一律走g_next,一条都不能例外。语义标志位以模板为准,只做必要的增减,改完用WSCEnumProtocols把条目打出来逐字段对比。
5.4 现象:64 位程序正常,32 位程序完全没反应,或者反过来
原因:只在一套目录里注册了。32 位进程读的是重定向后的目录,与 64 位是两份独立的数据。用 64 位安装程序装完,32 位程序根本看不到你的条目。
解决:DLL 要编译两份,安装代码也要跑两遍,分别用 32 位和 64 位进程执行。验证时用两种位数的程序各测一次,别只测自己手上那个。
5.5 现象:卸载重装之后,协议目录里出现多条同名条目,网络时好时坏
原因:上次卸载没成功,或者用了新的 GUID 重新安装,导致旧条目残留在目录里,协议链指向已删除的 DLL。系统加载时找不到文件,整条链失效。
解决:安装前先按 GUID 尝试卸载一次,忽略返回值;安装后用WSCEnumProtocols按 GUID 统计条目数量,大于 1 就说明有残留。实在清不掉,就在测试机上恢复到注册表快照,别在污染过的目录上继续叠加。
6. 进阶验证:用一张对照表判断注入到底挂上没有
写完代码最怕的是"看起来生效了"。我习惯用一张对照表逐项确认,而不是靠某个程序能不能上网来猜。
| 验证项 | 期望结果 | 观测方法 |
|---|---|---|
| 目录条目存在 | 按 GUID 能枚举到 1 条 | WSCEnumProtocols后过滤 GUID 计数 |
| 协议链自引用正确 | ChainEntries[0]等于自身编号 | 打印条目全部字段比对 |
| 顺序优先级生效 | 自身编号排在基础条目之前 | WSCEnumProtocols返回顺序即优先级 |
| DLL 被真实加载 | 目标进程模块列表里有你的 DLL | 枚举目标进程模块,按名字匹配 |
WSPStartup被调用 | 每次进程启动恰好一次 | 内存计数器加一次,退出时落盘 |
| 32/64 位都覆盖 | 两种位数的进程都能看到 | 分别用两种程序各测一次 |
前四项是静态检查,装完不用跑业务就能确认。第五项是动态确认,能证明加载链路真的走通了,而不是目录里躺着一条没人用的记录。第六项最容易被跳过,但它是"一半程序出问题"的唯一有效防线。
调试阶段还有个小技巧:在WSPStartup里把wVersionRequested、传入的ChainEntries[1]、以及最终*lpProcTable的地址都记进环形缓冲区,进程退出时一次性写文件。这样即使进程崩了,也能从遗留的日志片段里看出崩在哪一步。不要在这个阶段用调试器附加目标进程,加载时序会被打乱,很多问题反而复现不出来。
从那以后我每次动 LSP 代码,都强制先在虚拟机里打快照,再按上面这张表从第一行走到最后一行,任何一行不过就不往下走。协议目录一旦被写坏,排查成本远高于重装一遍系统,这个后悔药是真的贵。希望帮到你。
本文还有配套的精品资源,点击获取