3. 网络基础与Socket编程

在安全自动化工程中,绝大多数资产发现、服务识别和漏洞探测任务,最终都要落到“向目标主机的某个端口发起一次网络交互”这个动作上。理解 TCP/UDP 的差异、掌握 Socket API 的调用方式,并据此写出一个最小可用的端口扫描器,是从脚本编写者迈向自动化工程师的关键一步。

一、TCP 与 UDP:两种传输语义决定了两种扫描策略

TCP 是面向连接的可靠传输协议。通信前必须经过三次握手:客户端发送 SYN,服务端回应 SYN+ACK,客户端再发送 ACK,连接才建立。这个“握手是否成功”本身就是极佳的可观测信号——如果目标端口开放,通常会返回 SYN+ACK;如果端口关闭,通常返回 RST;如果被防火墙丢弃,则表现为超时无响应。端口扫描的核心判断依据正是这三种响应。

UDP 是无连接协议,没有握手过程。向一个 UDP 端口发送数据报后,若端口关闭,目标通常返回 ICMP 端口不可达;若端口开放,服务可能回包,也可能因为协议设计而完全不响应。因此 UDP 扫描的可靠性天然低于 TCP,超时判定需要更长的等待时间,且“无响应”既可能是开放也可能是被过滤,必须结合业务特征二次确认。

从工程角度看,TCP 扫描适合快速、确定性地判断端口状态;UDP 扫描适合对 DNS、SNMP、NTP 等已知 UDP 服务做定向探测,而不适合大范围盲扫。

TCP 三次握手与端口状态判定信号 扫描端 (Client) 目标主机 (Server) ① SYN (seq=x) ② SYN+ACK (seq=y, ack=x+1) ③ ACK (ack=y+1) 开放:收到 SYN+ACK → 连接建立成功 关闭:收到 RST → 端口无服务监听 | 过滤:无响应/超时 → 防火墙丢弃

二、Socket API:Python 中的最小调用骨架

Python 的 socket 模块对 BSD Socket 做了薄封装。创建一个 TCP 客户端连接的核心调用链是:socket.socket(AF_INET, SOCK_STREAM) 创建套接字,connect((host, port)) 发起连接,成功后可用 send()/recv() 收发数据,最后 close() 释放资源。UDP 则使用 SOCK_DGRAM,通常配合 sendto()/recvfrom()。

在扫描场景中,我们并不需要真正完成三次握手。使用 connect_ex() 可以直接返回错误码而不抛出异常,非常适合批量判断:返回 0 表示连接成功(端口开放),返回 ECONNREFUSED(通常为 111)表示端口关闭,返回超时或 EHOSTUNREACH 则可能被过滤。另一个关键参数是 settimeout(),它决定了单次探测的等待上限,直接影响扫描速度与准确率的平衡。

import socket

def check_tcp_port(host, port, timeout=1.0):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(timeout)
    try:
        code = s.connect_ex((host, port))
        return code == 0, code
    finally:
        s.close()

# 示例:探测 127.0.0.1 的 22 与 9999 端口
for p in (22, 9999):
    ok, code = check_tcp_port("127.0.0.1", p)
    print(f"port {p}: open={ok}, errno={code}")

这段代码在本地回环地址上运行,若 22 端口有 SSH 服务,会输出 open=True, errno=0;若 9999 无监听,则输出 open=False, errno=111。这就是最小扫描器的判断闭环:输入是主机与端口,操作是发起连接并捕获错误码,结果是开放/关闭的布尔判定。

三、端口扫描原理与最小化扫描器实现

端口扫描的本质是“对目标主机的端口集合逐一发起探测,并根据响应分类”。按探测方式可分为 TCP 全连接扫描、TCP SYN 半开扫描、UDP 扫描等。全连接扫描直接调用 connect(),实现简单、无需原始套接字权限,但会在目标日志中留下完整连接记录;SYN 半开扫描需要构造原始报文,隐蔽性更好,但依赖 scapy 或原始套接字权限。

一个最小化但工程可用的扫描器,应当具备以下要素:目标与端口范围参数化、超时控制、并发或批量处理、结果分类输出。下面给出一个基于 concurrent.futures 的 TCP 全连接扫描器示例,它把“单端口探测”与“批量调度”解耦,便于后续扩展为服务识别或漏洞探测。

import socket
from concurrent.futures import ThreadPoolExecutor, as_completed

def scan_one(host, port, timeout=0.8):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(timeout)
    try:
        code = s.connect_ex((host, port))
        if code == 0:
            return port, "open"
        elif code == 111:
            return port, "closed"
        else:
            return port, f"filtered/errno={code}"
    except socket.timeout:
        return port, "timeout"
    finally:
        s.close()

def scan_range(host, start, end, workers=100):
    results = []
    with ThreadPoolExecutor(max_workers=workers) as pool:
        futures = {pool.submit(scan_one, host, p): p for p in range(start, end + 1)}
        for fut in as_completed(futures):
            results.append(fut.result())
    return sorted(results)

if __name__ == "__main__":
    for port, state in scan_range("127.0.0.1", 20, 100):
        if state == "open":
            print(f"[+] {port} {state}")

运行该脚本时,若本机开启了 22 端口,输出会包含 [+] 22 open;其余端口大多为 closed 或 timeout。这里有一个重要工程细节:workers 不宜过大,否则会触发目标主机的连接限制或本机文件描述符耗尽;timeout 也不宜过短,否则会把“慢响应但开放”的端口误判为 filtered。通常建议在局域网内使用 0.5~1 秒超时、100~200 并发;跨公网扫描则应降低并发并提高超时。

实践提示:扫描前务必确认授权范围。未经授权对公网主机进行端口扫描可能违反法律法规。教学与实验应限定在 127.0.0.1、本地虚拟机或明确授权的靶场环境中进行。

四、结果判读与常见误判

扫描结果不能只看“open/closed”两个标签。实际环境中,防火墙可能对关闭端口返回 RST,也可能直接丢弃;负载均衡或入侵检测系统可能对高频扫描进行限速或封禁,导致大量端口表现为 timeout。因此,当一次扫描出现“大面积 timeout”时,应优先怀疑网络路径或防护设备,而不是直接判定所有端口都被过滤。

一个实用的判读顺序是:先确认目标主机是否可达(如 ping 或对已知开放端口做单点探测),再观察开放端口是否符合业务预期,最后对 timeout 端口做降低并发、延长超时的复测。若复测后仍无响应,才可归类为 filtered。对于 UDP 扫描,还应结合具体服务做应用层验证,例如向 53 端口发送 DNS 查询、向 161 端口发送 SNMP get,而不能仅凭“有无 ICMP 不可达”下结论。