
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...