Avoiding DDOS: the PF way

I’ve run a FreeBSD server in my home for six years now. I love the capabilities home servers give you over your bog-standard wireless router – mine, for example, downloads all my POP3 email from various sources, runs it through a Bayesian-enhanced SpamAssassin and filters it through into various IMAP folders (on my boxes, usually Thunderbird or, on the laptop, Mail.app). But you’ve got to be very careful with this, and apart from a front-facing Postfix for email directed at my dynamic DNS domain I have had no regularly open ports. What if I want to access my email from work, for instance?

For this, I’d like to use SSH forwarding; putting the IMAP port through to a local port on the machine I’m using, with the actual data transferred securely over the Internet and where no-one can listen in, even if I’m on some crappy open wireless somewhere. SSH is configured to only accept public key authentication, and to refuse all password access – if you try connecting from a normal SSH client without a relevant key, you get dumped back to your command line with my snidely worded banner, and a “No password access” message. The only public key is in my possession and, of course, is passworded.

Despite this, having open SSH attracts scumbags like paparazzi to Amy Winehouse and the system I use for my firewall (a 733MHz Pentium-III with 256MB RAM) simply can’t cope with thousands of individual connections doing ineffectual dictionary attacks on usernames over Virgin’s 20Mbit connection; it locks up with a massive load average somewhere in the “c”‘s. As an added bonus, this of course eats my “unlimited” download cap during that particular point of the day.

How, therefore, can I balance my security with my convenience? The answer is the same thing I use to do my NAT forwarding, the pf packet filtering firewall.

pf originated with OpenBSD, and was introduced into FreeBSD somewhere around 5.3: I switched from FreeBSD’s own ipfw2 when I upgraded from 4.x to 6.x. As a bonus, pf allows dynamic lists to be built up of IPs that trigger specific rules, allowing for dynamic blocking of SSH offenders.

After my initial “block in” rule in my pf.conf, I define a table:

block in

table <abusive_hosts> persist
block quick from <abusive_hosts>

This defines a list of abusive hosts, traffic from which is blocked without any further discussion (with pf, applicable rules lower down the list take precidence over rules further up unless ‘quick’ is provided, which cuts off further parsing.) You can manually add to this table like so:

pfctl -t abusive_hosts -Tadd <IP address>

Or, more interestingly, you can add to it programatically. After my catch-all NAT rules, I make a rule to allow access to the local SSH port – with a catch.

pass in on $ext_if proto tcp to ($ext_if) port ssh flags S/SA keep state \
        (max-src-conn 10, max-src-conn-rate 6/30, overload <abusive_hosts> flush global)

This allows up to ten simultaneous connections from a particular SSH port, or up to six within thirty seconds. flush kills the states for previously OK connections when it over-runs; global kills all connections from the IP. And the overload rule causes all those things which fail the rule to be pushed into the abusive_hosts table, meaning anything that’s bad and repeatedly connects to my SSH port end up going straight to null.

And this works, too. Using the pfctl command, you can view the contents of the table. I’ll pass it first through awk to remove the spacing, aiding with xargs for further piping, and then through “wc -l” to get the line count:

orpheus# pfctl -t abusive_hosts -Tshow | awk '{print $1}' | wc -l

Removing ‘| wc -l’ gets you a list of IPs, and putting ‘xargs -n 1 host’ there instead gets you a list of the hostnames associated with each of the IPs which can give you an interesting picture: at least a couple of them right now are IPs on American cable modems who are almost certainly compromised home users.

That’s twenty-two abusive hosts who’ve met my SSH blackhole since I last rebooted my machine, who would otherwise have been a problem: pfctl -sr -v (which is sent to you in your nightly root emails) tells me that right now I’ve blocked 5.3MB of unwanted traffic from these hosts since I last rebooted 18 days ago, and I’m sure I’d have got much more if they hadn’t started getting nothing but silence from my machines since the point of blocking.

I’ve found this immeasurably useful for increasing my box’s uptime and overall reliability, which helps prove that a PIII type machine is still good enough for quite a lot of things. And if you click the link to read further, I’ve posted my complete (and only slightly altered) pf.conf for anyone’s interest.

As promised, a pf.conf:

# my external card and internal card, as macros
# specifically allowed local services: postfix only
tcp_services="{ 25 }"

# Port forwarding setup
laptop = ""
laptop_ports = "{ 48478 }"
laptop_udp_ports = "{ 48478 }"
desktop = ""
desktop_ports = "{ 1863, 5500, 29250, 49439 }"
desktop_udp_ports = "{ 1864, 5500, 29250, 49439 }"

# options
set block-policy return
set loginterface $ext_if

set skip on lo

# scrub
scrub in

# nat/rdr
nat on $ext_if from !($ext_if) -> ($ext_if:0)
nat-anchor "ftp-proxy/*"
rdr-anchor "ftp-proxy/*"

# ftp proxy
rdr pass on $int_if proto tcp to port ftp -> port 8021

# redirect the right ports
rdr on $ext_if proto tcp from any to any port $desktop_ports -> $desktop
rdr on $ext_if proto udp from any to any port $desktop_udp_ports -> $desktop
rdr on $ext_if proto tcp from any to any port $laptop_ports -> $laptop
rdr on $ext_if proto udp from any to any port $laptop_udp_ports -> $laptop

# filter rules - catch-all block
block in
# let's kill the bad guys
table <abusive_hosts> persist
block quick from <abusive_hosts>

# let through all verified traffic
pass out keep state

# ftp proxy stuff
anchor "ftp-proxy/*"
antispoof quick for { lo $int_if }

pass in on $ext_if inet proto tcp from any to $ext_if \
   user proxy keep state

# ssh blockage rule
pass in on $ext_if proto tcp to ($ext_if) port ssh flags S/SA keep state \
        (max-src-conn 10, max-src-conn-rate 6/30, overload <abusive_hosts> flush global)

# allow TCP services
pass in on $ext_if inet proto tcp from any to ($ext_if) \
   port $tcp_services flags S/SA keep state

pass in on $ext_if proto tcp from any to $laptop \
        port $laptop_ports flags S/SA synproxy state
pass in on $ext_if proto udp from any to $laptop \
        port $laptop_udp_ports synproxy state
pass in on $ext_if proto tcp from any to $desktop \
        port $desktop_ports flags S/SA synproxy state
pass in on $ext_if proto udp from any to $desktop \
        port $desktop_udp_ports synproxy state

pass in inet proto icmp all icmp-type $icmp_types keep state

pass quick on $int_if

This has working FTP, working forwarding to both my desktop and laptop, working ping and my SSH bad-hosts blocker.