Unpack IPTables: Its Inner Workings With Commands and Demos
Iptables is a technology used in Linux subsystems to filter packets. This was the third attempt, and it proved to be very successful. Let's unpack how they work.
Join the DZone community and get the full member experience.
Join For FreeWe all know that the internet works by sending and receiving small chunks of data called packets. Back in the early days, when the internet was still in its infancy, packets were allowed to transfer freely across a connected world, however small that world was. Anyone could send packets to your system, and you could send packets to other connected systems. All services running on systems were exposed by default.
As the internet started to grow, problems started to emerge, problems related to security. There are worms, viruses, unauthorized access, denial-of-service (DoS) attacks, IP spoofing, etc. Iptables is an attempt to deal with some of these problems.
Some History
In early Linux systems, there were basic tools to handle networking, allowing users to connect to remote machines, share data, and run servers. However, it quickly became clear that raw networking capabilities weren't enough. The growing demand called for:
- Firewalls – Blocking or allowing network traffic based on defined rules.
- Network Address Translation (NAT) – Allowing multiple devices to share a single IP address (we were running out of IPV4 addresses )
Initially, Linux introduced a tool called ipfwadm into the kernel to support a firewall. But it had significant limitations
- Only basic packet filtering capabilities
- No support for stateful inspection; if outbound traffic was allowed, there was no automatic way to allow the related inbound response. For example, if you pinged, you wouldn't automatically receive the reply unless you manually configured it.
Things got a little better somewhat with the introduction of ipchains. It offered certain advantages over ipfwadm, such as
- More flexible rule matching
- Basic support for connection tracking.
However, it still fell short. The connection tracking wasn't fully stateful, there was no support for IPv6, and the design wasn't scalable — meaning rule sets became slower and harder to manage as they grew.
NetFilter
Before getting to iptables, we should talk about NetFilter, after all, that is the foundation of iptables. Initial packet filtering in Linux systems was linear, and there was no easy way to intercept, modify, or drop packets. Netfilter provided the kernel-level foundation to solve this problem.
Netfilter is a framework that solves this problem and adds powerful new capabilities. Very cleverly, Netfilter developers introduced hook points at critical stages of packet processing within the Linux networking stack. These stages are:
- PREROUTING
- INPUT
- FORWARD
- OUTPUT
- POSTROUTING
They also implemented a mechanism that allows kernel modules to register callbacks (also known as hook functions ) at these points.
With these changes, the Linux networking stack began calling the registered hook functions during packet traversal, effectively enabling features like firewall, NAT, packet mangling, and many others.
Netfilter also provides an API for registering custom hooks. Iptables uses this registration API to register its function for actions such as ACCEPT, DROP, and LOG.
The api interface looks like:
//for single hook registration
int nf_register_net_hook(struct net *net, const struct nf_hook_ops *ops);
//for multiple hooks
int nf_register_net_hooks(struct net *net,
const struct nf_hook_ops *ops,
unsigned int n);
//ops is pointer to the struct that would define the hook function, priority etc
// net is the pointed to network namespace
Iptables
You can think of iptables as the frontend and Netfilter as the backend. Using Netfilters, iptables gives the capability to filter or mangle packets, perform NAT, etc. Instead of writing kernel modules for custom requirements, you could just configure iptables according to your requirements.
Table
Iptables performs multiple functions such as packet filtering, NAT, routing, and packet marking. These processing functions operate on packets in parallel but involve different processing logic, making them conceptually distinct. To support this, iptables uses tables that represent different functions. Below are some examples of different tables in iptables:
| table name | purpose | used for |
|---|---|---|
|
Filter (default) |
Packet Filtering |
Allow/deny traffic |
|
nat |
Network Address Translation |
Port forwarding, source NAT |
|
mangle |
Packet modification |
Qos, TTL, TOS, packet marking |
Chains
Iptables works based on rules, which are processed sequentially. For example, you might want to :
- Drop Ping (ICMP) connection from a particular IP
- Accept SSH connections from an internal subnet
- Log SSH connection from any other location
- Reject Ping connection from a particular source
You could create a flat list of rules, but managing them over time can become painful and prone to errors— for instance, accidentally allowing all IPS when you intended to allow only a specific one outside of your internal network.
To keep rules sequential and modular, iptables uses chains. A packet passes through each rule in a chain, and rules are applied depending on whether there is a match. So an SSH packer will be checked for each rule. If there is a match, action will be taken; otherwise, the next rule in the chain will be checked.
Some chains are built-in, but you can also create your own custom chains. The following are built-in chains:
- The INPUT chain handles packets destined for the local machine
- The OUTPUT chain manages packets generated by the local machine and sent out
- The FORWARD chain deals with packets being routed through the system (e.g., a router )
Fun fact : you can create your own custom chain and have a built-in chain call it using JUMP (-j) target. We will see how to do that in a little bit.
Enough of the blabbering, let's dive into some demos to see how things actually work. Before we get started, we need to set up our environment by creating two Linux VMs, one acting as the server and the other as the client. Follow along!
Virtualization
It's easier to demonstrate the example on virtual machines. You could create multiple VMs on your Mac/Windows using free virtualization software like VMware Fusion or Oracle VirtualBox.
If you are on a Mac with an Apple M-series chip, you'll need to use VMware Fusion, as VirtualBox doesn't support ARM-based Macs.
You could download VMware Fusion from Broadcom using the following link.
Note: You'll need to register, and they require your physical address. Honestly, I found their website experience a bit clunky; it took me quite a while just to get to the actual download link.
The following steps can be followed to create VMs using VMware Fusion:
- Open VMware Fusion.
- Click on + at the top to create a new VM.
- Select "Install from disc or image". Note: You will need to download the image beforehand. You can download Ubuntu from the provided link.
- Click continue or done multiple times.
A rather simpler way to manage VM Lifecycle is to use Vagrant.
Vagrant
Vagrant is a tool that helps create and manage virtual machines. To some extent, Vagrant is to VMs what Docker is to containers; it simplifies the creation and management of consistent developer environments.
Vagrant uses a configuration file called Vagrantfile, which allows you to define and share VM configurations across teams or systems. This ensures that virtual machines are created consistently across different environments. Vagrant has base images for VM available at the link.
The following steps can be followed to set up VMs using vagrant:
- Install Vagrant. On Mac, the command is
brew install --cask vagrant. - This link can be referenced for other OS.
- Create a Vagrant Project and Initialize, some commands are:
Shell
mkdir iptables-demo cd iptables-demo -- vagrant init will create a vagrant file Vagrant init - Modify the vagrant file to look like below:
Shell
Vagrant.configure("2") do |config| config.vm.define "server" do |server| server.vm.box = "ubuntu/bionic64" server.vm.hostname = "server" server.vm.network "private_network", ip: "192.168.212.134" end config.vm.define "client" do |client| client.vm.box = "ubuntu/bionic64" client.vm.hostname = "client" client.vm.network "private_network", ip: "192.168.212.135" end end - You will also need to install the vagrant vmware desktop plugin and the vagrant vmware utility. The plugin can be installed using
vagrant plugin install vagrant-vmware-desktopand vmware utility can be downloaded from here. - Now run the command
vagrant up --provider=vmware_desktopto create the VMs
Note: I had to give my terminal access to "input monitoring" on Mac, so I recommend running under elevated privilege to catch and fix any permission issues.
Now, that the VMs are up, you should SSH into them either directly using their IP or using vagrant ssh <VMName>.
The Demo
Hopefully, by now you have two VMs like :
- Server with IP address 192.168.212.134
- Client with IP address 192.168.212.135
Note: IP addresses may differ if you used VMware Fusion UI to create the VMs.
Let’s start by examining what already exists in your iptables configuration and try to understand it. The following command will help you view the current rules:
sudo iptables -L -v -n
-L is for Listing the rules
-v is for verbose, to show detail like number of packets
-n show raw ips for source destination instead of using DNS
The output should display the default chains — INPUT, OUTPUT, and FORWARD, along with their associated rules.
Depending on how your VM is set up, you may also see additional chains and rules. For example, in my case, since Docker is installed, there are extra chains and rules related to Docker.
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain FORWARD (policy DROP 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
0 0 DOCKER-USER 0 -- * * 0.0.0.0/0 0.0.0.0/0
0 0 DOCKER-FORWARD 0 -- * * 0.0.0.0/0 0.0.0.0/0
Chain OUTPUT (policy ACCEPT 0 packets, 0 bytes)
pkts bytes target prot opt in out source destination
Chain DOCKER (1 references)
pkts bytes target prot opt in out source destination
0 0 DROP 0 -- !docker0 docker0 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-BRIDGE (1 references)
pkts bytes target prot opt in out source destination
0 0 DOCKER 0 -- * docker0 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-CT (1 references)
pkts bytes target prot opt in out source destination
0 0 ACCEPT 0 -- * docker0 0.0.0.0/0 0.0.0.0/0 ctstate RELATED,ESTABLISHED
Chain DOCKER-FORWARD (1 references)
pkts bytes target prot opt in out source destination
0 0 DOCKER-CT 0 -- * * 0.0.0.0/0 0.0.0.0/0
0 0 DOCKER-ISOLATION-STAGE-1 0 -- * * 0.0.0.0/0 0.0.0.0/0
0 0 DOCKER-BRIDGE 0 -- * * 0.0.0.0/0 0.0.0.0/0
0 0 ACCEPT 0 -- docker0 * 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-ISOLATION-STAGE-1 (1 references)
pkts bytes target prot opt in out source destination
0 0 DOCKER-ISOLATION-STAGE-2 0 -- docker0 !docker0 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-ISOLATION-STAGE-2 (1 references)
pkts bytes target prot opt in out source destination
0 0 DROP 0 -- * docker0 0.0.0.0/0 0.0.0.0/0
Chain DOCKER-USER (1 references)
pkts bytes target prot opt in out source destination
0 0 RETURN 0 -- * * 0.0.0.0/0 0.0.0.0/0
Let's unpack this:
Chain INPUT (policy ACCEPT 0 packets, 0 bytes)
This is the built-in INPUT chain, responsible for handling packets destined for the local system (i.e., the server or the machine you're currently on).
The policy is set to ACCEPT, meaning any packet that doesn’t match a rule in this chain will be accepted by default.
Currently, there are no rules in the INPUT chain, so all incoming packets will be accepted. We’ll modify this behavior shortly.
Here’s a breakdown of the columns you’ll see:
- pkts – Number of packets that matched this group of rules. Since there are no rules yet, this is currently 0
- bytes – Total number of bytes from the matched packets
- target – The action taken if the rule matches (e.g
ACCEPT,DROP, or jump to another chain). - prot – The protocol for the rule, example ICMP. 0 means all protocols
- opt – IP options. A dash (
–) means no special options. An example of a special option could be matching only TCP SYN packets. In most cases, you'll just see–here. - in – Input interface (example
eth0, * means any ) - out – output interface (* means any)
- source – The source IP address or range
- destination – The destination IP address or range
In my setup, I have a chain created by Docker, for its network to work properly within the defined rules — topic for the other day.
With this setup, let's also see how ping works from the client, run the following command from the client VM:
ping 192.168.212.134
PING 192.168.212.134 (192.168.212.134) 56(84) bytes of data.
64 bytes from 192.168.212.134: icmp_seq=1 ttl=64 time=1.42 ms
64 bytes from 192.168.212.134: icmp_seq=2 ttl=64 time=0.582 ms
64 bytes from 192.168.212.134: icmp_seq=3 ttl=64 time=0.974 ms
^C
--- 192.168.212.134 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2037ms
rtt min/avg/max/mdev = 0.582/0.992/1.421/0.342 ms
Note that all packets were sent and received just fine.
Configure a Rule for the DROP Packet
With an understanding of the baseline status of iptables, let's play with it and add some rules.
The first rule we will add is to drop ping (ICMP) packets from our client, remember, previously ping was allowed without any issues. The following command will do that:
sudo iptables -A INPUT -p icmp --icmp-type echo-request -s 192.168.212.135 -j DROP
-A is used to append to input chain
-p is the protocol, ICMP in our case
–icmp-type echo-request is an extension of ICMP match extension.
-s is the source ip address, our client vm
-j is the target action, DROP in our case.
After making the change, you could see the iptables status using sudo iptables -L -v -n
You will see new rule show up in iptables, now lets run ping again and see what happens.
-- on server vm
sudo iptables -L -v -n
--output
Chain INPUT (policy ACCEPT 618 packets, 43156 bytes)
pkts bytes target prot opt in out source destination
14 1176 DROP 1 -- * * 192.168.212.135 0.0.0.0/0 icmptype 8
-- on client vm
ping 192.168.212.134
PING 192.168.212.134 (192.168.212.134) 56(84) bytes of data.
You’ll notice that the terminal on the client side appears to hang. The reason is quite interesting: we used the DROP target, which silently discards the packet without sending any response back to the client. As a result, the client continues waiting for a reply and, depending on its configuration, may or may not eventually timeout. If you terminate the terminal session on the client, you’ll see 100% packet loss.
Another interesting observation: If you run tcpdump on the server, you won’t see the dropped packets. So, what’s happening here? Based on what we’ve learned so far, the packets should have been dropped.
In reality, tcpdump operates at the Data Link layer or IP layer, before iptables applies its rules. As long as packets arrive at your network interface card (NIC), tcpdump will capture and display them even if iptables later drops those packets. Tcpdump doesn’t have any visibility into the filtering decisions made by iptables.
It’s fun to see how these different layers interact, right?
Below is my tcpdump result while the client's ping request was hung.
sudo tcpdump -i ens160 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on ens160, link-type EN10MB (Ethernet), snapshot length 262144 bytes
22:10:09.944514 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 596, length 64
22:10:10.968081 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 597, length 64
22:10:11.993027 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 598, length 64
22:10:13.016252 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 599, length 64
22:10:14.040524 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 600, length 64
22:10:15.065030 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 601, length 64
22:10:16.088700 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 602, length 64
22:10:17.113139 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 603, length 64
22:10:18.137066 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 604, length 64
22:10:19.161158 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 605, length 64
22:10:20.184311 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 606, length 64
22:10:21.208336 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 607, length 64
22:10:22.232126 IP 192.168.212.135 > rameshvmserver: ICMP echo request, id 3119, seq 608, length 64
13 packets captured
13 packets received by filter
0 packets dropped by kernel
Configure a Rule for the REJECT Packet
The command below will add a rule to reject a ping request from the client:
sudo iptables -A INPUT -p icmp --icmp-type echo-request -s 192.168.212.135 -j REJECT
If you add a REJECT rule without deleting the existing DROP rule, you won’t see packets being rejected. This is because iptables applies rules in order, and the first matching rule’s action is taken.
The commands below will help you delete the rules.
First, let's find out the line numbers for the rules.
sudo iptables -L INPUT --line-numbers
-- my results are
Chain INPUT (policy ACCEPT)
num target prot opt source destination
1 DROP icmp -- 192.168.212.135 anywhere icmp echo-request
2 REJECT icmp -- 192.168.212.135 anywhere icmp echo-request reject-with icmp-port-unreachable
Delete the DROP rule.
sudo iptables -D INPUT 1
Now, if you see the list again, the drop rule will be gone, and the ping experience on the client side will be:
ping 192.168.212.134
PING 192.168.212.134 (192.168.212.134) 56(84) bytes of data.
From 192.168.212.134 icmp_seq=1 Destination Port Unreachable
From 192.168.212.134 icmp_seq=2 Destination Port Unreachable
From 192.168.212.134 icmp_seq=3 Destination Port Unreachable
From 192.168.212.134 icmp_seq=4 Destination Port Unreachable
From 192.168.212.134 icmp_seq=5 Destination Port Unreachable
From 192.168.212.134 icmp_seq=6 Destination Port Unreachable
Custom Chain
Let's have more fun. We will create a custom chain to log ping packets from the client and accept them. Before that, though, don't forget to delete existing rules.
Command to create a custom chain:
sudo iptables -N CUSTOM_LOG_AND_DROP_ICMP
Let’s add multiple rules to the chain, one for logging, the other for dropping ICMP packets:
sudo iptables -A CUSTOM_LOG_AND_DROP_ICMP -j LOG --log-prefix "ICMP_DROP: " --log-level 4
sudo iptables -A CUSTOM_LOG_AND_DROP_ICMP -j DROP
A few things to note:
- log-prefix – This is for convenience, so you could look up (grep) the logs.
- log-level – There are various log levels. 4 corresponds to a warning.
The following command will add a rule in the INPUT chain to jump to our custom chain.
sudo iptables -A INPUT -p icmp --icmp-type echo-request -s 192.168.212.135 -j CUSTOM_LOG_AND_DROP_ICMP
Now, if we ping again, we will get packet drops, and we will also see the logs. The following commands can be used to see the logs:
sudo dmesg | grep ICMP_DROP
-- output
[22328.664960] ICMP_DROP: IN=ens160 OUT= MAC=00:0c:29:40:3c:6e:00:0c:29:c0:00:27:08:00 SRC=192.168.212.135 DST=192.168.212.134 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=17928 DF PROTO=ICMP TYPE=8 CODE=0 ID=3231 SEQ=1
[22329.674403] ICMP_DROP: IN=ens160 OUT= MAC=00:0c:29:40:3c:6e:00:0c:29:c0:00:27:08:00 SRC=192.168.212.135 DST=192.168.212.134 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=18496 DF PROTO=ICMP TYPE=8 CODE=0 ID=3231 SEQ=2
[22330.699224] ICMP_DROP: IN=ens160 OUT= MAC=00:0c:29:40:3c:6e:00:0c:29:c0:00:27:08:00 SRC=192.168.212.135 DST=192.168.212.134 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=18551 DF PROTO=ICMP TYPE=8 CODE=0 ID=3231 SEQ=3
sudo tail -f /var/log/kern.log
--output
2025-09-13T22:53:32.618844+00:00 rameshvmserver kernel: ICMP_DROP: IN=ens160 OUT= MAC=00:0c:29:40:3c:6e:00:0c:29:c0:00:27:08:00 SRC=192.168.212.135 DST=192.168.212.134 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=22971 DF PROTO=ICMP TYPE=8 CODE=0 ID=3231 SEQ=13
2025-09-13T22:53:33.642762+00:00 rameshvmserver kernel: ICMP_DROP: IN=ens160 OUT= MAC=00:0c:29:40:3c:6e:00:0c:29:c0:00:27:08:00 SRC=192.168.212.135 DST=192.168.212.134 LEN=84 TOS=0x00 PREC=0x00 TTL=64 ID=23438 DF PROTO=ICMP TYPE=8 CODE=0 ID=3231 SEQ=14
Conclusion
IPtables is a well-thought-through and designed technology; it is at the foundation of many software-based firewalls and routers , and it is heavily used by cloud platforms and container technologies like Docker. Having said that, no technology is perfect, at this point, iptables is almost considered a legacy, and newer technologies like nftables, eBPF are replacing it. Next time, we will dive into the details for one of those.
Opinions expressed by DZone contributors are their own.
Comments