In previous articles, we discussed the design principles of Masscan and the engineering practices of Naabu. Today, we dive into the code level and use the Go language and Google’s powerful gopacket library to build a simple SYN Scanner with our own hands.

Why Not Just Use net.Dial?

In Go, the simplest way to check a port is to use net.Dial("tcp", "ip:port"). This corresponds to the OS’s Connect Scan:

  1. System sends SYN.
  2. Target replies SYN+ACK.
  3. System automatically replies ACK (handshake complete).
  4. Application layer Dial returns success.
  5. Application layer calls Close, sending FIN/RST.

Disadvantages of this method:

  • Slow: Completing the full three-way handshake increases Round-Trip Time (RTT).
  • Heavy: The OS kernel needs to maintain state (socket descriptors) for each connection, consuming significant resources. High concurrency can easily hit ulimit restrictions.
  • Noisy: Full connection logs are more likely to be recorded by the target host’s application layer logs.

Advantages of SYN Scan (Half-open Scan): We only send a SYN packet. If we receive a SYN+ACK, it means the port is open, and we then directly discard it or send a RST without establishing a connection. This requires bypassing the kernel protocol stack and operating directly on network card packets.

Prerequisites

We need to use libpcap (packet capture library) and gopacket (Go language wrapper).

1. Install libpcap

  • macOS: brew install libpcap
  • Ubuntu/Debian: sudo apt-get install libpcap-dev
  • Windows: Requires Npcap installation.

2. Import Go Dependency

go get github.com/google/gopacket

Core Implementation Steps

Implementing a SYN scanner mainly involves three steps:

  1. Construction: Manually build the data for the Ethernet layer, IP layer, and TCP layer.
  2. Sending: Send the binary data out through the network interface.
  3. Receiving: Listen to the network interface and filter out SYN+ACK packets returned by the target IP.

Step 1: Open Network Interface Handle

Use pcap to open the device, used for both sending and receiving.

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

// Open device, snapshotLen set to 65535, promiscuous set to true
handle, err := pcap.OpenLive("eth0", 65535, true, pcap.BlockForever)
if err != nil {
    log.Fatal(err)
}
defer handle.Close()

Step 2: Construct Packets

This is the most critical step. We need to encapsulate the protocols layer by layer like building blocks.

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
    // Note: Correct MAC address is needed for local LAN scanning
    // For cross-network scanning, DstMAC is usually the Gateway MAC
    ethLayer := &layers.Ethernet{
        SrcMAC:       net.HardwareAddr{0x00, 0x0c, 0x29, 0xbd, 0xda, 0xcf}, // Local MAC
        DstMAC:       net.HardwareAddr{0x00, 0x50, 0x56, 0xea, 0x8b, 0x98}, // Target/Gateway MAC
        EthernetType: layers.EthernetTypeIPv4,
    }

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

    // 3. TCP Layer
    tcpLayer := &layers.TCP{
        SrcPort: srcPort,
        DstPort: dstPort,
        Seq:     1105024, // Random Sequence Number
        SYN:     true,    // Key: Set SYN flag
        Window:  64240,
    }
    
    // Key Point: Set Network Layer info for TCP Checksum calculation
    // If not set, checksum will be 0 and packet will be dropped by target
    tcpLayer.SetNetworkLayerForChecksum(ipLayer)

    // 4. Serialize
    buffer := gopacket.NewSerializeBuffer()
    opts := gopacket.SerializeOptions{
        ComputeChecksums: true, // Automatically compute Checksum
        FixLengths:       true, // Automatically fix length fields
    }

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

Step 3: Sending and Receiving

Sending is simple, just call handle.WritePacketData(data). The difficulty lies in receiving and matching. We need an independent Goroutine to listen for return packets.

// Start listening Goroutine
go func() {
    // Set filter: Only receive TCP, and since we only care about 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() {
        // Parse TCP Layer
        if tcpLayer := packet.Layer(layers.LayerTypeTCP); tcpLayer != nil {
            tcp, _ := tcpLayer.(*layers.TCP)
            
            // Check if it is SYN+ACK
            if tcp.SYN && tcp.ACK {
                fmt.Printf("[+] Port %d is OPEN!\n", tcp.SrcPort)
            }
        }
    }
}()

Pitfalls and Considerations

1. Checksum Errors

This is the most common mistake for beginners. TCP checksum calculation depends on a “pseudo-header” (containing source/dest IP, protocol number, etc.). In gopacket, you must explicitly call tcpLayer.SetNetworkLayerForChecksum(ipLayer), otherwise the sent packet will be silently dropped by the receiver’s kernel, and you will never receive a response.

2. MAC Address Resolution

In the code above, we hardcoded the MAC address.

  • If the target is in the same LAN, you need to send an ARP request first to get the target’s MAC.
  • If the target is on the public internet, you need to fill in the Gateway’s MAC. If the MAC address is wrong, the packet will be discarded at the link layer.

3. System Permissions

Constructing raw packets (Raw Socket) requires Root privileges.

  • Linux/macOS: sudo go run main.go
  • Windows: Run as Administrator.

4. Local Firewall Interference

Sometimes you send the packet and the target replies, but your local iptables or firewall intercepts it, so the program doesn’t receive it. You may need to temporarily disable the firewall or add allow rules during testing.

Summary

Through gopacket, we bypass the OS connection management and “talk” to the target directly at the data link layer and network layer. This is the core prototype of high-speed scanners like Masscan and ZMap.

Of course, to implement a production-grade scanner, you also need to consider:

  • Concurrency Control (Using Token Bucket algorithm, refer to previous articles)
  • Automatic Route Discovery (Automatically getting Gateway MAC)
  • Fingerprinting (Parsing Payload)

I hope this article helps you open the door to low-level network programming in Go!