Secure Transport / TLS (certificate-validation-bypass MITM)
Security & Cryptography · Nutthakorn Chalaemwongwan
Today
- The TLS handshake: what it authenticates — and what it doesn't
- X.509 certificates & the CA chain of trust
- Hostname verification (SAN, RFC 6125)
- Live MITM: a client that skips certificate validation
- 🎭 Game: The Impostor's Certificate
Recap — Week 11
- A digital signature lets anyone with the public key verify a claim — no shared secret needed
- A signature is also a non-interactive zero-knowledge proof: it convinces you the signer knows the private key, without revealing it
- This week: a certificate is a CA's signature over "this key belongs to this name" — watch what happens when nobody checks it
The TLS handshake — three jobs
- Key exchange — negotiate a shared session key → confidentiality
- Server authentication — the server presents a certificate claiming an identity
CertificateVerify— server proves it holds the private key matching that cert- Skip any one of the three and a machine-in-the-middle is back on the table
X.509 certificates & the chain of trust
- A certificate is a claim: "this public key belongs to
bob" - Anyone can generate a certificate that claims to be
bob— self-signing costs nothing and needs no permission - A Certificate Authority's signature is what makes the claim checkable — your OS/browser already trusts the CA, so a CA-signed cert inherits that trust
- No signature from a trusted CA → no chain → nothing backs the claim
Hostname verification
- A valid chain of trust still isn't enough — the name in the cert must match the name you meant to connect to
- Check the SAN (Subject Alternative Name) — RFC 6125 — not just the legacy CN field
- In this week's lab, both the real and impostor certs carry
CN=bob,SAN=DNS:bob— hostname matching alone cannot tell them apart - You need both: chain of trust and hostname match
The lab: 4 services, one Docker network
| Service | Role | What it does |
|---|---|---|
gen_certs.py | init | generates a demo CA, Bob's CA-signed cert, and a self-signed impostor (also CN=bob) |
bob.py | legit server | mostly idle — Alice never dials it directly |
mitm.py | attacker | presents the impostor cert; logs MITM INTERCEPTED: <secret> on success |
alice.py | client | dials mitm, believing it's bob; behavior set by env var VERIFY |
The break: CERT_NONE, check_hostname=False
ALICE: TLS handshake succeeded WITHOUT verifying the server cert
ALICE: sent encrypted message ('the vault code is 7731')
MITM INTERCEPTED: the vault code is 7731
- CWE-295 — Improper Certificate Validation
- CWE-300 — Channel Accessible by Non-Endpoint
- The handshake genuinely succeeds — real crypto, real session key — just negotiated with the wrong party
"Encrypted" ≠ "encrypted to the right party"
- Encryption protects a channel; only certificate validation ties that channel to a specific identity
CertificateVerifyeven passes — the MITM genuinely holds the impostor cert's private keycheck_hostname=False+CERT_NONE= Alice never asks "is this actually bob?"- Whoever answers when Alice dials
mitm:8443gets treated asbob— no questions asked
The fix: VERIFY=1 — load the CA, keep hostname checking
ALICE: connecting to mitm:8443 as if it were 'bob' -- FIXED (VERIFY=1)
CERT VERIFICATION FAILED - ABORTING
ALICE: (reason: self-signed certificate)
mitm.pyis byte-for-byte unchanged — same impostor cert, same attempt- Alice now trusts only the demo CA; the impostor has no chain to it
- Fails during the handshake, before the secret is sent
- Reason given: a trust/issuer error — not a hostname mismatch (the name was correct all along)
The real-world version of this bug
verify=False(Pythonrequests) ·CERT_NONE(Pythonssl) ·InsecureSkipVerify(Go)- Almost always copy-pasted to silence an SSL error — "just make it work"
- "It's HTTPS, so it's encrypted" is true — and irrelevant if nobody checked who is on the other end
- This is the single most common real-world TLS mistake — not a CA compromise, not a crypto break
Beyond the demo — this week's essay arm
- Why SSL was retired, what TLS 1.3 changed
- Forward secrecy — each session gets an ephemeral key, so a leaked long-term key can't decrypt past traffic (contrast: legacy static-RSA key exchange, removed in TLS 1.3 — see Heartbleed as the case for why that mattered)
- 0-RTT — the performance-vs-replay trade-off
- Mitigations for a compromised CA: pinning, revocation (CRL/OCSP), Certificate Transparency
- TLS vs. the Noise Protocol Framework
🎭 Game — "The Impostor's Certificate"
- You play the attacker: stand in for the server with nothing but a self-signed cert you generated yourself
- Watch a careless client hand you its secret — "encrypted" the whole time
- Flip on real certificate validation — the same impostor cert gets rejected before a single byte ships
Lab today — Worksheet 12
📋 Worksheet 12 —
labs/week12-secure-transport/worksheet.md· kickoff:docker compose -f docker-compose.vulnerable.yml up --build
- Part 1 (essays): SSL→TLS, handshake security roles, forward secrecy, 0-RTT, Web PKI, TLS vs Noise
- Part 2a: capture vulnerable + fixed logs, then write the fix yourself in
alice_student.py - 🤖 Audit the AI: critique a
verify=False"fix" that an AI assistant swears is secure - EiPE + Prompt Problem + viva prep
Key takeaways
- TLS's crypto is textbook-sound — key exchange and signatures both work exactly as designed
- Textbook-secure primitive, real-system failure: the bug was skipping validation, not broken math
- "Encrypted" and "encrypted to the right endpoint" are different claims — only certificate validation bridges them
Questions?
Next week: End-to-end encryption