
简介面向Windows平台C开发者与安全技术学习者的LSP注入工程源码包围绕语言服务提供者LSP的COM组件实现、注册表注册、进程注入与请求拦截展开可用于理解底层钩子机制并搭建可调试的实验工程。压缩包共38个文件约150KB以15个cpp源文件和7个h头文件为核心配合vcxproj工程、sln解决方案、bat脚本及def导出定义等覆盖LSP组件从接口实现到编译注册的主要环节。已有1582人学习适合需要对照源码梳理IDispatch、IUnknown与ILanguageService实现细节、观察注入与拦截流程的读者。包内提供较完整的工程结构和可参考的代码组织便于在本地编译调试、验证注册与卸载步骤并据此分析安全检测、数据恢复、调试工具等场景下的技术边界使用时需注意合法合规及系统稳定性风险。1. LSP 注入的真相不是你去塞 DLL是所有联网进程主动来加载它很多人第一次听到 lsp 注入脑子里浮现的是远程线程注入那套动作打开目标进程、分配内存、写进去、再拉起一个线程。lsp 注入是另一回事它不需要你碰任何别的进程。把一个符合 Winsock SPI 规范的 DLL 注册进协议目录之后任何进程只要调一次 WSAStartupws2_32.dll 就会自己把这份 DLL 加载起来并调用它的 WSPStartup 导出函数。加载动作是目标进程自己完成的你要负责的只有登记和实现。这套机制本来是给开发者留的扩展点用来在 Winsock API 与底层传输协议之间插一层自己的逻辑做协议统计、流量审计、自定义调度。它适合已经会写 DLL、写过 socket 程序但没碰过 SPI 这一层的人也适合接手历史代码、需要弄清楚某个老旧 LSP 为什么让整机没法上网的人。2. Winsock SPI 的分层结构从 WSAStartup 到协议链的第 0 号节点2.1 API 与 SPI 的分界线socket 函数到底转发给了谁写 socket 程序时调用的 socket、connect、send 这些符号全部来自 ws2_32.dll这一侧叫 API。ws2_32.dll 内部并不自己干活它按地址族、套接字类型、协议号去协议目录里匹配一条协议链把链上每一层的函数表收集起来再把调用逐层往下传这一侧叫 SPI。同名的两个世界里函数长得像但不是一回事API 侧的 send 返回实际发送字节数出错返回 SOCKET_ERROR 并让 WSAGetLastError 拿到错误码SPI 侧的 WSPSend 多了一个 LPINT lpErrno 出参错误码直接写进去不走线程局部存储。还有个常被忽略的差别是线程标识。SPI 里的阻塞型函数普遍多一个 LPWSATHREADID lpThreadId因为分层实现在处理阻塞时需要把自己的线程上下文交给下层让下层的完成通知能回到正确的线程上。另一个关键物件是 WSPUPCALLTABLE也就是所谓的 upcall 表里面的 WPUCreateSocketHandle、WPUQueryBlockingCallback、WPUCompleteOverlappedRequest 三个函数由 ws2_32.dll 提供给上层实现调用。如果你的 LSP 只是观察数据、不改句柄语义这张表基本用不上一旦你要自己造句柄或者在重叠 I/O 里主动完成请求就必须把它存下来。维度API 侧ws2_32 导出SPI 侧LSP 实现错误返回返回值 WSAGetLastError返回值 lpErrno 出参阻塞上下文无lpThreadId句柄创建由协议栈内部完成IFS 场景需调 WPUCreateSocketHandle完成通知由 ws2_32 触发需调 WPUCompleteOverlappedRequest谁调用应用程序ws2_32.dll把这张表记住后面写转发代码时就不会把 send 的参数照抄到 WSPSend 上去。血泪经验是参数个数差一个编译期不一定报错运行期直接栈错位进程当场没了还查不出是谁干的。2.2 WSAPROTOCOL_INFO 与 ProtocolChain一条链是怎么排出来的协议目录里每一项都是一个 WSAPROTOCOL_INFOW 结构装进注册表的 Protocol_Catalog9\Catalog_Entries 下面64 位系统下 32 位视图另有一份。关键的几个字段决定了这条链长什么样字段含义写 LSP 时的取值要点dwCatalogEntryId目录项编号系统分配安装后由 WSCInstallProvider 回填不要自己编ProviderId提供者 GUID每个 LSP 唯一卸载时靠它定位ProtocolChain.ChainLen链长度基础协议为 1纯分层协议为 0挂载后的链大于 1ProtocolChain.ChainEntries[]链上各节点的目录项编号[0] 是自己[1] 是下一层末位是基础协议dwServiceFlags1服务能力位含 XP1_IFS_HANDLES 表示句柄可跨 API 与 SPI 混用szProtocol显示名在 netsh 和枚举工具里看到的就是它iAddressFamily / iSocketType / iProtocol匹配三元组必须和你要覆盖的底层协议一致ChainLen 的三种形态是最容易搞混的地方。ChainLen 等于 1 表示这是一条基础协议比如 TCP、UDP链上只有它自己。ChainLen 等于 0 表示这是一个纯分层协议条目它本身不能被应用直接选中只是一个可以被别的链引用的“零件”。ChainLen 大于 1 才是真正的协议链数组第 0 位放的是最外层 LSP 的目录项编号最后一位放的是基础协议编号。装 LSP 的本质动作就是把你自己的编号插到某个已有链的最前面再把这个新链写回目录。这里有个反直觉的结论分层协议条目和协议链条目是两份不同的记录很多人只写了前者就以为装好了结果 WSAStartup 根本不会加载他的 DLL。判断方法很简单用 WSAEnumProtocols 枚举一遍看有没有哪一项的 ChainEntries[0] 是你的目录项编号。2.3 加载时序WSPStartup 到底被谁、以什么参数调用当一个进程第一次调 WSAStartupws2_32.dll 会做这几件事读协议目录按应用请求的三元组找到匹配的链对链上从 0 到 n-1 的每个节点LoadLibrary 对应 DLL 并取 WSPStartup 的地址然后从上往下依次调用最外层先调最外层在自己的 WSPStartup 里再去调下一层的 WSPStartup一层层把函数表串起来。传给每个 WSPStartup 的 lpProtocolInfo 是“当前这个节点视角下的整条链信息”不是最外层的信息。所以在你的 WSPStartup 里lpProtocolInfo-ProtocolChain.ChainEntries[0] 永远是你自己ChainEntries[1] 才是你要转发给的下家。这个细节决定了你该怎么找下层 DLL拿 ChainEntries[1] 去枚举目录找到 dwCatalogEntryId 等于它的那一项再用 WSCGetProviderPath 换出 DLL 路径。加载发生在 WSAStartup 里意味着两件事。第一只要用户装了你的 LSP全机器所有 Winsock 程序启动时都会走你的代码包括杀毒软件、输入法、安装程序。第二任何在你的 WSPStartup 里抛出的异常、死循环、长时间阻塞都会直接拖慢甚至拖死这些程序的启动。常见做法是 WSPStartup 里只做最小初始化配置读取放到 DLL 的 DllMain 之外惰性加载或者干脆编译期写死。2.4 为什么是 LSP而不是 API Hook 或内核过滤同样想在应用层观察网络行为还有两条路Inline Hook ws2_32 的导出函数或者写内核态过滤驱动。三者对比起来差别很大方案覆盖范围实现成本兼容风险API Hook只覆盖走 ws2_32 的调用且易被绕过中要处理指令长度与多线程高系统更新即失效LSP所有通过 Winsock 协议链的调用低纯用户态 DLL中需处理 32/64 位双份内核过滤驱动全系统含非 Winsock 流量高需驱动签名与调试环境高蓝屏风险选 LSP 的理由很实际不用驱动签名不用面对内核崩溃代码量小到一个人一天能写出能跑的版本。代价是它只在应用层的 Winsock 路径上生效走原始套接字或者别的网络栈的程序看不到。另外要清楚微软从 Windows 8 起把 WFP 作为推荐方案LSP 机制仍然可用但不再建议用于新项目。所以拿它做教学、做内网工具的协议扩展没问题做长期维护的商业产品要掂量一下。3. 手写一个最小分层协议 DLLWSPStartup 里替换哪几个函数3.1 工程骨架与导出约定只需要导出 WSPStartup 一个符号这是最多人搞错的地方。SPI 规范里一个分层协议 DLL 只需要导出 WSPStartup其余几十个函数通过 WSPPROC_TABLE 函数表返回给调用方根本不需要导出。你在网上看到的那些导出 WSPSocket、WSPConnect 的 .def 文件多数是抄来抄去抄错了。导出多了不会立刻出错但会让系统在解析时多做无用功也容易在 x86 上踩名字修饰的坑。WSPAPI 在 32 位下展开成 __stdcall导出符号会被修饰成 _WSPStartup20。系统是按未修饰名 WSPStartup 去找的所以 .def 里必须写别名。64 位没有这个修饰写裸名即可。我的做法是准备两份 .def构建时按平台选别指望一个文件通吃。// lspdemo.def —— 32 位版本 LIBRARY lspdemo EXPORTS WSPStartup_WSPStartup20 // lspdemo64.def —— 64 位版本 LIBRARY lspdemo EXPORTS WSPStartup工程配置上还有三条硬要求。第一必须同时产出 32 位和 64 位两份 DLL装的时候成对注册否则 32 位程序启动时会因为加载不了 64 位 DLL 而报错第二DLL 尽量静态链接 CRT减少对目标进程运行库版本的依赖因为会被加载进各种五花八门的进程第三不要在有全局构造函数的 C 对象上做重活DLL 被加载的时机在进程很早的阶段这时候很多系统组件还没就绪。3.2 WSPStartup 的三件事存 upcall 表、拿下层函数表、换掉自己的函数指针WSPStartup 是整个 LSP 的心脏它要做的事情可以拆成三步。第一步把 ws2_32 传进来的 lpWSPData 和 UpcallTable 保存成全局变量后面转发时要用。第二步以 ChainEntries[1] 为目标找到下层协议目录项加载它的 DLL调它的 WSPStartup拿到下层的 WSPPROC_TABLE。第三步把 ws2_32 传进来的 lpProcTable 复制一份作为基础把你关心的那几个函数指针改成自己的实现再写回 lpProcTable 交给上层。没改的那些指针保持原样等于直接透传给下层这是最省事的做法。#include winsock2.h #include ws2spi.h #include stdio.h #pragma comment(lib, ws2_32.lib) static WSPUPCALLTABLE g_upcall; // ws2_32 提供的上行调用表 static WSPPROC_TABLE g_nextTable; // 下层的函数表真正干活的人 static WSPPROC_TABLE g_selfTable; // 我们交给上层的表 static HMODULE g_hNextDll NULL; // 在目录里按 catalog id 找到协议项并换出它的 DLL 路径 static BOOL ResolveNextProvider(LPWSAPROTOCOL_INFOW self, LPWSAPROTOCOL_INFOW outNext, WCHAR* dllPath) { DWORD chainId self-ProtocolChain.ChainEntries[1]; DWORD cb 0; WSAEnumProtocolsW(NULL, NULL, cb); // 先探所需缓冲区大小 BYTE* buf (BYTE*)HeapAlloc(GetProcessHeap(), 0, cb); if (!buf) return FALSE; int n WSAEnumProtocolsW(NULL, (LPWSAPROTOCOL_INFOW)buf, cb); BOOL found FALSE; for (int i 0; i n; i) { LPWSAPROTOCOL_INFOW p (LPWSAPROTOCOL_INFOW)(buf i * sizeof(WSAPROTOCOL_INFOW)); if (p-dwCatalogEntryId chainId) { *outNext *p; found TRUE; break; } } HeapFree(GetProcessHeap(), 0, buf); if (!found) return FALSE; // 注意必须按 64 位视图查询下层提供者路径 INT len 512, err 0; return WSCGetProviderPath(outNext-ProviderId, dllPath, len, err) 0; } int WSPAPI WSPStartup(WORD wVersion, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { g_upcall UpcallTable; WSAPROTOCOL_INFOW nextInfo { 0 }; WCHAR path[512] { 0 }; if (!ResolveNextProvider(lpProtocolInfo, nextInfo, path)) return WSAEPROVIDERFAILEDINIT; if (!g_hNextDll) g_hNextDll LoadLibraryW(path); if (!g_hNextDll) return WSAEPROVIDERFAILEDINIT; typedef int (WSPAPI *PFN_WSPSTARTUP)(WORD, LPWSPDATA, LPWSAPROTOCOL_INFOW, WSPUPCALLTABLE, LPWSPPROC_TABLE); PFN_WSPSTARTUP pfn (PFN_WSPSTARTUP)GetProcAddress(g_hNextDll, WSPStartup); if (!pfn) return WSAEPROVIDERFAILEDINIT; // 用“下层视角”的协议信息去启动下层链信息会自动再往下剥一层 int rc pfn(wVersion, lpWSPData, nextInfo, UpcallTable, g_nextTable); if (rc ! 0) return rc; // 以 ws2_32 给的表为底逐个替换需要观察的函数 g_selfTable *lpProcTable; g_selfTable.WSPSend WSPSend; g_selfTable.WSPSendTo WSPSendTo; g_selfTable.WSPRecv WSPRecv; g_selfTable.WSPRecvFrom WSPRecvFrom; g_selfTable.WSPCloseSocket WSPCloseSocket; *lpProcTable g_selfTable; return 0; }这段代码有几个参数必须说清楚。wVersion 是 SPI 版本号一般是 MAKEWORD(2,2)原样往下传。lpWSPData 是出参下层填好后里面是 WSADATA 副本通常不用动。lpProtocolInfo 是入参就是前面说的“当前节点视角”的链信息必须原样传给下家不要自己改 ChainEntries改了会让下层找不到它的下家。UpcallTable 是 ws2_32 提供的函数表多层 LSP 叠加时每一层拿到的都是同一份直接透传即可。最后一步的 *lpProcTable g_selfTable 是整个流程的收尾。ws2_32 拿到这张表后应用的每一次 send 都会跳到你的 WSPSend你在里面调 g_nextTable.WSPSend 转到下层。这里有个容易忽略的细节g_nextTable 是全局单例如果一个进程里同时打开了多个不同基础协议的套接字这个单例可能被覆盖。更严谨的写法是按 lpProtocolInfo 建一个上下文对象用容器管理但对单协议场景够用了。3.3 拦截 WSPSend函数签名、缓冲区遍历与错误透传WSPSend 是最适合做第一次实验的位置参数比 WSPSocket 简单触发频率又高。它的原型里比 API 侧的 send 多了三个参数lpThreadId、lpErrno以及缓冲区数组用 LPWSABUF 而不是裸指针。写的时候要记住三点遍历缓冲区要按 len 累加不能假设只有一个 buffer返回值语义和 send 一致返回 0 表示成功SOCKET_ERROR 表示失败失败时把下层的错误码原样写进 lpErrno不要自己编一个。int WSPAPI WSPSend(SOCKET s, LPWSABUF lpBuffers, DWORD dwBufferCount, LPDWORD lpNumberOfBytesSent, DWORD dwFlags, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine, LPWSATHREADID lpThreadId, LPINT lpErrno) { // 只在同步、无重叠的情形下采样异步路径直接转发避免破坏完成语义 if (!lpOverlapped lpBuffers dwBufferCount 0) { DWORD total 0; for (DWORD i 0; i dwBufferCount; i) total lpBuffers[i].len; // 这里只做统计绝不能改动 lpBuffers 里的内容 OutputDebugStringW(L[lsp] send observed\n); InterlockedAdd64(g_bytesOut, total); } int rc g_nextTable.WSPSend(s, lpBuffers, dwBufferCount, lpNumberOfBytesSent, dwFlags, lpOverlapped, lpCompletionRoutine, lpThreadId, lpErrno); return rc; }参数说明按顺序过一遍。s 是套接字句柄绝大多数情况下它就是下层返回的原始句柄不需要映射表。lpBuffers 和 dwBufferCount 构成缓冲区数组遍历时用 DWORD 累加注意单个缓冲区长度是 ULONG总数可能超过 32 位统计时用 64 位变量。lpNumberOfBytesSent 是出参由下层填你只负责往下传。dwFlags 常见值是 0透传即可。lpOverlapped 和 lpCompletionRoutine 非空表示异步路径这一段是 LSP 里最容易翻车的地方简单实现里建议直接透传不要在旁边加任何可能阻塞的日志操作。lpThreadId 是当前线程的 SPI 标识同样透传。lpErrno 是错误码落点下层返回 SOCKET_ERROR 时你已经不用管它自己会填。如果你想在发送前改数据比如给每个包加一段头部做法是构造一组新的 WSABUF 指向自己的缓冲区把原内容拷进去再调下层。这里要特别注意两件事一是删除头部时不能只改 len还要处理 lpNumberOfBytesSent 的回填否则上层会以为发出去的字节数和实际不符二是自己分配的缓冲区必须活到调用返回之后同步路径下用栈上数组或者局部堆分配没问题异步路径下必须跟随 OVERLAPPED 结构一起管理生命周期否则就是悬空指针。4. 把 DLL 挂进协议目录WSCInstallProvider 与链顺序调整4.1 安装器要构造的 WSAPROTOCOL_INFOW 字段表安装逻辑通常是单独写一个控制台程序管理员权限运行负责把 DLL 路径写进注册表并调整链序。它要构造两类目录项一个是分层协议条目ChainLen 为 0描述你的 DLL 本身另一个是基于已有基础协议扩建出来的协议链ChainLen 大于 1把你自己插到最前面。字段填错是安装失败的头号原因下面这张表是我实际调试下来最稳妥的取值。字段分层协议条目协议链条目szProtocol自定义名称如 MyLayer over TCP自定义名称如 MyLayer [TCP]ProviderId自己生成的 GUID与分层条目一致dwCatalogEntryId交给系统分配填 0交给系统分配填 0ProtocolChain.ChainLen02自己 基础协议ChainEntries[0]不用填安装返回的分层条目编号ChainEntries[1]不用填被覆盖的基础协议编号如 TCP 的 6 号iAddressFamilyAF_INET与基础协议一致iSocketTypeSOCK_STREAM与基础协议一致iProtocolIPPROTO_TCP与基础协议一致dwServiceFlags1继承基础协议的能力位保留 XP1_IFS_HANDLES同左dwProviderReserved00两个条目都要调用 WSCInstallProvider第一次调用返回分层条目的 catalog id这个 id 要回填到第二个条目的 ChainEntries[0] 里然后第二次调用安装协议链。顺序不能反因为链里引用的编号必须已经存在。4.2 安装代码两次 WSCInstallProvider 加一次链序调整最省事的做法是基于现成的 TCP 目录项复制一份出来改而不是从零手填字段太多容易漏。先枚举找到 iProtocol 等于 IPPROTO_TCP、ChainLen 等于 1 的那一项拷出来当模板改掉名字、GUID、链信息再装进去。// 只贴关键步骤错误分支已省略 WSAPROTOCOL_INFOW baseTcp { 0 }; DWORD cb 0; WSAEnumProtocolsW(NULL, NULL, cb); BYTE* buf (BYTE*)HeapAlloc(GetProcessHeap(), 0, cb); int n WSAEnumProtocolsW(NULL, (LPWSAPROTOCOL_INFOW)buf, cb); for (int i 0; i n; i) { LPWSAPROTOCOL_INFOW p (LPWSAPROTOCOL_INFOW)(buf i * sizeof(WSAPROTOCOL_INFOW)); if (p-iProtocol IPPROTO_TCP p-ProtocolChain.ChainLen 1) { baseTcp *p; break; } } // 第一步装分层协议条目 WSAPROTOCOL_INFOW layer baseTcp; layer.dwCatalogEntryId 0; layer.ProtocolChain.ChainLen 0; CoCreateGuid(layer.ProviderId); // 自己生成一个唯一 GUID wcscpy_s(layer.szProtocol, LMyLayer); DWORD layerId 0; int rc WSCInstallProvider(layer.ProviderId, dllPath, layer, 1, rc2); // 第二步装协议链把自己插到 TCP 前面 WSAPROTOCOL_INFOW chain baseTcp; chain.dwCatalogEntryId 0; chain.ProviderId layer.ProviderId; chain.ProtocolChain.ChainLen 2; chain.ProtocolChain.ChainEntries[0] layerId; // 上一步返回的编号 chain.ProtocolChain.ChainEntries[1] baseTcp.dwCatalogEntryId; wcscpy_s(chain.szProtocol, LMyLayer over TCP); WSCInstallProvider(chain.ProviderId, dllPath, chain, 1, rc2); // 第三步把自己的链条目提到 TCP 之前让匹配优先命中 DWORD order[] { chainId, baseTcp.dwCatalogEntryId, /* 其余编号原样排后面 */ }; WSCWriteProviderOrder(order, count);三个步骤里第三步最容易被漏掉。WSCInstallProvider 只是把条目写进目录具体哪个先被匹配取决于目录顺序。如果新链排在基础协议后面应用请求 SOCK_STREAM IPPROTO_TCP 时先命中原始 TCP你的 DLL 根本不会被加载。WSCWriteProviderOrder 接受一个完整顺序数组必须把当前目录里所有条目编号都排进去漏掉任何一项都会让那一项失效所以正确做法是先枚举出全部编号把新链提到最前再整体写回。另外注意 dllPath 必须是绝对路径且 32 位和 64 位要分别调用一次。64 位系统上存在两份独立的 Winsock 目录安装器要以 32 位和 64 位两种身份各跑一遍或者用 WOW64 相关的 API 显式指定视图否则会出现“64 位程序能上网、32 位程序全挂”这种看起来像玄学的问题。4.3 卸载与回滚WSCDeinstallProvider 的调用顺序卸载比安装更需要谨慎因为一旦删错了链整台机器的网络会立刻不可用。正确顺序是先用 WSCDeinstallProvider 删掉协议链条目再删分层协议条目最后用 WSCWriteProviderOrder 把目录顺序恢复成原始状态。顺序反了会留下悬空引用系统在解析目录时找不到对应的 id表现为所有 socket 创建失败。// 卸载先链后层再恢复顺序 WSCDeinstallProvider(chain.ProviderId, err); WSCDeinstallProvider(layer.ProviderId, err); // 恢复原始顺序把 TCP 等基础协议重新排回前面 WSCWriteProviderOrder(restoredOrder, restoredCount);还有一点必须强调WSCDeinstallProvider 只是把目录项删掉已经加载进各个进程的 DLL 不会立刻卸载。所以卸载后必须重启或者在测试环境里逐个重启关键程序。如果卸载过程中你的 DLL 正在被某个进程使用删除会返回共享冲突这时候不要强删注册表正确做法是先停掉占用进程再重试。真正要留后悔药的做法是在安装之前先导出当前目录状态。我一般的习惯是安装脚本第一步枚举全部条目把 id、名称、顺序写进一个文本备份卸载脚本直接读这份备份恢复。有了它就算链序被改乱也能一条命令回到原样。5. 避坑与排查加载失败、循环转发、卸载残留的五个现场5.1 WSAStartup 返回 10106或者进程一启动就退出现象是安装完成后任何联网程序都打不开用调试器附加能看到 ws2_32 在初始化阶段直接返回 WSASYSCALLFAILURE也就是 10106。原因几乎都是 WSPStartup 里出了纰漏可能是 ResolveNextProvider 没找到 ChainEntries[1] 对应的目录项可能是下层 DLL 路径拼错也可能是 DLL 位数和进程不匹配。排查方法是打开注册表看 Protocol_Catalog9 下你那一项的 ChainEntries[1] 是否指向一个真实存在的条目然后用 Process Monitor 过滤进程名看它有没有去加载你指定的路径、结果是不是 NAME NOT FOUND 或者 IMAGE NOT FOUND。修复方式是在 WSPStartup 的每个失败分支都返回明确错误码并在调试输出里打日志别让它默默返回 0 骗过上层。5.2 递归转发导致栈溢出进程崩溃在 WSPSend 里现象是程序能启动一发数据就崩调用栈里全是重复的 WSPSend 帧几百层之后栈溢出。原因是替换函数指针时把自己写进了 g_nextTable或者 g_nextTable 根本没被下层填过你的实现调来调去调回了自己。判断方法很简单在 WSPSend 入口打一次地址看 s 和函数指针是不是每次都相同且指向本模块。解决方式是严格区分两张表g_selfTable 只交给上层g_nextTable 只来自下层两者不能互相赋值。另外在 WSPStartup 里调下层之前先把 g_nextTable 清零成功后校验其中的 WSPSend 非空再继续空指针总比递归好查。5.3 64 位系统上只有 32 位程序出问题或者反过来现象是同一台机器上某类程序联网正常另一类全部超时日志里能看到你的 DLL 在失败进程里压根没被加载。原因是 Winsock 协议目录在 64 位系统上分两份32 位进程读的是 Wow6432Node 下的视图如果你的安装器只用一种位数跑过另一份目录里没有你的条目。还有个变体是两边都注册了但只提供了一份位数的 DLL另一边加载时报错。修复方式是构建两个平台版本安装器同时以两种视图各注册一次并且把两个 DLL 的路径分别写进对应目录。验证可以用一个 32 位和一个 64 位的小程序各跑一次 WSAEnumProtocols比对两边的条目数量和链序是否一致。5.4 卸载之后网络还是不通重启也没用现象是执行完卸载脚本网络仍然瘫痪甚至重启后依旧。原因通常是链序被破坏删掉协议链条目后目录里还留着指向它的引用或者 WSCWriteProviderOrder 用了一个不完整的顺序数组把某些条目排到了列表之外。另一种情况是安装时用了一份 GUID卸载时又生成了新的 GUIDWSCDeinstallProvider 找不到目标什么都没删掉。解决办法分两层能进系统就重新跑一次完整枚举把所有残留的、名字里带你自己标识的条目逐个删除再按备份的顺序整体写回进不去系统就进安全模式做同样的事。这类事故的后悔药只有一个就是安装前导出目录快照我现在的安装脚本里这一步是强制的不做就不允许继续。5.5 调试输出刷不出来日志文件也是空的现象是 DLL 明明被加载了但 OutputDebugString 的内容在调试器里看不到写文件的日志也是 0 字节。原因有两个常见版本一是 DllMain 里做了文件操作而加载时进程正持有加载器锁写文件触发另一个 DLL 加载直接死锁表现为进程卡住而不是没日志二是代码在多个进程里并发写同一个日志文件没有加锁文件被互相覆盖。排查方式是先把日志改成 OutputDebugString 加 DbgView 观察确认路径通不通再换成按进程 ID 分文件写。解决方式是把所有日志初始化从 DllMain 里挪出去放到 WSPStartup 首次调用时惰性初始化写文件时用进程 ID 做文件名后缀单进程内再加一把临界区。6. 验证与进阶用 WSAEnumProtocols 自检链路再补一个重叠 I/O 的转发装完不要急着开浏览器测先做一次目录自检确认链序、位数、路径三件事都对能省掉一大半盲目排查。下面这段小程序跑一遍输出就是判断依据。// 列举全部协议项重点看 ChainLen 和你自己的条目位置 DWORD cb 0; WSAEnumProtocolsW(NULL, NULL, cb); BYTE* buf (BYTE*)HeapAlloc(GetProcessHeap(), 0, cb); int n WSAEnumProtocolsW(NULL, (LPWSAPROTOCOL_INFOW)buf, cb); for (int i 0; i n; i) { LPWSAPROTOCOL_INFOW p (LPWSAPROTOCOL_INFOW)(buf i * sizeof(WSAPROTOCOL_INFOW)); wprintf(L%u\t%s\tChainLen%u\t[0]%u\t[1]%u\n, p-dwCatalogEntryId, p-szProtocol, p-ProtocolChain.ChainLen, p-ProtocolChain.ChainEntries[0], p-ProtocolChain.ChainEntries[1]); }看三件事你自己的条目 ChainLen 是不是 2ChainEntries[0] 是不是你的分层条目编号条目顺序是不是排在基础协议之前。两边的条目数量也要对比32 位和 64 位视图下你的条目都得在。进阶一点的坑在重叠 I/O。WSPSend 和 WSPRecv 在 lpOverlapped 非空时不返回最终结果而是返回 WSA_IO_PENDING真正的完成要等下层通过 lpCompletionRoutine 回调或者事件置位。这时候你不能在自己的实现里加任何同步等待也不能让局部变量活不到完成时刻。如果需要在下层完成后再做点事标准写法是把自定义的上下文挂在 OVERLAPPED 结构的后面分配时多申请一块内存在回调里先取出上下文再手动调 WPUCompleteOverlappedRequest 通知上层。这个函数必须从 g_upcall 里取不能自己去调 SetEvent 了事否则上层拿到的完成状态和真实结果会对不上。最后一个验证技巧找一个最小的客户端程序先不装 LSP 跑一次记录耗时装了之后再跑一次对比。如果启动时间明显变长说明你的 WSPStartup 里干了不该干的事比如同步读配置文件或者做 DNS 解析。我吃过一次亏在 WSPStartup 里读了一个网络路径下的配置文件结果那台机器上所有程序启动都卡三秒排查了半天才定位到这一行。从那以后我每次写 LSPWSPStartup 里只允许出现三件事存指针、加载下家、替换函数表任何其他逻辑都挪到首次真正调用时再做。希望帮到你。本文还有配套的精品资源点击获取