Winsock LSP 注入机制:协议链、WSPStartup 与函数表转发 简介围绕LSP注入技术整理的工程源码包面向具备C与COM编程基础、希望深入理解Windows底层钩子机制的安全研究人员与开发学习者。内容围绕LSP组件的创建、注册表注册、运行时注入与请求拦截处理展开涉及IDispatch、IUnknown及ILanguageService等接口的实现要点并补充安全检测、数据恢复、调试开发工具及恶意软件滥用等应用场景帮助读者建立从原理到代码的对应关系。包内共38个文件以15个cpp源文件和7个头文件承载核心逻辑配合vcxproj、sln等Visual Studio工程文件以及dll、def、bat等编译产物与注册辅助脚本压缩包约150KB体量轻便、模块划分清晰可按安装、公共组件与接口实现逐层阅读。目前已有1582人学习下载。读者可从中获得一套可编译运行的LSP注入示例工程顺着源码理解COM组件从接口实现、注册到注入加载的完整链路并借助注册脚本与工程配置排查编译、注册及加载阶段的常见问题同时也能通过应用场景与风险提示把握该技术的合法使用边界。1. LSP 注入不是远程线程它把 DLL 变成 Winsock 协议链的一节lsp 注入常被误会成远程线程注入打开目标进程、写入内存、再拉起一个线程。真正的 lsp注入 发生在 Winsock 2 的 SPI 层它把自定义 DLL 注册成协议链中的一层应用一调用 socket 或 connect系统就按 Catalog 条目加载这层 DLL。整个过程不碰目标进程的线程也不改应用代码却能让每一次发送和接收先经过你的函数表。搞懂它等于搞懂 Winsock 的加载顺序、协议链结构和函数表转发装完 LSP 后浏览器打不开网页根子往往也在这三件事上。接下来从协议链为什么能被系统主动加载讲起。2. 协议链Winsock 为什么愿意加载一个陌生 DLL2.1 SPI 的三层角色应用、WS2_32 与服务提供者Winsock 2 的 SPI 是一套用户态接口不是内核驱动。应用调用的 socket、connect、send 来自 ws2_32.dll这一层是前端负责把应用 API 翻译成对服务提供者的 WSP* 调用。服务提供者才是真正干活的对象基础协议提供者处理 TCP、UDP分层服务提供者夹在中间做协议扩展、流量观测或数据转换。LSP 就是分层服务提供者它的 DLL 导出一组 WSP 函数其中入口是 WSPStartup。系统怎么知道该加载哪个 DLL答案在协议目录里。每个协议条目由 WSAPROTOCOL_INFOW 描述关键字段包括 dwCatalogEntryId、iAddressFamily、iSocketType、iProtocol、dwServiceFlags1以及 ProtocolChain。ProtocolChain 是一个定长数组结构dwChainLen 表示链长度ChainEntries 按顺序存放协议条目的 Catalog ID。链的最后一环是基础协议前面的每一环都是分层协议。应用枚举协议时看到的协议链条目就是系统用来创建 socket 的路线图。typedef struct _WSAPROTOCOL_INFOW { DWORD dwServiceFlags1; DWORD dwProviderReserved; GUID ProviderId; DWORD dwCatalogEntryId; WSAPROTOCOLCHAIN ProtocolChain; // 链长度与 Catalog ID 数组 int iAddressFamily; int iSocketType; int iProtocol; WCHAR szProtocol[WSAPROTOCOL_LEN 1]; // 其余字段省略 } WSAPROTOCOL_INFOW;这段结构里最值得盯的是 ProtocolChain。dwChainLen 不是随便填的系统对链长度有上限常见实现里最多七层。ChainEntries 的最后一环必须指向一个基础协议条目否则协议链不成立。安装 LSP 时通常要准备两类条目一类描述分层协议本身另一类描述“分层协议 基础协议”的协议链。应用选择的是协议链条目系统按链逐层加载 DLL再逐层调用 WSPStartup。也就是说LSP 不是被动等别人来注入而是被协议目录“登记”成了网络调用路径的一部分。注意协议目录是系统级配置任何一条链写坏受影响的不只是测试程序可能是整台机器上所有走 Winsock 的进程。动手前先做快照并备份对应注册表分支。2.2 协议链的加载时序从 socket 调用到 WSPStartup一次 socket 创建会触发一条清晰的加载链。应用调用 WSASocketws2_32 根据传入的协议信息或默认条目找到协议链然后按 ChainEntries 的顺序加载每一层 DLL。每一层 DLL 被加载后系统调用它的 WSPStartup把当前层的协议条目、向上调用表和一张待填充的函数表传进去。分层 LSP 在 WSPStartup 里不能自己“凭空”实现所有网络操作它要找到下一层调用下一层的 WSPStartup拿到下一层的函数表再把自己的函数表返回给上一层。这个顺序决定了 LSP 的行为边界。WSPStartup 是每层协议初始化的入口不是每个 socket 调一次函数表在协议层初始化时建立后续同一协议链上的 socket 共享这套函数指针。真正的连接状态、缓冲区、重叠 I/O 上下文要在各个 WSP 函数里按 socket 维护。很多实现把“初始化下层”和“维护连接上下文”混在一起结果在 WSPStartup 里做了重活或者把全局锁带进了数据路径后面就会看到卡顿和死锁。从应用视角看调用栈像这样应用调用 sendws2_32 找到协议链最上层 LSP 的 WSPSendLSP 处理后调用下一层 WSPSend下一层可能是另一个 LSP也可能是基础协议。基础协议把数据交给内核网络栈。返回时逐层回传。应用只拿到最终结果完全不知道中间多了一层。这个“无侵入”是 LSP 最大的吸引力也是它最大的风险应用没有选择权链上任何一层返回错误应用只看到 socket 调用失败。2.3 无侵入的代价链越长路径越重协议链的设计动机很直接让网络功能可以分层组合而不必改应用也不必改基础协议。对学习 Winsock 加载机制来说LSP 是最直观的样本对做协议观测的工具来说它能在应用层缓冲区附近拿到发送和接收的数据长度、地址信息、socket 状态。但它把代价转嫁给了整条路径每个 socket 调用都要多走一层用户态函数链越长函数调用和上下文切换越多。数据量大时这种开销不能忽略。更麻烦的是故障域。LSP 一旦注册成默认协议链的一环所有新建 socket 都可能经过它。如果 WSPStartup 返回失败应用可能连 socket 都创建不出来如果 WSPSend 里写了阻塞日志所有发送都会被拖慢如果卸载时只删 DLL 不删协议链系统还会尝试加载一个不存在的路径。常见的实现思路是把 LSP 做成“薄层”数据路径只做轻量记录和转发重活放到队列或单独线程安装和卸载走官方 API不手改二进制 Catalog。这样虽然不能消除风险但至少能把故障限制在可控范围。3. WSPStartup一次调用里完成的函数表接龙3.1 入参里的三个关键对象协议条目、向上调用表、函数表WSPStartup 的签名不长但每个参数都决定后面能不能跑通。常见原型如下INT WSPAPI WSPStartup( WORD wHighVersion, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE upcallTable, LPWSPPROC_TABLE lpProcTable );wHighVersion 是调用方支持的 SPI 版本通常按原值往下传。lpProtocolInfo 是当前层的协议条目里面的 ProtocolChain 描述了整条链也包含当前层在链中的位置。upcallTable 是向上调用表提供 WPUCreateSocketHandle、WPUQueryBlockingCallback 等回调分层协议在需要创建新句柄或查询阻塞状态时会用到。lpProcTable 是待填充的函数表WSPStartup 返回前必须把这一层支持的 WSP 函数填进去。很多初学者会直接拿 lpProtocolInfo 去调用下一层这是不对的。下一层看到的协议条目应该是“从下一层开始到基础协议”的那一段链而不是整条链。当前层在 ChainEntries 中的位置决定了要截掉多少。常见做法是复制一份 WSAPROTOCOL_INFOW把 ChainEntries 数组从当前层的下一项开始拷贝dwChainLen 改成剩余长度。这样下一层调用 WSPStartup 时它看到的链才是合法的。3.2 找到下一层ChainEntries 与 WSCGetProviderPath找到下一层的步骤可以拆成四步定位当前层在链中的下标取下一个 Catalog ID用 WSCGetProviderPath 查出对应 DLL 路径加载 DLL 并取 WSPStartup 地址。WSCGetProviderPath 的入参是 ProviderId 或 Catalog ID 对应的 GUID实际实现里通常先用 WSCEnumProtocols 枚举出条目匹配 dwCatalogEntryId拿到 ProviderId再查路径。路径可能是环境变量形式需要展开。// 简化示意定位下一层并加载其 WSPStartup int myIndex -1; for (DWORD i 0; i info-ProtocolChain.dwChainLen; i) { if (info-ProtocolChain.ChainEntries[i] info-dwCatalogEntryId) { myIndex static_castint(i); break; } } if (myIndex 0 || myIndex 1 static_castint(info-ProtocolChain.dwChainLen)) { return WSAEINVAL; // 当前层不在链中或没有下一层 } DWORD nextCatalogId info-ProtocolChain.ChainEntries[myIndex 1]; GUID nextProviderId LookupProviderIdByCatalogId(nextCatalogId); // 枚举匹配 WCHAR dllPath[MAX_PATH] {}; int pathLen MAX_PATH; if (WSCGetProviderPath(nextProviderId, dllPath, pathLen, err) SOCKET_ERROR) { return err; } HMODULE nextDll LoadLibraryW(dllPath); auto nextStartup reinterpret_castLPWSPSTARTUP( GetProcAddress(nextDll, WSPStartup)); if (!nextStartup) { return WSAEPROVIDERFAILEDINIT; } WSAPROTOCOL_INFOW nextInfo *info; nextInfo.dwCatalogEntryId nextCatalogId; nextInfo.ProtocolChain.dwChainLen info-ProtocolChain.dwChainLen - (myIndex 1); for (DWORD i 0; i nextInfo.ProtocolChain.dwChainLen; i) { nextInfo.ProtocolChain.ChainEntries[i] info-ProtocolChain.ChainEntries[myIndex 1 i]; } WSPPROC_TABLE nextProcTable {}; int ret nextStartup(wHighVersion, nextInfo, upcallTable, nextProcTable); if (ret ! 0) { return ret; }这段代码的关键不是语法而是三个动作截链、加载、调用。截链保证下一层看到合法协议条目加载拿到下一层入口调用让下一层填充它自己的函数表。nextProcTable 必须保存下来后面所有转发都靠它。如果当前层是链的最后一环说明它本身就是基础协议不应该再找下一层。实际实现里还要处理 DLL 加载失败、重复加载、多个协议条目共享同一 DLL 的情况通常会做引用计数或按 Catalog ID 缓存。3.3 函数表替换与重入为什么不能在 LSP 里直接调 send拿到 nextProcTable 后典型做法是先整体复制到 lpProcTable再替换需要拦截的函数。比如只关心发送和接收就替换 lpWSPSend、lpWSPRecv、lpWSPWSASend、lpWSPWSARecv其余保持下一层的实现。这样未替换的函数自动转发减少出错面。函数表是协议层级别的不是每个 socket 一份拦截函数里要根据 socket 找到对应的下一层函数表和连接上下文。最危险的错误是在 LSP 的 WSPSend 里调用 ws2_32 的 send。那会再次进入协议链可能回到同一个 WSPSend形成递归即使没有立刻递归也会绕开下一层函数表破坏调用顺序。正确做法是调用保存的 nextProcTable.lpWSPSend把 socket、缓冲区、长度、标志原样传下去。接收方向同理。所有转发函数都要遵守这个原则LSP 是链中的一环不是链的替代品。static WSPPROC_TABLE g_nextProc; // 单链简化多链应按协议条目保存 static std::mutex g_ctxLock; static std::unordered_mapSOCKET, ConnContext g_ctx; int WSPAPI MyWSPSend( SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine, LPWSATHREADID lpThreadId, LPINT lpErrno) { // 只记录元数据不在这里做阻塞 I/O { std::lock_guardstd::mutex lock(g_ctxLock); auto it g_ctx.find(s); if (it ! g_ctx.end()) { it-second.sendCalls; } } return g_nextProc.lpWSPSend( s, lpBuffers, dwBufferCount, lpNumberOfBytesSent, dwFlags, lpOverlapped, lpCompletionRoutine, lpThreadId, lpErrno); }这段包装展示了最小拦截形态先做轻量计数再原样转发。参数 lpBuffers 和 dwBufferCount 描述发送缓冲区数组lpNumberOfBytesSent 是同步发送的返回长度lpOverlapped 和 lpCompletionRoutine 决定这次调用是不是异步。异步时不能在函数返回后立刻读缓冲区因为下层可能返回 WSA_IO_PENDING缓冲区仍由调用方持有。g_nextProc 这里用单链简化真实实现要按协议条目或 socket 保存避免多个链互相覆盖g_ctxLock 的临界区也要尽量短不能把日志写入放在锁里。4. 数据路径WSPSend/WSPRecv 里到底能拿到什么4.1 同步与重叠 I/O只拦 WSPSend 会漏掉什么Winsock 的发送接收有两套入口同步风格的 WSPSend、WSPRecv以及支持重叠 I/O 的 WSPWSASend、WSPWSARecv。很多应用直接用 WSASend/WSARecv 提交重叠操作完成通知走事件、完成例程或完成端口。只替换 WSPSend 和 WSPRecv日志里会出现“有连接、没数据”的假象实际数据全从 WSA 版本走了。要覆盖完整至少同时处理 WSPSend、WSPSendTo、WSPRecv、WSPRecvFrom、WSPWSASend、WSPWSASendTo、WSPWSARecv、WSPWSARecvFrom。同步和异步的差异不只在函数名。同步调用返回时lpNumberOfBytesSent 或 lpNumberOfBytesRecv 已经有效可以读长度异步调用可能返回 WSA_IO_PENDING真正的完成在之后发生。如果 LSP 在异步提交后立刻读缓冲区或长度读到的是未定义状态。更稳妥的策略是把日志点放在“提交”处只记录请求元数据在完成回调处再记录实际字节数。但完成回调的包装很讲究如果 LSP 把自己的完成例程传给下层就要保存应用原来的例程和 overlapped 指针完成时先更新统计再调用原例程不能弄丢上下文。4.2 一个安全的发送日志包装只记元数据不碰缓冲区生命周期数据路径的包装要克制。下面这个版本只统计发送调用次数和请求长度不读取缓冲区内容也不在异步路径上假设完成时机。它的目标是可观测不是内容分析。int WSPAPI MyWSPSend( SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine, LPWSATHREADID lpThreadId, LPINT lpErrno) { ULONG requestBytes 0; for (DWORD i 0; i dwBufferCount; i) { requestBytes lpBuffers[i].len; // 只读长度字段不读 buf 内容 } RecordSendRequest(s, requestBytes, lpOverlapped ! nullptr); int ret g_nextProc.lpWSPSend( s, lpBuffers, dwBufferCount, lpNumberOfBytesSent, dwFlags, lpOverlapped, lpCompletionRoutine, lpThreadId, lpErrno); if (ret 0 lpNumberOfBytesSent) { RecordSendComplete(s, *lpNumberOfBytesSent); } return ret; }这里的参数含义要分清lpBuffers 是 WSABUF 数组每个元素包含长度和缓冲区指针dwBufferCount 是数组元素个数lpNumberOfBytesSent 是输出参数lpOverlapped 非空表示这次调用可能异步lpCompletionRoutine 是完成例程。代码只在提交前累加 len不访问 buf避免异步生命周期问题只有同步成功时才读 lpNumberOfBytesSent。真实实现还要处理 WSA_IO_PENDING不能把 pending 当失败也不能在 pending 时统计完成字节。记录函数要足够轻最好写入无锁队列由后台线程落盘别在数据路径里做文件 I/O。4.3 连接上下文WSPConnect、WSPAccept 与 WSPCloseSocket 的配对数据路径上的 socket 不是孤立的。要记录五元组、连接方向、协议类型就得在 WSPConnect 里保存目标地址在 WSPAccept 里保存新接受的 socket在 WSPCloseSocket 里清理。WSPConnect 的参数包含 sockaddr 和 namelen成功后再标记连接已建立WSPAccept 返回新的 SOCKETLSP 要把它加入上下文表并关联到监听 socket 的本地地址。WSPCloseSocket 则要删除对应条目避免 map 无限增长。这里常见的实现思路是用一个连接上下文结构保存 socket、地址族、本地/远端地址、协议条目指针、下一层函数表指针和统计计数。上下文表要加锁但锁的粒度要小查找和插入用短锁日志落盘放锁外。WSPAccept 可能被多线程并发调用不能假设单线程。还要注意 socket 句柄复用关闭后同一句柄值可能被新连接使用所以关闭时必须彻底删除旧上下文不能只置空字段。把这些配对关系理清数据路径的日志才有意义否则你看到的只是孤立的 send 调用无法还原连接。5. 部署与排查为什么装完 LSP 后浏览器打不开网页5.1 安装不是复制 DLLCatalog 条目、协议链和 32/64 位目录LSP 安装的核心不是把 DLL 拷到系统目录而是往 Winsock 协议目录里登记条目。32 位和 64 位各有一套 Catalog常见注册表位置是# 只读查看不要直接改二进制值 Get-ChildItem HKLM:\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries Get-ChildItem HKLM:\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries64每条 Catalog 条目包含协议信息和 DLL 路径。手动改二进制很容易把结构写坏正规做法是走 WSCInstallProvider、WSCInstallProviderAndChains 一类 API让系统写入并维护协议链。安装时要准备分层协议条目和协议链条目分层条目描述 LSP 自身协议链条目把 LSP 和基础协议串起来。应用枚举到的是协议链条目系统按 ChainEntries 逐层加载。32 位进程只能加载 32 位 DLL64 位进程只能加载 64 位 DLL所以同一份 LSP 通常要编译两个版本分别安装到对应目录。路径也有讲究。DLL 路径必须是系统加载时能访问到的位置不能依赖当前用户目录或临时目录。安装后先确认 Catalog 里能看到新条目再用最小客户端创建 socket 验证。不要一上来就替换默认协议链先在测试机里做一个独立协议链让测试程序显式选择它。这样即使链有问题也不会立刻影响整机网络。卸载时用对应的卸载 API 移除协议链和分层条目再删除 DLL只删文件不删条目系统下次还会去找那个路径。5.2 五条高频翻车记录现象、原因与解决现象一装完后浏览器打不开网页但 ping 回环正常。原因LSP 的 WSPStartup 返回失败或者协议链指向了不存在的 DLL应用创建 socket 时直接失败。解决进测试机快照回滚或用卸载 API 移除刚装的协议链检查 WSPStartup 是否在加载下一层时失败先换成“空转发”版本验证链本身。现象二32 位程序正常64 位程序一启动就报网络初始化失败。原因只编译并安装了 32 位 DLL或者 64 位 Catalog 里的路径写错。解决分别编译 32/64 位版本分别安装到 Catalog_Entries 和 Catalog_Entries64用 32 位和 64 位的最小客户端各跑一次。现象三日志里只有发送没有接收或者只有连接没有数据。原因只替换了 WSPSend/WSPRecv没有处理 WSPWSASend/WSPWSARecv 等重叠 I/O 入口。解决补齐同步和异步入口异步路径只记录提交元数据完成字节数在完成回调里统计不要在 pending 时读缓冲区。现象四卸载后 DLL 删不掉或者重启后仍然加载。原因协议链条目还在引用 DLL或者只删了文件没有注销 provider。解决先调用卸载 API 移除协议链和分层条目确认 Catalog 中没有残留再删除 DLL必要时重启后再删。现象五网络时快时慢个别程序卡死。原因在数据路径里加了大锁或同步写日志或者在 DllMain 里加载下层 DLL造成加载锁与 Winsock 锁互相等待。解决DllMain 只做最小初始化下层加载放到 WSPStartup数据路径只入无锁队列落盘交给后台线程避免跨层持有全局锁。5.3 回滚与验证先用空转发跑通再挂日志验证 LSP 最稳的顺序是“空转发 → 单点日志 → 完整统计”。空转发版本只做一件事WSPStartup 找到下一层复制函数表所有 WSP 函数原样转发。这个版本能跑通说明协议链、位数、路径、函数表接龙都正确跑不通先别加业务逻辑。单点日志版本只替换 WSPSend记录调用次数和请求长度确认数据路径能被观察到。完整统计版本再补接收、连接、关闭和异步完成。回滚方案要在安装前准备好测试机快照、Catalog 分支备份、卸载脚本、最小客户端。最小客户端只连接本机回环端口发送固定长度数据观察日志计数是否增加不要拿日常应用做第一次验证。每次改动只动一个变量比如只改 DLL 路径或只改链顺序避免一次排查多个未知。装崩过一次之后我通常会把“空转发能通”当成硬门槛不通过就不往下走。6. 设计取舍LSP 注入的边界与更现代的替代6.1 什么时候选 LSP什么时候别选LSP 的优点是位置好它在用户态、在应用缓冲区附近能拿到发送接收的长度、地址和 socket 状态而且不用改应用代码。学习 Winsock SPI、验证协议链、做小范围协议观测它是最直接的样本。但它只覆盖走 Winsock 的流量链上任何一层出错都会影响整机链越长每个 socket 调用的开销越大安装卸载涉及系统协议目录回滚成本高。维度LSP内核过滤框架应用层 API Hook实现层用户态 SPI内核网络栈用户态进程内覆盖范围所有 Winsock 进程系统级指定进程数据位置应用缓冲区附近网络层包API 参数主要风险链断影响全局驱动复杂、签名要求版本兼容、易被绕过如果目标只是观察某个进程的调用API Hook 更局部如果要做系统级网络过滤内核过滤框架更合适如果目标是搞懂 Winsock 的加载顺序和函数表转发LSP 仍然值得亲手写一遍。选型时先问故障域能不能接受“装坏一次影响整机”不能接受就别把 LSP 放到默认链上。6.2 一次装崩之后的检查习惯那份 LSP 注入资源的源码和文档适合放在测试机里对照 WSPStartup 的截链过程、函数表复制顺序和安装脚本看。先跑空转发确认 32/64 位目录和协议链都正确再逐个替换 WSPSend、WSPRecv、WSPConnect每加一个点就回滚验证一次。从那以后我每次装 LSP 前都强制走一遍快照、Catalog 备份和最小客户端验证确认空转发能通才挂日志这套习惯比任何排查技巧都管用。希望帮到你。本文还有配套的精品资源点击获取