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.
These PF behaviors are faithfully simulated:
- Last-match-wins rule evaluation — the core of PF's semantics
quickshort-circuit — terminates evaluation immediately- Anchor ordering — files loaded in declared order, rules evaluated sequentially
- Tag assignment and carry —
match ... tag Xassigns, tags survive NAT,tagged Xmatches on egress - Antispoof expansion — generates the actual block rules PF creates internally, catches bugs like DHCP vs antispoof ordering
rdr-todestination rewriting — rewrites the packet's destination mid-evaluation, re-evaluates egress against the rewritten targetnat-totracking — records that NAT was appliedroute-topolicy routing — verifies packets are directed to the correct next-hopmax-src-connrate 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 resolution —
egressresolves to WAN - Topology auto-detection — builds the interface-to-network map from your macros and match rules
These are known inaccuracies. A packet that passes in the simulator may not pass in real PF, or vice versa.
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.
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.
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.
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.
These are outside the simulator's scope entirely.
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.
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 stateworks. - 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.
pfctl -nfsyntax 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-tois recorded but the simulator can't verify the route exists. urpf-failedandno-routerequire the kernel FIB.
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.
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.
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.
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.
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-toprobability N%on rulesset queueassignmentsreceived-on,max-pkt-rateround-robinon rdr-to poolsset 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.
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 -nffor syntax validationnmap/ 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