β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-3268

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3268 Β· 8 files
FileTypeDescriptionSize
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.)

VERDICT.md
↓ download raw

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:209 kmalloc_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 0 makes 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_testable
baseline reproduced→ patch + rebuild →patched clean

fix authored (fix.diff) but not built/validated on the guest within budget

fix.diff
↓ fix.diffper-fix-DF-3268

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.