简介:这是一份面向网络安全课程设计场景的端口监控程序完整源码,基于MFC对话框框架实现,可对指定IP地址、指定端口与自定义线程数执行多线程端口扫描,适合高校信息安全、网络工程等专业学生作为课程设计或实验参考。文件共17个,核心代码集中在4个头文件与3个C++源文件中,涵盖端口扫描逻辑、线程调度和对话框界面,另含工程文件、图标与资源脚本,便于在Visual C++ 6.0中直接打开编译,压缩包仅29KB。已有238人浏览学习。结合代码可理解端口扫描的基本原理与实现细节,学习入侵者如何探测目标主机的开放端口,进而据此加固本机系统,为将来设计更安全的网络应用打下基础。
1. 网络安全终端监控里的端口扫描代码,课程设计到底要你交什么
端口扫描看起来像黑客电影里的戏码,但落到“网络安全终端监控”这门课程设计里,它只是一段需要稳定运行的网络探测代码。你的目标是输入一个 IP 或者网段,输出这台设备上正在监听的 TCP 端口清单,再把这个清单交给你写的监控逻辑去做异常判断。我见到的课程设计,几乎都会把这个模块放在整套代码最前面:因为端口一变,通常意味着服务装错了或者机器可能被动手脚。适合做这个方向的,是已经有一门 Python 或 C 语言编程基础、手里有一台虚拟机或教学机房机器的同学;如果你还没理解三次握手,先看完第二章再写代码,能少改一半 bug。
2. 端口扫描原理与课程设计定位:先搞清 TCP 半开扫描和三次握手,再动手写
2.1 终端监控为什么必须依赖端口扫描
一台主机启动某个服务时,服务端程序会调用 bind() 绑定一个 IP 和端口,然后进入 LISTEN 状态监听连接。比如 Web 服务监听 80 或 443,远程登录服务监听 22,数据库可能监听 3306。这些端口就是操作系统里对外开放的“门牌号”。端口扫描做的事情,就是主动去连接这些端口,看哪些端口愿意响应“我可以接受连接”,从而反推出这台机器正在运行哪些网络服务。
放到终端监控的场景里,这个信息不是课外附加题。一台被管控的终端如果突然多出几个对外监听的端口,极有可能意味着被植入了后门程序,或者被安装了违规的外部访问软件;反过来,一个本该在 443 端口提供服务的业务主机突然扫不到 443,那就要立刻检查服务是否崩溃、配置是否被改动。可以说,没有端口扫描模块,终端监控只能看到进程列表和 CPU 占用,看不到网络上到底暴露了什么。把这两件事绑在一起,正是这门课程设计想让你掌握的核心能力。
2.2 三种常见扫描方式:TCP connect、SYN 半开、UDP
课程设计答辩时,老师大概率会先问“你用的是哪种扫描方式”。常见的端口扫描有三类,区别主要在对 TCP 三次握手的处理上。
| 扫描方式 | 原理 | 权限要求 | 速度 | 对目标产生的影响 | 课程设计推荐度 |
|---|---|---|---|---|---|
| TCP connect 扫描 | 完整建立三次握手,连接成功说明端口开放 | 普通用户即可 | 中等 | 会留下完整连接记录,目标日志能看到 | 最推荐 |
| SYN 半开扫描 | 只发 SYN,收到 SYN-ACK 就判定开放,不发起 ACK 完成握手 | 需要 root 权限构造原始报文 | 快 | 目标一般只会记录到异常半连接 | 可讲原理,不建议主写 |
| UDP 扫描 | 发送 UDP 报文,靠 ICMP 端口不可达判断关闭 | 普通用户即可 | 慢且不可靠 | 可能触发目标系统大量 ICMP 响应 | 只做扩展项 |
TCP connect 扫描最贴近“课程设计”的定位:代码逻辑直观、不依赖底层 raw socket、普通账号就能跑。它的原理就是主动完成一次三次握手:客户端发 SYN,服务端回 SYN-ACK,客户端再回 ACK,握手完成。如果端口没监听,内核会直接回 RST,握手在第一步就中断,connect 调用立即失败。你只要会调用系统提供的 socket 接口,就不需要关心底层报文封装的细节。
SYN 半开扫描在原理上更“黑客”,但在纯 Python 里实现非常别扭,你要自己构造 IP 头和 TCP 头,算校验和,用 raw socket 发送,而 Windows 和大部分 Linux 教学环境对普通用户的 raw socket 都有限制。与其把大量时间耗在底层协议栈上,不如先把 TCP connect 扫描做扎实,然后在文档里把 SYN 原理讲清楚,这已经能满足课程设计的考核要求。UDP 扫描则更麻烦,因为很多 UDP 服务不回应任何报文,判断不能得出结论,只适合作为加分项写进报告。
2.3 课程设计评分的三个考察点:正确性、异常处理、结构化输出
我给学生看代码时经常说,课程设计不是“效果越炫评分越高”,而是看代码能不能被别人复用。评分通常落在三个点上。第一是正确性,扫描结果和系统自带的 netstat 或第三方工具比对,不能出现大量漏报和误报。第二是异常处理,目标不可达、连接超时、线程池抛错,这些分支都要处理干净,不能随便崩掉。第三是输出结构化,扫描结果不应该只是 print 到控制台,而应该返回一个列表或 JSON 对象,方便终端监控模块继续消费。
曾经有个同学把扫描结果全部 print 到屏幕上,监控子模块完全不知道去哪里读取,答辩时又把代码写成了硬编码端口列表,换个目标 IP 就得改源码。这个案例我印象很深:课程设计的端口扫描代码,本质上是给下一个程序调用的,不是给老师肉眼看的。所以我建议你把代码拆成三层:底层函数只负责“输入一个 IP 和端口,返回布尔值”;中层负责“扫描一段端口,返回开放端口列表”;上层负责“把结果记录成 JSON 或写入数据库”。三层分开,后面改并发、加服务识别、加告警逻辑都会顺手很多。
3. 用 Python 交付可运行的端口扫描代码:从单线程到并发
3.1 最小可运行版本:基于 TCP connect 的单线程扫描
先别急着上并发,把最小可运行版本跑通更重要。这段代码可以直接保存成scanner.py,只依赖 Python 标准库 socket,不需要装任何第三方包。
import socket def check_port(host: str, port: int, timeout: float = 1.0) -> bool: """ 检测目标主机上的单个 TCP 端口是否开放。 返回 True 表示端口开放,False 表示关闭或不可达。 """ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: # connect_ex 返回 0 表示连接成功,非 0 表示失败 return sock.connect_ex((host, port)) == 0 except OSError: # 超时、网络不可达等情况下直接视为不可用 return False finally: sock.close() if __name__ == "__main__": target = "127.0.0.1" for port in range(1, 1025): if check_port(target, port): print(f"{port} open")这里我特意用了connect_ex而不是connect()。connect()在超时会抛出TimeoutError,连接被拒绝会抛ConnectionRefusedError,每个错误都要单独写 try 分支,代码会显得很啰嗦。connect_ex会把结果直接映射为错误码整数,0 表示连接成功,其余值代表不同的失败原因,更适合写扫描器这种“结果只有开或关”的场景。
注意函数的三个参数:host是目标 IP,port是要测的端口,timeout控制单次连接的超时秒数。finally里sock.close()必须保留,否则大量 socket 句柄堆积会导致程序运行一段时间后报“无法分配文件描述符”的错误。这段代码的问题也很明显:单线程逐个连接,扫描 1 到 1024 端口在内网可以接受,但如果目标在跨网段、防火墙策略严格的地方,每个端口都要等满 timeout 秒,整个扫描会拖得很久。先跑通这个版本,等于给后面的并发版本留了一张正确性对照表。
3.2 加并发:用线程池把扫描时间从几分钟压到几秒
终端监控需要周期性扫描,单线程版本扫一次要等好几分钟,显然不现实。常见做法是引入concurrent.futures.ThreadPoolExecutor,用一批线程同时发起连接。socket 的 connect 属于 I/O 等待型操作,等待期间线程会主动让出 CPU,所以即使 Python 有 GIL,这段代码依然能获得接近线性的并发提升。
from concurrent.futures import ThreadPoolExecutor, as_completed def scan_one(host: str, port: int, timeout: float): """单个端口扫描的线程任务,返回 (端口, 是否开放)。""" sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.settimeout(timeout) try: if sock.connect_ex((host, port)) == 0: return port, True return port, False except OSError: return port, False finally: sock.close() def scan_ports(host: str, ports, timeout: float = 1.0, max_workers: int = 256): """并发扫描端口列表,返回升序排列的开放端口列表。""" open_ports = [] with ThreadPoolExecutor(max_workers=max_workers) as executor: future_map = { executor.submit(scan_one, host, port, timeout): port for port in ports } for future in as_completed(future_map): port, is_open = future.result() if is_open: open_ports.append(port) return sorted(open_ports)max_workers=256不是越大越好。本机实验室环境 256 足够,再高会让系统同时堆积大量半开连接,反而加大丢包概率,产生大量假超时。timeout的设置在第四章里会专门讲,这里只要记住:局域网目标用 0.3 到 1.0 秒,跨网段建议 1.5 秒以上。
这段代码还有一个容易被忽略的好处:每个线程创建的 socket 都是独立的,线程之间没有任何共享可变状态,天然线程安全。如果你尝试把同一个 socket 分给多个线程去并发 connect,那才是真正的“玄学”现场。另外,as_completed会按完成顺序返回结果,而不是按提交顺序,所以最后必须对open_ports做一次sorted(),保证输出稳定,方便后续比对。
3.3 把结果整理成监控能用的 JSON 记录
课程设计的终端监控模块需要知道三件事:扫描的是哪台主机、什么时候扫的、哪些端口是开放的。只返回一个裸列表不满足要求。我在项目里一般会把上层输出设计成 JSON 对象,这样无论是写文件、写数据库还是传给前端展示,都不用改接口。
import json import time def scan_host_to_record(host: str, start_port: int = 1, end_port: int = 1024, timeout: float = 1.0, max_workers: int = 256): """扫描指定主机的端口区间,返回一条结构化记录。""" ports = range(start_port, end_port + 1) open_ports = scan_ports(host, ports, timeout, max_workers) return { "host": host, "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"), "open_ports": open_ports, "count": len(open_ports), } if __name__ == "__main__": record = scan_host_to_record("127.0.0.1") print(json.dumps(record, ensure_ascii=False, indent=2))输出示例会像这样:{"host": "127.0.0.1", "timestamp": "2025-01-15 14:22:30", "open_ports": [80, 443], "count": 2}。这个结构有什么用?终端监控模块拿到记录后,把同主机的相邻两次记录做差集,就能得到“新增端口”和“消失端口”两个列表。新增端口意味着可能部署了新服务或出现可疑程序,消失端口意味着业务异常下线,这些才是真正需要告警的事件。时间戳字段也很关键,没有时间戳,历史记录就无法排序,课程设计里最常用的“增量变化”功能也就做不出来了。
4. 扫描参数怎么调:超时、线程数、端口范围和服务识别
4.1 超时时间:内网用 0.3 秒,跨网段用 1.5 秒,误报怎么控制
端口扫描翻车最多的地方不在代码逻辑,而在参数设置。timeout设短了,目标明明开放却来不及回包,扫描结果就是一片“假关闭”;设长了,每次扫描耗时长,课程演示时机器容易卡在三五分钟没有任何输出。
我给你一个相对稳妥的经验:同一个交换机或同一台物理机上的虚拟机,RTT 通常不超过 2 毫秒,timeout 给 0.3 秒就够;跨机房、跨网段、经过防火墙的常规链路,timeout 给 1.5 到 2 秒;公网目标建议至少 3 秒,但课程设计里我不建议拿公网主机乱试。扫描总耗时大概可以按这个公式估算:端口数量除以并发线程数,再乘以超时时间。所以如果端口范围实在太大,优先考虑减少端口数,而不是盲目调低 timeout。
还有个细节值得单独说:connect_ex在超时后会抛出OSError的子类异常,而不是返回一个可读错误码。所以我在 3.1 节里用 try 包裹了整个调用的处理是必要的。你要判断“超时”和“连接被拒绝”其实可以进一步区分,但课程设计里把两者都归为“端口未开放”即可,因为监控系统的动作都是重新扫描或者告警,不会深究失败模式。
4.2 端口服务识别:不要只返回端口号,用 getservbyport 和 banner
只返回端口号,老师会问你“8080 到底是什么服务”。常见做法是调用socket.getservbyport()把端口翻译成服务名,比如 22 映射到 ssh,80 映射到 http,443 映射到 https。这个函数读的是系统服务表,在 Windows 和 Linux 上结果略有差异,但通常够用。
def guess_service(port: int) -> str: """尽力猜测端口对应的常见服务名,识别不了则返回 unknown。""" try: return socket.getservbyport(port) except OSError: return "unknown"如果你想在课程设计里拿一个加分项,可以再做 banner 抓取。很多服务在连接建立后,会主动打印一条版本信息,最典型的是 FTP、SSH、HTTP。抓到了版本,就可以结合端口共同判断服务是否异常。
def grab_banner(host: str, port: int, timeout: float = 2.0) -> str: """尝试读取目标端口返回的第一行 banner 信息。""" try: s = socket.create_connection((host, port), timeout=timeout) s.sendall(b"\r\n") banner = s.recv(256) s.close() return banner.decode("utf-8", "ignore").strip() except OSError: return ""注意事项是:不是所有服务都会主动发 banner,有的服务收到非法数据直接关连接,有的服务根本不理会\r\n,所以这个函数的返回值只能作为辅助证据,不能用它作为端口是否开放的唯一判断依据。建议把grab_banner单独放到一个函数里,只对已确认开放的端口调用,避免对大量关闭端口做无意义探测。
4.3 三种场景的参数模板:本机、教学机房、模拟靶场
课程设计一般会在三种环境里运行:自己的本机、教学机房局域网、两台虚拟机组成的模拟靶场。三种环境的参数完全不一样,我一般会在代码开头放一个参数配置区,统一维护。
| 场景 | 端口范围 | timeout | 线程数 | 说明 |
|---|---|---|---|---|
| 本机回环地址 | 1-65535 | 0.1-0.3 秒 | 256 | 扫描速度很快,适合功能自测 |
| 教学机房同一网段 | 1-1024 | 0.3-0.5 秒 | 128 | 并发不要太高,避免影响同机房其他机器 |
| 虚拟机模拟靶场 | 1-10000 | 1.0-2.0 秒 | 200 | 目标防火墙策略不一致,预留重试空间 |
表格里的 1-1024 和 1-10000 区间都只是一个建议,按课程要求改。如果你把教学机房所在的网段整个扫一遍,建议按单台主机逐个扫,并且每台主机之间留一点间隔,不要让同一秒内大量 SYN 包并发涌向整个机房。一个很常见的做法是加一个delay_between_hosts参数,单位秒,默认 0.2 秒,只在多主机扫描时生效。这个细节讲出去,至少能避免现场被其他正在上课的同学投诉网卡卡。
5. 端口扫描代码的常见翻车与排查:超时全灭、结果对不上、卡死
5.1 现象:所有端口都显示关闭或超时
这个现象我见过太多次。学生信心满满跑程序,结果 1 到 1024 端口全部超时,第一反应是“代码写错了”。其实代码大概率没问题,问题是目标主机不搭理你。
原因是目标机器的防火墙在默认策略下丢弃了陌生来源的连接请求;或者代码里写了host = "localhost",而目标服务只监听了 IPv6 的::1,你的 IPv4 socket 永远连不上;再或者两台机器虽然在同一网段,但互相之间被交换机的端口隔离策略挡住了。
解决步骤按顺序来:先用ping -c 3 目标IP确认网络可达;再用nc -zv -w 2 目标IP 具体端口单独测一个已知开放端口,比如目标机器上确定跑着的 22 或 80,确认业务端口正常;最后检查代码里连接的 IP 和实际目标机 IP 是否一致,尤其别把 WiFi 网卡的 IP 误当成了虚拟机 IP。如果 ping 不通但同网段内其他机器能通,就先去目标机器上看防火墙状态,在受控的教学环境里临时关闭防火墙可以辅助排查,但这是排查手段,不是最终方案,实验做完要记得还原。
5.2 现象:扫描结果和系统自带 netstat 对不上
扫描结果明明显示 8080 端口开放,用netstat -an看却找不到这个端口,于是怀疑代码误报。这种对不上,往往不是同一个概念。
原因有三个。第一,netstat默认显示 IPv4 和 IPv6 混合,你可能看到的是 TCPv6 的监听,而扫描器只探测了 IPv4。第二,Linux 上用ss -tlnp看到的 LISTEN 状态才表示端口处于可用状态,netstat里还可能有一批TIME_WAIT状态的连接记录,这些不代表端口开放,如果你把TIME_WAIT端口也当成开放端口,就会多出一堆假数据。第三,Windows 防火墙可能允许外部连接访问某个端口,但该端口并没有被服务监听,netstat同样不会显示,而扫描器从外部连接却成功了,这是因为防火墙的放行策略和服务的监听状态是两回事。
解决的办法是统一对比口径:在目标机上执行netstat -tlnp(Linux)或netstat -an | findstr LISTENING(Windows),只看 LISTEN 状态的端口,并且用同一个 IP 地址族和扫描器比对。如果在 Linux 上有 1000 个端口需要快速核对,用ss -tlnp | awk '{print $4}'提取端口列表,再和扫描结果做差集,这样就不会被连接状态干扰了。
5.3 现象:程序跑到一半卡死,像黑匣子一样没有输出
并发版本最容易出的问题就是:运行后终端一片空白,等十几分钟也不结束,让人完全不知道它到底是还在跑还是已经死了。这其实就是一个黑匣子问题,根本原因有三个:一是把 65535 个端口一次性全部丢进线程池,每个 future 都要排队,后面排队的任务还没执行,前面的结果也在堆积;二是 timeout 设了 5 秒,而目标机根本没有这个端口,每个连接都要等满超时;三是代码里把异常吞掉了,没打任何日志,程序实际上还在跑,但你完全感知不到。
解决的办法有三条:端口量大的时候分批提交,比如每 1000 个端口一个批次,批次之间做一次 sleep(0.2);timeout 遵循“能短则短”的原则,避免一个异常目标拖垮全部任务;在每扫描完一个批次后打印日志,输出当前已用时间、已扫描端口数、已发现开放端口数,这样任何时候都能判断程序是正常推进还是真的卡死了。把这三条写进去,课程演示的时候就不会出现全班同学盯着一个没有输出的终端等一分钟的尴尬场面。
5.4 现象:现场演示时被问到“这样扫描目标会发现吗”
课程设计答辩经常被追问一个战术问题:你的扫描会不会被目标发现。这不算 bug,但答不好会让前面所有工作扣印象分。
原因在于很多学生只写了代码,没想清楚端口扫描行为的本质。TCP connect 扫描会在目标机器上留下完整的连接记录,Web 服务器更会把每个连接写进访问日志,所以目标端完全可以发现扫描行为。SYN 半开扫描留下的痕迹少很多,但也不是零痕迹,而且需要 root 权限。课程设计题目是“网络安全终端监控”,核心价值是保护自己的终端,不是去探测别人的机器。
解决方案是两层:第一层,在代码里明确加一个授权校验,比如用常量AUTHORIZED_HOSTS限定可扫描的 IP 列表,遇到列表外的目标直接拒绝执行并打印提示;第二层,答辩时把话说明白:本代码用于受控实验环境的终端监控,不会拿去做未授权扫描。这两个回答已经能把问题接住了,还能顺势引出你对合规边界的理解。
6. 把端口扫描变成终端监控的核心模块:验证、落库与增量对比
6.1 用 SQLite 落库并做两次扫描的增量对比
端口扫描代码在课程设计里最自然的上游应用,是把结果存库,然后通过增量对比产生告警。我用 SQLite 做存储,不需要额外安装服务,代码也只依赖标准库 sqlite3。
import sqlite3 import json def save_scan(conn: sqlite3.Connection, record: dict): """把一条扫描记录写入 SQLite 数据库。""" conn.execute( """ CREATE TABLE IF NOT EXISTS scan_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, host TEXT, ts TEXT, open_ports TEXT ) """ ) conn.execute( "INSERT INTO scan_log (host, ts, open_ports) VALUES (?, ?, ?)", (record["host"], record["timestamp"], json.dumps(record["open_ports"])), ) conn.commit() def compare_last_two(conn: sqlite3.Connection, host: str): """取同一主机最近两次扫描结果,返回端口变化。""" rows = conn.execute( "SELECT open_ports FROM scan_log WHERE host=? ORDER BY id DESC LIMIT 2", (host,), ).fetchall() if len(rows) < 2: return "首次扫描,没有可比对记录" old = set(json.loads(rows[1][0])) new = set(json.loads(rows[0][0])) return { "新增端口": sorted(new - old), "消失端口": sorted(old - new), }这段代码把 JSON 直接存成文本字段,对于课程设计规模的数据量完全够用。compare_last_two返回的“新增端口”和“消失端口”就是终端监控模块的原始告警素材,你可以在此基础上加一个简单策略:发现高危端口(比如 23、3389、4444)新出现时,立刻输出告警;发现监控名目的业务端口消失时,也立刻输出告警。做到这一步,端口扫描代码就已经不是一个孤立工具,而是真正嵌进了监控系统。
6.2 在提交课程设计前做的三个自查验证
课程设计提交前,我会建议每个人都做一遍“背靠背验证”:用自己代码跑一遍,再拿系统命令或第三方工具跑一遍,两边结果对上才敢说代码正确。第一个验证是回环地址自测,在没有任何服务的 Windows 或 Linux 机器上扫描127.0.0.1,结果应该为空,然后随便启动一个本地服务,比如 Python 自带的python -m http.server 8000,再扫描一次,端口 8000 必须在开放列表里。第二个验证是找一台虚拟机或同机房机器,用你的代码和nmap -sT各扫描一次目标主机的同一个端口段,对比开放列表;不需要完全相同,但你的开放端口集合必须是对方开放端口集合的子集,多出来的就可能有问题。第三个验证是连续扫描同一目标五次,结果应该完全一致;如果五次结果忽多忽少,就要回头看 timeout 设置是否过短、线程并发是否过高、目标防火墙是否在做动态丢包。
我印象最深的一次翻车,是我自己做这个题目时把端口范围拉满到 65535,timeout 设成 3 秒,线程数 512,结果整个机房扫描持续了十几分钟,期间其他同学上课用的网络明显卡顿。后来我养成了一个习惯,也是对这门课最简单的建议:代码里永远保留一个AUTHORIZED_RANGES常量,只在明确授权的 IP 范围内执行扫描;单机演示默认从回环地址和本机局域网 IP 开始,不要一上来就扫整个网段。这个习惯让我后来做监控类项目时少踩了很多坑。希望这篇整理出的思路能帮到你,祝你的课程设计一次过。
本文还有配套的精品资源,点击获取