用C++实现LSP拦截FTP流量:Winsock SPI协议链注入实战
简介面向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 是ChainLen1的基础提供者装上 LSP 后Winsock 会生成一条ChainLen2的链前一个条目是 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 缓冲区只是为了拿到需要的字节数第二次调用才真正填数据。输出里ChainLen1的条目是基础提供者ChainLen2说明链上已经有分层提供者。如果你的机器装过加速器或安全软件会看到多条链叠在一起如果运行这个程序后没看到自己的 LSP说明 DLL 根本没进目录或者WSCWriteProviderOrder的排序没有生效。WSAStartup和WSACleanup必须成对出现否则 Winsock 的引用计数会泄漏。2.3 拦截 FTP 该盯住三个调用点FTP 的控制连接是 TCP 明文客户端发出的 USER、PASS、RETR、STOR 命令都在 send 方向服务端的 220、230、550 状态码在 recv 方向。因此 LSP 只需要处理三个函数WSPConnect用于识别目标端口是否为 21WSPSend用于过滤上行命令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_NextProtoInfo。WSCGetProviderPath的作用是根据 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。WSPRecv和WSPSend的缓冲区参数是一个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; // direction1 表示客户端上行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, LC:\\tools\\MyLsp.dll, chain, 1, NULL);WSCInstallProvider的第二个参数是 DLL 绝对路径第三个参数是协议信息数组第四个参数是数组长度。这里传 1 表示同时注册一条链。ChainEntries[0]是 LSP 自己的目录条目 IDChainEntries[1]是基础提供者的 ID顺序反了整条链都会失效。lspGuid要固定写死卸载时复用同一个 GUID。装完之后还要用WSCWriteProviderOrder把这条链排到最前否则系统可能仍然优先使用原来的基础提供者。新系统上WSCInstallProvider需要管理员权限而且 DLL 需要有有效签名。开发期可以用自签名证书配合测试签名模式否则函数会返回WSAEINVAL让人误以为是参数写错。提示安装前先跑一次第 2 章的枚举程序做备份。万一目录被写坏可以用netsh winsock reset恢复但这条命令会清掉所有第三方 LSP也会影响本机网络配置只在调试环境使用。5. 用回环实验验证拦截然后回到现实5.1 三分钟跑通一个本地 FTP 服务安装好 LSP 后验证目标很简单跑一个本地 FTP 服务客户端连上去看 LSP 的日志是否打印出 USER 和 PASS 行。常见做法是本地启动 pyftpdlibpip install pyftpdlib python3 -m pyftpdlib -p 2121 -u test -P testWindows 上如果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不注册 UDPWSPConnect里只记录 21 端口其余 socket 直接透传只重写WSPStartup返回的表里的lpWSPSend和lpWSPRecv其他函数全部沿用下一层表。安装时WSCWriteProviderOrder的入参只写这一条链LSP 对系统的噪音就会降到最小。这也是 LSP 相比 WFP 的最后一个实用价值用最少的代码看一条 TCP 控制流里到底在传什么。本文还有配套的精品资源点击获取