Light-weight port-knocking to protect SSH

A bit more than a year ago, I hardened my SSH server, which resulted in the near-disappearance of automated SSH login attempts. Alas, the script-kiddie tools have finally caught up with the current state of cryptography; or at least with the level of cryptography that I dare require, and still maintain compatibility with most devices that I use.

Fail2ban, although dormant all this time, still ran like the ever-vigilant Argos, and resumed its usual work as the attacks came back. But I do not like relying solely on fail2ban. So I decided to add port-knocking as a protection.

What is port-knocking?

Port-knocking is the practice of sending network packets to a list of ports on the server, and expect the server to grant access as a result. It is like typing a password, with letters being replaced with port numbers. The ports can be open ports as well as closed ports, and the port-knocking process does not alter in any way the normal operation of services running on open ports.

The nice thing about protecting port 22 (SSH) is that I can expect full network access on the client side; were that not the case, the client probably would not reach port 22 anyway (it probably would be able to reach ports 80 and 443… maybe port 53 too). This means that I can use the full range of port numbers; said differently, my “password” is composed of “letters” taken in an alphabet of roughly 65000 characters. So guessing a 2-port sequence is already trying to find one combination among more than 4 billion!

Given that port-knocking is fast, I can easily choose a 4-port sequence (1 chance in 18 billions of billions), or more :-)

Minimal port-knocking

As always with my low-power server, I want to keep the number of running processes as low as possible. iptables is up to the job, and it is already running, so I base my solution on iptables only. My firewall script, now enhanced with port-knocking, looks like this:

#!/bin/bash

# default rules
iptables -t filter -P INPUT DROP
iptables -t filter -P FORWARD DROP
iptables -t filter -P OUTPUT ACCEPT

# allow established connections
iptables -t filter -A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
iptables -t filter -A INPUT -f -j ACCEPT

# allow local connections
iptables -t filter -A INPUT -i lo -j ACCEPT

# port-knocking sequence for port 22
K22P=(443 2387 920 18357 50)

# init port-knocking
iptables -t filter -N KNOCK22
K22IPT='iptables -t filter -A KNOCK22 -p tcp -m state --state NEW'

# port-knocking rules
$K22IPT -m tcp --dport 22 -m recent --name KNOCKED22-${#K22P[*]} --seconds 60 --rcheck -j ACCEPT
for ((i=${#K22P[*]}; i>1; i--)); do
iptables -t filter -N KNOCK22-$i
iptables -t filter -A KNOCK22-$i -m recent --name KNOCKED22-$i --set -j RETURN
$K22IPT -m tcp --dport ${K22P[i-1]} -m recent --name KNOCKED22-$((i-1)) --remove -g KNOCK22-$i
$K22IPT -m tcp --dport ${K22P[i-2]} -m recent --name KNOCKED22-$((i-1)) --seconds 10 --rcheck -j RETURN
$K22IPT -m recent --name KNOCKED22-$((i-1)) --remove -j RETURN
done
$K22IPT -m tcp --dport ${K22P[0]} -m recent --name KNOCKED22-1 --set -j RETURN
iptables -t filter -A INPUT -j KNOCK22

# other firewall rules

The ports to knock are listed in the K22P array. The number of ports as well as their value can be changed. The same port can be used several times but not in a row.

Explanation: an IP address wanting to be granted access to port 22 must first knock on ports 4589, 2387, 920, 18357, and 50 in that order, and then connect to SSH in the next 60 seconds. An IP address that has successfully knocked on the first N ports is flagged with the name KNOCKED22-N. An IP address that knocks the right Nth port is sent to the KNOCK22-N action, where it gets flagged with the right name. After access has been granted, the port-knocking algorithm becomes irrelevant because the SSH session is « established », and is thus allowed.

With this in mind, the script above reads almost like plain English:

  1. If a new connection is attempted to port 22 within 60 seconds of having seen this IP knock the 5th port, then accept the connection, and ignore the following lines.
  2. Else:
    1. (First the action allowing to register the 5th knock is created. Then…)
    2. If an IP address knocks the 5th port after having successfully knocked the previous 4 ports, then the information that 4 ports have been knocked is removed, and the action that registers the 5th knock is triggered; and the following lines are ignored.
    3. If an IP address knocks the 4th port even though it already did (in the last 10 seconds), this knock is just ignored (as well as the following lines); this is because of “echoes”.
    4. If an IP address has successfully knocked 4 ports, but knocked the wrong 5th port, then its “score” of 4-ports-knocked is forgotten; it must start again from the beginning; the following lines are ignored too.
    5. If an IP address gets to this line, it is because it has not knocked 4 ports yet, so the loop starts again at the step below (4th port), and below (3rd port), and so on until it is seen that the IP address has not even knocked the first port.
  3. But if the port being knocked right now is the first in the list, then this first knock is registered.

Additional notes:

  • The presence of port 443 in this example sequence does not disrupt the Web service in any way. In fact, someone observing the server’s behaviour has no way to even see that port-knocking is configured, much less detect any hint regarding the right sequence or its length.
  • The KNOCKED22-* flags are actually lists maintained by the firewall. While the script is written in such a way that those lists get automatically cleaned, you may have noticed that the last list is not. This is because very few IP addresses get appended to this list anyway, and I decided that I preferred to be able to see the full list at any time for auditing the system. In my example, the command to do so would be:
    cat /proc/net/xt_recent/KNOCKED22-5

Connecting from a client

Any network tool that is able to send data to the server can be used. Just be sure to choose a tool with a configurable connection timeout, because requests to closed ports can take a long time otherwise. Here is for example the command I run on my Fairphone, using Termux:

for p in 100 443 2387 920 18357 50; do nmap -Pn --max-retries 0 -p $p yalis.fr; done

The first port is bogus; its only purpose it to be wrong, and thus to « put the caret » at the start of the expected input sequence.

I was formerly using nc, sometimes found as ncat, or netcat, but although it was working, it had the unfortunate effect of actually sending data, which was interpreted as a malformed HTTPS request on port 443, and got me banned by fail2ban :-D Well, it may be useful for computers where nmap is not available, so here is the command:

for p in 100 443 2387 920 18357 50; do nc -w 1 yalis.fr $p <<<"1"; done

And then SSH can be used as usual.

Changelog:

  • 2016-07-04 — I now use nmap instead of netcat to knock. And I now insert a wrong port at the start of the sequence to make iptables forget any former knock.

Ajouter un commentaire

Le code HTML est affiché comme du texte et les adresses web sont automatiquement transformées.

La discussion continue ailleurs

URL de rétrolien : http://yalis.fr/cms/index.php/trackback/87

Fil des commentaires de ce billet