在之前的文章中,我们聊过 Masscan 的设计原理和 Naabu 的工程化实践。今天,我们切入代码层面,通过 Go 语言和 Google 强大的 gopacket 库,亲手打造一个简单的 SYN 扫描器。
为什么不能只用 net.Dial?
在 Go 语言中,最简单的端口检测方式是使用 net.Dial("tcp", "ip:port")。这对应的是操作系统的 全连接扫描(Connect Scan):
- 系统发送 SYN
- 目标回复 SYN+ACK
- 系统自动回复 ACK(握手完成)
- 应用层
Dial成功返回 - 应用层调用
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 扫描器主要分为三步:
- 构造:手动构建以太网层、IP 层和 TCP 层的数据。
- 发送:通过网卡将二进制数据发送出去。
- 接收:监听网卡,过滤出目标 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 网络底层编程的大门!