Fixing pf State Table Exhaustion on FreeBSD
How to confirm PF state exhaustion, identify the responsible rules and sources, size memory safely, apply limits, and validate a ruleset reload.
When PF cannot create another state, new flows matching stateful rules fail while established flows may continue. That symptom resembles DNS, routing, application backlog, SYN flooding, or ephemeral-port exhaustion, so a high state count alone is not a diagnosis. Confirm the configured limit, insertion/drop counters, per-rule concentration, memory pressure, and traffic pattern before raising anything.
Confirm exhaustion with counters and limits
Capture PF status, global limits, rules, and state count at the same time:
pfctl -si
pfctl -sm
pfctl -vvs rules
pfctl -ss | wc -l
pfctl -sm shows configured memory-related limits, including states. pfctl -si reports current state-table activity and counters. Look for current entries at the limit together with failed state insertions or matching drop behavior during the incident. A table that once peaked near a limit but is now low does not establish why a connection currently fails.
Record pfctl -si twice to obtain counter deltas. Counters are cumulative since PF was enabled or reset, so a nonzero historical value is weaker than an increase aligned with the outage. Also check host memory and PF zones:
vmstat -z | grep -i pf
vmstat -m | grep -i pf
Exact allocator names vary by release. Raising states consumes kernel memory and can move the outage from a bounded PF refusal to system-wide memory pressure. Capacity planning must include states, source nodes, tables, fragments, rules, and the rest of the firewall workload.
Identify the rule, direction, and source pattern
pfctl -vvs rules displays evaluation, packet, byte, and state information per rule. Rules with labels make this much easier:
pass in on $ext_if proto tcp to ($ext_if) port 443 \
flags S/SA keep state label "public_https"
Compare which rules create most states and how quickly. Inspect a bounded sample with pfctl -ss -vv; do not rely on fragile awk parsing that mistakes IPv6 colons, direction arrows, or changing output formats for source addresses. For durable monitoring, use labels and release-supported structured output where available.
Classify the concentration:
- many legitimate clients with normal state lifetimes may indicate undersizing;
- one or a few sources with many half-open connections may indicate abuse or a broken client;
- one internal source opening connections rapidly may indicate an application loop;
- large numbers of long-idle established states may indicate unsuitable timeout policy;
- repeated NAT flows can expose a different resource limit even before global state exhaustion.
Capture traffic and application metrics before blocking a source. A reverse proxy, load balancer, or carrier-grade NAT can make thousands of legitimate users appear under one address.
Raise the global limit only after sizing memory
The syntax is straightforward:
set limit states 100000
The number is illustrative, not a recommended universal value. Derive it from measured peak concurrency, growth, acceptable headroom, kernel memory, and failure policy. Confirm that the live limit after reload matches the intended file.
Always validate syntax first:
pfctl -nf /etc/pf.conf
pfctl -f /etc/pf.conf
pfctl -sm
A normal ruleset reload preserves existing states; commands that explicitly flush states do not. Perform remote firewall changes with console access or a tested rollback mechanism because a syntactically valid rule can still block management traffic.
Increasing the limit restores capacity but does not contain a state-creation attack. If growth immediately consumes the new headroom, the firewall has only delayed recurrence and increased memory exposure.
Apply per-rule and per-source controls at the edge
PF supports rule options such as max, max-src-states, max-src-conn, and max-src-conn-rate, with source tracking. These controls should reflect application behavior:
table <abusive> persist
pass in on $ext_if proto tcp to ($ext_if) port 443 \
flags S/SA keep state \
(max 40000, max-src-conn-rate 200/10, \
overload <abusive> flush global) \
label "public_https"
This is a conceptual example and can block legitimate clients behind NAT. flush global is intentionally aggressive: when a source exceeds the criterion it can flush that source’s states across rules. Test thresholds using observed distributions, document exceptions, and monitor the overload table.
max-src-states bounds states attributed to a source, while max-src-conn counts established TCP connections and requires source tracking. Connection-rate controls address bursts, not long-lived legitimate concurrency. Read the matching pf.conf(5) semantics before combining them; similar names protect different resources.
Use SYN-proxy or syncookie-related capabilities only where supported and appropriate to the FreeBSD release and traffic. They change handshake behavior and do not replace upstream DDoS capacity when the link itself is saturated.
Tune timeouts from observed state classes
PF removes states according to protocol and state-specific timeouts. Lowering tcp.established from its default to one hour can terminate legitimate idle SSH, database, or long-poll connections. Before changing it, count state classes and consult application keepalive policy.
Adaptive timeouts let PF shorten expirations as the table moves between configured occupancy thresholds. That can degrade more gracefully than one globally short established timeout, but it still trades connection continuity for capacity. Set adaptive start/end below the hard limit with enough room for cleanup, and load-test the behavior.
Shorter closing or handshake timeouts may help a SYN-heavy workload, while they do little for genuinely established sessions. UDP “connections” are stateful observations with their own lifetimes. Tune the class actually consuming the table.
Verify recovery and monitor recurrence
After reloading, confirm new connections establish, failed-insertion counters stop growing, state count remains below the new limit, and kernel memory is healthy. Verify critical protocols in both directions and ensure existing sessions survived the reload.
Add alerts on percentage of the configured limit, insertion failures, state-creation rate, source-node usage, PF allocator failures, and overload-table growth. Graph both count and churn: a stable 60,000 states and a workload creating 60,000 per second have different risks.
Finally, preserve a short incident timeline with traffic changes, top rules, sources, old/new limits, memory measurements, and timeout policy. PF state exhaustion is rarely solved by one larger integer. A durable repair combines enough global capacity with rule-level containment, realistic timeouts, application awareness, and evidence-based monitoring.
Related:
- Configuring pf on FreeBSD: A Practical Guide to Packet Filtering
- How to Configure FreeBSD as a Router with pf NAT
Sources: