Step 03 of 25

Networking essentials

Trace a connection from DNS lookup through the TCP and TLS handshakes, find what's listening with ss, and lock a host down with a firewall.

18 min 8 sections

Most attacks arrive over the network, and most security controls (firewalls, TLS, security groups, network policies) are network controls. To configure them correctly, and to debug them when they break, you need a working model of what happens when one machine talks to another. In this lesson you'll follow a single HTTPS request from name lookup to encrypted response, using the same tools you'll reach for in real investigations: dig, tcpdump, ss, curl -v, openssl, ufw and nftables.

Addresses, ports and protocols

Every machine on a network has an IP address. IPv4 addresses look like 203.0.113.10; IPv6 addresses look like 2001:db8::10. Ranges are written in CIDR notation: 10.0.0.0/8 means "the first 8 bits are fixed", so it covers 10.0.0.0 to 10.255.255.255. A /24 such as 198.51.100.0/24 is 256 addresses; a /32 is exactly one.

Some ranges are reserved, and you'll see them constantly in firewall rules and SSRF defences:

RangeMeaning
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16Private networks (not routed on the internet)
127.0.0.0/8, ::1Loopback: this machine only
169.254.0.0/16Link-local; includes the cloud metadata endpoint 169.254.169.254
0.0.0.0In a listening socket: "every interface on this machine"

An address gets a packet to a machine; a port (0 to 65535) gets it to a specific program on that machine. Two transport protocols carry almost everything:

  • TCP gives a reliable, ordered byte stream. A connection must be set up first. HTTP, SSH and database protocols use it.
  • UDP sends independent datagrams with no connection and no delivery guarantee. DNS queries, many VPNs and HTTP/3 (QUIC) use it.

Ports you should recognise on sight: 22 (SSH), 53 (DNS), 80 (HTTP), 443 (HTTPS), 5432 (PostgreSQL), 3306 (MySQL), 6379 (Redis), 9200 (Elasticsearch). The last few should almost never be reachable from the internet; exposed databases and caches are a leading cause of data breaches.

The TCP three-way handshake

Before any data flows, TCP sets up a connection in three steps. You can watch it with tcpdump while curl connects to a local server on port 8443:

text
$ sudo tcpdump -i lo -n 'tcp port 8443'
09:36:17.278552 IP 127.0.0.1.47328 > 127.0.0.1.8443: Flags [S], seq 2676987039, win 65495, length 0
09:36:17.278567 IP 127.0.0.1.8443 > 127.0.0.1.47328: Flags [S.], seq 1152219584, ack 2676987040, win 65483, length 0
09:36:17.278583 IP 127.0.0.1.47328 > 127.0.0.1.8443: Flags [.], ack 1, win 64, length 0
09:36:17.280011 IP 127.0.0.1.47328 > 127.0.0.1.8443: Flags [P.], seq 1:518, ack 1, win 64, length 517

(TCP options trimmed.) Read the flags:

  1. SYN ([S]): the client, using a random high port 47328, asks to connect and picks a starting sequence number.
  2. SYN-ACK ([S.]): the server agrees, picks its own sequence number, and acknowledges the client's (ack 2676987040, the client's number plus one).
  3. ACK ([.]): the client acknowledges the server. The connection is established.

The fourth packet ([P.], push) carries the first 517 bytes of data, which here is the start of the TLS handshake.

The same exchange as a diagram, using the sequence numbers from the capture above. Step through it and watch each side acknowledge the other's number plus one.

DiagramTCP three-way handshake (curl to port 8443)
Client (port 47328)Server (port 8443)1SYN seq=26769870392SYN-ACK seq=1152219584 ack=26769870403ACK ack=11522195854Established after one round trip5PSH, 517 bytes: TLS ClientHello

Refused versus filtered

What happens when nothing is listening tells you a lot. If the port is closed, the kernel answers the SYN with a reset (RST) immediately:

text
$ nc -zv 127.0.0.1 9999
nc: connect to 127.0.0.1 port 9999 (tcp) failed: Connection refused

09:36:27.335758 IP 127.0.0.1.33528 > 127.0.0.1.9999: Flags [S], seq 241177951, length 0
09:36:27.335772 IP 127.0.0.1.9999 > 127.0.0.1.33528: Flags [R.], seq 0, ack 241177952, win 0, length 0

If a firewall drops the SYN instead, nothing comes back and the client retries until it times out. So "connection refused" means you reached the machine but nothing listens on that port; "connection timed out" usually means a firewall or security group is in the way, or the host is down. That one distinction saves hours of debugging.

What is listening: ss

ss (socket statistics) replaced the older netstat. The flags you'll use most are -t (TCP), -u (UDP), -l (listening only), -n (numeric, no name lookups) and -p (show the owning process, needs root for other users' processes):

text
$ sudo ss -tlnp
State   Recv-Q  Send-Q   Local Address:Port    Peer Address:Port  Process
LISTEN  0       4096         127.0.0.1:5432         0.0.0.0:*      users:(("postgres",pid=812,fd=6))
LISTEN  0       511            0.0.0.0:80           0.0.0.0:*      users:(("nginx",pid=975,fd=6))
LISTEN  0       511            0.0.0.0:443          0.0.0.0:*      users:(("nginx",pid=975,fd=7))
LISTEN  0       2048         127.0.0.1:8000         0.0.0.0:*      users:(("gunicorn",pid=1842,fd=5))
LISTEN  0       128            0.0.0.0:22           0.0.0.0:*      users:(("sshd",pid=701,fd=3))
LISTEN  0       511            0.0.0.0:6379         0.0.0.0:*      users:(("redis-server",pid=1203,fd=6))

The local address is the key security column. 127.0.0.1:5432 means PostgreSQL only accepts connections from this machine. 0.0.0.0:6379 means Redis accepts connections on every interface, including the public one. Unless you specifically need remote access, bind services to 127.0.0.1 (or a private interface) in their own configuration, and also block the port in the firewall. Two independent layers is defense in depth.

ss -tnp without -l shows established connections, which is useful when you suspect a process is talking to somewhere it shouldn't.

DNS: turning names into addresses

Before connecting, the client resolves the hostname. dig shows exactly what DNS returns:

text
$ dig api.example.com A

; <<>> DiG 9.18.39-0ubuntu0.24.04.7-Ubuntu <<>> api.example.com A
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 41872
;; flags: qr rd ra; QUERY: 1, ANSWER: 2, AUTHORITY: 0, ADDITIONAL: 1

;; QUESTION SECTION:
;api.example.com.               IN      A

;; ANSWER SECTION:
api.example.com.        300     IN      CNAME   api-prod.eu.example.net.
api-prod.eu.example.net. 60     IN      A       203.0.113.10

;; Query time: 23 msec
;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP)

status: NOERROR means the name exists (NXDOMAIN means it doesn't). The answer shows a CNAME (an alias) pointing at another name, which has an A record with the IPv4 address. The numbers 300 and 60 are TTLs: how many seconds resolvers may cache each answer. SERVER is the resolver that answered; on Ubuntu, 127.0.0.53 is the local systemd-resolved stub.

Record types worth knowing:

TypeHoldsSecurity relevance
A / AAAAIPv4 / IPv6 addressWhere traffic actually goes
CNAMEAlias to another nameDangling CNAMEs enable subdomain takeover
MXMail serversPhishing and spoofing defence starts here
TXTFree textSPF, DMARC and domain-verification tokens
NSAuthoritative name serversWhoever controls these controls the domain
CAAWhich CAs may issue certificatesLimits who can get a certificate for your name

Useful variants: dig +short example.com prints only the answers, dig @1.1.1.1 example.com asks a specific resolver, and dig +trace example.com follows the delegation from the root servers down.

TLS: encryption and identity

Plain TCP is readable and modifiable by anyone on the path. TLS adds three guarantees: confidentiality (encryption), integrity (tampering is detected) and authentication (you are talking to the real api.example.com). The last one is the part people misconfigure.

A TLS 1.3 handshake takes one round trip:

  1. ClientHello: supported cipher suites, a key share for key exchange, the hostname it wants (SNI), and the application protocols it speaks (ALPN, e.g. h2).
  2. ServerHello: the chosen cipher and the server's key share. Both sides now derive the same session keys; everything after this is encrypted.
  3. Certificate and CertificateVerify: the server sends its certificate chain and signs the handshake with its private key, proving it owns the certificate.
  4. Finished from both sides confirms nobody tampered with the handshake.

Here is that handshake message by message, over a TCP connection that is already open. Braces mark messages encrypted with the handshake keys and square brackets mark application data, as in the TLS 1.3 specification. Notice that the client sends its key share in its very first message, which is what lets TLS 1.3 finish in one round trip.

DiagramTLS 1.3 full handshake (1-RTT)
ClientServer1ClientHello: key_share, SNI, ALPN2ServerHello: chosen cipher, key_share3{EncryptedExtensions}: ALPN h24{Certificate}: chain for api.example.com5{CertificateVerify}: signature6{Finished}7Validate chain, dates, SAN; check signature8{Finished}9[GET /health]10[HTTP/2 200]

Because the key exchange uses fresh (ephemeral) keys every time, recorded traffic can't be decrypted later even if the server's private key leaks. This property is called forward secrecy.

The client then validates the certificate: it must chain up to a root CA in the client's trust store, be within its validity dates, and list the requested hostname in its Subject Alternative Name (SAN) extension. curl -v shows every step:

text
$ curl -sv https://api.example.com/health
*   Trying 203.0.113.10:443...
* Connected to api.example.com (203.0.113.10) port 443
* ALPN: curl offers h2,http/1.1
* TLSv1.3 (OUT), TLS handshake, Client hello (1):
*  CAfile: /etc/ssl/certs/ca-certificates.crt
* TLSv1.3 (IN), TLS handshake, Server hello (2):
* TLSv1.3 (IN), TLS handshake, Encrypted Extensions (8):
* TLSv1.3 (IN), TLS handshake, Certificate (11):
* TLSv1.3 (IN), TLS handshake, CERT verify (15):
* TLSv1.3 (IN), TLS handshake, Finished (20):
* TLSv1.3 (OUT), TLS handshake, Finished (20):
* SSL connection using TLSv1.3 / TLS_AES_256_GCM_SHA384 / X25519 / id-ecPublicKey
* ALPN: server accepted h2
* Server certificate:
*  subject: CN=api.example.com
*  start date: Sep 14 08:12:31 2026 GMT
*  expire date: Dec 13 08:12:30 2026 GMT
*  subjectAltName: host "api.example.com" matched cert's "api.example.com"
*  issuer: C=US; O=Let's Encrypt; CN=E7
*  SSL certificate verify ok.
> GET /health HTTP/2
> Host: api.example.com
> User-Agent: curl/8.5.0
> Accept: */*
>
< HTTP/2 200
< content-type: application/json
< strict-transport-security: max-age=31536000; includeSubDomains
<
{"status":"ok"}

(A few lines trimmed.) Lines starting * are curl's commentary, > is the request it sent and < is the response. You can see the TCP connect to the resolved address, the TLS messages in order, the negotiated version and cipher, the certificate checks, and finally the HTTP exchange. The strict-transport-security header (HSTS) tells browsers to use HTTPS for this host from now on, even if a user types http://.

When validation fails

These two errors are the ones you'll meet most:

text
$ curl -sS https://internal.lab.test:8443/health
curl: (60) SSL certificate problem: unable to get local issuer certificate

$ curl -sS https://wrong.lab.test:8443/health
curl: (60) SSL: no alternative certificate subject name matches target host name 'wrong.lab.test'

The first means the chain doesn't lead to a CA the client trusts, typically a self-signed or private-CA certificate. The fix is to give the client the right CA (curl --cacert ca.crt, or add it to the system trust store), not to disable checking. The second means the certificate is valid but for a different name.

To inspect a server's certificate directly, use openssl:

text
$ openssl s_client -connect api.example.com:443 -servername api.example.com </dev/null 2>/dev/null \
    | openssl x509 -noout -subject -issuer -dates -ext subjectAltName
subject=CN = api.example.com
issuer=C = US, O = Let's Encrypt, CN = E7
notBefore=Sep 14 08:12:31 2026 GMT
notAfter=Dec 13 08:12:30 2026 GMT
X509v3 Subject Alternative Name:
    DNS:api.example.com

-servername sets SNI; without it, a server hosting many sites may send the wrong certificate. Monitoring notAfter and alerting a few weeks ahead prevents the most common TLS outage of all: an expired certificate.

Firewalls: ufw and nftables

A host firewall decides which packets the kernel accepts. The safe pattern is default deny inbound: block everything, then allow only what the machine serves. Firewalls are stateful: once an outgoing connection is allowed, its replies are let back in automatically.

On Ubuntu, ufw (uncomplicated firewall) is the friendly front end. Allow SSH before enabling the firewall, or you'll lock yourself out of a remote machine:

text
$ sudo ufw default deny incoming
Default incoming policy changed to 'deny'
$ sudo ufw default allow outgoing
$ sudo ufw allow from 198.51.100.0/24 to any port 22 proto tcp
Rules updated
$ sudo ufw allow 443/tcp
Rules updated
Rules updated (v6)
$ sudo ufw enable
Command may disrupt existing ssh connections. Proceed with operation (y|n)? y
Firewall is active and enabled on system startup
$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    198.51.100.0/24
443/tcp                    ALLOW IN    Anywhere
443/tcp (v6)               ALLOW IN    Anywhere (v6)

SSH is now reachable only from the office range 198.51.100.0/24, HTTPS from anywhere, and everything else (including that exposed Redis on 6379) is dropped.

Underneath, modern distributions use nftables. On servers without ufw, or when you need more control, you write the ruleset directly. This file expresses the same policy:

text
#!/usr/sbin/nft -f
flush ruleset

table inet filter {
    chain input {
        type filter hook input priority filter; policy drop;

        ct state established,related accept
        ct state invalid drop
        iif "lo" accept
        meta l4proto { icmp, ipv6-icmp } accept

        tcp dport 22 ip saddr 198.51.100.0/24 accept
        tcp dport 443 accept

        limit rate 5/minute log prefix "nft-drop: "
    }

    chain forward {
        type filter hook forward priority filter; policy drop;
    }
}

The inet family covers IPv4 and IPv6 at once. Rules are checked top to bottom: replies to existing connections are accepted first (the stateful part), loopback traffic and ICMP are allowed (ICMP carries important errors such as "fragmentation needed"), then the two services, and anything left falls through to policy drop, with a rate-limited log line for visibility. Check the syntax with sudo nft -c -f /etc/nftables.conf before loading it with sudo nft -f, and list the live rules with sudo nft list ruleset.

In the cloud, security groups and network ACLs do the same job in front of the machine. Use both: the cloud control protects you if someone disables the host firewall, and the host firewall protects you if someone opens the security group too widely.

Practice

On a Linux VM (not a machine you can only reach over SSH, unless you're careful with rule order):

  1. List every listening TCP and UDP socket with its process. For each, decide whether it should be reachable from the network.
  2. Make any service that only local clients use listen on 127.0.0.1, restart it, and confirm with ss.
  3. Configure ufw: deny inbound by default, allow SSH from one address or range only, allow 443 from anywhere. Enable it.
  4. From another machine, confirm that a blocked port times out (nc -zv -w 3 <ip> 6379) while an allowed but unused port is refused.
  5. Use curl -v and openssl s_client against a public HTTPS site and write down the TLS version, the issuer, the SAN entries and the expiry date.
Show solution
bash
sudo ss -tulnp                    # step 1: TCP and UDP listeners with owners

# step 2 (Redis example): in /etc/redis/redis.conf set
#   bind 127.0.0.1 -::1
sudo systemctl restart redis-server
sudo ss -tlnp | grep 6379         # now 127.0.0.1:6379 and [::1]:6379

# step 3: SSH rule first, then enable
sudo ufw default deny incoming
sudo ufw default allow outgoing
sudo ufw allow from 198.51.100.7 to any port 22 proto tcp
sudo ufw allow 443/tcp
sudo ufw enable
sudo ufw status verbose

Step 4, from a second machine: nc -zv -w 3 203.0.113.20 6379 prints a timeout because ufw drops the SYN silently. A port the firewall allows but nothing listens on returns "Connection refused" (an RST). If you allowed 443 but nginx isn't running yet, nc -zv 203.0.113.20 443 shows exactly that.

Step 5:

bash
curl -sv -o /dev/null https://example.org 2>&1 | grep -E 'SSL connection|subject:|issuer:|expire date|subjectAltName'
openssl s_client -connect example.org:443 -servername example.org </dev/null 2>/dev/null \
  | openssl x509 -noout -issuer -dates -ext subjectAltName

You should see TLSv1.3, an issuer such as a public CA's intermediate, one or more DNS: SAN entries that include the hostname, and a notAfter date in the future. If the SAN list doesn't contain the exact name you requested, browsers would reject the certificate.

Key takeaways

  • An IP address reaches a machine and a port reaches a program; know the private, loopback and link-local ranges by heart.
  • TCP connects with SYN, SYN-ACK, ACK. "Refused" (RST) means the host answered but nothing listens; a timeout usually means a firewall dropped the packet.
  • ss -tlnp shows what is listening and on which address: 0.0.0.0 is every interface, 127.0.0.1 is local only.
  • dig shows DNS answers, record types and TTLs; stale CNAMEs to deleted resources enable subdomain takeover.
  • TLS authenticates the server through a certificate chain to a trusted CA plus a hostname match in the SAN; never disable verification to make an error go away.
  • Default-deny inbound firewalls (ufw or nftables), plus cloud security groups, give two independent layers around every host.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.`nc -zv 10.0.4.12 5432` hangs for several seconds and then times out. What is the most likely explanation?

  2. 2.`ss -tlnp` shows `0.0.0.0:6379` for redis-server. Why is this a concern?

  3. 3.During certificate validation, which check confirms the certificate belongs to the host you asked for?

  4. 4.A colleague fixes a "unable to get local issuer certificate" error by adding `curl -k` to a deploy script. What is the right fix?

Concepts in this lesson

One-page notes on each idea, for review or a quick lookup later.

Finished reading?

Mark it complete to fill in your roadmap.