简介:围绕 Windows 语言服务提供者(LSP)注入的原理与代码实现展开,面向已具备 C++/COM 基础、希望理解系统底层钩子机制与文件处理拦截流程的开发者与安全研究人员。内容从 LSP 的 COM 组件本质讲起,串联组件创建、注册表注册、运行时注入以及请求拦截与转发等关键环节,并给出 IDispatch、IUnknown 与 ILanguageService 等接口的实现思路。压缩包共 38 个文件,约 150KB,以 15 个 cpp 源文件和 7 个 h 头文件为主体,配合 vcxproj/sln 工程配置、bat 注册脚本、dll 与 exe 等编译产物及 def 导出定义,形成可对照阅读的工程样例。已有 1583 人学习。读者可据此梳理 LSP 注入从接口实现、注册卸载到注入代码的完整链路,理解其在安全检测、数据恢复、调试工具等场景中的用法与边界,同时注意底层操作可能带来的稳定性与合规风险,适合作为原理学习与代码剖析的参考。
1. 一个进程启动就自动多出个 DLL:LSP 注入改的到底是什么
有次排查一台机器的怪现象:随便开个程序,用进程查看工具一瞧,连记事本里都挂着一个来路不明的模块。不是驱动,也不是常见的启动项劫持,而是 LSP 注入——把自己的 DLL 注册成 Winsock 分层服务提供者,从此凡是用套接字的进程,都会被ws2_32.dll自动拉起来。这套机制的正式名字是 Winsock 2 SPI,lsp和lsp注入说的就是它:协议链怎么排、WSPStartup怎么拿到下层函数表、三十多个WSPxxx怎么逐个转发、装完怎么排到协议目录最前面。
适合两类人:已经会写 DLL、想搞清楚 Windows 网络过滤层真实调用链的开发者;以及接手了"前人在服务器上装过一层东西没卸干净、现在网络时好时坏"这种遗留问题的同学。下面按原理、核心代码、安装卸载、排查、二次开发的顺序往下拆。
2. 原理拆解:Winsock 2 SPI 协议链是怎么把 DLL 塞进每个进程的
2.1 从 WSAStartup 到 WSPStartup:一次 socket 调用到底走了几层
很多人以为ws2_32.dll就是网络实现,其实它更像一个调度器。真正的收发动作由服务提供者(Service Provider)完成,ws2_32.dll只负责在中间查表、分发。这个"表"就是协议目录,落盘在HKLM\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9下面。
进程第一次调用WSAStartup时,ws2_32.dll会做几件事:读取协议目录,把所有条目读成WSAPROTOCOL_INFOW数组;对每个条目检查它的ProtocolChain,如果ChainLen > 1说明这是个分层链,需要把链上每一层的 DLL 都加载进来;加载方式是LoadLibrary加GetProcAddress("WSPStartup"),然后按链的顺序从下往上调用各自的WSPStartup,把下层的函数表一层层传上去。
于是调用链变成这样:
应用代码 socket()/connect()/send() ↓ ws2_32.dll (查当前进程的 provider 表,找到匹配的链) ↓ 你的 LSP 的 WSPConnect / WSPSend ... ← 代码跑在目标进程里 ↓ g_NextProcTable.lpWSPConnect(下层) ↓ 基础提供者(TCP/IP),最终进内核注意一个容易搞混的点:WSPStartup只会被调用一次,在进程初始化阶段;之后每次 socket 操作走的都是函数表里的指针。这意味着挂接逻辑必须放在WSPStartup里完成,等业务代码跑起来再想改函数表就晚了。
还有一层认知要摆正:这个 DLL 是加载进目标进程的地址空间的,它的权限就是宿主进程的权限。普通用户跑的程序,LSP 代码也只有普通用户权限;想让管理员进程也加载,得由管理员身份的进程去触发WSAStartup。所以别指望它天然拥有系统级能力。
2.2 协议链与协议目录:WSAPROTOCOL_INFOW 里哪几个字段是命门
一条目录记录就是一个WSAPROTOCOL_INFOW结构,字段不少,但真正决定行为的就那么几个:
| 字段 | 作用 | 改错的后果 |
|---|---|---|
dwCatalogEntryId | 条目在目录里的唯一编号,链里靠它引用 | 编号对不上,链断,WSAStartup直接失败 |
ProviderId | 提供者 GUID,安装时由你生成 | 和 DLL 里写的不一致,卸载时找不到 |
ProtocolChain.ChainLen | 链长,1 表示基础协议,≥2 表示分层 | 写成 1 就退化成普通条目,永远不会调你的WSPStartup |
ProtocolChain.ChainEntries[] | 从上层到基础的编号序列 | 顺序反了会把自己递归调死 |
dwServiceFlags1 | 支持的能力位,如XP1_CONNECTIONLESS | 和基础协议不一致会导致某些程序建套接字失败 |
iAddressFamily/iSocketType/iProtocol | 匹配维度 | 只写AF_INET就拦不到 IPv6 流量 |
szProtocol | 显示名,在netsh winsock show catalog里能看到 | 只是个名字,但排查时全靠它认人 |
链的结构是最容易翻车的地方。假设你把自己的条目编为 1000(分层),基础 TCP 条目是 1001,那么一个两层的链长这样:
ChainLen = 2 ChainEntries[0] = 1000 ; 最上层,也就是你的 LSP ChainEntries[1] = 1001 ; 基础协议,干活的那一层再往下套一层就变成[1000, 1002, 1001],转发时每层把自己的头摘掉再传给下一层。这个"摘头"动作在WSPStartup里完成,后面代码会写。
匹配规则也要清楚:应用调socket(af, type, protocol)时,ws2_32从目录按顺序找第一个iAddressFamily、iSocketType、iProtocol都匹配的条目。所以想让自己的链被选中,光装进去不够,还必须排到目录最前面。这一步是很多人第一次做 LSP 时漏掉的,装完了、日志一条没有,以为代码写错,其实是顺序没调。
2.3 注入时机:DLL 被加载进进程的真实边界
说"注入"其实有点误导,它没有远程线程、没有CreateRemoteThread、没有 APC 排队,纯粹是 DLL 被正常加载。加载时机就在目标进程第一次WSAStartup的时候,由ws2_32.dll主动LoadLibrary。所以它的覆盖面取决于"谁调了 WSAStartup",而不是"谁在运行"。
有几类流量它天然拦不到,这点在评估方案时必须先想清楚:
- 走内核态网络栈的服务。某些 Web 服务用的是内核态的 HTTP 栈,压根不经过用户态 Winsock,LSP 代码一行都不会执行。
- 自己实现协议栈、直接和设备驱动打交道的程序。
- 进程内部把
ws2_32.dll静态链接或用非标准方式加载的极端情况(少见,但遇到过)。 - 只做文件映射、不走套接字的本地通信。
反过来,覆盖面又很广:浏览器、下载工具、IM、游戏客户端,只要用了 Winsock,全都会被加载。这也是为什么这东西装错了后果特别严重——不是某个程序坏,是所有联网程序一起坏。我在第 5 章会专门写这块的排查。
另外还有个执行顺序的坑:如果宿主进程加载你的 DLL 时,你的DllMain里又调了WSAStartup或者创建套接字,就会触发再次加载,形成递归。常见做法是在DllMain里只做最保守的初始化(保存模块句柄、初始化临界区),任何和网络相关的动作都推到WSPStartup里去做。
3. 核心实现:WSPStartup 拿到下层函数表,再逐个挂接 WSPxxx
3.1 工程骨架与位数配置:一个 DLL 要出两份
先看目录,一个能跑的最小工程大概长这样:
lsp-audit/ ├── src/ │ ├── dllmain.c // 入口,只存模块句柄 │ ├── wspstartup.c // 核心:拿下层表、填自己的表 │ ├── hooks.c // 挂接的 WSPConnect / WSPSend / WSPRecv │ ├── forward.c // 其余 WSPxxx 的批量转发 │ ├── install.c // 安装、卸载、顺序调整 │ └── log.c // 落盘日志 ├── include/ │ └── lsp.h ├── lsp-audit.def // 导出 WSPStartup └── lsp-audit.vcxproj.def文件必须导出WSPStartup,名字不能带修饰:
LIBRARY lsp-audit EXPORTS WSPStartup位数是第一个硬性要求。64 位系统上,32 位进程和 64 位进程读的是两套独立的协议目录,ws2_32也是两份。你只装 32 位版本,那 64 位程序全都不受影响;反之只装 64 位,32 位程序全不受影响。所以工程里至少要出Win32和x64两个目标,安装的时候两个都要装。这点在第 5 章还会展开,属于最高频的"装了没效果"。
链接依赖只有ws2_32.lib,头文件是<winsock2.h>和<ws2spi.h>,注意包含顺序,winsock2.h必须在windows.h之前,否则会撞上一堆重定义。
3.2 WSPStartup:四步拿到下层函数表
这是整个项目的心脏。逻辑可以拆成四步:确定下一层是谁、把下一层的 DLL 加载起来、构造一个"摘掉自己"的协议信息传下去、复制下层函数表并替换挂接点。
// wspstartup.c #include <winsock2.h> #include <ws2spi.h> #include "lsp.h" WSPPROC_TABLE g_NextProcTable; // 下层的原始函数表,转发时全靠它 WSPUPCALLTABLE g_UpCallTable; // 上层回调表,拿下层路径要用 static DWORD g_NextProviderId; // 下一层的目录编号 int WSPAPI WSPStartup( WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { if (lpProtocolInfo == NULL || lpProcTable == NULL) return WSAEPROVIDERFAILEDINIT; g_UpCallTable = UpcallTable; // 步骤 1:链形如 [自己, 下层, ..., 基础协议],取 ChainEntries[1] if (lpProtocolInfo->ProtocolChain.ChainLen < 2) return WSAEPROVIDERFAILEDINIT; // 安装时链写错了 g_NextProviderId = lpProtocolInfo->ProtocolChain.ChainEntries[1]; // 步骤 2:通过回调表问出下层 DLL 的完整路径 WCHAR szPath[MAX_PATH] = {0}; int nPathLen = MAX_PATH, nErr = 0; if (UpcallTable.lpWPUGetProviderPath(&g_NextProviderId, szPath, &nPathLen, &nErr) == SOCKET_ERROR) return WSAEPROVIDERFAILEDINIT; HMODULE hNext = LoadLibraryW(szPath); if (hNext == NULL) return WSAEPROVIDERFAILEDINIT; LPWSPSTARTUP pfnNextStartup = (LPWSPSTARTUP)GetProcAddress(hNext, "WSPStartup"); if (pfnNextStartup == NULL) return WSAEPROVIDERFAILEDINIT; // 步骤 3:把本层从链头摘掉,再交给下层初始化 WSAPROTOCOL_INFOW info = *lpProtocolInfo; info.ProtocolChain.ChainLen--; for (int i = 0; i < info.ProtocolChain.ChainLen; i++) info.ProtocolChain.ChainEntries[i] = lpProtocolInfo->ProtocolChain.ChainEntries[i + 1]; int nRet = pfnNextStartup(wVersionRequested, lpWSPData, &info, UpcallTable, &g_NextProcTable); if (nRet != 0) return nRet; // 下层都起不来,别硬撑,原样上报 // 步骤 4:先整体复制,再替换要挂接的那几个 *lpProcTable = g_NextProcTable; lpProcTable->lpWSPConnect = WSPConnect; lpProcTable->lpWSPSend = WSPSend; lpProcTable->lpWSPRecv = WSPRecv; lpProcTable->lpWSPCloseSocket = WSPCloseSocket; LoggerInit(); // 日志在这里初始化,别放到 DllMain return 0; }几个参数值得单独说。wVersionRequested是宿主进程要的 Winsock 版本,原样往下传就行,不要自己改成 2.2,有些老程序对版本很敏感。lpWSPData是出参,由下层填写实际协商的版本和描述,我们不动。lpProtocolInfo里的ChainEntries[0]一定是自己,[1]才是下一层——如果安装时把顺序写反,这里取到的会是自己,于是LoadLibrary加载的还是自己,WSPStartup无限递归,进程直接栈溢出。这个错误我在第 5 章会给出识别特征。
步骤 4 里的顺序是有讲究的:必须先整体复制再改。如果写成g_NextProcTable.lpWSPConnect = WSPConnect,那转发函数里再调g_NextProcTable.lpWSPConnect就是调自己,一次connect直接死循环。血泪经验,别问怎么知道的。
还有一点,LoggerInit()放在这里而不是DllMain,是因为DllMain里拿锁、开文件都有加载器锁死锁的风险。WSPStartup阶段加载器锁已经释放,安全得多。
3.3 三十多个 WSPxxx 怎么转发:用宏批量生成
WSPPROC_TABLE里有三十多个函数指针,真正需要动手改的可能就四五个,剩下的全是原样透传。手写三十个函数既枯燥又容易抄错参数,用宏批量生成是常见做法:
// forward.c —— 参数列表整体当宏参数传,避免展开时逗号打架 #define FORWARD(fn, parms, argv) \ int WSPAPI fn parms { \ return g_NextProcTable.lp##fn argv; \ } FORWARD(WSPAccept, (SOCKET s, struct sockaddr *addr, LPINT addrlen, LPCONDITIONPROC lpfnCondition, DWORD_PTR dwCallbackData, LPINT lpErrno), (s, addr, addrlen, lpfnCondition, dwCallbackData, lpErrno)) FORWARD(WSPBind, (SOCKET s, const struct sockaddr *name, int namelen, LPINT lpErrno), (s, name, namelen, lpErrno)) FORWARD(WSPListen, (SOCKET s, int backlog, LPINT lpErrno), (s, backlog, lpErrno)) FORWARD(WSPGetPeerName, (SOCKET s, struct sockaddr *name, LPINT namelen, LPINT lpErrno), (s, name, namelen, lpErrno))宏展开后lp##fn拼出的是lpWSPAccept、lpWSPBind,正好对上WSPPROC_TABLE的成员名,所以不用逐个核对。参数列表整体加括号当宏参数传,是为了防止参数里的逗号把宏拆散——这是 C 预处理器的老规矩。
这种写法有个代价:编译错误信息会变得很难读,宏展开后的行号指向定义处而不是调用处。调试期我一般先手写两三个确认对得上,剩下的再批量套宏。另外不同 SDK 版本里WSPPROC_TABLE的成员数量会有出入,转发宏只覆盖你实际用到的那些,其余靠*lpProcTable = g_NextProcTable整体复制兜底,比一个个补要稳妥。
如果嫌宏太黑,也可以写个脚本从ws2spi.h里把函数原型抽出来生成.c文件,一次性生成、之后手改。项目一旦超过三层链,生成比手写省心。
3.4 挂接点选择:每个 WSPxxx 能拿到什么
不是所有函数都值得挂。挑挂接点要看你需要什么信息:
| 挂接点 | 能拿到的信息 | 典型用途 |
|---|---|---|
WSPConnect | 目标地址、端口,调用前后都能插手 | 连接审计、按地址决定放行还是拒绝 |
WSPSend/WSPSendTo | 发送缓冲区内容、长度 | 协议特征识别、请求记录 |
WSPRecv/WSPRecvFrom | 接收缓冲区、实际字节数 | 响应记录、状态码提取 |
WSPCloseSocket | 套接字句柄,可关联会话 | 会话时长、流量统计 |
WSPIoctl | 控制码,如FIONBIO、FIONREAD | 非阻塞状态同步 |
举个最常用的WSPConnect写法,只记录不阻断:
// hooks.c int WSPAPI WSPConnect(SOCKET s, const struct sockaddr *name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, LPINT lpErrno) { // 只处理 IPv4 的连接请求,其它原样放行 if (name != NULL && namelen >= (int)sizeof(SOCKADDR_IN) && name->sa_family == AF_INET) { const SOCKADDR_IN *p = (const SOCKADDR_IN *)name; LogConnect(s, ntohs(p->sin_port), p->sin_addr.S_un.S_addr); } return g_NextProcTable.lpWSPConnect(s, name, namelen, lpCallerData, lpCalleeData, lpSQOS, lpGQOS, lpErrno); }参数里的name是目标地址,namelen是它的长度,判断长度是为了防止某些程序传一个截断的结构体导致越界读——这种崩溃在宿主进程里发生,堆栈一看全是别人的代码,很难定位。s是套接字句柄,可以拿它当 key 关联后续的 send/recv。
想阻断连接,就在调用下层之前返回SOCKET_ERROR并把*lpErrno设成WSAECONNREFUSED,应用侧看到的就是"连接被拒绝",表现和真实拒绝一样,不会暴露中间层的存在。但要注意:只拦WSPConnect拦不住 UDP 的WSPSendTo,也拦不住WSPAccept进来的入站连接,按需补挂接点。
WSPSend里能直接读到业务数据,这是做协议识别的入口。但这里有个性能红线:别在WSPSend里做同步磁盘写。一次大文件下载会有几十万次调用,每次都fflush的话,宿主进程会被拖到几乎不可用。稳妥做法是写进环形缓冲区,由独立线程批量落盘。
4. 安装与卸载:把协议链写进 Protocol_Catalog9 并排到最前
4.1 安装三步:装条目、找编号、调顺序
安装比写代码更容易出错,因为出错往往没有明显报错,只是"没生效"。完整流程是三步:
- 用
WSCInstallProvider把 DLL 和一个"分层条目 + 基础条目"的数组注册进协议目录; - 重新枚举目录,按自己的 GUID 找到系统分配的新编号;
- 用
WSCWriteProviderOrder把这个编号排到最前面,让socket()匹配时优先选中它。
// install.c int InstallLSP(void) { WCHAR szDll[MAX_PATH] = {0}; GetModuleFileNameW(g_hSelf, szDll, MAX_PATH); // 1. 从目录里找一个 TCP/IPv4 基础协议当模板(ChainLen == 1) WSAPROTOCOL_INFOW base = {0}; if (!FindBaseTcpProvider(AF_INET, SOCK_STREAM, IPPROTO_TCP, &base)) return -1; // 2. 组装两条记录:分层条目 + 基础条目 WSAPROTOCOL_INFOW arr[2] = {0}; arr[0] = base; // 字段先抄基础协议 arr[0].ProviderId = g_LspGuid; // 换成自己的 GUID arr[0].ProtocolChain.ChainLen = 1; // 安装时先写 1,系统会回填成 2 wcscpy_s(arr[0].szProtocol, WSAPROTOCOL_LEN + 1, L"AuditLayer over TCP/IPv4"); arr[1] = base; // 链尾,保持原样 int nErr = 0; if (WSCInstallProvider(&g_LspGuid, szDll, arr, 2, &nErr) == SOCKET_ERROR) return nErr; // 10013 权限不足 / 10014 参数错 // 3. 找到刚装进去的条目编号 DWORD dwNewId = FindCatalogIdByGuid(&g_LspGuid); if (dwNewId == 0) return -1; // 4. 枚举全部条目,把新编号排到最前,其余保持原序 DWORD dwBytes = 0; WSCEnumProtocols(NULL, NULL, &dwBytes, &nErr); // 首次调用返回所需字节数 DWORD n = dwBytes / sizeof(WSAPROTOCOL_INFOW); WSAPROTOCOL_INFOW *pAll = (WSAPROTOCOL_INFOW *)malloc(dwBytes); if (WSCEnumProtocols(NULL, pAll, &dwBytes, &nErr) == SOCKET_ERROR) { free(pAll); return nErr; } DWORD *pOrder = (DWORD *)malloc(sizeof(DWORD) * n); pOrder[0] = dwNewId; DWORD k = 1; for (DWORD i = 0; i < n; i++) if (pAll[i].dwCatalogEntryId != dwNewId) pOrder[k++] = pAll[i].dwCatalogEntryId; int nRet = (WSCWriteProviderOrder(pOrder, k) == SOCKET_ERROR) ? nErr : 0; free(pOrder); free(pAll); return nRet; }arr[0].ProtocolChain.ChainLen = 1这行看着别扭,其实是故意的:安装时系统会根据数组里的两条记录重新构造链,把ChainLen改成 2 并把两条编号分别填进ChainEntries[0]和[1]。你手工填编号反而是白填,因为新编号要装完才分配。
WSCEnumProtocols的两次调用模式(第一次传 NULL 拿字节数、第二次传缓冲区)是 Winsock 里的通用套路,注意dwBytes是字节数不是条目数,除一下sizeof才对。
WSCWriteProviderOrder需要管理员权限,且只修改调用进程位数的目录。64 位进程调它改的是 64 位目录,32 位程序要单独跑一次 32 位的安装器。这一点在部署脚本里必须体现,不能只跑一次了事。
4.2 装完怎么确认真的生效
装完不验证,等于没装。三个层次的确认手段,建议都过一遍:
第一个,看目录。在管理员命令行里跑:
netsh winsock show catalog输出里能找到你写的szProtocol名字,重点看它是不是排在前面,以及Chain那一列是不是2 1000 1001这种形式。如果名字在但顺序靠后,说明第 4 步没成功。
第二个,看加载。随便启动一个联网程序,用进程查看工具(能列模块的那种)看它加载的 DLL 列表里有没有你的模块。这一层能确认WSPStartup被调到了。
第三个,看日志。日志里应该能看到宿主进程名和真实的连接记录。如果前两层都过了但日志为空,问题多半出在挂接点没替换成功——回去检查*lpProcTable = g_NextProcTable之后那几行赋值是不是被别的代码覆盖了。
卸载是反过来的顺序:先WSCDeinstallProvider(&g_LspGuid, &nErr)把条目删掉,再把目录顺序恢复。顺序不能反,先删条目再调顺序的话,顺序数组里还留着已经不存在的编号,WSCWriteProviderOrder会返回错误,目录就停在一个半残状态。
// 卸载:先删条目,再刷顺序 int UninstallLSP(void) { int nErr = 0; if (WSCDeinstallProvider(&g_LspGuid, &nErr) == SOCKET_ERROR) return nErr; ReorderRemoveGuid(&g_LspGuid); // 重新枚举并写回不含本层的顺序 return 0; }5. 避坑与排查:装错一次,整台机器上不了网
LSP 的坑有个共同特点:错误发生在别人进程里,堆栈、日志、崩溃报告全都不指向你的代码。下面几条是我实际踩过、也见别人踩过的。
5.1 只装了 32 位:一半程序没反应,另一半正常
现象:装完之后,用 64 位浏览器访问网页完全正常,日志一条没有;换 32 位的老客户端立刻就有记录。或者反过来。
原因:64 位系统的协议目录是两套,ws2_32.dll也是两份,分别给 32 位和 64 位进程用。你的安装器如果是 32 位的,WSCInstallProvider改的只能是 32 位目录。
解决:编译两个目标平台,安装器也出两份。部署时先跑 64 位版本再跑 32 位版本,或者在 64 位安装器里显式调用 32 位的安装逻辑。验证方式是分别用 32 位和 64 位的netsh看目录——32 位版本在SysWOW64目录下。
5.2 装了但完全没生效:条目在,就是不加载
现象:netsh winsock show catalog里能看到自己的条目,名字、链长都对,但任何程序都不加载,日志空白。
原因:九成是顺序问题。协议目录是有序表,socket()按顺序匹配第一个符合iAddressFamily/iSocketType/iProtocol的条目。你的条目排在基础协议后面,永远轮不到。
解决:确认WSCWriteProviderOrder真的成功了,返回值要检查。另一个隐蔽原因是排序数组里少了条目——枚举拿到的总数和实际写入的数量不一致,系统会拒绝整个顺序,报错但不生效。写入前先断言k == n。
5.3 装完直接断网:所有程序都连不上
现象:安装脚本跑完,浏览器打不开,即时通讯软件全掉线,甚至 ping 通但应用层全部超时。
原因:这是最严重的一种。通常是WSPStartup里return了非零值——比如LoadLibraryW失败、GetProcAddress拿不到WSPStartup、或者下层初始化返回错误。链上任何一层起不来,整个WSAStartup就失败,宿主进程彻底失去网络能力。
解决:第一件事是恢复网络,用管理员权限跑:
netsh winsock reset然后重启。这条命令会重建协议目录,把非标准条目清掉,是必备的后悔药。恢复之后再回头查:szPath是不是取到了正确的 DLL 路径(64 位进程拿到 32 位路径就会LoadLibrary失败)、DLL 依赖的运行时库在目标机器上有没有。
写代码时的自保做法是:任何一步失败都先尝试退化到透传,而不是直接返回错误。理想情况下,如果拿不到下层表,宁可让调用失败也不要让整个进程起不来。但现实是链式结构不允许这幺做——WSPStartup失败就是失败。所以更可靠的做法是在WSPStartup里把所有可能失败的点都记日志,出问题时能立刻定位到是哪一步。
5.4 无限递归:进程一联网就崩
现象:目标进程启动后立刻崩溃,事件日志里是栈溢出,或者进程直接消失,没有任何提示。
原因:协议链的顺序写反了,ChainEntries[0]填了基础协议的编号,ChainEntries[1]填了自己的。于是WSPStartup里取ChainEntries[1]拿到的是自己,LoadLibrary加载的还是同一个 DLL,GetProcAddress拿到的还是自己的WSPStartup,一路递归到底。
解决:安装前打印一遍准备写入的数组,人工核对arr[0].ProviderId == g_LspGuid。另一个判断方法是看加载次数——正常情况一个进程只加载一次你的 DLL,工具里看到同一个模块反复出现就是这个问题。
5.5 装上了但机器变卡:性能黑洞
现象:装上之后功能都对,但整机明显变慢,尤其是下载、视频这类高吞吐场景。
原因:在WSPSend/WSPRecv里做了同步 IO。这两个函数一秒能被调用上万次,每次写文件、每次OutputDebugString,代价都会被放大几万倍。OutputDebugString尤其隐蔽——没有调试器监听时它会走异常路径,比有监听时慢得多。
解决:日志改成内存环形缓冲加独立线程批量落盘;OutputDebugString只在调试构建里开。另外转发函数里不要加锁,非要用共享状态就上无锁队列或者线程局部存储。
6. 二次开发:把它改成只拦指定进程的审计层
原样跑通只是第一步,真正落地时最常改的方向是"别拦所有程序,只盯我关心的那几个"。改起来比想象中简单,因为WSPStartup是在宿主进程里执行的,问一句"我是谁"就行。
// 在 WSPStartup 步骤 4 之前插入:非目标进程直接透传 static const WCHAR *g_TargetExe[] = { L"target-app.exe", L"helper.exe" }; static BOOL IsTargetProcess(void) { WCHAR szExe[MAX_PATH] = {0}; GetModuleFileNameW(NULL, szExe, MAX_PATH); // NULL = 宿主进程自身 const WCHAR *pName = wcsrchr(szExe, L'\\'); pName = (pName != NULL) ? pName + 1 : szExe; for (int i = 0; i < _countof(g_TargetExe); i++) if (_wcsicmp(pName, g_TargetExe[i]) == 0) return TRUE; return FALSE; }调用点放在拿到g_NextProcTable之后、替换函数表之前:
*lpProcTable = g_NextProcTable; if (!IsTargetProcess()) return 0; // 不认识的进程,一行代码都别挂这样 DLL 依然会被加载进所有进程(加载是ws2_32决定的,拦不住),但只有目标进程会真正进入你的挂接逻辑,其余进程的函数表和原生完全一致,零开销。这是个很实用的取舍:用一点点加载开销换全局的兼容性。
再往下走,就该考虑还要不要继续用 LSP 了。它有几个结构性限制:只能在用户态、
本文还有配套的精品资源,点击获取