WireGuard, Seen From Both Sides: The Same Ping as Ciphertext and Cleartext
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.
The encryption labs so far wrapped one thing at a time: TLS wraps a TCP stream (Lab 09), DoT/DoH wrap DNS (Lab 14). A VPN tunnel wraps everything — any IP packet — and sends it across an untrusted network. This lab builds a WireGuard tunnel between two nodes and shows the two views of the same ping:
- On the underlay (the real link,
eth1) you see only encrypted UDP to port 51820. The inner packet is hidden. - Inside the tunnel interface (
wg0) you see the cleartext ICMP — because that is where WireGuard hands you the decrypted packet.
Reading guide: rfc-notes/wg-wireguard-tunnel.md
Prerequisite: TCP Lab 07: One Connection, From SYN to FIN (for reading packet captures)
Expected time: 50–65 minutes.
The Goal
By the end, you should be able to fill in this table for one ping across the tunnel:
| Where you capture | What you see |
|---|---|
underlay eth1 |
10.0.0.1.51820 > 10.0.0.2.51820: UDP (encrypted, no ICMP) |
tunnel wg0 |
10.99.0.1 > 10.99.0.2: ICMP echo request (cleartext) |
What You Will Learn
- What a tunnel is: an overlay (
10.99.0.0/24) carried inside packets on an underlay (10.0.0.0/24). - How WireGuard identifies peers by public keys (not usernames/passwords) and pins each to an endpoint + allowed IPs.
- Why the underlay shows only UDP/51820 and the inner protocol is invisible there.
- Where you can still read the inner traffic (on
wg0, at the tunnel endpoint). - How this compares to TLS/DoT/DoH: same idea (encrypt + authenticate), but at the packet layer, for any protocol.
This lab does not cover:
- WireGuard's cryptographic internals (Noise handshake, key rotation) beyond naming them.
- Roaming, NAT traversal, or
wg-quick/config-file workflows. - Routing whole subnets or building a full site-to-site VPN.
Where to Read the Specs
WireGuard is not defined by a single RFC; it is specified by Jason A. Donenfeld's whitepaper plus the RFCs for the cryptography it uses.
| Reference | What to focus on |
|---|---|
| WireGuard whitepaper | Overview; peer = public key, endpoint, allowed-ips, transport over UDP |
| RFC 7748 | Curve25519 / X25519 (the elliptic curve used for key agreement) |
| RFC 8439 | ChaCha20-Poly1305 (encryption and authentication of the data) |
| Noise Protocol Framework | The foundation of WireGuard's handshake (for reference) |
| RFC 5737 | Confirming the addresses used here are documentation-only |
The Big Picture
Two nodes. On top of the real link (the underlay, eth1), we build a WireGuard overlay (wg0).
overlay (tunnel): 10.99.0.1 <== wg0 ==> 10.99.0.2
| |
node-a ------------- eth1 (underlay 10.0.0.0/24) ------------- node-b
10.0.0.1 10.0.0.2
When node-a pings the overlay address 10.99.0.2, WireGuard encrypts that packet and sends it as UDP to the underlay address 10.0.0.2:51820. node-b decrypts it and emits it from wg0. Therefore:
- Capturing the underlay (
eth1) shows only UDP/51820 ciphertext. The inner ICMP is invisible. - Capturing
wg0shows the cleartext ICMP.
flowchart LR
subgraph node-a
p1["ping 10.99.0.2<br/>(ICMP)"] --> wga["wg0<br/>encrypt"]
end
subgraph node-b
wgb["wg0<br/>decrypt"] --> p2["ICMP delivered"]
end
wga -- "UDP 51820<br/>(ciphertext)" --> wgb
note["Only UDP is visible on the underlay.<br/>ICMP appears only inside wg0"]
203.0.113.0/24 isn't used here, but both 10.0.0.0/24 (underlay) and 10.99.0.0/24 (overlay) are local, closed ranges.
Note: Everything here uses local/documentation address space (RFC 1918 / RFC 5737), so nothing in this lab touches the real internet.
What You Need
Recommended environment:
- Linux / WSL2 / a Linux VM (the host needs the WireGuard kernel module — standard on recent Linux, auto-loaded when the first
wg0is created) - Docker
- containerlab
Images used:
protocol-lab/wireguard:latest— built locally from examples/wg-16/Dockerfile; it is justnicolaka/netshootpluswireguard-tools.
WireGuard's data path runs in the host kernel, so the containers only need the wg userspace tool. The run script builds the image before deploying.
Running the Lab
The quick path, which builds the image, deploys, generates keys and configures peers, pings across the tunnel, captures the underlay and wg0 simultaneously, compares them, and tears everything down:
./scripts/labctl.sh run wg-16
Or step through it manually:
1. Move into the working directory
cd protocol-lab/examples/wg-16
2. Deploy
docker build -t protocol-lab/wireguard:latest .
sudo containerlab deploy -t wg-16.clab.yml
3. Generate keys and bring up the tunnel
Each node creates its own private key, and the two exchange public keys.
# key pairs
A_PRIV=$(docker exec clab-wg-16-node-a wg genkey)
A_PUB=$(printf '%s' "$A_PRIV" | docker exec -i clab-wg-16-node-a wg pubkey)
B_PRIV=$(docker exec clab-wg-16-node-b wg genkey)
B_PUB=$(printf '%s' "$B_PRIV" | docker exec -i clab-wg-16-node-b wg pubkey)
# node-a: create wg0, add B as a peer
docker exec clab-wg-16-node-a ip link add wg0 type wireguard
printf '%s' "$A_PRIV" | docker exec -i clab-wg-16-node-a sh -c \
"wg set wg0 private-key /dev/stdin listen-port 51820 \
peer $B_PUB endpoint 10.0.0.2:51820 allowed-ips 10.99.0.0/24"
docker exec clab-wg-16-node-a sh -c "ip addr add 10.99.0.1/24 dev wg0; ip link set wg0 up"
# node-b: symmetric
docker exec clab-wg-16-node-b ip link add wg0 type wireguard
printf '%s' "$B_PRIV" | docker exec -i clab-wg-16-node-b sh -c \
"wg set wg0 private-key /dev/stdin listen-port 51820 \
peer $A_PUB endpoint 10.0.0.1:51820 allowed-ips 10.99.0.0/24"
docker exec clab-wg-16-node-b sh -c "ip addr add 10.99.0.2/24 dev wg0; ip link set wg0 up"
(The private keys are passed via /dev/stdin — they are never written to a file.)
4. Ping across the tunnel
docker exec clab-wg-16-node-a ping -c3 10.99.0.2
docker exec clab-wg-16-node-a wg show wg0
If the ping succeeds and wg show reports latest handshake and transfer, the tunnel is working.
5. Capture in two places at once and compare
# underlay (the real link): UDP 51820 and (if any shows up) ICMP
docker exec -d clab-wg-16-node-a tcpdump -i eth1 -n -w /tmp/under.pcap "udp port 51820 or icmp"
# inside the tunnel
docker exec -d clab-wg-16-node-a tcpdump -i wg0 -n -w /tmp/inner.pcap "icmp"
docker exec clab-wg-16-node-a ping -c3 10.99.0.2
docker exec clab-wg-16-node-a pkill -INT tcpdump
docker exec clab-wg-16-node-a tcpdump -n -r /tmp/under.pcap # -> UDP 51820 only
docker exec clab-wg-16-node-a tcpdump -n -r /tmp/inner.pcap # -> ICMP echo
Expected Output
ping 10.99.0.2succeeds;wg show wg0lists the peer withlatest handshakeandtransfer.- Underlay capture:
10.0.0.1.51820 > 10.0.0.2.51820: UDP(ciphertext). No ICMP appears. wg0capture:10.99.0.1 > 10.99.0.2: ICMP echo request(cleartext).
Why It Works
A tunnel is a mechanism for carrying overlay packets inside underlay packets (encapsulation). WireGuard does that with encryption — a very small VPN.
- Peer = public key. WireGuard has no "username/password." Each peer is identified by its public key, and
allowed-ipspins the overlay addresses that peer is allowed to claim (cryptokey routing). This doubles as authentication. - Carried over UDP. The encrypted packet is sent via UDP to the peer's
endpoint(IP:51820). That's why the underlay shows nothing but UDP/51820 — the inner IP packet (here, ICMP) is inside the ciphertext. wg0is the decryption doorway. On the sending side, "handed to wg0" means "encrypt and transmit"; on the receiving side, packets are decrypted and emitted fromwg0. So capturingwg0shows cleartext. That isn't "eavesdropping" — you can read the contents only because you are the tunnel endpoint.- How it differs from TLS/DoT/DoH. Those wrap a specific protocol (a TCP stream, DNS). WireGuard wraps the IP packet itself, so whatever runs on top — TCP, UDP, or ICMP — gets encrypted wholesale. It operates at a different layer.
The key insight: the encryption applies to the underlay, and the inside is readable only at the endpoints. "I saw cleartext on wg0" does not mean "it isn't encrypted."
Common Pitfalls
- Misreading the cleartext on
wg0as "not encrypted."wg0is the post-decryption doorway. On the path (the underlay), the traffic is encrypted. - Mixing up underlay and overlay addresses. Underlay =
10.0.0.0/24(the real link), overlay =10.99.0.0/24(the tunnel). You ping the overlay. - Swapping public and private keys. You hand your peer the public key. The private key never leaves its own node.
- Underestimating
allowed-ips. It defines "the overlay addresses this peer may use," serving as both authentication and routing. Too broad is dangerous. - The kernel module. WireGuard's data path is the host kernel. Environments without the module won't work (recent Linux has it by default).
- Endpoint direction. Each peer is told the other's endpoint (IP:port). With NAT traversal, one side alone can suffice (this lab fixes both sides).
Cleanup
sudo containerlab destroy -t wg-16.clab.yml --cleanup
If you used labctl.sh run wg-16, the script runs destroy for you at the end.
Check Your Understanding
- What is the difference between the underlay and the overlay? In this lab, which addresses are which?
- Why does the underlay capture show only UDP/51820 and no ICMP?
- Capturing
wg0shows cleartext ICMP. Does that mean the traffic "isn't encrypted"? Why or why not? - How does WireGuard identify a peer? What does
allowed-ipsdecide? - Compared with TLS (Lab 09) and DoT/DoH (Lab 14), what does a WireGuard tunnel wrap that they don't?
- Where does WireGuard's data path run — inside the container or on the host?
References
- WireGuard: Next Generation Kernel Network Tunnel (whitepaper)
- WireGuard official site
- RFC 7748: Elliptic Curves for Security (Curve25519 / X25519)
- RFC 8439: ChaCha20 and Poly1305 for IETF Protocols
- RFC 5737: IPv4 Address Blocks Reserved for Documentation
Verified Run Log (2026-07-07)
This lab has been confirmed reproducible on real hardware.
Environment:
- Ubuntu 26.04 LTS (kernel 7.0.0-27-generic, x86_64). The WireGuard kernel module auto-loaded when the first
wg0was created. - Docker 29.1.3
- containerlab 0.77.0
- node-a / node-b:
protocol-lab/wireguard:latest(nicolaka/netshootpluswireguard-tools)
Running PATH="/tmp/pl-shim:$PATH" ./scripts/labctl.sh run wg-16 performed deploy → verify → destroy, and verification.json returned "status": "verified".
Ping across the tunnel, and wg's status
$ docker exec clab-wg-16-node-a ping -c3 10.99.0.2
3 packets transmitted, 3 received, 0% packet loss
$ docker exec clab-wg-16-node-a wg show wg0
listening port: 51820
peer: iS00XLODRk3nKHX53HttPf53j0AkuHqnjmY2OMU7fTU=
latest handshake: 6 seconds ago
transfer: 604 B received, 660 B sent
The underlay (eth1) shows only encrypted UDP
$ docker exec clab-wg-16-node-a tcpdump -n -r underlay.pcap
09:51:28.861019 IP 10.0.0.1.51820 > 10.0.0.2.51820: UDP, length 128
... (6 packets of UDP/51820, 0 ICMP)
ICMP never appears in the underlay capture (grep -c ICMP = 0). The payload is ciphertext.
Inside the tunnel (wg0), the cleartext ICMP is visible
$ docker exec clab-wg-16-node-a tcpdump -n -r inner-wg0.pcap
09:51:28.860993 IP 10.99.0.1 > 10.99.0.2: ICMP echo request, id 59876, seq 1, length 64
... (6 ICMP echo packets)
The same ping shows up as UDP/51820 ciphertext on the underlay and as cleartext ICMP inside wg0. The encryption applies to the path, and the contents are readable only at the endpoints — the same idea as TLS (Lab 09) and DoT/DoH (Lab 14), applied at the packet layer to any IP packet.
Cleanup
containerlab destroy -t wg-16.clab.yml --cleanup
That's a VPN tunnel in one picture: the same ping, ciphertext on the wire and cleartext only at the endpoints — encapsulation plus encryption, applied to any IP packet.
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 pulling protocols apart in the container lab — packet captures in hand, one layer at a time.