用C++实现LSP拦截FTP流量:Winsock SPI协议链注入实战
2026/9/23 23:09:58 网站建设 项目流程

简介:面向Windows网络编程与安全研究人员的一份C++ LSP(分层服务提供程序)注入实现工程,重点演示如何通过LSP方式拦截Socket通信与FTP协议流量。压缩包共13个文件,体积仅52KB,其中以3个cpp源文件和2个h头文件作为核心代码,配合dsp/dsw工程文件与def、ini等配置文件,构成一套完整的DLL注入示例,目前已有324人学习。内容包含LSP DLL完整源码、注入注册说明和演示工程(含LSP.dll与实例程序),覆盖LSP在Winsock层次结构中的安装、利用DLL注入目标进程、在send/recv等调用上挂接自定义处理函数等关键步骤。读者可以借此理解Windows网络数据包的底层流向,掌握Socket级流量的捕获、分析与修改方法,也可将这套代码用作自定义网络过滤、监控或安全增强功能的基础,适合具备一定C++基础、希望深入网络底层机制的开发者和安全爱好者学习。

1. LSP 拦截 FTP:一个老技术为什么还能解决新问题

很多人下载 LSPInject.rar 这类包或者搜 "lsp inject" 时,第一反应是"这又是一个杀毒软件报毒的工具"。把最外层的落盘行为剥掉,LSP 的本体其实是一个 Winsock 服务提供者 DLL,安装后挂进系统 Winsock 目录,任何进程创建 socket、connect、send、recv 时都会先经过它的转发函数。在 Win10/Win11 上微软已经不再推荐新写 LSP,但这个机制对 C++ 做 socket 拦截和注入依然是最直观的教材:它能用很少的代码看到一条 TCP 流里的所有明文。本文以拦截 FTP 为目标:用 C++ 实现一个最小 LSP,在协议目录中插入自己的分层服务提供者,然后在 WSPConnect、WSPSend、WSPRecv 三个入口对 21 端口流量做过滤。适合有 C++ 基础、想搞清 Winsock SPI,或者要做内网 FTP 审计的读者。

2. 理解 Winsock 目录与 LSP 的注入位置

2.1 Winsock 的 SPI 层:从 API 到服务提供者

任何应用调用socket()时,ws2_32.dll都会去 Winsock 目录里查找匹配的服务提供者。目录里有两类条目:一类是直接操作底层网络设备的传输提供者,例如 TCP/IP 基础提供者;另一类是贴在传输提供者之上的分层提供者,也就是 LSP。分层提供者不生产网络流量,它只负责转发函数调用,并可以在转发过程中读取、修改参数和缓冲区。

C++ 写 LSP 注入的本质,就是把自己写成一个符合 SPI 规范的 DLL,再把这个 DLL 对应的目录条目插到协议链的最前面。这跟普通的 DLL 注入完全不同:DLL 注入是让目标进程加载你的代码,而 LSP 是让 Winsock 在进程创建 socket 时主动来调用你的代码。前者要处理远程线程和注入器,后者只需要修改系统目录条目。

2.2 协议链:一个 socket 经过的目录项序列

目录中每条链由一个或多个目录条目组成,链长ChainLen决定数据包的转发路径。默认 TCP/IP 是ChainLen=1的基础提供者;装上 LSP 后,Winsock 会生成一条ChainLen=2的链,前一个条目是 LSP,后一个是原有基础提供者。当程序创建 socket 时,系统按链的顺序依次调用每个提供者的WSPStartup,并在同一进程内共享各自的函数表。

先用一段代码看看当前机器上的 Winsock 目录长什么样。这段代码可以放进任何 Win32 控制台程序,编译后直接运行:

#include <winsock2.h> #include <ws2spi.h> #include <stdio.h> #include <stdlib.h> #pragma comment(lib, "ws2_32.lib") #pragma comment(lib, "ws2spi.lib") void DumpWinsockCatalog() { DWORD dwSize = 0; // 第一次调用传 NULL,让系统告诉我们缓冲区需要多大 WSCEnumProtocols(NULL, NULL, &dwSize, NULL); LPWSAPROTOCOL_INFOW pInfo = (LPWSAPROTOCOL_INFOW)malloc(dwSize); DWORD dwCount = WSCEnumProtocols(NULL, pInfo, &dwSize, NULL); for (DWORD i = 0; i < dwCount; ++i) { printf("[%02u] ChainLen=%d %ls\n", i, pInfo[i].ProtocolChain.ChainLen, pInfo[i].szProtocol); for (int j = 0; j < pInfo[i].ProtocolChain.ChainLen; ++j) { printf(" step %d -> CatalogEntry %lu\n", j, pInfo[i].ProtocolChain.ChainEntries[j]); } } free(pInfo); } int main() { WSADATA wsa; WSAStartup(MAKEWORD(2, 2), &wsa); DumpWinsockCatalog(); WSACleanup(); return 0; }

第一次调用WSCEnumProtocols故意用 NULL 缓冲区,只是为了拿到需要的字节数,第二次调用才真正填数据。输出里ChainLen=1的条目是基础提供者,ChainLen>=2说明链上已经有分层提供者。如果你的机器装过加速器或安全软件,会看到多条链叠在一起;如果运行这个程序后没看到自己的 LSP,说明 DLL 根本没进目录,或者WSCWriteProviderOrder的排序没有生效。WSAStartupWSACleanup必须成对出现,否则 Winsock 的引用计数会泄漏。

2.3 拦截 FTP 该盯住三个调用点

FTP 的控制连接是 TCP 明文,客户端发出的 USER、PASS、RETR、STOR 命令都在 send 方向,服务端的 220、230、550 状态码在 recv 方向。因此 LSP 只需要处理三个函数:WSPConnect用于识别目标端口是否为 21,WSPSend用于过滤上行命令,WSPRecv用于审计下行响应。

和 hook 应用内函数不同,LSP 的注入点在 Winsock 内部,所以进程里任何使用 Winsock 的代码都会被同一份过滤逻辑覆盖,包括那些不经过自己业务代码的第三方 FTP 客户端。这既是优势也是风险:目录一旦装错,影响的是全系统的网络调用。

3. C++ 实现 socket 注入的最小 LSP 骨架

3.1 工程结构:一个 DLL 加一个安装器

LSP 项目至少包含两部分:一个导出WSPStartup函数的 DLL,以及一个把 DLL 写入 Winsock 目录的安装程序。把过滤业务单独拆出来的好处是,DLL 只做转发和回调,逻辑简单了,调试时也更容易定位问题。

模块文件职责
LSP DLLMyLsp.dll导出 WSPStartup,实现 WSPConnect/WSPSend/WSPRecv 转发
安装器install.exe枚举目录,复制基础提供者信息,插入自己的目录条目
卸载器uninstall.exe枚举目录,按 GUID 删除条目,恢复原链顺序

特别注意:安装器写错比 DLL 写错更可怕。如果 DLL 报错,最多是 socket 调用失败;如果安装器把链的ChainEntries顺序填反,系统可能连基本网络功能都受影响。动手之前,先用第 2 章的枚举程序把目录全部导出一份留底。

3.2 写出能通过编译的 WSPStartup 框架

一个最小 LSP 的WSPStartup要完成四件事:校验自己确实是被当作分层提供者加载;取到下一层的WSAPROTOCOL_INFOW;加载下一层 DLL 并初始化;把转发函数表替换成自己的实现。

#include <winsock2.h> #include <ws2spi.h> #pragma comment(lib, "ws2_32.lib") #pragma comment(lib, "ws2spi.lib") // 下一层提供者的函数表,所有函数都从这里转发 static WSPPROC_TABLE g_NextProcTable; static WSAPROTOCOL_INFOW g_NextProtoInfo; // 辅助函数:从当前链里找到下一个目录条目 // 典型实现是 WSCEnumProtocols 遍历目录,找到自己位置后 // 取出 ChainEntries 数组中后一项对应的 CatalogEntryId extern int FindNextProvider(PDWORD dwNextCatalogId, LPWSAPROTOCOL_INFOW lpNextInfo); // WSPConnect 转发:先判断目标端口,再调用下一层 static int WSPAPI MyWSPConnect( SOCKET s, const struct sockaddr* name, int namelen, LPWSABUF lpCallerData, LPWSABUF lpCalleeData, LPWSAOVERLAPPED lpOverlapped, LPWSAOVERLAPPED_COMPLETION_ROUTINE lpCompletionRoutine, LPWSATHREADID lpThreadId, LPINT lpErrno) { if (name && name->sa_family == AF_INET) { // 这里可以取出 sin_port,判断目标端口是否是 21 // 决定是否给这个 socket 打上 "FTP 控制连接" 的标记 } return g_NextProcTable.lpWSPConnect( s, name, namelen, lpCallerData, lpCalleeData, lpOverlapped, lpCompletionRoutine, lpThreadId, lpErrno); } int WSPAPI WSPStartup( WORD wVersionRequested, LPWSPDATA lpWSPData, LPWSAPROTOCOL_INFOW lpProtocolInfo, WSPUPCALLTABLE upcallTable, LPWSPPROC_TABLE lpWSPFunctionTable) { // 链长小于 2,说明当前不是作为分层提供者被加载 if (lpProtocolInfo->ProtocolChain.ChainLen < 2) return WSAEINVAL; DWORD dwNextId = 0; if (FindNextProvider(&dwNextId, &g_NextProtoInfo) != 0) return WSAEINVAL; wchar_t szPath[MAX_PATH] = {0}; int nPathLen = MAX_PATH; if (WSCGetProviderPath(&g_NextProtoInfo.ProviderId, szPath, &nPathLen, NULL) != 0) return WSAEINVAL; HMODULE hNext = LoadLibraryW(szPath); if (!hNext) return WSAEINVAL; LPWSPSTARTUP pfnNext = (LPWSPSTARTUP) GetProcAddress(hNext, "WSPStartup"); if (!pfnNext) return WSAEINVAL; int rc = pfnNext(wVersionRequested, lpWSPData, &g_NextProtoInfo, upcallTable, &g_NextProcTable); if (rc != 0) return rc; // 复制下一层函数表,再替换关心的三个函数 WSPPROC_TABLE myTable = g_NextProcTable; myTable.lpWSPConnect = MyWSPConnect; // myTable.lpWSPSend = MyWSPSend; // myTable.lpWSPRecv = MyWSPRecv; *lpWSPFunctionTable = myTable; // 整个表按值返回 return 0; }

说明几个关键点:FindNextProvider不是系统 API,是工程里的辅助函数,典型做法是WSCEnumProtocols遍历目录,找到ChainEntries数组中当前条目 ID 的后一项,把它的协议信息拷到g_NextProtoInfoWSCGetProviderPath的作用是根据 Provider GUID 找到下一层 DLL 的绝对路径。这里有个常见错误:直接GetProcAddress(GetModuleHandle("ws2_32.dll"), "WSPStartup"),这拿到的是 ws2_32 的转发器,不是下一层服务提供者的入口,必须按目录条目的 GUID 查路径再 LoadLibrary。

WSPStartup的最后一个参数lpWSPFunctionTable是指向WSPPROC_TABLE的指针,所以赋值时是把整个表按值写进去。如果把它当成函数指针来用,第一次 socket 调用就会崩溃。

3.3 转发的三个函数与缓冲区处理

WSPConnect的参数里,name指向目标地址结构,namelen是结构长度。对 FTP 拦截来说,只要在这个入口判断sin_port == 21,然后把 socket 句柄记录到一个哈希表里,后续WSPSend/WSPRecv就可以只处理这张表里的 socket。

WSPRecvWSPSend的缓冲区参数是一个WSABUF数组,不是单个字符串指针。数组长度由dwBufferCount指定。解析时一定要遍历数组,不能假设lpBuffers[0]就是完整一包数据,也不能直接把缓冲区当 C 字符串处理,因为里面没有\0结尾。

// 在 WSPSend 里,lpBuffers[i].buf 指向应用层数据,len 是字节数 for (DWORD i = 0; i < dwBufferCount; ++i) { // FTP 命令短,一般一次调用能到达完整一行 FilterFtpText(lpBuffers[i].buf, lpBuffers[i].len, 1); }

4. 在 LSP 里过滤 FTP:从端口判断到命令级拦截

4.1 为什么 FTP 适合用 LSP 拦

FTP 有两条连接:控制连接用 TCP 21,用于传命令和响应;数据连接用于传文件内容。LSP 拦到的是 TCP 字节流,如果想正确识别命令,只需要关心控制连接上的字节流。数据连接的流量如果不做文件内容过滤,直接透传就行。

这里有个容易踩的坑:被动模式下,数据连接是客户端连接到服务端的某个随机端口,这些数据包的sin_port不是 21。如果你在WSPConnect里只是简单地判断"目标端口不等于 21 就放行",那数据连接确实被放行了,没问题。但如果你想在数据连接上做上传下载记录,就必须在 PASV 响应里解析服务端下发的端口,再对那个端口做匹配,复杂度会明显上升。做命令级审计时,通常不需要走到这一步。

4.2 按行切分并匹配 FTP 命令

FTP 控制流是文本行,以\r\n结尾,命令名一般是四个字节,例如 USER、PASS、RETR、STOR。在 LSP 的缓冲区里直接查找关键字即可,但一次 recv 可能只收到半行,所以需要一个挂起缓冲区。

static char g_pending[4096]; static int g_pendingLen = 0; void FilterFtpText(const char* buf, int len, int direction) { for (int i = 0; i < len; ++i) { g_pending[g_pendingLen++] = buf[i]; if (g_pendingLen >= (int)sizeof(g_pending) - 1) { g_pendingLen = 0; // 防止超长行撑爆缓冲区 continue; } if (buf[i] == '\n') { // 一行结束 g_pending[g_pendingLen] = '\0'; // direction=1 表示客户端上行,0 表示服务端下行 if (direction == 1) { printf("[FTP C->S] %s", g_pending); } else { printf("[FTP S->C] %s", g_pending); } g_pendingLen = 0; } } }

挂起缓冲区是必须的:一条命令横跨两个 recv 时,如果不把半行先攒起来,关键字匹配就会漏掉。4KB 对 FTP 命令来说绰绰有余;如果真出现超过 4KB 的行,直接清空并放弃这次分析,因为合法的 FTP 命令不可能这么长。实际过滤时,把printf换成你自己的动作函数,这个函数拿到一行完整的命令,可以记录、替换或者丢弃。

4.3 过滤策略:记录、替换、丢弃

策略实现位置关键参数
记录WSPSend / WSPRecv在 FilterFtpText 里打印整行,或写日志文件
替换WSPSend修改 lpBuffers[i].buf 里的敏感字段,例如把密码改为固定串
丢弃WSPSend不调用下一层,直接返回成功,但需要处理协议状态

丢弃是最容易写错的部分:不能简单把 length 改成 0 再传给下一层,那样 FTP 协议状态机收到一个空白请求会卡住。更稳的做法是把敏感内容替换成等长或更短的无害字符串,例如把PASS xxxxx改成PASS filtered,长度变短后要把缓冲区的剩余位置补空格或其他可打印字符,避免尾端出现残留。

另外要注意重叠 I/O。如果调用方传进来的lpOverlapped不为 NULL,说明这是一个异步发送,缓冲区内容在函数返回后仍然可能被底层读取。这时候直接改写缓冲区会造成数据竞争。稳妥的做法是:只在同步发送时做内容替换,异步发送只打印日志。

4.4 安装到 Winsock 目录:WSCInstallProvider 与链顺序

安装程序的核心是把 LSP 作为一个分层条目插进目录,然后把 TCP 链顺序调整到最前。逻辑如下:

WSAPROTOCOL_INFOW base; // 从目录枚举中选出的 TCP 基础提供者 WSAPROTOCOL_INFOW chain = base; chain.dwProviderFlags |= PFL_HIDDEN; chain.ProtocolChain.ChainLen = 2; chain.ProtocolChain.ChainEntries[0] = lspCatalogId; chain.ProtocolChain.ChainEntries[1] = baseCatalogId; int rc = WSCInstallProvider(&lspGuid, L"C:\\tools\\MyLsp.dll", &chain, 1, NULL);

WSCInstallProvider的第二个参数是 DLL 绝对路径,第三个参数是协议信息数组,第四个参数是数组长度。这里传 1 表示同时注册一条链。ChainEntries[0]是 LSP 自己的目录条目 ID,ChainEntries[1]是基础提供者的 ID,顺序反了整条链都会失效。lspGuid要固定写死,卸载时复用同一个 GUID。

装完之后还要用WSCWriteProviderOrder把这条链排到最前,否则系统可能仍然优先使用原来的基础提供者。新系统上WSCInstallProvider需要管理员权限,而且 DLL 需要有有效签名。开发期可以用自签名证书配合测试签名模式,否则函数会返回WSAEINVAL,让人误以为是参数写错。

提示:安装前先跑一次第 2 章的枚举程序做备份。万一目录被写坏,可以用netsh winsock reset恢复,但这条命令会清掉所有第三方 LSP,也会影响本机网络配置,只在调试环境使用。

5. 用回环实验验证拦截,然后回到现实

5.1 三分钟跑通一个本地 FTP 服务

安装好 LSP 后,验证目标很简单:跑一个本地 FTP 服务,客户端连上去,看 LSP 的日志是否打印出 USER 和 PASS 行。常见做法是本地启动 pyftpdlib:

pip install pyftpdlib python3 -m pyftpdlib -p 2121 -u test -P test

Windows 上如果python3不存在,用py -3 -m pyftpdlib。端口选 2121 而不是 21,可以避开系统可能占用的 21 端口,同时验证你的过滤逻辑没有把端口写死。然后用系统自带 ftp 客户端连接:

ftp 127.0.0.1 2121

输入用户名 test 和密码 test 后,LSP 过滤器应该打印出 USER 和 PASS 两行。如果没有任何输出,先确认第 2 章的枚举程序里能看到 MyLsp 的链,再检查WSPConnect是否真的记录了 2121 端口。

5.2 三个常见故障的排查方向

现象可能原因排查步骤
socket() 返回 10044Winsock 目录链损坏netsh winsock reset,重启后重新安装
WSCInstallProvider 失败DLL 没有正确签名用 Signtool 自签名,或开启测试签名模式
LSP 装上后所有程序都不能联网WSPStartup 递归加载了自己单步调试,检查 ProviderId 是否指向下一个目录条目

递归加载的典型特征:hNext加载出来的 DLL 路径和当前进程的模块路径完全相同。这意味着WSCGetProviderPath查到的还是你自己,安装器把ChainEntries[1]填成了 LSP 自己的 ID。这种错误会导致 socket 调用无限递归,进程栈被压爆。

5.3 LSP 的边界与更现代的替代路线

LSP 已经不被微软推荐用于新项目,主要原因是它在系统全局插入调用链,任何 socket 数据都会经过 DllMain 之外的转发层,杀毒软件告警和系统不稳定都因此而来。另一个现实问题是 FTPS 用 TLS 加密后,LSP 在 SPI 层拿到的是密文,明文过滤全部失效。生产环境做 socket 拦截,更常见的方案是 WFP 的流层过滤,它在协议栈深处处理 IP/TCP 数据,不接触应用层缓冲区。

如果只是想对内网 FTP 做审计,还有一个比 WFP 轻量得多的路线:把 LSP 的过滤逻辑收敛到一条链上。只注册 TCP,不注册 UDP;WSPConnect里只记录 21 端口,其余 socket 直接透传;只重写WSPStartup返回的表里的lpWSPSendlpWSPRecv,其他函数全部沿用下一层表。安装时WSCWriteProviderOrder的入参只写这一条链,LSP 对系统的噪音就会降到最小。这也是 LSP 相比 WFP 的最后一个实用价值:用最少的代码,看一条 TCP 控制流里到底在传什么。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询