port-knocking-lab

Port knocking lab (Docker)

A small, safe network on your own machine where you can watch port knocking work, watch it get sniffed and replayed, and see Single Packet Authorization (SPA) stop the replay.

Four containers share one private network (203.0.113.0/24). Nothing is published to your computer’s ports.

Container Address Role
server 203.0.113.7 SSH server, firewalled. Runs knockd or fwknopd.
client 203.0.113.10 You. Has the SSH key and the knock tools.
attacker 203.0.113.66 Someone on the path who can read the wire.
tap shares the server’s network Records the server’s traffic into a file the attacker can read. It stands in for “someone watching the network”, because Docker won’t show one container’s traffic to another.

The lab is IPv4-only: the network has no IPv6.

Safety

This is a lab. Everything here targets the made-up addresses above, inside Docker. Never knock on, scan or replay packets at hosts you don’t own or have permission to test.

The client’s SSH config turns off host-key checking so the lab keeps working after rebuilds. Never copy that setting to a real host. It removes the protection against someone impersonating your server.

What you need

Docker Desktop, or Docker Engine with the Compose plugin. The first build downloads Debian packages and can take several minutes.

Start

docker compose up -d --build

This starts the classic knocking lab (MODE=classic, which runs knockd). The server drops all traffic to SSH until the right knock arrives. Keys are generated for you on first start and stay in Docker volumes.

Open a shell where each command below says client or attacker:

docker compose exec client bash
docker compose exec attacker bash

Part 1: classic port knocking

1. Nothing looks open. From the client, scan nmap’s default 1,000 TCP ports:

nmap -Pn 203.0.113.7

You should see Not shown: 999 closed tcp ports and 22/tcp filtered ssh: no open TCP ports. Packets to SSH are silently dropped.

2. Knock. From the client:

knock 203.0.113.7 8000 7000 9001
nmap -Pn -p 22 203.0.113.7

The scan now shows 22/tcp open ssh. To see the rule knockd added, run this on the server (docker compose exec server bash):

iptables -L KNOCK -n

It shows ACCEPT tcp -- 203.0.113.10 0.0.0.0/0 tcp dpt:22. The door stays open for your address for 30 seconds. knockd only watches the three ports in the sequence (8000, 7000, 9001); a knock on any other port is invisible to it. A watched port in the wrong order resets your attempt, so a fumbled sequence just means starting over.

3. Log in. From the client, within those 30 seconds:

ssh you@203.0.113.7 'echo "$(whoami)@$(hostname)"'

You should see you@server. SSH still needs the key, so the knock only decides who can try.

4. Everyone else is still locked out. The attacker (a different address) scans while you’re inside:

nmap -Pn -p 22 203.0.113.7

Still filtered. The door opened for the client’s address only.

5. Sniff and replay. The knock travels in the clear, so the attacker can read it from the recording, repeat it and get in. After the client has knocked again, on the attacker:

tcpdump -nr /captures/classic.pcap 'tcp[tcpflags] == tcp-syn and dst host 203.0.113.7 and not dst port 22'
knock 203.0.113.7 8000 7000 9001
nc -zv -w2 203.0.113.7 22

The first command shows the client’s three knocks. The last reports that the connection succeeded. The attacker still has no SSH key, but the port is open to them. A knock is a password anyone on the path can read.

Part 2: Single Packet Authorization

Switch the server to fwknopd:

MODE=spa docker compose up -d --build

Debian’s fwknopd has no libpcap, so here it runs as a UDP listener on port 62201 (ENABLE_UDP_SERVER) rather than as a silent sniffer. A real deployment can sniff the packet without any open socket; the lab’s listener never replies.

6. Open with one packet. From the client:

nmap -Pn -p 22 203.0.113.7
fwknop -A tcp/22 -a 203.0.113.10 -D 203.0.113.7 --use-hmac
nmap -Pn -p 22 203.0.113.7
ssh you@203.0.113.7 'echo "$(whoami)@$(hostname)"'

Filtered, then open, then you@server. The packet is encrypted and signed with keys the client and server share, so there is nothing readable to copy.

7. A replayed packet fails. The attacker can still sniff the encrypted packet and resend it byte for byte from their own address. On the attacker:

tcpdump -nr /captures/spa.pcap udp and dst port 62201
replay-spa /captures/spa.pcap
nc -zv -w2 203.0.113.7 22

The connection times out. Each valid packet is remembered by its digest, and fwknopd refuses a second copy. On the server (docker compose exec server bash):

grep Replay /var/log/fwknopd.log

You’ll see Replay detected from source IP: 203.0.113.66. That digest cache is what stops replays. The setting REQUIRE_SOURCE_ADDRESS in the lab’s config is something else: it only forces the packet to name a real IP address to open (no 0.0.0.0, no -s). It does not compare that address with where the packet came from.

Run the lab’s own tests

test/run.sh all            # 11 checks
test/run.sh all --twice    # also checks a restart; 22 checks in all

capture.sh runs the seven beats above and saves the commands with their real output, one file per beat, in ./captures/ (set CAPTURE_DIR to put them elsewhere).

Stop

docker compose down -v

-v also deletes the generated keys and captures; the next start makes fresh ones.

Licence

MIT. See ../LICENSE.