简介:这份 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,指向注册时登记的 DLL,ProtocolChain描述这条链怎么串。
基础协议(TCP、UDP 自己)的ChainLen是 1,ChainEntries[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 个函数指针;你再把这张表填回去给上层。三者的关系可以对着看:
| 表 | 谁提供 | 内容 | 典型用途 |
|---|---|---|---|
WSPUPCALLTABLE | ws2_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, L"SampleLsp over TCP"); INT ret = WSCInstallProviderAndChains( &kLspGuid, dll64, // 64 位 DLL 完整路径 dll32, // 32 位 DLL 完整路径,传 NULL 表示只装当前位数 L"SampleLsp", 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 href="https://download.csdn.net/download/u013761036/9632021" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>