TLS 1.3 on the Wire: What an Eavesdropper Can Still See Before Encryption Kicks In

This post is part of Protocol Lab, a free, hands-on series for learning networking protocols by building and breaking them in a container lab. All the lab material — topologies, configs, and scripts — lives in the repo: github.com/pathvector-studio/protocol-lab.

TCP gave us a reliable byte stream. TLS runs on top of it to add encryption and server authentication. This lab opens one TLS 1.3 connection and asks a precise question: what can an on-path observer see before encryption takes over?

Reading guide: rfc-notes/tls-handshake-certificates.md

Prerequisite: TCP Lab 07: One Connection, From SYN to FIN

Expected time: 55–70 minutes.

The Goal

You will:

  • run an openssl s_server with a self-signed certificate and ALPN,
  • connect with openssl s_client using SNI (-servername),
  • read the negotiated protocol, cipher, certificate, and ALPN,
  • capture the handshake and see that ClientHello (with SNI and the ALPN offer) is in the clear, while in TLS 1.3 the certificate is encrypted.

By the end, you should be able to fill in this table:

Handshake item Visible on the wire (TLS 1.3)? How you saw it
SNI (server_name) yes, in ClientHello capture / tshark
ALPN offer yes, in ClientHello capture / tshark
Chosen cipher / version yes, in ServerHello capture
Server certificate no (encrypted) only via s_client endpoint

What You Will Learn

  • Where TLS sits: on top of TCP, below the application (HTTP).
  • What ClientHello and ServerHello carry, and which parts are cleartext.
  • What SNI is and why the server needs it before choosing a certificate.
  • What ALPN negotiates (e.g. h2 vs http/1.1).
  • Why, in TLS 1.3, the certificate is sent encrypted (unlike TLS 1.2).

This lab does not cover:

  • A real CA-signed certificate chain or trust-store validation.
  • Client certificates / mutual TLS.
  • Encrypted Client Hello (ECH), which also hides SNI.
  • The cryptographic details of key exchange.

Where to Read in the RFCs

RFC Section What to focus on
RFC 8446 2 The overall TLS 1.3 handshake (1-RTT)
RFC 8446 4.1.2 What's inside ClientHello
RFC 8446 4.1.3 What's inside ServerHello
RFC 8446 4.4.2 The Certificate message (encrypted in 1.3)
RFC 6066 3 Server Name Indication (SNI)
RFC 7301 3 Application-Layer Protocol Negotiation (ALPN)
RFC 5737 3 Confirming the names/addresses used here are documentation-only

The Big Picture

Same two-node setup as Labs 07/08: the server runs a TLS listener, and the client connects over TLS.

client (10.0.0.1) ------ eth1/eth1 ------ server (10.0.0.2:4433)
                                          openssl s_server
                                          cert CN=www.example.lab
                                          ALPN: h2, http/1.1

The client connects with SNI=www.example.lab and an ALPN offer of h2,http/1.1, and captures the handshake on its own side.

sequenceDiagram
  participant C as client
  participant S as server

  Note over C,S: TCP already established (Lab 07)
  C->>S: ClientHello (SNI=www.example.lab, ALPN=[h2,http/1.1], ciphers, key share)
  Note right of C: cleartext — visible on the wire
  S->>C: ServerHello (chosen cipher, key share)
  Note left of S: cleartext — visible on the wire
  Note over C,S: keys derived; rest is encrypted
  S-->>C: {EncryptedExtensions (ALPN=h2)}
  S-->>C: {Certificate CN=www.example.lab}  (encrypted in TLS 1.3)
  S-->>C: {CertificateVerify, Finished}
  C-->>S: {Finished}
  Note over C,S: application data (HTTP) flows encrypted

Both nodes run nicolaka/netshoot (which bundles openssl, tcpdump, and tshark). No additional images are required.

Note: The names and addresses used here are local/documentation-only, so nothing in this lab touches the real internet.

What You Need

Recommended environment:

  • Linux / WSL2 / a Linux VM
  • Docker
  • containerlab
  • tcpdump / tshark

Images used:

  • nicolaka/netshoot:latest

Running the Lab

The quick path, which deploys, generates the certificate, starts s_server, captures the handshake, runs s_client, checks SNI/ALPN/certificate, and tears down for you:

./scripts/labctl.sh run tls-09

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/tls-09

2. Deploy and create the certificate

sudo containerlab deploy -t tls-09.clab.yml
docker exec clab-tls-09-server sh -c \
  "openssl req -x509 -newkey rsa:2048 -nodes \
     -keyout /tmp/server.key -out /tmp/server.crt \
     -subj '/CN=www.example.lab' -days 30 -addext 'subjectAltName=DNS:www.example.lab'"

This is a self-signed certificate for a closed lab — no public CA involved.

3. Start the TLS listener on the server

docker exec -d clab-tls-09-server sh -c \
  "openssl s_server -accept 4433 -cert /tmp/server.crt -key /tmp/server.key -alpn h2,http/1.1 -www -quiet"

-alpn h2,http/1.1 declares which application protocols the server can speak.

4. Set up the capture on the client

In a separate shell, start the capture (use -s0 so you get the whole handshake):

docker exec -it clab-tls-09-client tcpdump -i eth1 -s0 -n "tcp port 4433"

5. Connect over TLS

docker exec -it clab-tls-09-client sh -c \
  "echo Q | openssl s_client -connect 10.0.0.2:4433 -servername www.example.lab -alpn h2,http/1.1 -tls1_3"

What to look for in the s_client output:

subject=CN = www.example.lab
issuer=CN = www.example.lab
...
ALPN protocol: h2
...
Protocol  : TLSv1.3
Cipher    : TLS_AES_256_GCM_SHA384
Verify return code: 18 (self signed certificate)
  • subject=CN = www.example.lab: the certificate the server presented.
  • ALPN protocol: h2: client and server agreed on h2 (HTTP/2).
  • Protocol : TLSv1.3.
  • Verify return code: 18: verification is treated as failed because the certificate is self-signed (not from a public CA). In this lab, that's expected.

6. Read the cleartext parts out of the capture

If you have tshark, you can extract the SNI and the ALPN offer from inside the ClientHello:

docker exec clab-tls-09-client sh -c \
  "tshark -r /tmp/tls-09.pcap -Y 'tls.handshake.type==1' \
     -T fields -e tls.handshake.extensions_server_name -e tls.handshake.extensions_alpn_str"

Expected:

www.example.lab   h2,http/1.1

In other words, the SNI and the ALPN offer sit in the ClientHello in cleartext. The certificate, on the other hand, is sent after encryption begins in TLS 1.3, so its contents cannot be read from the capture (s_client can decrypt and display it because it is an endpoint of the connection).

Expected Output

  • s_client: CN = www.example.lab, ALPN protocol: h2, TLSv1.3.
  • tshark (if available): the ClientHello's server_name = www.example.lab, alpn = h2,http/1.1.
  • The capture: ClientHello / ServerHello are readable, but the Certificate is encrypted and is not.

Why It Works

TLS is the layer that adds "encryption" and "verifying the peer is genuine" on top of TCP. A TLS connection first runs a handshake to agree on the cipher suite, the keys, and the application protocol.

  • Why SNI is cleartext. A server may host multiple sites on a single IP. To choose which certificate to present, it needs to know "which site is this for?" before any encryption keys exist. That's why SNI rides in the ClientHello in cleartext. (Hiding it is what ECH does — outside this lab's scope.)
  • What ALPN decides. It negotiates the upper-layer protocol — HTTP/2 (h2) vs HTTP/1.1 — inside the handshake, in a single round trip. No extra negotiation round trips are needed.
  • Why TLS 1.3 encrypts the certificate. Right after ClientHello / ServerHello exchange key material (key shares), the keys are derived, and everything from then on (EncryptedExtensions, Certificate, Finished) is encrypted. In TLS 1.2 the certificate went out in cleartext; in 1.3 it's hidden. So an on-path observer can't see which certificate was served (though the SNI still hints at the site).

The key insight: be able to point at the exact boundary where encryption begins, right there in the capture. Everything up to ClientHello/ServerHello is cleartext; everything after is encrypted.

Common Pitfalls

  • Confusing the SNI with the certificate's CN. SNI is the client's cleartext statement of "this is the site I want." CN/SAN is the name in the certificate the server sends back.
  • Treating Verify return code: 18 as a bug. It's self-signed, so verification is expected to fail. With a public CA it would be 0.
  • The TLS 1.2 vs 1.3 difference. In 1.2 the certificate is visible in cleartext. This lab forces 1.3, so it's encrypted. Switch to -tls1_2 and you can confirm the certificate becomes readable in the capture.
  • Empty ALPN. Forget -alpn on the server side and no negotiation happens.
  • Capture size. Without -s0, packets can be truncated and you may lose parts of the handshake.
  • ECH. A mechanism to hide SNI exists, but it's not on by default. In this lab, the SNI is visible.

Cleanup

sudo containerlab destroy -t tls-09.clab.yml --cleanup

If you used labctl.sh run tls-09, the script runs destroy for you at the end.

Check Your Understanding

  1. What layer does TLS sit on top of, and what layer sits above it?
  2. Who sends the SNI, and why? Why is it cleartext?
  3. What does ALPN decide? What was chosen in this lab?
  4. In TLS 1.3, up to which message can you read from a capture? Can you read the certificate? Why or why not?
  5. What does Verify return code: 18 mean? What would it be with a public CA?
  6. How does the visibility of the certificate differ between TLS 1.2 and 1.3?

References

Verified Run Log (2026-07-05)

This lab has been confirmed reproducible on real hardware.

Environment:

  • Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64)
  • Docker 29.1.3
  • containerlab 0.77.0
  • client / server: nicolaka/netshoot:latest (bundles openssl / tshark)

Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run tls-09 performed deploy → verify → destroy, and verification.json returned "status": "verified". No image or topology fixes were needed.

The handshake result as seen from openssl s_client

$ echo Q | openssl s_client -connect 10.0.0.2:4433 -servername www.example.lab \
    -alpn h2,http/1.1 -tls1_3

Server certificate
subject=CN=www.example.lab
issuer=CN=www.example.lab
Verification error: self-signed certificate
New, TLSv1.3, Cipher is TLS_AES_256_GCM_SHA384
Protocol: TLSv1.3
ALPN protocol: h2

How to read it:

  • The certificate selected via SNI: subject=CN=www.example.lab. The server presented the certificate for exactly the name the client requested with -servername www.example.lab (SNI).
  • Self-signed: issuer equals subject, and Verification error: self-signed certificate. The lab CA isn't trusted, so verification failing is the correct outcome here (in production, a CA chain would make it pass).
  • TLS 1.3: Protocol: TLSv1.3, cipher TLS_AES_256_GCM_SHA384.
  • ALPN: ALPN protocol: h2. The client offered h2,http/1.1 and the server chose h2.

Confirming the ClientHello on the wire (tshark)

Extracting the SNI and ALPN from the ClientHello (tls.handshake.type==1) in the capture:

$ tshark -r tls-09.pcap -Y "tls.handshake.type==1" \
    -T fields -e tls.handshake.extensions_server_name -e tls.handshake.extensions_alpn_str

www.example.lab	h2,http/1.1

Even in TLS 1.3, the ClientHello is cleartext, so the SNI (the hostname being connected to) and the offered ALPN list (h2,http/1.1) are visible from the network. The ALPN the server chose (h2) travels inside the encrypted EncryptedExtensions, which is why you check it in the s_client output above instead.

Cleanup

containerlab destroy -t tls-09.clab.yml --cleanup

That's the TLS 1.3 handshake on the wire: SNI and the ALPN offer in the clear, keys derived right after ServerHello, and everything from the certificate onward hidden from anyone watching the path.

Explore the full Protocol Lab series here: github.com/pathvector-studio/protocol-lab. If these labs are useful to you, please ⭐ star the repo on GitHub — it genuinely helps others find the project.

Next up, we'll keep climbing the stack and look at what the application layer does with the encrypted channel TLS just handed it.

Read more