ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

C++网络扫描器源码全解析:从Socket连接到多线程并发实现

C++网络扫描器源码全解析:从Socket连接到多线程并发实现 简介面向网络安全初学者与高校相关专业学生这里是一套C实现的网络扫描器课程设计源码以Visual C工程完整呈现。项目通过MFC对话框搭建操作界面涵盖套接字编程、TCP/UDP/ICMP扫描包含SYN半开扫描、UDP探测、Ping检测、端口探测、多线程并发、结果解析与显示等核心环节可将网络协议知识落实到实际编码中。压缩包大小2.17MB共28个文件包括4个.h头文件、3个.cpp源文件、1个.txt说明文档以及MFC工程配置所需的.dsp、.dsw、.rc等文件Debug目录下还附带编译生成的.exe和.pdb可直接运行查看效果。清晰的目录结构将界面代码、核心逻辑与工程配置分开便于按模块对照阅读、修改和二次开发。目前已有479人学习。通过阅读源码与实践能够系统掌握网络扫描器的工作原理、常见扫描方式的特点以及C工程组织和错误处理技巧是一份兼顾原理与实践的课程设计参考资料。1. 从课程设计到可运行扫描器C网络扫描器源码到底要写什么大学课程设计拿到“C实现网络扫描器”这个题目最常见的两个误区一是把所有端口用单线程循环扫一遍程序跑起来像计时器交差后连自己都说不清结果怎么来的二是直接抄一份现成源码连参数报错都定位不了。网络扫描器的本质是用有限的网络探测动作推断目标主机开放了哪些服务。落到课程设计里它的价值不在“扫”这个动作本身而在完整经历 Socket 编程、多线程控制、协议解析、结果序列化这整条链路——这也是 C 后端面试最常追问的底层话题。这篇文章顺着一个可编译的源码骨架把扫描器该有的分层结构、线程配比、参数设置和踩坑点讲清楚适合正在做网络安全相关课设或想补 C 网络编程短板的工程师。2. 扫描器的核心原理与 Socket 实现从 connect 返回值判断端口状态2.1 端口状态与三次握手扫描器的判断依据网络扫描器判断端口是否开放依赖的是 TCP 三次握手的状态机。客户端发送 SYN目标返回 SYNACK 说明端口监听中客户端再回 ACK 建立连接目标返回 RST 则说明端口未开放如果发包后迟迟没有响应则可能被防火墙丢弃得到 filtered 状态。课程设计里默认采用 TCP connect 扫描就是直接调用connect()尝试建立完整连接让内核协议栈替我们完成三次握手。这份源码把三种结果映射成两个布尔量connect()成功为 open收到 RST 为 closed超时无响应为 filtered。这样设计的原因很实际TCP connect 不需要管理员权限也不依赖原始套接字构造报文Windows 和 Linux 上都能跑。课程设计的环境一般不给你开通 raw socket 权限第一版代码先把 connect 路径走通后续再考虑更底层的实现。真实网络环境里系统防火墙可能会静默丢弃探测包导致延时很长所以超时控制必须是扫描器源码的第一优先级。2.2 用 select 控制超时的 TCP connect 扫描代码阻塞模式下的connect()会使用系统默认超时Linux 上通常要等一两分钟必须改成非阻塞加select()等待。下面是这份源码里的核心函数#include sys/socket.h #include netinet/in.h #include arpa/inet.h #include fcntl.h #include unistd.h #include sys/select.h bool connect_host(const char* ip, unsigned short port, int timeout_ms) { int sockfd socket(AF_INET, SOCK_STREAM, 0); if (sockfd 0) return false; // 设置非阻塞否则 connect 会阻塞在系统超时上 int flags fcntl(sockfd, F_GETFL, 0); fcntl(sockfd, F_SETFL, flags | O_NONBLOCK); sockaddr_in addr{}; addr.sin_family AF_INET; addr.sin_port htons(port); inet_pton(AF_INET, ip, addr.sin_addr); int ret connect(sockfd, (sockaddr*)addr, sizeof(addr)); if (ret 0) { // EINPROGRESS 表示连接正在建立交给 select 等待结果 if (errno EINPROGRESS) { fd_set wset; FD_ZERO(wset); FD_SET(sockfd, wset); timeval tv{ timeout_ms / 1000, timeout_ms % 1000 * 1000 }; ret select(sockfd 1, nullptr, wset, nullptr, tv); if (ret 0) { close(sockfd); return false; // 超时或异常判定 filtered/closed } } else { close(sockfd); return false; // 目标直接返回 RST端口未开放 } } // 连接建立成功读 SO_ERROR 确认真实错误码 int err 0; socklen_t len sizeof(err); getsockopt(sockfd, SOL_SOCKET, SO_ERROR, err, len); close(sockfd); return err 0; }select 返回大于 0 只代表套接字可写不代表连接一定成功必须用getsockopt()读取 SO_ERROR 才能拿到 connect 的真实结果。这是初写扫描器最容易漏的一步漏掉之后 closed 端口会被误报成 open报告里数据全错。2.2.1 超时参数怎么选超时设置直接影响扫描结果质量和总耗时。这份源码的默认配置目标网络超时时间说明本机回环 127.0.0.1200ms无网络往返超时只用于兜底局域网800ms有线局域网延迟通常低于 10ms公网地址1500-2000ms跨运营商丢包抖动大取 2 秒超时设太短会把慢速主机全判成 filtered设太长会让全端口扫描拖到几十分钟。把超时做成启动参数比写死在代码里更合理后面第 4 章会给出命令行解析方案。2.3 为什么课程设计不首选 SYN 扫描权限、校验和与防火墙干扰SYN 扫描不建立完整连接性能比 TCP connect 高很多但代价是需要自己构造 IP 头和 TCP 头调用 raw socket 发送再通过recvfrom()收取目标返回的报文。这份源码里保留了 SYN 扫描的选测模块默认不开启原因是大多数课设环境不开放 raw socket 权限Windows 上还需要管理员权限跑不起来反而耽误进度。构造 TCP 报文最麻烦的是校验和计算。TCP 校验和要把伪头部、TCP 头和数据一起参与计算伪头部包含源 IP、目标 IP、协议号和 TCP 长度unsigned short tcp_checksum(void* tcp_seg, int len, unsigned int src_ip, unsigned int dst_ip) { struct pseudo_header { unsigned int src; unsigned int dst; unsigned char zero; unsigned char proto; unsigned short tcp_len; } pseudo; pseudo.src src_ip; pseudo.dst dst_ip; pseudo.zero 0; pseudo.proto IPPROTO_TCP; pseudo.tcp_len htons(len); unsigned int sum 0; unsigned short* p (unsigned short*)pseudo; for (int i 0; i 6; i) sum *p; p (unsigned short*)tcp_seg; while (len 1) { sum *p; len - 2; } if (len) sum *(unsigned char*)p; while (sum 16) sum (sum 0xffff) (sum 16); return (unsigned short)~sum; }注意伪头部里的 IP 地址要使用网络字节序TCP 头里所有字段也必须是网络字节序混用会导致校验和永远不对。实测中 SYN 扫描还会遇到另一个问题防火墙对未完成握手的连接会直接丢弃扫描结果一片 filtered比 TCP connect 更难判断。课程设计报告里能把 TCP connect 的结果和 SYN 扫描的差异写清楚就已经超过大多数交差式作业了。特性TCP connect 扫描SYN 扫描权限要求普通用户即可需要 root/管理员实现复杂度内核代劳握手手写 IP/TCP 头与校验和对目标影响留下完整连接记录目标系统可能记录半连接结果置信度高connect 成功即开放受中间设备丢弃影响大课程设计推荐度优先实现作为进阶选测项3. 多线程并发与 C 源码工程结构线程池、任务队列、结果收集3.1 线程数不是越多越好瓶颈在网络接受队列扫描器提速的第一反应是给每个端口开一个线程但这在 Windows 和 Linux 上都不现实。TCP 连接本身要占用文件描述符目标主机的半连接队列也有上限线程数上到几千时目标系统触发了防扫描机制反而拒绝所有后续探测。扫描线程数要看目标主机性能和本机可用连接数经验值是这样算的线程池大小取端口总量和 256 的较小值再按目标网络延迟微调。公网扫描建议降到 64本地回环测试可以开到 512。这个数量控制不是拍脑袋连接建立的瓶颈大多在目标系统内核的 accept 队列上。本机ulimit -n限制的是打开文件数和线程数叠加超过系统上限后socket()直接返回 EMFILE程序却没有感知表现为莫名漏扫。3.2 一个课程设计够用的线程池实现线程池的好处是线程生命周期固定任务放进队列由空闲线程取走执行避免反复创建线程的损耗。这份源码用 C11 标准库实现了一个最小线程池#include thread #include vector #include queue #include functional #include mutex #include condition_variable class ThreadPool { public: void start(int n) { stop_ false; for (int i 0; i n; i) { workers_.emplace_back([this] { for (;;) { std::functionvoid() task; { std::unique_lockstd::mutex lk(mx_); cv_.wait(lk, [this] { return stop_ || !tasks_.empty(); }); if (stop_ tasks_.empty()) return; task std::move(tasks_.front()); tasks_.pop(); } task(); } }); } } void submit(std::functionvoid() f) { { std::lock_guardstd::mutex lk(mx_); tasks_.push(std::move(f)); } cv_.notify_one(); } void stop() { { std::lock_guardstd::mutex lk(mx_); stop_ true; } cv_.notify_all(); for (auto t : workers_) if (t.joinable()) t.join(); } private: std::vectorstd::thread workers_; std::queuestd::functionvoid() tasks_; std::mutex mx_; std::condition_variable cv_; bool stop_ false; };使用这套线程池时submit()把扫描函数作为任务塞进队列cv_.notify_one()唤醒一个空闲线程。停止时需要先置stop_ true再notify_all()否则工作线程全部阻塞在cv_.wait()上无法退出。析构顺序要保证先 stop 再销毁成员变量否则条件变量被销毁后线程还在等待。工作线程的退出条件是stop_ tasks_.empty()这保证线程池停止前把队列里剩余的任务都跑完。扫描场景下这个语义很重要——主线程提交完所有端口任务后立即调用stop()所有任务执行完毕程序才退出结果不会丢。3.3 vscode 环境下的源码工程骨架课程设计源码的组织方式决定了答辩时你能讲多清楚。一份保持简洁的分层结构如下scanner/ ├── src/ │ ├── main.cpp # 入口解析参数 │ ├── scanner.cpp # 端口扫描核心逻辑 │ ├── scanner.h │ ├── thread_pool.cpp │ ├── thread_pool.h │ └── utils.cpp # 校验和、TTL解析、JSON导出 ├── include/ │ └── scan_types.h # 扫描结果结构体定义 ├── Makefile └── README.md文件职责关键点main.cpp命令行参数解析与流程编排不写具体扫描逻辑scanner.cpp端口探测、并发调度调用 thread_pool 提交任务thread_pool.cpp线程池实现线程安全队列utils.cpp校验和、服务识别、JSON 导出纯函数方便单独测试scan_types.hScanResult 等结构定义头文件只放类型声明vscode 配置 C/C 环境时在.vscode/c_cpp_properties.json里把includePath指到项目根目录这样跳转和自动补全才生效。Windows 上建议用 MinGW-w64 工具链Linux 直接用 g源码尽量避免引入 Windows 专属头文件保持可移植性。Makefile 里需要链接 pthread 库漏掉会在运行时报错CXX g CXXFLAGS -stdc17 -Wall -Wextra -pthread OBJS src/main.o src/scanner.o src/thread_pool.o src/utils.o scanner: $(OBJS) $(CXX) $(CXXFLAGS) -o $ $(OBJS)-pthread必须同时出现在编译和链接阶段只写在链接时某些平台上编译线程代码也会报错。3.4 结果收集与去重锁与容器的配合多线程扫描时多个工作线程同时写结果容器必须加锁。这份源码用一个全局std::mutex保护std::vectorScanResultstd::mutex result_mutex; std::vectorScanResult results; void on_port_found(const ScanResult r) { std::lock_guardstd::mutex lk(result_mutex); results.push_back(r); }还有一个隐藏问题当端口范围重叠或目标地址重复时同一个端口可能被探测两次结果是重复项。简单去重可以在收集阶段用std::unordered_setuint64_t记录已存在的 ipport 组合键值用 ip 的整数表示左移 16 位与端口取或空间开销小查询 O(1)。锁的粒度要控制到最小只锁 push 操作不要在锁内做网络请求或耗时的服务识别。4. 从“端口通”到“服务是什么”源码里的协议识别与结果导出4.1 基于 getservbyport 与指纹表识别服务扫描出开放端口只是第一步课程设计报告要有点数就需要识别端口背后的服务。最轻量的做法是调用getservbyport()查系统服务表但这份源码里更推荐自带一份常用端口映射表因为getservbyport()返回的是/etc/services里的静态映射只能给出端口对应的协议名不能确认该端口上实际跑的服务。const char* guess_service(unsigned short port) { static const std::unordered_mapunsigned short, std::string table { {21, ftp}, {22, ssh}, {23, telnet}, {25, smtp}, {80, http}, {110, pop3}, {143, imap}, {443, https}, {3306, mysql}, {5432, postgresql}, {6379, redis}, {27017, mongodb}, {8080, http-alt} }; auto it table.find(port); if (it ! table.end()) return it-second.c_str(); struct servent* se getservbyport(htons(port), nullptr); return se ? se-s_name : unknown; }表里缺自定义端口时回退到系统服务表这样既保证常用端口识别准确又不丢失长尾。写报告时可以明确说明端口映射只是静态推断真实服务要以 banner 抓取和协议交互为准。这里要强调的是getservbyport的端口参数必须经过htons()转换直接传整型端口号在某些平台上查不到结果。进一步识别可以尝试连接后读取 banner。比如连上 22 端口读几行数据SSH 服务会返回包含版本号的字符串HTTP 会返回类似HTTP/1.1 400 Bad Request的响应头。课程设计的实现可以在 connect 成功后保留套接字用非阻塞 read 读 256 字节做正则匹配这种方式比静态表更接近真实扫描器的工作方式。4.2 通过 TTL 字段推断目标操作系统拿到扫描结果后很多同学会卡在“如何判断目标是什么系统”上。最朴素的办法是从 IP 头里的 TTL 字段入手。IPv4 报文每经过一个路由器 TTL 减 1常见操作系统的初始 TTL 是固定值Windows 初始 128Linux 初始 64。根据收到的 TTL 反推初始值就能得到一个大致的 OS 范围。这份源码在 utils.cpp 里实现了一个简化判断函数int guess_initial_ttl(int received_ttl) { // 常见初始 TTLWindows 128Linux/Android 64部分网络设备 255 // 这里不做跳数精确计算只反推最接近的整档值 if (received_ttl 96) return 128; if (received_ttl 32) return 64; if (received_ttl 16) return 32; return 64; }这个判断依赖前提扫描源与目标主机之间的路由器跳数小于 32。如果目标在网络深处TTL 被减得厉害Windows 主机收到 110 和 Linux 收到 50 无法区分。更准确的 OS 指纹要对比 TCP 窗口大小、MSS 等细节课程设计里能给出 TTL 反推值并标注置信度即可。4.3 结果序列化控制台打印之外要有 JSON 导出扫描器源码在 main.cpp 里默认把结果打印到控制台但一份合格的课程设计最好支持导出结构化文件。用 JSON 格式最直观答辩时打开文件就能看到完整结果也能被后续分析脚本直接消费。手写一个最小 JSON 导出函数避免课程设计引入第三方 json 库增加部署难度void export_json(const std::vectorScanResult results, const char* path) { std::ofstream ofs(path); ofs {\n \scan\: {\n \results\: [\n; for (size_t i 0; i results.size(); i) { const auto r results[i]; ofs {\ip\: \ r.ip \, \port\: r.port , \service\: \ r.service \, \os_hint\: r.os_hint }; // 最后一项后面不能加逗号否则 JSON 解析报错 ofs (i 1 results.size() ? , : ) \n; } ofs ]\n }\n}\n; }尾逗号是手写 JSON 最常见的坑。上面的写法用三目运算符判断最后一项比在循环里特判更简洁。导出后可以用python3 -c import json; json.load(open(result.json))或 Node.js 的JSON.parse验证文件是否合法。4.4 命令行参数解析端口范围、线程数与超时配置为了让不同测试场景复用同一份源码入口要支持参数化。这里不用 getopt直接手写一个简单的解析循环Windows 和 Linux 下行为一致int main(int argc, char* argv[]) { std::string host; int start_port 1; int end_port 1024; int threads 128; int timeout_ms 800; for (int i 1; i argc; i) { std::string arg argv[i]; if (arg -h i 1 argc) host argv[i]; else if (arg -p i 1 argc) parse_port_range(argv[i], start_port, end_port); else if (arg -t i 1 argc) threads std::stoi(argv[i]); else if (arg -w i 1 argc) timeout_ms std::stoi(argv[i]); else if (arg -o i 1 argc) output_path argv[i]; else { std::cerr Usage: scanner -h host [-p 1-1024] [-t threads] [-w timeout] [-o file]\n; return 1; } } // 校验必填参数 if (host.empty()) { std::cerr Missing host\n; return 1; } if (start_port 1 || end_port 65535 || start_port end_port) { std::cerr Bad port range\n; return 1; } run_scan(host, start_port, end_port, threads, timeout_ms, output_path); return 0; }参数校验放在业务逻辑之前std::stoi要放在 try-catch 里防止非数字输入导致抛异常崩溃。端口范围用start_port-end_port这种写法解析时用std::strtok或std::string::find切成两个数即可注意边界是闭区间还是开区间统一语义后在 README 里写清楚。5. 验证你的扫描器回环自测、性能基准与常见报错定位5.1 一份可以重复的自测矩阵拿到能编译通过的源码后先别急着扫公网地址。用自己的机器做回环测试结果可预期、不踩法律与安全红线。在本地开一个临时 HTTP 服务python3 -m http.server 8000 --bind 127.0.0.1然后在另一个终端运行扫描器./scanner -h 127.0.0.1 -p 1-1000 -t 64 -w 200 -o result.json测试场景预期结果用途扫描 127.0.0.1 端口 1-10008000 端口 open其余 closed验证基本流程对不存在的地址如 192.0.2.1 扫描全部 filtered 或超时验证超时路径用-t 1单个线程扫描结果完整但耗时拉长观察并发收益连续执行两次扫描结果完全一致验证结果去重与文件覆盖5.2 性能数字怎么测扫描器的性能指标有两个总耗时和单位时间探测端口数。用 time 命令统计time ./scanner -h 127.0.0.1 -p 1-65535 -t 256 -w 200 -o full.json注意本机回环场景下网络不是瓶颈性能数据只能用来对比线程数差异。要测真实并发效果在局域网内找一台你用自己账号登录过的测试机得到的是同网段延迟下的数据。系统防火墙会过滤部分探测包导致结果中出现 filtered这是正常现象报告中如实记录即可。5.3 常见报错清单报错信息原因处理方式connect: Operation now in progress非阻塞 connect 的正常现象不是错误走 select 等待即可select: Interrupted system call信号打断 select外层循环重试Too many open files文件描述符耗尽线程数过高调小-t或提高 ulimitPermission deniedSYN 模式raw socket 需要 root 权限回退 TCP connect 模式扫描结果全为 filtered本机防火墙丢弃探测包检查防火墙策略回环测试不设置拦截规则connect: Operation now in progress是新手最容易被吓到的报错它表示连接正在进行接下来的正确操作是进入 select 等待而不是直接关闭套接字。select: Interrupted system call常见于调试器下运行处理方式是把 select 包进循环返回 -1 且 errno 为 EINTR 时继续等待。本文还有配套的精品资源点击获取
返回列表