ARTICLE DETAIL

资讯详情

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

C语言端口扫描器实战:从socket连接到多线程并发优化

C语言端口扫描器实战:从socket连接到多线程并发优化 简介基于C语言与Go语言实现的端口扫描工具课程设计资料包面向计算机网络相关专业学生覆盖TCP-connect、SYN、FIN、UDP四种常见扫描方式。Go实现利用协程与生产者消费者模型实现并行异步探测能有效降低Socket I/O等待C实现则采用多线程并发扫描轮询多个Socket I/O的返回状态以判定端口是否开放。两套源码均完整可运行并配套清晰的工程目录。压缩包共25个文件包含C源码与头文件、Go源码、构建脚本、md技术报告、docx设计报告与pptx答辩演示整体仅6.35MB覆盖从方案设计、代码实现到成果汇报的完整流程。已有1434人学习下载适合作为课程设计或毕业设计的参考资料报告中梳理了设计思路与测试流程读者可直接运行源码复现实验亦可参考工程组织方式进一步扩展扫描策略加深对并发模型和网络协议的理解。1. 端口扫描器用C语言写到底图什么从一次内网资产梳理说起拿到一个叫《基于C语言的端口扫描工具设计与实现.zip》的压缩包很多人的第一反应是“这不就是给 socket 套个循环吗”。真按这个思路写完跑内网第一个结果就翻车要么慢得像蜗牛要么扫出一堆假开放。这个标题背后其实是一套完整的网络编程基本功——socket 生命周期管理、非阻塞 I/O、超时控制、并发模型、缓冲区处理任何一环偷懒结果都不可信。我给你的建议是别急着复制代码先把它当成一个“用最小成本拿到可靠端口状态”的工程问题来解。这套笔记适合正在做 C 语言课程设计、网络编程入门、或要给内网做资产盘点但不想依赖重量级工具的工程师。每段代码你都能直接编译跑但更重要的是知道哪个参数在什么场景下会坑你。2. 先搞懂端口扫描在扫什么TCP握手、连接状态与扫描分类2.1 三次握手决定了“开放”的真正含义端口扫描的本质是探测目标主机的某个端口上是否有进程在监听。对 TCP 协议来说一个端口“开放”意味着目标在这个端口上完成了三次握手——SYN、SYNACK、ACK。如果你向一个没人监听的端口发 SYN目标会回 RST连接被拒绝如果防火墙把包丢了你什么回复都收不到。所以扫描结果其实只有四类开放、关闭、被过滤、未知。很多 C 语言初学者以为 connect 返回成功就代表端口开放这句话对但不完整。connect 返回成功说明三次握手全部完成端口确实是开放的可 connect 失败不代表端口关闭它可能是超时、被拒绝、被防火墙丢弃这三者的区分需要靠 errno 来判断而不是简单打印“fail”。理解这一点后你就能明白为什么端口扫描器不能只写一个阻塞式 connect 循环。阻塞式 connect 在内核里对 SYN 的重传次数由 tcp_syn_retries 控制默认可能要等几十秒才返回错误扫 65535 个端口根本不现实。所以真正可用的扫描器必须能控制每次探测的超时时间并且让多个端口探测并发执行。那怎么控制 connect 的超时呢常见做法是把 socket 设为非阻塞然后交给 select 去等待“可写”事件再通过 getsockopt 拿到连接结果。这个方法不算复杂但它是后续所有优化和踩坑的基础。2.2 扫描类型对比全连接、SYN、UDP、ACK为什么课程设计只做全连接端口扫描按实现方式大致分四类TCP 全连接扫描、TCP SYN 半连接扫描、UDP 扫描、ACK 扫描。全连接扫描直接调用 connect 完成三次握手优点是不需要 raw socket不需要 root 权限普通用户权限就能跑而且结果可靠不会因为系统协议栈限制出幺蛾子。SYN 扫描需要自己构造 IP 和 TCP 头把 SYN 包发出去后观察返回的是 SYNACK 还是 RST这需要 raw socket 和 root 权限而且如果目标网络对异常流量有检测SYN 扫描很容易触发防火墙告警。UDP 扫描更特殊因为 UDP 没有握手通常靠 ICMP Port Unreachable 判断端口关闭但很多网络环境会屏蔽 ICMP结果就变成一片“open|filtered”不可靠。所以课程设计和工程实践里的“基于 C 语言的端口扫描工具”绝大多数选的就是 TCP 全连接扫描。这个选择很务实代码量小、权限要求低、逻辑直接、还方便加服务指纹识别。SYN 扫描这种活Nmap 早做到极致了自己从零造轮子去抢半连接扫描的性能投入产出比很低。你需要掌握的是全连接扫描的完整链路建 socket、准备 sockaddr_in、非阻塞 connect、select 等待、取 SO_ERROR、收尾清理。这条路走通再往并发、多线程、端口规程化解析上扩展都顺理成章。下面是这个链路的最小实现。3. 从零实现一个TCP全连接扫描器socket、connect、select与超时控制3.1 能编译运行的最小扫描器单个端口探测函数很多网上的 C 语言端口扫描器代码把整个逻辑堆在 main 里看着头疼还不好测试。我更习惯先写一个独立的scan_port函数它只负责探测一个 IP 和一个端口返回 1 开放、0 关闭、-1 错误。这样后续扩展并发、解析端口列表、输出格式化都不用动底层。下面是这个函数以及一个串行版本的完整代码Linux 下直接gcc scanner.c -o scanner就能编译。#include stdio.h #include stdlib.h #include string.h #include unistd.h #include fcntl.h #include errno.h #include sys/socket.h #include sys/select.h #include netinet/in.h #include arpa/inet.h int scan_port(const char *host, int port, int timeout_ms) { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); return -1; } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_port htons(port); if (inet_pton(AF_INET, host, addr.sin_addr) ! 1) { close(fd); return -1; } // 设为非阻塞connect 才能被 select 管住 int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); int ret connect(fd, (struct sockaddr *)addr, sizeof(addr)); if (ret 0) { close(fd); return 1; // 没走非阻塞流程直接连上了 } if (errno ! EINPROGRESS) { // EINPROGRESS 表示连接仍在进行其他错误代表端口拒绝等 close(fd); return 0; } fd_set wfds; FD_ZERO(wfds); FD_SET(fd, wfds); struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; int sel_ret select(fd 1, NULL, wfds, NULL, tv); if (sel_ret 0) { close(fd); return 0; // 超时目标没回包大概率被过滤或丢包 } int so_error 0; socklen_t len sizeof(so_error); getsockopt(fd, SOL_SOCKET, SO_ERROR, so_error, len); close(fd); return so_error 0 ? 1 : 0; } int main(int argc, char *argv[]) { if (argc ! 4) { printf(Usage: %s ip start_port end_port\n, argv[0]); return 1; } const char *host argv[1]; int start atoi(argv[2]); int end atoi(argv[3]); printf(Scanning %s ports %d-%d\n, host, start, end); for (int p start; p end; p) { int r scan_port(host, p, 1000); if (r 1) { printf(Port %d: OPEN\n, p); } fflush(stdout); } return 0; }这段代码里关键的坑有两个。第一个是EINPROGRESS的判断connect 返回 -1 不一定是失败非阻塞 socket 上只要 errno 是 EINPROGRESS就表示连接已经启动结果得等 select 回来才知道。很多初学者在这里直接当成失败返回结果所有端口都显示关闭。第二个是getsockopt取SO_ERRORselect 提示“可写”并不代表连接成功它也可能是一个失败事件只有把SO_ERROR取出来值为 0 时才真正代表连接建立。你没有其他可靠办法从 select 返回值区分成功和失败这个步骤不能省。另外代码里的超时单位是毫秒但 select 的timeval需要拆成秒和微秒写错导致超时变成 0 或过长扫描结果会变得完全不可信。3.2 把串行改成并发多线程还是 fork我的选型和理由串行版扫 100 个端口都要一百多秒因为每个端口至少等 1 秒超时。实际使用中必须并发。C 语言里有两种常见并发方式多线程和 fork 多进程。我一般选多线程因为端口扫描是 I/O 密集型任务每个线程只是发起 connect 然后阻塞在 select不涉及共享状态的复杂同步用 pthread 就够了。fork 的缺点是每个子进程都要复制父进程的内存映像启动开销大而且进程间通信还得用 pipe麻烦。如果你在 Windows 上用 WinSock那还有 IOCP 和重叠 I/O 这些更底层的东西但课程设计阶段完全没必要上。给一个多线程的生产者-消费者模型是很自然的主线程解析端口范围把每个端口封装成一个struct thread_arg然后用 pthread_create 创建一批线程每个线程独立调用scan_port。为了避免线程数过多导致本地端口耗尽通常用信号量限制同时在飞的线程数量。下面是创建线程的骨架你可以把它嵌到上面的 main 里替换串行循环。#include pthread.h #include semaphore.h struct thread_arg { const char *host; int port; int timeout_ms; }; sem_t semaphore; void *worker(void *arg) { struct thread_arg *ta (struct thread_arg *)arg; int r scan_port(ta-host, ta-port, ta-timeout_ms); if (r 1) { printf(Port %d: OPEN\n, ta-port); fflush(stdout); } free(ta); sem_post(semaphore); return NULL; } void create_scan_thread(const char *host, int port, int timeout_ms) { sem_wait(semaphore); struct thread_arg *ta malloc(sizeof(struct thread_arg)); ta-host host; ta-port port; ta-timeout_ms timeout_ms; pthread_t tid; pthread_create(tid, NULL, worker, ta); pthread_detach(tid); }注意ta-host指向的字符串必须是 main 里那个全局的 argv[1]在整个程序生命周期内有效。你可能会想为什么不直接传端口数字非要 malloc 一个结构体因为如果传局部变量线程还没退出栈内存就被覆盖了查错够你查半天。信号量的初始化值是并发上限建议在 main 里写sem_init(semaphore, 0, 100)。这里还有一个让我踩过坑的细节pthread_detach必须在pthread_create后立刻调用否则线程退出后资源不回收跑几千个端口后会出现无法创建新线程直接报EAGAIN。如果你不想用信号量也可以在主线程里维护一个计数变量但线程退出后怎么通知主线程减计数绕来绕去不如信号量干净。3.3 输入解析不要再用 scanf 读端口标题里的压缩包多半是课程设计很多同学习惯用scanf(%d %d, start, end)读端口。这个写法在交互式终端没问题但一旦你写自动化脚本用管道喂数据scanf 遇到换行或非数字字符就会停在原地甚至把上一次的残留字符留给下一次调用造成死循环。更可靠的解析方式是用fgets读一整行然后用strtol逐段解析支持“80,443,8000-9000”这种混合格式。这也是热词里“c语言 字符串函数”“c语言 文件缓冲区”和“c语言 数组 指针”真正派上用场的场景。void parse_ports(const char *input, int *ports, int *port_count) { char buf[256]; strncpy(buf, input, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0; const char *delim ,; char *token strtok(buf, delim); while (token ! NULL) { char *dash strchr(token, -); if (dash ! NULL) { *dash \0; int start atoi(token); int end atoi(dash 1); for (int i start; i end *port_count 1024; i) { ports[(*port_count)] i; } } else { if (*port_count 1024) { ports[(*port_count)] atoi(token); } } token strtok(NULL, delim); } }这个实现用strtok切逗号用strchr找范围号。注意strtok会修改原字符串所以必须先用strncpy复制一份否则你的参数在后续解析中被切开。ports数组由调用方分配我上面限制最多 1024 个避免越界。atoi对非法输入不报错但课程设计够用真正工程项目里建议用strtol加错误检测。解析完以后主线程就可以遍历这个数组每次取一个端口丢给create_scan_thread。这一步做完你的扫描器已经能接受常见用法./scanner 192.168.1.1 80,443,8000-9000。4. 让扫描器真正可用三个必调参数、输出格式与服务指纹识别4.1 并发数、超时时间、端口重试策略调参决定了扫描结果的可靠度同样的代码参数不同结果可能一个天一个地。我调参的经验第一条并发数不是越高越好。本地环境下 100 到 200 个并发线程没问题因为目标就在同一台机器或同一个交换机上延迟不到一毫秒。但如果目标在跨网段的公网或远程办公网丢包率稍高200 个并发同时发 SYN很容易触发目标或中间设备的速率限制导致大量连接被静默丢弃结果呈现为“关闭”或“超时”。我在内网资产梳理时通常先用 50 并发试探如果超时比例超过 5%就降到 30再不行就 20。对应地超时时间也要按网络环境调整局域网 300 到 500 毫秒足够跨网段 1 到 2 秒遇到高延迟链路3 秒也不嫌多。第二个参数是端口重试策略。很多扫描器对超时端口直接报“过滤”但网络抖动可能让 SYN 丢失尤其在你并发拉满之后重传机制还没生效select 就超时了。所以我习惯对“超时”的端口做一次重试重试超时时间适当放长。注意重试只针对超时不针对收到 RST 的端口。RST 是明确的拒绝重试一百次结果都一样。还要注意重试时不能复用同一个 socket必须重新socket()新建描述符因为一个 socket 一旦经历连接失败协议栈状态已经脏了。这个细节不难但很多人忘了结果第二次 connect 直接返回EADDRINUSE。第三个参数是源端口范围。当并发高时本地自动分配的临时端口有限如果系统默认的ip_local_port_range是 32768 到 60999大约 28000 个听起来不少但一个扫描器在几秒内就消耗光了。要根治这个问题要么限制并发数到远低于临时端口总量要么在 bind 时显式指定一个随机的源端口但后者需要自己处理冲突。课程设计不用做到这一步知道为什么并发一高就大量连接失败就够了。下表是我常用的参数组合你可以按网络环境对照设。网络环境推荐并发数超时时间重试次数局域网内同网段100-200300-500 ms1跨网段内网50-1001000-1500 ms1高延迟或弱网20-502000-3000 ms2公网目标已授权10-302000 ms1这个表不是万能答案但至少是一个靠谱的起点。扫描结果里出现大量超时端口时优先怀疑的不是目标而是你自己的并发和超时参数。4.2 输出格式设计从“一行一端口”变成可解析的结果课程设计交作业时打印“Port 80: OPEN”就够了。但如果你真的用它做内网资产梳理这种输出没法被脚本消费。我通常会加一个-j参数让结果输出为 JSON 或者 CSV这样后续还能配合 grep、awk 或者简单的 Python 脚本来统计。下面是一个输出 JSON 的代码片段扫描结束后一次性打印。void print_json(int *ports, int count, int *results) { printf([); int first 1; for (int i 0; i count; i) { if (results[i] 1) { if (!first) printf(,); printf({\port\:%d,\status\:\open\}, ports[i]); first 0; } } printf(]\n); }这段代码里的results数组由扫描完成后的回调填充元素对应ports数组里的每一项。你可能会想为什么不边扫边打印边扫边打印在多线程下会乱序而且 JSON 括号会断成碎片。更好的做法是每个线程扫描完后把结果写入一个共享数组加锁保护写操作最后统一输出。这里踩到“c语言 内存管理”和“c语言 数组 指针”的地方在于共享数组必须预先分配好不能用realloc动态扩容否则多线程同时扩容会互相覆盖。如果不知道扫描总端口数就按解析出来的端口数量分配一个定长数组。在数组界内写results[i] r配合一个pthread_mutex_t锁安全又简单。4.3 增加服务指纹识别banner grabbing 的receive处理端口开放但不知道跑的是什么服务扫描价值直接缩水一半。TCP 全连接扫描天然适合 banner grabbing——因为三次握手完成了你可以立刻send一个字符串然后recv等待目标返回它的欢迎信息。很多服务如 SSH、FTP、SMTP 都会在连接建立后主动发送 banner。这个动作要在扫描成功后做而不是另开连接否则你又要经历一次完整握手。实现上只需要在scan_port返回 1 后对同一个 fd 先send再recv但recv必须设置超时避免目标不发 banner 时线程卡死。#include sys/time.h int grab_banner(int fd, char *buf, size_t len, int timeout_ms) { struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv)); ssize_t n recv(fd, buf, len - 1, 0); if (n 0) { buf[n] \0; return 0; } return -1; }注意SO_RCVTIMEO的单位在 Linux 下是timeval在 Windows 下是 DWORD 毫秒如果你要跨平台这是一个不小的分支。另外recv只调用一次可能只读到部分 banner因为 TCP 是流协议。严格的做法是循环recv直到超时或读够一定字节。但服务指纹通常在一段内能读完循环里要有限制比如最长等 1 秒、最多收 4KB防止目标持续往外吐垃圾数据。加这个功能以后你扫描出的开放端口旁边就能带上 SSH、HTTP、MySQL 这类标识实用性立刻上来。5. 端口扫描避坑指南5条让结果“看起来很对”实则翻车的细节5.1 现象本机扫描正常一到跨网段就几乎全超时跑到另一台服务器上扫描另一个部门的内网段结果几乎全部超时但用 Nmap 同网段扫却有大量开放端口。原因大概率是你发送的探测包触发了中间交换机或主机的速率限制或者你的并发数太高导致本机临时端口耗尽。解决分两步先看超时比例如果超过 50%把并发数降到 20超时时间加到 3000 毫秒重试一次如果还是全超时再用ping看目标是否可达如果不通那就是网络策略的丢弃不是扫描器问题。这个场景里最容易犯的错是把超时当作“端口关闭”结果就漏报一大片。5.2 现象端口明明开着却扫不到本地起了一个 HTTP 服务监听 127.0.0.1:8080扫描器对着本机公网 IP 扫显示端口 8080 关闭。原因是服务只绑定在了回环地址上对外网卡并没有监听。ss -lnt看得到127.0.0.1:8080但扫描器连的是192.168.x.x:8080connect 自然被拒绝。解决方法是明确目标 IP 的监听地址扫描前先确认服务到底绑定在哪个接口。还有一种类似情况是服务监听在 IPv6 地址上而扫描器只发 IPv4也会漏。C 语言里处理 IPv6 需要AF_INET6结构体要换成sockaddr_in6代码量翻倍一般课程设计不会做但你心里要有这根弦。5.3 现象并发一高本地连接数飙升后大量失败线程数从 50 加到 200结果反而开放端口数量下降系统日志里看到Cannot assign requested address。原因是本机临时端口不够用了所有连接都在 TIME_WAIT 或 FIN_WAIT 状态占用着源端口。解决方法是控制并发上限或者调整系统参数net.ipv4.ip_local_port_range和net.ipv4.tcp_fin_timeout。这两个参数需要 root 权限而且改了影响整个系统不能乱动。课程设计阶段最稳妥的方案是把并发数调到 100 以下并在每次扫描完主动close(fd)不要依赖系统自动回收。还要注意每个 socket 在 close 后会有 TIME_WAIT 状态如果你在循环里一直新建 socket也会快速耗尽端口资源。5.4 现象用scanf读端口范围时程序偶尔不输出直接卡死交互输入80,443后按回车程序像死了一样等半天才打印结果或者干脆不动。原因是scanf(%d)读取时遇到了非数字字符逗号和换行留在缓冲区里下一次scanf又读到同一个字符反复失败。解决方法是彻底放弃scanf改用fgets读一行到char buf[256]再按第 3.3 节的parse_ports解析。这个坑在“c语言 文件缓冲区”这个词里很经典fgets读到的是完整行缓冲区状态干净不会留下残留字符。我已经记不清多少次看到同学在这里用getchar()清空缓冲区结果多按一次回车反而把后续输入吃掉了。认真说fgetsstrtol才是该有的习惯。5.5 现象扫描结果和 Nmap 对不上Nmap 扫出开放你的程序显示关闭用select等待连接结果时你看到 select 返回 1但getsockopt取到的SO_ERROR是ECONNREFUSED你把端口当作关闭。可 Nmap 那边显示开放这是因为目标实现了连接限制策略同一时刻只允许少量连接超出的连接直接被拒绝。也有可能你对超时的理解不同Nmap 默认认为连接成功后需要至少一次数据交换才算 open你的程序只做了握手然后立即断开目标有连接跟踪功能时会记录一次完整连接然后才认为 open。解决方法是不仅做 connect还在连接成功后尝试recv一个字节如果能收到 banner 或收到对端关闭的 FIN都算端口开放。这样虽然慢一点但结果更可靠。如果不想改代码就用第 6 章里的 Nmap 对照法辅助验证。6. 从能扫到好用用Nmap和nc验证你的扫描结果再做两件小事写完扫描器别急着说“能用”。我的验证习惯是找一个目标主机选定 3 到 5 个端口先用你的程序扫一遍再用 Nmap 的全连接扫描扫一遍最后用nc -zv逐个连一遍三方结果对齐才放心。具体命令是nmap -sT -p 80,443,3306 目标IPnc -zv -w 2 目标IP 80可以验证单个端口。如果 Nmap 扫出开放你的程序却显示关闭优先查你的超时是否太短如果你的程序扫出开放Nmap 却没有优先查你是否把“连接成功”误判为“开放”比如 select 返回但SO_ERROR没取对。验证之外有两件小事能让这个工具从“课程设计”变成“实用工具”。第一件是增加主机 IP 解析用getaddrinfo把域名解析成 IP顺便支持 CIDR 格式比如输入192.168.1.0/24自动展开成 254 个 IP。第二件是把扫描结果保存到文件同时打印进度条进度条不用花哨每扫完 1% 打印一个点就行。这两件事都依赖 C 语言的指针和数组操作正好和热词里的“c语言 数组 指针 移动 指定位输出 字符”这类问题挂上钩。最后分享一个我自己的教训有一次扫描内网 1000 台机器我把超时设成 500 毫秒结果服务商网络有 600 毫秒延迟扫出几万个“关闭”端口后来加了重试才把误报压下来。从那以后我养成了习惯——先拿已知开放端口校准参数再大规模扫。希望帮到你。本文还有配套的精品资源点击获取
返回列表