Two panels: a replayed fixed knock of 8000, 7000 and 9001 leads to OPEN, while a replayed SPA packet of ciphertext plus HMAC leads to REFUSED.

Replaying a sniffed port knock, then watching SPA refuse the copy

A browser lab and a Docker lab let you sniff a port knock and replay it to get in, then see Single Packet Authorization refuse the same kind of copy.

Published Project 2026

Lab sourceTry the web labDocker lab walkthrough

A port knock is a password spelled in port numbers. The lab’s server, at 203.0.113.7, drops every packet bound for SSH until it sees TCP SYNs to 8000, 7000 and 9001, in that order, within ten seconds. Then it adds a firewall rule that lets the knocking address reach port 22 for thirty seconds. Those three numbers cross the network as ordinary packet headers, and the server accepts the same three every time. Anyone who can read the wire can repeat them.

The lab turns that weakness into something you do rather than something you are told. It comes in two forms that tell the same story: a page that runs in the browser, and a Docker network that runs the real daemons.

The knock, and its copy

The web lab is a simulation. Nothing leaves the page. Packets are JavaScript objects handed to a small server model in sim.js, written to behave like knockd 0.8 as the Docker lab configures it. It watches only the three sequence ports, so the decoy key, 1234, is invisible to it. A watched port out of order resets the attempt and is used up by the reset. Press 8000, 7000 and 9001 on the knock pad and the server log prints knockd’s stages, then an ACCEPT rule for the client, 203.0.113.10, appears with a countdown.

The sniffer panel records every packet the client sends. Its replay button resends each captured packet with the source changed to the attacker’s address, 203.0.113.66. The server has nothing to tell those knocks apart from yours, so it opens port 22 for the attacker too. SSH still wants a key. The knock only decided who was allowed to try.

The Docker lab does this with real traffic. Four containers share a private network: a server running sshd behind iptables and knockd, a client holding the SSH key, an attacker, and a tap. A Docker bridge won’t show one container’s unicast traffic to another, so the tap shares the server’s network stack and writes a capture file the attacker can read. The attacker filters it for SYNs, reads off 8000 7000 9001, runs knock with them and connects to port 22. One of the lab’s tests asserts exactly that: the sniffed ports, in order, and then the open port.

This is a property of a plain fixed sequence. knockd also offers one-time sequences that are invalidated after each use; the lab sticks to the fixed kind because it makes the copy visible.

One packet that can’t be reused

Single Packet Authorization, as implemented by fwknop, replaces the sequence with one UDP packet, by default to port 62201. Encrypting a knock does not on its own stop replay: an attacker who cannot read a packet can still send it again byte for byte. Michael Rash’s SPA paper makes that point. The defence is that the server remembers each packet it has accepted and refuses a second copy.

The web lab’s SPA mode follows fwknop’s shape, with Web Crypto standing in for fwknop’s own ciphers. The request, { allowIp, open: 'tcp/22', ts, nonce }, is encrypted with AES-GCM under a key derived from the shared secret. An HMAC-SHA256 over the IV and ciphertext goes alongside it: encrypt, then sign. The sniffer sees only hex. The simulated fwknopd checks in this order:

if ((await hmacHex(key, macInput(p))) !== p.mac) return ev('spa-bad', 'bad mac');
if (seen.has(p.mac)) return ev('spa-replay', 'digest already seen', { original: seen.get(p.mac) });
const body = await openSpaPacket(key, p);

After decrypting, it requires a valid address and rejects a timestamp more than 120 seconds from its own clock. Each check covers something different. The HMAC means only a key holder can make a packet the server will accept. The random nonce and the timestamp make every legitimate packet different, so a fresh one never collides with a cached one. The cache catches the exact copy, before any decryption, and the log names both sides: Replay detected from source IP: 203.0.113.66, followed by the original sender, 203.0.113.10. In the simulation that “digest” is the packet’s HMAC, kept in memory until the lab is reset.

The timestamp handles what the cache cannot. A packet an on-path attacker copies but stops from arriving never reaches the cache, and without a freshness check it would stay valid indefinitely.

A copy that got through would also buy less than it seems. The rule opens for the address sealed inside the packet, whoever sends it; a repository test checks that a valid packet arriving from the attacker opens port 22 for the client and not for the attacker. The lab does not lean on that, since the cache refuses the copy first.

In Docker, the real fwknopd runs with keys the client generated using fwknop --key-gen. Debian builds it without libpcap, so instead of sniffing it listens on UDP 62201 and never replies. A short scapy script, replay-spa, pulls the sniffed payload out of the capture and sends it from the attacker’s own address. The connection to port 22 times out, and fwknopd’s log records the replay. The lab’s config also corrects a likely misreading: REQUIRE_SOURCE_ADDRESS only forces the packet to name a real address to open. It does not compare that address with the sender. The digest cache is what stops the copy.

What is simulated

The page does not send packets. Its log lines and firewall rules are drawings of what knockd and fwknopd would do, pinned by unit tests that encode the same rules: the knock order, the timeouts, a replay, a tampered ciphertext, a wrong key and a stale timestamp. The Docker lab’s 11 checks run against real knockd, fwknopd, iptables and tcpdump. Both stay on 203.0.113.0/24, a range reserved for documentation, and the README asks readers to knock, scan or replay only against hosts they own or have permission to test.