Before we dive in, let me preface by stating that there are obviously pre-built firewalls (both free and commercial) which will be much more performant and secure. However, this should not stop you from building your own firewall: it’s both fun and educational! I’ll be using a Raspberry Pi to build the firewall, but you can use any Linux based OS.
But first, there is some background I personally feel is very important to understanding firewalling on Linux, namely netfilter and iptables.
What is NETFILTER and IPTABLES?
Linux (from version 2.4) has a built-in packet filtering framework called netfilter, this is used for host firewalling. You can not call netfilter directly, it provides the option to hook into network events programmatically. To use netfilter without writing complex software you can use iptables.
Iptables provides an interface to a set of rules that you can configure to act on certain flows. An example of this are these three default chains in the “filter” table: INPUT, FORWARD and OUTPUT.
The INPUT chain is traffic destined for the Linux host itself, for example if you’re running a webserver on port 443, and you want to firewall this off you’d use the INPUT chain.
FORWARD is traffic that you’re routing. This is traffic not destined for your localhost, and not initiated by it either. You can setup any Linux host to be a router by enabling IP Forwarding, for example on Debian:
sudo sysctl -w net.ipv4.ip_forward=1
Finally, OUTPUT is for traffic that originated on the box itself, for example when you type wget https://www.forwardproxy.com on the box itself this would match on the OUTPUT chain.
What is NFQUEUE?
When you write an iptables rule, you’ll want to specify an action on the rule. Some traffic you want to drop, some you want to allow. These actions are called targets in iptables. There are a couple of targets, but for the purpose of this guide I’ll focus on three:
ACCEPT and DROP are self-explanatory. It is important to know DROP is a complete discard, the packet isn’t handled whatsoever and no reply is sent.
There is also (NF)QUEUE: this makes iptables send the packet to userspace, where you can see all data, modify it, and send it back with a verdict: accept or drop. There is both QUEUE and NFQUEUE. The latter allows you to specify an ID for the queue you want to send it to (0 – 65535), whereas the former will always send to queue 0.
Reading from NFQUEUE
This guide will use Node.js because the thought of a firewall running on Javascript is kind of amazing. It’s also a very familiar language to many and allows for very readable code.
Installing the required dependencies
I’m installing this on a Raspberry Pi because that seems to be the kind of device for a project such as this. To install the required dependencies on Raspbian (or any Debian-based distro) as root (or using sudo):
Node.js + npm
We’ll be installing Node.js 8.x because the nfqueue library doesn’t seem to be compatible with 10.x and greater.
apt install -y build-essential
curl -sL https://deb.nodesource.com/setup_8.x | bash -
apt install -y nodejs
Libnetfilter + libcap
You’ll need libnetfilter so node-nfqueue has something to hook onto. The libcap library has a couple of useful packet decoders which will make your life a lot easier.
apt install libnetfilter-queue-dev
apt install -y libcap0.8-dev
node-nfqueue
Now, using a non-root user you can use npm to install the node-nfqueue library:
mdkir ~/firewall
cd ~/firewall
npm install nfqueue
npm install pcap
A useful extra is node-netmask, it implements subnet matching which you can use in your firewall.
IPTABLES commands
To route traffic to NFQUEUE, you need a iptables policy to send it there. The following command sends all traffic to queue with ID 30:
iptables -t filter -A FORWARD -j NFQUEUE --queue-num 30
If you find yourself needing to delete this rule again for any reason, use:
iptables -D FORWARD 1
You probably also want to NAT behind the outgoing interface, for this use:
iptables -t nat -A POSTROUTING -o eth0-j MASQUERADE
Don’t forget to change eth0 with whatever interface points towards your router!
Creating the firewall script
Despite being a crappy coder, please find my code below. This could be improved upon endlessly, but it should serve as a good starting point. First I’ll walk through the different parts of the code. You can find the full version below or at this location.
This firewall has the following features:
- Stateful packet inspection, you need to make your rules only one-way as they get matched against the session table once established.
- Two example rules:
- Drop all TCP traffic from 192.168.0.0/16 (encompasses most networks at home) towards 8.8.8.8
- Allow all other traffic originating from 192.168.0.0/16
- Session aging: sessions that have been idle for over 600 seconds get removed (reaping) from the session table.
This only works for TCP traffic, but you can easily adapt it for other protocols. Typically you’ll want to implement ICMP (protocol: 1) and UDP (protocol: 17) as well for a proper home network.
First, there are the dependencies and the configurable options:
var nfq = require('nfqueue'); var IPv4 = require('pcap/decode/ipv4'); var netmask = require('netmask').Netmask // Delete idle sessions after 10 minutes var idleTimeout = 600; // Firewall rules var rules = [ { src: '192.168.0.0/16', dst: '8.8.8.8', serviceStart: 0, serviceEnd: 65535, action: nfq.NF_DROP }, { src: '192.168.0.0/16', dst: '0.0.0.0/0', serviceStart: 0, serviceEnd: 65535, action: nfq.NF_ACCEPT } ];
This should be pretty self-explanatory. The serviceStart and serviceEnd parameters are there for the TCP ports to match on. If you want to match only a single service such as SSH, you’d change both of them to 22.
Now we want an object to store sessions in and a struct of session states so the code becomes more legible later on.
// Our session state object var sessions = {}; // The different states of a session as we see it, not exactly RFC compliant. const sessionState = { SYNSENT : 0, ESTABLISHED : 1, CLOSING: 2, CLOSED: 3 };
We’ll run through the rulebase once to use node-netmask so we don’t need to do this every time we want to match a rule:
// Add the necessary masks so we can match them later for(var i in rules) { rules[i].srcMask = new netmask(rules[i].src); rules[i].dstMask = new netmask(rules[i].dst); }
Now, we need to handle the NFQUEUE, create a handler:
nfq.createQueueHandler(30, 65535, function(nfpacket) { var packet = new IPv4().decode(nfpacket.payload, 0); var verdict = nfq.NF_DROP;
And now for our entire (once again…) basic TCP handling:
if(packet.protocol == 6) { /* Check for existing session first */ var key = create_key(packet.daddr, packet.payload.dport, packet.saddr, packet.payload.sport); if(typeof sessions[key] !== 'undefined') { verdict = nfq.NF_ACCEPT; if(packet.payload.flags.syn && packet.payload.flags.ack) { update_session(key, sessionState.ESTABLISHED); } else if(packet.payload.flags.fin) { if(sessions[key].state == sessionState.CLOSING) { update_session(key, sessionState.CLOSED); } else { update_session(key, sessionState.CLOSING); } } else if(packet.payload.flags.rst) { update_session(key, sessionState.CLOSED); } sessions[key].idle = 0; } else { /* Couldn't find existing session, run through rulebase. */ var key = create_key(packet.saddr, packet.payload.sport, packet.daddr, packet.payload.dport); for(var i in rules) { var rule = rules[i]; if(rule.srcMask.contains(packet.saddr) && rule.dstMask.contains(packet.daddr) && (packet.payload.dport >= rule.serviceStart && packet.payload.dport <= rule.serviceEnd)) { if(packet.payload.flags.syn) { if(rule.action != nfq.NF_DROP) { update_session(key, sessionState.SYNSENT); } } verdict = rule.action; // Break loop early as no further rule processing is required break; } } } }
This includes code to check for existing sessions and a way to handle FIN and RST flags.
And end the handler with:
else { // ACCEPT anything that isn't TCP verdict = nfq.NF_ACCEPT; } nfpacket.setVerdict(verdict); });
There is also the function to create a tuple-like construct for the keys in our session state table:
var create_key = function(srcip, srcport, dstip, dstport) { return [srcip, srcport, dstip, dstport].sort(); }
I’m sort()’ing the array because as of now I can’t think of a better way to match connections without this thing becoming too complex.
To age the sessions and check if anything needs to be reaped:
var age_sessions = function(sessions) { for(var i in sessions) { sessions[i].idle++; if(sessions[i].idle > idleTimeout || sessions[i].state == sessionState.CLOSED) { console.log("Reaping " + i); delete(sessions[i]); } } setTimeout(function(){age_sessions(sessions)}, 1000); } setTimeout(function(){age_sessions(sessions)}, 1000);
And finally the function to update session states, or create the session if it doesn’t exist yet.
var update_session = function(key, state) { if(typeof sessions[key] === 'undefined') { sessions[key] = {}; } sessions[key].state = state; sessions[key].idle = 0; }
The full script can be found here.
After placing the script in the earlier created ~/firewall directory you can run it using:
sudo nodejs ~/firewall/firewall.js
Now all that is left to change the default gateway of your network adapter to your Raspberry Pi’s IP address.
That’s it!
FAQ
“Why not just make iptables rules?”
This is definitely one of those “because you can” projects. Additionally, you can use this for a lot more than firewalling as you get to modify the payload of the packet.
Leave a Reply