# 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.

DevSecOps Engineer roadmap · Foundations · 18 min read · https://levelcode.org/roadmaps/devsecops/networking-essentials

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:

| Range | Meaning |
|---|---|
| `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` | Private networks (not routed on the internet) |
| `127.0.0.0/8`, `::1` | Loopback: this machine only |
| `169.254.0.0/16` | Link-local; includes the cloud metadata endpoint `169.254.169.254` |
| `0.0.0.0` | In 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.

```sequence
title: TCP three-way handshake (curl to port 8443)
participants: Client (port 47328), Server (port 8443)
Client (port 47328) -> Server (port 8443): SYN seq=2676987039
> The client picks a random initial sequence number and asks to connect. The kernel chose the high source port.
Server (port 8443) --> Client (port 47328): SYN-ACK seq=1152219584 ack=2676987040
> The server picks its own initial sequence number and acknowledges the client's plus one: "I expect byte 2676987040 next."
Client (port 47328) -> Server (port 8443): ACK ack=1152219585
> tcpdump prints this as "ack 1" because it switches to sequence numbers relative to the start. Both sides now consider the connection established.
note Client (port 47328), Server (port 8443): Established after one round trip
Client (port 47328) -> Server (port 8443): PSH, 517 bytes: TLS ClientHello
> The first data segment. Its sequence number is the client's initial number plus one, shown by tcpdump as "seq 1:518".
```

### 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:

| Type | Holds | Security relevance |
|---|---|---|
| A / AAAA | IPv4 / IPv6 address | Where traffic actually goes |
| CNAME | Alias to another name | Dangling CNAMEs enable subdomain takeover |
| MX | Mail servers | Phishing and spoofing defence starts here |
| TXT | Free text | SPF, DMARC and domain-verification tokens |
| NS | Authoritative name servers | Whoever controls these controls the domain |
| CAA | Which CAs may issue certificates | Limits 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.

> [!WARNING]
> A **subdomain takeover** happens when `shop.example.com` is a CNAME to a deleted cloud resource (an old storage bucket or app). Anyone who creates a resource with that name can serve content on your subdomain. Delete DNS records when you delete what they point to.

## 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.

```sequence
title: TLS 1.3 full handshake (1-RTT)
participants: Client, Server
Client -> Server: ClientHello: key_share, SNI, ALPN
> The client guesses a key exchange group (usually X25519) and sends a fresh ephemeral public key straight away, plus the hostname (SNI) and protocols (ALPN) it wants.
Server --> Client: ServerHello: chosen cipher, key_share
> The server sends its own ephemeral key share. Both sides combine the two shares into the same secret and derive handshake keys. Everything after this message is encrypted.
Server --> Client: {EncryptedExtensions}: ALPN h2
> Sent in the same flight as ServerHello, like the next three messages. It carries settings such as the chosen application protocol.
Server --> Client: {Certificate}: chain for api.example.com
> The server's certificate plus any intermediate certificates that link it to a root CA. The client already has the roots in its trust store.
Server --> Client: {CertificateVerify}: signature
> The server signs a hash of the whole handshake so far with the certificate's private key, proving it holds that key.
Server --> Client: {Finished}
> A MAC over the transcript with keys only the two endpoints can have, so any tampering with earlier messages is detected.
Client -> Client: Validate chain, dates, SAN; check signature
> If any check fails the client aborts with an alert, before sending a single byte of application data.
Client -> Server: {Finished}
> One round trip after ClientHello, the handshake is complete. Both sides switch to application traffic keys.
Client -> Server: [GET /health]
> The request can go out in the same flight as the client's Finished.
Server --> Client: [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.

> [!IMPORTANT]
> `curl -k`, `verify=False` in Python and `InsecureSkipVerify: true` in Go turn off authentication. The connection is still encrypted, but to whoever answered, which may be an attacker in the middle. Never ship these to production; fix the trust configuration instead.

## 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.

**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.

## Quiz

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

   A. PostgreSQL is running but rejected the password
   B. A firewall or security group is dropping the SYN packets
   C. Nothing is listening on port 5432, so the kernel sent a reset
   D. DNS resolution failed

   **Answer: B.** A closed port answers immediately with RST ("Connection refused"). Silence until timeout means packets are being dropped on the way, typically by a firewall. DNS isn't involved because an IP address was used.

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

   A. Port 6379 is reserved for the kernel
   B. Redis will refuse all connections
   C. The process is running as root
   D. Redis accepts connections on every network interface, including public ones

   **Answer: D.** 0.0.0.0 means all interfaces. Unless a firewall blocks it, anyone who can reach the machine can connect. Binding to 127.0.0.1 and blocking the port in the firewall are both recommended.

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

   A. The requested hostname appears in the certificate's Subject Alternative Name
   B. The certificate uses an elliptic-curve key
   C. The server supports TLS 1.3
   D. The certificate's serial number is unique

   **Answer: A.** Clients match the hostname against the SAN entries. A certificate can be perfectly valid and trusted but issued for a different name, which curl reports as "no alternative certificate subject name matches".

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?

   A. Keep -k, because the connection is still encrypted
   B. Switch from HTTPS to plain HTTP
   C. Provide the issuing CA to the client (for example with --cacert or the system trust store)
   D. Lower the TLS version to 1.2

   **Answer: C.** -k disables authentication, so the script would happily talk to an attacker in the middle. The error means the client doesn't trust the issuing CA; give it the right CA certificate.
