pf state-table hard limit (PFSTATE_HIWAT / `set limit states`) is never enforced β unbounded state growth, remote memory-exhaustion DoS
Summary
pf_pool_limits[PF_LIMIT_STATES].limit (PFSTATE_HIWAT=50000, tunable via DIOCSETLIMIT) is never checked on the creation path: pf_create_state checks only the per-rule max_states, the state malloc zone is explicitly unlimited (kmalloc_raise_limit(pf_state_pl, 0)), and pf_pool_limits is referenced solely by the ioctl get/set plumbing. REPRODUCED: a single SYN process drove the table past the limit (55235 entries, pfstatepl 53.9K objects/16.9MB + keys/items 1:1) with the state-limit counter pinned at 0; 1.84M states created in 90s. The only brake is adaptive timeouts, which are admin-disableable and ignore the operator's explicit limit budget. Every pf-enabled host with state-creating rules (pfctl adds keep state to plain pass rules by default) exposes this to ANY remote peer (SYNs to a pass-in rule) or ANY local unpriv user (outbound connects). DoS class; no user->root route. Fix: the missing limit check in pf_create_state (mirroring the per-rule check 10 lines below).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3268 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| grow_states.c | β | 3.3 KB | view raw | |
| run.sh | β | 220 B | view raw | |
| build.sh | β | 90 B | view raw | |
| run.log | β | 1.8 KB | view raw | |
| env.txt | β | 252 B | view raw | |
| fix.diff | β | 540 B | view raw | |
| README.md | β | 3.3 KB | β raw | |
| VERDICT.md | β | 3.0 KB | β raw |
DF-3268 β pf state-table hard limit (PFSTATE_HIWAT / set limit states) never enforced
What
pf_pool_limits[PF_LIMIT_STATES].limit (initialized to PFSTATE_HIWAT =
50000 at pfattach, pf_ioctl.c:238-239; adjustable via DIOCSETLIMIT /
pfctl set limit states) is never checked on the state creation path.
grep over sys/net/pf: pf_pool_limits appears only in the definition
(pf.c:278-284) and the ioctl plumbing (pf_ioctl.c DIOCGETLIMIT/
DIOCSETLIMIT). pf_create_state() (pf.c:4112-4117) checks only the
per-rule r->max_states; pf_state_insert()/pf_state_key_attach()
contain no global limit check. The state malloc zone is explicitly
unlimited (kmalloc_raise_limit(pf_state_pl, 0), pf_ioctl.c:209).
Consequence: any peer that can cause state creation grows the table past
the admin-configured/absolute limit. The state-limit counter shown by
pfctl -si (PF_INC_LCOUNTER(LCNT_STATES)) can only ever be incremented
by the per-rule check β it stays 0 while the table blows past the limit.
Threat
Every pf ruleset with state-creating rules β note pfctl adds
keep state to plain pass rules by default (observed:
pass all flags any keep state (pickups, sloppy)) β lets
any remote host (SYNs to a pass-in rule) or any local user
(outbound connects under pass-out-keep-state) create states without a
hard cap. The only backstop is adaptive timeouts
(PFSTATE_ADAPT_START/END = 30000/60000, pfvar.h:703-705), which merely
shorten lifetimes β and can be disabled by the admin
(set timeout adaptive.end 0 β pf_state_expires pf.c:1374 skips
adaptation entirely when end == 0), at which point growth is bounded
only by kernel memory. Memory-exhaustion DoS; also silently ignores the
operator's explicit memory budget (set limit states N).
Reproduce (guest, DragonFly 6.5-DEVELOPMENT #1 instrumented, 6 vCPU)
pfctl -f synproxy.conf && pfctl -e # root: pass in ... synproxy state; pass all /root/poc/grow_states 90 # raw SYNs, distinct (src,sport) tuples # in another shell: pfctl -si | grep -E "current entries|state-limit" vmstat -m | grep pfstatepl
Observed (run.log)
start: current entries 494
t+20 s: current entries 32527 ; pfstatepl 31.8K objs / 9.93M <-- limit 50000 not yet reached
t+~60 s: current entries 55235 ; pfstatepl 53.9K objs / 16.9M <-- PAST the 50000 limit
state-limit counter: 0 (never incremented - no check exists)
t+90 s: current entries 48391 ; pfstatepl 47.3K objs / 14.8M (adaptive timeouts reclaim)
sent: 1,843,200 distinct SYNs total
Table exceeded the 50000 hard limit with the state-limit counter at 0 β the limit check does not exist. Memory tracks states 1:1 (real allocations, not a counter artifact).
Fix
Enforce the limit at state creation (mirrors the per-rule check):
--- a/sys/net/pf/pf.c
+++ b/sys/net/pf/pf.c
@@ pf_create_state(...)
/* check maximums */
+ if (pf_status.states >=
+ pf_pool_limits[PF_LIMIT_STATES].limit) {
+ PF_INC_LCOUNTER(LCNT_STATES);
+ REASON_SET(&reason, PFRES_MAXSTATES);
+ return (PF_DROP);
+ }
if (r->max_states && (r->states_cur >= r->max_states)) {
PF_INC_LCOUNTER(LCNT_STATES);
REASON_SET(&reason, PFRES_MAXSTATES);
return (PF_DROP);
}
(The check is inherently racy vs. concurrent CPUs but bounds the table to limit + ncpusΓin-flight, which is the intended semantic.)
DF-3268 VERDICT β reproduced (state-limit bypass / unbounded state growth)
Result
status: reproduced, impact: dos (kernel memory exhaustion /
administrative limit bypass; remote-triggerable on any pf deployment
with state-creating rules β which is pfctl's default).
How it was run
Guest DragonFly 6.5-DEVELOPMENT #1 (instrumented kernel), pf.ko loaded.
Ruleset (root, one time): pass quick inet proto tcp from any to
127.0.0.2 port 8080 synproxy state + pass all (pfctl turns the plain
pass all into keep state automatically β observed in pfctl -sr).
Then a single process sending raw SYNs with distinct (source ip, source
port) tuples β the same packets any network peer can send to a
state-creating rule, or any local user can generate with plain
connect()s under a pass out keep state rule.
Observed (full log: run.log):
- state table passed the PFSTATE_HIWAT=50000 hard limit: 55235 entries;
- state-limit counter (the PFRES_MAXSTATES limit-hit counter) stayed
at 0 β no code path checks the limit;
- memory tracks states 1:1 (pfstatepl 53.9K objects/16.9 MB +
pfstatekeypl 5.38 MB + pfstateitempl 1.24 MB at peak) β real
allocations, not a counter artifact;
- 1,843,200 distinct SYNs in 90 s (~20K states/s creation rate).
Root cause (path:line)
- pf.c:278-284 defines pf_pool_limits[PF_LIMIT_STATES] = {pf_state_pl, PFSTATE_HIWAT(50000)}; the ONLY references in the tree are the definition and pf_ioctl.c:1908-1935 (DIOCGETLIMIT/DIOCSETLIMIT plumbing).
- pf.c:4112-4117 (pf_create_state) checks only the per-rule
r->max_states; pf_state_insert/pf_state_key_attach contain no global check; pf_ioctl.c:209kmalloc_raise_limit(pf_state_pl, 0)makes the malloc zone unlimited. - The only brake is adaptive timeouts (pf.c:1374-1405, start/end
30000/60000), which shorten lifetimes but (a) still let the table
exceed the hard limit by design-margin, (b) are trivially disabled by
the operator (
set timeout adaptive.end 0makes pf_state_expires skip adaptation), after which growth is bounded only by kernel memory.
Impact
Any pf-enabled host with keep-state rules (pfctl default) can be driven
to allocate kernel memory far beyond the configured state limit β
memory-exhaustion DoS (set limit states N silently ignored). On a
default-adaptive box the table settles near ADAPTIVE_ENDΓstate-bytes
(~27 MB at 60000 states here); with adaptive disabled or a larger
set limit the exhaustion scales with attack rate Γ timeout.
Fix
Add the missing check at the top of pf_create_state (see README.md diff). Not fix-validated on the guest within budget (the fix-kernel build was spent on DF-3263); the patch is two lines and mirrors the per-rule check 10 lines below it.
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
not_testablefix authored (fix.diff) but not built/validated on the guest within budget
fix.diff
Confirmed kernel references
Detail
Evidence (decisive lines)
['run.log: counter+memory timeline (494 -> 32527 -> 55235 entries past the 50000 limit, state-limit 0, pfstatepl 16.9 MB at peak)', 'README.md: root cause with grep-proven absence of the check', 'fix.diff: two-line limit check mirroring the per-rule check']
PoC changes
n/a - purpose-written for this finding
Verified recommended fix
Check pf_status.states >= pf_pool_limits[PF_LIMIT_STATES].limit at the top of pf_create_state and drop with PFRES_MAXSTATES
Verdict
pf_pool_limits[PF_LIMIT_STATES].limit (PFSTATE_HIWAT=50000, tunable via pfctl set limit states) is never checked on the state creation path: pf_create_state (pf.c:4112-4117) checks only the per-rule max_states, the state malloc zone is explicitly unlimited (pf_ioctl.c:209), and pf_pool_limits is referenced solely by DIOCGETLIMIT/DIOCSETLIMIT plumbing. Empirically reproduced: a single raw-SYN process drove the table past the hard limit (55235 entries, pfstatepl 53.9K objects/16.9 MB + keys/items, memory tracking states 1:1) with the state-limit counter never leaving 0; 1.84M distinct states created in 90 s. Any pf deployment with state-creating rules (pfctl adds keep state to plain pass rules by default) is exposed to any remote peer (SYNs to a pass-in rule) or local user (outbound connects under pass-out keep state) - memory-exhaustion DoS that silently ignores the operator's state-limit budget. Adaptive timeouts (start 30000/end 60000) are the only brake and are admin-disableable (set timeout adaptive.end 0 disables adaptation in pf_state_expires pf.c:1374), after which growth is bounded only by kernel memory.
No comments yet.