Articles / Networking

  • go
  • nmap
  • networking
  • ipv6
  • linux
  • debugging

Why Your Port Scan Misses Half the Open Ports on a Go Target

You run nmap against a box and it reports two open ports. Then ss on the box itself lists seven listeners. Nothing is lying: the scan and the kernel are answering different questions, and a Go program's defaults make the gap easy to fall into.

Here are the causes in rough order of how often they bite, plus the one command that settles it.

Start with the ground truth on the host

Before blaming the scanner, ask the kernel what is listening:

ss -lntup

That shows TCP and UDP listeners, the local address each is bound to, and the owning process. Every cause below appears as a mismatch between this list and what a scanner sees.

1. nmap only scans 1000 ports by default

This is the boring one and it is usually the answer. A default nmap run checks the 1000 most common ports, not all 65535. Go services tend to sit on 8080, 9090, or whatever number was picked during development, and plenty of those are not on the list.

nmap -p- 192.0.2.10

Use -p- before you conclude anything. It takes longer, which is the whole trade.

2. The listener is on a different address than the one you scanned

Go makes you say where to bind, and the string you pass decides who can reach it:

  • ":8080" binds the wildcard address, reachable on every interface.
  • "127.0.0.1:8080" is loopback only; a scan from another machine sees nothing.
  • "localhost:8080" resolves the name and binds a single address, normally the IPv4 loopback.
  • "10.0.0.5:8080" answers only on that one interface.

So a service that looks open from the host is closed from the LAN, and a scan from the host's own public IP can behave differently again. Scan from where your real clients are, and compare against the "Local Address" column in ss.

3. IPv4 versus IPv6

Quick detour, because this one is neat. With net.Listen("tcp", ":8080") Go normally opens one dual-stack socket on [::]:8080 that accepts IPv4 too. But "tcp4" gives you IPv4 only, and "tcp6" gives you IPv6 only.

package main

import (
	"log"
	"net"
)

func main() {
	// IPv6 only: an IPv4 scan will not see this.
	ln, err := net.Listen("tcp6", "[::]:9000")
	if err != nil {
		log.Fatal(err)
	}
	defer ln.Close()
	for {
		c, err := ln.Accept()
		if err != nil {
			log.Fatal(err)
		}
		c.Close()
	}
}

nmap scans IPv4 unless you pass -6, and the IPv6 address is a different target. Scan both when a host has both.

nmap -6 -p- 2001:db8::10

4. It is UDP, and you scanned TCP

A TCP scan will never find a UDP listener, because they are separate port spaces. Go's net.ListenPacket("udp", ...) or a DNS or QUIC service sits there invisibly. UDP scanning is slow and ambiguous (silence can mean open or filtered), so scan a targeted range:

sudo nmap -sU -p 53,443,5353 192.0.2.10

Note that HTTP/3 runs over UDP 443, so a Go server offering it can look closed on 443 to a TCP-only scan if it only serves QUIC there.

5. The scan itself overflowed the accept queue

Here is the subtle one. Each listening socket has a backlog of connections the kernel has completed but your code has not yet accepted. Go sizes it from /proc/sys/net/core/somaxconn at startup. If a fast scan (say -T5) arrives while the accept loop is slow or blocked, the queue fills and further SYNs are dropped.

A dropped SYN looks exactly like a firewall to the scanner, so the port is reported as filtered or missed. Re-scan more gently:

nmap -p- -T3 --max-retries 3 192.0.2.10

You can watch for it with nstat -az TcpExtListenOverflows TcpExtListenDrops on the target. If those counters climb during the scan, that was your culprit. Whether it matters in practice depends on your kernel settings and how quickly the handler accepts.

A quick checklist

  1. Run ss -lntup on the host and write down every port and bind address.
  2. Scan all ports with -p- from the network position of your real clients.
  3. Repeat with -6 if anything is bound to [::] or an IPv6 address.
  4. Scan UDP separately for the ports that ss showed under udp.
  5. Slow the scan down if the counters above moved.

If the lists still disagree after that, the remaining suspect is a firewall rule that treats scanners differently from real clients, and that is a separate afternoon.