Firewalling with iptables

Cheap, fast, and simple kernel-level Linux firewalls for fun and profit.

Disclaimer: Running any of the commands here might break your production systems. As is good practice regardless, always test things before using them, and make sure you understand everything that you're using. Especially when it's from a random stranger on the Internet. You have been warned.

The netfilter Linux kernel-space APIs that have existed since the early 2010s are a really powerful way of filtering TCP/IP traffic. Not only can they manage every packet of traffic, they do so at an incredible pace, almost never being the bottleneck. iptables is one common userspace program that allows the interaction with and administration of the netfilter module.

Intro to iptables(8)

The reader may, at this point, be very well familiar with the iptables command. In its most basic form, iptables accepts a few key arguments, and does a few key things. In iptables-land, operations are centered around the following objects: chains, rules, and targets. We will now explore each of these to a slight level of detail, however the manual page is unsurprisingly quite helpful here.

Chains, rules, and targets can also belong to specific (routing) tables, which are managed by the kernel. These include the filter, nat, mangle, raw, and security tables, but we’ll just look at the filter table in this post.

Chains can also have a “policy,” which is just what happens to the packet if it reaches the end of the chain without being matched; it must be either ACCEPT or DROP.

Users can specify their own rules, chains, and targets, and it is by arranging configurations that users of iptables can create robust filtering setups.

Implementations may vary from kernel to kernel, however, as netfilter compilations may have different flags turned on. You’ll want to consult man 8 iptables-extensions to make sure you have a given feature.

Examples

Let’s take a look at some chains, now! Consider the following input sequence:

iptables -w --append INPUT --source 1.2.3.4/32 --destination-port 22 --jump ACCEPT
iptables -w       -A INPUT       -s 1.2.3.4/32                           -j DROP
iptables -w       -A INPUT       -s 5.6.7.0/24                           -j DROP
iptables -w       -A INPUT       -s 0.0.0.0/0                            -j ACCEPT

This is a list of four rules, all of which modify the INPUT chain.

Daisy-chaining and logging

If you like the verbiage “chains,” you may be pleased to know that targets for rules need not be the defaults, and you can forward packets between chains! For example,

# Create a chain called "log-then-drop"
iptables -w --new-chain LOG_THEN_DROP

# Packets on the LOG_THEN_DROP chain should first be logged, then dropped.
iptables -w -A LOG_THEN_DROP -j LOG  --log-level info
iptables -w -A LOG_THEN_DROP -j DROP

# Send INPUT packets from 1.2.3.4 to the LOG_THEN_DROP chain
iptables -w -A INPUT -s 1.2.3.4/32 -j LOG_THEN_DROP

Then 1.2.3.4 would see:

$ ping server
PING server.krye.io (X.X.X.X) 56(84) bytes of data.

(That is, they would not get ping responses.)

And server would see these in the logs:

Jun 17 19:37:21 server kernel: IN=eth0 OUT= MAC=[...] SRC=1.2.3.4 DST=X.X.X.X LEN=84 TOS=0x00 PREC=0x00 TTL=63 ID=29283 DF PROTO=ICMP TYPE=8 CODE=0 ID=[...] SEQ=119

Using the LOG target can be quite helpful for debugging, especially if you’re being aggressive such as using a default-DROP policy.

REJECT vs DROP

There seems to be a consensus that DROP is better if you want people not to even know that your server exists; any packets that reach the DROP target will not provoke a response by the server! But, sometimes you want to help your users understand what they are supposed to do. Taking our previous example and making it a bit more friendly,

# Delete the DROP rule from above
iptables -w --delete LOG_THEN_DROP -j DROP

# Instead, at the end of our LOG_THEN_DROP chain, reject the packet with "host unreachable"
iptables -w       -A LOG_THEN_DROP -j REJECT --reject-with icmp-host-unreachable

Now, 1.2.3.4 instead sees

$ ping server
PING server.krye.io (X.X.X.X) 56(84) bytes of data.
From server.krye.io (X.X.X.X) icmp_seq=1 Destination Host Unreachable
From server.krye.io (X.X.X.X) icmp_seq=2 Destination Host Unreachable
From server.krye.io (X.X.X.X) icmp_seq=3 Destination Host Unreachable
From server.krye.io (X.X.X.X) icmp_seq=4 Destination Host Unreachable
From server.krye.io (X.X.X.X) icmp_seq=5 Destination Host Unreachable
From server.krye.io (X.X.X.X) icmp_seq=6 Destination Host Unreachable

It’s important to note that DROP imitates the behavior of pinging a completely dead host, which doesn’t help your end-users. If you want people to know that they are being filtered, it might be advised to instead use the REJECT target with an appropriate ICMP message.

Faster bulk filtering with ipset

If you, like I do, have automated the process of blocking things, eventually you will end up with some very long netfilter chains. Diving into the Linux kernel source for nft_do_chain, (which is responsible for actually walking netfilter chains) it’s pretty clear that each rule in the chain gets evaluated one-by-one; there’s no magic hashing or anything going on to speed things up. This can be problematic if you have a lot of rules, especially multiple corresponding to the same network range. On one of my production servers, I had around 1000 rules blocking different IPs that belonged to AS4134 (CHINANET-BACKBONE), IPs which had been abusively scanning. (Side note: If you’re curious, you can execute bulk queries against whois.cymru.com if you want to see what an IP is.)

One might be inclined to think, “should I sort my chains somehow?” Or, “can I reduce the work required to block this entire badly-behaving ISP?” Of course you can!

The ipset framework also exists within the Linux kernel, and can store sets of individual IPs, networks, port numbers, MAC addresses, and the like. According to their site,

If you want to

then ipset may be the proper tool for you.

Sure enough, you can define ipsets quite easily after installing the CLI utility. For example, to block AS396507, after obtaining a list of CIDR prefixes, (e.g. 23.129.64.0/24) all you need is:

ipset create AS396507-v4 hash:net

to create your set. (hash:net is appropriate for matching multiple ranges, but you may want to explore the other options too) To add your prefixes,

ipset add AS396507-v4 23.129.64.0/24

and then to finally put your rule in the chain,

iptables -A INPUT -m set --match-set AS396507-v4 src -j DROP

Now, any time that rule gets evaluated against traffic, the IP gets hashed and checked in O(1) time. These hashes are also tiny; as they fill up, they stay within a certain size in memory. If you want to block both IPv4 and IPv6 with one ipset, you might consider the list:set set type, which lets you make a set of sets, and then make an IPv6 set. (ipset infers and pins a set to a specific IP version.) These are evaluated in O(n) time where n is the number of sets in your list, however.

Conclusion

For some, netfilter is the firewall you never knew about, with iptables being its most simple manager. There are management tools out there (like firewalld) that add a layer of abstraction so you don’t have to think in chains, but at the end of the day, netfilter is one of many powerful and arguably underused features of the Linux kernel.