简介:这是一份面向计算机网络与信息安全方向课程设计的端口扫描工具完整资源包,采用C和Go两种语言分别实现,覆盖TCP-connect、SYN、FIN、UDP四种常见扫描方式。压缩包共25个文件、大小6.35MB,内含课程设计报告(docx)、答辩PPT(pptx)、C语言源码(.c/.h)、Go源码(.go)、技术报告/README说明文档(md)以及Makefile等构建配置,目录结构清晰,可对照报告直接编译运行。实现上,Go版本采用协程与生产者消费者模型,通过多生产者发送探测、多消费者监听返回实现异步并行,显著减少socket IO等待;C版本借助多线程轮询多个socket描述符,只要其中某个IO有响应即可判定端口开放,兼顾速度与可靠性。整套交付内容包含可运行源码、设计报告和答辩PPT,既能支撑课程设计全过程,也适合想学习并发网络编程、四种扫描原理的同学深入参考。当前已有1434人学习下载。
1. 端口扫描工具在 C 语言里能解决什么问题
做内网基线核查或者交 C 语言课程设计的时候,十有八九会遇到这个需求:给我一台机器的 IP,我想快速知道它开没开 22、80、3306 这类常用端口。用 Python 写网络脚本确实够快,但如果你想把这套逻辑压缩成一个不依赖外部库、拷到哪台机器都能编译跑起来的工具,C 语言是更扎实的选择。这篇文章要讲的端口扫描工具,核心就三件事:用 socket 系统调用发起 TCP connect,用 select 控制超时,用多线程提高探测速度。它不是什么黑科技,但把它读透之后,你对 TCP 状态机、阻塞与非阻塞 I/O、文件描述符生命周期这些概念都会建立起很直观的认知。这篇文章适合正在学网络编程、要做 C 语言课设、或者需要做轻量端口基线核查的读者。
2. 为什么用 TCP connect 做端口扫描:三次握手与超时机制
2.1 端口开放状态与 TCP 状态机:SYN、ACK、RST 分别告诉你什么
端口扫描的本质,是向目标主机的某个端口发起连接请求,然后根据回应判断这个端口的状态。最常见的做法是 TCP connect 扫描:完整走一遍三次握手,连接成功就认为端口开放。这里我们要先把手握手的细节讲清楚,因为后面写代码时很多“奇怪现象”都得回到这一节来理解。
客户端发送 SYN 报文后,会遇到三种回复。第一种是 SYN-ACK,说明目标端口处于监听状态,愿意建立连接,这是端口开放的最直接证据。第二种是 RST 报文,说明目标主机的协议栈根本找不到对应端口在监听,直接拒绝,这就是端口关闭的典型表现。第三种是什么都不回,没有 SYN-ACK 也没有 RST,大概率是探测报文被防火墙规则丢弃了,或者目标主机本身不可达。很多初学者把“没反应”当成“端口开放”,这是第一个需要纠正的认知。
把三种回应整理一下,方便写代码时对应处理:
| 对端回应 | 端口状态 | connect 系统调用表现 | 说明 |
|---|---|---|---|
| SYN-ACK | 开放 | 连接成功,connect 返回 0 | 最可靠,但也最容易在目标日志里留下记录 |
| RST | 关闭 | connect 立即失败,errno 为 ECONNREFUSED | 判定准确,速度快 |
| 无回应 | 被过滤或不可达 | 一直阻塞,最终超时返回 EINPROGRESS 或超时 | 需要结合超时机制兜底 |
这里要额外说一句:connect 扫描是“有痕”的,因为三次握手已经完成,目标服务日志会记录一次完整连接。如果只是做网络基线核查、了解自己内网资产开放了哪些端口,这个痕迹完全可以接受。但如果你在探测不属于自己的网络资产,请先停下手,这涉及到授权和法规问题,技术文章只讨论技术实现,不鼓励越权使用。
2.2 阻塞与非阻塞:让超时控制在 1 秒而不是 75 秒
确定了判定标准,下一个要解决的就是超时。C 语言里直接调用 connect,socket 默认是阻塞模式。阻塞模式下,如果目标端口不回 RST 也不回 SYN-ACK,connect 会卡在系统内核里,按 TCP 重传机制反复发 SYN,整个过程可能持续几十秒甚至超过一分钟。你写一个扫描工具,目标防火墙如果对非开放端口做了 DROP 处理,那一个端口就得等 75 秒,扫完 1000 个端口要等 20 多个小时,这工具就废了。
解决办法有两个方向。方向一是给 socket 设置收发超时,使用setsockopt和SO_SNDTIMEO、SO_RCVTIMEO,让阻塞 connect 最多等待指定的秒数。方向二是把 socket 切换成非阻塞模式,立即调用 connect,然后交给 select 去等待连接结果。这两个方案我都用过,方向上推荐后者,因为 select 能给你更精细的控制:不仅知道“连上了没有”,还能区分“超时”“连接中”“立即失败”三种状态。
非阻塞 connect 有一个必须理解的流程:connect 返回 -1,设置 errno 为 EINPROGRESS,说明连接正在建立中。这时候调用 select 检查这个 socket 是否可写,如果可写,再通过 getsockopt 取出 SO_ERROR,值为 0 才代表连接成功。这个流程看起来绕,却是很多网络工具底层的标准做法。
int flags = fcntl(sock, F_GETFL, 0); fcntl(sock, F_SETFL, flags | O_NONBLOCK); int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr)); if (ret != 0 && errno != EINPROGRESS) { return 0; // 立即失败:RST 或网络错误 } fd_set wset; FD_ZERO(&wset); FD_SET(sock, &wset); struct timeval tv = {timeout_sec, timeout_usec}; ret = select(sock + 1, NULL, &wset, NULL, &tv); if (ret == 0) { return 0; // 超时 } int so_error = 0; socklen_t len = sizeof(so_error); getsockopt(sock, SOL_SOCKET, SO_ERROR, &so_error, &len); if (so_error == 0) { return 1; // 连接成功,端口开放 }这段代码的逻辑说明:先切换非阻塞模式,connect 不再死等内核重传;然后 select 的监听对象是写事件,连接建立成功的标志是 socket 变为可写;最后一步 getsockopt 是为了确认 SO_ERROR 确实是 0,因为 select 返回可写也有可能是收到了 RST,这种时候 socket 同样可写。三个步骤缺一不可,我第一次写的时候跳过了 getsockopt,结果把一部分关闭端口误判成了开放,这是个很典型的坑。
参数方面,timeout_sec 基本固定在 1 到 3 秒,timeout_usec 一般设 0。局域网内扫描 1 秒足够,跨公网可以适当放宽到 3 秒。两个值不要都设很大,一个端口 3 秒,65535 个端口理论最大要 54 小时,后面第 4 章讲多线程并发时你会看到,超时值直接决定了整体扫描耗时的上限。
3. 单线程最小实现:一个能跑通的端口扫描核心代码
3.1 扫描主流程:socket、connect、select、回收
原理讲清楚了,现在给一个能直接编译运行的完整版本。这个版本是单线程的,功能很简单:输入 IP、起始端口、结束端口,逐个端口做判断,开放就打印出来。它不适合扫大范围端口段,但作为最小实现,逻辑最干净,排错也容易。真正做全量扫描,在第 4 章的并发版本上扩展即可。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <fcntl.h> #include <sys/select.h> #include <sys/time.h> int check_port(const char *ip, int port, int timeout_sec, int timeout_usec) { int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 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, ip, &addr.sin_addr) != 1) { fprintf(stderr, "IP 地址格式错误\n"); close(sock); return -2; } int flags = fcntl(sock, F_GETFL, 0); if (fcntl(sock, F_SETFL, flags | O_NONBLOCK) != 0) { close(sock); return -3; } int ret = connect(sock, (struct sockaddr *)&addr, sizeof(addr)); if (ret != 0 && errno != EINPROGRESS) { close(sock); return 0; // 端口关闭或被拒绝 } fd_set wset; FD_ZERO(&wset); FD_SET(sock, &wset); struct timeval tv; tv.tv_sec = timeout_sec; tv.tv_usec = timeout_usec; ret = select(sock + 1, NULL, &wset, NULL, &tv); if (ret == 0) { close(sock); return 0; // 超时,不判定为开放 } if (ret < 0) { close(sock); return -4; } int so_error = 0; socklen_t len = sizeof(so_error); getsockopt(sock, SOL_SOCKET, SO_ERROR, &so_error, &len); close(sock); if (so_error == 0) { return 1; // 确认连接成功 } return 0; } int main(int argc, char *argv[]) { if (argc != 4) { fprintf(stderr, "用法: %s <IP> <起始端口> <结束端口>\n", argv[0]); return 1; } const char *ip = argv[1]; int start = atoi(argv[2]); int end = atoi(argv[3]); if (start < 1 || end > 65535 || start > end) { fprintf(stderr, "端口范围不合法,必须在 1-65535 之间\n"); return 1; } printf("开始扫描 %s,端口范围 %d-%d\n", ip, start, end); for (int port = start; port <= end; port++) { int result = check_port(ip, port, 1, 0); if (result == 1) { printf("%d open\n", port); fflush(stdout); } } printf("扫描结束\n"); return 0; }这段代码的建议编译命令是gcc -o scan scan.c,运行示例是./scan 127.0.0.1 22 443。整体流程是:每个端口新建一个 socket,设置非阻塞,发起 connect,select 等待最多 1 秒,最后 getsockopt 确认结果。注意每个分支都要 close,socket 是文件描述符资源,不关的话进程跑完一轮就没有 fd 可用了。
参数上的几个选择我补充说明一下。timeout 我在这里写死了 1 秒,因为本机回环地址和局域网内响应基本在毫秒级。如果扫公网 IP,建议改成 3 秒,或者把超时值做成第五个参数,这样不用重新编译。IP 解析直接用了inet_pton而不是gethostbyname,好处是支持点分十进制输入且不需要处理 DNS 解析,如果你要按主机名扫描,要先把这一步换成getaddrinfo。
3.2 输入参数与边界处理:atoi 只是起点
这个版本的输入处理暴露了一个问题:atoi 不会检查数字合法性,输入./scan 127.0.0.1 abc 100,start 会变成 0,程序会直接报“端口范围不合法”退出。更隐蔽的是输入1.1.1.1 65536 65537,end 在 atoi 层面变成 65537,但条件是 end > 65535,所以也会被拦住。这里的边界校验能挡住大多数误输入,但还不够严格——atoi 对字符串里的非数字字符只会静默忽略,比如"80abc"会被解析成 80。
如果你想把工具做得稳一点,可以把参数的解析换成strtol,检查endptr是否指向字符串结尾,再配合errno判断是否溢出。另一个容易被忽略的点是inet_pton对 IPv4 的检查:它传人的地址必须是点分十进制且每段在 0-255 之间,比如127.0.0.1没问题,127.0.0.256会返回 0。这些边界处理看起来琐碎,但真的到了给不懂技术的人演示工具的时候,能把报错挡在前面,体验会好很多。
3.3 运行与验证:先扫本机再扫局域网
编译好之后别急着扫公网,先扫本机回环地址。在大多数 Linux 主机上,22(SSH)、80(HTTP)、3306(MySQL)这些端口要么真实开放,要么服务没装但系统层面没有监听。本机扫描结果可以用ss -ltn或者netstat -ltn去对照,哪些端口开着,一查便知。我一般先跑./scan 127.0.0.1 20 100,观察结果里有没有与ss -ltn输出重叠的端口号。
如果扫出来与预期完全不一致,优先怀疑ss -ltn没有显示是你权限不够,换成sudo ss -ltn再比一次。回环测试通过后再扫局域网内一台已知开了 HTTP 服务的主机,比如./scan 192.168.1.23 80 81,预期 80 开放、81 关闭。这一步验证的是非阻塞 connect 在真实网络环境下的表现,和回环环境相比,多了 ARP 寻址、路由转发这些环节,能暴露防火墙丢包导致的超时问题。
4. 并发扫描设计与 3 个关键参数:线程数、超时值、结果缓冲
4.1 线程模型:一个端口一个线程为什么不可行
单线程版本扫 100 个端口,每个端口最多等 1 秒,最慢要 100 秒。这个速度显然不靠谱,所以并发改造是必然的。最常见的做法是给每个端口开一个线程,线程里调用第 3 章的check_port逻辑,主线程只负责创建和回收。代码写起来很简单,但这是我最不建议在生产环境抄的方案——65535 个端口就意味着同时创建 65535 个线程,线程不是免费资源。
Linux 下每个线程默认栈大小是 8MB,这是虚拟内存地址空间的占用,不会立刻吃掉物理内存,但线程总数受内核参数限制(kernel.threads-max、vm.max_map_count),开太多线程会出现pthread_create failed,而且线程切换开销会让整个扫描速度反而变慢。更稳妥的做法是引入信号量限制并发数,比如同时最多跑 200 个线程,扫完一个再补一个,这样系统负载稳定,速度也足够快。
信号量的用法注意sem_init初始值设为最大并发数,主线程每次创建任务前sem_wait,工作线程完成后sem_post释放一个名额。这个模型的优点是线程数可控,缺点是信号量操作有轻微开销,但对于端口扫描这种网络 I/O 密集的任务来说,这部分开销可以忽略。
sem_t sem; sem_init(&sem, 0, max_threads); // max_threads = 200 for (int port = start; port <= end; port++) { sem_wait(&sem); // 占用一个并发名额 port_arg *arg = malloc(sizeof(port_arg)); strncpy(arg->ip, ip, sizeof(arg->ip) - 1); arg->port = port; arg->timeout_sec = timeout_sec; arg->timeout_usec = timeout_usec; pthread_t tid; if (pthread_create(&tid, NULL, thread_scan, arg) != 0) { free(arg); sem_post(&sem); continue; } pthread_detach(tid); // 线程结束自动回收资源 }线程函数thread_scan里复用的是check_port,拿到结果为 1 时打印端口号,最后free(arg)和sem_post。注意两个细节:参数结构体必须在堆上malloc,不能用栈上的局部变量,因为线程刚启动时函数可能已经返回了;线程用pthread_detach脱离主线程,这样不用逐个pthread_join,简化了回收逻辑。
4.2 三个必调参数:并发线程数、单连接超时、FD 上限
并发扫描要调好三个参数,缺一个都会在特定场景翻车。
第一是最大并发线程数。默认 200 比较稳,局域网内可以调到 500,公网建议保留 200。这个值取决于目标机器的连接承受能力以及你运行扫描的机器本身。目标机器的net.core.somaxconn如果设置得小,大量 SYN 同时涌进来可能会丢包,导致一部分开放端口超时漏报。
第二是单连接超时值。前面说过 1 秒适合局域网,公网建议 3 秒。但要注意超时值和并发数是互相关联的:200 个线程、每个线程等 3 秒,每秒大约能探测 66 个端口,扫完 65535 个端口需要约 16 分钟。要提速,要么加线程数,要么减超时值,但两个方向都有上限。
第三是文件描述符上限。每个线程一个 socket,就是 200 个 fd,看起来不多,但ulimit -n默认值在部分发行版上是 1024,如果你的并发数开到了 1000,先确认这个限制。用ulimit -n查看,如果不够就临时调大,或者把并发数控制在软限制之内。
#include <pthread.h> #include <semaphore.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #define MAX_THREADS 200 #define TIMEOUT_SEC 1 typedef struct { char ip[16]; int port; int timeout_sec; int timeout_usec; } port_arg; pthread_mutex_t print_lock = PTHREAD_MUTEX_INITIALIZER; void *thread_scan(void *arg) { port_arg *p = (port_arg *)arg; int result = check_port(p->ip, p->port, p->timeout_sec, p->timeout_usec); if (result == 1) { pthread_mutex_lock(&print_lock); printf("%d open\n", p->port); fflush(stdout); pthread_mutex_unlock(&print_lock); } free(arg); return NULL; }这段代码新增了两个关键点:print_lock互斥锁保护打印操作,避免多线程同时 printf 时输出交错;free(arg)放在线程函数内部,因为主线程创建完就继续干别的事了,不能替子线程管理这块内存。这里也回应了之前热词里提到的 C 语言内存管理问题——多线程环境下堆内存的分配和释放必须做清晰的责任划分,否则真会出现 double free 或者内存泄漏。
4.3 结果收集:直接打印还是集中汇总
并发场景下,每个线程直接向终端输出有三个问题。第一是混淆,两个开放端口挨在一起打印,你很难看出哪个是哪个。第二是性能,printf 本身有锁、有缓冲刷新,200 个线程同时打印会产生锁竞争。第三是后续处理困难,结果散落在标准输出里,你要统计“这轮扫到几个端口”,得另写脚本去解析。
我一般会为结果收集单独设计一个结构体数组,每个线程把结果写进去,主线程扫描结束后统一输出。数组用互斥锁保护,端口号、开放状态、超时时间三项为一组。这里的容量预估:开放端口通常占比很小,给MAX_THREADS * 10的容量基本够用,要注意数组越界仍然要加计数器判断。
5. 端口扫描工具常见问题排查:5 条踩坑记录
5.1 connect 返回 ECONNREFUSED 其实是对的
现象:扫到某个端口,日志显示 connect 返回 -1,errno 是 ECONNREFUSED,你以为是程序出错了。原因:这恰恰是端口关闭的标准判定结果,目标协议栈在收到 SYN 后回发了 RST,connect 被拒绝。解决:不用把它当错误处理,它的语义就是“端口关闭”,直接在代码里把这种情况映射为return 0即可。真正要警惕的是 EINPROGRESS 和超时,那才说明端口状态不确定。
5.2 每个端口都要新建 socket,文件描述符会偷偷耗尽
现象:程序扫到大概 1000 个端口后突然报错 “socket: Too many open files”。原因:ulimit -n默认 1024,你的程序同时有 1000 个 socket fd 未关闭,超过上限。解决:第一确认每个分支都调用了 close,第二检查是否用了FD_CLOEXEC或用close-on-exec标志,第三调大ulimit -n。这个坑最坑的点在于它在单线程版本里不出现,一旦并发拉高就暴露,属于资源管理问题。
5.3 select 返回可写,端口也未必开放
现象:明明 getsockopt 返回的 SO_ERROR 是 0,但该端口其实没有服务在监听。原因:select 的“可写”含义只有一个——这个 socket 上的写操作不会阻塞,包括收到 RST 之后 socket 也可以可写。解决:不能省掉 getsockopt 那一步,SO_ERROR 是 0 才能算连接成功。如果还想更严谨,还可以在 getsockopt 之后调用一次getpeername验证,但不推荐,因为 multilink 场景下偶尔会有协议栈层面的干扰。
5.4 多线程传参传了 &i,扫出一堆鬼画符端口
现象:并发版本扫出来的开放端口号完全不对,77、553、6002 这种毫无规律的数值。原因:线程函数的参数是循环变量i的地址,主线程循环跑得飞快,子线程读到的i早已不是开始的值,甚至可能已经被主线程改过多次。解决:参数结构体在堆上malloc然后逐字段赋值,或者按值传参,但 C 语言 pthread 只传 void 指针,所以最安全的就是每个线程一个独立堆对象,保证线程间互不干扰。这个坑几乎是每个写 C 多线程的人都要踩一次的。
5.5 自己写的工具扫不到任何端口,先查防火墙而不是怀疑代码
现象:扫描一个局域网主机,所有端口都超时,一个 open 都没有。原因:目标主机防火墙默认策略大概率是 DROP,直接丢弃入站 SYN,你的 connect 一直等不到回复,走超时分支。这不是代码逻辑错误,是网络环境限制。解决:先ping目标 IP 确认通,再用nc -zv手动测一次已知开放端口,判断是目标策略限制还是自己工具的问题。如果ping通但nc也超时,就是防火墙拦截,与工具无关。
6. 扫描结果验证与进阶技巧:从“能跑”到“可信”
工具写完之后,验证可信度比继续加功能更重要。我的验证习惯是先扫回环地址,用ss -ltn对照;再扫两台知道端口状况的局域网机器,确认没有漏报和错报;最后才敢扫一个未知网段。验证过程中如果发现结果不稳定,大概率是并发数高导致目标机丢包,这时把并发数降一半重扫即可,不需要在代码层面做大改。
进阶方向有三个值得投入。第一是重试机制:对超时端口做第二遍扫描,超时值放宽到 3 秒,能显著降低公网环境下的误报率。第二是SYN半开扫描,用 raw socket 直接发送 SYN 报文而不完成三次握手,只根据 SYN-ACK 或 RST 判定端口状态,速度快且连接痕迹少,但需要 root 权限。第三是把结果输出改成日志落盘,每次扫描带时间戳保存到文本文件,方便后续资产状态对比。
最后一个建议送给你:把check_port函数做成独立的可复用模块,不要在第 4 章那种把逻辑全部堆在 main 函数里的版本上继续添功能。我当时偷懒把线程、信号量、端口循环全写在一起,结果调参时到处改代码,最后花了两个晚上重构。先用第 3 章的小版本理清逻辑,再上第 4 章的并发模型,每步都验证,这个顺序能帮你少走很多弯路。希望帮到你。
本文还有配套的精品资源,点击获取