Winsock LSP注入与WSPStartup拦截实战
发布时间:2026/10/31 18:30:17 锦皓数字建站

简介这份 LSP 注入技术学习资料围绕 Windows 语言服务提供者Language Service Provider的底层钩子机制展开面向已具备 C 基础、希望理解 COM 组件注册与进程注入原理的中级开发者。内容从 LSP 概念与 COM 接口规范切入讲解自定义语言服务 DLL 的编写、注册表登记、运行时注入以及请求拦截处理的完整链路并说明安全检测、数据恢复、调试工具与恶意软件滥用等典型应用场景帮助读者建立对底层钩子技术的能力边界与风险认知。压缩包共 38 个文件、约 150KB以 15 个 cpp 源文件和 7 个 h 头文件为主体配合 vcxproj 与 sln 工程文件可直接用 Visual Studio 打开编译另有 bat 注册脚本、def 导出定义及已编译的 dll、exe便于对照源码与成品验证注入效果。目前已有 1582 人学习适合想动手复现 LSP 注入流程、厘清 COM 接口实现细节的读者作为练手工程。1. 一次断网排查lsp 注入 到底在应用进程里做了什么某公司的终端合规客户端上线第二天八台办公机网页打不开ping 网关却正常重装网卡驱动、重置 IP 全都没用。运维把netsh winsock show catalog的输出导出来发现协议目录里多了一条链链尾挂着一个已经被删掉的 DLL 路径。这就是 lsp 注入 的典型现场它跟往进程里写内存的那类注入不是一回事lsp 注入 走的是 Winsock SPI 的分层机制把自己的 DLL 注册进协议目录应用一调WSAStartup系统就按链把这一层拉进进程之后每一次connect、send、recv都要先过它。适合看这篇的人很明确做桌面终端安全客户端、本机流量审计、网络行为管控的开发者以及想弄明白这类客户端底层凭什么能拦住连接的人。下面按一份能跑起来的样例工程拆开讲——链怎么排、WSPStartup怎么接、拦截点放在哪、卸载怎么收尾不把自己玩死。2. 分层链的内部结构协议目录、ChainEntries 与三张函数表2.1 协议目录里那一行到底存了什么协议目录是注册表里的一组二进制记录32 位和 64 位各存一套netsh winsock show catalog打出来的是当前进程位数对应的那一套。每一条记录展开就是一个WSAPROTOCOL_INFOW真正决定行为的是三个字段dwCatalogEntryId是全局唯一编号ProviderId是 GUID指向注册时登记的 DLLProtocolChain描述这条链怎么串。基础协议TCP、UDP 自己的ChainLen是 1ChainEntries[0]就是它自己的编号一旦ChainLen大于 1它就是一条分层链ChainEntries[0]固定为 1表示这是一条以基础协议收尾的链往后按从外到内的顺序排列各层的 catalog id最后一个通常就是 TCP 或 UDP。理解这个数组的方向很重要——它决定了WSPStartup里怎么找“下一层”。// 从 lpProtocolInfo 里把自己在链上的位置和下一层找出来 DWORD FindNextProviderId(const WSAPROTOCOL_INFOW* info) { const WSAPROTOCOLCHAIN* chain info-ProtocolChain; // ChainLen 1 说明拿到的是基础协议下面没东西了 if (chain-ChainLen 1) return 0; // ChainEntries[0] 固定是 1从下标 1 开始才是真正的各层编号 for (int i 1; i chain-ChainLen; i) { if (chain-ChainEntries[i] info-dwCatalogEntryId) { // 找到自己下一格就是更内层自己是最后一层时返回 0 表示到底了 return (i 1 chain-ChainLen) ? chain-ChainEntries[i 1] : 0; } } return 0; // 链里没有自己说明注册信息被改坏了 }这段函数是整个实现的地基参数只有一个lpProtocolInfo返回值是下一层的 catalog id。注意它做了两件事第一用dwCatalogEntryId而不是 GUID 来定位自己因为同一个 GUID 可能被注册成 TCP 和 UDP 两个入口用 GUID 会认错第二找不到自己时返回 0 而不是随便返回个数让上层有机会直接报错退出。我踩过一次坑早期版本用“取数组最后一个元素”来当下一层结果系统里还有别的厂商的分层链排在后面WSPStartup直接把别人的 DLL 当成了 TCP一调就崩。自那以后我只认dwCatalogEntryId精确匹配。2.2 WSPStartup系统什么时候把你的 DLL 拉进进程应用调用WSAStartup时ws2_32.dll去协议目录里找匹配的链然后从链头开始逐层LoadLibrary并调用 DLL 导出的WSPStartup。注意加载顺序是链头先、链底后返回值却是链底先、链头后——因为每一层都要先拿到下层的 30 个函数指针才能填自己的表往上传。整个 LSP 里只有WSPStartup是必须导出的其余 30 个 SPI 函数不需要__declspec(dllexport)它们是通过lpProcTable这个结构体以函数指针形式返回给调用方的。这一点很多人第一次写会搞混在 .def 文件里把 30 个函数全导出了结果系统根本不看反而因为导出表太长拖慢加载。// 按 catalog id 反查 DLL 路径先枚举拿长度再枚举拿内容 static BOOL PathFromCatalogId(DWORD catalogId, WCHAR* outPath, DWORD cch) { INT err 0; DWORD bufLen 0; // 第一次调用只为拿所需缓冲区大小返回 SOCKET_ERROR 是正常的 WSCEnumProtocols(NULL, NULL, bufLen, err); BYTE* buf (BYTE*)HeapAlloc(GetProcessHeap(), 0, bufLen); if (!buf) return FALSE; BOOL found FALSE; if (WSCEnumProtocols(NULL, (LPWSAPROTOCOL_INFOW)buf, bufLen, err) ! SOCKET_ERROR) { for (BYTE* p buf; p buf bufLen; ) { WSAPROTOCOL_INFOW* pi (WSAPROTOCOL_INFOW*)p; if (pi-dwCatalogEntryId catalogId) { INT len (INT)cch; if (WSCGetProviderPath((LPGUID)pi-ProviderId, outPath, len, err) ! SOCKET_ERROR) { // 返回的路径常带 %SystemRoot% 前缀不展开 LoadLibrary 直接失败 ExpandEnvironmentStringsW(outPath, outPath, cch); found TRUE; } break; } p sizeof(WSAPROTOCOL_INFOW) pi-dwProviderReserved; } } HeapFree(GetProcessHeap(), 0, buf); return found; }这里有两个细节值得单说。WSCEnumProtocols的缓冲区不是定长数组每条记录后面可能还跟着dwProviderReserved字节的附加数据所以遍历的时候步长必须算上它不能直接p sizeof(WSAPROTOCOL_INFOW)。另一个是ExpandEnvironmentStringsW少了这一行LoadLibrary拿到的字符串里带着%SystemRoot%在部分系统上直接返回 NULL表现就是“WSPStartup 里 LoadLibrary 失败但路径打印出来看着是对的”这种玄学问题。2.3 三张函数表把整条链串起来真正让这个架构转起来的是三张函数表。WSPStartup的参数里带着UpcallTable这是系统给的一批向上回调处理异步事件时会用到下层的WSPStartup会通过lpProcTable输出它的 30 个函数指针你再把这张表填回去给上层。三者的关系可以对着看表谁提供内容典型用途WSPUPCALLTABLEws2_32传入异步事件、重叠 IO 的向上回调转发WSAAsyncSelect关联的窗口消息NextProcTable下层 provider 输出30 个 SPI 函数指针不拦截的调用原样转发lpProcTable你填返回给上层同样 30 个指针覆盖掉要拦的那几个有了这三张表一个“只观测、不改变数据”的 LSP 其实只要写一个函数就够了——把NextProcTable整体赋值给lpProcTable然后替换想挂钩的成员。这也是我建议所有新项目的起步形态先让链跑通、能加载、能正常上网再一个一个加拦截点别一上来就把 30 个函数全写一遍。3. 最小可跑工程从 WSPStartup 骨架到 WSCInstallProviderAndChains 注册3.1 目录结构32 位和 64 位必须出两套 DLL工程我一般这么摆重点是build下面两个平台分开编译导出的 DLL 名字也分开安装时一次性把两个路径传给注册函数lsp-demo/ src/ lsp_core.c # WSPStartup 主体负责接链和填表 lsp_hooks.c # 拦截点WSPConnect / WSPSendTo / WSPCloseSocket lsp_state.c # 每 socket 的状态表与临界区 lsp_rule.c # 规则判定先做成硬编码后面再抽出配置 lsp_install.c # 安装卸载调用 WSCInstallProviderAndChains lsp_ctx.h build/ Win32/lspdemo32.dll x64/lspdemo64.dll tools/ dump_catalog.bat # 封装 netsh winsock show catalog 并打时间戳分开编译不是洁癖。32 位进程只会去读 32 位那一套协议目录64 位进程读 64 位那一套两套目录互相独立。只注册 64 位结果就是浏览器64 位能拦住、某些老客户端32 位完全不受影响测试同学会以为拦截逻辑有 bug其实是根本没加载你的 DLL。3.2 WSPStartup 骨架先整体拷贝再覆盖下面是lsp_core.c的主体。它的职责就三件找到下一层、把下层拉起来、把表填回去。typedef struct _LSP_CTX { WSPUPCALLTABLE UpcallTable; // 系统给的向上回调 WSPPROC_TABLE NextTable; // 下层的 30 个函数指针 HMODULE hNextDll; // 下层 DLL 句柄卸载时要 FreeLibrary DWORD selfId; } LSP_CTX; static __declspec(thread) LSP_CTX* g_tlsCtx NULL; // 每个线程一份即可 int WSPAPI WSPStartup(WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE UpcallTable, LPWSPPROC_TABLE lpProcTable) { // 1) 定位自己在链上的下一层 DWORD nextId FindNextProviderId(lpProtocolInfo); if (nextId 0) return WSAEINVAL; // 2) 找到下层 DLL 并取出它的 WSPStartup WCHAR dllPath[MAX_PATH] {0}; if (!PathFromCatalogId(nextId, dllPath, MAX_PATH)) return WSAEPROVIDERFAILEDINIT; HMODULE hNext LoadLibraryW(dllPath); if (!hNext) return WSAEPROVIDERFAILEDINIT; LPWSPSTARTUP pfnNext (LPWSPSTARTUP)GetProcAddress(hNext, WSPStartup); if (!pfnNext) { FreeLibrary(hNext); return WSAEPROVIDERFAILEDINIT; } // 3) 把入参里的编号换成下一层链结构原样往下传下层自己会定位 WSAPROTOCOL_INFOW nextInfo *lpProtocolInfo; nextInfo.dwCatalogEntryId nextId; WSPPROC_TABLE nextTable {0}; INT ret pfnNext(wVersionRequested, lpWSPData, nextInfo, UpcallTable, nextTable); if (ret ! 0) { FreeLibrary(hNext); return ret; } // 4) 建上下文 LSP_CTX* ctx (LSP_CTX*)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(LSP_CTX)); if (!ctx) { FreeLibrary(hNext); return WSAENOBUFS; } ctx-UpcallTable UpcallTable; ctx-NextTable nextTable; ctx-hNextDll hNext; ctx-selfId lpProtocolInfo-dwCatalogEntryId; g_tlsCtx ctx; // 5) 关键一步整表拷贝再只覆盖要拦的那几个 *lpProcTable nextTable; lpProcTable-lpWSPConnect Hook_WSPConnect; lpProcTable-lpWSPSendTo Hook_WSPSendTo; lpProcTable-lpWSPCloseSocket Hook_WSPCloseSocket; lpProcTable-lpWSPCleanup Hook_WSPCleanup; return 0; }参数逐个说明wVersionRequested和lpWSPData只管透传不用改lpProtocolInfo是入参复制后改dwCatalogEntryId就够了不要自作聪明去改ProtocolChain.ChainLen改了以后下层如果按链长做校验会直接拒绝UpcallTable要存下来后面处理异步事件时用得上lpProcTable是输出参数*lpProcTable nextTable;这一行是整个实现里最省事的地方把 30 个转发函数一次性解决。g_tlsCtx用线程局部存储是因为WSPStartup在一个进程里可能被调用多次应用多次WSAStartup或者不同线程各自初始化用全局单例会在多线程下互相踩。真实的商用实现通常在一个进程里维护一张 socket 到状态的映射但上下文本身用 TLS 存最省心。3.3 安装与卸载WSCInstallProviderAndChains 的调用细节注册这一步不需要自己拼注册表WSCInstallProviderAndChains会帮你把 entry 写进目录、把链拼好。前提是有个固定的 GUID安装和卸载必须用同一个否则卸载时找不到自己// 自己生成一次之后写死在代码里别每次安装随机生成 static const GUID kLspGuid { 0x9a3f1c20, 0x7b44, 0x4e21, { 0x8d, 0x35, 0x2c, 0x11, 0x6e, 0x90, 0x4a, 0x77 } }; INT InstallLsp(const WCHAR* dll64, const WCHAR* dll32) { INT err 0; DWORD entryId 0; WSAPROTOCOL_INFOW info {0}; // 描述这一层支持的协议TCP 和 UDP 各登记一次这里以 TCP 为例 info.dwServiceFlags1 XP1_IFS_HANDLES; info.iAddressFamily AF_INET; info.iSocketType SOCK_STREAM; info.iProtocol IPPROTO_TCP; info.iVersion 2; info.ProviderId kLspGuid; wcscpy_s(info.szProtocol, WSAPROTOCOL_LEN, LSampleLsp over TCP); INT ret WSCInstallProviderAndChains( kLspGuid, dll64, // 64 位 DLL 完整路径 dll32, // 32 位 DLL 完整路径传 NULL 表示只装当前位数 LSampleLsp, XP1_IFS_HANDLES, info, 1, entryId, err); return (ret SOCKET_ERROR) ? err : 0; }dll64和dll32必须是绝对路径WSCInstallProviderAndChains不会帮你解析相对路径。entryId是输出拿到后建议落一行日志后面排查“装的是哪一条”时全靠它。dwServiceFlags传XP1_IFS_HANDLES表示这层能透传 IFS 句柄如果你的层不创建新 socket这个标志必须带上否则部分应用尤其是用 IOCP 的会拿不到可用句柄而报错。卸载就一句WSCDeinstallProvider(kLspGuid, err)但顺序不能错先调它把目录恢复再想办法删 DLL 文件。反过来先删文件目录里那条记录还指向不存在的路径全机所有进程WSAStartup都会失败——这就是开头那次断网事故的成因。4. 拦截点实现WSPConnect 的判定姿势与每 socket 状态表4.1 连接拦截地址判定与拒绝的正确姿势WSPConnect是最自然的拦截点因为目标地址就在参数里不用维护状态。写法上要特别注意拒绝之后怎么收场int WSPAPI Hook_WSPConnect(SOCKET s, const struct sockaddr FAR* name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPQOS lpSQOS, LPQOS lpGQOS, LPINT lpErrno) { LSP_CTX* ctx g_tlsCtx; if (!ctx) { *lpErrno WSAENOTCONN; return SOCKET_ERROR; } if (name name-sa_family AF_INET namelen (int)sizeof(SOCKADDR_IN)) { const SOCKADDR_IN* v4 (const SOCK p a hrefhttps://download.csdn.net/download/u013761036/9632021 stylecolor:#ec7500;font-size:14px; 本文还有配套的精品资源点击获取 /a img altmenu-r.4af5f7ec.gif srchttps://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif stylewidth:16px;margin-left:4px;vertical-align:text-bottom;cursor:text; /p
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。