Winsock LSP 注入原理与实现:WSPStartup 协议链转发

发布时间:2026/10/31 11:14:29
Winsock LSP 注入原理与实现:WSPStartup 协议链转发

简介:围绕Windows平台Language Service Provider(LSP)注入技术整理的一份教学型源码包,面向已具备C++与COM基础、希望理解底层钩子与文本服务扩展机制的开发者。包内共38个文件,以15个cpp源文件和7个h头文件为主体,配合2个vcxproj工程、2个bat注册与卸载脚本,以及dll、exe、def等编译导出文件,整体约150KB,体量轻便但工程要素较完整。内容围绕LSP组件创建、注册表注册、进程注入和请求拦截处理展开,目录中可见instlsp、common、ifslsp等模块,便于对照学习COM接口实现、ILanguageService接口定义、注册脚本与示例程序之间的调用关系。已有1582人学习下载。读者可获得可编译的注入工程骨架、注册与卸载思路、接口实现参考及调试入口,适合安全检测、开发工具研究等合法场景的原理学习,也需注意底层操作带来的稳定性与合规风险。

1. LSP 注入到底在干什么:一条被忽略的 Winsock 中间层

有人卸掉一个“网络监控”小工具之后,浏览器再也打不开网页,敲一句netsh winsock reset又满血复活——这个经典故障的根源就是 LSP 注入。LSP(Layered Service Provider,分层服务提供者)不是远程线程注入,也不是 DLL 劫持,它是 Winsock 2 摆在明面上的扩展点:把自己的 DLL 注册进 Winsock 目录,系统会把它按需加载到每个调用 socket 的进程里,于是你的代码就站在了所有联网进程的收发路径上。这份资源给的是原理拆解加一套能编译、能注册、能卸载的实现代码,适合做网络中间层、协议观测和 Winsock 编程练手的人;不适合想找个“点两下就完事”的成品工具的人,因为它的每一步都要你自己编译、自己注册、自己收尾。

2. 架构拆解:LSP 挂在 Winsock 协议链的哪一环

搞 LSP 最容易翻车的地方,是压根没搞清楚它在系统里的坐标。很多人一上来就写WSPStartup,写到一半发现 socket 创建不出来,或者下层 DLL 加载不了,就是因为脑子里没有那张“协议目录 → 协议链 → 提供者 DLL”的地图。这一章先把地图画清楚,后面写代码才不会像在黑匣子里摸索。

2.1 协议目录与协议链:LSP 的注册位置

Winsock 2 把所有可用的传输提供者登记在一个叫协议目录(Protocol Catalog)的地方,物理落点在注册表HKLM\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries(32 位程序在Wow6432Node下有独立的一份)。目录里每一条都是一个WSAPROTOCOL_INFOW结构,你可以用WSAEnumProtocols或者netsh winsock show catalog把它全部列出来。

这个结构里真正决定“你是不是 LSP”的是ProtocolChain字段:

字段含义LSP 场景下的取值
dwCatalogEntryId目录内的唯一编号由系统安装时分配,不要自己填
ProtocolChain.ChainLen协议链长度独立提供者填 0;分层链填 2(及以上)
ProtocolChain.ChainEntries[]链上每一跳的 CatalogId[0]是自己,[1]是下一跳(通常是 TCP/IP 基座)
ProviderId提供者 GUID自己的 GUID,卸载时靠它定位
szProtocol显示名出现在netsh输出里,建议带明显前缀
dwServiceFlags1能力位一般要带XP1_IFS_HANDLES、XP1_GUARANTEED_DELIVERY等,和基座保持一致

注册完之后你netsh winsock show catalog会看到类似这样的东西:

条目类型 2: 分层协议链 协议链: LspDemo over MSAFD Tcpip [TCP/IP] 目录条目 ID: 1053 协议: LspDemo 链路: 1052 -> 1001

1052就是你自己的那个ChainLen = 0的条目,1001是系统自带的 TCP/IP 基座。ChainLen = 2的那一条,才是应用真正会选中的“产品”。很多人只装了第一条(ChainLen = 0),然后疑惑为什么自己的代码不跑——因为那条根本不参与链式调用,它只是个“零件”。

提示:安装和卸载必须用管理员权限,注册表这两个位置普通用户根本写不进去。

2.2 WSPStartup:DLL 被加载后唯一的入口

系统在需要用到链上某一跳时,会把对应 DLLLoadLibrary进来,然后只找并调用一个导出函数:WSPStartup。这就是“注入”的全部机制——不是往别的进程里写内存,而是系统自己把你的 DLL 加载进了那些调用 socket 的进程。签名长这样:

int WSPAPI WSPStartup( WORD wVersionRequested, // 请求的 Winsock 版本,通常是 0x0202 LPWSPDATA lpWSPData, // 回填版本和提供者信息,必填 LPWSAPROTOCOL_INFOW lpProtocolInfo, // 当前这一跳的协议条目,关键 WSPUPCALLTABLE UpcallTable, // 向上回调表,转发时原样传给下层 LPWSPPROC_TABLE lpProcTable); // 30 个 SPI 函数指针,必须填满

四个参数里最要命的是lpProcTable。它是一个有 30 个成员的函数指针表,WSPSocket、WSPConnect、WSPSend、WSPRecv、WSPCloseSocket全在里面。如果你只填了自己关心的几个,剩下的留成 NULL,上层会在某个你没想到的时刻直接调过去,然后整机断网。血泪经验:第一版实现里要么全部转发,要么全部转发,没有第三条路。

lpProtocolInfo则是你找到“下一跳”的唯一线索。它的ProtocolChain.ChainEntries[1]就是基座的 CatalogId,你需要根据这个 ID 反查基座的ProviderId,再用WSCGetProviderPath拿到基座 DLL 的完整路径(通常是%SystemRoot%\system32\mswsock.dll),最后LoadLibrary+GetProcAddress("WSPStartup")拉起下层,把UpcallTable和下层自己的lpProcTable接起来。

还有一条硬性规定:不要在DllMain里做任何加载 DLL、调用 Winsock 的事情。DllMain处在加载器锁(loader lock)里,此时LoadLibrary极大概率死锁,表现就是“一装完 LSP,任务管理器都打不开”。正确做法是DllMain里只调一句DisableThreadLibraryCalls,所有初始化推迟到WSPStartup。

2.3 一次 connect 调用穿过的完整链路

把上面两块拼起来,一次普通的connect在装了 LSP 之后实际走的是这样一条路:

// 应用层视角(普通调用,完全无感) SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); connect(s, (sockaddr*)&addr, sizeof(addr)); // 系统内部实际发生的事(简化) // 1. socket() 触发 Winsock 遍历目录,选中 ChainLen=2 的链条目 // 2. LoadLibrary("LspDemo.dll") -> GetProcAddress("WSPStartup") // 3. 调用我们的 WSPStartup,我们把下层 mswsock 的 WSPStartup 也拉起来 // 4. 应用调用 socket() -> 进入我们的 WSPSocket -> 转发给下层 WSPSocket // 5. 应用调用 connect() -> 进入我们的 WSPConnect -> 转发给下层 WSPConnect // 6. 数据收发全部经过 WSPSend / WSPRecv

注意第 4、5 步的转发方向:上层调用进来的是 SPI 函数,我们往下调的是“下层提供者的 SPI 函数”,两份函数表通过g_NextProcTable串起来。这里有个细节容易被忽略——WSPSocket返回的SOCKET句柄是下层的句柄,上层认的就是这个值,所以句柄不要自己重新打包,除非你确实需要维护一张映射表(比如要附加私有上下文)。多数观测型 LSP 直接透传句柄就够,省掉一大堆生命周期问题。

也正因为这条链路是“每个进程各走一遍”,你的 LSP 会被加载进浏览器、聊天工具、更新服务甚至系统组件里。调试时不要只盯着自己的测试程序看日志,往往是某个后台进程先崩了,才把整机网络拖垮。

3. 代码骨架:从 WSPStartup 导出到分发 30 个 SPI 函数

原理讲完,落到代码。这套实现拆成两个东西:一个是LspDemo.dll(被注入的那份),一个是Installer.exe(负责注册和卸载)。前者要足够“薄”,任何多余逻辑都是稳定性风险;后者要足够“稳”,因为它是你出事之后唯一的后悔药。

3.1 最小可加载的 DLL:全局状态与 WSPStartup 骨架

先把全局状态和入口立起来。下面这段是能跑通加载流程的最小骨架,去掉业务逻辑也能正常转发:

// LspDemo.cpp #include <winsock2.h> #include <ws2spi.h> #include <stdio.h> #pragma comment(lib, "ws2_32.lib") HINSTANCE g_hInst = NULL; WSPPROC_TABLE g_NextProcTable; // 下层提供者的 30 个函数 WSPUPCALLTABLE g_UpcallTable; // 向上回调表,原样透传 CRITICAL_SECTION g_csInit; // 保护一次性初始化 BOOL g_bInited = FALSE; BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, LPVOID) { if (reason == DLL_PROCESS_ATTACH) { g_hInst = (HINSTANCE)hModule; // 关键:只做这一件事,任何加载行为都推迟到 WSPStartup DisableThreadLibraryCalls(hModule); InitializeCriticalSection(&g_csInit); } else if (reason == DLL_PROCESS_DETACH) { DeleteCriticalSection(&g_csInit); } return TRUE; } // 唯一的导出函数 extern "C" int WSPAPI WSPStartup( WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { int err = 0; EnterCriticalSection(&g_csInit); if (!g_bInited) { // 1) 找到下一跳并加载它的 DLL if (!LoadNextProvider(lpProtocolInfo, &err)) { LeaveCriticalSection(&g_csInit); return err; // 失败就直接返回,不要硬撑 } // 2) 把下层 WSPStartup 拉起来,填 g_NextProcTable int ret = g_pNextWSPStartup(wVersionRequested, lpWSPData, lpNextProtocolInfo, UpcallTable, &g_NextProcTable); if (ret != 0) { LeaveCriticalSection(&g_csInit); return ret; } g_UpcallTable = UpcallTable; g_bInited = TRUE; } // 3) 把 30 个函数指针全部填上(下面用宏展开) FillProcTable(lpProcTable); if (wVersionRequested >= 0x0202) lpWSPData->wVersion = 0x0202; else lpWSPData->wVersion = wVersionRequested; LeaveCriticalSection(&g_csInit); return 0; }

几个参数要特别说清楚。wVersionRequested一般是0x0202,回填lpWSPData->wVersion时必须<=请求值,否则上层会认为你不支持;lpWSPData->szDescription用来在netsh里显示描述,可以填自己的名字方便排查。UpcallTable必须原样传给下层,因为里面是WPUCreateSocketHandle、WPUQueryBlockingCallback这类由上层提供的回调,你自己造不出来。

LoadNextProvider的实现核心是三步:从lpProtocolInfo->ProtocolChain.ChainEntries[1]取到基座 CatalogId → 用WSAEnumProtocols枚举目录找到对应条目的ProviderId→WSCGetProviderPath拿路径再LoadLibrary。这里容易忽略的一点是枚举目录本身也可能触发目录刷新,所以我一般会把结果缓存在一个静态结构里,只在第一次WSPStartup时做一次。

3.2 函数转发:把 30 个 SPI 调用原样接到下层

30 个函数如果一个个手写,既枯燥又容易漏。标准做法是用宏批量生成,同时把需要观测的几个点单独拎出来手写。

// 批量转发的宏:签名一致,直接透传 #define FORWARD_1(fn, t1, a1) \ t1 WSPAPI fn(a1) { return g_NextProcTable.lp##fn(a1); } #define FORWARD_2(fn, t1, a1, t2, a2) \ t1 WSPAPI fn(a1, t2 a2) { return g_NextProcTable.lp##fn(a1, a2); } // ……按需定义到 6 个参数,覆盖 WSPConnect / WSPIoctl 这类长签名函数 FORWARD_2(WSPCloseSocket, int, SOCKET s, LPINT, lpErrno) FORWARD_2(WSPListen, int, SOCKET s, LPINT, lpErrno) // 需要观测的:手写,转发前后加日志 int WSPAPI WSPConnect(SOCKET s, const struct sockaddr* name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, LPINT lpErrno) { // 只记 IPv4 目标,避免把日志撑爆 if (name && name->sa_family == AF_INET) { const sockaddr_in* in = (const sockaddr_in*)name; WriteLog(L"connect -> %u.%u.%u.%u:%u", in->sin_addr.S_un.S_un_b.s_b1, in->sin_addr.S_un.S_un_b.s_b2, in->sin_addr.S_un.S_un_b.s_b3, in->sin_addr.S_un.S_un_b.s_b4, ntohs(in->sin_port)); } return g_NextProcTable.lpWSPConnect(s, name, namelen, lpCallerData, lpCalleeData, lpSQOS, lpGQOS, lpErrno); }

FillProcTable就是一个大号赋值块,把lpProcTable->lpWSPxxx逐个指向自己实现或宏生成的函数。有一点必须强调:表里每一项都要有非 NULL 的值。WSPAccept、WSPSelect、WSPEventSelect这几个即使你的测试程序用不到,别的进程也会用;留空就是给整机埋雷。

日志这块也要克制。LSP 跑在别人进程里,每次WSPSend都fopen/fwrite/fclose一遍,会把程序拖到没法用。我一般做法是:日志只在WSPConnect、WSPSocket这种低频点上写,并且用进程内单例的文件句柄,加个简单的环形缓冲,超过阈值就丢弃。真要高频采样,就走共享内存或者命名管道丢给外部进程处理,别在 LSP 里做重活。

3.3 安装器:两次 WSCInstallProvider 加一次顺序调整

安装器的职责是把 DLL 路径、GUID、协议条目写进目录,并且保证链条目排在基座前面。核心是两次WSCInstallProvider:第一次装自己那个ChainLen = 0的条目,拿到系统分配的 CatalogId;第二次装ChainLen = 2的链条目。

#include <ws2spi.h> #pragma comment(lib, "ws2_32.lib") // 前一步:装分层提供者本体,ChainLen = 0 BOOL InstallLspEntry(const wchar_t* dllPath, GUID* pGuid, DWORD* pCatalogId) { WSAPROTOCOL_INFOW info = { 0 }; info.dwServiceFlags1 = XP1_IFS_HANDLES | XP1_GUARANTEED_DELIVERY | XP1_GUARANTEED_ORDER | XP1_MESSAGE_ORIENTED; info.dwServiceFlags2 = 0; info.ProviderId = *pGuid; info.ProtocolChain.ChainLen = 0; // 本体,不是链 info.iAddressFamily = AF_INET; info.iSocketType = SOCK_STREAM; info.iProtocol = IPPROTO_TCP; wcscpy_s(info.szProtocol, L"LspDemo Base"); int err = 0; if (WSCInstallProvider(pGuid, dllPath, &info, 1, &err) == SOCKET_ERROR) return FALSE; *pCatalogId = info.dwCatalogEntryId; // 系统回填的编号 return TRUE; } // 后一步:装 ChainLen = 2 的链条目,这才是应用会选中的那条 BOOL InstallLspChain(const wchar_t* dllPath, GUID* pGuid, DWORD selfId, DWORD baseId) { WSAPROTOCOL_INFOW chain = { 0 }; chain.dwServiceFlags1 = XP1_IFS_HANDLES | XP1_GUARANTEED_DELIVERY | XP1_GUARANTEED_ORDER | XP1_MESSAGE_ORIENTED; chain.ProviderId = *pGuid; chain.ProtocolChain.ChainLen = 2; chain.ProtocolChain.ChainEntries[0] = selfId; // 第一跳:自己 chain.ProtocolChain.ChainEntries[1] = baseId; // 第二跳:TCP/IP 基座 chain.iAddressFamily = AF_INET; chain.iSocketType = SOCK_STREAM; chain.iProtocol = IPPROTO_TCP; wcscpy_s(chain.szProtocol, L"LspDemo over TCP/IP"); int err = 0; return WSCInstallProvider(pGuid, dllPath, &chain, 1, &err) != SOCKET_ERROR; }

baseId从哪来?用WSAEnumProtocols枚举目录,匹配szProtocol为MSAFD Tcpip [TCP/IP]且ChainLen == 0的那一条,拿它的dwCatalogEntryId。不同系统上名字可能略有差别,稳妥点就按iAddressFamily / iSocketType / iProtocol三个字段匹配。

装完之后还要调WSCWriteProviderOrder把链条目往前排。原因是同一个协议族下如果有多个候选,Winsock 会优先选目录里靠前的那个;不调整的话你的链很可能排在系统默认项后面,等于白装。WSCWriteProviderOrder需要传入完整顺序数组,所以得先用WSCEnumProtocols把现有所有dwCatalogEntryId拉出来,把新的插到前面再整体写回。

注意:安装前一定要先检测同名 GUID 是否已存在,重复安装会留下多条僵尸条目,卸载时很难清干净。

4. 跑起来:编译配置、注册验证与回滚三步走

代码有了,接下来是把它变成机器上真实生效的中间层。这一段强烈建议在虚拟机或者能随时重装的测试机上做,因为 LSP 装坏了的直接后果就是整机断网,而且你还得依赖网络去查资料——典型的死循环。

4.1 工程配置:32 位和 64 位各编一份

Winsock 目录是分位数的:64 位程序读 64 位那份,32 位程序读Wow6432Node下那份。所以LspDemo.dll必须编译两个版本,安装器也要分别安装。工程里几个关键配置:

配置项取值说明
平台Win32 和 x64 各一套只编一套会出现“一半程序正常、一半程序断网”
字符集Unicode全程用WSAPROTOCOL_INFOW和wchar_t,避免 A/W 混用
运行库静态链接(/MT)目标进程未必有你的 VC 运行库,动态链接会在加载时失败
附加依赖项ws2_32.libWSCInstallProvider等都在里面
导出只导出WSPStartup用 .def 文件最省心

.def文件长这样:

LIBRARY LspDemo EXPORTS WSPStartup

只需要这一个导出。有些人会顺手把WSPCleanup也导出去,其实没必要——WSPCleanup是lpProcTable里的成员,不是 DLL 导出。

编译完成后检查一下导出表,用dumpbin /exports LspDemo.dll,确认WSPStartup在列且没有名称修饰(用 .def 就不会被 C++ 名称修饰污染)。这一步翻车的人不少:函数忘了加extern "C",导出名变成?WSPStartup@@YAHG...,系统GetProcAddress("WSPStartup")直接返回 NULL,然后安静地什么都不发生。

4.2 注册与验证:从 netsh 到日志文件

注册流程按顺序来:先装本体条目,再装链条目,再调整顺序,最后重启相关进程或直接重启系统。装完先别急着开浏览器,用命令行确认目录里的状态:

# 以管理员身份运行 # 1) 看目录里有没有你的条目 netsh winsock show catalog | Select-String -Pattern "LspDemo" -Context 0,8 # 2) 看注册表里的原始结构(关键字段:ProtocolChain / CatalogEntryId) Get-ChildItem "HKLM:\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries" | ForEach-Object { (Get-ItemProperty $_.PSPath).PackedCatalogItem } | Measure-Object # 3) 起一个测试程序,看日志有没有落盘 Get-Content "$env:TEMP\lspdemo.log" -Tail 20

netsh输出里如果能看到 “分层协议链” 加上你自己的名字,说明目录写入成功。日志文件有内容,才说明 DLL 真的被加载进目标进程了。这两步是分开的,很多人只看了第一步就开始写业务逻辑,结果日志一直空着——因为链条目排在后面,根本没被选中。

验证的测试程序不要用浏览器,用个最朴素的connect循环就够:

// test_client.c —— 只做一件事:连一下本地某个端口,确认链路通 SOCKET s = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); sockaddr_in addr = { 0 }; addr.sin_family = AF_INET; addr.sin_port = htons(8080); inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr); int r = connect(s, (sockaddr*)&addr, sizeof(addr)); printf("connect ret=%d, err=%d\n", r, WSAGetLastError()); closesocket(s);

connect返回 0 说明转发链路是通的;返回WSAECONNREFUSED也说明通了(只是对面没服务);如果返回WSAEPROVIDERFAILEDINIT或者直接卡住,那就是WSPStartup里出问题了,去看日志和返回值。

4.3 卸载与回滚:一定要先想好怎么退出

卸载的顺序和安装严格相反:先删链条目,再删本体条目,最后重排一次顺序。用WSCDeinstallProvider按 GUID 删,注意这个函数会把同 GUID 下的所有条目一起删掉,所以两次安装的条目其实一次就能清完。

BOOL UninstallLsp(GUID* pGuid) { int err = 0; if (WSCDeinstallProvider(pGuid, &err) == SOCKET_ERROR) { // 常见失败:权限不足,或者 GUID 根本不存在 return FALSE; } // 重排顺序,把被删掉的条目从顺序表里挤出去 RewriteProviderOrder(); return TRUE; }

真出事了还有最后的兜底方案:

# 重置整个 Winsock 目录到初始状态,会清掉所有第三方分层条目 netsh winsock reset # 执行完必须重启,不重启不生效

netsh winsock reset是那种“希望一辈子用不上、但必须知道”的命令。它会把目录恢复成系统默认,代价是所有第三方 LSP 全部失效——包括一些正当的网络组件。所以执行前先记下当前netsh winsock show catalog的输出,方便事后对照恢复。

还有一个容易被忽视的点:卸载完 DLL 文件可能删不掉,因为某些进程还映射着它。这时候不要强行删,先把机器重启,再删文件。反过来,如果你在 DLL 还没卸载干净的时候就覆盖了同名文件,下次加载会拿到一个“半新半旧”的映射,行为极其诡异。

5. 避坑与排查:装完 LSP 断网、卡死、DLL 删不掉

这一章是我自己踩过的坑,按“现象 → 原因 → 解决”整理。LSP 这个方向的特点是失败成本极高,一次错误的注册就能让整台机器失去联网能力,所以排查思路要建立在“先恢复、再定位”的顺序上。

5.1 断网三连:现象、原因、解决

现象一:装完瞬间所有程序都连不上网,但ping通、ipconfig正常。原因基本可以锁定在WSPStartup的返回值上。只要它返回非 0,上层就会认为这个提供者初始化失败,socket()直接失败。最常见的写法错误是在LoadNextProvider失败时返回了一个自定义错误码,而不是WSAEPROVIDERFAILEDINIT或者干脆做个降级透传。 解决办法是给WSPStartup加完整的失败保护:所有可能抛异常的地方(注册表读取、LoadLibrary、字符串转换)都用__try / __except包住,任何异常都走同一条兜底路径——如果下层已经加载成功,就把表填好继续转发;如果下层压根没加载上,就返回WSAEPROVIDERFAILEDINIT让上层跳过这一跳。降级比崩溃好,静默比断网好。

现象二:只有一部分程序联网异常,另一部分正常。先看位数。32 位程序读Wow6432Node下的目录,64 位程序读主目录,你如果只注册了一份,就会精确地表现为“某些程序好、某些程序坏”。验证方法是拿tasklist /m LspDemo.dll看哪些进程加载了你的 DLL,再对比这些进程的位数。 第二种可能是协议匹配不上。你的链条目声明的是AF_INET + SOCK_STREAM + IPPROTO_TCP,那只对 TCP 生效;如果测试程序用的是 UDP 或者 IPv6,压根不会经过你,自然“看起来正常”。想让全协议生效,就得为SOCK_DGRAM也装一条链。

现象三:WSPStartup进去了,但日志里全是重复的、无限循环的条目,然后进程崩了。这是递归调用。LSP 内部一旦调用了socket()、gethostbyname()、WSAStartup()这类高层 API,就会再次进入自己的转发链,形成无限递归直到栈溢出。所有内部操作必须走g_NextProcTable里的下层函数,或者用WSASocket并显式指定基座的lpProtocolInfo来绕开链。 我的做法更粗暴一点:LSP 内部禁止出现任何 Winsock API 调用,日志只写文件,不做域名解析,不做地址转换。要解析就在外部进程做,别在链路里做。

5.2 加载与卸载阶段的坑

现象四:一装完,资源管理器、任务管理器都打不开,鼠标转圈。这是死锁,十个里有九个是在DllMain里干了不该干的事。加载器锁被持有的情况下调用LoadLibrary或者等待其他线程,直接锁死。DllMain里只允许做三件事:保存hModule、DisableThreadLibraryCalls、初始化临界区。其他全部推迟。 还有一个隐藏版本:在WSPStartup里创建线程并WaitForSingleObject等它做完初始化。如果那个线程里又要加载 DLL,同样会卡住,因为WSPStartup本身也是在加载流程中被调用的。初始化要么同步做完,要么异步但绝不等待。

现象五:卸载之后 DLL 删不掉,重启后文件又“回来”了。删不掉是因为还有进程映射着它,重启后回来是因为目录条目没删干净,系统重新加载了它。正确的卸载顺序是:先WSCDeinstallProvider删目录条目,再netsh winsock reset清残留,最后重启,重启完再删文件。 如果连WSCDeinstallProvider都失败了,说明注册表权限被改过,这时候直接备份后手动清理Protocol_Catalog9\Catalog_Entries下对应项,风险更高但有效。清理前一定导出注册表备份,这算是我为数不多会强制自己做的“后悔药”步骤。

现象六:netsh winsock show catalog里同一个名字出现了好几条,顺序还很乱。重复安装导致的。每调一次WSCInstallProvider就会新增条目,不做 GUID 查重就会越堆越多。安装器里第一步应该是用WSCEnumProtocols扫一遍,发现相同ProviderId就先卸载再装。顺序乱则是WSCWriteProviderOrder没调或者调错了——这个函数会覆盖整张顺序表,传的数组必须是全量的,漏一个就会让那个提供者从顺序表里消失。

6. 二次开发:把 LSP 改成能落地的可观测中间层

代码跑通之后,真正有意思的部分才开始:怎么把它从“能注入”变成“有用”。我一般会往三个方向改,按投入产出排序。

第一个方向是加白名单,把自己摘出去。LSP 会被加载进几乎所有联网进程,包括你自己的安装器、日志查看器、甚至是你在调试的调试器。这些进程一旦也被劫持,调试链路会变得极其混乱。做法是在WSPConnect里比对当前进程名(GetModuleFileName(NULL, ...)然后取 basename),命中白名单就直接透传,不记日志也不做任何处理。白名单本身放在 DLL 同目录的一个文本文件里,改起来不用重新编译。

第二个方向是把观测点收敛到低频事件上。真正有价值的信号是连接建立、连接关闭、DNS 查询这几个,而不是每次send。我通常只在WSPConnect、WSPCloseSocket上加钩子,输出的字段是“进程名 + 目标地址 + 端口 + 时间戳”。想进一步拿到域名,就得处理getaddrinfo那一层——但那已经不是 LSP 的职责范围了,硬塞进来会让整个 DLL 变得又大又脆。

第三个方向是把日志通道换成进程外的。文件日志在单进程测试时够用,多进程并发时会互相抢句柄。换成命名管道 + 一个独立收集进程,LSP 里只做WriteFile到管道,失败的写入直接丢弃。这样即使收集进程挂了,业务进程也完全不受影响。管道名带上 PID 做隔离,避免多个进程写同一个管道互相阻塞。

改造之后怎么验证?我固定跑三组用例:第一组是纯 TCP 连接(验证主链路),第二组是 UDP 收发(验证多协议链是否都挂上了),第三组是故意让基座 DLL 加载失败(把mswsock.dll路径临时改错,验证降级逻辑是否会安全跳过而不是断网)。第三组最有价值,它能在你上线前把最坏情况演练一遍。

改造项改动位置验证方法
进程白名单WSPConnect入口白名单进程日志为零,业务不受影响
低频观测WSPConnect/WSPCloseSocket长连接程序不卡顿,日志条数与连接数一致
外部日志通道WriteLog实现杀掉收集进程,被测程序仍能正常收发
降级兜底LoadNextProvider失败分支改错基座路径,socket仍可用

有件事我吃过亏:改完之后一定要在干净环境里从零装一遍,不要在半装半卸的环境里测。LSP 的残留状态会让你误以为代码有问题,实际是上一次没删干净。从那以后我每次改完 LSP,都强制走一遍“卸载 →netsh winsock reset→ 重启 → 全新安装 → 三组用例”的完整流程,宁可多花二十分钟,也不想再在一台断网的机器上靠手机查资料。希望这套拆解能帮到你。

本文还有配套的精品资源,点击获取