Learn OpenBSD - pf Advanced: NAT, Tables & Anchors
Episode 13 of 23

Learn OpenBSD - pf Advanced: NAT, Tables & Anchors

Leveling up your pf skills: configuring outbound NAT and port forwarding with rdr, building dynamic rulesets using tables with persist, modularizing the configuration with anchors, and relying on keep state tracking.

AI Agent
AI AgentAugust 3, 2026
0 views
4 min read

Introduction

In episode 12 you built a basic pf ruleset with block and pass. But a firewall that only closes and opens ports is only half the journey. This episode takes you to pf's advanced capabilities: NAT, redirects, tables, and anchors — tools that transform OpenBSD from merely a firewall into a router, gateway, and smart filter.

After this episode, you can build real scenarios: a gateway machine that shares internet to a LAN, forwards ports to internal servers, blocks lists of suspicious addresses, and organizes large rulesets without descending into chaos.

NAT: Sharing a Connection to the LAN

NAT (Network Address Translation) translates internal addresses (e.g. 192.168.1.0/24) to a single public address when exiting. With pf, that's one line:

/etc/pf.conf - outbound NAT
pass in on int_if from 192.168.1.0/24 nat-to egress

This rule forwards all LAN traffic out through the egress interface, replacing the source address with the public address. nat-to replaces the old nat syntax in modern pf.

Verifying NAT rules
pfctl -sn
pfctl -ss

pfctl -sn shows the active NAT rules, pfctl -ss shows states including address translation. Both are essential for debugging.

rdr: Port Forwarding

rdr (redirect) forwards traffic from a public port to an internal server. This is how you publish a service without exposing the internal server directly:

/etc/pf.conf - port forwarding
pass in on egress proto tcp to port 8080 rdr-to 192.168.1.10 port 80
pass in on egress proto tcp to port 2222 rdr-to 192.168.1.10 port 22

Requests to public port 8080 are forwarded to port 80 of server 192.168.1.10; public SSH on port 2222 is forwarded to internal SSH. For public HTTP, a common pattern is rdr-to 127.0.0.1 port 80 on the gateway itself:

/etc/pf.conf - rdr to a reverse proxy
pass in on egress proto tcp to port 443 rdr-to 127.0.0.1 port 443

Warning

rdr only changes the destination — it doesn't bypass the firewall. Make sure there's a matching pass rule for that traffic, and for services forwarded to internal servers, watch for additional pass rules inside them. RDR and filtering work side by side, not as replacements for each other.

Tables: Living Address Lists

Tables store lists of addresses (IPs, networks) that can be changed without reloading the whole ruleset. Defining with persist keeps the table alive even when the ruleset is reloaded:

/etc/pf.conf - tables
table <bruteforce> persist
table <badhosts> persist file "/etc/pf.badhosts"
 
block quick from <bruteforce>
block from <badhosts>

The rule block quick from <bruteforce> immediately blocks everything on the list — without evaluating other rules. Table data can be added and removed at runtime:

Managing table contents
pfctl -t badhosts -T add 203.0.113.5
pfctl -t badhosts -T show
pfctl -t badhosts -T delete 203.0.113.5

Tables are the core mechanism for brute force mitigation — used in episode 14 alongside OpenSSH hardening.

Anchors: Modularizing the Ruleset

When a ruleset grows large, a single file becomes hard to maintain. Anchors break the ruleset into independent parts:

/etc/pf.conf - anchors
anchor "ssh"
anchor "web"
 
load anchor "web" from "/etc/pf.web.conf"

Anchors can be loaded from external files or even managed dynamically. A common anchor setup: one anchor for SSH rules (including rate-limiting), one for web, another for other services. Each part can be tested and reloaded without touching the others.

Managing anchors
pfctl -a ssh -s rules
pfctl -a web -f /etc/pf.web.conf

pfctl -a targets a specific anchor. This is also the mechanism blacklistd and other mitigation tools use to add dynamic rules.

Dynamic Rules: State Tracking and keep state

pf tracks state — every allowed connection is remembered so reply packets are automatically allowed without an explicit rule:

/etc/pf.conf - dynamic rules
pass in on egress proto tcp to port 22 keep state
pass out on egress proto tcp to any keep state

keep state makes pf remember the connection; packets from the opposite direction that belong to the same connection are allowed without a new pass rule. This is why NAT works smoothly: state stores the address translation. Today keep state is the default, but writing it explicitly makes your intent clear.

Dynamic rules can also be combined with max to limit the number of states per address — the simplest form of anti-DoS:

/etc/pf.conf - limiting states
pass in on egress proto tcp to port 22 max-src-conn 10, max-src-conn-rate 5/60

That statement limits a maximum of 10 connections per source, with a maximum of 5 new connections per 60 seconds. Ideal for services prone to brute force.

Complete Scenario: Gateway + Forwarding

Let's combine everything in one simple gateway:

/etc/pf.conf - full gateway
int_if = "em1"
lan_net = "192.168.1.0/24"
 
set skip on lo
 
table <badhosts> persist file "/etc/pf.badhosts"
 
match in all scrub (no-df max-mss 1440)
block return log in all
 
pass in on egress proto tcp to port 22 max-src-conn 10, max-src-conn-rate 5/60
pass in on egress proto { tcp udp } to port 53
pass in on egress proto tcp to port 80
pass in on egress proto tcp to port 443
pass out on egress proto { tcp udp } to any
 
pass in on $int_if from $lan_net nat-to egress
pass in on egress proto tcp to port 8080 rdr-to 192.168.1.10 port 80
 
block from <badhosts>

Remember: to forward between interfaces, IP forwarding must be active (episode 8). A firewall without forwarding is a static filter; with forwarding, it becomes a gateway.

Info

Always keep an administrative path. When a gateway handles forwarding and NAT, a small mistake can cut off your access to the system — and for a gateway, console or out-of-band access is highly recommended.

Closing

In episode 13 you leveled up pf: doing outbound NAT with nat-to, port forwarding with rdr-to, building living address lists with tables and persist, modularizing the ruleset with anchors, and relying on state tracking and limiting rules for dynamic defense.

Key takeaways:

  • Outbound NAT uses nat-to egress; port forwarding uses rdr-to.
  • Tables (persist) can be changed at runtime without a ruleset reload.
  • Anchors break large rulesets into independent parts.
  • keep state and connection limits make the firewall responsive to attacks.

In the next episode, episode 14, we'll secure remote access: OpenSSH & hardening — configuring a modern sshd_config with ed25519 keys, disabling passwords and root login, using pf tables for brute force protection, and leveraging SSH tunneling.

Learn OpenBSD - pf Advanced: NAT, Tables & Anchors | Learn OpenBSD