DEV Community

pathvector-dev
pathvector-dev

Posted on • Originally published at blog.pathvector.dev

WireGuard, Seen From Both Sides: The Same Ping as Ciphertext and Cleartext

Originally published at https://blog.pathvector.dev/protocol-lab-wg-16/ — part of the free Protocol Lab series.

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
Enter fullscreen mode Exit fullscreen mode

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 wg0 shows 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"]
Enter fullscreen mode Exit fullscreen mode

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 wg0 is created)
  • Docker
  • containerlab

Images used:

  • protocol-lab/wireguard:latest — built locally from examples/wg-16/Dockerfile; it is just nicolaka/netshoot plus wireguard-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
Enter fullscreen mode Exit fullscreen mode

Or step through it manually:

1. Move into the working directory

cd protocol-lab/examples/wg-16
Enter fullscreen mode Exit fullscreen mode

2. Deploy

docker build -t protocol-lab/wireguard:latest .
sudo containerlab deploy -t wg-16.clab.yml
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Expected Output

  • ping 10.99.0.2 succeeds; wg show wg0 lists the peer with latest handshake and transfer.
  • Underlay capture: 10.0.0.1.51820 > 10.0.0.2.51820: UDP (ciphertext). No ICMP appears.
  • wg0 capture: 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-ips pins 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.
  • wg0 is the decryption doorway. On the sending side, "handed to wg0" means "encrypt and transmit"; on the receiving side, packets are decrypted and emitted from wg0. So capturing wg0 shows 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 wg0 as "not encrypted." wg0 is 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
Enter fullscreen mode Exit fullscreen mode

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

Check Your Understanding

  1. What is the difference between the underlay and the overlay? In this lab, which addresses are which?
  2. Why does the underlay capture show only UDP/51820 and no ICMP?
  3. Capturing wg0 shows cleartext ICMP. Does that mean the traffic "isn't encrypted"? Why or why not?
  4. How does WireGuard identify a peer? What does allowed-ips decide?
  5. Compared with TLS (Lab 09) and DoT/DoH (Lab 14), what does a WireGuard tunnel wrap that they don't?
  6. Where does WireGuard's data path run — inside the container or on the host?

References

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 wg0 was created.
  • Docker 29.1.3
  • containerlab 0.77.0
  • node-a / node-b: protocol-lab/wireguard:latest (nicolaka/netshoot plus wireguard-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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)