Sixteen Bits That Pick the Application

An IP address gets a packet to a machine; a port gets it to the right program on that machine. TCP and UDP headers each carry a 16-bit source and destination port, so there are 65,536 of each, and TCP port 53 and UDP port 53 are completely separate. A connection is identified by the four-tuple of source IP, source port, destination IP and destination port, which is how one server port can carry thousands of simultaneous clients. The transport details are in TCP vs UDP; this lesson is about which numbers mean what, how to see who is using them, and why the numbers themselves protect nothing.

The Three Ranges

IANA divides the space by convention:

Range Name Used for
0-1023 well-known (system) standard services: SSH, DNS, HTTP, HTTPS
1024-49151 registered applications that registered a number: databases, RDP, many vendor tools
49152-65535 dynamic / private ephemeral client ports, by IANA's recommendation

Operating systems do not all follow the last row. Linux hands out ephemeral ports from 32768-60999 by default (sysctl net.ipv4.ip_local_port_range); Windows follows IANA, which you can confirm in PowerShell:

PS> Get-NetTCPSetting -SettingName Internet | Select DynamicPortRangeStartPort,DynamicPortRangeNumberOfPorts

DynamicPortRangeStartPort DynamicPortRangeNumberOfPorts
------------------------- -----------------------------
                    49152                         16384

On Linux, binding a port below 1024 requires root or the CAP_NET_BIND_SERVICE capability (the threshold is net.ipv4.ip_unprivileged_port_start). This is why web servers start as root, bind 80 and 443, then drop privileges, and why development servers default to 8000 or 8080. Windows has no such restriction.

The Ports You Will Actually Meet

Port Proto Service Notes
22 TCP SSH, SFTP brute-forced constantly if public
23 TCP Telnet cleartext; should not exist on modern networks
25 TCP SMTP server-to-server many ISPs and clouds block outbound 25
53 UDP + TCP DNS TCP for large answers and zone transfers
67 / 68 UDP DHCP server / client
80 TCP HTTP usually just redirects to 443
123 UDP NTP
143 / 993 TCP IMAP / IMAP over TLS
161 / 162 UDP SNMP / SNMP traps v1/v2c community strings are passwords in cleartext
389 / 636 TCP LDAP / LDAPS
443 TCP + UDP HTTPS; UDP 443 is HTTP/3 (QUIC) see HTTP/2 and HTTP/3
445 TCP SMB file sharing never expose to the internet
465 / 587 TCP SMTP submission (implicit TLS / STARTTLS) where mail clients send
500 / 4500 UDP IPsec IKE / NAT traversal
853 TCP DNS over TLS
1194 UDP OpenVPN default
1433 TCP Microsoft SQL Server
3306 TCP MySQL / MariaDB
3389 TCP + UDP RDP a favourite ransomware entry point
5353 UDP mDNS (.local names)
5432 TCP PostgreSQL
6379 TCP Redis historically no authentication by default
6443 TCP Kubernetes API server
8080 / 8443 TCP alternative HTTP / HTTPS dev servers, proxies, admin panels
9200 TCP Elasticsearch HTTP API frequent source of data leaks
27017 TCP MongoDB
51820 UDP WireGuard (common default)

Two patterns are worth memorising. Protocols that added TLS later often got a second port for the encrypted version (143/993, 389/636), while newer designs upgrade in place with STARTTLS or are encrypted from the start. And a port being listed as TCP tells you nothing about UDP: DNS, RDP and HTTPS use both.

The Services Database Is Just a Text File

The names tools print next to port numbers come from a local file, /etc/services on Unix and C:\Windows\System32\drivers\etc\services on Windows. Python reads the same file:

import socket

for name, proto in [("ssh", "tcp"), ("https", "tcp"), ("domain", "udp"), ("postgresql", "tcp")]:
    try:
        print(f"{name}/{proto} -> {socket.getservbyname(name, proto)}")
    except OSError as e:
        print(f"{name}/{proto} -> {e}")
print(socket.getservbyport(3389, "tcp"))

On Windows:

ssh/tcp -> 22
https/tcp -> 443
domain/udp -> 53
postgresql/tcp -> service/proto not found
ms-wbt-server

Most Linux distributions do list postgresql; Windows' file is shorter. The lesson is that these labels are local guesses. When netstat or a firewall log says https, it means "port 443", not "this traffic was HTTPS".

What Is Listening on This Machine?

An open port is almost always a program that called bind() (and listen() for TCP; see Socket Programming in Python). Finding it is the first step of every hardening job:

sudo ss -tulpn                               # Linux: TCP+UDP, listening, with process, numeric
sudo lsof -nP -iTCP -sTCP:LISTEN             # Linux/macOS: listeners per process
sudo lsof -nP -i :5432                       # who owns this one port?
netstat -ano | findstr LISTENING             # Windows: PID in the last column

A typical ss -tulpn on a small Linux web server:

Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:Port Process
udp   UNCONN 0      0      127.0.0.53%lo:53         0.0.0.0:*     users:(("systemd-resolve",pid=612,fd=13))
tcp   LISTEN 0      4096         0.0.0.0:22         0.0.0.0:*     users:(("sshd",pid=901,fd=3))
tcp   LISTEN 0      244        127.0.0.1:5432       0.0.0.0:*     users:(("postgres",pid=1022,fd=6))
tcp   LISTEN 0      511          0.0.0.0:443        0.0.0.0:*     users:(("nginx",pid=1200,fd=6))
tcp   LISTEN 0      4096               *:9100             *:*     users:(("node_exporter",pid=1310,fd=3))
tcp   LISTEN 0      4096            [::]:22            [::]:*     users:(("sshd",pid=901,fd=4))

The local address column matters as much as the port:

Bound to Reachable from
127.0.0.1, ::1, 127.0.0.53%lo this machine only
a specific interface address (10.0.1.5) networks that reach that interface
0.0.0.0 every IPv4 interface
[::] every IPv6 interface (IPv4 as well only if the socket is dual-stack)
* (in ss) a dual-stack socket: every IPv4 and IPv6 interface

So PostgreSQL above is safe by binding alone, while node_exporter on *:9100 answers anyone the firewall lets through. The PowerShell equivalent resolves process names for you:

PS> Get-NetTCPConnection -State Listen | Select LocalAddress,LocalPort,OwningProcess,
      @{n='Process';e={(Get-Process -Id $_.OwningProcess).ProcessName}}

LocalAddress LocalPort OwningProcess Process
------------ --------- ------------- -------
0.0.0.0            135          1592 svchost
::                 135          1592 svchost
::                 445             4 System

Checking from the machine shows what is listening; checking from another host shows what is reachable. Do both: nmap -Pn -p- host from outside, on systems you are authorised to scan, catches the firewall rule you forgot.

Ephemeral Ports: The Other Half of Every Connection

A client connecting to 203.0.113.10:443 gets a source port picked from the ephemeral range, and the reply comes back to that high port. This has practical consequences:

  • Stateless filters need a return rule. A stateful firewall remembers the outbound connection and allows the reply automatically. A stateless one, such as an AWS network ACL, must explicitly allow inbound traffic to the ephemeral range, or every outbound connection silently hangs.
  • Exhaustion. A proxy or scraper opening many short connections to one destination can run out of source ports; the cause and fix are in TCP Handshake and Control Flags.
  • Conflicts. A service configured to listen on a port inside the ephemeral range may fail to start at boot because an outgoing connection happened to grab that port first. Keep server ports out of it, or reserve them (net.ipv4.ip_local_reserved_ports on Linux).
  • NAT rewrites them. Your home router changes source ports as it translates, so the port a server logs is not the one your laptop chose.

Ports Are a Convention, Not a Security Control

Nothing forces a program on port 443 to speak HTTPS, or an SSH server to use 22. This local demo runs HTTP on 2222 and an SSH-style greeting on 8080, then identifies each by what it actually says:

import socket, threading
from http.server import HTTPServer, SimpleHTTPRequestHandler

class Quiet(SimpleHTTPRequestHandler):
    def log_message(self, *args): pass

# Two local services on misleading ports: HTTP on 2222, an SSH greeting on 8080.
http = HTTPServer(("127.0.0.1", 2222), Quiet)
threading.Thread(target=http.serve_forever, daemon=True).start()

fake_ssh = socket.create_server(("127.0.0.1", 8080))
def greet():
    while True:
        conn, _ = fake_ssh.accept()
        conn.sendall(b"SSH-2.0-OpenSSH_9.6\r\n")      # SSH servers speak first
        conn.close()
threading.Thread(target=greet, daemon=True).start()

def identify(host, port):
    with socket.create_connection((host, port), timeout=3) as s:
        s.settimeout(1.5)
        try:
            banner = s.recv(200)                       # server-speaks-first: SSH, SMTP, FTP
        except TimeoutError:
            s.sendall(b"HEAD / HTTP/1.0\r\n\r\n")      # silent: try a client-speaks-first protocol
            banner = s.recv(200)
    return banner.split(b"\r\n")[0].decode(errors="replace")

for port, usual in [(2222, "SSH"), (8080, "HTTP")]:
    print(f"port {port} (usually {usual}) answered: {identify('127.0.0.1', port)}")
port 2222 (usually SSH) answered: HTTP/1.0 200 OK
port 8080 (usually HTTP) answered: SSH-2.0-OpenSSH_9.6

The trick is the difference between protocols where the server speaks first (SSH, SMTP, FTP send a greeting on connect) and those where the client speaks first (HTTP, TLS). nmap -sV automates the same idea with a large probe database, and internet-wide scanners run it against every port. Three conclusions follow:

  • Moving SSH to a high port cuts log noise from lazy bots but does not stop anyone who scans all ports. It is not a substitute for key-only authentication.
  • "Allow TCP 443 outbound" allows anything that runs on 443. VPNs and tunnels use it precisely because it is always open. Controlling applications needs identification by content (next-generation firewalls, TLS inspection, or proxies), as the Network Security lesson explains.
  • A port on its standard number can still be something else. Treat port-based labels in logs as hints and confirm with the protocol.

Real protection comes from binding services to the right interface, default-deny firewalls, authentication, and encryption. Databases, caches, SMB, RDP and admin APIs should never be reachable from the internet at all; put them behind a VPN or bastion.

Practice

On a machine you control, run ss -tulpn (or the PowerShell command) and account for every listener: which program, why it runs, and whether its bind address is wider than it needs to be. Then scan the same machine from another host with nmap -Pn -p- -sV, and explain every difference between the two lists.