Skip to content

Lesson 2 of 9 · 20m

Ports, Sockets, and the Five-Tuple

How the kernel demultiplexes connections, why bind() fails, and the listening address that quietly exposes you.

Two engineers SSH into the same bastion, to the same port 22, from the same office IP. Both sessions work. Meanwhile your deploy script dies with EADDRINUSE: address already in use. Same machine, same protocol — one behavior is multiplexed perfectly, the other refuses to bind. The difference is the five-tuple, and once you see it, half of "the network is being weird" resolves itself.

The five-tuple

The kernel identifies a connection by five fields: protocol, source IP, source port, destination IP, destination port. Every socket you see in ss is one of these. Two connections to 10.0.0.5:22 coexist because the client side differs — different source ports. Ports aren't per-service scarcity; they're 16 bits of multiplexing space per IP pair. 10.0.0.5:22 and 127.0.0.1:22 are different sockets, which is why you can bind the same port on two interfaces at once.

ss -tlnp                       # listening TCP sockets, with the process
ss -tan 'dport = :22'          # everything headed for ssh
cat /proc/sys/net/ipv4/ip_local_port_range   # client ephemeral ports: 32768 60999

When your client connects, the kernel grabs a port from ip_local_port_range — roughly 28k ports on a default Linux box. That's your outbound connection budget per destination IP:port pair, and when you exhaust it you get "cannot assign requested address," not "connection refused."

Why bind() fails

EADDRINUSE means a socket exists whose local IP:port matches what you asked for. Two distinct causes:

  1. A listener already holds it. Find it with ss -tlnp 'sport = :8080'.
  2. TIME_WAIT residue. A closed connection lingers in TIME_WAIT (~60s, 2×MSL) so stray retransmits can't poison a new connection. ss -tan state time-wait will show them.

SO_REUSEADDR fixes #2 — it lets bind() succeed despite TIME_WAIT sockets on that port. It does not let two listeners share a port on Linux; that's SO_REUSEPORT, which load-balances incoming connections across the binds. Confusing the two is a classic interview question and a classic prod bug.

The binding that ships CVEs

Listen addresses are interfaces, not decoration:

s.bind(("127.0.0.1", 8080))   # loopback only — invisible to the network
s.bind(("0.0.0.0", 8080))     # EVERY interface, including the public one

The bug that ships: a debug server or metrics endpoint bound to 0.0.0.0 because "localhost didn't work in the container." Docker makes this sharper — docker run -p 8080:8080 publishes on 0.0.0.0 of the host. Write -p 127.0.0.1:8080:8080 if it shouldn't be internet-facing. More services get owned this way than by actual exploits.

Pitfalls

  • Ephemeral exhaustion: proxies and NAT'd clients burn a source port per connection. 28k ports ÷ 60s of TIME_WAIT = ~470 conn/s ceiling to one destination. Fix by reusing connections (keep-alive), widening the range, or adding client IPs — not by lowering TIME_WAIT.
  • netstat vs ss: same data, ss is faster and doesn't resolve by default (use -n anyway). netstat is deprecated on modern distros.
  • IPv6 wildcard covers IPv4 too: a listener on :: usually accepts IPv4-mapped connections (net.ipv6.bindv6only=0). Your "IPv6-only" bind may be more public than intended.

Try this

Run python3 -m http.server 8080 --bind 127.0.0.1, then from another host curl http://<ip>:8080 — refused. Rebind to 0.0.0.0 and watch it work. Check ss -tlnp both times and read the local address column: 127.0.0.1:8080 vs 0.0.0.0:8080. That column is your exposure boundary; learn to read it before you learn to fix it.