When you have a server on the Internet, you get lots of "brute force" attacks on the SSH daemon, trying plausible logins with a variety of passwords. Even with good passwords, these attacks might eventually succeed (and they're annoying even when they don't), so you want to thwart them.
One way is to use fail2ban, a script that monitors the failed
connections, and sets up firewall rules (for instance) blocking
further connections from the attacking IP addresses. It's good, but
it fills your logs with messages about IPs getting banned and unbanned
after a while. And you're still at risk that the multiple connections
crash the SSH daemon, or trigger a bug in it, or whatever.
A second layer of protection can be to block all SSH connection attempts except when they come from known IP addresses, but that doesn't work when you're away from home, and you're locked out. Been there, done that.
So, some wise people have devised a trick called "port-knocking". It's similar to only opening the door to people who use a special knock (think "That's all, folks"): the firewall stays closed, but it opens a tiny targeted hole to some IP addresses for a limited length of time, based on a secret handshake. The window for attack is therefore very small, and the SSH daemon stays idle most of the time. And you can still log on your hosted server when you're attending conferences. There are a variety of implementations for this concept. Some could be web-based (you need to submit the right password to a web page), some could use other services or a dedicated daemon.
But when I started investigating port-knocking, I wanted something simple, preferably with no dependencies on a daemon that would need to be exposed to the net and potentially crash. I found an article on the Debian Administration website, but I wasn't entirely satisfied with it. The principles appealed to me (netfilter-only, secret handshake in the form of opening connections to secret ports), though, so I evolved it into my own implementation, which I proudly present to you today.
The goals of this implementation were:
- flexibility;
- robustness;
- resistance to replay attacks;
- no dependency on user-land daemons;
- simple, easily auditable code;
- must not be a pain in the neck to use.
The bulk of the work therefore stays in the kernel's netfilter (that's for robustness and no user-land dependency), but the control interface is integrated with the usual firewalling script.
Resistance to replay attacks is achieved by choosing hard-to-predict
ports. So if someone snoops the wireless while I'm at a conference
and catches my secret handshake, it'll only be valid for a short
period of time, hopefully short enough to prevent dictionary attacks.
The handshake is therefore calculated as a function of the current
date and time, with an added secret seed. The following shell
function calculates 5 port numbers within a given range (requires dc
to be installed, for big-integer arithmetic):
calc_knock_ports () { secret=$1 bottomport=$2 topport=$3 nbports=$(( $topport - $bottomport + 1 )) hash=$(TZ=UTC date +%Y-%m-%d-%H-$secret | md5sum | awk '{print $1}' | tr a-z A-Z) num=$(echo 16i $hash f | dc) pk_port1=$(echo $num $nbports 0 ^ / $nbports % $bottomport + f | dc) pk_port2=$(echo $num $nbports 1 ^ / $nbports % $bottomport + f | dc) pk_port3=$(echo $num $nbports 2 ^ / $nbports % $bottomport + f | dc) pk_port4=$(echo $num $nbports 3 ^ / $nbports % $bottomport + f | dc) pk_port5=$(echo $num $nbports 4 ^ / $nbports % $bottomport + f | dc) }
Okay. So this function calculates ports, now what? Now we're going to define a few chains by which netfilter will store states of IP addresses as they progress through the handshake:
setup_portknocking_tables () { iptables -N portknock_into_phase1 iptables -A portknock_into_phase1 -m recent --name PK_PHASE1 --set # iptables -A portknock_into_phase1 -j LOG --log-level notice --log-prefix "INTO PK_PHASE1: " iptables -N portknock_into_phase2 iptables -A portknock_into_phase2 -m recent --name PK_PHASE1 --remove iptables -A portknock_into_phase2 -m recent --name PK_PHASE2 --set # iptables -A portknock_into_phase2 -j LOG --log-level notice --log-prefix "INTO PK_PHASE2: " iptables -N portknock_into_phase3 iptables -A portknock_into_phase3 -m recent --name PK_PHASE2 --remove iptables -A portknock_into_phase3 -m recent --name PK_PHASE3 --set # iptables -A portknock_into_phase3 -j LOG --log-level notice --log-prefix "INTO PK_PHASE3: " iptables -N portknock_into_phase4 iptables -A portknock_into_phase4 -m recent --name PK_PHASE3 --remove iptables -A portknock_into_phase4 -m recent --name PK_PHASE4 --set # iptables -A portknock_into_phase4 -j LOG --log-level notice --log-prefix "INTO PK_PHASE4: " iptables -N portknock_into_phase5 iptables -A portknock_into_phase5 -m recent --name PK_PHASE4 --remove iptables -A portknock_into_phase5 -m recent --name PK_PHASE5 --set iptables -A portknock_into_phase5 -m recent --name PK_ESTABLISHED --set # iptables -A portknock_into_phase5 -j LOG --log-level notice --log-prefix "INTO PK_PHASE5: " iptables -N portknock_accept iptables -A portknock_accept -m limit -j LOG --log-level notice --log-prefix "ACCEPTED AFTER PORTKNOCKING: " # iptables -A portknock_accept -m recent --name PK_PHASE5 --remove iptables -A portknock_accept -j ACCEPT iptables -N portknocking }
These chains use the recent module, which seems to be commonly
available in standard kernels. You'll notice how, as one packet goes
through these rules, its originating IP address moves from one set of
"recent" addresses to the next. But no logic exists yet to make the
packet actually go through these rules, so here comes the glue:
refresh_portknocking () { calc_knock_ports f00b4r 10000 10999 iptables -F portknocking iptables -A portknocking -p tcp --dport $pk_port1 -m state --state NEW -j portknock_into_phase1 iptables -A portknocking -p tcp --dport $pk_port2 -m state --state NEW -m recent --rcheck --name PK_PHASE1 --seconds 5 -j portknock_into_phase2 iptables -A portknocking -p tcp --dport $pk_port3 -m state --state NEW -m recent --rcheck --name PK_PHASE2 --seconds 5 -j portknock_into_phase3 iptables -A portknocking -p tcp --dport $pk_port4 -m state --state NEW -m recent --rcheck --name PK_PHASE3 --seconds 5 -j portknock_into_phase4 iptables -A portknocking -p tcp --dport $pk_port5 -m state --state NEW -m recent --rcheck --name PK_PHASE4 --seconds 5 -j portknock_into_phase5 # echo clear > /proc/net/ipt_recent/PK_DONE echo clear > /proc/net/ipt_recent/PK_PHASE1 echo clear > /proc/net/ipt_recent/PK_PHASE2 echo clear > /proc/net/ipt_recent/PK_PHASE3 echo clear > /proc/net/ipt_recent/PK_PHASE4 echo clear > /proc/net/ipt_recent/PK_PHASE5 }
Right. This function adds rules to the portknocking chain. A
packet injected into this ruleset will, depending on its destination
port and whether its source IP address has already been seen, end up
in one of the PK_PHASE* sets. All we have to do now is therefore to
send some packets to this portknocking chain, and use the
port-knocking sets to decide whether to accept incoming connections or
not:
iptables -A INPUT -j portknocking iptables -A INPUT -m recent --rcheck --seconds 5 --name PK_PHASE5 -m state --state NEW -p tcp --dport ssh -j portknock_accept
This example only mentions accepting incoming SSH connections, but it's in no way a limitation: a server of mine uses similar rules to DNAT certain ports to internal IP addresses.
And there we have it for the server part: incoming SSH connections are
usually ignored (well, handled by the rest of the firewall script, but
let's assume that it drops these packets by default), but if one IP
address knows the appropriate ports and sends a connection attempt to
them in order, then it'll be able to open SSH connections for a little
while after that. Of course, it's going to be boring if one has to
send these packets by hand, but it can be easily automated by a
script. Here's a ~/bin/portknock.sh I have:
#! /bin/sh host=$1 port=$2 calc_knock_ports () { [...] } calc_knock_ports f00b4r 10000 10999 for i in $pk_port1 $pk_port2 $pk_port3 $pk_port4 $pk_port5 ; do nc -w 1 $host $i < /dev/null > /dev/null 2>&1 done nc $host $port
It's designed to be called with two parameters, a host and a port, and
it needs netcat in addition to dc. Why the last line, I hear you
cry? Because then I can just add the following lines to my
~/.ssh/config:
Host blahblah IdentityFile foobar ProxyCommand /home/roland/bin/portknock.sh %h %p
...and SSH will automagically tunnel its network socket through the
script, which will in turn happily tunnel that through netcat after
completing the secret handshake.
And when I type ssh myserver on my laptop, interesting stuff happens
behind the scenes, and a special, just-for-me hole is opened in the
server firewall, just for the few seconds I need to establish the SSH
session (packets belonging to established TCP sockets are allowed by
the firewall's connection tracking).
Note: This article is deliberately short on details and ready-to-run scripts. Firstly because firewall scripts vary wildly so any script would have to be adapted anyway, but mostly because security is best handled with one's brain switched on. Fiddling with a firewall can easily open gaping holes or lock everyone out. So please make sure you understand what goes on before blindly pasting stuff into your own setup. Some of the lines that are commented out may also be of interest, and were left as an exercise for the reader. Other lines were not included, and are also left as a rather important exercise to the reader; note in particular how the netfilter rules as currently established do not mitigate the replay attacks...
Update: You may be interested by a followup to this article, integrating-fwbuilder-with-fail2ban-and-port-knocking.
Posted mar. 19 août 2008 17:59:12 CEST