Skip to content

Latest commit

 

History

History
162 lines (121 loc) · 6.33 KB

File metadata and controls

162 lines (121 loc) · 6.33 KB

pftest: what it can and can't do

What it does

pftest is a Python simulator that parses your pf.conf and evaluates packets against the ruleset offline. It catches logic bugs before you deploy to hardware.

What it gets right

These PF behaviors are faithfully simulated:

  • Last-match-wins rule evaluation — the core of PF's semantics
  • quick short-circuit — terminates evaluation immediately
  • Anchor ordering — files loaded in declared order, rules evaluated sequentially
  • Tag assignment and carrymatch ... tag X assigns, tags survive NAT, tagged X matches on egress
  • Antispoof expansion — generates the actual block rules PF creates internally, catches bugs like DHCP vs antispoof ordering
  • rdr-to destination rewriting — rewrites the packet's destination mid-evaluation, re-evaluates egress against the rewritten target
  • nat-to tracking — records that NAT was applied
  • route-to policy routing — verifies packets are directed to the correct next-hop
  • max-src-conn rate limits — tracks connections per source, blocks at the threshold
  • Table and macro resolution — recursive macro expansion, table membership checks
  • Interface and address matching — CIDR, negation (!), lists ({ }), table references (<name>)
  • Interface group resolutionegress resolves to WAN
  • Topology auto-detection — builds the interface-to-network map from your macros and match rules

What it gets wrong

These are known inaccuracies. A packet that passes in the simulator may not pass in real PF, or vice versa.

(iface) self-address always matches

Rules like from (igc1) match the router's own IP in real PF. The simulator matches any IP. This means rules using interface self-addresses are more permissive in the simulator than in production. No way to fix this without knowing the router's actual interface IPs.

no-route and urpf-failed are not matched

Real PF evaluates these against the kernel routing table. The simulator skips them. Rules like block drop in quick from no-route are parsed but never match. The simulator is more permissive than real PF for packets with unroutable or spoofed sources.

Scrub is parsed and ignored

match in on $wan scrub (no-df random-id max-mss 1460 reassemble tcp) is recognized but has no effect on evaluation. In real PF, scrub modifies packets — clears the DF flag, clamps MSS, reassembles fragments, randomizes IP IDs. The simulator can't modify packets because there are no real packets to modify.

max-src-conn-rate is not enforced

The N/seconds rate limit is parsed but not simulated. Only the absolute max-src-conn limit is enforced. A source that opens connections slowly enough stays under max-src-conn in the simulator but might be blocked by max-src-conn-rate in real PF.

What it can't do at all

These are outside the simulator's scope entirely.

No real packets

The simulator evaluates a Packet dataclass against parsed Rule objects. There are no TCP handshakes, no sequence numbers, no checksums, no fragmentation, no retransmissions. A test that says "pass" means the ruleset logic allows it, not that the connection would succeed.

No state table

Real PF maintains a state table. Return traffic matches state entries, not rules. The simulator has no state table — it evaluates each packet independently against the ruleset. This means:

  • Return traffic (SYN-ACK, established data) is not simulated. Tests assume keep state works.
  • State timeout and expiration are not modeled. Long-idle connections that would lose state in real PF are not tested.
  • State table exhaustion (hitting the global limit) is not modeled. The simulator will pass packets that real PF would drop under load.

The max-src-conn simulation is a counter, not a real state table. It tracks how many times a source matched a rule, not actual TCP state.

No kernel integration

  • pfctl -nf syntax checking requires real PF (OpenBSD, macOS, FreeBSD). On Linux, the simulator is the only verification.
  • Interface IP addresses are unknown. (igc1) can't resolve to a real IP.
  • The routing table is unknown. route-to is recorded but the simulator can't verify the route exists.
  • urpf-failed and no-route require the kernel FIB.

No layer 2

PF operates at layer 3+. The simulator is the same. ARP, STP, LLDP, same-VLAN attacks, MAC spoofing, 802.1Q tag manipulation — none of these exist in the model.

No application layer

The simulator sees port numbers, not protocols. Port 443 is just the number 443 — it could be HTTPS, C2, a tunnel, or anything else. TLS contents, HTTP headers, DNS query names, MQTT payloads — all invisible.

No concurrency or timing

All evaluation is single-threaded and instantaneous. Race conditions in rule loading, state table contention under high connection rates, and adaptive timeout behavior are not modeled.

No fragmentation or evasion

Overlapping fragments, tiny fragments, TTL-based evasion, TCP segmentation tricks, and RST injection are not modeled. Real PF's scrub and reassemble tcp handle most of these, but the simulator can't verify that.

What the regex parser misses

The rule parser uses regular expressions, not PF's actual grammar. It handles every construct used in the included test configs, but will silently misparse or ignore:

  • Inline anchors: anchor "name" { ... }
  • Filtered anchors: anchor "name" on iface
  • divert-to, binat-to, af-to
  • probability N% on rules
  • set queue assignments
  • received-on, max-pkt-rate
  • round-robin on rdr-to pools
  • set state-defaults
  • Nested macro expansion in some edge cases

If your config uses any of these, the simulator may produce incorrect results without warning.

Always run pfctl -nf on the target system before deploying.

Bottom line

pftest verifies that your ruleset logic does what you think it does: which packets pass, which get blocked, which tags are assigned, which destinations get rewritten. It caught a real DHCP/antispoof ordering bug, a missing quick on a printer exception, and a VPN route-to override — all before deployment.

It does not replace:

  • pfctl -nf for syntax validation
  • nmap / pen testing for real-world verification
  • Suricata / Zeek for application-layer visibility
  • Load testing for state table behavior
  • Physical network auditing for layer 2 security