i7griffin/port-scanner

GitHub: i7griffin/port-scanner

一个分阶段迭代的 C++ 跨平台 TCP 端口扫描器教学项目,通过实现单端口检查和端口范围扫描来深入讲解 socket 编程原理与超时控制机制。

Stars: 0 | Forks: 0

# port-scanner # 第一阶段:单端口 TCP 连接检查 ## 目标 构建一个 C++ 程序,接收一个 IP 地址和一个端口号,尝试与该地址/端口组合建立 TCP 连接,并报告连接是否成功(端口开放)或失败(端口关闭/无法访问)。此阶段建立了所有后续阶段所需的基础 socket 编程概念和跨平台兼容性基础设施。 ## 核心概念 **TCP 三次握手** TCP 连接在任何数据传输之前,都会以三步握手开始: 1. 你的机器发送一个 SYN(同步)数据包以发起连接 2. 如果有程序正在监听该端口,远程机器会回复一个 SYN-ACK 数据包 3. 你的机器发送一个 ACK(确认)数据包以完成握手 现在连接已建立,可以进行数据传输。如果没有程序监听目标端口,远程操作系统会立即响应一个 RST(重置)数据包,拒绝连接。 `connect()` 函数封装了这些数据包交换过程。你的程序只需调用一次 `connect()`,内核就会在内部处理整个三次握手。该函数只有在握手完成(成功)或明确失败(端口关闭/无法访问/被过滤)后才会返回。 **Sockets** Socket 是一种操作系统级别的抽象,代表网络连接的一个端点。在系统层面上,它仅仅是一个小整数(在 Linux 上是文件描述符,在 Windows 上是句柄),操作系统将其用作引用。你的程序永远不会直接访问 socket 的内部状态——它只使用这个句柄来告诉内核要执行什么操作。 操作系统在内核内存中维护实际的 socket 状态(缓冲区、连接参数、协议状态等)。当你调用 `socket()` 时,内核会分配这些内部结构并返回句柄给你。当你调用 `connect()` 或 `close()` 等函数时,你需要传递这个句柄,以便内核知道要操作哪个 socket。 **网络字节序** CPU 根据架构以不同的顺序存储多字节整数(整数、地址、端口号)。大多数现代 CPU 使用小端序(最低有效字节在前),但网络协议标准化为大端序(最高有效字节在前),称为网络字节序。 端口号是 16 位值。在小端序(你的 CPU)中,端口 22 在内存中存储为 `[0x16, 0x00]`。在网络字节序中,它是 `[0x00, 0x16]`。如果你以机器的本地字节序发送端口号而不进行转换,远程机器会将其解析为完全不同的端口。 `htons()` 函数(“host to network short”)负责为端口号等 16 位值执行此转换。内核通过 `inet_pton()` 自动处理 IP 地址的字节序转换,但你必须显式地为端口号调用 `htons()`。 **地址结构** `sockaddr_in` 结构体将 IPv4 地址、端口和协议族打包到一个单一的数据结构中,格式完全符合操作系统内核的期望。其定义如下: ``` struct sockaddr_in { short sin_family; // Protocol family (AF_INET for IPv4) unsigned short sin_port; // Port number in network byte order struct in_addr sin_addr; // IPv4 address in binary form char sin_zero[8]; // Padding, must be zero }; ``` 嵌套在 `sockaddr_in` 中的 `in_addr` 结构体包含一个 32 位无符号整数,以二进制形式表示 IPv4 地址。 `sin_zero` 填充字段是为了与更大的通用 `sockaddr` 类型兼容所必需的。该字段必须显式清零——使用未初始化的填充字段在某些系统上会导致不可预测的行为。标准做法是在填充命名字段之前,使用 `memset()` 将整个结构体清零。 **跨平台 Socket 类型** 不同的操作系统以不同的方式表示 socket: - 在 Linux 上,socket 是一个普通的 `int`(文件描述符) - 在 Windows 上,socket 是一个 `SOCKET` 类型(无符号整数句柄) 无效的 socket 是: - 在 Linux 上为 `-1` - 在 Windows 上为 `INVALID_SOCKET` 常量 要编写在两个平台上都能完全一致编译的代码,请使用映射到正确的特定平台类型的类型别名和常量: ``` #ifdef _WIN32 using socket_t = SOCKET; const socket_t INVALID_SOCK = INVALID_SOCKET; #else using socket_t = int; const socket_t INVALID_SOCK = -1; #endif ``` 然后所有 socket 变量都使用 `socket_t`,预处理器会自动为每个平台选择正确的基础类型。 **Windows Winsock 初始化** 在 Linux 上,sockets 是始终可用的核心内核功能——不需要初始化。在 Windows 上,网络层(Winsock)是一个独立的组件,必须在使用任何 socket 函数之前显式初始化。 `WSAStartup()` 函数初始化 Winsock,而 `WSACleanup()` 释放它。如果不先调用 `WSAStartup()` 就调用任何 socket 函数,会导致 `WSANOTINITIALISED` 错误。 Linux 没有与这些函数等价的实现——sockets 直接就能工作。将两者封装在带有 `#ifdef` 守卫的 `initSockets()` 和 `cleanupSockets()` 函数中,以便 `main()` 保持与平台无关。 **错误报告** 平台差异也延伸到了错误报告: - 在 Linux 上,socket 函数失败时会设置全局的 `errno` 变量。使用 `strerror(errno)` 获取人类可读的错误消息。 - 在 Windows 上,socket 函数失败时调用 `WSAGetLastError()` 来获取错误代码。如果不使用更复杂的 API,就没有 `strerror()` 的直接等价物。 将两者封装在一个返回字符串的 `getSocketError()` 函数中,允许程序的其余部分在不使用特定平台代码的情况下报告错误。 ## 你使用的关键函数 **`socket(AF_INET, SOCK_STREAM, 0)`** — 创建一个新的 socket。接收地址族(IPv4 为 `AF_INET`)、socket 类型(TCP 为 `SOCK_STREAM`)和协议(0 表示让操作系统选择)。成功时返回一个 socket 句柄,失败时返回 `INVALID_SOCK`。这必须在任何其他 socket 操作之前被调用。 **`htons(port)`** — 将 16 位端口号从主机字节序转换为网络字节序。在将端口放入 `sin_port` 之前,这是必不可少的。省略此操作是一个典型的 bug,会悄无声息地连接到错误的端口。 **`inet_pton(AF_INET, "192.168.1.1", &addr.sin_addr)`** — 将人类可读的 IPv4 地址字符串转换为其 32 位二进制表示形式。接收地址族、字符串和指向目标的指针。成功时返回 1,如果字符串不是有效的 IP 地址则返回 0,发生其他错误时返回 -1。自动处理字节顺序转换,因此不需要单独调用 `htonl()`。 **`memset(&addr, 0, sizeof(addr))`** — 用零填充地址结构体。在填充命名字段之前,这是必不可少的,因为它确保了填充(`sin_zero`)被正确初始化。如果填充未初始化,某些系统会出现未定义行为。 **`connect(socket_fd, (struct sockaddr*)&addr, sizeof(addr))`** — 发起与 `sockaddr_in` 结构体中指定地址的 TCP 三次握手。第二个参数被转换为函数签名期望的通用 `sockaddr*` 类型(内存兼容的转换)。连接成功(端口开放)时返回 0,失败(端口关闭/被过滤/无法访问)时返回 -1。会一直阻塞,直到握手完成或操作系统超时(通常为 20–60 秒)。 **`WSAStartup(MAKEWORD(2, 2), &wsaData)`**(仅限 Windows)— 初始化 Winsock。宏 `MAKEWORD(2, 2)` 指定版本 2.2(当前标准)。`wsaData` 结构体接收有关 Winsock 实现的信息。成功时返回 0。在 Windows 上必须在任何其他 socket 函数之前调用。 **`WSAGetLastError()`**(Windows) / **`strerror(errno)`**(Linux)— 在 socket 函数失败后检索人类可读的错误描述。Windows 和 Linux 使用不同的机制,因此将两者封装在一个辅助函数中。 **`closesocket()`**(Windows) / **`close()`**(Linux)— 将 socket 句柄释放回操作系统。不调用此函数会导致文件描述符泄漏。即使在错误路径中也必须调用它以确保清理。 ## 程序结构 ``` main() ├─ initSockets() // Windows: WSAStartup, Linux: nothing ├─ Get IP address from user (string input) ├─ Get port number from user (int input) ├─ Declare and zero sockaddr_in struct ├─ Fill address struct: │ ├─ sin_family = AF_INET │ ├─ sin_port = htons(port) │ └─ inet_pton(AF_INET, ip, &addr.sin_addr) ├─ Check inet_pton() result: │ ├─ Fail → Print error and exit early │ └─ Success → Continue ├─ createsocket() ├─ Check socket creation: │ ├─ Fail → Print error and exit early │ └─ Success → Continue ├─ connect(socket, &addr, sizeof(addr)) ├─ Check result: │ ├─ 0 → Print "Port X is OPEN" │ └─ -1 → Print "Port X is CLOSED" ├─ closeSocket(socket) ├─ cleanupSockets() // Windows: WSACleanup, Linux: nothing └─ return 0 ``` ## 如何编译和运行 **命令行(Windows 上的 PowerShell):** ``` g++ -o build/scanner src/main.cpp -lws2_32 .\build\scanner ``` **命令行(Linux 上的 Bash):** ``` g++ -o build/scanner src/main.cpp ./build/scanner ``` **使用 Makefile:** ``` make .\build\scanner ``` `-lws2_32` 标志(仅限 Windows)链接到 Winsock 库。这会告诉链接器去哪里寻找 socket 函数的编译实现。Makefile 可以根据平台检测有条件地包含此标志。 ## 测试第一阶段 **识别你机器上的开放端口:** ``` netstat -ano | findstr LISTENING ``` 这会列出服务正在积极监听的端口,适合用于测试成功的情况。 **使用已知的开放端口进行测试:** ``` Enter IP: 127.0.0.1 Enter port: [any port from netstat output, e.g., 22 if SSH is running] Expected output: Port 22 is OPEN ``` **使用已知的关闭端口进行测试:** ``` Enter IP: 127.0.0.1 Enter port: 9999 (assuming nothing listens here) Expected output: Port 9999 is CLOSED ``` **重要观察:** 如果程序在打印“CLOSED”之前挂起 20 多秒,说明你遇到了一个被过滤的端口(防火墙静默丢弃数据包而不是拒绝它们)。这种行为在第一阶段是预期的,而第二阶段将通过自定义超时来解决这个问题。 ## 第一阶段没有做到的事情 - **不支持端口范围** — 每次程序执行只检查一个端口 - **无并发** — 顺序执行;扩展性差 - **无自定义超时** — 使用操作系统默认值(通常为 20–60 秒),导致在被过滤的端口上长时间挂起 - **无服务识别** — 仅报告开放/关闭,而不报告正在运行什么服务 - **无命令行解析** — 需要在执行期间输入用户信息 - **无输出选项** — 结果仅打印到控制台 - **无 DNS 解析** — 需要 IP 地址,而不是主机名 ## 性能特征 - **开放端口:** ~0.1 秒(即时内核确认) - **关闭端口:** ~0.1–0.2 秒(内核接收并处理 RST) - **被过滤的端口:** 20–60+ 秒(操作系统超时,等待永远不会到来的响应) ## 第一阶段检查点 当满足以下条件时,第一阶段即完成: - 程序在 Windows 和 Linux 上均成功编译 - 针对测试端口正确地将端口报告为 OPEN 或 CLOSED - 正确清理资源(sockets 已关闭) - 接受用户输入的 IP 和端口 - 优雅地处理无效的 IP 地址 - 优雅地处理 socket 创建失败的情况 - 编译和运行时 `main()` 中不需要特定平台的代码 # 第二阶段:端口范围 + 超时处理 ## 目标 扩展单端口扫描器,以顺序扫描一系列端口(例如 1–1024),并带有自定义超时控制。通过使用非阻塞 sockets 和 `select()` 来等待有限的时间(每个端口 1–2 秒),而不是操作系统的默认时间(20–60+ 秒),从而消除在被过滤端口上的无限期挂起。 ## 核心概念 **非阻塞 Sockets** 在第一阶段中创建的 socket 是阻塞的——当你调用 `connect()` 时,该函数直到连接成功、明确失败并报错,或者使用操作系统的默认超时(通常为 20–60 秒)超时之前都不会返回。这对于端口扫描来说是有问题的:单个被过滤的端口可能会使整个扫描停滞一分钟以上。 非阻塞 socket 会立即从 `connect()` 返回,即使连接尚未完成。实际的连接尝试在后台继续,由操作系统内核管理。然后你使用 `select()` 并配合你自己的超时来检查连接状态。 在 Windows 上,通过 `ioctlsocket(sock, FIONBIO, &mode)`(其中 `mode = 1`)启用非阻塞。 在 Linux 上,通过 `fcntl(sock, F_SETFL, flags | O_NONBLOCK)` 启用非阻塞。 **select() 函数** `select()` 是一个阻塞调用,它等待一个或多个 sockets 上的网络活动,并带有你指定的超时时间。你传递给它: - 一个或多个文件描述符集合(sockets 的集合) - 一个超时持续时间(秒和微秒) `select()` 会阻塞,直到发生以下三种情况之一: 1. 被监视的某个 socket 发生活动(它会立即返回活动 sockets 的数量) 2. 超时到期(它返回 0) 3. 发生错误(它返回 -1) 对于端口扫描器,你需要同时监视每个 socket 上的两个条件:可写(连接成功)或出错(连接失败)。你创建两个 fd_sets(`writeSet` 和 `errorSet`),将 socket 添加到两者中,并以 1–2 秒的超时时间调用 `select()`。 **文件描述符集合 (fd_set)** `fd_set` 是一种代表文件描述符(Windows/Linux 上的 sockets)集合的数据结构。你使用宏来操作它: - `FD_ZERO(&set)` — 清空集合(使其为空) - `FD_SET(sock, &set)` — 将一个 socket 添加到集合中 - `FD_CLR(sock, &set)` — 从集合中移除一个 socket 在 `select()` 返回后,你使用 `FD_ISSET(sock, &set)` 检查哪些 sockets 在哪些集合中,以确定发生了什么。 **使用 getsockopt() 检查连接结果** 在 `select()` 指示一个 socket 已准备就绪后,你调用 `getsockopt(sock, SOL_SOCKET, SO_ERROR, &error, &errorLen 来读取存储在该 socket 上的实际错误代码。这会告诉你: - `error == 0` → 连接成功(端口为 OPEN) - `error == ECONNREFUSED` → 连接被拒绝(端口为 CLOSED) - 其他错误代码 → 由于其他原因连接失败(端口为 FILTERED 或无法访问) **超时问题与解决方案** 第一阶段在被过滤的端口上每个端口可能会挂起 20–60+ 秒,因为 `connect()` 使用的是操作系统默认的 TCP 超时。如果一半的端口被过滤,一个 1024 端口的扫描可能需要 30 多分钟。 第二阶段通过以下方式解决了这个问题: 1. 将 sockets 设置为非阻塞,以便 `connect()` 立即返回 2. 使用带有自定义 1–2 秒超时的 `select()` 3. 如果 `select()` 超时,立即移动到下一个端口 4. 如果 `select()` 返回,检查实际结果并继续 一个完整的 1–1024 端口扫描现在可以在几秒钟内完成,无论是否存在被过滤的端口。 ## 你使用的关键函数 **`setNonBlocking(socket_t sock)`** — 将 socket 设置为非阻塞模式。在 Windows 上调用 `ioctlsocket()`,在 Linux 上调用 `fcntl()`。在此之后,`connect()` 会立即返回,而不是无限期阻塞。 **`waitForConnection(socket_t sock, int timeoutSeconds)`** — 创建两个 fd_sets(writeSet 和 errorSet),将 socket 添加到两者中,并带超时调用 `select()`。如果 socket 准备就绪(连接已完成,无论成功还是失败)则返回 true,如果超时已过则返回 false。 **`checkConnectionResult(socket_t sock)`** — 调用 `getsockopt(SO_ERROR)` 从 socket 读取实际结果。如果错误为 0(端口开放)则返回 true,否则返回 false(端口关闭/被过滤)。 **`htons(port)`** — 将端口号转换为网络字节序。在循环中更新 `addr.sin_port` 之前对每个端口调用。 **`connect(sock, &addr, sizeof(addr))`** — 与第一阶段相同,但现在在非阻塞 sockets 上会立即返回 EINPROGRESS 或 EWOULDBLOCK 错误(意思是“连接正在进行中”)。 ## 程序结构 ``` main() ├─ initSockets() // Windows: WSAStartup ├─ Get IP from user ├─ Get port range (startPort, endPort) ├─ Set up address struct (once) │ ├─ memset │ ├─ sin_family = AF_INET │ └─ inet_pton() for IP │ ├─ Loop over port range: │ ├─ createsocket() │ ├─ setNonBlocking(sock) │ ├─ Update addr.sin_port = htons(port) │ ├─ connect(sock, &addr) │ │ └─ Returns immediately with EINPROGRESS │ ├─ waitForConnection(sock, 2 seconds) │ │ ├─ True → checkConnectionResult() │ │ │ ├─ True → Print "OPEN" │ │ │ └─ False → Print "CLOSED" │ │ └─ False (timeout) → Print "FILTERED" │ └─ closeSocket(sock) │ ├─ cleanupSockets() // Windows: WSACleanup └─ return 0 ``` ## 如何编译和运行 命令行(Windows 上的 PowerShell): ``` g++ -o build/scanner src/main.cpp -lws2_32 .\build\scanner ``` 然后输入: - IP 地址(例如 `127.0.0.1`) - 起始端口(例如 `1`) - 结束端口(例如 `1024`) 使用 Makefile: ``` make .\build\scanner ``` `-lws2_32` 标志链接到 Windows Winsock 库(与第一阶段相同)。 ## 测试第二阶段 **在 localhost 上扫描一个小范围:** ``` IP: 127.0.0.1 Start port: 1 End port: 1024 ``` 预期行为: - 扫描在 3–5 秒内完成(而不是 20 多分钟) - 显示 OPEN、CLOSED 和 FILTERED 端口的混合情况 - 示例输出: Port 135 is OPEN Port 137 is CLOSED Port 445 is OPEN Port 1 is FILTERED Port 2 is FILTERED ... **验证超时是否有效:** 添加调试输出(打印开始和结束时间),以确认无论有多少端口未返回响应,扫描都能在可预期的时间内完成。 **确认资源清理:** 背靠背连续运行多次扫描。如果程序泄漏文件描述符,它最终会因“too many open files”而失败。正确的清理(每次迭代都关闭 socket)应该允许无限制的连续扫描。 ## 第二阶段没有做到的事情 - **不支持多线程** — 仍然一次顺序扫描一个端口。第三阶段添加此功能。 - **无 banner grabbing** — 仍然仅报告开放/关闭,没有服务识别。 - **无 DNS 解析** — 仍然需要 IP 地址,而不是主机名。 - **无输出到文件** — 结果仅打印到控制台。 - **无扫描统计** — 没有时间信息或汇总报告。 - **无端口名称映射** — 不显示与端口号关联的常见服务名称(SSH、HTTP 等)。 ## 性能特征 - **单端口(第一阶段):** ~0.1 秒(即时成功/失败)到 2 秒(带超时的被过滤端口) - **1024 个端口(第二阶段,顺序执行):** 有许多响应端口时约 10–20 秒,如果超时时间非常长且有许多被过滤的端口,最多约 2000 秒。典型的本地扫描:3–5 秒。 - **多线程优势(第三阶段):** 带有 50 个工作线程的 1024 个端口:约 5–10 秒,与响应率无关,因为有 50 个端口在并行扫描。 ## 第二阶段检查点 当满足以下条件时,第二阶段即完成: - 扫描可配置的端口范围而不会挂起 - 在可预期的时间内完成(几秒钟,而不是几分钟) - 正确识别开放、关闭和被过滤的端口 - 正确关闭所有 sockets(无资源泄漏) - 在 Windows 和 Linux 上使用相同的代码均可运行
标签:C++, DNS查询工具, Socket编程, TCP/IP, 插件系统, 数据擦除, 数据泄露防护, 端口扫描器, 网络工具, 网络探测