尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

C++网络扫描器源码拆解:从Winsock到多线程TCP端口扫描

C++网络扫描器源码拆解:从Winsock到多线程TCP端口扫描 简介这是一份基于C实现的网络扫描器课程设计源码面向网络安全方向的学生和C编程初学者可作为课程设计、毕业设计或安全技术入门参考。资源压缩包为zip格式共包含28个文件其中4个.h和3个.cpp为程序主体源码配合.dsp/.dsw工程文件可直接在Visual C 6.0中打开编译Debug目录下附有.exe可执行文件及.pdb/.obj等调试信息另有.rc/.ico等界面资源文件整体大小约2.17MB。已有479人学习浏览。源码项目围绕网络扫描核心功能展开涵盖ICMP探测、TCP SYN半开扫描、UDP探测等常见扫描方式并通过套接字编程实现数据收发结合多线程提升扫描效率最终以列表形式展示目标主机状态和开放端口。代码结构清晰按对话框程序组织注释相对完整便于二次开发。通过阅读和运行该源码可以深入理解网络协议底层交互、端口扫描原理同时掌握C在系统网络编程中的实际应用对提升安全测试和编程能力都有直接帮助。1. 这份C网络扫描器源码值得花一个下午拆开看拿到 NetScanner-master.zip 时第一眼看到的是 VC6.0 时代的 .dsw/.dsp 工程文件加上 NetScannerDlg.cpp 这种典型的 MFC 对话框命名基本可以断定这是 2000 年代中期的课程设计作品。但别因为工程老就低估它——网络扫描器的核心骨架二十年来没有本质变化套接字初始化、ICMP 探测、TCP 端口遍历、多线程并发这些恰恰是今天做主机发现、内网资产盘点、服务探测时依然要面对的基础问题。对于正在学 C 网络编程、或者准备网络安全课程设计的人来说这份源码的价值在于它把 Winsock 编程、MFC 界面线程交互、扫描策略选择这几件事串在了一个完整项目里。本篇就按「协议原理 → 核心实现 → 并发改造 → 验证排错」的顺序把这套代码拆开讲透。2. Winsock 初始化与 ICMP 探测扫描器的地基2.1 为什么扫描器从 Winsock 初始化开始任何基于 Windows 的 C 网络程序第一步必然是加载 Winsock 库。VC6.0 工程里默认链接 wsock32.lib对应的是 Winsock 1.1但 ICMP 原始套接字和某些高级选项需要 Winsock2所以工程里一般通过#include winsock2.h并链接 ws2_32.lib 来升级。如果编译时报一堆WSASocket未定义十有八九是头文件顺序问题——winsock2.h 和 windows.h 冲突时必须在 windows.h 之前包含 winsock2.h或者定义WIN32_LEAN_AND_MEAN。2.2 初始化代码的标准写法NetScanner 的初始化逻辑通常放在OnInitDialog或首次扫描前的准备函数里。下面是兼容 VC6.0 的初始化模板#include winsock2.h #include ws2tcpip.h #pragma comment(lib, ws2_32.lib) void InitWinsock() { WSADATA wsaData; int nRet WSAStartup(MAKEWORD(2, 2), wsaData); if (nRet ! 0) { // 加载失败检查是否已初始化或系统版本过旧 AfxMessageBox(_T(WSAStartup failed)); return; } // 校验返回的版本号确保支持 Winsock 2.2 if (LOBYTE(wsaData.wVersion) ! 2 || HIBYTE(wsaData.wVersion) ! 2) { WSACleanup(); AfxMessageBox(_T(Winsock 2.2 not available)); } }这段代码有两个关键参数MAKEWORD(2, 2)表示请求 Winsock 2.2 版本wsaData用于接收系统实际支持的协议栈信息。只检查WSAStartup返回值还不够因为系统可能返回一个较低的版本——所以要用LOBYTE/HIBYTE宏拆出主次版本号逐一核对。工程里如果只需要 connect 和 sendWinsock 1.1 够用但要实现 SYN 半开扫描或原始套接字发包就必须确认 2.2 已加载。2.3 ICMP Ping 探测的实现细节ICMP 扫描用于判断目标主机是否存活。Windows 下有两种实现路径一是用IcmpSendEcho这个系统 API二是自己构造 ICMP 报文通过 SOCK_RAW 发送。NetScanner 这种课程设计多数采用第二种因为能展示 IP 协议头结构。构造 ICMP Echo 请求的核心代码如下typedef struct icmp_hdr { BYTE type; // 类型8 表示 Echo Request BYTE code; // 代码恒为 0 USHORT checksum; // 校验和 USHORT id; // 标识符用于匹配应答 USHORT seq; // 序列号 } ICMP_HDR; USHORT CalculateChecksum(USHORT* pBuf, int nLen) { DWORD dwSum 0; while (nLen 1) { dwSum *pBuf; nLen - sizeof(USHORT); } if (nLen) dwSum *(BYTE*)pBuf; while (dwSum 16) dwSum (dwSum 0xFFFF) (dwSum 16); return (USHORT)(~dwSum); }校验和计算是 ICMP 报文最容易出错的地方。算法是把报文按 16 位一组累加溢出部分回卷加到低 16 位最后取反。注意id和seq在发送端填充后接收端会原样返回所以可以用这两个字段来匹配请求和应答——多线程并发 Ping 时这个匹配机制尤其重要。2.4 发送与接收的超时控制发送 ICMP 包用sendto接收用recvfrom。这里最容易踩的坑是recvfrom默认阻塞目标主机不可达时线程会卡死在接收调用上。需要通过setsockopt设置接收超时SOCKET sockRaw socket(AF_INET, SOCK_RAW, IPPROTO_ICMP); int nTimeOut 1000; // 1秒 setsockopt(sockRaw, SOL_SOCKET, SO_RCVTIMEO, (char*)nTimeOut, sizeof(nTimeOut)); // 发送 ICMP Echo 请求 sendto(sockRaw, (char*)icmpPkt, sizeof(icmpPkt), 0, (SOCKADDR*)destAddr, sizeof(destAddr)); // 接收响应超时返回 SOCKET_ERRORWSAGetLastError() WSAETIMEDOUT int nRecvLen recvfrom(sockRaw, recvBuf, sizeof(recvBuf), 0, (SOCKADDR*)srcAddr, nSrcLen);SO_RCVTIMEO的单位是毫秒VC6.0 下结构体是int而非DWORD有些移植代码在这里会编译报错。设置 1000 毫秒意味着目标无响应时recvfrom最多阻塞 1 秒配合多线程可以做到 255 个地址段大约十几秒扫完。原始套接字在 Windows 2000 之后的系统上需要管理员权限这也是课程设计答辩时老师常问的点——如果程序跑在普通权限下socket(AF_INET, SOCK_RAW, IPPROTO_ICMP)会返回 INVALID_SOCKET错误码 WSAEACCES。3. TCP 端口扫描从 connect 到半开扫描3.1 三种扫描模式的选型理由NetScanner 里的 TCP 扫描部分通常提供至少三种模式Connect 扫描、SYN 半开扫描、以及针对常见端口的快速列表扫描。Connect 扫描调用connect()完成 TCP 三次握手成功即端口开放SYN 扫描只发送 SYN 包收到 SYN/ACK 即判定开放不完成握手隐蔽性更好。课程设计阶段Connect 扫描是首选——实现简单、不需要原始套接字权限、和 MFC 的消息循环容易配合。SYN 扫描则适合作为进阶展示放在设计文档的「改进方向」一节。3.2 Connect 扫描的代码骨架BOOL TryConnect(CString strIP, int nPort, int nTimeout) { SOCKET sock socket(AF_INET, SOCK_STREAM, 0); if (sock INVALID_SOCKET) return FALSE; // 设置为非阻塞模式配合 select 实现超时 u_long nMode 1; ioctlsocket(sock, FIONBIO, nMode); SOCKADDR_IN addr; addr.sin_family AF_INET; addr.sin_port htons(nPort); addr.sin_addr.s_addr inet_addr(strIP); // 非阻塞 connect 立即返回用 select 等待结果 int nRet connect(sock, (SOCKADDR*)addr, sizeof(addr)); if (nRet SOCKET_ERROR WSAGetLastError() ! WSAEWOULDBLOCK) { closesocket(sock); return FALSE; } fd_set fdWrite; FD_ZERO(fdWrite); FD_SET(sock, fdWrite); timeval tv { 0, nTimeout * 1000 }; // 秒和微秒 nRet select(0, NULL, fdWrite, NULL, tv); if (nRet 0) // 超时或出错 { closesocket(sock); return FALSE; } closesocket(sock); return TRUE; // 可写表示连接成功 }这段代码把阻塞 connect 改造成了带超时的非阻塞连接是端口扫描里最关键的技巧。默认connect在目标 IP 不可达时会阻塞 20 秒以上而扫描器需要快速遍历上百个端口。通过ioctlsocket把 socket 切到非阻塞模式connect立即返回WSAEWOULDBLOCK随后用select最多等待nTimeout毫秒。select的第二个参数是读集合这里传 NULL第三个是写集合——连接建立后 socket 变为可写态所以只需监视写事件。注意timeval的tv_sec和tv_usec是两段式{0, 1000000}表示 1 秒别把微秒位写错成 1000。3.3 端口扫描结果的判定与过滤Connect 扫描收到ECONNREFUSED说明端口关闭超时说明可能被防火墙过滤连接成功则端口开放。有些网络环境下防火墙会对未开放端口直接丢弃 SYN 包而不是回复 RST这时超时和拒绝需要分开统计状态判定方式含义开放connect 成功或 select 可写目标端口有服务监听关闭收到 ECONNREFUSEDWSAECONNREFUSED端口未监听系统主动拒绝被过滤超时无响应WSAETIMEDOUT防火墙规则拦截或主机不可达区分这两个状态很重要扫描 192.168.1.1 的 1-1024 端口时如果大量端口都超时更可能的原因是对端主机下线或中间防火墙丢弃了流量而不是这些端口恰好都关闭。NetScanner 结果显示列表里应该把超时单独标成「filtered」而不是混在关闭里否则报告会误导审计结论。3.4 MFC 界面扫描结果的刷新VC6.0 工程里扫描结果显示在CListCtrl中但工作线程不能直接操作 UI 控件——MFC 的窗口消息只能在创建它的线程中处理。NetScanner 的常见做法是用PostMessage向主窗口发送自定义消息#define WM_SCAN_RESULT (WM_USER 100) // 工作线程中投递扫描结果 void PostScanResult(HWND hWnd, CString strIP, int nPort, CString strStatus) { // 简单封装把信息放到全局结构体或堆对象中 SCAN_ITEM* pItem new SCAN_ITEM; pItem-strIP strIP; pItem-nPort nPort; pItem-strStatus strStatus; ::PostMessage(hWnd, WM_SCAN_RESULT, (WPARAM)pItem, 0); } // 主窗口消息映射中处理 LRESULT CNetScannerDlg::OnScanResult(WPARAM wParam, LPARAM lParam) { SCAN_ITEM* pItem (SCAN_ITEM*)wParam; int nIndex m_listResult.InsertItem(0, pItem-strIP); m_listResult.SetItemText(nIndex, 1, pItem-strStatus); // ... 设置更多子项 delete pItem; return 0; }这段代码里有两个要点传给PostMessage的指针必须new出来不能在栈上创建因为消息处理是异步的主窗口处理时扫描线程早已离开当前函数栈消息处理完后务必delete否则每次扫描都泄漏一个结构体扫 65535 个端口就漏 65535 次物理内存很快被吃光。WM_USER 100是自定义消息的起点避免和系统消息冲突。4. 多线程并发扫描效率与稳定性的平衡4.1 线程粒度选择端口级还是主机级NetScanner 要扫描的不只是单个 IP而是一个网段内所有主机、每台主机多个端口。这就涉及线程粒度问题。常见方案有两种一是每台主机开一个线程内部串行扫端口二是把IP, 端口对均匀分给固定数量的工作线程。前者代码直观但主机数多时线程泛滥——扫描 /24 网段的 20 个端口直接创建 254 个线程系统调度开销巨大。后者用线程池配合任务队列是更工程化的做法。4.2 用 _beginthreadex 而非 CreateThreadVC6.0 时代 MFC 工程推荐使用_beginthreadex而不是CreateThread原因是后者不会初始化 CRT 运行时环境使用malloc、printf等 CRT 函数时可能造成内存泄漏或崩溃。代码如下#include process.h typedef struct { CString strStartIP; CString strEndIP; int nStartPort; int nEndPort; int nThreadIndex; } SCAN_PARAM; UINT __stdcall ScanThreadFunc(LPVOID lpParam) { SCAN_PARAM* pParam (SCAN_PARAM*)lpParam; // 按线程索引拆分扫描范围 for (unsigned long ip inet_addr(pParam-strStartIP); ip inet_addr(pParam-strEndIP); ip) { // inet_addr 返回的网络字节序递增其实不直观 // 更稳妥的做法是换算成点分十进制后逐段加一 ScanOneHost(pParam-strIP, pParam-nStartPort, pParam-nEndPort); } delete pParam; return 0; } // 启动线程的调用方 void StartScan() { HANDLE hThreads[32]; int nThreadCount 32; // 预先划分每个线程负责的 IP 段或端口段 for (int i 0; i nThreadCount; i) { SCAN_PARAM* pParam new SCAN_PARAM; // 初始化参数... hThreads[i] (HANDLE)_beginthreadex(NULL, 0, ScanThreadFunc, pParam, 0, NULL); } WaitForMultipleObjects(nThreadCount, hThreads, TRUE, INFINITE); }_beginthreadex的第二个参数是栈大小传 0 表示使用默认 1MB第三个参数是线程函数地址第六个参数返回线程 ID。注意线程函数声明必须是UINT __stdcall参数是LPVOID这和CreateThread的签名一致。如果编译报错提示找不到_beginthreadex需要在工程设置里把运行时库从单线程/ML改为多线程/MT 或 /MD。4.3 等待与同步避免主窗口卡死上面的代码用WaitForMultipleObjects等待所有线程结束这在控制台程序里没问题但在 MFC 界面程序里会把主窗口的 UI 消息循环阻塞住——用户点了「扫描」按钮后窗口就无响应了。正确的做法是把等待逻辑放到独立的管理线程中或者用MsgWaitForMultipleObjects在等待的同时仍能分发窗口消息。NetScanner 这种课程设计用最简单的方式即可扫描结果通过PostMessage回传扫描线程结束时也发一条WM_SCAN_FINISHED主窗口收到后恢复按钮可用状态不显式等待任何线程。4.4 共享资源的临界区保护扫描结果如果直接向全局容器如CArray或std::vector写入多线程同时写必然造成数据竞争。要么像前文那样用PostMessage把数据封装后发给主窗口由主线程独占操作列表要么用CCriticalSection把共享数据访问包起来CCriticalSection g_csResult; void AddResultToGlobalList(SCAN_ITEM* pItem) { g_csResult.Lock(); g_scanResultList.Add(pItem); g_csResult.Unlock(); }CCriticalSection是 MFC 对 CRITICAL_SECTION 的封装Lock/Unlock对应EnterCriticalSection/LeaveCriticalSection。需要注意的是临界区保护的对象越小越好只包住容器操作那一两行如果把整个端口扫描循环都锁住多线程就失去意义了。另外CCriticalSection的析构函数会自动释放内核对象无需手动DeleteCriticalSection。4.5 连接数上限与文件描述符耗尽C 程序里还有个隐性限制Windows 默认允许一个进程打开的 socket 数量受物理内存和maxfds在 Winsock 里是动态分配约束虽然不像 Linux 的ulimit -n那样严格但大量线程同时建立连接时仍可能遇到WSAENOBUFS或WSAEMFILE。常见的规避手段是限制并发 socket 数量比如每个线程在发起新的 connect 前检查当前活动连接数是否超过阈值如 200超过则短暂休眠再继续。NetScanner 的 32 线程扫描每线程同时只扫一个端口自然受控但如果改造成每线程循环快速 connect这个坑就会出现。5. 扫描参数的构造与范围切分别在 IP 换算上翻车5.1 输入区间的 IP 地址处理NetScanner 界面通常有「起始 IP」和「结束 IP」输入框程序需要把形如 192.168.1.1 的字符串转换成 32 位整数并遍历。最容易出错的地方是inet_addr的返回值已经是网络字节序大端直接加 1 得到的是字节序错乱的结果。可靠做法是手动解析unsigned long IPToULong(CString strIP) { // 把点分十进制拆成四段转成主机字节序的整数 int nField[4] {0}; _stscanf(strIP, _T(%d.%d.%d.%d), nField[0], nField[1], nField[2], nField[3]); return ((unsigned long)nField[0] 24) | ((unsigned long)nField[1] 16) | ((unsigned long)nField[2] 8) | (unsigned long)nField[3]; } CString ULongToIP(unsigned long ulIP) { CString strIP; strIP.Format(_T(%d.%d.%d.%d), (ulIP 24) 0xFF, (ulIP 16) 0xFF, (ulIP 8) 0xFF, ulIP 0xFF); return strIP; }_stscanf是 VC6.0 下的安全版本功能等同于sscanf。IP 地址中的192.168.1.1解析后得到的整数是 0xC0A80101遍历时加 1、判断是否大于结束地址即可。这里要特别注意如果用户输入了非法 IP比如999.1.1.1四段分解后高位数字会溢出写入int再移位就会得到错误结果。课程设计可以不做严格校验但实际使用中至少应该在输入框失去焦点时做一次范围检查。5.2 端口范围的边界检查端口号合法范围是 1-655350 和 65536 以上要么非法要么有特殊含义0 表示随机端口。NetScanner 的端口输入框需要用CSliderCtrl或两个CEdit限制范围。扫描 1-1024 的常见端口时耗时很短但如果用户输入 1-65535单个主机就要跑 6 万多次 connect即便超时设为 50 毫秒串行也需要近一个小时——这时多线程的收益就体现出来了。我一般建议课程设计的默认配置是「常见端口列表」21, 22, 23, 25, 53, 80, 110, 111, 135, 139, 143, 443, 445, 993, 995, 1433, 1521, 3306, 3389, 5432, 6379, 8080这个列表覆盖了 FTP、SSH、Telnet、SMTP、DNS、HTTP、SMB、SQL Server、Oracle、MySQL、Redis 等主流服务扫描一轮在本地网络几秒内完成展示效果和实际价值都比较理想。5.3 超时参数的合理设置超时时间直接决定扫描总耗时。局域网内 RTT 通常小于 1 毫秒50 毫秒的超时基本不会误判跨网段扫描时建议放宽到 500 毫秒到 1 秒。NetScanner 界面如果提供超时输入框我会把它做成可选项默认 100 毫秒。这里有一个平衡超时太短会把响应慢的开放端口误判为关闭尤其是有防火墙延迟响应的场景超时太长则整体耗时线性增长。用非阻塞 connect 加 select 的好处是超时粒度精细可以精确到毫秒比Sleep轮询方式好得多。6. 验证扫描结果与排错让源码真正跑起来拿到源码后第一件事不是读代码而是先编译运行确认项目能在你的环境下正常工作。VC6.0 工程在 Windows 10 以上的系统编译时常见问题有三个一是winsock.h和winsock2.h的重复定义报错集中在socket、bind等函数的重声明上解决办法是在 StdAfx.h 里把#include winsock2.h放在#include windows.h之前二是inet_addr等函数被标记为已弃用这个只是警告不影响编译三是工程需要以管理员权限运行否则原始套接字打不开扫描器启动时如果提示WSAEACCES用右键「以管理员身份运行」即可。扫描结果的验证是一个容易被忽略的环节。最直接的方法是拿一台已知开放端口的机器测试把目标设为本地127.0.0.1在命令行用netstat -ano列出当前监听端口然后对照扫描器的输出。如果扫描结果和netstat不一致优先排查超时设置——本机扫描时 50 毫秒足够但 Windows 防火墙可能拦截回环地址的某些探测这时用管理员权限运行通常能解决。另外可以再用在线端口扫描工具或另一台机器上的 Nmap 做交叉验证如果 NetScanner 报告的端口和 Nmap 结果一致说明扫描逻辑没有问题不一致则多半是连接被防火墙以不同方式处理而不是代码有 bug。一个适合在课程设计报告里展示的验证技巧是扫描目标设为网关 IP如 192.168.1.1用arp -a确认目标在线然后用浏览器访问http://192.168.1.1确认 80 端口确实开放。如果 NetScanner 在 80 端口上显示超时而非开放检查本机是否配置了代理——浏览器可能走了代理而扫描器的 socket 直连没有代理导致目标 HTTP 服务只响应代理请求而非直接连接请求。这个细节在答辩时讲出来能体现对 TCP 连接行为的理解深度。最后说一个提升可维护性的技巧在NetScannerDlg.cpp里加一个#define SCAN_DEBUG宏调试模式下把每次 connect 的错误码和耗时输出到调试窗口#ifdef SCAN_DEBUG int nErr WSAGetLastError(); TRACE(_T(Port %d connect failed, error%d\n), nPort, nErr); #endifTRACE是 MFC 的调试输出宏输出内容可以在 VC6.0 的 Output 窗口里实时看到。错误码的含义对照手册可以快速定位问题10061 是拒绝连接端口关闭、10060 是超时被过滤、10048 是端口被占用或 socket 未释放。加上这行输出扫描器在真实网络环境里的表现会一目了然这也是把课程设计源码变成自己真正理解的技术栈的最短路径。本文还有配套的精品资源点击获取
返回列表