Search

Sniffing with Net::Cap to stealthily managing iptables rules remotely, Part 1

0 views

Problem Statement and Design Goals

When you are tasked with hardening a Linux server for an environment that demands zero inbound connectivity, yet still needs to allow occasional SSH access, the challenge quickly becomes one of balance. The IT department will happily accept a machine that does not expose any open TCP ports to the public internet, but they will flag anything that looks like an unexpected service or port scanner. The requirement, then, is to keep the default state of the firewall completely closed, but to be able to open a single port – usually 22 for SSH – on a short‑lived basis, only for machines that are trusted.

Traditional approaches to this problem involve a VPN or a static rule set that is manually edited. Those solutions either violate the zero‑open‑port rule or require privileged software on the client side, which may not be available on a legacy Windows box that only carries the default command‑line utilities. The objective here is to devise a mechanism that is both transparent to the IT auditors and minimal on the client side, while still giving the administrator fine‑grained, time‑limited control over inbound traffic.

The strategy that emerged during this project is to treat DNS queries as a covert channel for authorization. DNS is ubiquitous: every machine on the network – Windows, Mac, or Linux – can perform a lookup against a domain name without installing additional tools. By listening for a particular DNS request, the server can decide to temporarily open the SSH port. This approach satisfies the requirement to avoid exposing a new listening port; it also keeps the server free from any extra services that might raise red flags during a network sweep.

From a security perspective, the design hinges on three pillars: (1) a default deny policy that blocks all inbound TCP connections; (2) a lightweight, non‑privileged listener that watches for a specific DNS query; and (3) a root‑level daemon that reacts to the listener's output by inserting a timed rule into iptables. The listener runs with the least privileges necessary, which reduces the blast radius if the code is compromised. The root daemon receives only a structured stream of events and performs the rule insertion in a controlled, auditable manner.

Below, the implementation is broken into three parts: setting up the hardening baseline, constructing the DNS‑based trigger, and building a Net::Pcap sniffer that forwards the relevant information to the root daemon. Each section dives into the practical steps and code samples that make the approach work in a real environment.

Hardening the Default Firewall

Before any advanced technique can be deployed, the firewall must be locked down. On a modern Linux distribution, the simplest way to enforce a default deny policy is to use iptables with a DROP rule on the INPUT chain. The command below ensures that no packet that doesn't match an explicit ACCEPT rule reaches the kernel.

Prompt
sudo iptables -P INPUT DROP</p> <p>sudo iptables -P FORWARD DROP</p> <p>sudo iptables -P OUTPUT ACCEPT</p>

With this policy in place, any inbound traffic – whether it be HTTP, SSH, or even a DNS query from a legitimate client – will be blocked by default. However, we still need a way to allow the DNS traffic that will serve as our authorization trigger. The solution is to add a narrow rule that permits only the UDP packets destined for port 53 on the local server’s IP address. The rule must be very specific to avoid leaking any other DNS service or port to the outside world.

Prompt
sudo iptables -A INPUT -p udp --dport 53 -d 10.1.1.1 -j ACCEPT</p>

Here, is the server’s public IP. The rule says: accept any UDP packet whose destination port is 53 and whose destination address matches the server. All other UDP traffic, even if it hits port 53, is dropped. This isolation is critical because it guarantees that only packets aimed explicitly at the server’s DNS port can pass through, while all other inbound traffic remains blocked.

Testing the configuration is straightforward. From a remote client, run:

Prompt
nslookup google.com 10.1.1.1</p>

The query should succeed because it matches the accept rule. Conversely, attempting to establish an SSH session directly to the server will fail, as expected:

Prompt
ssh user@10.1.1.1</p> <p>Connection refused</p>

With the firewall baseline established, the next step is to craft the mechanism that will temporarily lift the block on SSH when a trusted client performs a special DNS query. This will be accomplished by a dedicated sniffer that parses incoming DNS requests and forwards the relevant data to a privileged service that updates the iptables table.

Leveraging DNS for Temporary Access

Choosing DNS as the trigger for dynamic rule insertion offers several advantages. First, it requires no new binaries on the client side – the built‑in nslookup utility on Windows or dig on Unix suffices. Second, the server can be configured to accept only that specific DNS query, effectively turning the request into a simple one‑way handshake. Finally, because the server already has a listener on port 53 for legitimate DNS queries, adding a tiny filter for the special query does not create a new exposed service.

The core idea is to define a magic domain name – for example ssh.allow.example.com. Only a client that knows this exact name and knows the server’s IP will send a query that matches the listener’s filter. The sniffer sees the packet, extracts the source IP, and passes the data to a root‑privileged daemon. The daemon then creates a temporary rule that allows inbound SSH from that IP for a limited period, say five minutes.

In practice, the sniffer can be written in any language that supports libpcap, but Perl’s Net::Pcap module offers a concise interface that bridges the native C library with Perl’s scripting flexibility. The sniffer runs under a non‑root user but attaches to a packet capture handle that was opened by a root process. After the handle is set up, the sniffer drops privileges, ensuring that only the packet data is exposed to the script. The daemon remains the sole process that manipulates the firewall, keeping the attack surface small.

There is a subtle but important security consideration: the DNS query must not reveal any sensitive information. The magic domain name should be random enough that an attacker cannot guess it, yet short enough to be typed manually by an authorized user. Storing the domain name in a configuration file that is read by both the sniffer and the daemon keeps the system flexible; if the domain ever needs to be rotated, a single change propagates to both components.

With the design principles in place, the next section walks through the actual Perl code that captures DNS packets, parses them, and emits a clean, structured output that the daemon can consume. This is the bridge that turns a mundane DNS lookup into a dynamic firewall rule.

Crafting a Net::Pcap Sniffer

The sniffer is built around four core responsibilities: open a packet capture interface, enforce a tight filter for UDP port 53 traffic destined for our IP, drop privileges, and then iterate over captured packets. The Net::Pcap module abstracts most of the low‑level work, allowing us to focus on the packet parsing logic.

Prompt
#!/usr/bin/perl -w</p> <p>use strict;</p> <p>use Net::Pcap;</p> <p>use English qw(-no_match_vars);</p><h1>Configuration</h1> my $MY_IP_ADDRESS = '10.1.1.1';</p> <p>my $UNPRIV = 200; # UID/GID to drop to</p> <p>STDOUT->autoflush(1);</p><h1>Main loop: keep the sniffer alive even if a child dies</h1> while (1) {</p> <p> my $pid = fork;</p> <p> die "Fork failed: $!" unless defined $pid;</p> <p> if ($pid) { # Parent</p> <p> wait;</p> <p> sleep 1;</p> <p> } else { # Child</p> <p> my $pcap = create_pcap</p> <p> or die "Could not create pcap handle";</p> <p> # Drop to unprivileged user</p> <p> $EGID = $UNPRIV;</p> <p> $GID = $UNPRIV;</p> <p> $EUID = $UNPRIV;</p> <p> $UID = $UNPRIV;</p> <p> Net::Pcap::loop($pcap, -1, \&process_pkt, 0);</p> <p> Net::Pcap::close($pcap);</p> <p> exit 0;</p> <p> }</p> <p>}</p> <p>sub create_pcap {</p> <p> my $dev = Net::Pcap::lookupdev(my $err)</p> <p> or die "No device found: $err";</p> <p> my ($net, $mask) = (0, 0);</p> <p> Net::Pcap::lookupnet($dev, $net, $mask, $err)</p> <p> or die "lookupnet error: $err";</p> <p> my $pcap = Net::Pcap::open_live($dev, 135, 0, 0, $err)</p> <p> or die "open_live error: $err";</p> <p> my $filter = "udp dst port 53 and dst host $MY_IP_ADDRESS";</p> <p> my $filter_t;</p> <p> Net::Pcap::compile($pcap, $filter_t, $filter, 1, $net)</p> <p> or die "compile error: $!";</p> <p> Net::Pcap::setfilter($pcap, $filter_t)</p> <p> or die "setfilter error: $!";</p> <p> return $pcap;</p> <p>}</p> <p>sub process_pkt {</p> <p> my ($user_data, $hdr, $pkt) = @_;</p> <p> my $src_ip = 26; # offset of source IP in IP header</p> <p> my $dst_ip = 30; # offset of dest IP</p> <p> my $q_start = 55; # start of the DNS query string</p> <p> my $source = sprintf "%d.%d.%d.%d",</p> <p> ord(substr($pkt, $src_ip, 1)),</p> <p> ord(substr($pkt, $src_ip+1, 1)),</p> <p> ord(substr($pkt, $src_ip+2, 1)),</p> <p> ord(substr($pkt, $src_ip+3, 1));</p> <p> my $dest = sprintf "%d.%d.%d.%d",</p> <p> ord(substr($pkt, $dst_ip, 1)),</p> <p> ord(substr($pkt, $dst_ip+1, 1)),</p> <p> ord(substr($pkt, $dst_ip+2, 1)),</p> <p> ord(substr($pkt, $dst_ip+3, 1));</p> <p> my $query = substr($pkt, $q_start);</p> <p> $query =~ s/0.*//; # strip the trailing zero terminator</p> <p> print "$source -> $dest: $query " if $source && $dest && $query;</p> <p>}</p>

When the script starts, it spawns a child that attaches to the default network device – usually eth0 – and configures a BPF filter that limits captured traffic to UDP packets on port 53 destined for the server’s address. Once the filter is in place, the sniffer drops to a non‑root user before entering the packet loop. This guarantees that the code running in the loop never has the ability to alter firewall rules or perform privileged operations.

The process_pkt callback receives the raw packet payload. By slicing the IP header at fixed offsets, it reconstructs the dotted‑quad source and destination addresses. The DNS query string begins after the UDP header and the DNS header; the code extracts it, trims the terminating zero byte, and prints a human‑readable line containing the source IP, destination IP, and the domain name that was queried.

Only output is written to STDOUT. The child process never interacts with iptables. Instead, the parent, running as root, continuously reads this output. Whenever a line appears, the parent forwards the information to the privileged daemon that will decide whether to open the SSH port for the requesting IP. This separation keeps the packet sniffing logic isolated from firewall manipulation, a key principle for hardening the overall system.

Extending the Architecture: From Capture to Rule Injection

The final piece of the puzzle is the root‑level daemon that consumes the sniffer’s output and updates iptables accordingly. The daemon’s responsibilities are straightforward: parse the incoming lines, validate the source IP against an authorized list, and insert a timed rule that opens port 22 for that IP. After a configurable timeout, the rule is removed automatically.

Below is a skeleton in Perl that demonstrates the essential logic. In a production environment, you would replace the hard‑coded authorization list with a lookup against a secure store or an LDAP server. The daemon also logs all events for audit purposes.

Prompt
#!/usr/bin/perl -w</p> <p>use strict;</p> <p>use Time::HiRes qw(time);</p> <p>use File::Basename;</p><h1>Configuration</h1> my $AUTHORIZED_IPS = { '192.168.0.42' => 1, '10.0.0.7' => 1 };</p> <p>my $SSH_PORT = 22;</p> <p>my $RULE_TIMEOUT = 300; # 5 minutes</p><h1>Maintain a list of active rules: IP => expiry time</h1> my %active_rules;</p> <p>while (my $line = <STDIN>) {</p> <p> chomp $line;</p> <p> my ($src, $dst, $query) = split / -> /, $line, 2;</p> <p> $query =~ s/:.*$//; # strip any port part</p> <p> next unless $AUTHORIZED_IPS->{$src};</p> <p> # Add rule if not already present</p> <p> unless (exists $active_rules{$src}) {</p> <p> system("iptables -A INPUT -p tcp -s $src --dport $SSH_PORT -j ACCEPT");</p> <p> $active_rules{$src} = time + $RULE_TIMEOUT;</p> <p> print <<'END';</p> <p>Rule added: $src -> SSH allowed for 5 minutes</p> <p>END</p> <p> } else {</p> <p> # Reset expiry if already present</p> <p>Rule refreshed: $src -> SSH allowed extended</p> <p>END</p> <p> }</p> <p>}</p><h1>Periodic cleanup</h1> while (1) {</p> <p> sleep 60;</p> <p> my $now = time;</p> <p> foreach my $ip (keys %active_rules) {</p> <p> if ($now > $active_rules{$ip}) {</p> <p> system("iptables -D INPUT -p tcp -s $ip --dport $SSH_PORT -j ACCEPT");</p> <p> delete $active_rules{$ip};</p> <p> print "Rule removed: $ip -> SSH closed ";</p> <p> }</p> <p> }</p> <p>}</p>

The daemon starts by reading lines from STDIN, which are fed by the sniffer’s parent process. Each line contains the source IP, destination IP, and DNS query. The code extracts the source IP and checks it against the authorized list. If the IP is known, the daemon adds an iptables rule that accepts TCP packets on port 22 from that address. The rule is appended to the INPUT chain, ensuring that it takes precedence over the default DROP policy.

To prevent rules from lingering indefinitely, the daemon tracks the expiry time for each active rule. Every minute it scans the table and removes any rule whose timeout has passed. This clean‑up loop guarantees that the SSH port will only be open for the window you define – a critical property when you need to maintain strict access controls.

By keeping the sniffer and rule‑injection logic in separate processes, each with the minimal privileges required for its task, the architecture limits the damage potential of an attacker who manages to compromise one component. If the sniffer is tampered with, it can only produce bogus output; the root daemon still requires a valid IP to add a rule. Conversely, if the daemon is compromised, it cannot listen for DNS packets because the sniffer remains unprivileged. This compartmentalization is a core tenet of secure system design.

Deploying this solution in a production environment involves adding the sniffer script and the daemon to the system’s startup sequence. On a system using systemd, two unit files – one for the sniffer and one for the daemon – can enforce the proper user and group contexts. Logging can be routed to the journal or a dedicated log file for forensic review. With these pieces in place, the server achieves the desired zero‑open‑port posture while retaining the ability to grant temporary SSH access through a covert DNS query.

Suggest a Correction

Found an error or have a suggestion? Let us know and we'll review it.

Share this article

Comments (0)

Please sign in to leave a comment.

No comments yet. Be the first to comment!

Related Articles