Aggressive pf(4) configuration for SSH protection
If some unknown person came up to your house and started trying to open each window, jiggling each door handle, and punching in random codes on your garage-door opener, you could safely assume that they sought trouble and that you should stop them. Similarly, if some unknown computer on the internet started probing at ports on your server where you offer no services, it should set off alarm-bells and you should prevent this miscreant from interacting with your server. Especially SSH access. You know what services your machine provides. If anybody attempts to connect to some other service on your machine, in all likelihood they don't have your best interests in mind.
In this post,
we configure
pf
on
OpenBSD
to catch inappropriate connection-attempts
and block the offender.
- Assumptions
- Setup
- Goals
- Move the SSH port
- Creating a minefield
- Rate-limiting connections
- Limiting by country
- Final file
- Conclusion
Assumptions
This post assumes a few things about you:
- OS installed that can run
pf
-
While I wrote this article using
pf
on OpenBSD, many of the aspects should translate to FreeBSD even though FreeBSD's version ofpf
represents an older snapshot from when their codebases diverged. Particularly,-
I think the abstract
egress
interface-group may not exist so you'd have to specify the interface directly (either inline or as a macro) -
Where I use
doas
, you either have to installsudo
ordoas
or else you mustsu -
to become root for many of these commands. -
in the
_geoip
script, you would need to replace the use offtp
with eitherfetch
,wget
, orcurl
-
I think the abstract
- Administrative rights
- This should go without saying, but if you don't administer the machine, you can't modify the firewall configuration.
- Basic CLI
-
In this case,
we have minimal work to do at the command-line
but it helps to know your way around.
OpenBSD has a template
/etc/examples/pf.conf
you might want to copy as a starting-point; you might find it easier tocd
into/etc
directory; or you might usetmux
andssh
to access the server. Note that editing/etc/pf.conf
can leave your server inaccessible (like cutting off the tree-branch while sitting on it), so you'll want to make sure that you have console access — locally, via a serial connection/KVM, or VNC connection. - Basic networking knowledge
- The basic networking ideas of UDP, TCP, ports, services, etc. If you need to get up to speed here you might want a resource like Michael W. Lucas's Networking for System Administrators, O'Reilly's TCP/IP Network Administration, or some other such book.
- Edit text-files
-
This focuses primarily on modifying your
/etc/pf.conf
which you can edit with your favorite$EDITOR
, whethernano
,emacs
/mg
,vi
/vim
/neovim
,ed
,cat
, a magnetized needle and a steady hand, or butterflies. But this guide assumes that you can edit text files and save them.
Setup
For purposes here,
I'll use OpenBSD's
doas
command to elevate privileges
but you can use
sudo
if you have it installed on FreeBSD
or execute these commands
under a
su -
to
root
if you like to live dangerously.
This assumes that
doas
(or
sudo
)
has a configuration that lets you run the commands in question.
This also assumes starting with an empty
(or mostly-empty if you did
doas cp /etc/examples/pf.conf /etc/
)
pf.conf
file.
If you already have an existing configuration,
you'll need to work out how these changes integrate
with your existing setup.
Goals
This post covers several tricks to protect the actual SSH port.
- change port on which SSH listens
- flag any IP addresses that touch our server's minefield and prevent them from accessing SSH
- if anybody actually finds the SSH port and hammers on it, ban them too
- limit the country/countries from which we can SSH
Change the
sshd
port number
For purposes of this post,
I configure
sshd
to listen on port
2345
but you should choose
a different one that suits you.
This doesn't give much
in the way of security,
but it reduces some of the noise
in your log-files
and makes the
minefield
work by making it unknown
and surrounded by trip-ports.
Change the listening port
Edit your
/etc/ssh/sshd_config
file and specify the
Port
by adding/editing the line to read
Save the file
and restart
sshd
Note:
If you make this configuration change
while SSHed into the server,
you might cut off your access.
As mentioned,
make sure that you have console,
serial, or VNC access
in case you need to recover from making an error here.
Change the default connecting port
On my local machine,
I find it painful to remember
how to specify the port to various
ssh
commands.
Some take
-p $PORTNUM
or
-P $PORTNUM
,
while others take
-o Port=$PORTNUM
and yet others expect it as an environment variable.
To simplify this,
edit (or create) your
~/.ssh/config
to specify the port-number there
Now
ssh
,
scp
,
sftp
,
rsync
,
etc,
should all default to the right port-number
without you needing to specify it explicitly.
Creating a minefield
Here we configure a minefield of ports such that if anybody tries to connect on any of these fake ports, they get added to a table of troublemakers. For the minefield, I default to 1024:9999 but if you want a broader range, check the output of which gives a larger swath. If you extend it below 1024, make sure you don't interfere with other services that you might actually want to provide.
We only specify TCP because malicious actors could spoof UDP source-addresses, blocking innocent folks.
Feel free to add other services to the list of services you don't provide (I have telnet, SSH, and SMB in my list) if you notice strangers knocking elsewhere.
We tag packets as
"trouble
",
and then tag the one port we know as
"good
".
If a packet still has the
"trouble
"
tag,
we use some trickery
(involving
max-src-conn
&
max-src-conn-rate
to limit them to one connection
and to one attempt every 10 seconds)
to add them to the
troublemakers
table, banning all future SSH access for them.
The
flush global
keyword kills any other existing connections
this offending visitor currently has.
We also need the
synproxy state
which allows
pf
to handle the connection to ports
that don't actually have services backing them.
(Aside: I
think
that all ports that don't have a real service behind them
need the
synproxy state
but it might turn out that only the
overload
item needs it.)
With all that in place,
any attempt to connect to a port in our minefield
(or select services we know we don't run)
results in the remote IP address
getting added to the
troublemakers
table and subsequently blocked from our SSH port.
Rate-limiting connections
If an attacker does manage to guess
our actual port
2345
on the first try,
they will likely try to hammer on it.
So we want to rate-limit them:
This limits to 5 concurrent connections
(max-src-conn 5
)
and 5 connection-attempts during a 10-second window
(max-src-conn-rate 5/10
).
Limiting by country
To begin with, I know that I will almost certainly never connect to SSH from outside the United States. IPDeny.com provides regularly-updated lists of IPv4 & IPv6 ranges for each country that I can use as a rough determination of country-of-origin. Yes, this can get thrown off by VPNs but it cuts off a surprising number of attackers.
Download GeoIP data
First, create the folder where
pf
will find the GeoIP tables.
Inside that, create a
tmp/
directory so we can download there,
compare the results with what we already have,
and move the new data atop the old data atomically:
For now, we will manually download
the IPv4 and IPv6 GeoIP blocks:
Now with these files,
we can point
pf
at them and prohibit access
from non-US visitors:
With this in place,
any visitors from a non-US IP address
simply cannot access the alternate SSH port.
Automating GeoIP downloading
Occasionally the GeoIP ranges change
so you may want to download them automatically
and have
pf
pick them up automatically.
For this we'll
create a stand-alone
_geoip
user,
modify the permissions
of the GeoIP data directory,
set up
a script to automatically download GeoIP data,
compare it to existing data,
and (if it changed) replace the old data
and restart
pf
.
Create a
_geoip
user
Here we use the
adduser
command to create a new user.
I put this user in the "daemon" login-class,
specify an empty password,
and then disable password logins:
You could specify the shell as
nologin
but then have to do some work-arounds
to get a shell to
create the script
and set up the
cron
job.
After configuring the setup below,
you can go back and use
chsh
to change the shell to
nologin
for added lockdown.
Modify permissions on GeoIP data directory
Now we need to make sure that the
_geoip
user can write to the directories
Allow the
_geoip
user access to restart
pf
In order for the
_geoip
user to instruct
pf
to reload its configuration
and pick up the new GeoIP files,
we need to add a line in our
/etc/doas.conf
file:
This allows the
_geoip
user to run only
pfctl
as specified by full path,
and only with the arguments
-f /etc/pf.conf
to minimize things.
If you want to lock it down even further
you can limit the
_geoip
user to only reload those two tables
with something like this
(untested):
If you choose to go this route,
make sure that you
update the script below
to use
instead.
Create a script to download & install GeoIP info
I switch to the
_geoip
user and create the script to use
I then used my
$EDITOR
to populate that
~/bin/update_zone.sh
file with the following script:
Scheduling the download
On OpenBSD 6.7 and later
cron
allows the use of the
~
operator to indicate a randomly chosen time
to help distribute the running of tasks
without everything happening at one time.
So after issuing
su - _geoip
to become the
_geoip
user,
I used
to edit the
crontab
and add an entry to run the script:
This picks some random time between
1:00am and 3:59am on Tuesday ("2")
to run our script.
If you use another
cron
or an older version on OpenBSD,
choose an appropriate time.
The
IPDeny
terms-and-conditions
offer very liberal usage limits
so you
could
download them more frequently.
However, I find that the data doesn't change too frequently,
so weekly works for me.
Final file
Putting it all together
my final file looks something like this
(including the initial entries from
/etc/examples/pf.conf
)
Conclusion
With all this in place,
the SSH port now has protection from
non-domestic IP addresses,
folks probing around randomly
to ports they have no business connecting to,
and folks who actually find the port
but bang against it too hard.
From here, one might also implement
fail2ban
,
blacklistd
,
or
sshguard
to monitor your
sshd
logs, watching for failed logins
and then banning based on repeated login failures.