在之前的文章中,我们聊过 Masscan 的设计原理和 Naabu 的工程化实践。今天,我们切入代码层面,通过 Go 语言和 Google 强大的 gopacket 库,亲手打造一个简单的 SYN 扫描器

为什么不能只用 net.Dial

在 Go 语言中,最简单的端口检测方式是使用 net.Dial("tcp", "ip:port")。这对应的是操作系统的 全连接扫描(Connect Scan)

  1. 系统发送 SYN
  2. 目标回复 SYN+ACK
  3. 系统自动回复 ACK(握手完成)
  4. 应用层 Dial 成功返回
  5. 应用层调用 Close,发送 FIN/RST

这种方式的缺点:

  • :必须完成完整的三次握手,增加了网络往返时间(RTT)。
  • :操作系统内核需要为每个连接维护状态(Socket 描述符),消耗资源多,高并发下容易触达 ulimit 限制。
  • :完整的连接日志更容易被目标主机的应用层日志记录。

SYN 扫描(半开扫描)的优势: 我们只发送 SYN 包,如果收到 SYN+ACK,说明端口开放,然后直接丢弃或发送 RST,不建立连接。这需要绕过内核协议栈,直接操作网卡数据包。

环境准备

我们需要用到 libpcap(数据包捕获库)和 gopacket(Go 语言封装)。

1. 安装 libpcap

  • macOS: brew install libpcap
  • Ubuntu/Debian: sudo apt-get install libpcap-dev
  • Windows: 需要安装 Npcap。

2. 引入 Go 依赖

go get github.com/google/gopacket

核心实现步骤

实现一个 SYN 扫描器主要分为三步:

  1. 构造:手动构建以太网层、IP 层和 TCP 层的数据。
  2. 发送:通过网卡将二进制数据发送出去。
  3. 接收:监听网卡,过滤出目标 IP 返回的 SYN+ACK 包。

第一步:打开网卡句柄

使用 pcap 打开设备,既用于发送也用于接收。

import (
    "github.com/google/gopacket/pcap"
)

// 打开网卡,snapshotLen 设为 65535,promiscuous 设为 true
handle, err := pcap.OpenLive("eth0", 65535, true, pcap.BlockForever)
if err != nil {
    log.Fatal(err)
}
defer handle.Close()

第二步:构造数据包

这是最关键的一步。我们需要像搭积木一样,一层层封装协议。

import (
    "net"
    "github.com/google/gopacket"
    "github.com/google/gopacket/layers"
)

func buildPacket(srcIP, dstIP net.IP, srcPort, dstPort layers.TCPPort) ([]byte, error) {
    // 1. 以太网层 (Ethernet Layer)
    // 注意:在本地局域网扫描时需要填写正确的 Mac 地址
    // 如果是跨网段扫描,DstMAC 通常是网关的 MAC
    ethLayer := &layers.Ethernet{
        SrcMAC:       net.HardwareAddr{0x00, 0x0c, 0x29, 0xbd, 0xda, 0xcf}, // 本机 MAC
        DstMAC:       net.HardwareAddr{0x00, 0x50, 0x56, 0xea, 0x8b, 0x98}, // 目标/网关 MAC
        EthernetType: layers.EthernetTypeIPv4,
    }

    // 2. IP 层 (IPv4 Layer)
    ipLayer := &layers.IPv4{
        SrcIP:    srcIP,
        DstIP:    dstIP,
        Version:  4,
        TTL:      64,
        Protocol: layers.IPProtocolTCP,
    }

    // 3. TCP 层 (TCP Layer)
    tcpLayer := &layers.TCP{
        SrcPort: srcPort,
        DstPort: dstPort,
        Seq:     1105024, // 随机序列号
        SYN:     true,    // 关键:设置 SYN 标志位
        Window:  64240,
    }
    
    // 关键点:设置 TCP 层的网络层信息,用于计算 Checksum
    // 如果不设置,校验和会为 0,数据包会被目标系统丢弃
    tcpLayer.SetNetworkLayerForChecksum(ipLayer)

    // 4. 序列化
    buffer := gopacket.NewSerializeBuffer()
    opts := gopacket.SerializeOptions{
        ComputeChecksums: true, // 自动计算 Checksum
        FixLengths:       true, // 自动修正长度字段
    }

    if err := gopacket.SerializeLayers(buffer, opts, ethLayer, ipLayer, tcpLayer); err != nil {
        return nil, err
    }
    
    return buffer.Bytes(), nil
}

第三步:发送与接收

发送很简单,直接调用 handle.WritePacketData(data)。 难点在于接收和匹配。我们需要一个独立的 Goroutine 来监听回包。

// 启动监听 Goroutine
go func() {
    // 设置过滤器:只接收 TCP 协议,且由于我们只关心 SYN+ACK
    // TCP Flags: FIN(1) SYN(2) RST(4) PSH(8) ACK(16) URG(32)
    // SYN+ACK = 2 + 16 = 18
    if err := handle.SetBPFFilter("tcp and src host " + targetIP); err != nil {
        log.Fatal(err)
    }
    
    packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
    for packet := range packetSource.Packets() {
        // 解析 TCP 层
        if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
            tcp, _ := tcpLayer.(*layers.TCP)
            
            // 判断是否为 SYN+ACK
            if tcp.SYN && tcp.ACK {
                fmt.Printf("[+] Port %d is OPEN!\n", tcp.SrcPort)
            }
        }
    }
}()

遇到的坑与注意事项

1. 校验和(Checksum)错误

这是新手最容易犯的错。TCP 校验和的计算依赖于“伪首部”(包含源/目的 IP、协议号等)。在 gopacket 中,必须显式调用 tcpLayer.SetNetworkLayerForChecksum(ipLayer),否则发出去的包会被接收方内核静默丢弃,导致你永远收不到回应。

2. MAC 地址解析

在上面的代码中,我们硬编码了 MAC 地址。

  • 如果目标在同一局域网,你需要先发 ARP 请求获取目标的 MAC。
  • 如果目标在公网,你需要填网关的 MAC。 如果 MAC 地址填错,数据包在链路层就会被丢弃。

3. 系统权限

构造原始数据包(Raw Socket)需要 Root 权限。

  • Linux/macOS: sudo go run main.go
  • Windows: 需要管理员身份运行。

4. 本地防火墙干扰

有时候你发出了包,目标也回了包,但被你本地的 iptables 或防火墙拦截了,导致程序收不到。测试时可能需要暂时关闭防火墙或添加放行规则。

总结

通过 gopacket,我们绕过了 OS 的连接管理,直接在数据链路层和网络层与目标“对话”。这就是 Masscan、ZMap 等高速扫描器的核心雏形。

当然,要实现一个生产级的扫描器,还需要考虑:

  • 并发控制(使用令牌桶算法,参考之前的文章)
  • 路由自动发现(自动获取网关 MAC)
  • 指纹识别(解析 Payload)

希望这篇文章能帮你推开 Go 网络底层编程的大门!