资讯详情

资讯详情

LSP注入全链路解析:Winsock 2协议链、WSPStartup与排障

简介围绕LSP注入原理与代码实现整理的Windows底层技术资料面向安全研究人员、Windows开发者及COM编程学习者可用于理解语言服务提供者如何注册、加载并拦截文件处理请求也适合调试与安全检测场景参考。压缩包为7z格式共38个文件约150KB主体包括15个cpp源文件、7个头文件、Visual Studio工程与解决方案文件并配有bat注册/卸载脚本、dll、exe、def等编译注入示例便于从接口实现到工程构建完整走读。已有1583人学习说明该主题具有稳定参考需求。内容覆盖COM接口实现、LSP注册流程与注入代码等关键环节可对照工程结构阅读源码、借助脚本搭建验证环境并梳理注册失败、注入异常等排错思路使用时需遵守合法与道德边界避免影响系统稳定性。1. LSP 注入真正把你的代码加载进去的是系统自己很多人听到 LSP 注入第一反应是 CreateRemoteThread 那套往别人进程里硬写内存的手法。恰好相反LSP 注入没有一个字节是硬塞进去的全程由系统的 Winsock 前端按协议目录里的记录主动LoadLibrary完成。你要做的只是往目录里写一条看起来合法的记录再配一个导出WSPStartup的 DLL。代价也很直白——这段代码会被加载进几乎所有发起网络请求的进程崩一次就是整机网络瘫痪。所以难点不在「塞进去」而在提供者选择链路究竟在哪一步交出控制权以及WSPStartup里必须完成哪些握手才不会被上层一脚踢开。下面从加载链开始拆。2. 加载链Winsock 2 在哪一步把控制权交出去要理解 LSP 注入得先把 Winsock 2 的「前端 / 提供者」二分结构摆清楚。很多人把这套东西当成一个整体实际上从 Winsock 2 开始它就被拆成了两个物理上独立的模块一个是所有程序都链接的ws2_32.dll另一个是真正干活的提供者 DLL。前者导出WSA*系列函数后者导出WSP*系列函数两套函数一一对应中间隔着一张函数表。LSP 的机会就藏在这张表的填充时刻。2.1 ws2_32 只是转发器WSA 与 WSP 的对应关系先看命名规律应用调WSASocket提供者实现WSPSocket应用调WSAConnect提供者实现WSPConnect应用调WSACloseSocket提供者实现WSPCloseSocket。前缀从 WSA 换成 WSP中间的 A 是 ApplicationP 是 Provider。ws2_32.dll里几乎每个WSA*函数都是薄薄一层查句柄上下文、做参数合法性检查、跳到函数表对应项、返回。真正的 socket 创建、连接建立、数据收发都在提供者里。那前端为什么不干脆把这些函数直接转发出去非要自己插一手因为它要处理提供者处理不了的事。最典型的是异步通知WSAAsyncSelect要往某个窗口投递消息提供者跑在不知道窗口消息循环存在的地方没法自己投递WSAEventSelect要把 socket 事件映射到事件对象同样需要前端参与。还有句柄映射——应用拿到的SOCKET是前端分配的索引值不是提供者内部的对象指针前端维护着一张映射表每次调用都要翻译一次。这些活儿决定了前端必须存在也决定了 LSP 拿到的SOCKET参数永远是前端那一侧的句柄而不是内核对象。理解这一点很关键。后面写转发函数时最省事的做法就是把参数原样往下传因为句柄本身就是前端的东西你不需要翻译。很多初次实现 LSP 的人会误以为要自己做句柄转换绕一大圈反而把链路搞断。2.2 WSAStartup 那一刻发生了什么一个进程从「完全不知道网络为何物」到「能发 HTTP 请求」中间有个明确的触发点WSAStartup。这个函数看起来只是初始化实际上它是一次完整的提供者装载流程前端读取协议目录找到匹配wVersionRequested和调用方指定lpProtocolInfo的目录项取该目录项里的LibraryPath字段LoadLibrary这个 DLLGetProcAddress拿WSPStartup的地址调用WSPStartup把lpProtocolInfo、前端回调表UpcallTable、以及一张空的WSPPROC_TABLE传进去提供者把自家函数地址填满WSPPROC_TABLE返回 0之后每一次WSA*调用都通过这张表跳转。第 4 步是整条链上唯一的「代码执行入口」。你的 DLL 被加载后系统只认一个导出符号WSPStartup。只要这个函数签名对得上、返回 0、并且把函数表填好前端就默认你是一个合格的提供者后面所有网络调用都会经过你。它不校验你的 DLL 有没有数字签名不校验你是不是系统自带的也不问你从哪来。这是设计上的取舍——Winsock 2 规范的年代可扩展性优先于安全性。顺带说一句WSAStartup不是唯一的触发点。某些 API 比如名字解析路径也会在没有显式WSAStartup的情况下触发提供者加载。所以「我只在需要的时候才加载」这种假设是不成立的DLL 一旦挂进目录覆盖面就是全进程级。2.3 协议目录注册表里那几十个槽位协议目录不是一个内存结构它有物理落点。32 位视图在HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\WinSock2\Parameters\Protocol_Catalog9\Catalog_Entries64 位视图在紧邻的Catalog_Entries64。每个子项名字是一串零填充的序号项里最关键的几个值是PaddedSpia一个序列化后的WSAPROTOCOL_INFOW结构字段被 8 字节对齐填充过、LibraryPathDLL 全路径、ProviderId提供者 GUID。序号的数值本身没有语义系统按名字排序遍历所以你看到编号是000000000001到0000000000xx但新增一项之后编号可能不连续。读取这张表有两个 API区别很大WSAEnumProtocols只返回「应用可见」的协议项ChainLen 1的分层项和一些内部项会被过滤掉WSCEnumProtocols返回全部包括分层项返回的结构是WSAPROTOCOL_INFOW宽字符版。写安装逻辑时必须用WSC版本否则你根本看不到自己刚挂上去的那一层也没法判断链路是否完整。提示不要在代码里硬编码Catalog_Entries\000000000001这种路径。序号在安装、卸载、系统更新后都会变唯一稳定的标识是dwCatalogEntryId和与之对应的ProviderId。3. 协议链LSP 凭什么能被排进调用序列里目录项里有一个字段决定了调用顺序叫ProtocolChain。它的结构长这样typedef struct _WSAPROTOCOLCHAIN { int ChainLen; // 链长 DWORD ChainEntries[MAX_PROTOCOL_CHAIN]; // 最多 7 层每层存一个 CatalogEntryId } WSAPROTOCOLCHAIN;MAX_PROTOCOL_CHAIN是 7意味着一条链最多挂 7 层。这个数字不是随便定的它限制了单个协议上能叠加的提供者数量也意味着存在天然的天花板——如果某个环境里已经有几层 LSP你再往上叠就可能失败。理解这个上限是排查「装完没反应」的第一条线索。3.1 WSAPROTOCOL_INFOW 里真正决定行为的字段整个结构有二十来个字段但对 LSP 来说只有几个是「活的」字段作用写错会怎样dwCatalogEntryId本项在目录中的唯一编号链里引用的 ID 对不上加载失败ProtocolChain调用顺序链首在前顺序反了会递归调用自己iAddressFamily/iSocketType/iProtocol三元组决定这个项服务哪类 socket不匹配则应用根本选不到你dwProviderFlags是否隐藏、是否被推荐设错会导致应用枚举时选错提供者dwServiceFlags1能力位如XP1_IFS_HANDLES影响句柄能否被直接当文件句柄用szProtocol显示名只影响可读性三元组是最容易被忽略的一项。一个 LSP 目录项如果写成AF_INET / SOCK_STREAM / IPPROTO_TCP那它只对 TCP 生效UDP 流量完全绕过你。想让所有 IPv4 流量都经过得为SOCK_DGRAM / IPPROTO_UDP单独准备一项IPv6 还要再来一套。这就是为什么成熟的实现会一次性挂四到六项而不是一项打天下。dwProviderFlags里的隐藏位也值得说。分层项如果不设隐藏某些应用在枚举协议时会把它当成一个独立协议列出来用户可能手动选中它然后绕过下层基础提供者直接连到一个没有实际传输能力的壳上——结果就是连接永远失败。3.2 ChainEntries 的语义链首是最后被加载的链路解析顺序是这样的前端读到一个ChainLen 2的项ChainEntries[0]是链首提供者的CatalogEntryIdChainEntries[1]是链尾基础提供者。加载时从前端视角看先加载[0]把lpProtocolInfo传给它它在WSPStartup里应当把[0]这一段从链里摘掉把剩下的部分传给[1]对应的提供者。换句话说在同一个进程里WSPStartup的调用顺序是「外到内」最外层的 LSP 先被调用它再调用下一层依次递进直到基础提供者。而数据流的顺序是相反方向调用从外层发起一路穿到内核。这里有个反直觉的点链首是最后被安装的而不是最先被安装的。你装第一个 LSP 时它成为链首再装第二个新的那个成为新的链首原来的退居第二。所以「谁的代码先执行」取决于安装顺序而不是任何显式优先级。多个安全软件各挂一层 LSP 时谁在最外层往往取决于安装先后这种隐式排序是这套机制后来的主要槽点之一。3.3 挂载的两步法先立锚点再改造所有基础项挂载不能靠一次调用搞定因为它需要先拿到一个「自己的编号」才能写进别人的链里。常见的实现思路分两步第一步用WSCInstallProvider登记一个ChainLen 1的目录项DLL 路径指向自己。这一步只是为了让系统分配一个dwCatalogEntryId给这份 DLL此时它还是个「伪基础提供者」没有任何流量会经过它。第二步用WSCEnumProtocols把所有已有的基础项ChainLen 1且地址族是自己关心的那些遍历一遍逐项改造成ChainLen 2ChainEntries[0]填自己的 IDChainEntries[1]填原来那一项的 ID写回目录。改完之后原先的应用请求就会先落到你的WSPStartup。// 改造单个目录项把 item 从「基础」变成「分层」自己插到链首 bool PatchCatalogEntry(WSAPROTOCOL_INFOW* item, DWORD selfId) { if (item-ProtocolChain.ChainLen ! 1) return false; // 已经是分层项跳过 if (item-iAddressFamily ! AF_INET) return false; // 只处理 IPv4 const DWORD baseId item-ProtocolChain.ChainEntries[0]; // 原来的基础提供者 item-ProtocolChain.ChainLen 2; item-ProtocolChain.ChainEntries[0] selfId; // 链首自己 item-ProtocolChain.ChainEntries[1] baseId; // 链尾原来的 item-dwProviderFlags | PFL_HIDDEN; // 隐藏避免被应用直接选中 return true; // 由调用方写回目录 }参数selfId就是第一步拿到的编号。写回目录有两种途径直接改注册表项里的PaddedSpia二进制值或者调用WSCUpdateProvider让系统帮你序列化。后者更安全因为它保证结构里的填充字节也正确手写二进制很容易漏掉对齐填充导致系统读出垃圾。还有一个细节改造时必须保留原项的ProviderId和LibraryPath。有些实现为了「彻底」把这两项也换掉结果链尾指向了自己的 DLL形成自引用WSPStartup无限递归进程直接爆栈。这是血泪经验级别的坑第 5 章还会再说一次。4. 实现细节一个能跑通的最小 LSP 骨架到这一步目录已经指向你了接下来要保证WSPStartup干完它该干的事。这个函数是整个方案里唯一必须手写的入口剩下的三十来个WSP*函数都可以先做成透传。4.1 WSPStartup 里必须做完的四件事签名是这样的int WSPAPI WSPStartup( WORD wEndPointVersion, LPWSPDATA lpWSPData, LPWSPROTOCOL_INFOW lpProtocolInfo, // 注意实际类型是 LPWSAPROTOCOL_INFOW WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable);四件事按顺序做存下UpcallTable。这张表是前端提供的回调集合里面最常用的是WPUCreateSocketHandle用于创建前端认可的句柄。如果你的实现只做透传可以不用它但必须存下来——某些场景下基础提供者会通过它反查上层状态。把自己从链里摘掉。复制一份lpProtocolInfo把ChainLen减一ChainEntries整体左移一位。传给下一层的必须是「摘掉自己」之后的版本否则下一层会在链里又看到你形成环。加载下一跳。用ChainEntries[1]摘完之后的新链首去查对应的LibraryPathLoadLibrary之后GetProcAddress(WSPStartup)。查路径用WSCGetProviderPath它接受一个ProviderId或者目录 ID返回 DLL 全路径。填函数表。调用下层的WSPStartup让它先把函数表填满然后你逐项覆盖成自己的转发函数。int WSPAPI WSPStartup(WORD ver, LPWSPDATA data, LPWSAPROTOCOL_INFOW info, WSPUPCALLTABLE upcall, LPWSPPROC_TABLE procTable) { g_upcall upcall; // 1) 存回调表 WSAPROTOCOL_INFOW next *info; // 2) 摘掉自己这一层 if (next.ProtocolChain.ChainLen 1) { return WSAEPROVIDERFAILEDINIT; // 链断了说明目录被写坏 } const DWORD nextId next.ProtocolChain.ChainEntries[1]; next.ProtocolChain.ChainLen--; for (int i 0; i next.ProtocolChain.ChainLen; i) { next.ProtocolChain.ChainEntries[i] next.ProtocolChain.ChainEntries[i 1]; } WCHAR path[MAX_PATH]; // 3) 加载下一跳 if (!LookupProviderPath(nextId, path, MAX_PATH)) { return WSAEPROVIDERFAILEDINIT; } g_nextModule LoadLibraryW(path); if (!g_nextModule) return WSAEPROVIDERFAILEDINIT; auto pfnStartup (PFN_WSPSTARTUP)GetProcAddress(g_nextModule, WSPStartup); if (!pfnStartup) return WSAEPROVIDERFAILEDINIT; int err pfnStartup(ver, data, next, upcall, procTable); if (err ! 0) return err; g_nextTable *procTable; // 4) 保存下层表逐项替换 procTable-lpWSPSocket WSPSocket; procTable-lpWSPConnect WSPConnect; procTable-lpWSPCloseSocket WSPCloseSocket; // ... 其余入口同理一个都不能漏 return 0; }LookupProviderPath内部调WSCGetProviderPath传入nextId和一个GUID缓冲。注意这个函数要求同时给出ProviderId所以实际做法是先用WSCEnumProtocols遍历一遍找到dwCatalogEntryId nextId的那一项取出它的ProviderId再查路径。4.2 函数表转发三十个入口怎么做到不漏WSPPROC_TABLE里有三十多个函数指针从lpWSPAccept一路到lpWSPStartup。漏填一个的后果不是编译错误而是运行时空指针——前端跳过去直接崩而且崩在别人的进程里你连调用栈都看不到。省事的做法是用宏批量生成转发函数// 生成一个纯透传的转发函数函数名和参数列表由宏参数给出 #define FORWARD(name, params, args) \ int WSPAPI name params { \ return g_nextTable.name args; \ } FORWARD(WSPConnect, (SOCKET s, const struct sockaddr* name, int namelen, LPWSABUF callerData, LPWSABUF calleeData, LPQOS sqos, LPQOS gqos, LPINT err), (s, name, namelen, callerData, calleeData, sqos, gqos, err))纯透传能跑通是因为 LSP 模式下句柄是前端分配的上下层共享同一个句柄空间。这一点和内核过滤框架完全不同——那边每一层都有自己的上下文对象必须显式转换。但纯透传有个代价你拿不到任何有意义的状态。想统计流量就得在WSPSend/WSPRecv里在lpNumberOfBytesSent返回后读一下想记录目标地址就得在WSPConnect返回成功后读name。这些都不需要改句柄属于低风险改动。真正需要小心的是WSPAccept。它的签名里有一个条件回调lpfnCondition非阻塞模式下前端会反复调用它。透传时把前端给的lpfnCondition原样传下去是安全的因为回调收到的SOCKET参数仍然是前端句柄。但如果你的实现自己造了句柄就必须在回调里做转换否则回调里的SOCKET对不上closesocket会关错对象。注意WSPCloseSocket一定要转发。有人觉得「反正进程退出时系统会回收」于是留空。结果是长连接场景下socket 句柄一直不释放进程句柄数持续上涨跑几个小时就WSAEMFILE。4.3 安装与卸载注册表动作的边界安装的第一步是WSCInstallProvider它接受一个GUID、DLL 路径、一组WSAPROTOCOL_INFOW、数量、错误码指针。传入的每个项如果ChainLen 1系统会认为你在登记一个基础提供者。这一步成功了你就拿到了一个属于自己的CatalogEntryId。卸载必须严格逆序这是最容易翻车的地方void Uninstall() { // 1) 先恢复所有被改造过的目录项把链尾 ID 还原回链首 RestoreAllCatalogEntries(); // 2) 再删除自己那个锚点项 WSCUnInstallProvider(g_selfGuid); // 3) 最后让系统重写整个目录确保变更落盘 WSCWriteProviderOrder(nullptr, 0); }顺序反了会怎样先删锚点项那些还引用着锚点 ID 的分层项就变成了悬空引用。系统加载时找不到对应 ID 的提供者直接初始化失败所有网络调用返回WSAEPROVIDERFAILEDINIT。整机断网只能进安全模式改注册表。这是真正意义上的「没有后悔药」。WSCWriteProviderOrder那一步也常被省略。目录变更写在内存里系统有个延迟落盘机制正常关机时才会写。如果卸载后直接强制断电改了一半的目录就留在磁盘上下次开机就是灾难现场。4.4 32 位与 64 位两套目录、两份 DLLCatalog_Entries和Catalog_Entries64是两张独立的表。32 位进程加载 32 位 DLL64 位进程加载 64 位 DLL互不相通。只往一张表里写另一张表里的进程完全不受影响——32 位程序一切正常64 位程序全部断网或者反过来。这意味着一套完整的 LSP 至少要有两个二进制一个x86一个x64代码可以完全相同编译目标不同。安装程序要判断当前系统位数把两份都装进去并且分别向两张目录表写入。卸载时要两边都清干净漏一边就是「以为删了其实没删」。还有更隐蔽的一层在某些系统上32 位进程访问的是被重定向过的注册表视图。直接用RegOpenKeyEx打开Catalog_Entries在 64 位进程里可能被重定向到Wow6432Node下面写进去的东西 32 位程序根本看不到。稳妥的做法是不直接操作注册表全部走WSC*系列 API它们内部处理了视图问题。5. 排查LSP 装完网络全断的几种典型现场LSP 的调试体验很差因为出问题的地方在别人的进程里日志不好打断点不好下现象还常常是「某个程序打不开网页」这种模糊描述。下面几条是按发生频率排的基本覆盖了大部分翻车场景。5.1 现象32 位程序正常64 位程序全部连不上原因安装程序只往Catalog_Entries写了没有写Catalog_Entries64或者只编译了 x86 的 DLL。64 位进程在目录里读到一条指向 x86 DLL 的记录LoadLibrary失败整个提供者链初始化中断。解决确认 DLL 有两份、目录有两张。写一个自检小工具分别在 32 位和 64 位下调用WSCEnumProtocols打印所有ChainLen 1的项肉眼比对两张表的差异。这一步应该在交付前跑而不是等出问题再跑。5.2 现象某个程序一启动就卡死任务管理器也杀不掉原因在DllMain的DLL_PROCESS_ATTACH分支里做了重活。DllMain执行时加载器锁是持有的任何触发模块加载、堆分配、线程创建的操作都可能死锁。典型写法是在DllMain里调WSAStartup或者LoadLibrary去加载下一跳的提供者——下一跳的DllMain又要等加载器锁而锁在你手里双方僵持。解决DllMain里只做一件事——把HINSTANCE存进全局变量然后立刻返回TRUE。所有加载逻辑挪到WSPStartup里。WSPStartup是在WSAStartup调用栈上执行的此时加载器锁已经释放可以安全地LoadLibrary。5.3 现象connect一律返回WSAEINVAL错误码看着莫名其妙原因WSPStartup里传给下一层的lpProtocolInfo没有正确摘链。要么没减ChainLen要么ChainEntries移动时下标写错导致下一层拿到的链首 ID 还是你自己。下一层再去查这个 ID发现指向的就是当前 DLL于是又调了一次WSPStartup如此往复几层后被系统检测到并拒绝。另一种可能是下层WSPStartup返回了非零值你却当成成功继续往下走函数表根本没填完。解决在摘链之后、调用下层之前把ChainLen和ChainEntries打出来确认链首已经不是自己。下层的返回值必须严格判断非零立即原样返回不要试图「容错」。5.4 现象卸载之后重启整机没有网络浏览器报「无法连接到代理服务器」原因卸载顺序错了。先删了自己的锚点项留下的分层项引用了不存在的 ID。系统加载这些项时全部失败而它们又是链首导致所有流量在第一步就被挡住。解决已经没有后悔药的话进安全模式把Protocol_Catalog9整个键导出备份后删除重启让系统重建默认目录。有后悔药的话卸载逻辑写成三步先还原所有链、再删锚点、最后强制落盘。还原那一步要做幂等校验——遍历时检查ChainEntries[0]是不是自己的 ID不是就跳过避免二次执行把别人的链也改了。5.5 现象大部分程序正常个别程序启动即崩崩溃模块指向你的 DLL原因漏填了WSPPROC_TABLE里的某个入口。不同程序用到的 API 集合不一样主流浏览器可能只用到十几个某个小众工具恰好调了WSPIoctl或WSPGetQOSByName跳到空指针直接崩。解决别手工逐个赋值。用memcpy整体复制下层的函数表再单独覆盖你真正想干预的那几个。这样即使将来结构体新增了字段也不会漏。写完做个静态检查把WSPPROC_TABLE的每个成员和你的覆盖列表对一遍确认没有遗漏项。6. 设计取舍这套机制为什么被官方文档判了「不推荐」把链路拆完回过头看这套设计的取舍就很清楚了。Winsock 2 规范的年代目标是「让传输层可扩展」——第三方协议栈、加密传输、协议适配都需要一个不修改系统库就能接入的扩展点。LSP 完美满足了这个目标用户态、纯 DLL、不改内核、不需要驱动签名、开发门槛低到一个人几天就能写出原型。这些优点在当年是决定性的。代价则全部集中在「隔离性」上。LSP 跑在调用方进程内没有独立地址空间没有异常隔离没有生命周期管理也没有版本协商机制。它和宿主进程共享同一个崩溃域一旦你的代码里有个野指针崩的不是你的模块是浏览器、是聊天软件、是系统更新服务。更麻烦的是它对所有进程一视同仁——你没法说「只对某个程序生效」装上去就是全局。后来的内核态过滤框架正是针对这些短板设计的有独立的分类器、有过滤器链、有 callout 注册、有明确的优先级、崩溃了也只影响自己那个组件。两相对比维度LSP用户态分层提供者内核态过滤框架开发门槛低普通 DLL高需要驱动开发与签名隔离性无与宿主进程同生共死有独立组件生效范围全进程无法按进程筛选可按进程、按条件筛选性能开销每次调用一次用户态跳转内核态回调路径更短稳定性风险崩一次全系统断网局部失效当前定位兼容性保留新项目不推荐主推方案所以今天还在用 LSP 的场景基本只剩两类一类是历史遗留系统改造成本太高动不了另一类是教学和研究因为它是理解「提供者链」这个概念最直观的样本——三十行代码就能看到调用是怎么一层层穿下去的这种可见性在内核框架里是拿不到的。我自己的习惯是任何涉及协议目录的改动先在虚拟机快照上跑一遍完整流程——装、测、卸、重启、再测——确认四个环节都干净了才敢碰真机。有一次图省事跳过了「重启后再测」这一步结果目录落盘延迟导致重启后链断花了半天才从安全模式里爬出来。从那以后我每次动Protocol_Catalog9之前都强制走一遍快照加导出备份一次没落下。文末那份带注释的骨架代码值得对着WSPPROC_TABLE的定义逐行读一遍把每个入口对应哪个WSA*函数都对上号比看十篇概述管用。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →