Defensive Measures
- Defensive Measures
Deciding what traffic to allow — and watching the rest
A firewall answers one question for every packet: should this be allowed through? That decision is the most fundamental network defense there is — it is access control applied to traffic instead of files. But filtering alone only stops what you thought to block, so a complete posture pairs packet filtering (firewalls) with detection (intrusion detection/prevention) that inspects the traffic you did let through.
This page works through both halves. On the filtering side it covers four rule engines you will actually meet — Linux’s iptables/nftables, BSD’s pf, and FreeBSD’s ipfw — each translating the same policy so you can see how the model carries across platforms. On the detection side it covers Suricata rules (including Lua), and finishes with the all-in-one “firewall distros” and monitoring appliances that package these pieces behind a web UI.
ℹ️ Two themes recur across every engine below: rule order matters, and the default policy is your real security boundary. Read each ruleset asking “what happens to a packet that matches nothing?”
Firewalls
Firewalls are a classic defensive measure. Designed as a tool to limit inbound or outbound connections, firewalls are traditionally rule based. We will primarily be discussing the Linux iptables instantiation of a firewall, mainly due to the ease of access – it’s on the Kali VM we have been using all term.
At the end of the day, firewalls are nothing but sets of rules. These rules govern which packets are kept, and which packets get dropped like a bad habit. One of the most important things to keep in mind is that order matters!
Firewall rules
As mentioned above, firewalls are simply a set of rules. If you’d like to see the current rule-set, execute the below
┌─(ROOT@kali478-0:pts/4)───────────────────────────────────────────────────────────────────(~/cs478)─┐
└─(2:12:43:#)── iptables --list ──(Fri,Feb14)─┘
Chain INPUT (policy ACCEPT)
target prot opt source destination
Chain FORWARD (policy ACCEPT)
target prot opt source destination
Chain OUTPUT (policy ACCEPT)
target prot opt source destination
As you can see, within Kali, the defaults are empty. If you want to know what else iptables can do, take a look at the help:
┌─(ROOT@kali478-0:pts/4)───────────────────────────────────────────────────────────────────(~/cs478)─┐
└─(2:12:43:#)── iptables --help ──(Fri,Feb14)─┘
iptables v1.8.3
Usage: iptables -[ACD] chain rule-specification [options]
iptables -I chain [rulenum] rule-specification [options]
iptables -R chain rulenum rule-specification [options]
iptables -D chain rulenum [options]
iptables -[LS] [chain [rulenum]] [options]
iptables -[FZ] [chain] [options]
iptables -[NX] chain
iptables -E old-chain-name new-chain-name
iptables -P chain target [options]
iptables -h (print this help information)
Commands:
Either long or short options are allowed.
--append -A chain Append to chain
--check -C chain Check for the existence of a rule
--delete -D chain Delete matching rule from chain
--delete -D chain rulenum
Delete rule rulenum (1 = first) from chain
--insert -I chain [rulenum]
Insert in chain as rulenum (default 1=first)
--replace -R chain rulenum
Replace rule rulenum (1 = first) in chain
--list -L [chain [rulenum]]
List the rules in a chain or all chains
--list-rules -S [chain [rulenum]]
Print the rules in a chain or all chains
--flush -F [chain] Delete all rules in chain or all chains
--zero -Z [chain [rulenum]]
Zero counters in chain or all chains
--new -N chain Create a new user-defined chain
--delete-chain
-X [chain] Delete a user-defined chain
--policy -P chain target
Change policy on chain to target
--rename-chain
-E old-chain new-chain
Change chain name, (moving any references)
Options:
--ipv4 -4 Nothing (line is ignored by ip6tables-restore)
--ipv6 -6 Error (line is ignored by iptables-restore)
[!] --proto -p proto protocol: by number or name, eg. 'tcp'
[!] --source -s address[/mask][...]
source specification
[!] --destination -d address[/mask][...]
destination specification
[!] --in-interface -i input name[+]
network interface name ([+] for wildcard)
--jump -j target
target for rule (may load target extension)
--goto -g chain
jump to chain with no return
--match -m match
extended match (may load extension)
--numeric -n numeric output of addresses and ports
[!] --out-interface -o output name[+]
network interface name ([+] for wildcard)
--table -t table table to manipulate (default: 'filter')
--verbose -v verbose mode
--wait -w [seconds] maximum wait to acquire xtables lock before give up
--wait-interval -W [usecs] wait time to try to acquire xtables lock
default is 1 second
--line-numbers print line numbers when listing
--exact -x expand numbers (display exact values)
[!] --fragment -f match second or further fragments only
--modprobe=<command> try to insert modules using this command
--set-counters PKTS BYTES set the counter during insert/append
[!] --version -V print package version.
So let’s add some rules:
❯ iptables -A OUTPUT -p tcp -d ubuntu.com -j ACCEPT
❯ iptables -A OUTPUT -p tcp -d ca.archive.ubuntu.com -j ACCEPT
❯ iptables -A OUTPUT -p tcp --dport 80 -j DROP
❯ iptables -A OUTPUT -p tcp --dport 443 -j DROP
❯ iptables -A INPUT -p tcp -s 172.16.0.45 --dport 22 -j ACCEPT
❯ iptables -A INPUT -p tcp -s 0.0.0.0/0 --dport 22 -j DROP
What do these rules do? Let’s break them down:
- Allow all traffic to
ubuntu.com - Allow all traffic to
ca.archive.ubuntu.com - Drop all traffic destined for port 80
- Drop all traffic destined for port 443
- Allow traffic from
172.16.0.45to port 22 - Drop all traffic from any other source to port 22
So we can SSH in, we can update the system, but we can’t browse the web. This is a very basic example of how iptables can be used to control traffic.
Rule processing
Rules are processed in numerical order, from first to last. Processing stops as soon as a rule is matched – ORDER MATTERS! There are ways to move rules around in the ipchains, but it’s better to just enter them in the correct order.
nftables
nftables is the successor to iptables and has been the default firewall framework on Debian-based systems (including Kali) since Debian 10. It replaces not just iptables but the entire collection of legacy tools — iptables, ip6tables, arptables, and ebtables — with a single unified front end: nft.
The kernel-side engine compiles rules into bytecode at the time they are loaded rather than interpreting them at packet time, which gives nftables a performance edge over iptables at high packet rates.
Structure: tables, chains, and rules
nftables organizes configuration in three layers:
| Layer | Purpose |
|---|---|
| Table | Top-level namespace; has an address family (ip, ip6, inet, arp, bridge, netdev) |
| Chain | Container for rules; a base chain hooks into the netfilter path; a regular chain is a jump target |
| Rule | Match expression + verdict (accept, drop, reject, jump, log, …) |
Unlike iptables, no tables or chains exist by default — you create only what you need. The inet family handles IPv4 and IPv6 with a single rule set, which is the most common choice for host firewalls.
Viewing the current ruleset
❯ nft list ruleset
On a fresh Kali install this produces no output — the ruleset is empty.
Basic nft commands
| Operation | Command |
|---|---|
| List everything | nft list ruleset |
| Add a table | nft add table inet filter |
| Add a base chain | nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }' |
| Add a rule | nft add rule inet filter input tcp dport 22 accept |
| Insert a rule at position 0 | nft insert rule inet filter input tcp dport 22 accept |
| List rules with handles | nft list chain inet filter input |
| Delete a rule by handle | nft delete rule inet filter input handle 4 |
| Flush all rules | nft flush ruleset |
Worked example
The iptables rules from the section above — allow SSH from one host, allow apt updates, block all other web traffic — translate to nftables as follows:
# Create a table and base chains
❯ nft add table inet filter
❯ nft add chain inet filter input '{ type filter hook input priority 0; policy accept; }'
❯ nft add chain inet filter output '{ type filter hook output priority 0; policy accept; }'
# OUTPUT: allow apt traffic to specific hosts, then drop ports 80 and 443
❯ nft add rule inet filter output ip daddr ubuntu.com tcp accept
❯ nft add rule inet filter output ip daddr ca.archive.ubuntu.com tcp accept
❯ nft add rule inet filter output tcp dport { 80, 443 } drop
# INPUT: allow SSH from one host, drop it from everyone else
❯ nft add rule inet filter input ip saddr 172.16.0.45 tcp dport 22 accept
❯ nft add rule inet filter input tcp dport 22 drop
Verify the result:
❯ nft list ruleset
table inet filter {
chain input {
type filter hook input priority filter; policy accept;
ip saddr 172.16.0.45 tcp dport 22 accept
tcp dport 22 drop
}
chain output {
type filter hook output priority filter; policy accept;
ip daddr ubuntu.com tcp accept
ip daddr ca.archive.ubuntu.com tcp accept
tcp dport { 80, 443 } drop
}
}
The { 80, 443 } syntax — called a set literal — lets you match multiple values in a single rule, replacing two separate iptables rules.
Persistent rules
Rules added with nft are lost on reboot. Persist them by saving the ruleset and loading it at boot:
# Save current ruleset
❯ nft list ruleset > /etc/nftables.conf
# Restore manually (or from a startup script)
❯ nft -f /etc/nftables.conf
On systemd-based systems the nftables service does this automatically:
❯ systemctl enable --now nftables
Translating existing iptables rules
iptables-translate converts individual iptables rules to their nftables equivalent:
❯ iptables-translate -A INPUT -p tcp -s 172.16.0.45 --dport 22 -j ACCEPT
nft add rule ip filter INPUT ip saddr 172.16.0.45 tcp dport 22 counter accept
iptables-nft is a drop-in iptables replacement that stores rules in the nftables kernel engine while accepting the old syntax — useful during a gradual migration.
pf
pf (Packet Filter) originated in OpenBSD and is the default firewall on all BSDs. It is also the engine under the hood of pfSense and OPNsense. macOS ships with pf as well. The command-line tool for managing pf is pfctl.
Rule evaluation
pf reads its configuration from /etc/pf.conf and evaluates rules top to bottom, but the last matching rule wins — the opposite of iptables and ipfw. The quick keyword overrides this: when a packet matches a quick rule, evaluation stops immediately at that rule.
# Without quick: last match wins
block all
pass proto tcp to any port 22 # ← this wins for SSH traffic
# With quick: first match stops processing
pass quick on lo0 all # ← loopback stops here
block all # ← never reached for lo0 packets
pfctl commands
| Operation | Command |
|---|---|
| Enable pf | pfctl -e |
| Disable pf | pfctl -d |
| Load rules | pfctl -f /etc/pf.conf |
| Dry run (syntax check) | pfctl -nf /etc/pf.conf |
| Show active rules | pfctl -sr |
| Show state table | pfctl -ss |
| Show statistics | pfctl -si |
| Flush rules | pfctl -F rules |
Worked example
The same policy as the iptables example — SSH from one host only, apt updates allowed, all other HTTP/HTTPS blocked — as a pf.conf:
# /etc/pf.conf
# Named table: resolved once at load time
table <apt_hosts> const { ubuntu.com, ca.archive.ubuntu.com }
# Default deny
block all
# Loopback is always trusted
pass quick on lo0 all
# SSH: allow from one host, block from everywhere else
pass in quick proto tcp from 172.16.0.45 to any port 22 keep state
block in quick proto tcp from any to any port 22
# Apt updates to specific hosts only
pass out quick proto tcp to <apt_hosts> port { 80, 443 } keep state
# Block all other outbound HTTP/HTTPS
block out proto tcp to any port { 80, 443 }
A pf table (<apt_hosts>) is a named set of addresses. You can add or remove members at runtime without reloading the full ruleset:
pfctl -t apt_hosts -T add 1.2.3.4
pfctl -t apt_hosts -T delete 1.2.3.4
pfctl -t apt_hosts -T show
Loading rules at boot
On OpenBSD, enable in /etc/rc.conf.local:
pf=YES
On FreeBSD, add to /etc/rc.conf:
pf_enable="YES"
pf_rules="/etc/pf.conf"
ipfw
ipfw is FreeBSD’s built-in stateful firewall. It uses first-match-wins semantics — the same as iptables. Rules are numbered 1–65535; rule 65535 is the built-in implicit deny all and cannot be deleted.
Rule syntax
ipfw add [rulenum] action [log] proto from src [port] to dst [port] [options]
Common actions: allow (aliases: accept, pass), deny (alias: drop), reject, count, skipto
ipfw commands
| Operation | Command |
|---|---|
| List all rules | ipfw list |
| List with byte/packet counts | ipfw -a list |
| Add a rule | ipfw add 100 allow tcp from any to me dst-port 22 |
| Delete rule 100 | ipfw delete 100 |
| Flush all rules | ipfw -f flush |
| Show dynamic state table | ipfw show |
Worked example
# Flush existing rules
ipfw -q -f flush
# Rule 100: loopback — always allow
ipfw add 100 allow all from any to any via lo0
# Rule 200: allow SSH from one host
# keep-state installs a dynamic rule for return traffic
ipfw add 200 allow tcp from 172.16.0.45 to me dst-port 22 keep-state
# Rules 300–310: allow outbound apt traffic to specific hosts
ipfw add 300 allow tcp from me to ubuntu.com keep-state
ipfw add 310 allow tcp from me to ca.archive.ubuntu.com keep-state
# Rule 400: block all other outbound HTTP/HTTPS
ipfw add 400 deny tcp from me to any dst-port 80,443
# Rule 65535 (implicit): deny all — already present, cannot be deleted
keep-state installs a dynamic rule that tracks the connection and automatically permits reply packets, so no explicit inbound allow is needed for established sessions.
Persistent rules
Write rules to a script and reference it in /etc/rc.conf:
firewall_enable="YES"
firewall_script="/etc/ipfw.rules"
Intrusion Detection/Prevention Systems
Suricata is an open source (with commercial support) network based intrusion detection/prevention system (NIDS/NIPS). Much like the Snort tool, it is a production grade NIPS that has significant community support. For our purposes, Suricata is the example used due to its support of rule expansions via the Lua scripting language. Snortv3 also now supports Lua scripts in rules, but is not well documented (to my knowledge).
Suricata rules
From the Suricata manual:
A rule/signature consists of the following:
* The action, that determines what happens when the signature matches
* The header, defining the protocol, IP addresses, ports and direction of the rule.
* The rule options, defining the specifics of the rule.
#action protocol IPs ports -> IPs ports (trigger conditions)
drop tcp 10.0.0.0/8 any -> $HOME_NET any (msg:"dropping all traffic from non-routable IPs"; sid:20201234; rev:2;)
The above has both the general format, as well as an example rule. sid is required, while rev is recommended.
Built-in rules
Suricata comes with a significant batch of pre-built rules for a lot of the common network packets you might want to filter out. These include unwanted pings, network scan requests, ICMP traffic at all, filtering for known exploit traffic, etc. Further, there are some packet “classes” you can use in rules that encapsulate fairly complex rule logic.
Custom rules
Sometimes though, the built in rules just won’t cut it. Maybe there’s a new exploit in the wild. Maybe you want to alert on specific actions from smb. Maybe you just want to explore. There are several ways to add custom rules:
- You can build them with the packet classes mentioned above
- You can filter on specific content
- You can filter on basic packet structure
- You can filter on either header or body of the packet, including re-assembled or not fragmented traffic
- You can write a Lua script to work on the packet to analyze it in a more in-depth fashion.
The above list is not exhaustive, and I want us to focus primarily on the final point. Lua gives us the ability to perform complex logic on our packet structure or the content of the packet. It allows for things like detecting a mismatch between reported and actual length (remember that, it’ll be useful soon). The below verifies that reported length is the same as actual length.
1 function init (args)
2 local needs = {}
3 needs["payload"] = tostring(true)
4 return needs
5 end
6
7 function string.tohex(str)
8 return (str:gsub('.', function (c)
9 return string.format('%02X ', string.byte(c))
10 end))
11 end
12
13 function match(args)
14 local b = args['payload']
15 if b == nil then
16 print ("Payload buffer empty! Aborting...")
17 return 0
18 end
19 print (string.tohex(b))
20 -- DNS RFC specifies length is reported in the first two bytes.
21 dns_size_high_byte = b:byte(1)
22 dns_size_low_byte = b:byte(2)
23 dns_size = tonumber(dns_size_high_byte) * 256 + tonumber(dns_size_low_byte)
24 -- check to ensure reported lenghth is same as actual length. Subtract 2 for length field
25 if dns_size ~= string.len(b)-2 then
26 return 1
27 end
28 return 0
29 end
Lines 1-5 are required, while lines 7-11 are useful for debugging purposes. On a failed test, return 1 to trigger the rule. On a passing test, return 0 to skip this rule. Pay attention to lines 21-23 – this is how you pull out bytes and combine into a 16-bit int.
Optional Additional Resources
If this topic interests you, then you might find the following optional resources useful.
There exist some operating systems that are designed to be firewalls. These are often referred to as “firewall distros”. Some (but certainly not all) of the more popular ones are:
The first two are BSD based, while the rest are Linux based. Their capabilities vary, but they all offer, at minimum, a firewall and intrusion detection/prevention system. They also often have VPN capabilities and a user-friendly web interface for configuration.
Security Appliances
The firewalls and tools above are typically deployed on general-purpose systems managed from the command line. Several projects package these capabilities into purpose-built distributions designed to run on dedicated hardware or VMs and managed entirely through a web interface.
OPNsense
OPNsense is a FreeBSD-based firewall and routing platform forked from pfSense in 2015 and maintained by Deciso. pf is the packet filtering engine; the web UI exposes it without requiring any command-line work.
Core capabilities:
| Feature | Details |
|---|---|
| Firewall | Stateful pf rules, NAT, traffic shaping, VLAN support |
| IDS/IPS | Built-in Suricata with Proofpoint Emerging Threats rules |
| VPN | WireGuard, OpenVPN, IPsec (route-based and policy-based) |
| DNS | Unbound resolver with DNS-over-TLS |
| High availability | CARP with automatic state synchronization |
| Automation | REST API for scripted configuration |
OPNsense releases on a predictable six-month cycle (January and July). It is a practical choice for a network perimeter device, a lab gateway, or anywhere you want pf-based enforcement without managing pf.conf by hand.
Security Onion
Security Onion is a Linux-based (Ubuntu) distribution designed for network security monitoring (NSM), threat hunting, and digital forensics and incident response (DFIR). It is not a traditional firewall — it is a detection and analysis platform.
Core components:
| Component | Role |
|---|---|
| Suricata | Signature-based IDS/IPS and per-alert PCAP capture |
| Zeek | Protocol metadata extraction and connection logging |
| Elasticsearch | Log and metadata indexing and search |
| Kibana | Dashboards and visualization |
| CyberChef | In-browser data decoding and transformation |
| osquery | Host-based endpoint telemetry |
| Security Onion Console | Alerting, case management, and threat hunting UI |
In a typical deployment a tap or SPAN port feeds all network traffic to the sensor; Suricata and Zeek process it in real time and write results into Elasticsearch for analysis. Security Onion scales from a single-node sensor on a laptop to a distributed grid.
It pairs well with OPNsense: OPNsense enforces policy at the network edge, Security Onion watches what passes through.
Key takeaways
- A firewall is just an ordered set of rules plus a default policy. The order decides which rule a packet hits first; the default policy decides the fate of anything that matches nothing — make it
drop/denyand allow deliberately. - The same policy expresses across engines, but the matching semantics differ:
iptablesandipfware first-match-wins,pfis last-match-wins (unless you usequick). Always know which model you’re reading. - On Linux,
nftablesis the successor toiptables— onenfttool, theinetfamily for IPv4+IPv6 together, set literals like{ 80, 443 }, andiptables-translateto convert existing rules. - Stateful rules (
keep state,keep-state, conntrack) track established connections so you don’t have to hand-write return-traffic rules. - Filtering stops only what you anticipated; IDS/IPS such as Suricata inspects what you allowed through, and its Lua rules let you express logic plain signatures can’t — e.g. flagging a mismatch between a protocol’s reported and actual length.
- Firewall distros and appliances (OPNsense, pfSense, OpenWRT, IPFire, Security Onion) package these engines behind a web UI; OPNsense enforces at the edge while Security Onion monitors what passes — they complement rather than replace each other.
References
- netfilter / nftables wiki — the canonical
nftreference. https://wiki.nftables.org/wiki-1.1.0/index.php/Main_Page - nftables — moving from iptables. https://wiki.nftables.org/wiki-1.1.0/index.php/Moving_from_iptables_to_nftables
- OpenBSD PF User’s Guide. https://www.openbsd.org/faq/pf/
- FreeBSD Handbook — Firewalls (
pf,ipfw). https://docs.freebsd.org/en/books/handbook/firewalls/ - Suricata documentation — rules and Lua scripting. https://docs.suricata.io/en/latest/
- NIST SP 800-41 Rev. 1 — Guidelines on Firewalls and Firewall Policy. https://csrc.nist.gov/pubs/sp/800/41/r1/final
Related course pages: Introduction to Networking · Capturing traffic with tcpdump · Analyzing traffic with Wireshark
🛠️ Maintenance note: tool versions drift — the
iptables --help/--listoutput and theiptables v1.8.3banner are from an older Kali; verify against the current image, and remember Kali now usesnftablesunderneathiptables-nftby default. OPNsense ships on a six-month cycle (January/July), and Suricata rule syntax and the Lua API evolve between major releases — re-check the linked docs each term.