☰
C++高性能网络扫描器设计与实现:epoll并发与TCP全连接扫描
2026/10/26 7:54:50 网站建设 项目流程

简介:这是一份面向高校计算机与信息安全专业学生的C++课程设计完整项目,主题为网络扫描器的设计与实现,适合正在学习MFC框架、网络编程或准备课程答辩的读者参考。项目基于Windows XP及以上系统,采用Microsoft Visual C++ 6.0与Notepad++开发,语言为C/C++与MFC,覆盖主机扫描、端口扫描、NetBIOS扫描、SNMP扫描、弱密码扫描、嗅探器、DOS攻击、注入检测及报告生成等九大功能模块,并配有对应测试截图与说明文档。资源包共96个文件,约5.1MB,包含14个cpp源文件、18个头文件、15张png与14张jpg界面截图、3个html报告页面及vcproj、sln等工程配置,另附论文PDF与LICENSE,结构完整便于直接编译运行。目前已有296人学习下载,读者可借此获得一套可运行的课程设计源码、模块化测试思路与界面实现参考,快速理解网络扫描工具的整体架构与关键代码逻辑。

1. 从一台内网机器说起:C++ 网络扫描器到底在扫什么

手里只有一台跳板机,想摸清同网段里到底有哪些主机活着、开了哪些端口、跑着什么服务,这种场景几乎每个做运维、安全评估或者后端排障的人都遇到过。用现成工具当然快,但当你需要把扫描逻辑嵌进自己的巡检系统、需要自定义探测报文、需要控制并发粒度时,一个能自己编译、自己改的 C++ 网络扫描器就成了刚需。这个标题讲的就是用 C++ 从零实现一个网络扫描器:底层用 socket 做 TCP 连接探测,用非阻塞 IO 加多路复用把并发拉起来,上层做端口状态判定、超时控制和结果输出。它适合有 C++ 基础、想理解网络编程真实落地细节的开发者,也适合需要把扫描能力私有化集成的团队。读完你能拿到一套可编译、可调参、可扩展的最小实现,而不是只会调别人封装好的命令行。

2. 扫描器的技术选型:为什么用 C++ 而不是脚本语言

2.1 扫描任务的性能瓶颈到底在哪

很多人第一反应是用 Python 写,几十行就能跑。但网络扫描的本质是大量短连接或半连接的状态管理,瓶颈不在业务逻辑,而在 IO 等待和文件描述符的调度效率。一个 C 段 254 个地址、每个地址扫 1000 个端口,就是 25 万次探测。如果用阻塞式 connect 串行跑,每次超时按 1 秒算,最坏情况要跑几十小时。脚本语言靠多线程或协程能缓解,但线程栈开销和 GIL 限制在高并发下会明显拖后腿。

C++ 的优势在这里很直接:可以用 epoll(Linux)或 IOCP(Windows)做事件驱动,单线程就能管理上万个并发连接;可以用原始套接字自己构造 SYN 包做半连接扫描,不依赖系统 connect 的三次握手完成;内存布局可控,连接状态用数组或哈希表存,缓存友好。这些是脚本语言很难同时做到的。所以选 C++ 不是因为它“高级”,而是因为扫描这个任务天然吃 IO 密集加高并发,C++ 给的底层控制力刚好对得上。

2.2 三种扫描模式的取舍:全连接、半连接、UDP

落地前先想清楚扫什么。常见做法分三类:

模式原理优点代价
TCP 全连接调用 connect 完成三次握手实现简单,不需要 root慢,日志留痕明显
TCP 半连接自己发 SYN,看回 SYN-ACK 还是 RST快,隐蔽性好需要原始套接字和 root
UDP 扫描发 UDP 包看是否回 ICMP 端口不可达能发现 UDP 服务判定不可靠,易误报

我一般会先做全连接扫描,因为它跨平台、不需要特权、调试直观。等逻辑跑通、并发框架稳定了,再把半连接作为可选模块加进去。标题里说的“设计与实现”,核心就是把全连接这条路径做扎实,半连接作为进阶。下面所有代码都以 Linux 下的全连接加 epoll 为主线,Windows 下把 epoll 换成 select 或 IOCP 即可,逻辑一致。

2.3 最小可运行骨架:socket 加 epoll 的并发模型

先给一个能编译、能扫单个端口的骨架,把主循环跑通再谈优化。

// scanner_base.cpp - 最小并发扫描骨架 #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <fcntl.h> #include <unistd.h> #include <sys/epoll.h> #include <cstring> #include <vector> #include <iostream> // 把 fd 设为非阻塞,这是 epoll 并发的前提 int set_nonblocking(int fd) { int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) return -1; return fcntl(fd, F_SETFL, flags | O_NONBLOCK); } int main() { const char* target_ip = "192.168.1.1"; int port = 80; int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { perror("socket"); return 1; } set_nonblocking(sock); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); inet_pton(AF_INET, target_ip, &addr.sin_addr); // 非阻塞 connect 会立刻返回 EINPROGRESS,不代表失败 int ret = connect(sock, (sockaddr*)&addr, sizeof(addr)); if (ret < 0 && errno != EINPROGRESS) { perror("connect"); close(sock); return 1; } int epfd = epoll_create1(0); epoll_event ev{}; ev.events = EPOLLOUT; // 关注可写事件,可写即连接有结果 ev.data.fd = sock; epoll_ctl(epfd, EPOLL_CTL_ADD, sock, &ev); epoll_event events[1]; int n = epoll_wait(epfd, events, 1, 2000); // 2 秒超时 if (n == 0) { std::cout << port << " filtered (timeout)\n"; } else { int err = 0; socklen_t len = sizeof(err); getsockopt(sock, SOL_SOCKET, SO_ERROR, &err, &len); if (err == 0) std::cout << port << " open\n"; else std::cout << port << " closed\n"; } close(sock); close(epfd); return 0; }

这段代码的关键点有三个。第一,set_nonblocking是必须的,阻塞 socket 放进 epoll 会让整个事件循环卡死。第二,非阻塞 connect 返回EINPROGRESS是正常流程,不是错误,真正的结果要靠SO_ERROR去取。第三,epoll_wait的超时参数就是扫描的超时时间,设 2000 毫秒意味着超过 2 秒没响应就判定为 filtered。编译命令:

g++ -O2 -std=c++17 scanner_base.cpp -o scanner_base

参数上,超时值是最需要调的。内网可以压到 200 到 500 毫秒,公网建议 1000 到 3000 毫秒。设太短会把慢主机误判成关闭,设太长整体扫描时间线性膨胀。这个骨架一次只扫一个端口,下一章把它扩成批量并发。

3. 把单端口扩成批量扫描:连接池、超时与状态机

3.1 用状态数组管理上万个并发连接

单端口跑通后,核心问题变成:怎么同时管理几万个 socket 而不乱。常见做法是给每个探测任务分配一个状态结构,用 fd 做索引,epoll 事件回来时通过ev.data.fd反查任务。

// 扫描任务状态机 enum class ScanState { CONNECTING, DONE_OPEN, DONE_CLOSED, DONE_TIMEOUT }; struct ScanTask { int fd; std::string ip; int port; ScanState state; std::chrono::steady_clock::time_point start; }; // fd -> 任务 的映射,用 vector 按 fd 直接索引,比 map 快 std::vector<ScanTask*> fd_to_task;

用vector按 fd 索引而不是unordered_map,是因为 fd 是连续分配的小整数,直接寻址是 O(1) 且缓存友好。每次创建 socket 后fd_to_task.resize(fd + 1)再挂上去。这个细节在几万并发时能省下可观的 CPU。

3.2 批量发起与事件回收的完整循环

把发起、等待、回收三段拼起来,就是扫描器的主循环。

// 批量扫描主循环(节选) const int MAX_CONCURRENT = 5000; // 同时挂起的连接上限 std::vector<std::string> targets = {"192.168.1.1", "192.168.1.2"}; std::vector<int> ports = {22, 80, 443, 3306, 8080}; int epfd = epoll_create1(0); int inflight = 0; size_t ti = 0, pi = 0; while (ti < targets.size()) { // 填满并发窗口 while (inflight < MAX_CONCURRENT && ti < targets.size()) { int fd = socket(AF_INET, SOCK_STREAM, 0); set_nonblocking(fd); sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(ports[pi]); inet_pton(AF_INET, targets[ti].c_str(), &addr.sin_addr); int r = connect(fd, (sockaddr*)&addr, sizeof(addr)); if (r < 0 && errno != EINPROGRESS) { close(fd); } else { auto* task = new ScanTask{fd, targets[ti], ports[pi], ScanState::CONNECTING, std::chrono::steady_clock::now()}; if ((size_t)fd >= fd_to_task.size()) fd_to_task.resize(fd + 1, nullptr); fd_to_task[fd] = task; epoll_event ev{}; ev.events = EPOLLOUT; ev.data.fd = fd; epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); inflight++; } // 端口和目标的推进逻辑 if (++pi >= ports.size()) { pi = 0; ti++; } } epoll_event events[1024]; int n = epoll_wait(epfd, events, 1024, 500); for (int i = 0; i < n; i++) { int fd = events[i].data.fd; ScanTask* t = fd_to_task[fd]; int err = 0; socklen_t len = sizeof(err); getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len); t->state = (err == 0) ? ScanState::DONE_OPEN : ScanState::DONE_CLOSED; if (t->state == ScanState::DONE_OPEN) std::cout << t->ip << ":" << t->port << " open\n"; epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr); close(fd); fd_to_task[fd] = nullptr; delete t; inflight--; } // 超时清理:遍历 fd_to_task,超过阈值的判 filtered }

逻辑说明:外层 while 负责把并发窗口填满,内层 epoll_wait 负责回收已完成或超时的连接。MAX_CONCURRENT是并发窗口,设太大(比如 10 万)会撞上系统的文件描述符上限和本地端口耗尽,设太小则并发上不去。参数上,Linux 默认单进程 fd 上限是 1024,跑之前先ulimit -n 65535。本地端口范围用sysctl net.ipv4.ip_local_port_range看,默认大概 2.8 万个,所以单机并发上限受这个约束,超过就要考虑多进程或半连接扫描。

3.3 超时判定为什么不能只靠 epoll_wait

上面代码里 epoll_wait 的超时是 500 毫秒,但这只是事件循环的轮询间隔,不等于每个连接的超时。真正的超时判定要单独做:每个任务记录start时间,在每轮循环末尾遍历fd_to_task,把now - start > timeout的任务标记为 filtered 并关闭。这里有个血泪经验:不要用epoll_wait的返回值来判断超时,它只告诉你这一轮有没有事件,没事件不代表所有连接都超时了。必须自己维护时间戳。另外,遍历整个fd_to_task是 O(n),几万并发时每轮都全扫会拖慢循环,常见优化是用一个按时间排序的队列或者时间轮,但那是进阶话题,先把基础版跑稳。

4. 避坑与排查:扫描器最容易翻车的五个地方

4.1 现象:扫内网很快,扫公网大量误报 closed

原因:公网链路 RTT 波动大,固定 500 毫秒超时对跨运营商目标太短,SYN-ACK 还没回来就被判超时,代码里又把它归到 closed。解决:把超时做成可配置,公网默认 2000 毫秒起步,并且把超时单独归为 filtered 而不是 closed,语义上区分开。判定逻辑里err == ETIMEDOUT和err == ECONNREFUSED要分开处理。

4.2 现象:跑到几千并发后程序卡死或报 Too many open files

原因:fd 没及时关闭,或者ulimit -n没调。epoll 回收路径里如果getsockopt之后忘了close(fd),fd 会泄漏,几分钟就耗尽。解决:每个任务结束必须epoll_ctl DEL加close,并且启动前ulimit -n 65535。可以在代码里加一个 fd 计数器,每轮打印当前 inflight,超过阈值就告警。

4.3 现象:本地端口耗尽,connect 返回 EADDRNOTAVAIL

原因:全连接扫描每个探测都占用一个本地临时端口,TIME_WAIT 状态下端口要等 60 秒才能复用。扫几万个端口时本地端口池被打满。解决:调大net.ipv4.ip_local_port_range,开启net.ipv4.tcp_tw_reuse,或者改用半连接扫描绕开这个问题。最直接的办法是控制并发窗口,别一次性把端口池抽干。

4.4 现象:Windows 下编译报 winsock 相关错误

原因:Windows 用 Winsock2,需要WSAStartup初始化,头文件顺序和 Linux 不同,close要换成closesocket。解决:用宏隔离平台差异,Linux 走 epoll,Windows 走 select 或 IOCP。如果只是本地调试,装好对应的 Visual C++ 运行库能解决一部分运行时缺失报错,但 socket API 的差异必须靠代码适配。

4.5 现象:扫描结果里同一端口时而 open 时而 closed

原因:目标主机有防护设备做限速或随机丢包,或者本机并发太高导致 SYN 队列溢出。解决:降低并发、增加重试次数,对判定为 filtered 的端口做二次确认。别迷信单次扫描结果,生产环境里我一般会对关键端口扫两到三轮取交集。

5. 进阶技巧:把扫描器做成能长期用的工具

5.1 用结果去重和分级输出替代裸打印

基础版直接cout在几万结果面前没法用。我一般会把结果按ip:port:state存进一个unordered_set去重,最后按状态分级输出:open 单独一份,filtered 单独一份,closed 默认不输出。这样一份扫描报告能直接喂给后续的服务识别模块。输出格式用 CSV 或 JSON,方便脚本消费。

5.2 加一个服务指纹探测的钩子

端口开着不代表知道是什么服务。在判定 open 之后,可以发一个探测包读 banner。比如对 80 端口发一个 HTTP HEAD 请求,读回响应头里的 Server 字段。这个钩子放在状态机里就是DONE_OPEN之后追加一个PROBING状态,复用同一个 epoll 循环,把EPOLLOUT换成EPOLLIN再等一次。代码结构不用大改,只是多一个状态转移。

5.3 验证扫描器准不准的三个方法

写完不能只看它跑得动,得验证准确性。第一,拿本机ss -tlnp的真实监听端口做对照,扫 127.0.0.1 看结果是否一致。第二,对一个已知关闭的端口扫,确认判 closed 而不是 filtered。第三,用tcpdump抓包,看发出的 SYN 和收到的 SYN-ACK/RST 是否和判定结果对得上。这三步做完,基本能排除大部分逻辑错误。

5.4 我踩过的最大一个坑

早期版本我用epoll_wait返回的事件数来判断进度,结果发现扫描进度条长时间不动,以为卡死了,其实是那一轮刚好没有事件返回。后来改成用 inflight 计数加已完成计数来算进度,才准确。这个教训让我明白:事件驱动模型里,没有事件不等于没有进展,状态得自己维护。做网络扫描器,耐心和抓包工具比聪明更重要。希望帮到你。

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

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

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

立即咨询