11. Network Security
Every protocol in the preceding chapters was designed to deliver packets, not to decide whether they should be trusted: IP accepts any source address, ARP believes any reply, and DNS answers whoever asks. Network security is the discipline of adding confidentiality, integrity, and authenticity on top of that trusting foundation. This chapter covers the principles used to reason about threats, the cryptography that makes protection possible, the protocols built from that cryptography (TLS, IPsec, WireGuard), the attacks that exploit the network's original assumptions, and the perimeter and monitoring systems that detect and contain them.
<a id="111-security-principles"></a>
11.1 Security Principles
Before choosing a cipher or configuring a firewall, you need a vocabulary for what you are protecting, from whom, and how: this subchapter covers the goals, models, and architectural principles that every later mechanism serves.
CIA Triad in Networking
Theory
Asking "is this network secure?" has no answer until you say secure against what. The CIA triad splits that vague question into three properties you can test separately:
| Property | Question it answers | Network failure that violates it | Typical mechanism |
|---|---|---|---|
| Confidentiality | Can only the intended parties read the data? | A packet sniffer on shared Wi-Fi reads a password | Encryption (TLS, IPsec, WPA3) |
| Integrity | Is the data exactly what the sender sent? | A router on the path rewrites a download link | MACs, digital signatures, AEAD ciphers |
| Availability | Can legitimate users reach the service when they need it? | A SYN flood exhausts the server's connection table | Redundancy, rate limiting, DDoS scrubbing |
A useful analogy is a sealed letter. Confidentiality is the opaque envelope; integrity is the wax seal that shows whether it was opened and resealed; availability is the postal service actually delivering it. An envelope made of clear plastic fails confidentiality, a seal-less envelope fails integrity, and a post office that is closed fails availability — each is a different kind of failure with a different fix.
The three properties are independent, which is exactly why the split is useful. Encrypting traffic without authenticating it gives confidentiality but not integrity: an attacker cannot read the ciphertext but can still flip bits in it (Section 11.2 shows how this works against CTR mode). Conversely, a signed but unencrypted software update has integrity and no confidentiality — which is often exactly what you want.
They also trade off against each other. Adding a mandatory authentication server improves confidentiality but introduces a single point of failure that hurts availability. Deep packet inspection improves integrity monitoring but requires decrypting traffic, weakening confidentiality. Security design is largely the art of choosing which property to favour where.
Two properties are frequently added to the triad in networking contexts:
- Authenticity: the data came from who it claims. Integrity says "unchanged"; authenticity says "unchanged and from Alice". In practice, the same mechanism (a MAC or signature keyed to Alice) provides both.
- Non-repudiation: the sender cannot later deny having sent it. Only public-key signatures provide this; a shared-key MAC does not, because either party holding the key could have produced it.
Examples
Where each property breaks along a real path
[laptop] --Wi-Fi--> [AP] --> [ISP router] --> [Internet] --> [server]
| | | |
| C: anyone in radio range can capture frames |
| | I: any router can modify packets |
| | | A: server can be flooded
| |
+------------------ TLS tunnel (C + I + authenticity) ------+
does NOT provide availability
Mapping controls to properties:
control C I A
------------------ --- --- ---
TLS X X
SHA-256 checksum * (* only if the hash itself is trusted)
HMAC X
Digital signature X (+ non-repudiation)
Anycast + CDN X
Rate limiting X
Backups X
import hashlib, hmac, os
message = b"transfer 100 EUR to account 12345"
# --- Integrity WITHOUT authenticity: a plain hash ---
digest = hashlib.sha256(message).hexdigest()
# An attacker who can modify the message can simply recompute the hash:
tampered = b"transfer 900 EUR to account 66666"
forged_digest = hashlib.sha256(tampered).hexdigest()
print(hashlib.sha256(tampered).hexdigest() == forged_digest)
# True <- receiver cannot tell; a bare hash only detects ACCIDENTAL corruption
# --- Integrity WITH authenticity: a keyed MAC ---
key = os.urandom(32) # shared only by sender and receiver
tag = hmac.new(key, message, hashlib.sha256).digest()
def verify(msg, tag):
expected = hmac.new(key, msg, hashlib.sha256).digest()
return hmac.compare_digest(expected, tag) # constant-time comparison
print(verify(message, tag)) # True
print(verify(tampered, tag)) # False <- attacker lacks the key, cannot forge a tag
# --- Confidentiality WITHOUT integrity: XOR "encryption" ---
keystream = os.urandom(len(message))
ciphertext = bytes(m ^ k for m, k in zip(message, keystream))
# Attacker knows the plaintext format and flips bits WITHOUT knowing the key:
pos = message.index(b"100")
delta = bytes(a ^ b for a, b in zip(b"100", b"900"))
evil = bytearray(ciphertext)
for i, d in enumerate(delta):
evil[pos + i] ^= d
decrypted = bytes(c ^ k for c, k in zip(evil, keystream))
print(decrypted)
# b'transfer 900 EUR to account 12345' <- confidential, yet silently modified
# Confidentiality check: is this service readable on the wire?
$ curl -sI http://example.com | head -1 # plaintext HTTP: C and I both absent
# HTTP/1.1 200 OK
$ curl -sI https://example.com | head -1 # TLS: C and I present
# HTTP/2 200
# Integrity check for a download: compare against a published checksum
$ sha256sum ubuntu-24.04-live-server-amd64.iso
# 8762f7e7... ubuntu-24.04-live-server-amd64.iso
$ gpg --verify SHA256SUMS.gpg SHA256SUMS # the SIGNATURE is what makes the checksum trustworthy
# gpg: Good signature from "Ubuntu CD Image Automatic Signing Key (2012) <cdimage@ubuntu.com>"
Key takeaways
- Confidentiality, integrity, and availability are independent properties; name which one a control provides before trusting it.
- Encryption alone does not give integrity — unauthenticated ciphertext can be modified predictably.
- A plain hash detects accidents, not attackers; integrity against an adversary needs a key (MAC) or a signature.
- Authenticity binds data to a sender; non-repudiation requires public-key signatures, not shared-key MACs.
- Controls trade properties against each other — security design is choosing which to favour in each place.
🧪 Practice
- Classify each as C, I, or A: WPA3 on a home network, an anycast DNS service, an HMAC on a webhook payload, a signed firmware image.
- Explain why a published SHA-256 checksum on the same HTTP page as the download gives weak protection, and what fixes it.
- Give one example where improving confidentiality reduces availability, and one where improving availability reduces confidentiality.
- Interview-style: A payment API encrypts request bodies with AES-CTR and a key shared with the client. Is that enough? (Hint: consider what an on-path attacker can do to a ciphertext whose plaintext format they already know.)
Threat Models and Attack Surfaces
Theory
Security without a threat model is guesswork: you either spend effort defending against attackers who do not exist or leave gaps for the ones who do. A threat model is an explicit statement of who the adversary is, what they can do, and what you are protecting. Everything else — cipher choice, firewall rules, logging — follows from it.
The classic network adversary model is Dolev-Yao: the attacker is the network. They can read, drop, delay, replay, reorder, and inject any message, but cannot break the underlying cryptography. Protocols like TLS are designed to be secure under this model, which is why they remain safe even on hostile Wi-Fi. Weaker models are often more realistic for specific situations:
| Adversary | Capabilities | Example |
|---|---|---|
| Passive eavesdropper | Reads traffic, cannot modify | Someone capturing frames in a café |
| Off-path attacker | Sends packets but cannot see replies | Blind TCP injection, DNS cache poisoning |
| On-path (active) attacker | Reads, modifies, injects, drops | Compromised router, ARP-spoofing host |
| Insider | Legitimate credentials and network position | Disgruntled employee, compromised workstation |
| Nation-state / global passive | Observes many links, stores traffic for later | Mass surveillance; "harvest now, decrypt later" |
The attack surface is the sum of every point where an attacker can interact with the system: listening ports, exposed APIs, management interfaces, wireless networks, VPN endpoints, third-party integrations, and the people who can be phished. A core principle is that every exposed interface is a liability — an SSH port open to the Internet is attack surface even if the password is strong, because a future vulnerability in the SSH daemon becomes exploitable immediately.
A practical way to build a threat model is STRIDE, which classifies threats by the property they violate:
| Threat | Violates | Network example |
|---|---|---|
| Spoofing | Authenticity | Forged source IP, ARP spoofing |
| Tampering | Integrity | Modifying HTTP responses in transit |
| Repudiation | Non-repudiation | Deleting logs to hide an action |
| Information disclosure | Confidentiality | Sniffing, DNS leaks |
| Denial of service | Availability | SYN flood, amplification |
| Elevation of privilege | Authorization | Pivoting from a guest VLAN to management |
Threat modelling also forces you to draw trust boundaries — lines where data crosses from a less-trusted zone into a more-trusted one. Every trust boundary needs a control: authentication, validation, or filtering.
Examples
Data-flow diagram with trust boundaries for a small web service
INTERNET (untrusted)
============================================== trust boundary 1
| TCP/443
v
[ load balancer ] --- TLS terminates here
|
============================================== trust boundary 2
| HTTP (plaintext inside DC!)
v
[ app server ] ------> [ database ] TCP/5432
| ^
| SSH/22 | trust boundary 3 (admin)
[ bastion host ] <------ [ admin laptop via VPN ]
Attack surface inventory:
boundary 1 : 443 on LB -> TLS stack, HTTP parser, WAF rules
boundary 2 : LB->app -> plaintext: insider can sniff (I, C)
boundary 3 : VPN + SSH -> credential theft, key compromise
hidden : DB port reachable from app ONLY? verify with a scan
# A minimal STRIDE worksheet generator: enumerate threats per data flow.
flows = [
("browser -> LB", "internet", "TLS"),
("LB -> app", "internal", "plaintext"),
("app -> db", "internal", "TLS"),
("admin -> SSH", "vpn", "SSH keys"),
]
STRIDE = {
"S": "attacker impersonates a party on this flow",
"T": "attacker modifies data on this flow",
"I": "attacker reads data on this flow",
"D": "attacker exhausts or blocks this flow",
}
def assess(name, zone, protection):
risks = []
if protection == "plaintext":
risks += ["T", "I"] # no crypto: tampering + disclosure possible
if zone == "internet":
risks += ["D", "S"] # reachable by anyone: flooding + impersonation attempts
if zone == "vpn":
risks += ["S"] # stolen credentials = spoofed admin
return risks
for name, zone, prot in flows:
r = assess(name, zone, prot)
print(f"{name:<14} {zone:<9} {prot:<10} -> " +
(", ".join(f"{k}: {STRIDE[k]}" for k in r) if r else "no open items"))
# browser -> LB internet TLS -> D: attacker exhausts or blocks this flow, S: attacker impersonates a party on this flow
# LB -> app internal plaintext -> T: attacker modifies data on this flow, I: attacker reads data on this flow
# app -> db internal TLS -> no open items
# admin -> SSH vpn SSH keys -> S: attacker impersonates a party on this flow
# Enumerate YOUR OWN attack surface: what is listening, and on which address?
$ sudo ss -tulpn
# Netid State Local Address:Port Process
# tcp LISTEN 0.0.0.0:22 sshd <- all interfaces: Internet-facing?
# tcp LISTEN 127.0.0.1:5432 postgres <- loopback only: not remotely reachable
# tcp LISTEN 0.0.0.0:8080 java <- forgotten dev server = unplanned surface
# udp UNCONN 0.0.0.0:161 snmpd <- SNMP v2c exposed: information disclosure
# Confirm from OUTSIDE (the view an attacker has), on a host you are authorized to test:
$ nmap -Pn -p- --open my-server.example.com
# PORT STATE SERVICE
# 22/tcp open ssh
# 8080/tcp open http-proxy <- surface you did not intend to expose
Key takeaways
- A threat model states the adversary, their capabilities, and the assets; controls without one are guesswork.
- Dolev-Yao assumes the attacker controls the network but not the cryptography — good protocols are secure under it.
- Distinguish passive, off-path, on-path, and insider adversaries; each defeats different defences.
- Attack surface is every reachable interface; shrinking it is often cheaper than hardening it.
- STRIDE maps threats to the property they violate; trust boundaries show where controls must sit.
🧪 Practice
- For a home router's web admin page, list one threat in each STRIDE category.
- Explain why an off-path attacker can still poison a DNS cache, while being unable to read the resolver's queries.
- Run
ss -tulpnon a machine you own and classify every listener as loopback-only, LAN-reachable, or Internet-reachable.- Interview-style: Your team says internal service-to-service traffic does not need TLS because it is "inside the firewall". Respond. (Hint: which adversary in the table is already inside, and what did the trust-boundary diagram show about plaintext hops?)
Authentication, Authorization, and Accounting
Theory
Once you have decided which traffic crosses a trust boundary, you need a way to decide who is allowed across and what they may do. AAA splits this into three separate questions, answered by separate mechanisms:
| Step | Question | Network example |
|---|---|---|
| Authentication | Who are you? — prove an identity | 802.1X EAP-TLS certificate, SSH key, VPN password + OTP |
| Authorization | What are you allowed to do? | Assign VLAN 30, permit only show commands, allow 10.0.0.0/8 routes |
| Accounting | What did you do? — record it | Session start/stop, bytes transferred, commands executed |
The analogy is a hotel. Showing your passport at the desk is authentication. The key card that opens only your room and the gym is authorization. The folio listing your minibar charges is accounting. Confusing the steps causes real bugs: a VPN that authenticates users correctly but authorizes every user to reach every subnet has a working front desk and master keys for everyone.
Authentication factors fall into three categories: something you know (password), something you have (hardware token, private key on a smart card), and something you are (biometric). Multi-factor authentication combines categories — two passwords are still one factor. For machines, authentication almost always means possession of a private key, proven by a signature, because passwords cannot be stored safely on unattended devices.
In networking, AAA is usually centralised on an AAA server so that hundreds of switches, access points, and VPN gateways share one policy. Two protocols dominate:
| RADIUS | TACACS+ | |
|---|---|---|
| Transport | UDP 1812/1813 | TCP 49 |
| Encryption | Only the password attribute | Entire payload |
| AAA separation | Authentication and authorization combined | Fully separate |
| Typical use | Network access (Wi-Fi, VPN, 802.1X) | Device administration (router CLI) |
| Per-command authorization | No | Yes |
RADIUS drives 802.1X port-based access control: a switch port or Wi-Fi association starts blocked, the device (supplicant) authenticates through the switch (authenticator) to the RADIUS server, and only on success does the port open — often placing the device into a VLAN chosen by the server. That last step is authorization delivered as a network attribute.
Accounting is the part teams skip and later regret. Without it, you cannot answer "who logged into the core switch at 03:12 and ran no shutdown?" during an incident.
Examples
802.1X with RADIUS: authentication, then authorization as a VLAN
[laptop] [switch] [RADIUS server]
supplicant authenticator authentication server
| | |
|-- EAPOL-Start --->| port state: BLOCKED |
|<- EAP-Request/Id -| (only EAPOL allowed) |
|-- EAP-Resp/Id --->|-- Access-Request ----->|
|<====== EAP-TLS exchange tunnelled in RADIUS =====>|
| |<- Access-Accept -------| AUTHENTICATION done
| | Tunnel-Private-Group-Id = 30 <- AUTHORIZATION
|<- EAP-Success ----| port state: AUTHORIZED, VLAN 30
| |-- Accounting-Request (Start) -->| ACCOUNTING
| ... session ... | |
| |-- Accounting-Request (Stop, bytes=81MB) ->|
# A toy AAA pipeline showing why the three steps must be separate functions.
import hashlib, hmac, time, os
USERS = {"alice": {"salt": b"s1", "hash": None, "groups": {"netops"}},
"bob": {"salt": b"s2", "hash": None, "groups": {"helpdesk"}}}
for u, pw in (("alice", b"correct horse"), ("bob", b"hunter2")):
USERS[u]["hash"] = hashlib.pbkdf2_hmac("sha256", pw, USERS[u]["salt"], 200_000)
POLICY = { # authorization: group -> allowed command prefixes
"netops": ("show", "configure", "reload"),
"helpdesk": ("show",),
}
AUDIT = [] # accounting log
def authenticate(user, password):
rec = USERS.get(user)
if not rec:
return False
candidate = hashlib.pbkdf2_hmac("sha256", password, rec["salt"], 200_000)
return hmac.compare_digest(candidate, rec["hash"])
def authorize(user, command):
return any(command.startswith(p)
for g in USERS[user]["groups"] for p in POLICY.get(g, ()))
def account(user, command, allowed):
AUDIT.append((time.strftime("%H:%M:%S"), user, command, "PERMIT" if allowed else "DENY"))
def run(user, password, command):
if not authenticate(user, password):
account(user, command, False)
return "authentication failed"
ok = authorize(user, command)
account(user, command, ok)
return "executed" if ok else "not authorized"
print(run("bob", b"hunter2", "show interfaces")) # executed
print(run("bob", b"hunter2", "reload")) # not authorized <- authN ok, authZ no
print(run("alice", b"wrong", "reload")) # authentication failed
print(run("alice", b"correct horse", "reload")) # executed
for row in AUDIT:
print(row)
# ('14:02:11', 'bob', 'show interfaces', 'PERMIT')
# ('14:02:11', 'bob', 'reload', 'DENY')
# ('14:02:11', 'alice', 'reload', 'DENY')
# ('14:02:11', 'alice', 'reload', 'PERMIT')
# Test RADIUS authentication directly against a server (FreeRADIUS tools):
$ radtest alice 'correct horse' radius.example.lan 0 sharedsecret123
# Sent Access-Request Id 42 from 0.0.0.0:51820 to 10.0.0.5:1812
# Received Access-Accept Id 42 from 10.0.0.5:1812
# Tunnel-Type:0 = VLAN
# Tunnel-Medium-Type:0 = IEEE-802
# Tunnel-Private-Group-Id:0 = "30" <- authorization result: put her in VLAN 30
# Check 802.1X port status on a Cisco switch:
switch# show access-session interface Gi1/0/12 details
# Interface: GigabitEthernet1/0/12
# MAC Address: 3c22.fb1a.0b07
# User-Name: alice
# Status: Authorized
# Vlan Policy: 30
Key takeaways
- Authentication proves identity, authorization grants permissions, accounting records actions — separate steps, separate failures.
- MFA means combining different factor categories; machines authenticate with private keys, not passwords.
- RADIUS (UDP, partial encryption) is used for network access and 802.1X; TACACS+ (TCP, full encryption, per-command authZ) for device administration.
- 802.1X keeps a port blocked until the RADIUS server accepts, and can assign a VLAN as the authorization result.
- Accounting logs are what make incident response possible; ship them off the device.
🧪 Practice
- For a corporate VPN, write down one concrete mechanism for each of authentication, authorization, and accounting.
- Explain why TACACS+ is preferred over RADIUS for administering router CLIs.
- Is "password plus security question" multi-factor authentication? Justify your answer.
- Interview-style: After an 802.1X rollout, every authenticated employee can reach the finance database subnet. What went wrong? (Hint: authentication is working — which of the three A's did the RADIUS policy never return?)
Defense in Depth
Theory
Every security control eventually fails: a firewall rule is misconfigured, a TLS library has a bug, an employee clicks a phishing link. Defense in depth accepts this and layers independent controls so that the failure of one does not mean compromise of the whole. The goal is that an attacker must defeat several unrelated mechanisms in sequence, each of which raises the cost and each of which is a chance to detect them.
The standard analogy is a medieval castle: a moat, an outer wall, an inner wall, a keep, and guards at each gate. Breaching the outer wall does not give you the treasury. The "Swiss cheese model" from safety engineering says the same thing more precisely: each layer has holes, but if the holes are in different places, no single path passes through all of them.
The crucial word is independent. Two firewalls from the same vendor running the same rule set are not two layers — one bug or one mistaken rule defeats both. Good layers differ in mechanism:
| Layer | Example controls | Stops |
|---|---|---|
| Perimeter | Edge firewall, DDoS scrubbing | Unsolicited inbound connections, volumetric floods |
| Network | VLAN segmentation, internal ACLs, NAC | Lateral movement between zones |
| Host | Host firewall, EDR, patching | Exploitation of a reachable service |
| Application | Input validation, authN/authZ, WAF | Injection, broken access control |
| Data | Encryption at rest, tokenisation | Theft of stored data after compromise |
| Detection | IDS, SIEM, logging | Attacks that got through the other layers |
| People/process | Training, change control, least privilege | Phishing, misconfiguration |
Two principles make the layers effective:
- Least privilege: every user, service, and network flow gets the minimum access it needs. A web server that can reach only its database, not the entire data-centre subnet, limits what an attacker inherits when they compromise it.
- Fail closed: when a control cannot decide (the AAA server is down, a certificate cannot be verified), it denies rather than allows. A layer that fails open is invisible exactly when it matters.
Defense in depth has costs: complexity, latency, and operational burden. More layers also mean more configuration to get wrong. The discipline is to choose layers that address different threats in your model, not to stack products.
Examples
An attack path and the layers that each have a chance to stop it
attacker
|
| 1. phishing email with malicious attachment
v
[ mail gateway: attachment sandbox ] ----- layer: perimeter/content (hole: new malware)
|
| 2. user opens it on workstation
v
[ EDR on workstation ] ----- layer: host (hole: evasion)
|
| 3. malware beacons to C2 server over HTTPS
v
[ egress proxy + DNS filtering ] ----- layer: network egress (hole: domain not yet flagged)
|
| 4. lateral movement: SMB to file server
v
[ internal segmentation: user VLAN cannot reach server SMB ] <-- STOPPED HERE
|
| (if it had passed)
v
[ file server: least-privilege ACLs, encryption at rest ]
[ SIEM: alert on unusual SMB volume from a workstation ]
Four of five layers had holes. One independent layer was enough.
# Why independence matters: probability an attacker passes all layers.
import random
def breach_probability(layer_miss_rates, correlated=False, trials=200_000):
breaches = 0
for _ in range(trials):
if correlated:
# every layer shares one weakness: a single roll decides all of them
r = random.random()
passed = all(r < p for p in layer_miss_rates)
else:
passed = all(random.random() < p for p in layer_miss_rates)
breaches += passed
return breaches / trials
layers = [0.2, 0.2, 0.2, 0.2] # each layer misses 20% of attacks
random.seed(1)
print(f"one layer : {0.2:.4f}")
print(f"4 independent layers : {breach_probability(layers):.4f} (theory 0.2^4 = {0.2**4:.4f})")
print(f"4 correlated layers : {breach_probability(layers, correlated=True):.4f} (no better than one)")
# one layer : 0.2000
# 4 independent layers : 0.0017 (theory 0.2^4 = 0.0016)
# 4 correlated layers : 0.1998 (no better than one)
# Host layer: even behind an edge firewall, allow only what this server needs (nftables).
$ sudo nft -f - <<'EOF'
table inet host {
chain input {
type filter hook input priority 0; policy drop; # fail closed: default deny
ct state established,related accept # replies to our own connections
iif lo accept
ip saddr 10.0.20.0/24 tcp dport 5432 accept # only the app subnet reaches Postgres
ip saddr 10.0.99.10 tcp dport 22 accept # only the bastion reaches SSH
counter log prefix "host-drop: " drop # detection layer: log what we refuse
}
}
EOF
Key takeaways
- Assume every control fails eventually; layer independent controls so no single failure is fatal.
- Layers must differ in mechanism, not just in count — correlated layers give no extra protection.
- Least privilege limits what an attacker inherits; fail closed keeps a layer effective when it breaks.
- Detection is a layer: it catches what prevention missed and shortens the time an attacker goes unnoticed.
- More layers add complexity; choose them to cover different threats in your threat model.
🧪 Practice
- Pick a web application you know and list one control at each of the network, host, application, and data layers.
- Explain why two identically configured firewalls in series add little protection.
- Describe a control that fails open and how you would change it to fail closed, including the availability cost.
- Interview-style: An attacker exploited a web server vulnerability and then dumped the whole customer database. Which layers should have limited the damage? (Hint: the first layer clearly failed — trace what the compromised process could reach, and what it could read once there.)
Zero Trust Networking
Theory
Traditional networks follow the castle-and-moat model: a hard perimeter firewall, and implicit trust for anything inside it. A laptop plugged into the office network could reach almost every internal server. This model breaks badly today for three reasons: employees work from home and cafés, applications live in several clouds, and attackers who phish one workstation land inside the moat with all the trust that implies. Most large breaches involve exactly this lateral movement.
Zero trust replaces "trust the network location" with "never trust, always verify". Being on the corporate LAN grants nothing. Every request to every resource is authenticated, authorized, and encrypted based on who is asking and the state of their device, regardless of where the packet came from. NIST SP 800-207 formalises the core tenets:
- All resources are accessed securely regardless of network location.
- Access is granted per session, per resource, with least privilege.
- Access decisions use dynamic policy: user identity, device health (patched? disk encrypted? EDR running?), location, time, behaviour.
- The enterprise monitors and measures the security posture of all assets continuously.
Architecturally, zero trust separates a control plane from a data plane:
- Policy Decision Point (PDP): evaluates identity, device posture, and policy; decides allow or deny.
- Policy Enforcement Point (PEP): sits in front of each resource (an identity-aware proxy, a sidecar, a ZTNA connector) and enforces the decision on every connection.
Google's BeyondCorp is the canonical implementation: internal apps are published through an identity-aware proxy on the Internet, with no VPN at all. Access to the payroll app depends on your identity and a managed, up-to-date laptop — not on being in the building. In service-to-service contexts, the same idea appears as mutual TLS with workload identity (e.g. SPIFFE IDs in a service mesh), where each service proves its identity cryptographically to every peer.
| Perimeter model | Zero trust | |
|---|---|---|
| Trust based on | Network location (inside = trusted) | Identity + device posture per request |
| Remote access | VPN puts you "inside" | Per-app access through a proxy |
| Lateral movement | Easy once inside | Each hop requires fresh authorization |
| Encryption internally | Often plaintext | Always (mTLS) |
| Main risk | One breach exposes everything | Complexity; identity provider becomes critical |
Zero trust is a direction, not a product. It raises the importance of the identity provider — which becomes both the key control and the most valuable target — and it requires good device inventory to evaluate posture at all.
Examples
Perimeter model vs zero trust for the same request
PERIMETER
[attacker-phished laptop] --inside LAN--> [payroll app] ALLOWED (it's inside)
--inside LAN--> [git server] ALLOWED
--inside LAN--> [DB admin UI] ALLOWED
ZERO TRUST
[laptop] --TLS--> [ identity-aware proxy (PEP) ] --mTLS--> [payroll app]
|
v "alice, device D-4471, request payroll:read"
[ policy engine (PDP) ]
identity : alice, MFA 10 min ago OK
group : finance OK
device : OS patched? YES, EDR? NO FAIL
decision : DENY (device posture)
The same credentials from an unhealthy device get nothing.
A request for [DB admin UI] would be evaluated separately — and denied by group.
# A tiny policy decision point: every request is evaluated independently.
from dataclasses import dataclass
import time
@dataclass
class Request:
user: str
groups: set
mfa_age_s: int
device_patched: bool
device_edr: bool
resource: str
source_ip: str # deliberately NOT used for trust
POLICIES = {
"payroll": {"groups": {"finance"}, "max_mfa_age_s": 3600, "require_healthy": True},
"wiki": {"groups": {"staff"}, "max_mfa_age_s": 86400, "require_healthy": False},
"db-admin": {"groups": {"dba"}, "max_mfa_age_s": 900, "require_healthy": True},
}
def decide(req: Request):
p = POLICIES.get(req.resource)
if p is None:
return "DENY", "unknown resource (default deny)"
if not (req.groups & p["groups"]):
return "DENY", "not in an authorized group"
if req.mfa_age_s > p["max_mfa_age_s"]:
return "DENY", "step-up MFA required"
if p["require_healthy"] and not (req.device_patched and req.device_edr):
return "DENY", "device posture failed"
return "ALLOW", "all checks passed"
alice_ok = Request("alice", {"staff", "finance"}, 600, True, True, "payroll", "10.1.2.3")
alice_bad = Request("alice", {"staff", "finance"}, 600, True, False, "payroll", "10.1.2.3")
alice_db = Request("alice", {"staff", "finance"}, 600, True, True, "db-admin", "10.1.2.3")
print(decide(alice_ok)) # ('ALLOW', 'all checks passed')
print(decide(alice_bad)) # ('DENY', 'device posture failed') <- same user, same LAN IP
print(decide(alice_db)) # ('DENY', 'not in an authorized group')
# Service-to-service zero trust: mutual TLS, where BOTH sides present certificates.
# Client must prove its identity; the server rejects connections without a valid client cert.
$ curl https://orders.internal:8443/health
# curl: (56) OpenSSL SSL_read: error:0A00045C:SSL routines::tlsv13 alert certificate required
$ curl --cert billing-svc.crt --key billing-svc.key --cacert mesh-ca.crt \
https://orders.internal:8443/health
# {"status":"ok","peer":"spiffe://corp.example/ns/prod/sa/billing"}
# ^ workload identity extracted from the client certificate
Key takeaways
- Zero trust removes implicit trust based on network location; being inside the LAN grants nothing.
- Every request is evaluated on identity, device posture, and context, per session and per resource.
- Architecture: a Policy Decision Point decides, a Policy Enforcement Point (proxy, sidecar) enforces on every connection.
- It directly targets lateral movement, the step that turns one phished laptop into a full breach.
- Internal traffic is encrypted and mutually authenticated (mTLS); the identity provider becomes the critical asset.
🧪 Practice
- List three signals a zero-trust policy engine might use beyond username and password.
- Explain why a traditional VPN conflicts with zero-trust principles, even when it uses strong MFA.
- Describe how mTLS in a service mesh limits what an attacker can do after compromising one pod.
- Interview-style: Your company "moved to zero trust" by buying a ZTNA product, but the flat internal network is unchanged. Is that zero trust? (Hint: ask what a compromised internal host can still reach without passing through any enforcement point.)
<a id="112-cryptography-for-networks"></a>
11.2 Cryptography for Networks
Every secure protocol in this chapter is assembled from a small set of cryptographic primitives: this subchapter explains what each primitive guarantees, how it is used on the wire, and the ways it fails when misused.
Symmetric Ciphers and Modes of Operation
Theory
A symmetric cipher uses the same secret key to encrypt and decrypt. It is the workhorse of network security: once two parties share a key, every byte of a TLS session, IPsec tunnel, or WireGuard packet is protected with a symmetric cipher, because it is thousands of times faster than public-key cryptography. Modern CPUs have dedicated AES instructions (AES-NI) that encrypt at many gigabytes per second per core.
Symmetric ciphers come in two families:
- Block ciphers encrypt fixed-size blocks. AES (Advanced Encryption Standard) uses 128-bit blocks and 128-, 192-, or 256-bit keys. On its own, a block cipher is just a keyed permutation: 16 bytes in, 16 bytes out.
- Stream ciphers generate a pseudorandom keystream that is XORed with the plaintext. ChaCha20 is the modern standard; it is fast in software, which matters on phones and devices without AES hardware.
Because real messages are longer than 16 bytes, a block cipher needs a mode of operation that says how to chain blocks together. The choice of mode matters more than the choice of cipher — most practical breaks come from mode misuse:
| Mode | How it works | Parallel? | Integrity? | Verdict |
|---|---|---|---|---|
| ECB | Encrypt each block independently | Yes | No | Never use: identical plaintext blocks give identical ciphertext |
| CBC | XOR each block with previous ciphertext, then encrypt; needs random IV | Decrypt only | No | Legacy; padding-oracle attacks (Lucky 13, POODLE) |
| CTR | Encrypt a counter to make a keystream, XOR with plaintext | Yes | No | Fast, but malleable; nonce must never repeat |
| GCM | CTR mode + a polynomial MAC (GHASH) | Yes | Yes | Standard AEAD in TLS/IPsec |
| ChaCha20-Poly1305 | ChaCha20 stream + Poly1305 MAC | Yes | Yes | Standard AEAD, preferred without AES hardware |
The last two rows are AEAD — Authenticated Encryption with Associated Data. AEAD encrypts the payload and produces an authentication tag covering the ciphertext plus extra unencrypted "associated data" (such as packet headers). Decryption either returns the correct plaintext or fails; it never returns modified data. Every modern protocol (TLS 1.3, WireGuard, QUIC, IPsec ESP with GCM) uses AEAD exclusively, because separate encrypt-then-MAC constructions were repeatedly implemented wrong.
The one rule AEAD users must never break is nonce uniqueness. GCM and ChaCha20-Poly1305 take a 96-bit nonce ("number used once"). Reusing a nonce with the same key reveals the XOR of the two plaintexts and, for GCM, lets an attacker forge tags. Protocols avoid this by deriving nonces from a packet sequence number that never repeats within a key's lifetime.
Examples
Why ECB is broken: identical blocks leak structure
plaintext blocks: [ AAAA ][ BBBB ][ AAAA ][ AAAA ][ CCCC ]
ECB ciphertext: [ x9f2 ][ 11ab ][ x9f2 ][ x9f2 ][ 7c0e ] <- repeats visible
CBC/CTR/GCM: [ 3a71 ][ e0c4 ][ 9b12 ][ 5f8d ][ 0a66 ] <- no pattern
CTR / GCM encryption of one packet
nonce (96 bit) || counter (32 bit)
|
[ AES_k ] -> keystream block 1 --XOR--> ciphertext block 1
[ AES_k ] -> keystream block 2 --XOR--> ciphertext block 2
...
GHASH_k( associated data || ciphertext ) -> 128-bit TAG
On the wire: [ header (AD, readable) | ciphertext | tag ]
Receiver recomputes the tag FIRST; any modified bit -> reject whole packet.
# pip install cryptography
import os
from cryptography.hazmat.primitives.ciphers.aead import AESGCM, ChaCha20Poly1305
from cryptography.exceptions import InvalidTag
key = AESGCM.generate_key(bit_length=256)
aead = AESGCM(key)
header = b"seq=42;src=10.0.0.1" # associated data: authenticated but NOT encrypted
nonce = (42).to_bytes(12, "big") # derived from the sequence number: never repeats
pt = b"GET /balance HTTP/1.1"
ct = aead.encrypt(nonce, pt, header) # returns ciphertext || 16-byte tag
print(len(pt), len(ct)) # 21 37 <- plaintext + 16-byte tag
print(aead.decrypt(nonce, ct, header)) # b'GET /balance HTTP/1.1'
# Flip one bit of ciphertext: AEAD refuses to decrypt at all.
bad = bytearray(ct); bad[3] ^= 0x01
try:
aead.decrypt(nonce, bytes(bad), header)
except InvalidTag:
print("tampered ciphertext rejected")
# Modify the unencrypted header: also rejected, because the tag covers it.
try:
aead.decrypt(nonce, ct, b"seq=42;src=6.6.6.6")
except InvalidTag:
print("tampered header rejected")
# ChaCha20-Poly1305 has the identical interface:
cc = ChaCha20Poly1305(ChaCha20Poly1305.generate_key())
print(cc.decrypt(nonce, cc.encrypt(nonce, pt, header), header) == pt) # True
# Why nonce reuse is catastrophic, shown with a stream cipher (standard library only).
import hashlib
def keystream(key, nonce, n):
out = b""
ctr = 0
while len(out) < n: # toy CTR-mode keystream from SHA-256
out += hashlib.sha256(key + nonce + ctr.to_bytes(4, "big")).digest()
ctr += 1
return out[:n]
def xor(a, b):
return bytes(x ^ y for x, y in zip(a, b))
key, nonce = b"k" * 32, b"n" * 12
p1 = b"attack at dawn!!"
p2 = b"meet me at noon."
c1 = xor(p1, keystream(key, nonce, len(p1)))
c2 = xor(p2, keystream(key, nonce, len(p2))) # SAME nonce reused
leak = xor(c1, c2) # keystream cancels out
print(xor(leak, p1)) # b'meet me at noon.'
# An attacker who knows (or guesses) one plaintext recovers the other without the key.
# Measure raw cipher throughput on your CPU (AES-NI vs software ChaCha20):
$ openssl speed -evp aes-256-gcm -evp chacha20-poly1305 2>/dev/null | tail -3
# type 16 bytes 1024 bytes 16384 bytes
# AES-256-GCM 690123.45k 5712345.67k 6612345.89k <- ~6.6 GB/s per core with AES-NI
# ChaCha20-Poly1305 402345.12k 2234567.89k 2398765.43k
# Which AEAD did a real TLS connection negotiate?
$ openssl s_client -connect example.com:443 </dev/null 2>/dev/null | grep -i cipher
# New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Key takeaways
- Symmetric ciphers protect bulk data; AES (block) and ChaCha20 (stream) are the modern standards.
- The mode of operation matters more than the cipher: ECB leaks patterns, CBC and CTR alone provide no integrity.
- Use AEAD (AES-GCM, ChaCha20-Poly1305): it encrypts and authenticates, and authenticates unencrypted headers as associated data.
- Never reuse a nonce with the same key — protocols derive nonces from sequence numbers to guarantee it.
- ChaCha20-Poly1305 is preferred on devices without AES hardware acceleration.
🧪 Practice
- Explain why encrypting a bitmap image with AES-ECB still shows the outline of the picture.
- A packet uses AES-GCM with the IP header as associated data. What happens if a NAT device rewrites the source address, and why?
- A developer generates GCM nonces with a random 32-bit value per message. Estimate after how many messages a collision becomes likely, and propose a fix.
- Interview-style: Why did TLS 1.3 remove CBC-mode cipher suites entirely? (Hint: look at which order MAC and encryption were applied in TLS 1.2 CBC suites, and what a receiver revealed when padding was wrong.)
Public-Key Cryptography and Key Exchange
Theory
Symmetric encryption has a chicken-and-egg problem: two strangers on the Internet have no shared key, and they cannot send one over a network that an attacker is watching. Public-key (asymmetric) cryptography solves this. Each party has a key pair: a private key kept secret and a public key that can be published. Operations with one key can only be checked or reversed with the other.
Public-key cryptography is used for two jobs in networking:
- Key exchange — letting two parties derive a shared symmetric key over an insecure channel.
- Digital signatures — proving who sent a message (covered in its own topic below).
It is not used for bulk encryption, because it is roughly 1,000 times slower than AES. Every real protocol is hybrid: public-key crypto establishes a key, then symmetric AEAD protects the data.
Diffie-Hellman (DH) key exchange is the foundational idea. The paint-mixing analogy explains it well: Alice and Bob agree publicly on a common colour (yellow). Each secretly picks a private colour, mixes it with yellow, and sends the mixture across. Each then adds their own secret colour to the mixture they received. Both end up with yellow + Alice's secret + Bob's secret — the same final colour — while an eavesdropper who saw only the two mixtures cannot "unmix" them.
Mathematically, with public prime p and generator g:
Alice: secret a, sends A = g^a mod p
Bob: secret b, sends B = g^b mod p
Alice computes B^a = g^(ab) mod p
Bob computes A^b = g^(ab) mod p <- same shared secret
Eavesdropper sees g, p, A, B -> must solve the discrete logarithm problem
Modern protocols use Elliptic-Curve Diffie-Hellman (ECDH), most commonly X25519, which gives equivalent security with much smaller keys (32 bytes) and faster operations:
| Algorithm | Security basis | Key size for ~128-bit security | Used for |
|---|---|---|---|
| RSA | Factoring large integers | 3072-bit | Signatures, legacy key transport |
| Finite-field DH | Discrete log mod p | 3072-bit | Legacy key exchange |
| ECDH (X25519, P-256) | Elliptic-curve discrete log | 256-bit | Key exchange in TLS 1.3, WireGuard, SSH |
| ML-KEM (Kyber) | Lattice problems | ~1184-byte public key | Post-quantum key exchange |
Plain DH has a critical weakness: it is unauthenticated. An on-path attacker can run one DH exchange with Alice and another with Bob, and relay traffic between them. The fix is to sign the DH values with a long-term key that the other side can verify — which is exactly what the TLS handshake does with certificates.
Looking ahead, a large quantum computer would break RSA, DH, and ECDH. Because adversaries can record traffic now and decrypt it later, TLS deployments since 2024 increasingly use hybrid key exchange (e.g. X25519MLKEM768), which combines X25519 with the post-quantum ML-KEM so that breaking the session requires breaking both.
Examples
Diffie-Hellman, and why it needs authentication
HONEST RUN
Alice (a) Bob (b)
| ------------- A = g^a ------------------> |
| <------------ B = g^b ------------------- |
K = B^a = g^ab K = A^b = g^ab
MAN-IN-THE-MIDDLE (unauthenticated DH)
Alice (a) Mallory (m1, m2) Bob (b)
| -- A = g^a ---> | |
| | ---- M2 = g^m2 --------> |
| | <--- B = g^b ---------- |
| <-- M1 = g^m1 -- | |
K1 = g^(a*m1) K1 and K2 K2 = g^(b*m2)
Alice and Mallory share K1; Bob and Mallory share K2.
Mallory decrypts, reads, re-encrypts. Neither side notices.
FIX: Bob SIGNS B with his certificate's private key.
Mallory cannot produce a valid signature over M1 -> attack detected.
# Diffie-Hellman with small numbers (to see the arithmetic), then real sizes.
import secrets
# Toy parameters: p prime, g a generator
p, g = 23, 5
a, b = 6, 15 # private keys
A = pow(g, a, p) # Alice sends 5^6 mod 23 = 8
B = pow(g, b, p) # Bob sends 5^15 mod 23 = 19
print(A, B) # 8 19
print(pow(B, a, p), pow(A, b, p)) # 2 2 <- identical shared secret
# Realistic: 2048-bit MODP group 14 prime (RFC 3526), g = 2
P = int(
"FFFFFFFFFFFFFFFFC90FDAA22168C234C4C6628B80DC1CD129024E088A67CC74020BBEA63B139B22514A0879"
"8E3404DDEF9519B3CD3A431B302B0A6DF25F14374FE1356D6D51C245E485B576625E7EC6F44C42E9A637ED6B0B"
"FF5CB6F406B7EDEE386BFB5A899FA5AE9F24117C4B1FE649286651ECE45B3DC2007CB8A163BF0598DA48361C55"
"D39A69163FA8FD24CF5F83655D23DCA3AD961C62F356208552BB9ED529077096966D670C354E4ABC9804F1746C"
"08CA18217C32905E462E36CE3BE39E772C180E86039B2783A2EC07A28FB5C55DF06F4C52C9DE2BCBF695581718"
"3995497CEA956AE515D2261898FA051015728E5A8AACAA68FFFFFFFFFFFFFFFF", 16)
G = 2
a, b = secrets.randbelow(P - 2) + 1, secrets.randbelow(P - 2) + 1
A, B = pow(G, a, P), pow(G, b, P)
print(pow(B, a, P) == pow(A, b, P)) # True
print(P.bit_length()) # 2048
# Real protocols use X25519 (pip install cryptography).
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from cryptography.hazmat.primitives import hashes
alice = X25519PrivateKey.generate()
bob = X25519PrivateKey.generate()
# Only the 32-byte PUBLIC keys cross the network.
shared_a = alice.exchange(bob.public_key())
shared_b = bob.exchange(alice.public_key())
assert shared_a == shared_b
# Never use the raw DH output as a key: run it through a KDF first.
session_key = HKDF(algorithm=hashes.SHA256(), length=32, salt=None,
info=b"example handshake v1").derive(shared_a)
print(len(session_key)) # 32 <- ready for AES-256-GCM or ChaCha20-Poly1305
# Which key-exchange group did a TLS server choose?
$ openssl s_client -connect cloudflare.com:443 -groups X25519MLKEM768:X25519 </dev/null 2>/dev/null \
| grep -E "Negotiated TLS1.3 group|Server Temp Key"
# Negotiated TLS1.3 group: X25519MLKEM768 <- hybrid post-quantum key exchange
# Generate an X25519 key pair on the command line:
$ openssl genpkey -algorithm X25519 -out x.key
$ openssl pkey -in x.key -pubout
# -----BEGIN PUBLIC KEY-----
# MCowBQYDK2VuAyEA... <- 32 bytes of public key, base64 inside DER
Key takeaways
- Public-key crypto solves key distribution: a public key can be published; only the private key must stay secret.
- It is ~1,000x slower than AES, so protocols are hybrid: asymmetric for key exchange and signatures, symmetric AEAD for data.
- Diffie-Hellman lets two parties derive a shared secret over a public channel; X25519 is the modern default.
- Plain DH is unauthenticated and vulnerable to man-in-the-middle; the DH values must be signed by a trusted long-term key.
- Always pass raw DH output through a KDF (e.g. HKDF); post-quantum hybrid key exchange (X25519 + ML-KEM) is being deployed now.
🧪 Practice
- With
p = 23,g = 5, Alice's secreta = 4and Bob's secretb = 3, computeA,B, and the shared secret by hand.- Explain why an attacker who records
g,p,A, andBcannot compute the shared secret, naming the hard problem.- Why do protocols derive keys with HKDF instead of using the DH output directly as an AES key?
- Interview-style: Why is "harvest now, decrypt later" an argument for deploying post-quantum key exchange today, but not yet for post-quantum signatures? (Hint: ask what an attacker gains in ten years by breaking a recorded key exchange, versus by forging a signature on a handshake that ended long ago.)
Hash Functions and MACs
Theory
A cryptographic hash function maps input of any length to a fixed-size digest — 256 bits for SHA-256 — such that the digest acts like a fingerprint. Three properties make it useful for security:
| Property | Meaning | What breaks if it fails |
|---|---|---|
| Preimage resistance | Given a digest h, you cannot find any m with H(m) = h | Password hashes can be reversed |
| Second-preimage resistance | Given m1, you cannot find m2 ≠ m1 with the same digest | An attacker substitutes a different file with the same checksum |
| Collision resistance | You cannot find any pair m1 ≠ m2 with the same digest | Forged certificates (MD5 CA attack, 2008), SHA-1 SHAttered (2017) |
Hashes also show the avalanche effect: changing one input bit flips about half of the output bits, so similar inputs produce unrelated digests. Collision resistance is the hardest property to keep; because of the birthday paradox, a 256-bit hash offers only about 128 bits of collision resistance. MD5 and SHA-1 are broken for collisions and must not be used for signatures or certificates. The current standards are SHA-256/SHA-384 (SHA-2 family), SHA-3, and BLAKE2/BLAKE3.
A hash alone does not authenticate anything, because anyone can compute it — an attacker who modifies a message simply recomputes the digest. To prove a message came from someone holding a shared key, you need a Message Authentication Code (MAC): a keyed checksum tag = MAC(key, message). Only holders of the key can compute a valid tag.
The obvious construction H(key || message) is insecure with SHA-256 and SHA-2 in general, because of length extension: given H(key || m), an attacker can compute H(key || m || padding || extra) without knowing the key. HMAC solves this with two nested hashes:
HMAC(K, m) = H( (K XOR opad) || H( (K XOR ipad) || m ) )
HMAC-SHA256 is used everywhere: TLS 1.2 record MACs, the HKDF key-derivation function inside TLS 1.3, IPsec AH, JWT signatures (HS256), and webhook verification. Poly1305 and GHASH are faster one-time MACs used inside AEAD ciphers.
When verifying a MAC, always use a constant-time comparison. A naive == returns as soon as the first byte differs, and measuring that timing lets an attacker guess the correct tag one byte at a time.
Examples
Hash vs MAC in a webhook
sender receiver
body = {"event":"paid","amount":100}
tag = HMAC-SHA256(shared_key, body)
|--- POST /hook X-Signature: tag body --->|
| expected = HMAC-SHA256(shared_key, body)
| constant_time_equal(expected, tag)?
attacker changes amount to 100000:
- with a plain SHA-256 header: recompute hash -> ACCEPTED (broken)
- with HMAC: cannot compute tag without key -> REJECTED
Length extension against H(key || m)
server tag = SHA256(key || "user=bob")
attacker knows tag, length of key, and "user=bob"
attacker computes SHA256(key || "user=bob" || pad || "&admin=true")
without knowing key, because SHA-256's output IS its
internal state -- hashing can simply continue from it.
HMAC's outer hash hides the inner state, so extension is impossible.
import hashlib, hmac, time
# Avalanche effect: one character changes half the bits
h1 = hashlib.sha256(b"amount=100").digest()
h2 = hashlib.sha256(b"amount=101").digest()
diff = sum(bin(a ^ b).count("1") for a, b in zip(h1, h2))
print(f"{diff} of 256 bits differ") # 133 of 256 bits differ (~half)
# Fixed-size output regardless of input size
for n in (0, 10, 10_000_000):
print(n, len(hashlib.sha256(b"x" * n).hexdigest())) # always 64 hex chars = 256 bits
# Webhook verification with HMAC
SECRET = b"whsec_3f9a..." # shared out-of-band when the webhook was configured
def sign(body: bytes) -> str:
return hmac.new(SECRET, body, hashlib.sha256).hexdigest()
def verify(body: bytes, signature: str) -> bool:
return hmac.compare_digest(sign(body), signature) # constant-time: no timing leak
body = b'{"event":"paid","amount":100}'
sig = sign(body)
print(verify(body, sig)) # True
print(verify(b'{"event":"paid","amount":100000}', sig)) # False
# Why constant-time matters: a naive comparison exits early
def naive_equal(a, b):
for x, y in zip(a, b):
if x != y:
return False # returns sooner the earlier the mismatch
return len(a) == len(b)
# An attacker timing many requests learns how many leading characters were correct.
# Hashes from the command line
$ echo -n "amount=100" | sha256sum
# 3e9c... <- 64 hex chars
$ echo -n "amount=101" | sha256sum
# a05b... <- completely different
# HMAC from the command line (e.g. to test a webhook receiver)
$ echo -n '{"event":"paid","amount":100}' | openssl dgst -sha256 -hmac 'whsec_3f9a...'
# SHA2-256(stdin)= 5d1c0e...
# Refuse broken hashes in certificates: check what a server's cert was signed with
$ openssl s_client -connect example.com:443 </dev/null 2>/dev/null \
| openssl x509 -noout -text | grep "Signature Algorithm" | head -1
# Signature Algorithm: sha256WithRSAEncryption <- SHA-1 or MD5 here would be a red flag
Key takeaways
- A cryptographic hash is a fixed-size fingerprint with preimage, second-preimage, and collision resistance.
- MD5 and SHA-1 are broken for collisions; use SHA-256/384, SHA-3, or BLAKE2/3.
- A hash does not authenticate — anyone can recompute it. Use a MAC with a shared key.
H(key || message)is vulnerable to length extension; use HMAC.- Always verify tags with a constant-time comparison such as
hmac.compare_digest.
🧪 Practice
- Explain why the birthday paradox means a 256-bit hash gives only 128 bits of collision resistance.
- Write a function that verifies a GitHub-style
X-Hub-Signature-256: sha256=<hex>header for a request body.- Why does a MAC provide integrity and authenticity but not non-repudiation?
- Interview-style: An API signs requests as
sha256(secret + query_string). What attack does this allow? (Hint: think about what the SHA-256 output reveals about the hash's internal state after the last block.)
Digital Signatures
Theory
A MAC proves a message came from someone holding the shared key — which, with two parties, might be either of them. On the Internet we need something stronger: a way for anyone to verify that a message came from one specific party, without that party sharing any secret. Digital signatures provide this using a key pair:
- Sign with the private key:
sig = Sign(private_key, message) - Verify with the public key:
Verify(public_key, message, sig) → true/false
Because only the owner has the private key, a valid signature proves origin to every verifier who trusts the public key — this is what gives non-repudiation. In practice, the signer hashes the message first and signs the digest, so signature cost is independent of message size.
The analogy is a wax seal pressed with a unique signet ring. Anyone can compare the impression against a public reference of the ring's pattern, but only the ring's owner can make a new impression.
Three signature algorithms dominate network protocols:
| Algorithm | Basis | Signature size | Notes |
|---|---|---|---|
| RSA (PKCS#1 v1.5 / PSS) | Factoring | 256 bytes (2048-bit key) | Ubiquitous in certificates; slow to sign, fast to verify |
| ECDSA (P-256) | Elliptic curves | ~64-72 bytes | Needs a fresh, secret random nonce per signature |
| Ed25519 | Edwards curve | 64 bytes | Deterministic nonce, fast, hard to misuse; SSH, WireGuard ecosystem, modern PKI |
| ML-DSA (Dilithium) | Lattices | ~2.4 KB | Post-quantum; standardised 2024 |
ECDSA has a notorious failure mode: if the per-signature random nonce is ever reused or predictable, the private key can be computed from two signatures. This is how the Sony PlayStation 3 signing key was extracted in 2010. Ed25519 derives its nonce deterministically from the key and message, eliminating this class of bug.
In networks, signatures authenticate:
- TLS handshakes: the server signs the handshake transcript (including its DH share) with its certificate key — this is what defeats the man-in-the-middle shown earlier.
- Certificates: a CA signs the binding of a public key to a domain name.
- DNSSEC: zones sign their records so resolvers can detect forged answers.
- Software updates and packages: apt, rpm, and container images (Sigstore/cosign) are signed so a compromised mirror cannot inject code.
- BGP route origin (RPKI): signed statements that an AS may originate a prefix.
Examples
Signing and verification
SIGNER (has private key) VERIFIER (has public key only)
message m message m, signature s
| |
h = SHA-256(m) h' = SHA-256(m)
| |
s = Sign(priv, h) ----- m, s --------------> Verify(pub, h', s) ?
true -> m is authentic, unmodified
false -> m altered, or wrong signer
MAC vs signature
MAC: same key signs and verifies -> 2 parties, no non-repudiation
Signature: private signs, public verifies -> any number of verifiers,
non-repudiation
# Textbook RSA with tiny numbers to see the mechanism (NEVER use unpadded RSA for real).
p, q = 61, 53
n = p * q # 3233 (public modulus)
phi = (p - 1) * (q - 1) # 3120
e = 17 # public exponent
d = pow(e, -1, phi) # 2753 private exponent: e*d = 1 mod phi
print(n, d) # 3233 2753
h = 1234 # stand-in for a hash of the message (must be < n)
sig = pow(h, d, n) # sign with PRIVATE key
print(sig) # 1512
print(pow(sig, e, n) == h) # True <- anyone with (e, n) can verify
print(pow(sig, e, n) == 1235) # False <- a different message does not verify
# Real signatures with Ed25519 (pip install cryptography)
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
from cryptography.exceptions import InvalidSignature
priv = Ed25519PrivateKey.generate()
pub = priv.public_key() # distribute this freely
release = b"firmware-v2.3.1.bin sha256=9f1c..."
sig = priv.sign(release)
print(len(sig)) # 64
pub.verify(sig, release) # returns None: valid
try:
pub.verify(sig, b"firmware-v2.3.1.bin sha256=6666...")
except InvalidSignature:
print("forged release rejected")
# Deterministic: signing the same message twice yields the same signature.
print(priv.sign(release) == sig) # True <- no random nonce to get wrong
# Sign and verify a file with OpenSSL and Ed25519
$ openssl genpkey -algorithm ed25519 -out release.key
$ openssl pkey -in release.key -pubout -out release.pub
$ openssl pkeyutl -sign -inkey release.key -rawin -in firmware.bin -out firmware.sig
$ openssl pkeyutl -verify -pubin -inkey release.pub -rawin -in firmware.bin -sigfile firmware.sig
# Signature Verified Successfully
# Package managers verify signatures automatically; you can see the keys they trust:
$ gpg --show-keys /usr/share/keyrings/debian-archive-keyring.gpg | head -3
# Verify a container image signature (Sigstore cosign):
$ cosign verify --key cosign.pub registry.example.com/app:1.4
# Verification for registry.example.com/app:1.4 --
# The following checks were performed on each of these signatures:
# - The cosign claims were validated
# - The signatures were verified against the specified public key
Key takeaways
- Digital signatures let anyone with the public key verify that the private-key holder signed a message; they provide non-repudiation.
- Signers sign a hash of the message; verification uses the same hash.
- RSA, ECDSA, Ed25519 dominate; Ed25519 is fast, compact, and avoids ECDSA's nonce-reuse key-leak failure.
- Signatures authenticate TLS handshakes, certificates, DNSSEC, RPKI, and software updates.
- A signature is only as trustworthy as your reason to believe the public key belongs to the claimed signer — the problem PKI solves.
🧪 Practice
- Using
n = 3233,e = 17,d = 2753, signh = 65and verify the result.- Explain why a signed software update protects users even if the download mirror is compromised.
- Describe what an attacker can compute if an ECDSA signer uses the same nonce for two different messages.
- Interview-style: If you have the server's public key, why does TLS still need certificates? (Hint: a public key is just a number — ask how you know which server that number belongs to the first time you see it.)
Certificates, PKI, and Certificate Authorities
Theory
Signatures prove that a message was signed by the owner of a public key. But when your browser connects to bank.example, how does it know which public key belongs to bank.example rather than to an attacker? Distributing every server's key to every client in advance is impossible at Internet scale. Public Key Infrastructure (PKI) solves this by introducing trusted third parties that vouch for key ownership.
An X.509 certificate is a signed document that says: "the public key K belongs to the subject S, valid between dates T1 and T2", signed by a Certificate Authority (CA). The key fields are:
| Field | Purpose |
|---|---|
| Subject / Subject Alternative Name (SAN) | The identities covered (e.g. DNS:bank.example, DNS:*.bank.example) — browsers check SAN only |
| Subject Public Key Info | The key being certified |
| Issuer | Which CA signed it |
Validity (notBefore, notAfter) | Lifetime; public TLS certificates max out at 398 days and are shrinking toward 47 days |
| Key Usage / Extended Key Usage | What the key may do (e.g. TLS server authentication) |
| Basic Constraints | CA:TRUE only on CA certificates — prevents a leaf from issuing certificates |
| Signature | The issuer's signature over all of the above |
Trust forms a chain. Your operating system or browser ships a trust store of roughly 150 root CA certificates, which are self-signed. Roots are kept offline and sign intermediate CAs, which sign the leaf (end-entity) certificates servers use. The server sends its leaf plus intermediates; the client builds a path up to a root it already trusts and checks every signature along the way.
The analogy is a passport: you trust it not because you know the traveller, but because you trust the issuing government, and you can check its security features.
CAs must verify that the requester controls the domain before issuing. Domain Validation (DV) uses automated challenges via the ACME protocol (Let's Encrypt): place a token at http://domain/.well-known/acme-challenge/ (HTTP-01) or in a DNS TXT record (DNS-01). Automation made HTTPS nearly universal.
PKI's weakness is that any trusted CA can issue a certificate for any domain. Several incidents (DigiNotar in 2011, which led to its removal from all trust stores) showed that one compromised CA undermines everyone. Defences include:
- Certificate Transparency (CT): every public certificate must be logged in append-only public logs; browsers require proof of logging (SCTs). Domain owners monitor logs for certificates they did not request.
- CAA DNS records: a domain states which CAs may issue for it.
- Revocation: CRLs and OCSP let a CA mark a certificate invalid before expiry, but browsers apply them unevenly — which is one reason certificate lifetimes are shrinking instead.
Organisations also run private PKIs for internal services, VPN clients, 802.1X, and service-mesh mTLS, where they control the root and can issue short-lived certificates freely.
Examples
Certificate chain validation
TRUST STORE (on the client) SENT BY SERVER
+-----------------------------+
| ISRG Root X1 (self-signed) | <----+
+-----------------------------+ | signature verified with root's public key
|
+--------------------------------+
| Intermediate: Let's Encrypt R11|
| issuer: ISRG Root X1 |
+--------------------------------+
^ signature verified with intermediate's key
|
+---------------------------+
| Leaf: bank.example |
| SAN: bank.example, www... |
| issuer: R11 |
| valid: 2026-08-01..10-30 |
| EKU: serverAuth |
+---------------------------+
Client checks, for EVERY certificate in the path:
signature valid? within validity dates? not revoked?
intermediates have CA:TRUE? leaf SAN matches the hostname?
chain ends at a root in the local trust store?
# Inspect a live server's certificate with only the standard library.
import socket, ssl
from datetime import datetime, timezone
host = "example.com"
ctx = ssl.create_default_context() # loads system trust store, verifies chain + hostname
with socket.create_connection((host, 443), timeout=5) as sock:
with ctx.wrap_socket(sock, server_hostname=host) as tls:
cert = tls.getpeercert()
print("subject :", dict(x[0] for x in cert["subject"]))
print("issuer :", dict(x[0] for x in cert["issuer"])["organizationName"])
print("SANs :", [v for k, v in cert["subjectAltName"]][:4])
expires = datetime.strptime(cert["notAfter"], "%b %d %H:%M:%S %Y %Z").replace(tzinfo=timezone.utc)
print("expires :", expires.date(), f"({(expires - datetime.now(timezone.utc)).days} days left)")
# What a validation failure looks like:
try:
with socket.create_connection(("expired.badssl.com", 443), timeout=5) as s:
ctx.wrap_socket(s, server_hostname="expired.badssl.com")
except ssl.SSLCertVerificationError as e:
print("rejected:", e.verify_message) # rejected: certificate has expired
# Show the full chain a server sends:
$ openssl s_client -connect example.com:443 -showcerts </dev/null 2>/dev/null \
| grep -E "^ [0-9] s:|^ i:"
# 0 s:CN = example.com <- leaf
# i:C = US, O = DigiCert Inc, CN = DigiCert Global G3 TLS ECC SHA384 2020 CA1
# 1 s:C = US, O = DigiCert Inc, CN = DigiCert Global G3 TLS ECC SHA384 2020 CA1 <- intermediate
# i:C = US, O = DigiCert Inc, OU = www.digicert.com, CN = DigiCert Global Root G3
# Build a private CA and issue a leaf certificate (e.g. for an internal service):
$ openssl req -x509 -newkey ed25519 -nodes -keyout ca.key -out ca.crt \
-days 3650 -subj "/CN=Corp Internal Root" -addext "basicConstraints=critical,CA:TRUE"
$ openssl req -newkey ed25519 -nodes -keyout svc.key -out svc.csr -subj "/CN=orders.internal"
$ openssl x509 -req -in svc.csr -CA ca.crt -CAkey ca.key -days 30 -out svc.crt \
-extfile <(printf "subjectAltName=DNS:orders.internal\nextendedKeyUsage=serverAuth")
$ openssl verify -CAfile ca.crt svc.crt
# svc.crt: OK
# Which CAs may issue for a domain (CAA), and what has been issued (CT logs)?
$ dig +short CAA google.com
# 0 issue "pki.goog"
$ curl -s "https://crt.sh/?q=example.com&output=json" | jq -r '.[0:3][] | .issuer_name'
Key takeaways
- A certificate binds a public key to a name, signed by a CA; browsers match the hostname against the SAN field.
- Trust flows along a chain: leaf → intermediate(s) → a root in the client's trust store; every link is verified.
- ACME automates domain-validated issuance; short lifetimes and automation replace unreliable revocation.
- Any trusted CA can issue for any domain; Certificate Transparency and CAA records limit the damage.
- Private PKIs issue certificates for internal services, VPNs, 802.1X, and mTLS.
🧪 Practice
- Use
openssl s_client -showcertson three sites and draw each chain up to its root.- Explain why a server that sends only its leaf certificate works in some browsers but fails with
curl.- Why must intermediate CA certificates carry
basicConstraints=CA:TRUE, and what attack does checking it prevent?- Interview-style: How would you find out if someone obtained a valid certificate for your company's domain without your knowledge? (Hint: CAs are now required to publish every certificate somewhere public — where, and who can search it?)
Perfect Forward Secrecy
Theory
Imagine an attacker records all encrypted traffic to a bank's website for a year. They cannot read it — yet. Then, one day, they steal the server's private key through a breach, a court order, or a bug like Heartbleed. Can they now decrypt the whole year of recordings?
With older TLS configurations, yes. In RSA key transport (TLS 1.2 TLS_RSA_WITH_... suites), the client generated the session secret and encrypted it with the server's long-term RSA public key. Everything needed to recover every session key sat in the recorded handshakes, protected only by that one private key. Stealing it unlocks the entire archive.
Perfect Forward Secrecy (PFS) — also called forward secrecy — is the property that compromise of long-term keys does not compromise past session keys. It is achieved with ephemeral Diffie-Hellman (DHE or ECDHE): each session generates a fresh DH key pair, derives the session key, and then deletes the ephemeral private key. The long-term key is used only to sign the handshake (authentication), never to protect the session secret. After the session, the secret no longer exists anywhere — there is nothing left to steal.
The analogy is the difference between locking every letter with the same padlock whose key you keep in a drawer, versus using a fresh padlock for each letter and destroying its key after one use. Stealing the drawer key opens every old letter in the first case and none in the second.
| RSA key transport | Ephemeral (EC)DHE | |
|---|---|---|
| Session secret protected by | Server's long-term RSA key | Fresh DH values discarded after use |
| Long-term key used for | Decrypting the premaster secret | Signing the handshake only |
| Past traffic after key theft | Decryptable | Safe |
| TLS 1.2 | Allowed | Allowed (ECDHE_* suites) |
| TLS 1.3 | Removed | Mandatory |
PFS has limits worth knowing:
- It protects past sessions. An attacker holding the stolen key can still impersonate the server in future handshakes.
- Session resumption can weaken it. TLS session tickets are encrypted with a server-side ticket key; if that key is long-lived and stolen, resumed sessions are exposed. Rotate ticket keys frequently (hours, not months), or use resumption with a fresh (EC)DHE exchange (
psk_dhe_kein TLS 1.3). - TLS 1.3 0-RTT data is encrypted under the resumption key only, so it lacks forward secrecy against ticket-key compromise (and is replayable).
Protocols such as the Signal protocol go further with ratcheting: keys change after every message, so even compromising a live session exposes only a small window.
Examples
Why RSA key transport loses the archive, and ECDHE does not
RSA KEY TRANSPORT (TLS 1.2, no PFS)
client: premaster = random
sends Enc_RSApub(premaster) ------------> recorded by attacker
session key = KDF(premaster, randoms)
... attacker records ciphertext for a year ...
attacker steals RSA private key:
premaster = Dec_RSApriv(recorded blob) -> every session key recovered
ECDHE (TLS 1.3, PFS)
client: ephemeral a, sends g^a ------------> recorded
server: ephemeral b, sends g^b, Sign_RSApriv(transcript) -> recorded
session key = KDF(g^ab)
both sides DELETE a and b
attacker steals RSA private key:
can verify old signatures, cannot recover a or b -> sessions stay secret
# Simulate the difference: steal the long-term key after recording sessions.
import secrets, hashlib
P = 2**127 - 1 # a Mersenne prime; fine for a demonstration only
G = 3
def kdf(x):
return hashlib.sha256(str(x).encode()).hexdigest()[:16]
# Server's long-term secret (stands in for an RSA private key)
long_term = secrets.randbelow(P)
recorded = [] # what an eavesdropper captured on the wire
# --- Non-PFS: session secret derived from the long-term key + public data ---
for _ in range(3):
client_random = secrets.randbelow(P)
recorded.append(("static", client_random)) # public value on the wire
# --- PFS: fresh ephemeral secrets each session, then discarded ---
for _ in range(3):
a, b = secrets.randbelow(P), secrets.randbelow(P) # ephemeral, held only in memory
recorded.append(("ephemeral", pow(G, a, P), pow(G, b, P)))
del a, b # forward secrecy: erased
# Later: attacker steals long_term
def attacker_recover(entry, stolen_key):
if entry[0] == "static":
return kdf(pow(entry[1], stolen_key, P)) # recomputable from stolen key
return None # needs a or b: gone forever
for e in recorded:
print(e[0], "->", attacker_recover(e, long_term) or "unrecoverable")
# static -> 5b1c... (x3) <- archive decrypted
# ephemeral -> unrecoverable (x3)
# Check whether a server's TLS 1.2 configuration offers only forward-secret suites:
$ nmap --script ssl-enum-ciphers -p 443 my-server.example.com | grep -E "TLSv1.2|TLS_"
# TLSv1.2:
# TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 (secp256r1) - A <- PFS
# TLS_RSA_WITH_AES_128_GCM_SHA256 (rsa 2048) - A <- NO PFS: remove it
# nginx: allow only ECDHE suites for TLS 1.2 and rotate session-ticket keys
# ssl_protocols TLSv1.2 TLSv1.3;
# ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-CHACHA20-POLY1305;
# ssl_session_tickets off; # or rotate ticket keys every few hours
# Confirm the key exchange of a live connection is ephemeral:
$ openssl s_client -connect example.com:443 -tls1_2 </dev/null 2>/dev/null | grep "Server Temp Key"
# Server Temp Key: X25519, 253 bits <- ephemeral ECDH: forward secret
Key takeaways
- Forward secrecy means stealing a long-term key does not decrypt previously recorded sessions.
- It comes from ephemeral (EC)DHE: fresh key pairs per session, deleted afterwards; the long-term key only signs.
- RSA key transport has no PFS and was removed in TLS 1.3, where ephemeral key exchange is mandatory.
- PFS does not prevent future impersonation with a stolen key; revoke and reissue.
- Session ticket keys and 0-RTT data can undermine PFS — rotate ticket keys often.
🧪 Practice
- Explain in two sentences why a Heartbleed-style leak of a server's private key was far worse for servers using
TLS_RSA_*suites.- A server uses TLS 1.3 but never rotates its session-ticket encryption key. Which sessions are exposed if that key leaks?
- Does PFS protect against an attacker who steals the private key and then performs an active man-in-the-middle? Explain.
- Interview-style: Some enterprises resisted TLS 1.3 because their monitoring appliances passively decrypted traffic using the server's private key. Why did TLS 1.3 break that, and what do those appliances do now? (Hint: what did the appliance need from the recorded handshake, and does it still exist after an ephemeral exchange?)
<a id="113-secure-protocols"></a>
11.3 Secure Protocols
The primitives from 11.2 are only useful once they are assembled into protocols that negotiate keys, authenticate peers, and protect traffic on the wire: this subchapter covers TLS at the transport layer, IPsec at the network layer, and the VPN designs built on them.
TLS Handshake and Record Protocol
Theory
Transport Layer Security (TLS) turns an ordinary TCP byte stream into a channel with confidentiality, integrity, and server (optionally client) authentication. It protects HTTPS, SMTP submission, IMAP, database connections, gRPC, and — inside QUIC — HTTP/3. Its predecessor, SSL, is fully deprecated; the name survives only in library names like OpenSSL.
TLS has two layers:
- The handshake protocol negotiates the protocol version and cipher suite, authenticates the server with its certificate, and establishes shared keys through (EC)DHE.
- The record protocol splits application data into records (up to 16 KB), protects each with the negotiated AEAD cipher, and hands them to TCP.
The handshake has to solve several problems at once: two strangers must agree on algorithms, derive a shared secret over a hostile network, and prove that nobody tampered with the negotiation itself. The classic TLS 1.2 full handshake takes two round trips before application data can flow:
- ClientHello: supported versions, cipher suites, a random nonce, extensions (SNI — the hostname the client wants, supported groups, ALPN for
h2/http/1.1). - ServerHello + Certificate + ServerKeyExchange (the server's ephemeral ECDH public value, signed with its certificate key) + ServerHelloDone.
- ClientKeyExchange (client's ECDH public value) + ChangeCipherSpec + Finished.
- Server ChangeCipherSpec + Finished.
The Finished messages are the linchpin: each contains a MAC over the entire handshake transcript using the new keys. If an attacker altered anything — for example, removing strong cipher suites from the ClientHello to force a weak one (a downgrade attack) — the transcripts differ and the Finished check fails.
Once keys exist, the record protocol takes over. Each record has a 5-byte header (content type, version, length) followed by the AEAD-protected payload. A per-direction sequence number, never sent on the wire, is bound into every record's authentication (as associated data in TLS 1.2, mixed into the nonce in TLS 1.3), so reordered, replayed, or deleted records fail authentication. TCP guarantees order, so any gap is an attack.
A cipher suite names the algorithms used. In TLS 1.2 it names everything; in TLS 1.3 it names only the AEAD and hash, because key exchange and signature algorithms are negotiated separately:
TLS 1.2: TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
| | | |
key exch auth AEAD cipher PRF hash
TLS 1.3: TLS_AES_128_GCM_SHA256 (key exchange is always ephemeral (EC)DHE)
Examples
TLS 1.2 full handshake (ECDHE_RSA) over TCP
client server
|--- SYN ------------------------------------------------->| TCP
|<-- SYN-ACK ----------------------------------------------| 1 RTT
|--- ACK ------------------------------------------------->|
| |
|--- ClientHello (versions, suites, random, SNI, ALPN) --->| TLS RTT 1
|<-- ServerHello (chosen suite, random) |
|<-- Certificate (leaf + intermediates) |
|<-- ServerKeyExchange (ECDHE pub, SIGNED by cert key) |
|<-- ServerHelloDone --------------------------------------|
|--- ClientKeyExchange (ECDHE pub) | TLS RTT 2
|--- ChangeCipherSpec |
|--- Finished = MAC(keys, hash(all handshake msgs)) ----->|
|<-- ChangeCipherSpec |
|<-- Finished ---------------------------------------------|
|=== application data (AEAD records) =====================>|
Time to first byte: 3 RTT (1 TCP + 2 TLS)
Record on the wire
+------+--------+--------+-------------------------------+-------------+
| type | ver | length | encrypted payload | AEAD tag |
| 0x17 | 0x0303 | 0x0045 | ... | 16 bytes |
+------+--------+--------+-------------------------------+-------------+
5-byte header (sent in clear, authenticated as associated data)
# A TLS client and what it negotiated, using the standard library.
import socket, ssl
ctx = ssl.create_default_context() # sane defaults: verify cert + hostname
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_alpn_protocols(["h2", "http/1.1"]) # ALPN extension in the ClientHello
with socket.create_connection(("example.com", 443)) as raw:
with ctx.wrap_socket(raw, server_hostname="example.com") as tls: # SNI set here
print(tls.version()) # TLSv1.3
print(tls.cipher()) # ('TLS_AES_256_GCM_SHA384', 'TLSv1.3', 256)
print(tls.selected_alpn_protocol()) # h2
tls.sendall(b"HEAD / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n")
print(tls.recv(64)) # b'HTTP/1.1 200 OK\r\n...'
# A minimal TLS server (e.g. for testing), with a certificate from the PKI topic.
import socket, ssl
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("svc.crt", "svc.key") # leaf (+ intermediates) and private key
ctx.minimum_version = ssl.TLSVersion.TLSv1_2
ctx.set_ciphers("ECDHE+AESGCM:ECDHE+CHACHA20") # TLS 1.2: forward-secret AEAD suites only
with socket.create_server(("0.0.0.0", 8443)) as srv:
with ctx.wrap_socket(srv, server_side=True) as tls_srv:
conn, addr = tls_srv.accept() # handshake happens here
print(addr, conn.version(), conn.cipher()[0])
conn.sendall(b"hello over TLS\n")
conn.close()
# Watch a handshake message by message:
$ openssl s_client -connect example.com:443 -tls1_2 -msg </dev/null 2>/dev/null | grep -E "^(<<<|>>>)"
# >>> TLS 1.2, Handshake [length 0131], ClientHello
# <<< TLS 1.2, Handshake [length 005d], ServerHello
# <<< TLS 1.2, Handshake [length 0b8c], Certificate
# <<< TLS 1.2, Handshake [length 0070], ServerKeyExchange
# <<< TLS 1.2, Handshake [length 0004], ServerHelloDone
# >>> TLS 1.2, Handshake [length 0025], ClientKeyExchange
# >>> TLS 1.2, ChangeCipherSpec [length 0001]
# >>> TLS 1.2, Handshake [length 0010], Finished
# <<< TLS 1.2, ChangeCipherSpec [length 0001]
# <<< TLS 1.2, Handshake [length 0010], Finished
# Measure how much of connection time the handshake costs:
$ curl -so /dev/null -w "tcp=%{time_connect}s tls=%{time_appconnect}s total=%{time_total}s\n" https://example.com
# tcp=0.021s tls=0.064s total=0.089s <- TLS finished at 64 ms; ~2 RTT after TCP for TLS 1.2
# In Wireshark, filter handshake messages only:
# tls.handshake.type == 1 (ClientHello) -> inspect SNI, suites, supported_groups
Key takeaways
- TLS = handshake (negotiate, authenticate, derive keys) + record protocol (AEAD-protected records over TCP).
- The server's ephemeral key share is signed with its certificate key, which defeats man-in-the-middle on the key exchange.
- Finished messages MAC the whole transcript, detecting any tampering or downgrade of the negotiation.
- Records are authenticated with an implicit sequence number, so replayed, reordered, or dropped records are detected.
- TLS 1.2 full handshakes cost 2 RTT on top of TCP's 1 RTT.
🧪 Practice
- Label each part of
TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256and say what it negotiates.- Capture a TLS connection in Wireshark and find the SNI in the ClientHello. Explain why it is visible.
- Explain what the Finished message would detect if an attacker removed all AES-GCM suites from a ClientHello.
- Interview-style: TLS runs over TCP, which already has checksums and sequence numbers. Why does the TLS record layer need its own sequence number? (Hint: TCP's checksum and sequence numbers are not keyed — who else can compute them?)
TLS 1.3 Improvements
Theory
TLS 1.2 accumulated two decades of options, and many real attacks (BEAST, CRIME, Lucky 13, POODLE, FREAK, Logjam, ROBOT) exploited legacy algorithms still negotiable for compatibility. TLS 1.3 (RFC 8446, 2018) was a redesign that removed the problem at its source: it keeps only algorithms with no known weaknesses and restructures the handshake to be faster and more private.
1. Everything legacy is removed. Only five cipher suites remain, all AEAD. Gone are RSA key transport (no forward secrecy), static DH, CBC mode, RC4, 3DES, SHA-1 and MD5 in signatures, compression (CRIME), and renegotiation. Every TLS 1.3 connection has forward secrecy by construction.
2. One round trip (1-RTT). In TLS 1.2 the client waited to learn the server's chosen group before sending its key share. In TLS 1.3 the client guesses — it sends a key share (e.g. X25519) in the ClientHello. The server responds with its share and, because it can already derive keys, sends its certificate and Finished encrypted in the same flight. The client can send application data after one round trip. If the guess was wrong, the server sends a HelloRetryRequest, costing one extra RTT.
3. More of the handshake is encrypted. Everything after the ServerHello — including the server's certificate — is encrypted with handshake keys. A passive observer no longer sees which certificate the server presented. The ClientHello remains in plaintext, exposing SNI; Encrypted Client Hello (ECH), now deployed by major CDNs and browsers, encrypts the inner ClientHello using a key published in DNS.
4. 0-RTT resumption. A client that connected before holds a pre-shared key (session ticket) and can send application data in its very first flight ("early data"). The price: 0-RTT data has no replay protection — an attacker can capture and resend it — and no forward secrecy against ticket-key compromise. Servers must only accept idempotent requests (e.g. GET) as early data.
5. A cleaner key schedule. All keys derive from HKDF in a single, analysable schedule, with separate keys for handshake and application traffic. TLS 1.3 was formally verified during design, a first for a major protocol.
6. Downgrade protection. If a TLS 1.3 server is forced to negotiate TLS 1.2, it embeds a sentinel value in its random nonce; a TLS 1.3 client that sees it aborts.
| Aspect | TLS 1.2 | TLS 1.3 |
|---|---|---|
| Full handshake | 2 RTT | 1 RTT |
| Resumption | 1 RTT | 0 RTT (with replay caveat) |
| Key exchange | RSA, DH, ECDH, DHE, ECDHE | (EC)DHE only (+ hybrid PQ) |
| Forward secrecy | Optional | Always |
| Cipher suites | ~300 defined | 5, all AEAD |
| Certificate visible to observers | Yes | No (encrypted) |
| Compression, renegotiation | Supported | Removed |
For compatibility with middleboxes that choked on unknown versions, TLS 1.3 disguises itself: the ClientHello's legacy version field still says TLS 1.2, and the real version is negotiated in the supported_versions extension.
Examples
TLS 1.3 full handshake (1-RTT)
client server
|--- ClientHello |
| supported_versions: 1.3 |
| key_share: X25519 public value <- guessed up front |
| signature_algorithms, SNI, ALPN ------------------->|
| | derive handshake keys
|<-- ServerHello (key_share: X25519 public value) |
|<-- {EncryptedExtensions} |
|<-- {Certificate} <- encrypted! |
|<-- {CertificateVerify} <- signature over transcript |
|<-- {Finished} --------------------------------------------|
|--- {Finished} |
|=== [application data] ===================================>|
{ } = handshake keys, [ ] = application keys
Time to first byte: 2 RTT total (1 TCP + 1 TLS) -- vs 3 for TLS 1.2
0-RTT resumption
|--- ClientHello + pre_shared_key + early_data |
|=== [GET /index.html] (encrypted with resumption PSK) ====>| <- sent immediately
|<-- ServerHello ... Finished ------------------------------|
! an attacker can REPLAY the first flight -> the GET runs twice
# Force TLS 1.3 and observe what is negotiated.
import socket, ssl, time
def connect(host, version):
ctx = ssl.create_default_context()
ctx.minimum_version = ctx.maximum_version = version
t0 = time.perf_counter()
with socket.create_connection((host, 443)) as s:
t1 = time.perf_counter() # TCP done
with ctx.wrap_socket(s, server_hostname=host) as tls:
t2 = time.perf_counter() # TLS done
return tls.version(), tls.cipher()[0], (t1 - t0) * 1000, (t2 - t1) * 1000
for v in (ssl.TLSVersion.TLSv1_2, ssl.TLSVersion.TLSv1_3):
ver, suite, tcp_ms, tls_ms = connect("www.cloudflare.com", v)
print(f"{ver:8} {suite:32} tcp={tcp_ms:5.1f} ms tls={tls_ms:5.1f} ms")
# TLSv1.2 ECDHE-ECDSA-AES128-GCM-SHA256 tcp= 12.3 ms tls= 26.1 ms <- ~2 RTT
# TLSv1.3 TLS_AES_256_GCM_SHA384 tcp= 12.1 ms tls= 13.4 ms <- ~1 RTT
# The five TLS 1.3 suites your OpenSSL supports:
$ openssl ciphers -v -s -tls1_3
# TLS_AES_256_GCM_SHA384 TLSv1.3 Kx=any Au=any Enc=AESGCM(256) Mac=AEAD
# TLS_CHACHA20_POLY1305_SHA256 TLSv1.3 Kx=any Au=any Enc=CHACHA20/POLY1305(256) Mac=AEAD
# TLS_AES_128_GCM_SHA256 TLSv1.3 Kx=any Au=any Enc=AESGCM(128) Mac=AEAD
# Handshake messages in TLS 1.3 -- note Certificate is now inside encrypted records:
$ openssl s_client -connect example.com:443 -tls1_3 -msg </dev/null 2>/dev/null | grep -E "^(<<<|>>>)" | head -8
# >>> TLS 1.3, Handshake [length 0137], ClientHello
# <<< TLS 1.3, Handshake [length 007a], ServerHello
# <<< TLS 1.3, Handshake [length 0006], EncryptedExtensions
# <<< TLS 1.3, Handshake [length 0b8e], Certificate
# <<< TLS 1.3, Handshake [length 004b], CertificateVerify
# <<< TLS 1.3, Handshake [length 0034], Finished
# >>> TLS 1.3, Handshake [length 0034], Finished
# nginx: enable 0-RTT but tell the backend so it can reject non-idempotent early requests
# ssl_early_data on;
# proxy_set_header Early-Data $ssl_early_data; # backend returns 425 Too Early for POST
Key takeaways
- TLS 1.3 removed every legacy algorithm: only 5 AEAD suites, ephemeral (EC)DHE only, forward secrecy always.
- The client sends a key share in the ClientHello, cutting the full handshake to 1 RTT.
- Everything after ServerHello — including the certificate — is encrypted; ECH extends this to SNI.
- 0-RTT early data saves another RTT but is replayable — accept only idempotent requests.
- Built-in downgrade protection and a formally analysed key schedule close classes of attacks that plagued TLS 1.2.
🧪 Practice
- Compute time to first byte for TLS 1.2 and TLS 1.3 over TCP on a 100 ms RTT path, and for TLS 1.3 with 0-RTT.
- List three TLS 1.2 attacks and the TLS 1.3 change that eliminates each.
- What happens in a TLS 1.3 handshake if the client sends an X25519 key share but the server only supports P-256?
- Interview-style: Your API receives a duplicated payment request from the same client within milliseconds, and it only happens since enabling TLS 1.3 at the CDN. What is a likely cause? (Hint: which new TLS 1.3 feature lets data arrive before the handshake completes, and what property does that data lack?)
HTTPS and Certificate Validation
Theory
HTTPS is simply HTTP carried inside TLS, on port 443 by default. The encryption is the easy part; the part that actually protects users is certificate validation — making sure the TLS peer is really the site the user asked for. A TLS connection to the wrong server is encrypted and worthless: you would be sending your password securely to the attacker.
When a browser or HTTP client connects to https://shop.example/, it performs these checks on the server's certificate chain:
| Check | Failure means |
|---|---|
| Chain builds to a trusted root in the local store | Self-signed or unknown CA (unable to get local issuer certificate) |
| Every signature in the chain verifies | Tampered or forged certificate |
| Current time is within notBefore/notAfter for each cert | Expired (the most common production outage) |
Hostname matches a SAN entry (shop.example or *.example) | Wrong certificate or impersonation |
| Intermediates have CA:TRUE; leaf has EKU serverAuth | Misissued chain |
| Not revoked (OCSP/CRL, or browser-pushed lists) | Compromised key |
| Certificate Transparency SCTs present (browsers) | Certificate not publicly logged |
Hostname matching has precise rules: a wildcard *.example matches shop.example but not example or a.b.example — the wildcard covers exactly one label.
The most common real-world failure is not a clever attack but disabled validation in code: verify=False in Python requests, InsecureSkipVerify: true in Go, -k in curl, or a custom trust manager in Java that accepts everything. These silently turn HTTPS into unauthenticated encryption, fully vulnerable to on-path attackers.
HTTPS deployments add several protections on top of validation:
- HTTP Strict Transport Security (HSTS): the header
Strict-Transport-Security: max-age=31536000; includeSubDomainstells the browser to use HTTPS only for this host for a year. It defeats SSL stripping, where an attacker intercepts the initial plainhttp://request and keeps the victim on HTTP. Sites can be preloaded into browsers so even the very first visit is protected. - Redirecting HTTP to HTTPS with
301, combined with HSTS. - Certificate pinning: an application accepts only specific keys for a host. It is effective in mobile apps but risky — a lost key or CA change bricks clients — and browsers dropped HTTP-based pinning (HPKP).
- Mixed-content blocking: browsers refuse to load scripts over HTTP on an HTTPS page, since one plaintext script compromises the whole page.
Examples
SSL stripping, and how HSTS stops it
WITHOUT HSTS
user types shop.example
browser --- http://shop.example ---> [attacker on Wi-Fi] --- https://shop.example ---> server
browser <-- HTTP page (links rewritten to http) --- [attacker] <-- HTTPS page ----------
user logs in over HTTP; attacker reads the password. No certificate warning ever shown.
WITH HSTS (previous visit or preload list)
user types shop.example
browser: "shop.example is HSTS -> rewrite to https:// internally"
browser --- TLS ClientHello ---> [attacker] attacker has no valid cert for shop.example
browser shows a hard error with NO click-through option.
Hostname matching
certificate SAN: *.shop.example, shop.example
shop.example -> match (exact)
www.shop.example -> match (wildcard, one label)
api.eu.shop.example -> NO match (two labels)
shop.example.evil.com -> NO match
import requests, ssl, socket
# Correct: verification on by default (uses certifi's CA bundle)
r = requests.get("https://example.com", timeout=5)
print(r.status_code) # 200
# Each failure class, demonstrated against badssl.com test hosts:
for host in ("expired.badssl.com", "wrong.host.badssl.com",
"self-signed.badssl.com", "untrusted-root.badssl.com"):
try:
requests.get(f"https://{host}", timeout=5)
except requests.exceptions.SSLError as e:
reason = str(e).split("certificate verify failed: ")[-1].split(" (")[0]
print(f"{host:28} -> {reason}")
# expired.badssl.com -> certificate has expired
# wrong.host.badssl.com -> Hostname mismatch, certificate is not valid for 'wrong.host.badssl.com'.
# self-signed.badssl.com -> self-signed certificate
# untrusted-root.badssl.com -> self-signed certificate in certificate chain
# WRONG: this disables all of the above and should never reach production
# requests.get("https://internal.api", verify=False)
# RIGHT for a private CA: trust that CA explicitly instead of disabling checks
# requests.get("https://orders.internal", verify="/etc/pki/corp-root.crt")
# Checking HSTS on a site:
print(requests.get("https://github.com", timeout=5).headers.get("Strict-Transport-Security"))
# max-age=31536000; includeSubdomains; preload
# Verify a site's certificate and hostname from the command line (fails loudly on problems):
$ openssl s_client -connect shop.example:443 -servername shop.example \
-verify_hostname shop.example -verify_return_error </dev/null 2>&1 | grep -E "Verify return|verify error"
# Verify return code: 0 (ok)
# Days until expiry -- the check that prevents most HTTPS outages:
$ echo | openssl s_client -connect shop.example:443 -servername shop.example 2>/dev/null \
| openssl x509 -noout -enddate
# notAfter=Nov 30 23:59:59 2026 GMT
# Check that HTTP redirects to HTTPS and that HSTS is set:
$ curl -sI http://github.com | grep -iE "^(HTTP|location)"
# HTTP/1.1 301 Moved Permanently
# Location: https://github.com/
$ curl -sI https://github.com | grep -i strict
# strict-transport-security: max-age=31536000; includeSubdomains; preload
Key takeaways
- HTTPS security depends on certificate validation; encryption to an unauthenticated peer protects nothing.
- Clients check chain to a trusted root, signatures, validity dates, hostname vs SAN, CA constraints, revocation, and CT.
- A wildcard covers exactly one label; browsers check the SAN, not the CN.
- Never ship
verify=Falseor equivalents; for internal services, trust the private CA explicitly. - HSTS (ideally preloaded) defeats SSL stripping; expired certificates remain the most common outage — monitor expiry.
🧪 Practice
- For a certificate with SANs
*.api.exampleandapi.example, list which ofapi.example,v2.api.example,a.v2.api.example, andexamplematch.- Explain step by step how SSL stripping works on a first visit to a site without HSTS preload.
- Write a script that reports days until expiry for a list of hostnames and exits non-zero if any is under 14 days.
- Interview-style: A developer disabled certificate verification "only for the internal service mesh, since it's encrypted anyway". What is the risk? (Hint: encrypted to whom? Think about what an attacker with a foothold on the internal network needs in order to impersonate the service.)
IPsec AH and ESP
Theory
TLS protects individual applications that choose to use it. Sometimes you want to protect all IP traffic between two hosts or networks — regardless of application, including protocols that have no security of their own. IPsec does this at the network layer: it authenticates and encrypts IP packets themselves, invisibly to applications.
IPsec is a framework of three components:
- AH (Authentication Header), IP protocol 51: integrity and authentication, no encryption.
- ESP (Encapsulating Security Payload), IP protocol 50: encryption plus integrity and authentication.
- IKE (Internet Key Exchange): negotiates algorithms and keys (covered in the next topic).
The state for one direction of protected traffic is a Security Association (SA): the algorithms, keys, lifetime, and sequence counter. Each SA is identified by a 32-bit Security Parameter Index (SPI) carried in every packet, so the receiver can look up which keys to use. A bidirectional tunnel therefore needs two SAs. The Security Policy Database (SPD) decides which traffic must be protected (e.g. "10.1.0.0/16 to 10.2.0.0/16 → ESP"), and the Security Association Database (SAD) holds the active SAs.
AH computes its integrity check over the payload and the immutable fields of the outer IP header, including the source and destination addresses. That makes it incompatible with NAT, which rewrites addresses — the check fails at the receiver. Since AH also offers no confidentiality, it is rarely used today.
ESP encrypts the payload and authenticates the ESP header plus the encrypted payload, but not the outer IP header. It is what nearly every IPsec deployment uses, normally with an AEAD such as AES-GCM. To traverse NAT, ESP is wrapped in UDP port 4500 (NAT-T), giving NAT devices a port number to translate.
Both AH and ESP carry a sequence number and the receiver maintains an anti-replay window (typically 64 or more packets): a packet with a sequence number already seen, or too old for the window, is dropped.
| AH | ESP | |
|---|---|---|
| IP protocol | 51 | 50 (or UDP 4500 with NAT-T) |
| Confidentiality | No | Yes |
| Integrity/authentication | Payload + outer IP header | ESP header + payload (not outer IP header) |
| Works through NAT | No | Yes, with NAT-T |
| Anti-replay | Yes | Yes |
| Use today | Rare | Standard |
Examples
Packet layouts (transport mode, for clarity)
Original: [ IP hdr | TCP hdr | data ]
AH: [ IP hdr | AH (SPI, seq, ICV) | TCP hdr | data ]
<---------- authenticated (mutable IP fields zeroed) ------->
nothing encrypted
ESP: [ IP hdr | ESP hdr (SPI, seq) | IV | TCP hdr | data | pad | ESP trailer | ICV ]
<------------------- authenticated ----------------->
<-------- encrypted -------->
outer IP header is NOT authenticated -> NAT can rewrite it
ESP with NAT traversal:
[ IP hdr | UDP 4500 | ESP hdr | ... encrypted ... | ICV ]
Anti-replay window (size 8 for illustration)
highest seq seen = 20
window: 13 14 15 16 17 18 19 20
ok ok X ok ok X ok ok X = already received
arriving 15 -> DROP (replay) arriving 16 -> accept
arriving 12 -> DROP (too old) arriving 25 -> accept, window slides
# Anti-replay window as implemented by IPsec (and by WireGuard, DTLS...).
class ReplayWindow:
def __init__(self, size=64):
self.size = size
self.highest = 0
self.bitmap = 0 # bit i set = (highest - i) already received
def check_and_update(self, seq):
if seq == 0:
return False
if seq > self.highest: # new packet ahead of window
shift = seq - self.highest
self.bitmap = ((self.bitmap << shift) | 1) & ((1 << self.size) - 1)
self.highest = seq
return True
offset = self.highest - seq
if offset >= self.size:
return False # too old
if self.bitmap & (1 << offset):
return False # replay
self.bitmap |= 1 << offset # late but valid
return True
w = ReplayWindow(size=8)
for seq in (1, 2, 3, 5, 4, 5, 20, 13, 13, 12, 3):
print(seq, "accept" if w.check_and_update(seq) else "DROP")
# 1 accept
# 2 accept
# 3 accept
# 5 accept
# 4 accept <- reordered but within the window
# 5 DROP <- replay
# 20 accept <- window slides forward: now covers 13..20
# 13 accept <- late (offset 7) but never seen before
# 13 DROP <- replay
# 12 DROP <- offset 8 >= window size 8: too old
# 3 DROP <- far too old
# Inspect IPsec state on Linux (kernel XFRM framework):
$ sudo ip xfrm policy # the SPD: which traffic must be protected
# src 10.1.0.0/16 dst 10.2.0.0/16
# dir out priority 375423
# tmpl src 198.51.100.1 dst 203.0.113.9
# proto esp reqid 1 mode tunnel
$ sudo ip xfrm state # the SAD: active SAs, one per direction
# src 198.51.100.1 dst 203.0.113.9
# proto esp spi 0xc4a1f2e3 reqid 1 mode tunnel
# replay-window 0 flag af-unspec
# aead rfc4106(gcm(aes)) 0x9f... 128 <- AES-GCM, 128-bit ICV
# encap type espinudp sport 4500 dport 4500 <- NAT traversal active
# src 203.0.113.9 dst 198.51.100.1
# proto esp spi 0x7b0d11a8 reqid 1 mode tunnel
# ESP on the wire: payload is opaque, only SPI and sequence number are visible.
$ sudo tcpdump -ni eth0 'esp or udp port 4500' -c 3
# IP 198.51.100.1.4500 > 203.0.113.9.4500: UDP-encap: ESP(spi=0xc4a1f2e3,seq=0x1a2), length 132
Key takeaways
- IPsec secures traffic at the IP layer, transparently to applications.
- AH authenticates (including outer IP addresses) but does not encrypt and breaks through NAT; ESP encrypts and authenticates and is the standard choice.
- Each direction uses a Security Association identified by an SPI; the SPD says what to protect, the SAD holds keys.
- NAT-T wraps ESP in UDP 4500 so it survives NAT.
- Sequence numbers + an anti-replay window drop duplicated and too-old packets.
🧪 Practice
- With a window of 8 and highest sequence 20 received, state whether each arriving packet is accepted: 14, 14, 12, 21, 13.
- Explain precisely why AH fails when a NAT device sits between the peers, while ESP with NAT-T works.
- How many SAs does a site-to-site tunnel protecting two subnets in both directions need, and why?
- Interview-style: Why would anyone ever choose AH over ESP with NULL encryption? (Hint: compare exactly which bytes of the packet each one authenticates.)
Transport vs Tunnel Mode and IKE
Theory
ESP and AH can protect packets in two ways, depending on whether the endpoints of the protection are the same as the endpoints of the communication.
Transport mode protects the payload of the original IP packet. The original IP header stays in place, with the ESP header inserted after it. It is used for host-to-host protection, where the two machines that encrypt are also the two machines talking — for example, securing traffic between two database servers. It is efficient, but the real source and destination addresses remain visible.
Tunnel mode protects the entire original packet, header included, and wraps it in a new outer IP header addressed between the two security gateways. It is used when the encrypting devices are not the endpoints — gateway-to-gateway (site-to-site VPN) or host-to-gateway (remote access). Observers see only the gateways' addresses; the internal addresses and ports are hidden inside the encrypted payload.
The analogy: transport mode is sealing the letter's contents while leaving the original envelope; tunnel mode is putting the entire addressed envelope inside a new envelope sent between two post offices.
| Transport mode | Tunnel mode | |
|---|---|---|
| Protects | Payload only | Whole original packet |
| Outer IP header | Original | New (gateway to gateway) |
| Internal addresses visible | Yes | No |
| Overhead | ~30–60 bytes | ~50–80 bytes (extra IP header) |
| Typical use | Host-to-host, L2TP/IPsec, GRE over IPsec | Site-to-site and remote-access VPNs |
IKE (Internet Key Exchange) automates SA creation. Manually configuring keys does not scale and never rotates them, so IKE runs on UDP 500 (4500 under NAT) to authenticate peers, negotiate algorithms, and derive keys. IKEv2 (RFC 7296) is the current version; IKEv1 with its "main mode" and "aggressive mode" is legacy and should be avoided (aggressive mode exposes a hash crackable offline for pre-shared keys).
IKEv2 establishes a tunnel in two exchanges (4 messages):
- IKE_SA_INIT: negotiate crypto algorithms, exchange Diffie-Hellman values and nonces. This creates the IKE SA — an encrypted control channel — but neither side is authenticated yet.
- IKE_AUTH: inside the encrypted channel, each side proves its identity (certificates, pre-shared key, or EAP for user credentials) by signing or MACing the previous messages, and they create the first Child SA pair — the ESP SAs that carry data.
Later, CREATE_CHILD_SA exchanges rekey SAs before they expire (optionally with a fresh DH for forward secrecy), and INFORMATIONAL exchanges handle deletes and dead peer detection. IKEv2 also supports MOBIKE, letting a mobile client change IP address without rebuilding the tunnel.
Most IPsec troubleshooting is IKE troubleshooting: mismatched proposals (both sides must support a common algorithm set), mismatched traffic selectors (which subnets each side believes are in the tunnel), wrong identities, or UDP 500/4500 blocked by a firewall.
Examples
Transport vs tunnel mode, same inner packet (10.1.0.5 -> 10.2.0.9)
Original: [ IP 10.1.0.5 -> 10.2.0.9 | TCP | data ]
Transport: [ IP 10.1.0.5 -> 10.2.0.9 | ESP | {TCP | data} | ICV ]
^ real addresses visible { } encrypted
Tunnel: [ IP 198.51.100.1 -> 203.0.113.9 | ESP | {IP 10.1.0.5->10.2.0.9 | TCP | data} | ICV ]
^ gateway addresses only { } entire original packet encrypted
Site-to-site topology using tunnel mode
10.1.0.0/16 10.2.0.0/16
[host 10.1.0.5]--[GW-A 198.51.100.1]====Internet====[GW-B 203.0.113.9]--[host 10.2.0.9]
plaintext inside ESP tunnel mode plaintext inside
IKEv2 exchanges
initiator responder
|-- IKE_SA_INIT: SA proposals, KE (DH pub), Ni ------->|
|<- IKE_SA_INIT: chosen SA, KE (DH pub), Nr ----------| -> IKE SA keys derived
|-- IKE_AUTH {IDi, AUTH, SAi2, TSi, TSr} ------------->| { } = encrypted
|<- IKE_AUTH {IDr, AUTH, SAr2, TSi, TSr} --------------| -> Child SA (ESP) created
|== ESP data ==========================================|
... CREATE_CHILD_SA (rekey) INFORMATIONAL (DPD, delete)
TS = traffic selectors: "10.1.0.0/16 <-> 10.2.0.0/16"
# Overhead calculator: what tunnel-mode ESP does to your effective MTU.
def esp_overhead(mode, iv=8, icv=16, natt=False, payload=1400, ipv6=False, block=4):
ip_hdr = 40 if ipv6 else 20
esp_hdr = 8 # SPI + sequence number
trailer = 2 # pad length + next header
pad = (-(payload + trailer)) % block # AES-GCM pads to 4 bytes
outer_ip = ip_hdr if mode == "tunnel" else 0 # tunnel adds a whole new IP header
udp = 8 if natt else 0
return outer_ip + udp + esp_hdr + iv + pad + trailer + icv
for mode in ("transport", "tunnel"):
for natt in (False, True):
o = esp_overhead(mode, natt=natt)
print(f"{mode:9} NAT-T={str(natt):5} overhead={o:3} bytes "
f"max inner packet for 1500 MTU ~ {1500 - o}")
# transport NAT-T=False overhead= 36 bytes max inner packet for 1500 MTU ~ 1464
# transport NAT-T=True overhead= 44 bytes max inner packet for 1500 MTU ~ 1456
# tunnel NAT-T=False overhead= 56 bytes max inner packet for 1500 MTU ~ 1444
# tunnel NAT-T=True overhead= 64 bytes max inner packet for 1500 MTU ~ 1436
# -> clamp TCP MSS on tunnel interfaces or large packets fragment / black-hole.
# strongSwan site-to-site configuration (swanctl.conf), IKEv2 + tunnel-mode ESP
connections {
site-b {
version = 2
remote_addrs = 203.0.113.9
local { auth = pubkey certs = gw-a.crt id = gw-a.example }
remote { auth = pubkey id = gw-b.example }
proposals = aes256gcm16-sha384-x25519 # IKE SA algorithms
children {
net {
local_ts = 10.1.0.0/16 # traffic selectors
remote_ts = 10.2.0.0/16
mode = tunnel
esp_proposals = aes256gcm16-x25519 # Child SA; DH on rekey = PFS
rekey_time = 1h
start_action = start
}
}
}
}
$ sudo swanctl --initiate --child net
# [IKE] initiating IKE_SA site-b[1] to 203.0.113.9
# [ENC] generating IKE_SA_INIT request 0 [ SA KE No N(NATD_S_IP) N(NATD_D_IP) ... ]
# [ENC] parsed IKE_SA_INIT response 0 [ SA KE No ... ]
# [ENC] generating IKE_AUTH request 1 [ IDi CERT AUTH SA TSi TSr ]
# [IKE] IKE_SA site-b[1] established between 198.51.100.1[gw-a.example]...203.0.113.9[gw-b.example]
# [IKE] CHILD_SA net{1} established with SPIs c4a1f2e3_i 7b0d11a8_o and TS 10.1.0.0/16 === 10.2.0.0/16
$ sudo swanctl --list-sas
# site-b: #1, ESTABLISHED, IKEv2
# net: #1, INSTALLED, TUNNEL, ESP:AES_GCM_16-256/CURVE_25519
# in c4a1f2e3, 48213 bytes, 402 packets
# out 7b0d11a8, 51880 bytes, 398 packets
Key takeaways
- Transport mode protects the payload between the two communicating hosts; tunnel mode encapsulates the whole packet between gateways and hides internal addresses.
- Site-to-site and remote-access VPNs use tunnel mode; the extra outer header reduces effective MTU — clamp MSS.
- IKEv2 (UDP 500/4500) builds an encrypted IKE SA with DH, then authenticates and creates Child SAs in IKE_AUTH — 4 messages total.
- Rekeying with fresh DH (CREATE_CHILD_SA) provides forward secrecy; MOBIKE supports roaming clients.
- Most failures are proposal mismatches, traffic-selector mismatches, identity errors, or blocked UDP 500/4500.
🧪 Practice
- Draw the packet a host at 10.1.0.5 sends to 10.2.0.9 as it leaves GW-A in tunnel mode with NAT-T, labelling every header.
- Why does IKE_SA_INIT carry DH values but no authentication, and what prevents a man-in-the-middle at that stage?
- Using the overhead calculator, find the maximum TCP MSS for a tunnel-mode, NAT-T IPsec path over a 1500-byte MTU.
- Interview-style: An IPsec tunnel comes up, and ping works, but SSH sessions hang as soon as a large file listing is printed. (Hint: small packets succeed and large ones vanish — think about what tunnel encapsulation does to packet size and whether ICMP gets through.)
VPN Types: Site-to-Site and Remote Access
Theory
A Virtual Private Network creates a private, encrypted network path across a public one. Businesses used to buy dedicated leased lines between offices; a VPN achieves a similar result over the Internet at a fraction of the cost. VPNs come in two fundamental shapes, answering different needs.
Site-to-site VPNs connect entire networks. Gateways at each site (routers, firewalls, or cloud VPN gateways) maintain a permanent tunnel, and hosts on either side communicate normally without any VPN software — they simply route through the gateway. Typical uses are linking branch offices to headquarters and connecting an on-premises data centre to a cloud VPC. Topologies include hub-and-spoke (all branches tunnel to a central site) and full mesh, with dynamic designs such as DMVPN creating spoke-to-spoke tunnels on demand. Routes across the tunnel are either static or exchanged with BGP over the tunnel.
Remote-access VPNs connect individual users to a network. A client application on a laptop or phone builds a tunnel to a VPN concentrator, receives an internal IP address, and gains access to internal resources. Authentication is per user, usually with MFA, and increasingly with device posture checks.
A critical design decision for remote access is split tunnelling:
| Full tunnel | Split tunnel | |
|---|---|---|
| Traffic sent through VPN | Everything, including Internet browsing | Only corporate prefixes |
| Security visibility | All traffic inspected by corporate controls | Internet traffic bypasses them |
| Performance | Hairpins video calls and SaaS through HQ | Direct Internet access, lower latency |
| Concentrator load | High | Low |
Protocols used for VPNs differ mainly in layer and firewall-friendliness:
| Protocol | Layer / transport | Typical use | Notes |
|---|---|---|---|
| IPsec (IKEv2) | L3, UDP 500/4500 + ESP | Site-to-site, native OS clients | Standard, interoperable, complex |
| SSL/TLS VPN (OpenVPN, AnyConnect) | TLS over TCP/UDP 443 | Remote access | Passes through restrictive firewalls |
| WireGuard | L3, UDP | Both | Minimal, fast (next topic) |
| L2TP/IPsec, PPTP | L2 over IP | Legacy | PPTP is broken — never use |
Note the difference between these corporate VPNs and consumer "privacy" VPNs: the latter use the same technology but simply move your trust from your ISP to the VPN provider, who can see all your traffic.
VPNs have limits that motivated zero trust (11.1): a remote-access VPN typically grants network-level access, so a compromised laptop on the VPN can scan and attack the internal network. Modern designs restrict what each VPN user can reach, or replace the VPN with per-application access.
Examples
Site-to-site (hub and spoke) Remote access
[Branch A 10.10/16] [laptop at home]
\\ IPsec | VPN client, user cert + MFA
\\ | tunnel to 443/UDP or 500/4500
[HQ hub 10.0/16] === IPsec === [Cloud VPC] v
// (BGP over tunnels) [VPN concentrator] -- assigns 10.99.0.23
// IPsec |
[Branch B 10.20/16] [internal apps 10.0/16]
Hosts need no VPN software. Per-user authentication and policy.
Split vs full tunnel routing on the client
full tunnel: 0.0.0.0/0 -> tun0 (VPN)
split tunnel: 10.0.0.0/8 -> tun0 (VPN)
0.0.0.0/0 -> wlan0 (local Internet)
# Decide which interface a destination would use under split vs full tunnel routing.
import ipaddress
def route(dst, table):
"""Longest-prefix match, as the OS routing table does."""
ip = ipaddress.ip_address(dst)
matches = [(net, dev) for net, dev in table if ip in net]
net, dev = max(matches, key=lambda m: m[0].prefixlen)
return dev, net
split = [(ipaddress.ip_network("0.0.0.0/0"), "wlan0"),
(ipaddress.ip_network("10.0.0.0/8"), "tun0"),
(ipaddress.ip_network("172.16.0.0/12"), "tun0")]
full = [(ipaddress.ip_network("0.0.0.0/0"), "tun0"),
(ipaddress.ip_network("203.0.113.9/32"), "wlan0")] # VPN server itself must bypass the tunnel
for dst in ("10.0.5.20", "172.20.1.1", "142.250.180.14", "203.0.113.9"):
print(f"{dst:16} split -> {route(dst, split)[0]:6} full -> {route(dst, full)[0]}")
# 10.0.5.20 split -> tun0 full -> tun0
# 172.20.1.1 split -> tun0 full -> tun0
# 142.250.180.14 split -> wlan0 full -> tun0 <- Internet traffic: the key difference
# 203.0.113.9 split -> wlan0 full -> wlan0 <- tunnel endpoint must never route into itself
# Remote-access client connected: inspect assigned address and routes (Linux)
$ ip addr show tun0 | grep inet
# inet 10.99.0.23/32 scope global tun0
$ ip route
# default via 192.168.1.1 dev wlan0 <- split tunnel: Internet stays local
# 10.0.0.0/8 dev tun0 scope link <- corporate prefixes via VPN
# 203.0.113.9 via 192.168.1.1 dev wlan0 <- route to the VPN server itself
# Which path does a given destination take?
$ ip route get 10.0.5.20
# 10.0.5.20 dev tun0 src 10.99.0.23
# DNS is a classic split-tunnel leak: check which resolver answers internal names
$ resolvectl status tun0 | grep -E "DNS (Servers|Domain)"
# DNS Servers: 10.0.0.53
# DNS Domain: ~corp.example <- only corp.example queries go to the VPN resolver
Key takeaways
- Site-to-site VPNs join networks through gateways; hosts need no client. Remote-access VPNs connect individual authenticated users.
- Split tunnelling sends only corporate prefixes through the VPN — better performance, less visibility; full tunnel is the opposite.
- IPsec dominates site-to-site; TLS VPNs and WireGuard are common for remote access; PPTP is broken.
- The route to the VPN server itself must bypass the tunnel; DNS configuration is a common source of leaks.
- A classic VPN grants network-level access, which is why zero-trust designs restrict or replace it.
🧪 Practice
- Design a hub-and-spoke VPN for three branches and a cloud VPC, and list which routes each spoke needs.
- Explain one security benefit and one operational cost of forcing full tunnelling for all remote employees.
- Why do TLS-based VPNs work in hotels and airports where IPsec often fails?
- Interview-style: Employees on the VPN can reach internal sites by IP address but not by name. (Hint: routing is clearly fine — which other piece of client configuration does the VPN push, and which resolver is actually being asked?)
WireGuard Design
Theory
IPsec and OpenVPN are powerful, but they are also enormous: hundreds of thousands of lines of code, dozens of negotiable algorithms, and configuration that is easy to get wrong. Every negotiable option is an opportunity for downgrade attacks, interoperability bugs, and misconfiguration. WireGuard (in the Linux kernel since 5.6, 2020) takes the opposite approach: about 4,000 lines of code, no algorithm negotiation, and a configuration model as simple as SSH keys.
Its core design decisions:
- Fixed, modern cryptography ("cryptographic opinionation"): Curve25519 for key exchange, ChaCha20-Poly1305 for AEAD, BLAKE2s for hashing, HKDF for key derivation. There is nothing to negotiate, so there is nothing to downgrade. If a primitive is ever broken, the whole protocol is versioned instead.
- Keys as identities: each peer is identified by its Curve25519 public key, like SSH's
authorized_keys. There are no certificates, users, or passwords in the protocol. - Cryptokey routing: each peer entry lists AllowedIPs. On send, AllowedIPs acts as a routing table (which peer gets a packet for 10.0.0.2?). On receive, it acts as an access list: a decrypted packet whose inner source address is not in that peer's AllowedIPs is dropped. Identity and routing are one table.
- Noise protocol handshake (Noise_IK): a 1-RTT handshake that provides mutual authentication, forward secrecy (ephemeral keys), and identity hiding. Session keys rotate every 2 minutes or after a message count limit.
- Silence as a feature: WireGuard does not respond to unauthenticated packets at all. A port scan sees nothing. Handshake initiations must carry a valid MAC keyed with the responder's public key, so only parties who already know the server's key can even provoke a response.
- DoS resistance with cookies: under load, the responder replies with an encrypted cookie tied to the sender's IP instead of doing expensive DH work, similar in spirit to SYN cookies.
- Stateless-looking operation: there is no "connected" state to manage. A peer that changes IP (mobile roaming) is simply updated to the source address of its last authenticated packet.
- UDP only, and it runs as a network interface (
wg0) that you route to like any other.
| IPsec (IKEv2) | OpenVPN | WireGuard | |
|---|---|---|---|
| Code size | Very large | Large | ~4,000 lines (kernel) |
| Algorithm negotiation | Yes | Yes | None |
| Identity | Certs, PSK, EAP | Certs, user/pass | Public keys |
| Handshake | 2 exchanges (4 msgs) | TLS | 1 RTT (Noise_IK) |
| Responds to scans | Yes (UDP 500) | Yes | No |
| Roaming | MOBIKE | Reconnect | Automatic |
| User auth / MFA | Built in (EAP) | Built in | Not in protocol — added by management layers |
The simplicity has trade-offs: WireGuard does not assign addresses dynamically, has no built-in user authentication or MFA, and stores peer endpoints in memory, which some privacy-focused VPN providers had to work around. Products like Tailscale add those management features on top.
Examples
Cryptokey routing on a WireGuard server
interface wg0: 10.8.0.1/24, private key S, listens UDP 51820
peer (public key) AllowedIPs endpoint (learned)
----------------- ------------ ------------------
laptopKey= ... 10.8.0.2/32 198.51.100.7:40211
phoneKey= ... 10.8.0.3/32 (roams automatically)
branchKey= ... 10.8.0.4/32, 192.168.50.0/24
SEND: packet to 192.168.50.10 -> matches branchKey -> encrypt to that peer
RECEIVE: packet decrypts with laptopKey, inner src = 10.8.0.3
10.8.0.3 not in laptop's AllowedIPs -> DROP (spoofing prevented)
Handshake (1 RTT) and data
initiator responder
|-- Handshake Initiation (148 B) ----------->| ephemeral pub, encrypted static pub,
| | encrypted timestamp, mac1
|<- Handshake Response (92 B) ---------------| ephemeral pub, empty AEAD, mac1
|== Transport data (ChaCha20-Poly1305) =====>|
keys rotate every 2 minutes; invalid packets get NO reply at all
# /etc/wireguard/wg0.conf on the server
[Interface]
Address = 10.8.0.1/24
ListenPort = 51820
PrivateKey = <server-private-key> # generated with `wg genkey`
[Peer]
# laptop
PublicKey = <laptop-public-key>
AllowedIPs = 10.8.0.2/32 # only this inner source address is accepted from it
[Peer]
# branch office router: the whole branch subnet sits behind it
PublicKey = <branch-public-key>
AllowedIPs = 10.8.0.4/32, 192.168.50.0/24
# /etc/wireguard/wg0.conf on the laptop
[Interface]
Address = 10.8.0.2/32
PrivateKey = <laptop-private-key>
DNS = 10.8.0.1
[Peer]
PublicKey = <server-public-key>
Endpoint = vpn.example.com:51820
AllowedIPs = 10.8.0.0/24, 192.168.50.0/24 # split tunnel; use 0.0.0.0/0 for full tunnel
PersistentKeepalive = 25 # keep NAT mappings alive
# Generate a key pair (private key never leaves the host)
$ wg genkey | tee laptop.key | wg pubkey > laptop.pub
$ cat laptop.pub
# vb4Gk3c0sN8vQ...= <- 32-byte Curve25519 public key, base64
$ sudo wg-quick up wg0
$ sudo wg show
# interface: wg0
# public key: HIgo9xNzJMWLKAShlKl6/bUT1VI9Q0SDBXGtLXkPFXc=
# listening port: 51820
#
# peer: vb4Gk3c0sN8vQ...=
# endpoint: 198.51.100.7:40211 <- updated automatically if the laptop roams
# allowed ips: 10.8.0.2/32
# latest handshake: 47 seconds ago <- rekeyed every ~2 minutes while active
# transfer: 18.42 MiB received, 3.10 MiB sent
# A port scan of a WireGuard server shows nothing: unauthenticated packets get no reply.
$ sudo nmap -sU -p 51820 vpn.example.com
# PORT STATE SERVICE
# 51820/udp open|filtered unknown
Key takeaways
- WireGuard trades flexibility for simplicity: ~4,000 lines, no algorithm negotiation, fixed modern primitives.
- Peers are identified by public keys; cryptokey routing makes AllowedIPs both the routing table and the source-address filter.
- A 1-RTT Noise handshake gives mutual authentication and forward secrecy; keys rotate every 2 minutes.
- It is silent to unauthenticated packets, uses cookies under load, and handles roaming automatically.
- It lacks built-in user authentication and dynamic addressing — management layers add them.
🧪 Practice
- Write server and client configs connecting two laptops through a WireGuard server so they can reach each other but nothing else.
- Explain what happens when a peer sends a packet with inner source 10.8.0.9 but its AllowedIPs is 10.8.0.2/32.
- Why does removing algorithm negotiation eliminate downgrade attacks, and what is the cost if a primitive is broken?
- Interview-style: A WireGuard tunnel works until the laptop sits idle behind a home router for a few minutes, then incoming traffic stops arriving. (Hint: WireGuard sends nothing when idle — what does the NAT device in between do with a UDP mapping that sees no traffic, and which option exists for that?)
<a id="114-attacks-and-defenses"></a>
11.4 Attacks and Defenses
Most network attacks exploit a trust assumption made by a protocol from earlier chapters — that nobody else reads the medium, that source addresses are honest, that a reply belongs to a request: this subchapter explains each major attack class at the conceptual level and focuses on how defenders detect and mitigate it.
Packet Sniffing and Man-in-the-Middle
Theory
A network interface normally ignores frames that are not addressed to it. Capture tools such as tcpdump and Wireshark put it into promiscuous mode so it keeps everything it sees. Administrators rely on this every day to troubleshoot. It becomes sniffing, an attack, when someone reads other people's traffic without authorization. Sniffing is passive, so it violates confidentiality only.
How much a capture point can see depends on the medium:
| Medium | Visibility from one capture point |
|---|---|
| Hub / shared segment (legacy) | Everything on the segment |
| Switched Ethernet | Own traffic, broadcasts, flooded unknown-unicast |
| Open (unencrypted) Wi-Fi | Every frame in radio range |
| WPA3 Wi-Fi | Per-client encryption; other clients' frames unreadable |
| SPAN port / network tap | Whatever the operator mirrors (the legitimate monitoring point) |
A man-in-the-middle (MITM) is an active position: traffic flows through the attacker, who can read, alter, inject, or drop it while both endpoints believe they are talking directly. At a high level, attackers reach that position by abusing unauthenticated control protocols: address resolution, DHCP, DNS, rogue Wi-Fi access points, or, at Internet scale, BGP. The next topic covers why those protocols are exploitable and how switches and resolvers defend them.
The defence strategy has three layers:
- Make the position useless. Use end-to-end authenticated encryption (TLS with validated certificates, SSH with verified host keys, IPsec, WireGuard). An on-path party then sees only ciphertext, and any change fails the AEAD check. This is the decisive control.
- Make the position hard to reach. Switch features (port security, DHCP snooping, Dynamic ARP Inspection), 802.1X, WPA3, and HSTS each close one path.
- Notice it. Changed host keys, unexpected certificate issuers, duplicate address mappings, and hosts in promiscuous mode are all observable symptoms.
Encryption does not hide everything. Metadata stays visible: IP addresses, ports, packet sizes, timing, and (without ECH or encrypted DNS) the hostnames being visited.
Examples
Passive observation vs active interception
PASSIVE
[client] -----------------------> [gateway] ---> Internet
|
[capture point] copies frames, cannot change them
ACTIVE (on-path)
[client] ----> [on-path party] ----> [gateway] ---> Internet
Without end-to-end crypto: can read and modify.
With TLS/SSH/IPsec: sees ciphertext; any change -> integrity failure.
What an on-path observer still learns from a TLS 1.3 session (no ECH)
visible : src/dst IP, ports, timing, sizes, SNI hostname, DNS (if plaintext)
protected : URL path, headers, cookies, request/response bodies
# Defensive audit: list which of YOUR services still accept plaintext connections.
import socket, ssl
SERVICES = [("intranet.lab", 80), ("intranet.lab", 443),
("printer.lab", 80), ("mail.lab", 143), ("mail.lab", 993)]
def probe(host, port, timeout=3):
try:
with socket.create_connection((host, port), timeout=timeout) as s:
try:
ctx = ssl.create_default_context()
with ctx.wrap_socket(s, server_hostname=host) as t:
return f"TLS {t.version()}"
except ssl.SSLCertVerificationError as e:
return f"TLS, but certificate invalid ({e.verify_message})"
except (ssl.SSLError, ConnectionResetError):
return "PLAINTEXT" # port open, no TLS handshake
except OSError:
return "closed"
for host, port in SERVICES:
print(f"{host}:{port:<5} {probe(host, port)}")
# intranet.lab:80 PLAINTEXT <- should redirect to 443 and send HSTS
# intranet.lab:443 TLS TLSv1.3
# printer.lab:80 PLAINTEXT <- admin UI readable by anyone on the path
# mail.lab:143 PLAINTEXT <- IMAP without STARTTLS enforcement
# mail.lab:993 TLS TLSv1.3
# Detect interfaces in promiscuous mode on hosts you manage:
$ ip -d link | grep -E "PROMISC|promiscuity [1-9]"
# 3: eth0: <BROADCAST,MULTICAST,PROMISC,UP,LOWER_UP> ... <- something is capturing here
# SSH host-key verification is MITM detection. Record fingerprints out-of-band once:
$ ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
# 256 SHA256:Xq3v...kE root@core-sw (ED25519)
# A later "REMOTE HOST IDENTIFICATION HAS CHANGED" warning must be investigated, not bypassed.
# Confirm a site cannot be silently downgraded to HTTP:
$ curl -sI https://intranet.lab | grep -i strict-transport-security
# strict-transport-security: max-age=31536000; includeSubDomains
Key takeaways
- Sniffing is passive reading; a MITM is an active on-path position that can also modify, inject, and drop.
- Authenticated end-to-end encryption makes an on-path position useless, provided certificates and host keys are actually verified.
- Link-layer controls make the position harder to reach; host-key and certificate warnings are how it gets detected.
- Metadata (addresses, sizes, timing, SNI, plaintext DNS) stays visible even under TLS.
🧪 Practice
- Capture your own traffic to an HTTP-only lab service and then to its HTTPS version, and compare what is readable in each.
- List four things an on-path observer still learns about a TLS 1.3 connection without ECH.
- Explain why a switched network reduces but does not remove the need for encryption on the LAN.
- Interview-style: Users on guest Wi-Fi occasionally get certificate warnings for the intranet site, but never on the corporate SSID. What do you investigate? (Hint: a certificate warning is what TLS shows when something other than the real server answers. Which SSID has fewer controls on who can do that?)
Spoofing: IP, ARP, and DNS
Theory
Spoofing means a packet carries a false identity. It is possible because several foundational protocols were designed for cooperative networks and do not authenticate their messages. Each one needs a different defence.
IP source spoofing. IP does not verify the source address field. Replies go to the claimed address, not to the sender, so a spoofer normally cannot complete a TCP handshake. That limits spoofing to one-way abuse, most importantly reflection and amplification DDoS, where the claimed source is the victim. The fix is at the network edge:
- BCP 38 / ingress-egress filtering: a network forwards outbound packets only if their source belongs to its own prefixes.
- uRPF (unicast Reverse Path Forwarding): a router drops a packet if its source address would not be routed back out of the interface it arrived on.
ARP spoofing (and IPv6 ND/RA spoofing). ARP (Chapter 4) accepts replies without any proof of who sent them. So a host on the same LAN can falsely claim another host's IP address, such as the default gateway's, and draw that traffic to itself. IPv6 Neighbor Discovery and Router Advertisements have the same trust gap. Defences live on the access switch:
- DHCP snooping builds a trusted table of IP-MAC-port bindings from legitimate DHCP exchanges and blocks DHCP server messages on user ports.
- Dynamic ARP Inspection (DAI) drops ARP messages that contradict that table.
- RA Guard and ND inspection do the same for IPv6.
- Monitoring (arpwatch, NAC) alerts when an IP's MAC changes, especially the gateway's.
DNS spoofing and cache poisoning. A resolver accepts a response that matches its outstanding query. If an off-path party gets a forged answer accepted first, the resolver caches it and misdirects every client that uses it. The 2008 Kaminsky disclosure showed how practical this was when the only per-query randomness was the 16-bit transaction ID. The ecosystem answered with layered protections:
| Defence | Effect |
|---|---|
| Source-port randomisation | Adds ~16 bits of unpredictability per query |
| 0x20 case randomisation | Adds entropy through the echoed query name |
| DNSSEC | Records are signed; forged data fails validation |
| DoT / DoH | Authenticated, encrypted client-to-resolver channel |
| Minimal responses, bailiwick checks | Resolver ignores out-of-zone "extra" records |
In all three cases the lesson is the same: identity claimed in a header is not identity proven. Durable fixes either verify the claim cryptographically (DNSSEC, TLS) or enforce it at a trusted network choke point (uRPF, DAI).
Examples
Where each defence sits
[host] --access port-- [access switch] ------ [edge router] ------ Internet
DHCP snooping uRPF / BCP 38
DAI, RA Guard (drop forged sources)
port security
[host] -- DoT/DoH --> [validating resolver] -- DNSSEC validation --> authoritative
Dynamic ARP Inspection decision
DHCP snooping table: 10.0.0.50 bb:bb:bb:bb:bb:bb port Gi1/0/5 VLAN 10
ARP from Gi1/0/5 saying "10.0.0.50 is at bb:bb:.." -> matches -> forward
ARP from Gi1/0/9 saying "10.0.0.1 is at ee:ee:.." -> no binding -> DROP + log
uRPF (strict) decision on the edge router
packet arrives on customer port with source 198.51.100.7
route back to 198.51.100.7 goes out the SAME port? yes -> forward
no -> DROP (forged source)
# Defensive monitor: alert when the MAC address behind the default gateway changes
# (a symptom of ARP spoofing or of a real failover; both deserve a look). Linux only.
import subprocess, time
def gateway_ip():
out = subprocess.run(["ip", "route", "show", "default"], capture_output=True, text=True).stdout
return out.split()[2] # "default via 192.168.1.1 dev eth0 ..."
def mac_for(ip):
out = subprocess.run(["ip", "neigh", "show", ip], capture_output=True, text=True).stdout
parts = out.split()
return parts[parts.index("lladdr") + 1] if "lladdr" in parts else None
def watch(interval=5):
gw = gateway_ip()
baseline = mac_for(gw)
print(f"gateway {gw} baseline MAC {baseline}")
while True:
time.sleep(interval)
current = mac_for(gw)
if current and current != baseline:
print(f"ALERT: gateway {gw} MAC changed {baseline} -> {current}")
baseline = current
# watch()
# gateway 192.168.1.1 baseline MAC 3c:37:86:1f:2a:9b
# ALERT: gateway 192.168.1.1 MAC changed 3c:37:86:1f:2a:9b -> 08:00:27:5e:c1:04
# Switch hardening (Cisco IOS syntax): DHCP snooping + Dynamic ARP Inspection
switch(config)# ip dhcp snooping
switch(config)# ip dhcp snooping vlan 10
switch(config)# ip arp inspection vlan 10
switch(config)# interface Gi1/0/48
switch(config-if)# ip dhcp snooping trust # uplink toward the real DHCP server
switch(config-if)# ip arp inspection trust
switch# show ip arp inspection statistics vlan 10
# Vlan Forwarded Dropped DHCP Drops ACL Drops
# 10 18231 42 42 0 <- dropped = rejected ARP
# Edge router: strict uRPF drops packets with forged source addresses (Linux equivalent)
$ sudo sysctl -w net.ipv4.conf.all.rp_filter=1
# Resolver side: confirm DNSSEC validation is working
$ dig +dnssec example.com A | grep -E "flags:|RRSIG"
# ;; flags: qr rd ra ad; <- "ad" = authenticated data, validated by the resolver
$ dig dnssec-failed.org A | grep status
# ;; ->>HEADER<<- opcode: QUERY, status: SERVFAIL <- deliberately broken zone rejected
Key takeaways
- IP, ARP, and DNS do not authenticate their messages, so a claimed identity in a header proves nothing.
- IP spoofing enables one-way abuse (notably reflection DDoS); BCP 38 and uRPF stop it at the network edge.
- ARP/ND spoofing is a LAN problem; DHCP snooping + DAI + RA Guard on access switches are the fix.
- DNS cache poisoning is countered by randomisation, DNSSEC validation, and encrypted DNS.
- Durable defences either verify cryptographically or enforce at a trusted choke point.
🧪 Practice
- Explain why a forged source address prevents completing a TCP handshake but not sending a UDP request.
- Describe what DHCP snooping must learn before Dynamic ARP Inspection can make decisions, and what happens to hosts with static IPs.
- Calculate how many combinations an off-path guesser faces with a random 16-bit transaction ID alone versus ID plus a random 16-bit source port.
- Interview-style: After enabling DAI on a VLAN, a few servers lose connectivity while DHCP clients are fine. (Hint: DAI checks ARP against a table. Ask how entries get into that table and which hosts never create one.)
TCP SYN Flood and SYN Cookies
Theory
The TCP three-way handshake (Chapter 7) requires the server to remember each half-open connection: when a SYN arrives, it allocates state, sends a SYN-ACK, and waits for the final ACK. That state lives in a finite SYN (backlog) queue. A SYN flood sends SYNs, typically from forged sources, and never completes the handshake. The queue fills with half-open entries, and legitimate clients are refused. It is an availability attack on a state asymmetry: the attacker spends one small packet, the server spends memory and a retransmission timer.
The analogy is a restaurant that holds a table for every phone call saying "I'm coming". Enough prank calls and there are no tables for real guests, even though the dining room is empty.
SYN cookies remove the asymmetry: the server stops storing state for half-open connections. Instead it encodes the essential state into the initial sequence number of its SYN-ACK:
ISN = time_counter (5 bits) | MSS index (3 bits) | MAC_k(src IP, src port, dst IP, dst port, time) (24 bits)
The server forgets the SYN entirely. A legitimate client returns the ACK with ack = ISN + 1. The server subtracts one, recomputes the MAC over the connection 4-tuple, checks that the time counter is recent, recovers the MSS, and only then creates the full connection. A forged SYN never produces an ACK, so it costs nothing beyond one reply packet.
Trade-offs of SYN cookies:
- Only a few bits remain for TCP options, so options such as window scaling and SACK used to be lost. Linux now encodes them in the timestamp option when timestamps are enabled.
- They are typically activated only when the queue overflows, so normal operation is unaffected.
- They protect server state, not bandwidth. A flood large enough to saturate the link needs upstream mitigation (next topic).
Other mitigations complement cookies: larger backlogs, shorter SYN-ACK retry counts, per-source rate limits, and SYN proxies on load balancers or firewalls that complete the handshake before involving the server.
Examples
Normal handshake vs flood vs cookies
NORMAL
client --SYN--> server [alloc entry in SYN queue]
client <-SYN/ACK- server
client --ACK--> server [move entry to accept queue]
FLOOD (forged sources)
??? --SYN--> server [entry 1] SYN/ACK -> nobody answers
??? --SYN--> server [entry 2] ...retransmits for ~60 s...
??? --SYN--> server [queue FULL] -> real clients' SYNs dropped
SYN COOKIES
??? --SYN--> server: ISN = encode(time, MSS, MAC_k(4-tuple)) stores NOTHING
real client --ACK(ISN+1)--> server: verify MAC, check time, recover MSS -> create connection
forged SYNs never ACK -> zero memory spent
# A simplified SYN cookie encoder/verifier (the real Linux layout differs slightly).
import hmac, hashlib, time, os
SECRET = os.urandom(32)
MSS_TABLE = [536, 1300, 1440, 1460] # index fits in 2-3 bits
def _mac(saddr, sport, daddr, dport, t):
msg = f"{saddr}:{sport}->{daddr}:{dport}@{t}".encode()
return int.from_bytes(hmac.new(SECRET, msg, hashlib.sha256).digest()[:3], "big") # 24 bits
def make_cookie(saddr, sport, daddr, dport, mss, now=None):
t = int((now or time.time()) // 64) & 0x1F # 5-bit counter, 64-second slots
mss_idx = max(i for i, m in enumerate(MSS_TABLE) if m <= mss)
return (t << 27) | (mss_idx << 24) | _mac(saddr, sport, daddr, dport, t)
def check_cookie(ack, saddr, sport, daddr, dport, now=None):
cookie = (ack - 1) & 0xFFFFFFFF # client ACKs ISN + 1
t, mss_idx, mac = cookie >> 27, (cookie >> 24) & 0x7, cookie & 0xFFFFFF
current = int((now or time.time()) // 64) & 0x1F
if (current - t) % 32 > 1: # accept current or previous slot only
return None
if mac != _mac(saddr, sport, daddr, dport, t) or mss_idx >= len(MSS_TABLE):
return None
return MSS_TABLE[mss_idx] # handshake valid: recovered MSS
isn = make_cookie("198.51.100.7", 51514, "203.0.113.10", 443, mss=1460)
print(check_cookie(isn + 1, "198.51.100.7", 51514, "203.0.113.10", 443)) # 1460
print(check_cookie(isn + 1, "198.51.100.8", 51514, "203.0.113.10", 443)) # None (different 4-tuple)
print(check_cookie(isn + 1, "198.51.100.7", 51514, "203.0.113.10", 443,
now=time.time() + 600)) # None (expired)
# Linux: SYN cookies are on by default and engage when the SYN queue overflows
$ sysctl net.ipv4.tcp_syncookies
# net.ipv4.tcp_syncookies = 1 # 1 = on overflow, 2 = always
# Complementary tuning:
$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=4096 # larger half-open queue
$ sudo sysctl -w net.ipv4.tcp_synack_retries=2 # give up on unanswered SYN-ACKs sooner
# Detecting a flood: many SYN-RECV sockets and cookie counters rising
$ ss -tan state syn-recv | wc -l
# 3872
$ nstat -az | grep -i syncookies
# TcpExtSyncookiesSent 148213 <- cookies being issued: queue overflowed
# TcpExtSyncookiesRecv 1204 <- valid ACKs returned: real clients still served
# TcpExtSyncookiesFailed 30112 <- ACKs with invalid cookies
$ dmesg | grep -i "SYN flooding"
# TCP: request_sock_TCP: Possible SYN flooding on port 443. Sending cookies.
Key takeaways
- A SYN flood exploits the server's need to store half-open connection state; a full SYN queue refuses real clients.
- SYN cookies encode that state (time, MSS, a keyed MAC of the 4-tuple) into the ISN, so the server stores nothing until a valid ACK returns.
- Cookies activate on queue overflow, cost some TCP options (mitigated by timestamps), and protect state, not bandwidth.
- Complement them with backlog tuning, fewer SYN-ACK retries, rate limits, and SYN proxies.
- Monitor
SYN-RECVcounts andSyncookies*counters to detect floods.
🧪 Practice
- Explain why a SYN flood is effective even at a packet rate far below the server's bandwidth.
- Using the code above, show why a cookie issued for one client cannot be reused by a different source address.
- Why does a SYN cookie include a time counter, and what would go wrong without it?
- Interview-style: SYN cookies are enabled, yet the service is still unreachable during an attack. (Hint: cookies remove one resource bottleneck. Which other resource does every incoming packet still consume before the kernel ever sees it?)
DoS and DDoS Techniques and Mitigation
Theory
A denial-of-service (DoS) attack targets availability: it exhausts some resource so legitimate users are not served. When the traffic comes from many sources at once, usually a botnet, it is a distributed DoS (DDoS). Blocking one address is then useless, and total volume can exceed any single site's capacity.
Attacks are classified by which resource they exhaust. Defences follow from that classification:
| Class | Resource exhausted | Measured in | Examples (conceptual) | Primary mitigation |
|---|---|---|---|---|
| Volumetric | Link bandwidth | Gb/s, Tb/s | Reflection/amplification, UDP floods | Upstream scrubbing, anycast absorption, RTBH |
| Protocol / state | Connection tables in servers, firewalls, load balancers | Packets/s | SYN floods, fragment floods | SYN cookies/proxies, stateless filtering |
| Application (L7) | CPU, database, threads | Requests/s | HTTP request floods on expensive endpoints | Rate limiting, caching, challenges, WAF |
Reflection and amplification deserve particular understanding, because they produce the largest attacks. Some UDP services answer a small request with a much larger response. If request packets carry the victim's forged source address, the service "reflects" the enlarged response to the victim. The amplification factor (response bytes / request bytes) varies hugely: DNS with large records roughly 28–54x, NTP's legacy monlist up to ~550x, memcached over UDP up to tens of thousands. The defences are systemic: BCP 38 (no forged sources), and not exposing reflector-capable services (open resolvers, NTP monitoring commands, UDP memcached) to the Internet.
Because volumetric attacks saturate the link before reaching your equipment, mitigation must happen upstream:
- Anycast + CDN absorption: the same IP is announced from dozens of sites, so the attack is spread across global capacity.
- Scrubbing centres: traffic is diverted by BGP or DNS to a provider that filters it and forwards the clean remainder.
- Remotely Triggered Black Hole (RTBH) and BGP Flowspec: ask upstream routers to drop traffic to a victim prefix or matching a filter. Blackholing sacrifices the target to save the rest of the network.
Application-layer attacks look like real users, so they need behaviour-based defences: rate limiting per client or token, caching so repeated requests are cheap, challenges (JavaScript or proof-of-work) for suspicious clients, and load shedding that protects critical endpoints first. Capacity planning, autoscaling, and a rehearsed runbook with your provider are part of the defence too.
Examples
Where each class hits, and where it must be stopped
Internet ===(300 Gb/s of attack)===> [ISP edge] ===(10 Gb/s link)===> [firewall] -> [LB] -> [app] -> [DB]
^ ^ ^ ^
VOLUMETRIC saturates the 10 Gb/s link --+ | | |
-> must be filtered UPSTREAM (scrubbing, anycast, RTBH) | | |
PROTOCOL/STATE fills connection tables ------------------------------- +-----------+ |
-> SYN cookies, stateless ACLs, SYN proxy on the LB | |
APPLICATION looks like real requests, exhausts CPU / DB ---------------------------+--------+
-> rate limiting, caching, challenges, load shedding
Rule of thumb: a defence placed downstream of the bottleneck cannot save it.
Amplification arithmetic (why forged sources matter so much)
request 60 B ---> [ reflector service ] ---> response 3,000 B to the FORGED source
amplification factor = 3000 / 60 = 50x
attacker uplink 1 Gb/s x 50 = 50 Gb/s arriving at the victim
the victim sees traffic from thousands of INNOCENT servers, not from the attacker
Two independent fixes:
1. BCP 38 at the attacker's ISP -> forged source never leaves the network
2. no open reflectors on the Internet -> nothing to amplify with
# Capacity math for a DDoS runbook: at what point does each layer fail?
def saturation(attack_gbps, link_gbps, pps_capacity, avg_pkt_bytes=100):
attack_pps = (attack_gbps * 1e9) / (avg_pkt_bytes * 8) # small packets = high pps
return {
"link saturated": attack_gbps > link_gbps,
"attack Mpps": round(attack_pps / 1e6, 2),
"forwarding saturated": attack_pps > pps_capacity,
}
# A 10 Gb/s edge link and a firewall rated 2 Mpps:
for gbps in (1, 5, 20, 300):
print(f"{gbps:>4} Gb/s ->", saturation(gbps, link_gbps=10, pps_capacity=2_000_000))
# 1 Gb/s -> {'link saturated': False, 'attack Mpps': 1.25, 'forwarding saturated': False}
# 5 Gb/s -> {'link saturated': False, 'attack Mpps': 6.25, 'forwarding saturated': True}
# ^ note: the FIREWALL dies at 5 Gb/s while the link is still only half full --
# packet rate, not bandwidth, is often the real limit
# 20 Gb/s -> {'link saturated': True, 'attack Mpps': 25.0, 'forwarding saturated': True}
# 300 Gb/s -> {'link saturated': True, 'attack Mpps': 375.0, 'forwarding saturated': True}
# ^ nothing on-premises can help: only upstream scrubbing or anycast
# Application-layer defence: a token bucket per client, plus priority load shedding.
import time
from collections import defaultdict
class TokenBucket:
def __init__(self, rate, capacity):
self.rate, self.capacity = rate, capacity
self.tokens, self.last = capacity, time.monotonic()
def allow(self, cost=1):
now = time.monotonic()
self.tokens = min(self.capacity, self.tokens + (now - self.last) * self.rate)
self.last = now
if self.tokens >= cost:
self.tokens -= cost
return True
return False
buckets = defaultdict(lambda: TokenBucket(rate=10, capacity=20)) # 10 req/s, burst 20
# Expensive endpoints cost more tokens than cheap ones:
COST = {"/search": 5, "/report": 20, "/health": 0, "/api/item": 1}
# Under overload, shed by priority so critical traffic survives longest:
PRIORITY = {"/health": 0, "/api/item": 1, "/search": 2, "/report": 3} # higher = shed first
def handle(client, path, system_load):
if system_load > 0.9 and PRIORITY.get(path, 3) >= 3:
return 503, "shed: low-priority endpoint during overload"
if not buckets[client].allow(COST.get(path, 1)):
return 429, "rate limited (Retry-After: 1)"
return 200, "served"
print(handle("203.0.113.5", "/report", 0.5)) # (200, 'served')
print(handle("203.0.113.5", "/report", 0.5)) # (429, 'rate limited (Retry-After: 1)')
print(handle("203.0.113.5", "/health", 0.95)) # (200, 'served') <- health checks always pass
print(handle("203.0.113.5", "/report", 0.95)) # (503, 'shed: low-priority endpoint during overload')
# Is a resolver you run an OPEN resolver that strangers can abuse as a reflector?
$ dig +short @my-resolver.example.com example.com A
# ;; connection timed out; no servers could be reached <- good: not answering outsiders
# Check your own NTP server does not answer legacy monitoring queries:
$ ntpq -c rv my-ntp.example.com 2>&1 | head -2
# timed out, nothing received <- good: monitoring commands disabled
# Kernel-level protections against state-exhaustion floods:
$ sudo sysctl -w net.ipv4.tcp_syncookies=1
$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=8192
$ sudo sysctl -w net.netfilter.nf_conntrack_max=1048576 # conntrack table is itself a DoS target
# Rate-limit new connections at the edge with nftables (protocol-layer defence):
$ sudo nft add rule inet filter input tcp dport 443 ct state new \
limit rate over 200/second burst 400 packets counter drop
# Ask your upstream to blackhole a targeted /32 (RTBH) -- last resort, sacrifices that host:
# router bgp 64500
# neighbor 198.51.100.1 route-map RTBH-OUT out ! tags routes with community 64500:666
# ip route 203.0.113.77 255.255.255.255 null0 tag 666
# During an incident, confirm WHERE the bottleneck is before "fixing" the wrong layer:
$ ip -s link show eth0 | sed -n 3,6p
# RX: bytes packets errors dropped missed mcast
# 998887766 5123444 0 184322 0 12 <- drops at the NIC: pps/link problem
$ ss -s | head -3
# Total: 284113 (kernel 0)
# TCP: 281002 (estab 1204, closed 12, orphaned 0, synrecv 279001) <- synrecv: SYN flood
Key takeaways
- Classify a DoS by the resource it exhausts — bandwidth, connection state, or application CPU — because that determines where it must be stopped.
- Packet rate often kills middleboxes before bandwidth fills the link; measure both.
- Volumetric attacks must be handled upstream: anycast/CDN absorption, scrubbing centres, RTBH, or Flowspec.
- Amplification needs two enablers — forged sources and exposed reflector services — and BCP 38 plus not running open reflectors removes both.
- L7 floods look legitimate: defend with rate limiting, caching, challenges, and priority-based load shedding; rehearse the runbook with your provider in advance.
🧪 Practice
- An attacker controls 2 Gb/s of upstream capacity and uses a service with a 40x amplification factor. Compute the traffic arriving at the victim, and name the two independent fixes.
- Explain why a 3 Gb/s flood of 64-byte packets can break a firewall sitting behind a 10 Gb/s link that is never saturated.
- Classify each and name a mitigation: a flood of HTTP GETs against
/search?q=random, a flood of TCP SYNs, a 400 Gb/s UDP flood.- Interview-style: Your site is down under attack. You add firewall rules dropping the attack traffic, and it stays down. Why? (Hint: where is the traffic being discarded relative to the resource that is actually full?)
Session Hijacking and Replay Attacks
Theory
Authentication usually happens once, at the start of a session. Everything afterwards is authorised by a session token — a cookie, a bearer token, a TCP sequence number, a Wi-Fi association. That creates an obvious target: rather than attacking the login, an attacker takes over the already authenticated session. This is session hijacking, and it bypasses even perfect MFA, because MFA protected the login, not the token.
A session token is a bearer credential: whoever holds it is treated as the user. Tokens leak through predictable channels:
| Leak path | Example | Fix |
|---|---|---|
| Plaintext transport | Cookie sent over HTTP | HTTPS everywhere + HSTS + Secure flag |
| Client-side scripts | XSS reading document.cookie | HttpOnly flag, Content-Security-Policy |
| Cross-site requests | Browser attaching cookies to a forged request (CSRF) | SameSite=Lax/Strict, CSRF tokens |
| Weak generation | Sequential or timestamp-based IDs | ≥128 bits from a CSPRNG |
| No rotation | Same token before and after login | Regenerate the session ID on privilege change |
| Unbounded lifetime | Token valid for months | Idle + absolute timeouts, server-side revocation |
| Logging and URLs | Token in a query string, ending up in logs and Referer headers | Tokens only in headers or cookies |
Replay attacks are the related problem one layer down: an attacker captures a valid message and sends it again later. The message is authentic — its signature or MAC verifies — but it is not fresh. A captured "unlock the door" command or "transfer 100 EUR" request must not work twice. Integrity alone does not stop replay; you need freshness:
- Nonces: a random value the verifier remembers and accepts only once (challenge-response).
- Sequence numbers with a replay window: as in IPsec, WireGuard, and TLS records.
- Timestamps with a tolerance window: simple, but requires clock sync and short windows (Kerberos uses ±5 minutes).
- Idempotency keys: application-level; the server records the key and returns the first result instead of acting twice.
TLS and SSH make network-level replay essentially impossible within a session, because record sequence numbers are authenticated. Replay problems in practice appear where messages are protected individually rather than by a session: webhooks, API request signing, hardware tokens, and TLS 1.3 0-RTT early data (which is why only idempotent requests belong there).
Examples
Hijacking vs replay
HIJACKING: attacker obtains the token and acts as the user
[user] --login + MFA--> [server] token T issued
token T leaks (HTTP, XSS, log file, shared machine)
[attacker] --request with token T--> [server] "welcome back, alice"
MFA never re-enters the picture: the token IS the credential
REPLAY: attacker resends a captured, validly signed message
[client] --{"amount":100, sig}--> [server] accepted, money moves
[attacker] --{"amount":100, sig}--> [server] signature STILL VALID
fix: server must reject a message it has already processed (nonce / seq / idempotency key)
Freshness mechanisms compared
nonce/challenge : strongest, needs a round trip and server state
sequence number : cheap, needs ordered session state (TLS, IPsec, WireGuard)
timestamp : stateless-ish, needs clock sync + short window + recent-message cache
idempotency key : application-level, also fixes accidental double-submits
# Server-side session handling that resists hijacking. Contrast the two token issuers.
import secrets, time, hmac, hashlib
# WRONG: predictable token -- an attacker who sees one can guess neighbours
counter = 1000
def weak_token():
global counter
counter += 1
return f"sess-{counter}"
print(weak_token(), weak_token()) # sess-1001 sess-1002 <- guessable
# RIGHT: 256 bits from a CSPRNG
def strong_token():
return secrets.token_urlsafe(32) # ~256 bits of entropy, URL-safe
print(strong_token()[:16] + "...") # e.g. 'x7Qk3mZ1p...'
class SessionStore:
IDLE_TIMEOUT = 30 * 60 # 30 minutes without activity
ABSOLUTE_TIMEOUT = 12 * 3600 # re-authenticate after 12 hours regardless
def __init__(self):
self.sessions = {}
def create(self, user, ip, ua):
tok = strong_token()
now = time.time()
self.sessions[tok] = {"user": user, "created": now, "seen": now,
"ip": ip, "ua_hash": hashlib.sha256(ua.encode()).hexdigest()}
return tok
def validate(self, tok, ip, ua):
s = self.sessions.get(tok)
if not s:
return None, "unknown or revoked token"
now = time.time()
if now - s["seen"] > self.IDLE_TIMEOUT:
del self.sessions[tok]; return None, "idle timeout"
if now - s["created"] > self.ABSOLUTE_TIMEOUT:
del self.sessions[tok]; return None, "absolute timeout: re-authenticate"
# Binding signals: a changed user agent is suspicious; IP changes are normal on mobile,
# so treat them as a signal for step-up auth, not an automatic block.
if s["ua_hash"] != hashlib.sha256(ua.encode()).hexdigest():
del self.sessions[tok]; return None, "client fingerprint changed: session dropped"
s["seen"] = now
return s["user"], "ok"
def regenerate(self, old_tok):
"""Call on login and on any privilege change: defeats session fixation."""
s = self.sessions.pop(old_tok)
new = strong_token()
self.sessions[new] = s
return new
store = SessionStore()
t = store.create("alice", "198.51.100.7", "Mozilla/5.0 (Macintosh)")
print(store.validate(t, "198.51.100.7", "Mozilla/5.0 (Macintosh)")) # ('alice', 'ok')
print(store.validate(t, "203.0.113.9", "curl/8.4.0")) # (None, 'client fingerprint changed: session dropped')
print(store.validate(t, "198.51.100.7", "Mozilla/5.0 (Macintosh)")) # (None, 'unknown or revoked token')
# Replay protection for signed webhooks: signature + timestamp + nonce cache.
import hmac, hashlib, time
SECRET = b"whsec_example"
seen_ids = {} # id -> expiry; use Redis with TTL in production
TOLERANCE = 300 # accept messages at most 5 minutes old
def verify_webhook(msg_id, timestamp, body, signature, now=None):
now = now or time.time()
# 1. Freshness by timestamp: an old captured message is refused outright.
if abs(now - timestamp) > TOLERANCE:
return False, "timestamp outside tolerance window"
# 2. Signature must cover the timestamp and id, or an attacker could just change them.
signed_payload = f"{msg_id}.{int(timestamp)}.".encode() + body
expected = hmac.new(SECRET, signed_payload, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, signature):
return False, "bad signature"
# 3. Single use within the window: the nonce cache catches replays inside the tolerance.
for k, exp in list(seen_ids.items()):
if exp < now:
del seen_ids[k]
if msg_id in seen_ids:
return False, "replay detected (message id already processed)"
seen_ids[msg_id] = now + TOLERANCE
return True, "accepted"
now = time.time()
body = b'{"event":"payment","amount":100}'
sig = hmac.new(SECRET, f"evt_1.{int(now)}.".encode() + body, hashlib.sha256).hexdigest()
print(verify_webhook("evt_1", now, body, sig, now)) # (True, 'accepted')
print(verify_webhook("evt_1", now, body, sig, now + 10)) # (False, 'replay detected (message id already processed)')
print(verify_webhook("evt_1", now, body, sig, now + 600)) # (False, 'timestamp outside tolerance window')
# Cookie flags to look for when reviewing a service (all three should be present):
$ curl -sI https://app.example.com/login | grep -i set-cookie
# set-cookie: session=8f2a...; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600
# ^Secure: HTTPS only ^HttpOnly: no JS access ^SameSite: CSRF
# A token in a URL leaks into logs, browser history and Referer headers -- flag it in review:
# BAD : GET /dashboard?session=8f2a...
# GOOD: Cookie: session=8f2a... (or Authorization: Bearer ...)
# Confirm the session ID actually changes at login (defeats session fixation):
$ curl -sc jar -o /dev/null https://app.example.com/ && grep -o 'session.*' jar
# session 4a1b0c... <- pre-login token
$ curl -sb jar -c jar -o /dev/null -d 'user=alice&pw=...' https://app.example.com/login && grep -o 'session.*' jar
# session d93f71... <- CHANGED after authentication: correct
Key takeaways
- Session tokens are bearer credentials: stealing one bypasses the login, including MFA.
- Protect them with HTTPS + HSTS,
Secure,HttpOnly,SameSite, ≥128-bit CSPRNG values, and regeneration on login or privilege change. - Enforce idle and absolute timeouts and keep server-side revocation so logout really works.
- Replay resends an authentic message; integrity does not imply freshness.
- Use nonces, sequence numbers with a replay window, short timestamp tolerances, or idempotency keys — and remember TLS 1.3 0-RTT data is replayable by design.
🧪 Practice
- Review a session cookie from any service you control and list which of
Secure,HttpOnly, andSameSiteare set, plus what each one prevents.- Explain why regenerating the session ID at login defeats session fixation.
- Extend the webhook verifier so that a message replayed after the tolerance window is also rejected without relying on the timestamp check.
- Interview-style: An API signs every request with HMAC over the request body. Is that sufficient? (Hint: the signature proves who wrote the body — ask what it proves about when, and what happens if the same signed bytes arrive twice.)
Port Scanning and Reconnaissance
Theory
Before attacking anything, an adversary needs a map: which hosts exist, which services they run, which versions, and which of those have known vulnerabilities. This phase is reconnaissance. Defenders perform exactly the same activity against their own estate — under the names asset inventory, vulnerability management, and attack-surface management — and that symmetry is the point: whatever you can discover about yourself, someone else can discover too. The difference is authorisation.
Reconnaissance splits into two styles:
- Passive: no packets sent to the target. Public DNS records, TLS certificates in Certificate Transparency logs (an excellent source of subdomain names), WHOIS and RIR data for IP ranges, BGP looking glasses, job adverts naming technologies, code repositories, and search engines that index exposed services. It is undetectable by the target.
- Active: packets are sent, so the target can see it. Host discovery (ICMP echo, ARP sweeps on a LAN, TCP probes to common ports), port scanning, service and version detection from banners, and OS fingerprinting from stack behaviour.
Port scanning relies on how TCP and UDP answer unsolicited packets, which Chapter 7 already described:
| Probe | Response | Interpretation |
|---|---|---|
| TCP SYN | SYN-ACK | open — something is listening |
| TCP SYN | RST | closed — nothing listening, host reachable |
| TCP SYN | nothing | filtered — a firewall dropped it silently |
| UDP datagram | ICMP port unreachable | closed |
| UDP datagram | application reply | open |
| UDP datagram | nothing | ambiguous: open, filtered, or the probe was wrong |
UDP scanning is slow and unreliable precisely because "no answer" is ambiguous, and because ICMP error messages are themselves rate-limited.
Defensively, three ideas matter. First, drop rather than reject on Internet-facing perimeters: dropping gives an attacker no information and costs them time, while a RST confirms the host exists. (Inside a network, rejecting is friendlier for troubleshooting.) Second, reduce what there is to find — close unused ports, remove version banners, and keep an accurate inventory so nothing is exposed by accident; forgotten dev servers and management interfaces are found by scanners constantly. Third, detect the pattern: a single connection to a closed port is noise, but one source touching many ports or many hosts in a short window is a scan, and that signature is easy to alert on from firewall logs.
Note that the Internet is scanned continuously by researchers, search engines, and attackers. Any address you expose will be probed within minutes, so detection should focus on targeted scanning — unusual internal sources, or scans that follow a compromise — rather than on the constant background noise.
Examples
What each probe result tells an observer
scanner target
|--- SYN -> 22 --------------->| listening -> |<-- SYN-ACK --- OPEN
|--- SYN -> 23 --------------->| nothing there-> |<-- RST ------- CLOSED (host confirmed!)
|--- SYN -> 3389 ------------->| firewall DROP (silence) FILTERED (no information)
|--- UDP -> 161 -------------->| nothing there-> |<-- ICMP 3/3 -- CLOSED
|--- UDP -> 53 --------------->| resolver -> |<-- DNS reply - OPEN
Why perimeter firewalls should DROP, not REJECT:
REJECT -> attacker learns "host exists, port closed" (map built quickly)
DROP -> attacker learns nothing and must wait for timeouts (slow, noisy, detectable)
Scan shapes in a log, as a detector sees them
vertical scan : one source -> one host, MANY ports (service discovery)
horizontal sweep: one source -> MANY hosts, one port (looking for a vulnerable service)
block scan : one source -> many hosts, many ports (full mapping)
slow/low scan : spread over hours to stay under thresholds
# DEFENDER'S INVENTORY CHECK: enumerate your own host's listening sockets and flag exposure.
import socket, subprocess, re
def local_listeners():
"""Parse `ss -tulpn` into (proto, addr, port, process) tuples."""
out = subprocess.run(["ss", "-tulpnH"], capture_output=True, text=True).stdout
rows = []
for line in out.splitlines():
f = line.split()
if len(f) < 5:
continue
proto, local = f[0], f[4]
addr, _, port = local.rpartition(":")
proc = re.search(r'\("([^"]+)"', line)
rows.append((proto, addr.strip("[]"), port, proc.group(1) if proc else "?"))
return rows
def exposure(addr):
if addr in ("127.0.0.1", "::1"):
return "loopback only"
if addr in ("0.0.0.0", "*", "::"):
return "ALL INTERFACES -- reachable from anywhere routable"
return f"bound to {addr}"
EXPECTED = {"22", "443"} # what this host is supposed to expose
for proto, addr, port, proc in sorted(local_listeners(), key=lambda r: r[2]):
verdict = exposure(addr)
flag = "" if port in EXPECTED or "loopback" in verdict else " <-- REVIEW"
print(f"{proto:5} {port:>6} {proc:<12} {verdict}{flag}")
# tcp 22 sshd ALL INTERFACES -- reachable from anywhere routable
# tcp 443 nginx ALL INTERFACES -- reachable from anywhere routable
# tcp 5432 postgres loopback only
# tcp 8080 java ALL INTERFACES -- reachable from anywhere routable <-- REVIEW
# udp 161 snmpd ALL INTERFACES -- reachable from anywhere routable <-- REVIEW
# DETECTION: find scan patterns in firewall drop logs.
from collections import defaultdict
# Each entry: (timestamp_seconds, source_ip, destination_ip, destination_port)
drops = [
(100, "203.0.113.9", "10.0.0.5", 22), (100, "203.0.113.9", "10.0.0.5", 23),
(101, "203.0.113.9", "10.0.0.5", 25), (101, "203.0.113.9", "10.0.0.5", 80),
(101, "203.0.113.9", "10.0.0.5", 443), (102, "203.0.113.9", "10.0.0.5", 3306),
(102, "203.0.113.9", "10.0.0.5", 3389),(102, "203.0.113.9", "10.0.0.5", 8080),
(140, "10.0.0.31", "10.0.0.7", 445), (141, "10.0.0.31", "10.0.0.8", 445),
(141, "10.0.0.31", "10.0.0.9", 445), (142, "10.0.0.31", "10.0.0.10", 445),
(142, "10.0.0.31", "10.0.0.11", 445), (143, "10.0.0.31", "10.0.0.12", 445),
(300, "198.51.100.4", "10.0.0.5", 443), # ordinary noise
]
def detect_scans(events, window=60, port_threshold=6, host_threshold=5):
by_src = defaultdict(lambda: {"ports": defaultdict(set), "hosts": defaultdict(set)})
alerts = []
for ts, src, dst, port in events:
bucket = ts // window
st = by_src[src]
st["ports"][(bucket, dst)].add(port) # vertical: many ports on one host
st["hosts"][(bucket, port)].add(dst) # horizontal: one port across many hosts
for src, st in by_src.items():
for (bucket, dst), ports in st["ports"].items():
if len(ports) >= port_threshold:
alerts.append(f"VERTICAL scan: {src} -> {dst}, {len(ports)} ports in window {bucket}")
for (bucket, port), hosts in st["hosts"].items():
if len(hosts) >= host_threshold:
sev = "CRITICAL (internal source)" if src.startswith("10.") else "warning"
alerts.append(f"HORIZONTAL sweep [{sev}]: {src} -> {len(hosts)} hosts on port {port}")
return alerts
for a in detect_scans(drops):
print(a)
# VERTICAL scan: 203.0.113.9 -> 10.0.0.5, 8 ports in window 1
# HORIZONTAL sweep [CRITICAL (internal source)]: 10.0.0.31 -> 6 hosts on port 445
# ^ an internal host sweeping SMB is a lateral-movement signal, not background noise
# AUTHORISED self-assessment of your own estate (confirm scope before running anything):
$ sudo nmap -sS -p- --open -T4 10.0.0.5
# PORT STATE SERVICE
# 22/tcp open ssh
# 443/tcp open https
# 8080/tcp open http-proxy <- unplanned exposure found before someone else finds it
# Passive discovery of your own footprint -- no packets to the target at all:
$ curl -s "https://crt.sh/?q=%25.example.com&output=json" | jq -r '.[].name_value' | sort -u | head
# api.example.com
# dev-staging.example.com <- CT logs reveal names you never published in DNS
# vpn.example.com
# Make the perimeter silent (drop, do not reject) and log for detection:
$ sudo nft add rule inet filter input ct state new counter log prefix "fw-drop: " drop
# Alert on scan patterns from those logs (shape of a detection rule):
$ sudo journalctl -kf | grep "fw-drop" | awk '{print $NF}' | sort | uniq -c | sort -rn | head
# 142 SRC=203.0.113.9 <- one source generating many drops: investigate
# Remove version banners so recon yields less (nginx example):
# server_tokens off; # "nginx" instead of "nginx/1.24.0"
Key takeaways
- Reconnaissance builds a map; passive methods (DNS, CT logs, WHOIS, BGP data) are undetectable, active scanning is visible.
- Scan results follow from TCP/UDP semantics: SYN-ACK = open, RST = closed, silence = filtered; UDP is ambiguous and slow.
- On Internet-facing perimeters drop rather than reject — a RST confirms the host exists.
- Shrink what can be found: close unused ports, suppress banners, and keep an inventory; forgotten services are the usual finding.
- Detect the pattern (vertical, horizontal, block, slow) rather than single probes, and treat internal sweeps as high severity.
🧪 Practice
- For a host you administer, run the inventory script and justify every listener that is bound to all interfaces.
- Explain why "no response" to a UDP probe cannot distinguish open from filtered, and how a scanner tries to disambiguate.
- Adapt the detector to catch a slow scan that touches only three ports per minute over an hour.
- Interview-style: Would you configure an Internet-facing firewall to drop or to reject, and what changes inside the network? (Hint: think about what each response tells someone building a map, versus what a developer needs when a connection fails.)
Botnets and Command-and-Control
Theory
A botnet is a collection of compromised machines under a single operator's control. Each host runs software that periodically contacts a command-and-control (C2) infrastructure to fetch instructions and report results. Botnets provide attackers the one thing a single machine cannot: scale and distribution — the raw source of DDoS floods, credential-stuffing waves, spam, and the internal footholds used for ransomware.
Understanding C2 matters for defenders because it is the most detectable phase of an intrusion. Initial compromise may be a single event that blends into normal traffic, but C2 must happen repeatedly, from inside your network, outbound, for the whole life of the intrusion. That gives you many chances to notice.
C2 architectures have evolved to survive takedown:
| Architecture | How it works | Weakness for the attacker |
|---|---|---|
| Centralised (single server, IRC/HTTP historically) | Bots poll one address | One takedown kills the botnet |
| Multiple / rotating | Lists of fallback domains and IPs | Still enumerable |
| Domain Generation Algorithms (DGA) | Bots compute a daily list of pseudo-random domains; the operator registers one | Generated names look statistically odd |
| Fast flux | One domain resolves to many rapidly-rotating IPs with tiny TTLs | Unusual DNS behaviour |
| P2P | Bots relay commands between themselves | No single point, but noisy peer traffic |
| Legitimate services ("living off trusted sites") | C2 hidden in cloud storage, social posts, or CDN traffic | Hard to block without blocking the service |
Whatever the topology, C2 traffic has behavioural properties that differ from human-driven traffic:
- Beaconing: contact at regular intervals (often with random "jitter" to hide the pattern). Humans do not browse every 60 seconds ± 10% for days.
- Long-lived, low-volume sessions where the outbound direction carries more than the inbound, the opposite of normal web browsing.
- Rare destinations: newly registered domains, or domains no other host in the organisation has ever contacted.
- Protocol/port mismatch: traffic that is not TLS on port 443, or DNS queries with unusually long, high-entropy labels (DNS tunnelling).
- Non-browser clients: a process that is not a browser making direct HTTPS connections, or absent/odd TLS fingerprints (JA3/JA4).
Defences are layered, and the order matters:
- Prevent the foothold: patching, email filtering, application allow-listing, least privilege.
- Constrain egress — the highest-value control. Hosts and servers should not be able to open arbitrary outbound connections. Force traffic through an authenticated proxy, allow-list where servers may talk, and block direct outbound DNS so all queries go to inspected resolvers.
- Inspect DNS: filter newly-registered and known-malicious domains, and alert on DGA-like or high-entropy query names.
- Detect beaconing in flow records (NetFlow/Zeek), using interval regularity and rare-destination scoring.
- Segment so that a compromised workstation cannot reach servers directly (11.5), and monitor east-west traffic for the lateral movement that follows.
Notably, most of these defences are about outbound traffic — the direction traditional perimeter firewalls were configured to trust.
Examples
Beaconing: what makes automated callbacks stand out
human browsing (timestamps of requests to one domain)
| ||| | | |||| | | |||
irregular, bursty, long idle gaps, driven by attention
C2 beacon with 10% jitter, 60 s interval
| | | | | | | | |
intervals: 58 62 57 63 59 61 58 62 60 -> mean 60, stdev ~2
Detection metric: coefficient of variation (stdev/mean) is LOW for beacons
and HIGH for human traffic.
Volume asymmetry
normal web page : out 2 KB <- in 900 KB (download-heavy)
C2 exfiltration : out 400 KB <- in 1 KB (upload-heavy, small replies)
Egress control: the single most effective structural defence
WITHOUT WITH
[server] --any dst:any port--> Internet [server] --> [proxy: allow-list] --> Internet
compromise -> free C2 channel C2 to unknown domain -> BLOCKED + logged
DNS only to inspected resolver -> no tunnelling
# DETECTION 1: score outbound connection logs for beacon-like regularity.
import statistics
from collections import defaultdict
# (timestamp_seconds, src, dst_domain, bytes_out, bytes_in)
flows = [(t, "10.0.0.31", "cdn-update-svc.example", 1200, 300)
for t in range(0, 3600, 60)] # exactly periodic: suspicious
flows += [(t, "10.0.0.42", "news.example.com", 900, 240_000)
for t in (5, 9, 14, 61, 63, 400, 1200, 1207, 3000)] # bursty, download-heavy: human
def beacon_score(flows, min_events=6):
by_pair = defaultdict(list)
for ts, src, dst, out, inn in flows:
by_pair[(src, dst)].append((ts, out, inn))
findings = []
for (src, dst), events in by_pair.items():
if len(events) < min_events:
continue
events.sort()
gaps = [b[0] - a[0] for a, b in zip(events, events[1:])]
mean = statistics.fmean(gaps)
cv = statistics.pstdev(gaps) / mean if mean else 1.0 # low CV => machine-like timing
out_total = sum(e[1] for e in events)
in_total = sum(e[2] for e in events)
ratio = out_total / max(in_total, 1)
score = 0
if cv < 0.15: score += 3 # regular interval
if ratio > 1: score += 2 # upload-heavy
if len(events) > 30: score += 1 # persistent
findings.append((score, src, dst, round(mean), round(cv, 3), round(ratio, 2)))
return sorted(findings, reverse=True)
for score, src, dst, mean, cv, ratio in beacon_score(flows):
verdict = "INVESTIGATE" if score >= 4 else "normal"
print(f"{src:10} -> {dst:24} interval={mean:4}s cv={cv:<6} out/in={ratio:<7} {verdict}")
# 10.0.0.31 -> cdn-update-svc.example interval= 60s cv=0.0 out/in=4.0 INVESTIGATE
# 10.0.0.42 -> news.example.com interval= 374s cv=1.594 out/in=0.0 normal
# DETECTION 2: flag DGA-like and tunnelling-like DNS query names by entropy and shape.
import math, re
from collections import Counter
def entropy(s):
counts = Counter(s)
n = len(s)
return -sum((c / n) * math.log2(c / n) for c in counts.values())
VOWELS = set("aeiou")
def dns_anomaly(qname):
labels = qname.rstrip(".").split(".")
first = labels[0].lower()
reasons = []
if len(first) > 30:
reasons.append("very long label (tunnelling / data in query)")
if entropy(first) > 3.6:
reasons.append(f"high entropy {entropy(first):.2f} (random-looking: DGA or encoded data)")
letters = [c for c in first if c.isalpha()]
# Only meaningful on a reasonably long label: "www" legitimately has no vowels.
if len(letters) >= 8 and sum(c in VOWELS for c in letters) / len(letters) < 0.2:
reasons.append("almost no vowels (not a human-chosen name)")
if len(labels) > 5:
reasons.append("unusually deep subdomain nesting")
return reasons
for q in ("www.google.com",
"api.payments.example.com",
"kqxvbzrmtlwphdgn.example.net", # DGA-like
"ZjB4YTNkOWZiM2MwZGVhZmMxYjJlM2Q0NTY3ODkwYWJjZGVm.t.exfil.example"): # encoded payload
issues = dns_anomaly(q)
print(f"{q[:46]:48} {'; '.join(issues) if issues else 'looks normal'}")
# www.google.com looks normal
# api.payments.example.com looks normal
# kqxvbzrmtlwphdgn.example.net high entropy 4.00 (random-looking: DGA or encoded data); almost no vowels (not a human-chosen name)
# ZjB4YTNkOWZiM2MwZGVhZmMxYjJlM2Q0NTY3ODkwYWJjZGVm very long label (tunnelling / data in query); high entropy 4.15 (random-looking: DGA or encoded data); almost no vowels (not a human-chosen name)
# Constrain egress so an intrusion has no easy channel (the highest-value control).
# 1. Servers may only reach approved destinations; everything else is dropped and logged:
$ sudo nft -f - <<'EOF'
table inet egress {
set approved_v4 { type ipv4_addr; flags interval;
elements = { 10.0.0.0/8, 192.0.2.10, 198.51.100.0/24 } }
chain output {
type filter hook output priority 0; policy drop;
ct state established,related accept
ip daddr @approved_v4 accept
ip daddr 10.0.0.53 udp dport 53 accept # DNS only via the inspected resolver
counter log prefix "egress-block: " drop # everything else: blocked AND recorded
}
}
EOF
# 2. Find hosts talking directly to outside resolvers (a bypass of DNS inspection):
$ sudo tcpdump -ni eth0 'udp port 53 and not dst host 10.0.0.53' -c 5
# 10.0.0.31.51322 > 8.8.8.8.53: 41231+ A? kqxvbzrmtlwphdgn.example.net. <- investigate this host
# 3. Look for beacon-like regularity in flow records (Zeek conn.log):
$ zeek-cut id.orig_h id.resp_h ts < conn.log | sort -k1,2 -k3n | awk '
{if ($1"-"$2 == prev) print $1, $2, $3 - last; prev=$1"-"$2; last=$3}' | head
# 10.0.0.31 203.0.113.77 60.02 <- near-constant gaps to one destination
# 10.0.0.31 203.0.113.77 59.87
# 10.0.0.31 203.0.113.77 60.11
# 4. Check the proxy log for destinations no other host has ever visited:
$ awk '{print $NF}' /var/log/squid/access.log | sort | uniq -c | awk '$1 <= 2' | head
# 1 cdn-update-svc.example <- rare destination + one client = classic first signal
Key takeaways
- A botnet gives attackers scale; its C2 channel is the repeated, outbound, long-lived part of an intrusion — and therefore the most detectable.
- C2 resilience techniques include DGA, fast flux, P2P, and hiding inside legitimate cloud services.
- Behavioural signals beat signatures: regular beacon intervals (low coefficient of variation), upload-heavy asymmetry, rare destinations, and DNS anomalies (long, high-entropy labels).
- Egress control — proxy allow-lists and DNS forced through inspected resolvers — is the structural defence, because it removes the channel rather than chasing indicators.
- Assume the foothold will sometimes succeed: combine egress control with segmentation and east-west monitoring so one host does not become the whole network.
🧪 Practice
- Explain why C2 beaconing is easier to detect than the initial compromise, and name two metrics you would compute from flow logs.
- Why does a DGA make blocklists ineffective, and what detection approach works instead?
- Write an egress policy for a database server that needs only OS updates from a mirror and DNS from an internal resolver.
- Interview-style: A workstation makes an HTTPS request to the same unfamiliar domain every 300 seconds, transferring a few kilobytes each time. What is your hypothesis and your next three investigative steps? (Hint: consider what the regular interval and direction of data volume suggest, then think about which logs identify the process behind the connection.)
<a id="115-perimeter-and-monitoring-systems"></a>
11.5 Perimeter and Monitoring Systems
Cryptography protects traffic in transit, but something still has to decide which traffic is allowed to flow at all and notice when something goes wrong: this subchapter covers firewalls, proxies, segmentation, intrusion detection, deception, and the analytics that tie them together.
Packet Filtering and Stateful Firewalls
Theory
A firewall enforces a policy about which traffic may cross a boundary. The name comes from building construction: a wall that stops a fire in one room from spreading to the next. In networks, the "fire" is unwanted traffic, and the rooms are network zones.
The simplest firewall is a stateless packet filter. It looks at each packet in isolation and matches header fields — source and destination IP, protocol, source and destination port, TCP flags — against an ordered list of rules. The first matching rule decides: accept or drop. Router ACLs work this way.
Stateless filtering has a fundamental problem with return traffic. If an internal client connects to a web server on port 443, the reply arrives from port 443 to a random high port on the client. To allow that reply, a stateless filter must permit inbound traffic to all high ports — which also admits unsolicited connections. Checking the TCP ACK flag helps only slightly, because an outside host can set any flag it likes, and UDP has no flags at all.
A stateful firewall solves this with a connection tracking table. When an allowed outbound packet starts a new flow, the firewall records its 5-tuple (protocol, source IP, source port, destination IP, destination port) and tracks its state. Return packets are allowed only if they match an existing entry. Inbound packets that match nothing are dropped. For TCP, the firewall follows the handshake and teardown; for UDP and ICMP it creates pseudo-connections with timeouts.
| Stateless packet filter | Stateful firewall | |
|---|---|---|
| Decision based on | Each packet alone | Packet + connection state |
| Return traffic | Must be explicitly allowed (wide rules) | Allowed automatically if it matches a tracked flow |
| Memory per flow | None | One conntrack entry |
| Performance | Very fast, scales in hardware | Fast, but table size limits capacity |
| Weakness | Overly permissive rules | Table exhaustion under floods |
| Examples | Router ACLs, cloud NACLs | nftables/iptables with conntrack, cloud security groups, most appliances |
Good firewall policy follows a few rules that come directly from 11.1:
- Default deny: the last rule drops everything not explicitly allowed.
- Least privilege: allow specific sources to specific destinations and ports, not "any to any on 443".
- Filter egress too: blocking unexpected outbound traffic limits malware call-backs and data exfiltration.
- Order matters: put specific rules before broad ones; an early broad
acceptsilently makes later rules irrelevant. - Log drops at a sensible rate, so the firewall doubles as a sensor.
Examples
Why state matters: an outbound web request and its reply
client 10.0.0.5:51234 ----> 93.184.216.34:443 SYN (new flow, allowed by rule)
conntrack adds:
tcp 10.0.0.5:51234 <-> 93.184.216.34:443 SYN_SENT
client 10.0.0.5:51234 <---- 93.184.216.34:443 SYN-ACK
matches entry -> ESTABLISHED -> allowed
client 10.0.0.5:40000 <---- 198.51.100.66:443 unsolicited
no entry -> DROPPED
Stateless equivalent would need:
allow tcp from any port 443 to 10.0.0.0/24 ports 1024-65535 <- far too broad
Rule evaluation order (first match wins)
1 accept ct state established,related
2 accept tcp dport 22 from 10.0.99.0/24 (admin subnet)
3 accept tcp dport 443 from any
4 drop tcp dport 22 from 10.0.99.13 <- NEVER reached: rule 2 matched first
5 drop everything else (default deny)
# A small stateful filter: rules for NEW flows, a conntrack table for replies.
import ipaddress, time
RULES = [ # (action, proto, src_net, dst_port) evaluated top-down for NEW flows only
("accept", "tcp", "10.0.0.0/24", 443),
("accept", "udp", "10.0.0.0/24", 53),
("accept", "tcp", "10.0.99.0/24", 22),
]
conntrack = {} # key: normalised 5-tuple -> last-seen timestamp
TIMEOUT = {"tcp": 3600, "udp": 30}
def key(proto, a, ap, b, bp):
# same key for both directions of a flow
return (proto,) + tuple(sorted([(a, ap), (b, bp)]))
def filter_packet(proto, src, sport, dst, dport, now=None):
now = now or time.time()
k = key(proto, src, sport, dst, dport)
seen = conntrack.get(k)
if seen and now - seen < TIMEOUT[proto]:
conntrack[k] = now
return "accept (established)"
for action, p, net, port in RULES:
if p == proto and port == dport and ipaddress.ip_address(src) in ipaddress.ip_network(net):
conntrack[k] = now
return f"{action} (new, rule match)"
return "drop (default deny)"
print(filter_packet("tcp", "10.0.0.5", 51234, "93.184.216.34", 443)) # accept (new, rule match)
print(filter_packet("tcp", "93.184.216.34", 443, "10.0.0.5", 51234)) # accept (established)
print(filter_packet("tcp", "198.51.100.66", 443, "10.0.0.5", 40000)) # drop (default deny)
print(filter_packet("udp", "10.0.0.5", 5353, "10.0.0.53", 53)) # accept (new, rule match)
print(filter_packet("udp", "10.0.0.53", 53, "10.0.0.5", 5353, now=time.time() + 60))
# drop (default deny) <- UDP pseudo-connection expired after 30 s
# A stateful host/edge policy with nftables: default deny in both directions.
$ sudo nft -f - <<'EOF'
table inet fw {
chain input {
type filter hook input priority 0; policy drop;
ct state invalid drop # malformed / out-of-state packets
ct state established,related accept # replies to allowed flows
iif lo accept
ip saddr 10.0.99.0/24 tcp dport 22 accept # SSH from admin subnet only
tcp dport { 80, 443 } accept # public web service
icmp type { echo-request, destination-unreachable } limit rate 10/second accept
limit rate 5/second log prefix "fw-in-drop: " counter # rate-limited logging
}
chain output {
type filter hook output priority 0; policy drop; # egress filtering
ct state established,related accept
oif lo accept
udp dport 53 ip daddr 10.0.0.53 accept # DNS only to the internal resolver
tcp dport 443 accept
}
}
EOF
# Inspect the connection tracking table:
$ sudo conntrack -L | head -3
# tcp 6 431999 ESTABLISHED src=10.0.0.5 dst=93.184.216.34 sport=51234 dport=443 ... [ASSURED]
# udp 17 28 src=10.0.0.5 dst=10.0.0.53 sport=5353 dport=53 ...
$ sudo conntrack -C # current entry count
# 18422
$ sysctl net.netfilter.nf_conntrack_max # capacity limit
# net.netfilter.nf_conntrack_max = 262144
Key takeaways
- Stateless filters judge each packet alone and need broad rules for return traffic; stateful firewalls track connections and admit only matching replies.
- Connection tracking keys on the 5-tuple and uses timeouts for UDP and ICMP pseudo-flows.
- Policy basics: default deny, least privilege, egress filtering, specific rules first, log drops.
- Rule order is semantic: a broad early
acceptmakes later rules unreachable. - The conntrack table is finite; its size is a capacity and resilience parameter to monitor.
🧪 Practice
- Write stateless rules that allow a LAN to browse the web over HTTPS, then the stateful equivalent, and compare how much inbound traffic each permits.
- Explain why UDP "connections" in a stateful firewall need a timeout, and what goes wrong if it is too short for a VoIP call.
- Audit the rule order example above and rewrite it so that rule 4 actually takes effect.
- Interview-style: After a firewall migration, long-idle SSH sessions to a database server drop, but new connections always work. (Hint: the rules clearly allow SSH — think about what the firewall keeps per connection and how long it keeps it.)
Application-Layer Gateways and Proxies
Theory
A stateful firewall knows that a packet belongs to an allowed TCP connection to port 443, but nothing about what is inside it. Port numbers are a weak proxy for applications: anything can run on port 443. To make decisions based on the application itself — which URL, which SQL statement, which file type — a device has to understand the application protocol. That is the job of application-layer gateways and proxies.
A proxy terminates the client's connection and opens a separate connection to the server on the client's behalf. Because it holds two connections rather than forwarding packets, it sees full application messages and can inspect, rewrite, cache, authenticate, or block them. There are two orientations:
| Forward proxy | Reverse proxy | |
|---|---|---|
| Sits in front of | Clients | Servers |
| Acts on behalf of | Internal users going out | External users coming in |
| Typical functions | URL filtering, malware scanning, egress control, caching, user attribution | TLS termination, load balancing, WAF, caching, hiding backend topology |
| Examples | Squid, corporate secure web gateways | nginx, HAProxy, Envoy, CDNs |
A Web Application Firewall (WAF) is a reverse proxy specialised in inspecting HTTP requests against rules for common web attack patterns (the OWASP Core Rule Set is the standard open rule set). It is a useful layer, never a substitute for secure application code: because it matches patterns, it produces false positives and can be evaded by encodings its rules do not anticipate.
An Application Layer Gateway (ALG), in the narrow firewall sense, is a protocol-aware helper for traffic that carries addresses inside the payload. Classic FTP in active mode sends PORT 192,168,1,50,20,10 in the control channel and then expects an inbound data connection; SIP negotiates RTP ports in its message body. A NAT or firewall that does not read those payloads breaks the protocol, so an ALG parses them, rewrites embedded addresses, and opens the matching pinholes. ALGs are a common source of subtle bugs — they must re-parse protocols they may not fully implement — which is why modern designs prefer protocols that avoid embedded addresses (FTP passive mode, ICE/STUN for media).
Proxies bring a decisive advantage and a real cost. The advantage: they are the natural place for identity, logging, and egress control (11.4), because a proxy sees the user, the hostname, and the full request. The cost: TLS. To inspect encrypted traffic, a proxy must terminate TLS and re-encrypt — TLS interception — which requires installing the proxy's CA certificate on every client. That decrypts employee traffic at a single point, which is both a privacy question and a concentrated risk, and it breaks certificate pinning and mTLS. Two options avoid full interception: decide on SNI/DNS metadata only (no decryption), or terminate TLS only where you legitimately own both ends (your own reverse proxy in front of your own services).
Examples
Forward proxy (egress control) and reverse proxy (ingress control)
FORWARD REVERSE
[user] --CONNECT app.example:443--> Internet --> [reverse proxy / WAF]
[forward proxy] | TLS terminates here
| policy: is this user allowed | inspect HTTP, rate limit
| this category, this host? | route by path/host
v v
Internet [app-1] [app-2] [static cache]
Packet filter sees: "TCP 443 to 93.184.216.34 -- allowed"
Proxy sees: "alice requested https://app.example/admin/export?all=true
user-agent curl/8.4, 14:02:11, 1.2 MB response"
^ the difference between a port and an application decision
Two connections, not one (why a proxy can inspect at all)
[client] === TCP/TLS #1 ===> [proxy] === TCP/TLS #2 ===> [server]
^ full request assembled in memory here
# A minimal forward proxy policy engine: the decision a packet filter cannot make.
import fnmatch, time
from collections import defaultdict
CATEGORY = { # in practice from a feed / DNS categorisation
"*.corp-crm.example": "business",
"*.github.com": "development",
"*.newly-registered-domain.example": "uncategorised",
}
GROUP_POLICY = {
"engineering": {"business", "development"},
"finance": {"business"},
}
counters = defaultdict(int)
def categorise(host):
for pattern, cat in CATEGORY.items():
if fnmatch.fnmatch(host, pattern):
return cat
return "uncategorised" # default-deny posture for unknowns
def decide(user, group, host, method, path, body_bytes=0):
cat = categorise(host)
if cat not in GROUP_POLICY.get(group, set()):
return 403, f"category '{cat}' not permitted for group '{group}'"
if method in ("POST", "PUT") and body_bytes > 10_000_000:
return 413, "upload exceeds data-loss-prevention threshold" # egress DLP check
counters[(user, host)] += 1
if counters[(user, host)] > 500:
return 429, "unusual request volume to a single host"
return 200, f"allowed ({cat})"
print(decide("alice", "engineering", "api.github.com", "GET", "/repos"))
# (200, 'allowed (development)')
print(decide("bob", "finance", "api.github.com", "GET", "/repos"))
# (403, "category 'development' not permitted for group 'finance'")
print(decide("alice", "engineering", "cdn.newly-registered-domain.example", "GET", "/beacon"))
# (403, "category 'uncategorised' not permitted for group 'engineering'")
print(decide("alice", "engineering", "corp-crm.example", "POST", "/upload", body_bytes=50_000_000))
# (403, "category 'uncategorised' not permitted for group 'engineering'")
# ^ note: the wildcard "*.corp-crm.example" does NOT match the bare apex name --
# a classic policy bug. Patterns must list both the apex and the wildcard.
# Reverse proxy in front of an application: nginx terminating TLS, adding headers, rate limiting
# limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
# server {
# listen 443 ssl http2;
# server_name app.example;
# ssl_certificate /etc/ssl/app.crt;
# ssl_certificate_key /etc/ssl/app.key;
# server_tokens off; # do not advertise the version (recon)
# add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
# location /api/ {
# limit_req zone=api burst=40 nodelay; # application-layer flood control
# proxy_pass http://10.0.20.11:8080;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; # preserve client IP
# proxy_set_header X-Forwarded-Proto $scheme;
# }
# }
# Forward proxy log: the record a packet filter could never produce
$ tail -2 /var/log/squid/access.log
# 1758... 412 10.0.10.44 TCP_MISS/200 148231 GET https://api.github.com/repos alice DIRECT/140.82.121.6
# 1758... 63 10.0.10.44 TCP_DENIED/403 3892 CONNECT cdn.newly-registered-domain.example:443 alice NONE/-
# ^ blocked by category + user, with attribution for the investigation
# Verify a client is actually using the proxy and cannot bypass it:
$ curl -sv --proxy http://proxy.corp:3128 https://api.github.com/ 2>&1 | grep -i "CONNECT\|200"
# > CONNECT api.github.com:443 HTTP/1.1
# < HTTP/1.1 200 Connection established
$ curl -s --max-time 5 https://api.github.com/ ; echo "exit=$?"
# exit=28 <- direct egress is blocked, so the proxy cannot be bypassed
# See whether TLS interception is in effect on a managed device (issuer is the corporate CA):
$ echo | openssl s_client -connect example.com:443 2>/dev/null | openssl x509 -noout -issuer
# issuer=C = US, O = Corp IT, CN = Corp Internal TLS Inspection CA
Key takeaways
- A proxy terminates one connection and opens another, so it can decide on application content — URLs, methods, users — not just ports.
- Forward proxies control and attribute outbound traffic (the natural home of egress policy); reverse proxies protect and front-end servers (TLS termination, WAF, load balancing).
- A WAF adds HTTP attack-pattern filtering as a layer, not as a replacement for secure code.
- ALGs rewrite addresses embedded in payloads (active FTP, SIP) and are a frequent source of subtle breakage.
- Inspecting TLS requires interception with a private CA on every client — significant privacy and risk concentration, and it breaks pinning and mTLS.
🧪 Practice
- List three policy decisions a forward proxy can make that a stateful packet filter cannot, and say what information each requires.
- Fix the category-matching bug demonstrated above so that both
corp-crm.exampleand its subdomains are treated as business.- Explain why active-mode FTP needs an ALG through NAT and why passive mode does not.
- Interview-style: A mobile app on the corporate network stops working after TLS inspection is enabled, while browsers are fine. (Hint: browsers trust whatever CAs the device trusts — ask what a hardened app additionally checks about the certificate it receives.)
Network Address Translation as a Boundary
Theory
NAT was introduced in Chapter 5 as an address-conservation mechanism: many hosts with private addresses share one public address, with the translator rewriting addresses and ports and keeping a table of active mappings. A side effect is frequently described as a security feature, and understanding exactly how much protection it provides — and does not — is a common point of confusion.
The genuine effect: with NAPT (many-to-one NAT), an inbound packet arriving at the public address has no mapping unless an outbound packet created one first. Unsolicited inbound connections therefore have nowhere to go and are dropped. This makes internal hosts unreachable by default from outside, and it also hides internal addressing and host count. That is why a home router with NAT protects an unpatched device better than a directly routed public address would.
Why it is not a security boundary in itself:
- It is a side effect of statefulness, not a policy. The protection comes from "no mapping, no delivery", which is the same mechanism a stateful firewall implements deliberately — with logging, rules, and intent. NAT has no concept of which traffic should be allowed.
- It only constrains inbound traffic. Every internal host can still open arbitrary outbound connections, which is exactly what C2 channels (11.4) need. NAT does nothing against phishing, drive-by compromise, or data exfiltration.
- Mappings can be created deliberately: UPnP/NAT-PMP let internal software open inbound ports without administrator involvement, and hole punching (STUN/ICE) intentionally establishes peer-to-peer paths through NAT. Both are useful and both undercut the assumed protection.
- Nothing is filtered once a mapping exists, and NAT inspects no content.
- On the inside there is no protection at all: hosts behind the same NAT reach each other directly, so one compromised device can attack its neighbours.
- It is not needed for the same protection in IPv6: a stateful firewall rule gives identical inbound behaviour while preserving end-to-end addressing.
NAT also imposes real costs that matter to security operations: it breaks end-to-end addressing (complicating IPsec AH, some protocols, and P2P), and it obscures attribution — logs on the outside see one address for thousands of users, so incident response needs NAT/CGNAT session logs to identify who did what. Carrier-grade NAT makes this a legal and operational problem at ISP scale.
The correct framing: NAT provides incidental inbound obscurity; a stateful firewall with explicit policy provides security. Deploy the firewall for the security property, and treat any protection from NAT as a bonus you do not depend on.
| Property | NAPT alone | Stateful firewall (with or without NAT) |
|---|---|---|
| Unsolicited inbound blocked | Yes, as a side effect | Yes, by policy |
| Explicit allow/deny rules | No | Yes |
| Logging and alerting | Mapping tables only | Rule-level logs |
| Outbound control | No | Yes (egress policy) |
| Internal segmentation | No | Yes |
| Works for IPv6 | Not needed | Yes |
Examples
Why unsolicited inbound fails, and what that does not cover
OUTBOUND creates state
[10.0.0.5:51000] --> [NAT 203.0.113.9:40211] --> [server:443]
table: 10.0.0.5:51000 <-> 203.0.113.9:40211 (server 93.184.216.34:443)
the reply matches the entry -> delivered.
UNSOLICITED INBOUND has no state
scanner --> 203.0.113.9:40000 --> NAT: no mapping -> DROP
-> internal hosts are not directly reachable. Real, but incidental.
WHAT NAT DOES NOT STOP
internal host --> phishing page --> malware --> outbound C2 --> allowed (it is outbound)
internal host --> UPnP: "open port 8080 to me" --> inbound path now exists
internal host A --> internal host B: never touches the NAT at all
Attribution problem (CGNAT)
external log: "203.0.113.9 attempted 10,000 logins"
-> which of 4,000 subscribers? Only the NAT session log answers.
# What a NAPT table does and does not permit -- the mechanism, made explicit.
import time
class NAPT:
def __init__(self, public_ip, timeout=120):
self.public_ip, self.timeout = public_ip, timeout
self.table = {} # (pub_port) -> dict(inside, dst, expires)
self.next_port = 40000
def outbound(self, inside, dst, now=None):
now = now or time.time()
port = self.next_port; self.next_port += 1
self.table[port] = {"inside": inside, "dst": dst, "expires": now + self.timeout}
return f"{self.public_ip}:{port}"
def inbound(self, pub_port, src, now=None):
now = now or time.time()
e = self.table.get(pub_port)
if not e:
return "DROP: no mapping (unsolicited inbound)"
if e["expires"] < now:
del self.table[pub_port]
return "DROP: mapping expired"
# Endpoint-independent ("full cone") NAT accepts ANY source for an existing mapping;
# address-restricted NAT checks it. This difference is why hole punching works.
if src != e["dst"]:
return f"delivered to {e['inside']} (endpoint-independent) / would DROP if restricted"
return f"delivered to {e['inside']} (reply from the original destination)"
nat = NAPT("203.0.113.9")
mapped = nat.outbound("10.0.0.5:51000", "93.184.216.34:443")
print(mapped) # 203.0.113.9:40000
print(nat.inbound(40000, "93.184.216.34:443")) # delivered ... (reply from the original destination)
print(nat.inbound(40001, "198.51.100.7:1234")) # DROP: no mapping (unsolicited inbound)
print(nat.inbound(40000, "198.51.100.7:1234")) # delivered to 10.0.0.5:51000 (endpoint-independent) / would DROP if restricted
# ^ the third-party source reaching an existing mapping is exactly how STUN/ICE
# hole punching establishes P2P paths -- and why NAT is not an access-control policy.
print(nat.inbound(40000, "93.184.216.34:443", now=time.time() + 200)) # DROP: mapping expired
# Inspect the translation table (Linux conntrack) -- these are mappings, not policy:
$ sudo conntrack -L -p tcp 2>/dev/null | head -3
# tcp 6 431999 ESTABLISHED src=10.0.0.5 dst=93.184.216.34 sport=51000 dport=443 \
# src=93.184.216.34 dst=203.0.113.9 sport=443 dport=40211 [ASSURED] mark=0 use=1
# The security control is the RULE SET, so state it explicitly rather than relying on NAT:
$ sudo nft -f - <<'EOF'
table inet fw {
chain forward {
type filter hook forward priority 0; policy drop; # explicit default deny
ct state established,related accept
iifname "lan0" oifname "wan0" tcp dport { 80, 443 } accept # egress policy, logged below
iifname "lan0" oifname "wan0" counter log prefix "egress-other: " drop
}
}
EOF
# IPv6 gets the same inbound protection WITHOUT NAT, keeping end-to-end addresses:
$ sudo nft add rule inet fw forward iifname "wan0" ct state new drop
# Disable UPnP/NAT-PMP if you rely on inbound obscurity at all (router-dependent):
$ uci set upnpd.config.enabled=0 && uci commit upnpd # OpenWrt example
Key takeaways
- NAPT blocks unsolicited inbound traffic as a side effect of statefulness, not as a security policy.
- It does nothing about outbound traffic — phishing, malware C2, and exfiltration are unaffected — and nothing inside the network.
- UPnP/NAT-PMP and hole punching deliberately create inbound paths, and mappings are not content-filtered.
- NAT breaks end-to-end addressing and obscures attribution; CGNAT session logs become essential for incident response.
- Get the security property from a stateful firewall with explicit rules and logging; IPv6 achieves it without NAT.
🧪 Practice
- Explain to a non-specialist why "we have NAT, so we are safe from the Internet" is only partly true, using two concrete counterexamples.
- Write the nftables rules that give an IPv6 network the same inbound protection as NAPT without any translation.
- Describe what information an ISP must log to attribute an abuse report to a single CGNAT subscriber.
- Interview-style: Two users behind the same home router play a peer-to-peer game with players worldwide, with no port forwarding configured. How do inbound packets arrive? (Hint: both clients send outbound first, to a helper that tells each the other's mapped address and port — what does that create in each NAT table?)
Intrusion Detection and Prevention Systems
Theory
Firewalls decide whether a connection is allowed. They do not ask whether allowed traffic is malicious. An HTTP request to your public web server passes any sensible rule set — and may still be an exploit attempt. That gap is what intrusion detection fills: it analyses traffic and events looking for evidence of attack.
Two deployment models, differing in one decisive respect:
| IDS (detection) | IPS (prevention) | |
|---|---|---|
| Placement | Out of band, on a SPAN port or tap | In line, in the traffic path |
| Action | Alerts, logs | Drops, resets, rewrites |
| Failure mode | Misses nothing operationally: traffic flows regardless | A false positive blocks real users; a crash can break the network |
| Latency impact | None | Adds processing delay |
| Typical use | Broad visibility, investigation, tuning ground | Enforcement for well-understood, high-confidence signatures |
A common pattern is to deploy the same engine in IDS mode first, tune it against real traffic, then promote only high-confidence rules to blocking. The organisational lesson from practice: an IPS that blocks legitimate traffic gets disabled by the business, so precision matters more than coverage.
Detection uses three complementary methods:
- Signature-based (rule matching): look for known-bad patterns — a specific exploit string, a known malware TLS fingerprint, a request path used by one scanner. Precise, explainable, and cheap, but blind to anything not yet written down. Snort and Suricata rules are the standard form.
- Anomaly-based: build a baseline of normal behaviour and alert on deviation — a workstation suddenly serving SMB, a 20x spike in outbound volume at 03:00, a user authenticating from two continents. Can catch novel attacks, but produces false positives and needs a genuinely representative baseline.
- Protocol / behavioural analysis: parse protocols properly and alert on violations and semantics rather than byte patterns — a non-HTTP payload on port 80, a TLS session with no handshake, DNS responses that do not match any query. Zeek is the canonical tool, producing rich structured logs rather than alerts alone.
Two practical constraints shape every deployment. First, encryption: most traffic is TLS, so payload signatures apply only where you terminate TLS (your own reverse proxy) or to metadata — SNI, certificate details, JA3/JA4 fingerprints, packet sizes and timing. Modern network detection leans heavily on metadata and flow behaviour for that reason. Second, the base-rate problem: even a very accurate detector generates overwhelming false positives when attacks are rare relative to traffic volume, which is why tuning, suppression, and correlation (feeding into SIEM, 11.5's last topic) matter more than raw detection rates.
Placement follows the same logic as defence in depth: at the perimeter to see inbound attacks, on internal segments to see east-west lateral movement, and on hosts (HIDS, and endpoint detection) to see what never crosses a monitored link at all.
Examples
IDS out of band vs IPS in line
IDS IPS
Internet --> [switch] --> [servers] Internet --> [IPS] --> [servers]
| SPAN (copy) |
[IDS sensor] -> alerts drops / resets inline
cannot block; never breaks traffic can block; a false positive is an outage
Detection methods on the same event: 10,000 logins from one source in a minute
signature : matches a rule for a known credential-stuffing tool's user-agent
-> precise, but a renamed tool evades it
anomaly : login rate is 400x this endpoint's 30-day baseline
-> catches the unknown tool, may also flag a legitimate migration
protocol : requests lack the session cookie a real browser flow always carries
-> semantic evidence, robust to renaming
Where sensors go
[Internet] =IPS= [DMZ] --- [core] --- [user VLANs]
| | |
sensor sensor(east-west) host agents
inbound lateral movement what never crosses a link
# A small detection engine showing all three methods and the base-rate problem.
import re, statistics
from collections import defaultdict
# --- 1. Signature-based ---
SIGNATURES = [
("SCAN-UA", re.compile(rb"(?i)user-agent:\s*(nikto|sqlmap|masscan)")),
("TRAVERSAL", re.compile(rb"(\.\./){3,}")),
("SHELL-IN-UA",re.compile(rb"(?i)user-agent:.*\(\)\s*\{")), # Shellshock shape
]
def signature_check(payload):
return [name for name, rx in SIGNATURES if rx.search(payload)]
print(signature_check(b"GET / HTTP/1.1\r\nUser-Agent: sqlmap/1.7\r\n")) # ['SCAN-UA']
print(signature_check(b"GET /../../../../etc/passwd HTTP/1.1\r\n")) # ['TRAVERSAL']
print(signature_check(b"GET /index.html HTTP/1.1\r\nUser-Agent: Mozilla\r\n")) # []
# --- 2. Anomaly-based: compare against a learned baseline ---
class Baseline:
def __init__(self, samples):
self.mean = statistics.fmean(samples)
self.sd = statistics.pstdev(samples) or 1.0
def zscore(self, value):
return (value - self.mean) / self.sd
hourly_logins = [12, 15, 9, 14, 11, 13, 10, 16, 12, 14] # 10 hours of normal history
bl = Baseline(hourly_logins)
for observed in (13, 40, 4000):
z = bl.zscore(observed)
print(f"logins={observed:>5} z={z:6.1f} {'ALERT' if z > 4 else 'normal'}")
# logins= 13 z= 0.2 normal
# logins= 40 z= 13.0 ALERT
# logins= 4000 z=1892.3 ALERT
# --- 3. Protocol/behavioural: semantics, not bytes ---
def protocol_check(event):
issues = []
if event["dport"] == 80 and not event["payload"].startswith((b"GET", b"POST", b"HEAD", b"PUT")):
issues.append("non-HTTP payload on port 80 (tunnelling or wrong service)")
if event["dport"] == 443 and event["payload"][:1] != b"\x16":
issues.append("port 443 without a TLS handshake record") # 0x16 = Handshake
if event.get("dns_response_without_query"):
issues.append("DNS response with no matching outstanding query")
return issues
print(protocol_check({"dport": 443, "payload": b"SSH-2.0-OpenSSH_9.6"}))
# ['port 443 without a TLS handshake record']
# --- The base-rate problem: why precision dominates operations ---
def alert_math(events_per_day, attack_rate, detection_rate, false_positive_rate):
attacks = events_per_day * attack_rate
benign = events_per_day - attacks
tp = attacks * detection_rate
fp = benign * false_positive_rate
return {"true alerts/day": round(tp, 1), "false alerts/day": round(fp, 1),
"precision": f"{tp / (tp + fp):.2%}" if tp + fp else "n/a"}
print(alert_math(10_000_000, attack_rate=1e-6, detection_rate=0.99, false_positive_rate=0.0001))
# {'true alerts/day': 9.9, 'false alerts/day': 1000.0, 'precision': '0.98%'}
# 99% detection with a 0.01% false-positive rate still buries 10 real alerts under 1,000 false
# ones. This is why tuning, suppression and correlation matter more than raw detection rate.
# Suricata rule anatomy (Snort-compatible syntax):
# alert http $EXTERNAL_NET any -> $HOME_NET any ( \
# msg:"Directory traversal attempt"; flow:established,to_server; \
# http.uri; content:"../../"; fast_pattern; \
# classtype:web-application-attack; sid:1000101; rev:2; )
# ^ action ^ protocol/direction ^ what to match ^ metadata
# Run in IDS mode against a capture first: tune before ever blocking.
$ suricata -r traffic.pcap -S local.rules -l ./out
$ jq -r 'select(.event_type=="alert") | "\(.alert.signature) \(.src_ip) -> \(.dest_ip)"' out/eve.json | sort | uniq -c | sort -rn | head
# 842 SURICATA HTTP unable to match response to request 10.0.0.9 -> 10.0.20.11
# 3 Directory traversal attempt 203.0.113.9 -> 198.51.100.20
# ^ 842 of these is a tuning problem, not 842 intrusions: suppress or fix the rule first
# Zeek: protocol analysis producing structured logs rather than alerts
$ zeek -r traffic.pcap && ls *.log
# conn.log dns.log http.log ssl.log notice.log weird.log
$ zeek-cut id.orig_h server_name validation_status < ssl.log | grep -v ok | head -3
# 10.0.0.31 cdn-update-svc.example self signed certificate in certificate chain
# ^ TLS metadata is still informative even though the payload is encrypted
# Promote a tuned rule to blocking only when precision is proven (IPS mode):
# drop http $EXTERNAL_NET any -> $HOME_NET any (msg:"..."; sid:1000101; rev:3;)
$ sudo suricata -i eth0 --af-packet -S local.rules # inline requires careful capacity planning
Key takeaways
- A firewall decides allowed; an IDS/IPS asks whether allowed traffic is malicious.
- IDS is out of band and safe; IPS is in line and can block — so a false positive becomes an outage. Tune in IDS mode, then promote high-confidence rules.
- Signatures are precise but blind to the unknown; anomaly detection catches novelty with more noise; protocol analysis gives robust semantic evidence.
- Encryption pushes detection toward metadata: SNI, certificates, JA3/JA4, sizes, timing, flow behaviour.
- The base-rate problem means precision and correlation matter more than headline detection rates; place sensors at the perimeter, east-west, and on hosts.
🧪 Practice
- Give one attack a signature engine catches that an anomaly engine misses, and one for the reverse, with reasoning.
- Using the base-rate function, find the false-positive rate needed to keep false alerts under 10 per day at 10 million events with the same attack rate.
- Write a Suricata-style rule that alerts on inbound requests to
/wp-login.phpon a site that runs no WordPress, and explain why that is high precision.- Interview-style: Would you deploy an IPS in line in front of a payment system? (Hint: weigh what a missed attack costs against what a false positive costs on that specific path, and consider whether a tuning period changes your answer.)
DMZ and Network Segmentation
Theory
A flat network is a single blast radius: one compromised host can reach every other host, and the only real control is at the Internet edge. Segmentation divides the network into zones with controlled paths between them, so that compromise in one zone does not become compromise everywhere. It is the structural control that limits lateral movement (11.4), and in practice it is what determines whether an incident is one workstation or the whole company.
The classic form is the DMZ (demilitarised zone): a separate segment for services that must be reachable from the Internet — web servers, mail relays, reverse proxies, VPN concentrators. The rules that make it a DMZ are directional:
- Internet → DMZ: permitted, only on required ports.
- DMZ → internal: denied, except narrowly defined flows to specific hosts and ports (for example one application server to one database port).
- Internal → DMZ: permitted as needed for management and publishing.
- DMZ → Internet: restricted to what the service genuinely needs (this is egress control again).
The point is the third rule. A public web server will eventually be exploited; the DMZ ensures that what the attacker inherits is a segment with almost no onward reach, rather than a foothold inside the trusted network.
Segmentation operates at several granularities:
| Approach | Boundary | Granularity | Notes |
|---|---|---|---|
| VLANs + routed ACLs | Layer 2 domains, filtered at the router/firewall | Per subnet | Traditional; VLANs alone are not a security control without filtering between them |
| Zone-based firewalls | Named zones with inter-zone policy | Per zone | Clearer intent than per-interface rules |
| Private VLANs / port isolation | Within one subnet | Per port | Stops peer-to-peer traffic in, for example, a guest or server VLAN |
| Microsegmentation | Per workload, enforced by hypervisor, host firewall, or sidecar | Per host or per service | Cloud/SDN native; identity- or label-based rather than address-based |
| Air gap / one-way | Physical or data-diode | Absolute | Industrial control, high-assurance environments |
Typical zones in an enterprise: Internet, DMZ, user VLANs, server zones split by application tier, a dedicated management network (switch and hypervisor interfaces, out-of-band access), PCI/regulated scopes, guest Wi-Fi (Internet only), and OT/IoT networks — building controls, cameras, industrial equipment — which must be isolated because they are rarely patchable.
Two principles govern the rules themselves. Default deny between zones, with each permitted flow justified by a documented application requirement. And segment by trust and function, not by convenience: the reason a printer VLAN and a finance database must not share a zone has nothing to do with topology.
Segmentation's practical difficulty is not technology but knowing your flows. Projects fail when policy is written from assumptions; the reliable method is to log in permissive mode, derive the real dependency map from flow records, then tighten. Microsegmentation makes the enforcement fine-grained, which raises the cost of getting that map wrong.
Examples
Zones and the direction of trust
Internet
| only 443 inbound
+-------v--------+
| DMZ | reverse proxy, VPN concentrator, mail relay
| 198.51.100.0/24|
+---+--------+---+
DMZ -> internal: DENY except one flow |
| | app -> db: 10.0.30.11:5432 ONLY
+---------------v---+ +--------v---------+ +------------------+
| user VLANs | | app tier | | management |
| 10.0.10.0/24 | | 10.0.20.0/24 | | 10.0.99.0/24 |
+---------+---------+ +--------+---------+ +--------+---------+
| users -> app: 443 only | ^
+-----------------------------------+ | jump host only
+-------------------+ +------------------+ +--------+---------+
| guest Wi-Fi | | OT / IoT | | db tier |
| Internet only | | no Internet, | | 10.0.30.0/24 |
| no internal | | poll to 1 server| | no egress |
+-------------------+ +------------------+ +------------------+
The decisive rule: DMZ -> internal is DENY by default.
A compromised public web server then owns a segment, not the company.
# Zone policy as code: default deny, explicit justified flows, and a lateral-movement test.
from dataclasses import dataclass
import ipaddress
ZONES = {
"dmz": ipaddress.ip_network("198.51.100.0/24"),
"users": ipaddress.ip_network("10.0.10.0/24"),
"app": ipaddress.ip_network("10.0.20.0/24"),
"db": ipaddress.ip_network("10.0.30.0/24"),
"mgmt": ipaddress.ip_network("10.0.99.0/24"),
"guest": ipaddress.ip_network("10.0.200.0/24"),
"ot": ipaddress.ip_network("10.0.240.0/24"),
}
# (src_zone, dst_zone, port) -> why it exists. Anything absent is denied.
POLICY = {
("internet", "dmz", 443): "public web entry",
("users", "app", 443): "staff use the application",
("app", "db", 5432): "application reads its database",
("dmz", "app", 8080): "reverse proxy to app tier (only permitted DMZ->internal flow)",
("mgmt", "app", 22): "administration from the jump host",
("mgmt", "db", 22): "administration from the jump host",
("ot", "app", 1883): "sensors publish telemetry (MQTT)",
("guest", "internet", 443): "guest browsing only",
}
def zone_of(ip):
addr = ipaddress.ip_address(ip)
for name, net in ZONES.items():
if addr in net:
return name
return "internet"
def permitted(src_ip, dst_ip, port):
s, d = zone_of(src_ip), zone_of(dst_ip)
if s == d:
return True, f"intra-zone ({s}) -- consider microsegmentation if this should be filtered"
why = POLICY.get((s, d, port))
return (True, f"allowed: {why}") if why else (False, f"DENY {s}->{d}:{port} (no policy)")
tests = [
("198.51.100.20", "10.0.20.11", 8080, "proxy to app: expected to work"),
("198.51.100.20", "10.0.30.11", 5432, "compromised DMZ host reaching the database"),
("10.0.10.55", "10.0.30.11", 5432, "workstation reaching the database directly"),
("10.0.200.7", "10.0.10.55", 445, "guest device scanning staff laptops"),
("10.0.240.9", "10.0.20.11", 1883, "sensor telemetry"),
("10.0.20.11", "10.0.99.10", 22, "app tier into management network"),
]
for src, dst, port, label in tests:
ok, reason = permitted(src, dst, port)
print(f"{'ALLOW' if ok else 'BLOCK'} {label:48} {reason}")
# ALLOW proxy to app: expected to work allowed: reverse proxy to app tier (only permitted DMZ->internal flow)
# BLOCK compromised DMZ host reaching the database DENY dmz->db:5432 (no policy)
# BLOCK workstation reaching the database directly DENY users->db:5432 (no policy)
# BLOCK guest device scanning staff laptops DENY guest->users:445 (no policy)
# ALLOW sensor telemetry allowed: sensors publish telemetry (MQTT)
# BLOCK app tier into management network DENY app->mgmt:22 (no policy)
# ^ management is reachable FROM mgmt only: a compromised app server cannot pivot into it.
# Derive the real flow map BEFORE tightening policy (log-first method):
$ sudo nft add rule inet fw forward counter log prefix "flowmap: " accept # permissive + logged
$ sudo journalctl -k | grep flowmap | grep -oE 'SRC=\S+ DST=\S+ DPT=[0-9]+' | sort | uniq -c | sort -rn | head
# 18422 SRC=10.0.20.11 DST=10.0.30.11 DPT=5432 <- real dependency: app -> db
# 3011 SRC=10.0.10.55 DST=10.0.20.11 DPT=443 <- real dependency: users -> app
# 6 SRC=10.0.20.11 DST=10.0.99.10 DPT=22 <- unexpected: investigate before allowing
# Enforce zone policy with nftables (default deny between zones, explicit flows only):
$ sudo nft -f - <<'EOF'
table inet zones {
chain forward {
type filter hook forward priority 0; policy drop;
ct state established,related accept
ip saddr 198.51.100.0/24 ip daddr 10.0.20.0/24 tcp dport 8080 accept # dmz -> app
ip saddr 10.0.20.0/24 ip daddr 10.0.30.0/24 tcp dport 5432 accept # app -> db
ip saddr 10.0.10.0/24 ip daddr 10.0.20.0/24 tcp dport 443 accept # users -> app
ip saddr 10.0.200.0/24 ip daddr 10.0.0.0/8 counter log prefix "guest-to-internal: " drop
counter log prefix "zone-deny: " drop
}
}
EOF
# Stop peer-to-peer traffic inside one subnet (private VLAN / port isolation, Cisco):
# switch(config)# vlan 200
# switch(config-vlan)# private-vlan isolated ! guest devices cannot see each other
# Verify segmentation by testing from the wrong side (authorised validation):
$ nc -zv -w3 10.0.30.11 5432 # run from a user VLAN host: must FAIL
# nc: connect to 10.0.30.11 port 5432 (tcp) failed: Connection timed out <- policy proven
Key takeaways
- Segmentation limits blast radius and is the structural defence against lateral movement.
- A DMZ hosts Internet-facing services with DMZ → internal denied except narrow, justified flows — so compromising a public server yields little.
- Granularity ranges from VLANs with ACLs to microsegmentation per workload; VLANs alone, without filtering between them, are not a security control.
- Isolate management, guest, and OT/IoT networks; unpatchable devices belong behind their own boundary. · - Segment by trust and function with default deny, and derive the policy from observed flows, not assumptions.
🧪 Practice
- Write the four directional DMZ rules for a web application whose only internal dependency is one database.
- Explain why placing hosts in different VLANs provides no security on its own, and what must be added.
- Extend the policy-as-code example so that intra-zone traffic in the guest zone is denied, and show which test result changes.
- Interview-style: A ransomware incident began on one workstation and encrypted file servers across three sites. Which control would have limited it most, and how would you validate that control afterwards? (Hint: the malware had to reach those servers — think about which flows a workstation genuinely needs, and how you would prove the rest are blocked.)
Honeypots and Deception
Theory
Every control so far tries to keep attackers out or spot them in real traffic. Deception inverts the problem: deploy resources that have no legitimate purpose, so that any interaction with them is, by definition, suspicious. That property is what makes deception valuable — it sidesteps the base-rate problem that drowns conventional detection in false positives. Nobody has a reason to log into a decoy server, so an alert from one is high-signal almost by construction.
A honeypot is a system deliberately exposed to be attacked, instrumented to record what happens. Two axes describe them:
| Interaction level | What it emulates | Value | Risk |
|---|---|---|---|
| Low-interaction | Just enough of a service to capture the first moves (a fake banner, a fake login) | Credentials tried, sources, scanning trends | Low — nothing real to exploit |
| High-interaction | A real operating system and services | Deep insight into tools and post-exploitation behaviour | Real risk: it can be fully compromised and used to attack others |
| Purpose | Placement | Goal |
|---|---|---|
| Research | Internet-facing | Study attacker behaviour and trends at large |
| Production | Inside the network, among real assets | Detect intruders already inside — the highest-value use |
Related deception primitives are cheaper and often more useful than a full honeypot:
- Honeytokens: fake credentials, API keys, or AWS keys placed in configuration files, repositories, or password managers. Use of one proves both an intrusion and, usually, where the attacker looked.
- Honey records in a database (a fake customer row) reveal data theft when they appear elsewhere.
- Honey files and shares: a
salaries.xlsxon a share nobody should browse, alerting on access. - Canary hosts on internal VLANs that listen on common ports (SMB, RDP, SSH) and alert on any connection — extremely effective against the internal scanning that precedes lateral movement.
- Tarpits that answer very slowly to waste automated tooling's time.
Deployment discipline matters, because a honeypot is a deliberately weak machine:
- Isolate it. Its own segment, with tight egress so it cannot be used to attack third parties. This is the ethical and legal obligation that comes with running one.
- Log off-box. An attacker who controls the host controls its local logs; send telemetry to a system they cannot reach.
- Make it plausible but sterile. Realistic enough to engage, holding no real data and no credentials that work anywhere else.
- Route alerts to a process. A honeypot nobody monitors is just an extra vulnerable host.
- Do not counter-attack. Deception is for detection and intelligence; retaliating against an attacking host is illegal in most jurisdictions and frequently hits an innocent compromised third party.
Deception does not replace prevention or monitoring; it adds a layer with unusually good precision, especially for detecting an intruder who is already past the perimeter.
Examples
Why deception has near-perfect precision
REAL SERVICE thousands of legitimate requests/hour + rare attacks
-> detection must separate them: false positives inevitable
DECOY SERVICE zero legitimate requests, ever
-> ANY interaction = investigate. Precision approaches 100%.
Production placement: catch the intruder already inside
[compromised workstation 10.0.10.55]
| scans the /24 for SMB, RDP, SSH (lateral movement)
v
10.0.10.9 CANARY HOST: no users, no purpose, listens on 445/3389/22
|
+--> alert to SIEM: "10.0.10.55 connected to canary 10.0.10.9:445"
first solid evidence of an intrusion, minutes after it starts
Honeytoken flow
fake AWS key placed in an internal wiki page and a repo config
-> attacker finds it during discovery and tries to use it
-> cloud provider logs the attempt -> alert names the key's location
-> you learn there is an intruder AND where they have already looked
# A production canary service: listens on commonly-probed ports, alerts on any connection.
import socket, threading, json, datetime
CANARY_PORTS = [22, 445, 3389, 8080] # what internal scanners look for first
BANNERS = { # plausible, but the service does nothing
22: b"SSH-2.0-OpenSSH_8.9p1 Ubuntu-3ubuntu0.4\r\n",
445: b"",
3389: b"",
8080: b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"Router Admin\"\r\n\r\n",
}
def alert(event):
# Ship OFF-BOX: an attacker on this host must not be able to erase the evidence.
line = json.dumps({"ts": datetime.datetime.now(datetime.timezone.utc).isoformat(),
"severity": "high", "rule": "canary_touched", **event})
print(line) # in production: syslog/HTTPS to the SIEM
# syslog_socket.sendto(line.encode(), ("10.0.99.20", 514))
def serve(port):
with socket.create_server(("0.0.0.0", port), reuse_port=True) as srv:
while True:
conn, addr = srv.accept()
first = b""
try:
conn.settimeout(2)
if BANNERS[port]:
conn.sendall(BANNERS[port])
first = conn.recv(256) # record what the client sent: tool fingerprint
except OSError:
pass
finally:
conn.close()
alert({"src_ip": addr[0], "src_port": addr[1], "dst_port": port,
"first_bytes": first[:80].decode("latin-1", "replace")})
for p in CANARY_PORTS:
threading.Thread(target=serve, args=(p,), daemon=True).start()
# Any connection produces an alert like:
# {"ts": "2026-09-24T12:41:09+00:00", "severity": "high", "rule": "canary_touched",
# "src_ip": "10.0.10.55", "src_port": 51334, "dst_port": 445, "first_bytes": "\\x00\\x00\\x00\\xc0\\xfeSMB@..."}
# -> 10.0.10.55 has no business here: treat as a compromised host and investigate it.
# Honeytokens: generate decoy credentials, record where each was planted, alert on use.
import secrets, hashlib
class Honeytokens:
def __init__(self):
self.tokens = {} # token -> placement description
def mint(self, placement, kind="api_key"):
token = f"AKIA{secrets.token_hex(8).upper()}" if kind == "aws" else secrets.token_urlsafe(24)
self.tokens[token] = placement
return token
def check_use(self, presented_token, src_ip):
placement = self.tokens.get(presented_token)
if placement is None:
return None # a real (or simply invalid) credential
return {"severity": "critical",
"rule": "honeytoken_used",
"src_ip": src_ip,
"planted_in": placement,
"meaning": "intrusion confirmed; attacker reached this location"}
ht = Honeytokens()
wiki_key = ht.mint("internal wiki page 'Legacy S3 access'", kind="aws")
repo_key = ht.mint("config/settings.example.yml in the payments repo")
backup_key = ht.mint("credential manager entry 'old backup service'")
print(wiki_key) # e.g. AKIA3F9C21B70E4D8A15
print(ht.check_use("ordinary-user-token", "10.0.10.55")) # None
print(ht.check_use(repo_key, "203.0.113.9"))
# {'severity': 'critical', 'rule': 'honeytoken_used', 'src_ip': '203.0.113.9',
# 'planted_in': 'config/settings.example.yml in the payments repo',
# 'meaning': 'intrusion confirmed; attacker reached this location'}
# The placement tells you the attacker read that repository -- scope the incident from there.
# Isolate a honeypot so it can never attack third parties (the ethical requirement):
$ sudo nft -f - <<'EOF'
table inet honeypot {
chain input { type filter hook input priority 0; policy accept; } # let attackers in
chain output {
type filter hook output priority 0; policy drop; # but nothing out
ct state established,related accept # replies only
ip daddr 10.0.99.20 udp dport 514 accept # off-box logging only
}
}
EOF
# Low-interaction SSH honeypot session recorded by Cowrie (research deployment):
$ jq -r 'select(.eventid=="cowrie.login.failed") | "\(.src_ip) \(.username):\(.password)"' \
cowrie.json | sort | uniq -c | sort -rn | head -5
# 412 203.0.113.44 root:123456
# 388 203.0.113.44 root:admin
# 201 198.51.100.9 admin:admin
# ^ credential lists in active use -- feed these into your own password blocklist
# Canary alerts should be routed as high severity with no tuning needed:
$ logger -p auth.crit -t canary "canary_touched src=10.0.10.55 dst_port=445"
Key takeaways
- Deception works because decoys have no legitimate use, so any interaction is high-signal — sidestepping the base-rate problem.
- Low-interaction honeypots are safe and capture first moves; high-interaction ones yield deeper insight and carry real compromise risk.
- Production placement (canary hosts, honeytokens, honey files) detects intruders already inside, which is their highest-value use.
- Honeytokens reveal both the intrusion and where the attacker looked.
- Run them responsibly: isolate with strict egress, log off-box, keep them sterile, monitor the alerts, and never counter-attack.
🧪 Practice
- Explain why a canary host generates far fewer false positives than an anomaly-based IDS on the same VLAN.
- Choose three locations to plant honeytokens in your environment and state what the use of each would tell you.
- Write the egress rules for a high-interaction honeypot and justify each permitted flow.
- Interview-style: Your honeypot is compromised and starts scanning the Internet. What went wrong, and what is your responsibility? (Hint: the compromise itself was the point — ask which control should have bounded the consequences, and who the outbound traffic reaches.)
SIEM and Traffic Analytics
Theory
By this point the network produces evidence from many places: firewall drops, proxy logs, DNS queries, IDS alerts, flow records, authentication events, endpoint telemetry, honeytoken hits. Each is a narrow view, and attacks rarely announce themselves in a single source. A SIEM (Security Information and Event Management) system centralises these events, normalises them, correlates across sources, and turns them into alerts and investigations.
The core value is correlation. Individually, a failed VPN login, a DNS query for an unknown domain, and a large upload are unremarkable. In sequence, from one user, within ten minutes, they are an incident. Only a system holding all three sources can see that.
A SIEM pipeline has recognisable stages:
- Collection: agents and collectors gather syslog, Windows events, cloud audit logs (CloudTrail), flow records, proxy and DNS logs.
- Normalisation: map wildly different formats onto common fields (
src_ip,user,action,outcome) so a single query spans sources. Schemas like OCSF or ECS exist for this. - Enrichment: add context — geolocation, asset criticality, owner, threat-intelligence matches, whether the account is privileged. Enrichment is what turns "an IP" into "the finance database".
- Correlation and detection: rules and statistics across sources and time windows.
- Alerting and triage: route to analysts with enough context to act; suppress and tune relentlessly.
- Retention and search: keep data long enough for investigations — attacks are often discovered months later — which makes storage cost the central design constraint.
Two adjacent capabilities complete the picture. Traffic analytics (NetFlow/IPFIX, Zeek logs, cloud VPC flow logs) provides cheap, complete metadata coverage of who talked to whom, when, and how much. Because it survives encryption and is far smaller than full packet capture, it is the practical substrate for the behavioural detections in 11.4 — beaconing, volume asymmetry, rare destinations, internal scanning. UEBA (user and entity behaviour analytics) baselines per-user and per-host behaviour and scores deviations, catching credential misuse that no signature describes.
Practical realities to plan for:
- Volume and cost dominate. Decide deliberately what to collect at full fidelity, what to sample, and what to summarise. Collecting everything and searching nothing is the common failure.
- Time synchronisation (NTP) and consistent time zones are prerequisites; correlation across sources is impossible without them.
- Log integrity: ship logs off-host immediately and make them append-only, because erasing local logs is standard practice for intruders.
- Alert fatigue is the main operational risk. Fewer, higher-precision, well-documented detections beat a large noisy rule set; every alert needs a documented response.
- Coverage mapping: frameworks such as MITRE ATT&CK let you ask which techniques would we actually see? — usually more informative than counting rules.
Examples
Correlation: three harmless events, one incident
09:02 VPN user=alice result=success src=203.0.113.9 geo=RO (normally NL)
09:04 DNS src=10.99.0.23 query=cdn-update-svc.example (first time seen in the org)
09:06 proxy src=10.99.0.23 user=alice bytes_out=1.8 GB dst=storage.example
09:07 canary src=10.99.0.23 dst_port=445
individually : a trip abroad; an unknown domain; a big upload; one odd connection
correlated : impossible travel -> new C2 domain -> bulk egress -> internal scan
=> account compromise with data exfiltration in progress
Data sources and what each answers
firewall logs : was it allowed or blocked, and between which addresses
flow records : who talked to whom, how much, how often <- survives encryption, cheap
proxy logs : which URL, which user, which category
DNS logs : what names were resolved <- earliest signal in most intrusions
IDS alerts : which known-bad pattern matched
auth logs : who logged in, from where, success or failure
endpoint : which process did it <- links a connection to a binary
honeytokens : confirmed intrusion + where the attacker looked
# A small correlation engine: normalise, enrich, then score sequences across sources.
from dataclasses import dataclass, field
from collections import defaultdict
import datetime as dt
@dataclass
class Event: # the NORMALISED schema every source is mapped onto
ts: dt.datetime
source: str # vpn | dns | proxy | canary | auth
user: str | None
src_ip: str
action: str
outcome: str = "success"
detail: dict = field(default_factory=dict)
ASSETS = {"10.0.30.11": {"name": "finance-db", "criticality": "high"},
"10.99.0.23": {"name": "vpn-pool", "criticality": "low"}}
HOME_GEOS = {"alice": {"NL"}}
def enrich(e: Event):
e.detail["asset"] = ASSETS.get(e.src_ip, {}).get("name", "unknown")
if e.source == "vpn" and e.user in HOME_GEOS:
e.detail["unusual_geo"] = e.detail.get("geo") not in HOME_GEOS[e.user]
return e
RULES = [
("impossible_travel", lambda e: e.source == "vpn" and e.detail.get("unusual_geo"), 3),
("rare_domain", lambda e: e.source == "dns" and e.detail.get("first_seen"), 2),
("bulk_egress", lambda e: e.source == "proxy" and e.detail.get("bytes_out", 0) > 1e9, 3),
("canary_touched", lambda e: e.source == "canary", 5),
("brute_force", lambda e: e.source == "auth" and e.outcome == "failure", 1),
]
def correlate(events, window_minutes=15, threshold=6):
by_actor = defaultdict(list)
for e in events:
enrich(e)
for name, test, weight in RULES:
if test(e):
by_actor[e.user or e.src_ip].append((e.ts, name, weight))
incidents = []
for actor, hits in by_actor.items():
hits.sort()
for i, (ts0, _, _) in enumerate(hits):
window = [h for h in hits[i:] if (h[0] - ts0).total_seconds() <= window_minutes * 60]
score = sum(w for _, _, w in window)
if score >= threshold and len({n for _, n, _ in window}) >= 3:
incidents.append({"actor": actor, "score": score, "started": ts0.strftime("%H:%M"),
"signals": [n for _, n, _ in window]})
break
return incidents
t = lambda hhmm: dt.datetime.strptime(hhmm, "%H:%M")
events = [
Event(t("09:02"), "vpn", "alice", "203.0.113.9", "login", detail={"geo": "RO"}),
Event(t("09:04"), "dns", "alice", "10.99.0.23", "query",
detail={"qname": "cdn-update-svc.example", "first_seen": True}),
Event(t("09:06"), "proxy", "alice", "10.99.0.23", "upload", detail={"bytes_out": 1.8e9}),
Event(t("09:07"), "canary", "alice", "10.99.0.23", "connect", detail={"dst_port": 445}),
Event(t("11:30"), "auth", "bob", "10.0.10.77", "login", outcome="failure"),
]
for inc in correlate(events):
print(inc)
# {'actor': 'alice', 'score': 13, 'started': '09:02',
# 'signals': ['impossible_travel', 'rare_domain', 'bulk_egress', 'canary_touched']}
# bob's single failed login scores 1 and never becomes an incident -- which is the point.
# Flow records: the cheap, encryption-proof backbone of traffic analytics.
# Top talkers by volume (nfdump against NetFlow/IPFIX collected from routers):
$ nfdump -R /var/flows -s record/bytes -n 5
# Date first seen Duration Proto Src IP:Port Dst IP:Port Packets Bytes
# 2026-09-24 09:06:11 412.088 TCP 10.99.0.23:52344 198.51.100.40:443 1.2 M 1.8 G
# ^ 1.8 GB outbound from a VPN client: investigate
# Cloud equivalent: VPC flow logs queried for internal scanning
$ aws logs start-query --log-group-name /vpc/flowlogs \
--query-string 'fields srcAddr, dstPort | filter action="REJECT" | stats count_distinct(dstAddr) as hosts by srcAddr | filter hosts > 20'
# Centralise logs immediately so an intruder cannot erase them (rsyslog forwarding):
# *.* action(type="omfwd" target="10.0.99.20" port="6514"
# protocol="tcp" StreamDriver="gtls" StreamDriverMode="1")
# ^ TLS: the log channel itself needs integrity
# Time sync is a prerequisite for correlation -- verify it everywhere:
$ chronyc tracking | grep -E "System time|Last offset"
# System time : 0.000004821 seconds fast of NTP time
# Sizing reality check before turning on full collection:
$ echo "scale=1; 40000 * 500 * 86400 / 1024/1024/1024" | bc
# 1608.0 <- 40k events/s at 500 bytes = ~1.6 TB/day raw: decide fidelity deliberately
Key takeaways
- A SIEM centralises, normalises, enriches, and correlates events; its value is seeing sequences no single source reveals.
- Enrichment (asset criticality, identity, geo, threat intel) is what makes an alert actionable.
- Flow records give complete, cheap metadata coverage that survives encryption — the substrate for behavioural detection; UEBA baselines per-user behaviour.
- Plan for volume and cost, insist on NTP-synchronised clocks, and ship logs off-host so they cannot be erased.
- Alert fatigue is the main failure mode: prefer fewer high-precision detections with documented responses, and map coverage with MITRE ATT&CK.
🧪 Practice
- List which log source would answer each question: which process opened a connection; whether a packet was blocked; which URL a user fetched; how much data left the network.
- Write a correlation rule for "password spraying followed by one success from the same source", naming the sources and time window.
- Estimate daily storage for 8,000 events per second at 400 bytes each, and propose what to sample if the budget allows 1 TB per day.
- Interview-style: Your SIEM produces 4,000 alerts a day and the team investigates 30. How do you fix this? (Hint: the problem is precision, not volume of data — think about which alerts have ever led to a real finding, and what each remaining one should tell an analyst to do.)
<a id="12-quality-of-service-and-traffic-engineering"></a>