IP sets revisited
There is already an answer mentioning IP sets. However, it's rather one-dimensional in that it focuses on the performance gains over classic rules and the fact that IP sets mitigate the problem one has with lots of individual IP address that cannot easily be expressed as a subnet in CIDR notation.
Notation used below
For ipset
I will use the notation read by ipset restore
and written by ipset save
.
Correspondingly for iptables
(and ip6tables
) rules I will use the notation as read by iptables-restore
and written by iptables-save
. This makes for a shorter notation and it allows me to highlight potential IPv4-only (prefixed -4
) or IPv6-only (prefixed -6
) rules.
In some examples we'll divert the packet flow into another chain. The chain is assumed to exist at that point, so the lines to create the chains are not produced (nor is the table name mentioned or the commands COMMIT
-ted at the end).
Advanced IP sets
IP sets can do a lot more than was mentioned in the other answer and you should definitely read the IP set documentation (ipset(8)
) along with iptables-extensions(8)
in addition to this brief entry here.
For example I'll mainly focus on three set types: hash:ip
, hash:net
and list:set
, but there are more than those and they all have valid use cases.
You can for example also match port numbers, not just IP addresses.
Saving and restoring IP sets as with iptables-save
and iptables-restore
You can create IP set declarations in bulk and import them by piping them into ipset restore
. If you want to make your command more resilient against already existing entries, use ipset -exist restore
.
If your rules are in a file called default.set
you'd use:
ipset -exist restore < default.set
A file like that can contain entries to create
sets and to add
entries into them. But generally most of the commands from the command line seem to have a corresponding version in the files. Example (creating a set of DNS servers):
create dns4 hash:ip family inet
create dns6 hash:ip family inet6
# Google DNS servers
add dns4 8.8.8.8
add dns4 8.8.4.4
add dns6 2001:4860:4860::8888
add dns6 2001:4860:4860::8844
Here one set is created for IPv4 (dns4
) and one for IPv6 (dns6
).
Timeouts on IP sets
Timeouts in IP sets can be set as a default per set and also per entry. This is very useful for scenarios where you want to block someone temporarily (e.g. for port-scanning or attempting to brute-force your SSH server).
The way this works is as follows (default during creation of IP sets):
create ssh_loggedon4 hash:ip family inet timeout 5400
create ssh_loggedon6 hash:ip family inet6 timeout 5400
create ssh_dynblock4 hash:ip family inet timeout 1800
create ssh_dynblock6 hash:ip family inet6 timeout 1800
We'll get back to these particular sets below and the rationale as to why they're set the way they are.
If you wanted to set your timeout for a particular IP address, you could simply say:
add ssh_dynblock4 1.2.3.4 timeout 7200
To block IP 1.2.3.4 for two hours instead of the (set) default half hour.
If you were to look at that with ipset save ssh_dynblock4
after a short while, you'd see something along the lines of:
create ssh_dynblock4 hash:ip family inet hashsize 1024 maxelem 65536 timeout 1800
add ssh_dynblock4 1.2.3.4 timeout 6954
Timeout caveats
- timeouts are a feature on any given set. If the set was not created with timeout support you'll receive an error (e.g.
Kernel error received: Unknown error -1
).
- timeouts are given in seconds. Use Bash arithmetic expressions to get from minutes to seconds, for example. E.g.:
sudo ipset add ssh_dynblock4 1.2.3.4 timeout $((120*60))
Checking whether an entry exists in a given IP set
Inside of your scripts it can be useful to see whether an entry already exists. This can be achieved with ipset test
which returns zero if the entry exists and non-zero otherwise. So the usual checks can be applied in a script:
if ipset test dns4 8.8.8.8; then
echo "Google DNS is in the set"
fi
However, in many cases you'll rather want to use the -exist
switch to ipset
in order to direct it not to complain about existing entries.
Populating IP sets from iptables
rules
This, in my opinion, is one of the killer features of IP sets. Not only can you match against the entries of an IP set, you can also add new entries to an existing IP set.
For example in this answer to this question you have:
-A INPUT -p tcp -i eth0 -m state --state NEW --dport 22 -m recent --update --seconds 15 -j DROP
-A INPUT -p tcp -i eth0 -m state --state NEW --dport 22 -m recent --set -j ACCEPT
... with the intention to rate-limit connection attempts to SSH (TCP port 22). The used module recent
keeps track of recent connection attempts. Instead of the state
module, I prefer the conntrack
module, however.
# Say on your input chain of the filter table you have
-A INPUT -i eth+ -p tcp --dport ssh -j SSH
# Then inside the SSH chain you can
# 1. create an entry in the recent list on new connections
-A SSH -m conntrack --ctstate NEW -m recent --set --name tarpit
# 2. check whether 3 connection attempts were made within 2 minutes
# and if so add or update an entry in the ssh_dynblock4 IP set
-4 -A SSH -m conntrack --ctstate NEW -m recent --rcheck --seconds 120 --hitcount 3 --name tarpit -j SET --add-set ssh_dynblock4 src --exist
-6 -A SSH -m conntrack --ctstate NEW -m recent --rcheck --seconds 120 --hitcount 3 --name tarpit -j SET --add-set ssh_dynblock6 src --exist
# 3. last but not least reject the packets if the source IP is in our
# IP set
-4 -A SSH -m set --match-set ssh_dynblock4 src -j REJECT
-6 -A SSH -m set --match-set ssh_dynblock6 src -j REJECT
In this case I am redirecting the flow to the SSH
chain such that I don't have to repeat myself with -p tcp --dport ssh
for every single rule.
To reiterate:
-m set
makes iptables
aware that we're using switches from the set
module (which handles IP sets)
--match-set ssh_dynblock4 src
tells iptables
to match the source (src
) address against the named set (ssh_dynblock4
)
- this corresponds to
sudo ipset test ssh_dynblock4 $IP
(where $IP
contains the source IP address for the packet)
-j SET --add-set ssh_dynblock4 src --exist
adds or updates the source (src
) address from the packet into the IP set ssh_dynblock4
. If an entry exists (--exist
) it will simply be updated.
- this corresponds to
sudo ipset -exist add ssh_dynblock4 $IP
(where $IP
contains the source IP address for the packet)
If you wanted to match the target/destination address instead, you'd use dst
instead of src
. Consult the manual for more options.
Sets of sets
IP sets can contain other sets. Now if you followed the article up to here you'll have wondered whether it's possible to combine sets. And of course it is. For the IP sets from above we can create two joint sets ssh_dynblock
and ssh_loggedon
respectively to contain the IPv4-only and IPv6-only sets:
create ssh_loggedon4 hash:ip family inet timeout 5400
create ssh_loggedon6 hash:ip family inet6 timeout 5400
create ssh_dynblock4 hash:ip family inet timeout 1800
create ssh_dynblock6 hash:ip family inet6 timeout 1800
# Sets of sets
create ssh_loggedon list:set
create ssh_dynblock list:set
# Populate the sets of sets
add ssh_loggedon ssh_loggedon4
add ssh_loggedon ssh_loggedon6
add ssh_dynblock ssh_dynblock4
add ssh_dynblock ssh_dynblock6
And the next question that should pop up in your mind is whether this allows us to match and and manipulate IP sets in an IP version-agnostic fashion.
And the answer to that is a resounding: YES! (alas, this wasn't documented explicitly last time I checked)
Consequently the rules from the previous section can be rewritten to read:
-A INPUT -i eth+ -p tcp --dport ssh -j SSH
-A SSH -m conntrack --ctstate NEW -m recent --set --name tarpit
-A SSH -m conntrack --ctstate NEW -m recent --rcheck --seconds 120 --hitcount 3 --name tarpit -j SET --add-set ssh_dynblock src --exist
-A SSH -m set --match-set ssh_dynblock src -j REJECT
which is a lot more concise. And yes, this is tried and tested and works like a charm.
Putting it all together: SSH brute-force defense
On my servers I have a script run as a cron
job which takes a bunch of host names and resolves those to IP addresses, then feeding it into the IP set for "trusted hosts". The idea is that trusted hosts get more attempts to log into the server and aren't necessarily blocked out for as long as anybody else.
Conversely I have whole countries blocked out from connecting to my SSH server, with the (potential) exception of trusted hosts (i.e. order of rules matters).
However, that is left as an exercise for the reader. Here I'd like to add a neat solution that will use the sets contained in the ssh_loggedon
set to allow subsequent connection attempts to be handed through and not be subject to the same scrutiny as the other packets.
It is important to remember the default timeouts of 90 minutes for ssh_loggedon
and 30 minutes for ssh_dynblock
when looking at the following iptables
rules:
-A INPUT -i eth+ -p tcp --dport ssh -j SSH
-A SSH -m set --match-set ssh_loggedon src -j ACCEPT
-A SSH -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
-A SSH -m conntrack --ctstate NEW -m recent --set --name tarpit
-A SSH -m conntrack --ctstate NEW -m recent --rcheck --seconds 120 --hitcount 3 --name tarpit -j SET --add-set ssh_dynblock src --exist
-A SSH -m set --match-set ssh_dynblock src -j REJECT
By now you should ask yourself how the connecting IP address ends up in the ssh_loggedon
sub-sets. So read on ...
Bonus: adding the IP you log in with during SSH logon
If you have experimented with sshrc
and friends, you'll have learned of its shortcomings. But PAM comes to the rescue. A module named pam_exec.so
allows us to invoke a script during SSH logon at a point where we know that the user is admitted in.
In /etc/pam.d/sshd
below the pam_env
and pam_selinux
entries add the following line:
session optional pam_exec.so stdout /path/to/your/script
and make sure that your version of the script (/path/to/your/script
above) exists and is executable.
PAM uses environment variables to communicate what's going on, so you can use a simple script like this one:
#!/bin/bash
# When called via pam_exec.so ...
SETNAME=ssh_loggedon
if [[ "$PAM_TYPE" == "open_session" ]] && [[ -n "$PAM_RHOST" ]]; then
[[ "x$PAM_RHOST" != "x${PAM_RHOST//:/}" ]] && SETNAME="${SETNAME}6" || SETNAME="${SETNAME}4"
ipset -exist add $SETNAME "$PAM_RHOST"
fi
Unfortunately the ipset
utility doesn't seem to have the built-in smarts of netfilter. So we need to distinguish between IPv4 and IPv6 IP set when adding our entry. Otherwise ipset
will assume we want to add another set to the set of sets, instead of the IP. And of course it's unlikely that there would be a set named after an IP :)
So we check for :
in the IP address and append 6
to the set name in such case and 4
otherwise.
The end.