Network Scanning and Enumeration with Go
"Before a home inspector signs off on a house, they check every door, every window, and every outlet. Network scanning is that inspection, run against a machine instead of a building."
What Scanning and Enumeration Actually Mean
Scanning answers the question "what's reachable?" — which hosts are alive, which ports are open on them. Enumeration goes one level deeper: "what's actually running there?" — grabbing service banners, protocol versions, and other details that turn a bare port number into something you can assess for known weaknesses (Chapter 5.6).
Both are the first phase of nearly every security engagement, offensive or defensive. Attackers scan to find a way in. Defenders scan their own networks for the same reason: to find the way in before an attacker does, and to notice when something is listening that shouldn't be.
localhost, a VM you control, or a lab environment built for this purpose.How a TCP Connect Scan Works
The simplest and most portable scanning technique is the TCP connect scan: attempt a full three-way handshake against a port. If it completes, the port is open; if the connection is actively refused, the port is closed; if nothing comes back at all, it's likely filtered by a firewall.
Go's standard library makes this straightforward with net.DialTimeout, since it doesn't require raw sockets or elevated privileges the way a stealthier SYN scan (which sends a SYN and inspects the response without completing the handshake) would.
Go Implementation: A Concurrent Port Scanner
package main
import (
"context"
"fmt"
"net"
"sort"
"sync"
"time"
)
// scanPort attempts a TCP connect to host:port and reports whether it's open.
func scanPort(
ctx context.Context, host string, port int, timeout time.Duration,
) bool {
d := net.Dialer{Timeout: timeout}
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := d.DialContext(ctx, "tcp", address)
if err != nil {
return false // closed, filtered, or unreachable
}
conn.Close()
return true
}
// scanRange scans ports [start, end] on host with a bounded pool of workers,
// so we don't open thousands of goroutines/sockets at once.
func scanRange(host string, start, end, workers int, timeout time.Duration) []int {
ports := make(chan int, workers)
results := make(chan int)
var wg sync.WaitGroup
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for port := range ports {
if scanPort(ctx, host, port, timeout) {
results <- port
}
}
}()
}
go func() {
for p := start; p <= end; p++ {
ports <- p
}
close(ports)
wg.Wait()
close(results)
}()
var open []int
for p := range results {
open = append(open, p)
}
sort.Ints(open)
return open
}
func main() {
host := "127.0.0.1" // only scan hosts you own or are authorized to test
open := scanRange(host, 1, 1024, 200, 500*time.Millisecond)
fmt.Printf("Open ports on %s: %v\n", host, open)
}
A few design points worth calling out:
- Bounded concurrency: the worker pool (
workersgoroutines) prevents the scanner from opening thousands of simultaneous connections, which would look like — and functionally be — a denial-of-service attempt against the target. - Timeouts everywhere:
DialContextwith a short timeout keeps a single unresponsive port from stalling the whole scan. - Connect scan, not SYN scan: this approach only ever needs standard, unprivileged sockets, which is why it's the version worth teaching in portable Go — a raw SYN scanner needs
net.ListenIP/raw sockets, root orCAP_NET_RAW, and platform-specific handling that falls outside what the standard library offers directly.
Enumeration: Turning "Open" into "What"
Knowing port 22 is open tells you SSH is probably there. Enumeration confirms it and extracts detail by reading whatever the service says first — its banner:
package main
import (
"bufio"
"fmt"
"net"
"time"
)
// grabBanner connects to an open port and reads the first line the
// service sends, which is often enough to identify it (e.g. SSH
// servers announce "SSH-2.0-..." immediately on connect).
func grabBanner(host string, port int, timeout time.Duration) (string, error) {
address := net.JoinHostPort(host, fmt.Sprintf("%d", port))
conn, err := net.DialTimeout("tcp", address, timeout)
if err != nil {
return "", err
}
defer conn.Close()
conn.SetReadDeadline(time.Now().Add(timeout))
line, err := bufio.NewReader(conn).ReadString('\n')
if err != nil && line == "" {
return "", err
}
return line, nil
}
Not every service announces itself on connect — HTTP servers wait for a request first — so a thorough enumeration step sends a minimal protocol-appropriate probe (an HTTP HEAD /, for example) and reads the response headers rather than assuming a banner will just appear.
DNS enumeration is another common technique, using net.LookupHost, net.LookupMX, or net.LookupTXT from the standard library to map out a domain's associated infrastructure — useful during reconnaissance in an authorized penetration test (Chapter 5.8).
From Raw Results to Findings
A scan produces a list of open ports and banners. On its own, that's just data. Chapter 5.6 covers how to turn "port 21 open, running vsftpd 2.3.4" into an actual assessed finding — checking that version against known weaknesses and deciding whether it matters in context. Scanning tells you what's there; assessment tells you whether it's a problem.
Frequently Asked Questions
My scanner in this chapter can't do a SYN scan like nmap — is my code broken?
No, that's expected and by design. A TCP connect scan completes the full handshake using ordinary, unprivileged sockets, while a SYN scan deliberately stops halfway and needs raw sockets plus root or CAP_NET_RAW — capabilities the standard library doesn't expose portably. The connect scan is slightly noisier and slower, but it's the version that runs anywhere Go runs, which is exactly why it's the one worth learning first.
Why does the example scanner use a worker pool instead of just spawning a goroutine per port? Because thousands of simultaneous connection attempts against one host don't just look like a denial-of-service attempt — functionally, they are one. Bounding concurrency with a fixed number of workers keeps even a full 1-65535 scan from overwhelming the target or your own machine's socket table, which matters whether you're scanning a lab VM or, someday, a system under a signed engagement.
Is it actually illegal to scan a network I don't own, even if I never exploit what I find?
In many jurisdictions, yes — the Warning at the top of this chapter isn't boilerplate. Simply probing ports without authorization can itself be the offense under computer-crime statutes, independent of whatever you do (or don't do) with the results. Keep every scan pointed at localhost, a VM you control, or an environment you have written permission to test.
My banner grab against an HTTP server just hangs or returns nothing — what's going on?
That's normal, not a bug: unlike SSH, which announces itself the moment a connection opens, HTTP servers sit quietly waiting for the client to speak first. The fix is to send a minimal request yourself — an HTTP HEAD / is enough — and then read the response headers, rather than assuming a banner will arrive unprompted.
How does today's scanning tie into the "assessment" step Chapter 5.6 covers? Scanning and enumeration answer "what's reachable and what's running there," which produces raw data like "port 21 open, running vsftpd 2.3.4" — but that data isn't a finding on its own. Chapter 5.6 picks up exactly where this chapter stops, showing how to check that version against known weaknesses and decide whether it's actually a problem worth acting on.