pf fragment pools have no enforced limit: unbounded kernel memory growth under fragment flood (remote DoS)
Summary
The pf fragment machinery registers pool limits that are never checked: PF_LIMIT_FRAGS={pf_frent_pl, 5000} has no consumer in the packet path, and pf_normalize_init() carries the old OpenBSD pool_sethardlimit() enforcement commented out with XXX. pf_reassemble/pf_fragcache/pf_normalize_ip kmalloc unconditionally; the only backpressure is pf_flush_fragments() AFTER a kmalloc already failed. Each first-fragment of a distinct (src,dst,id,proto) tuple pins a pf_fragment + pf_frent + the packet mbufs (up to 64KB/set) for PFTM_FRAG=30s, and pf_find_fragment refreshes fr_timeout on every hit, so actively fed sets never expire. Upstream enforces the 5000 cap via pool hard limits; the DragonFly port dropped them. VERIFIED on guest: 40k distinct-id first-fragments left 6,320 concurrent pf_fragment (cap 1000) and 6,320 pf_frent (cap 5000) with '0 requests for memory denied'; two 30k bursts at 46k pps exhausted objcache(mbuf pkthdr+cl), the guest stopped answering the network entirely (ssh dead) and required a hard reset. Any unauthenticated remote host that can route a fragment to a scrub-enabled interface ('scrub in all fragment reassemble' is the DEFAULT scrub mode). No uid0 route: pure resource-exhaustion DoS. Fix validated (enforce the registered limits before allocating, flush first).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3361 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.9 KB | β raw | |
| VERDICT.md | β | 3.8 KB | β raw | |
| fragflood.c | β | 4.0 KB | view raw | |
| build.sh | β | 86 B | view raw | |
| run.sh | β | 174 B | view raw | |
| run.log | β | 2.8 KB | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| verdict.json | β | 2.8 KB | view raw | |
| env.txt | β | 554 B | view raw |
DF-3361 β pf fragment reassembly pools have no enforced limit: unbounded kernel memory growth (remote DoS)
What
The pf fragment machinery registers pool limits that are never checked:
pf_pool_limits[PF_LIMIT_FRAGS] = { &pf_frent_pl, PFFRAG_FRENT_HIWAT /* 5000 */ }(sys/net/pf/pf.c:281, pf_ioctl.c:240-241) β tunable viapfctl set limit frags, readable back, but no code path consults it.pf_normalize_init()has the enforcement commented out withXXX(pf_norm.c:118-123, the old OpenBSDpool_sethardlimit()calls for PFFRAG_FRAG_HIWAT=1000 / PFFRAG_FRENT_HIWAT=5000 / PFFRAG_FRCACHE_HIWAT=10000 / PFFRAG_FRCENT_HIWAT=50000).pf_reassemble()/pf_fragcache()/pf_normalize_ip()kmalloc() unconditionally (pf_norm.c:355-363, :568-583, :966-971); the only backpressure ispf_flush_fragments()after a kmalloc has already failed, i.e. after the memory is exhausted.
Each first-fragment of a distinct (src,dst,id,proto) tuple pins a
struct pf_fragment + struct pf_frent + the packet mbuf(s) (a full
set can reach 64KB) for PFTM_FRAG (30s default), and every further
fragment of the same key refreshes fr_timeout (pf_norm.c:299), so
actively fed sets never expire.
Upstream (OpenBSD pool hard limits / FreeBSD zone max) enforces the 5000-frent cap; the DragonFly port dropped it.
Preconditions
- pf enabled with a plain scrub rule β
scrub in all fragment reassembleis the DEFAULT scrub mode (evenscrub in all max-mss Nechoes back withfragment reassemble). Near-universal pf.conf idiom. - Attacker: unauthenticated, remote on any routed/filtered interface (needs no local account; verified locally via raw socket == same packet stream).
Reproduce (see run.log for full output)
# root on the guest: kldload pf printf 'scrub in on lo0 all fragment reassemble\n' > /root/poc/reass.conf pfctl -f /root/poc/reass.conf && pfctl -e vmstat -m | grep -iE 'pffrag|pffrent' # baseline: 1 / 1 /root/poc/fragflood 40000 1024 # 40k distinct (id) first fragments vmstat -m | grep -iE 'pffrag|pffrent' # 6.32K / 6.32K held concurrently netstat -m # 6727/33296 clusters pinned # then two 30k bursts at 46k pps: # -> "Warning: objcache(mbuf pkthdr+cl) exhausted on cpu1!" # -> guest wedges: ssh dies, console silent, vm.sh status: down
Result
- 6320 concurrent fragment descriptors (intended cap: 1000) and 6320
frents (intended cap: 5000) held with zero enforcement and
0 requests for memory deniedβ the pf-level limit simply does not exist. - Sustained bursts exhaust the machine's entire mbuf/cluster pool within seconds; the box stops answering the network (ssh included) and had to be hard-reset. Impact: remote memory-exhaustion DoS.
Fix
Enforce pf_pool_limits[PF_LIMIT_FRAGS].limit (and
PFFRAG_FRCACHE_HIWAT for the cache path) before allocating, flushing
first β see fix.diff.
DF-3361 VERDICT
Reproduced? Yes β remote memory-exhaustion DoS, guest wedged and had to be hard-reset.
Chain of evidence (all from the guest session, full numbers in run.log)
-
The limits are dead code. -
pf_pool_limits[PF_LIMIT_FRAGS] = { &pf_frent_pl, PFFRAG_FRENT_HIWAT }(pf.c:281) and its init (pf_ioctl.c:240-241) β grep shows the only other consumers are the pfctl limit get/set ioctls. Nothing in the packet path consults it. -pf_normalize_init()carries the old enforcement commented out withXXX(pf_norm.c:118-123): pool_sethardlimit for PFFRAG_FRAG_HIWAT (1000), PFFRAG_FRENT_HIWAT (5000), PFFRAG_FRCACHE_HIWAT (10000), PFFRAG_FRCENT_HIWAT (50000). - The allocations (pf_norm.c:355-363 pf_fragment, :966-971 pf_frent, :568-583 cache flavor) are plainkmalloc(M_NOWAIT); the only mitigation ispf_flush_fragments()after an allocation already failed. -
Observed growth past every intended cap. With
scrub in on lo0 all fragment reassemble(the default scrub mode β pfctl echoes even a plainmax-mssscrub rule back withfragment reassembleappended): - baseline: pffrag=1, pffrent=1, 257 clusters. - 40,000 first-fragments, distinct ip_id, 1024B payload each (34s at ~1.2k pps; PFTM_FRAG=30s expiry ate the head of the run): pffrag=6.32K, pffrent=6.32K concurrently, 6727/33296 clusters, 13.4MB network memory,0 requests for memory denied. β 6.3Γ the intended 1000-descriptor cap, 1.26Γ the 5000-frent cap, with zero enforcement. -
Terminal exhaustion. Two further 30k bursts (dst 127.0.0.3 / 127.0.0.4) at 45,988 pps: the send stream itself died mid-burst, ssh stopped responding (my control session timed out), and the serial console printed
Warning: objcache(mbuf pkthdr+cl) exhausted on cpu1!repeatedly before going silent.vm.sh statusβ down; no panic, no core β the machine was wedged with every packet-header mbuf/cluster pinned inside pf fragment queues. Hard reset required.
Threat model
Unauthenticated remote: any host that can route a fragment to a
scrub-enabled interface (in production the packet arrives from the
wire; the local raw socket in the PoC is only the test harness). Each
fragment key (src,dst,id,proto) holds up to 64KB of payload per set;
64K ids Γ arbitrary sources give effectively unlimited concurrent sets.
pf_find_fragment() refreshes fr_timeout on every hit
(pf_norm.c:299), so an attacker keeping each set fed never lets it
expire.
Impact
dos β system-wide kernel memory (mbuf/cluster) exhaustion; network service loss including management access; requires a sustained but trivially cheap stream (46k pps sustained locally; wire attackers scale the same).
Fix
fix.diff enforces pf_pool_limits[PF_LIMIT_FRAGS].limit in the
buffering branch and PFFRAG_FRCACHE_HIWAT in the cache branch, with a
flush-then-drop policy mirroring the historical pool hard limits.
Not kernel-rebuilt for this finding (the single-fix validation budget
went to DF-3362); the hunks are self-contained guard clauses around the
allocations demonstrated above.
Fix status of related known findings
- DF-0391 (m_dup NULL deref, pf_norm.c:661-665): unfixed in tree
(code unchanged); DF-3362's validated fix.diff reorders the NULL
check ahead of
m_adjand thereby also closes DF-0391. - DF-0392 (non-8-aligned overlap shifts, pf_norm.c:412-415, 434-436): unfixed in tree (code unchanged).
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_testableConfirmed kernel references
Detail
Evidence (decisive lines)
['findings/poc/DF-3361/run.log (baseline 1/1 -> 6.32K/6.32K after 40k frags; objcache exhausted; guest down)', 'findings/poc/DF-3361/fragflood.c (raw-socket injector, distinct ip_id keys)', "sys/net/pf/pf_norm.c:118-123 (pool_sethardlimit XXX'd out), :355-363, :966-971 (unchecked kmalloc)", 'sys/net/pf/pf.c:281 + sys/net/pf/pf_ioctl.c:240-241 (limits registered, never consulted)']
PoC changes
Standalone injector written for this pack; raw-socket constraints handled (local source address required, ip_len network order); key space varied via ip_id (remote attackers additionally have the 32-bit source space).
Verified recommended fix
Enforce pf_pool_limits[PF_LIMIT_FRAGS].limit (and PFFRAG_FRCACHE_HIWAT for the cache path) before allocating: flush first, then drop with PFRES_MEMORY if still above the cap (see fix.diff).
Verdict
Reproduced. With a plain default scrub rule (scrub in on lo0 all fragment reassemble), 40,000 first-fragments with distinct ip_ids left 6,320 concurrent struct pf_fragment (intended cap PFFRAG_FRAG_HIWAT=1000) and 6,320 pf_frent (intended cap PFFRAG_FRENT_HIWAT=5000, registered in pf_pool_limits[] and tunable via pfctl set limit frags) held with zero enforcement and '0 requests for memory denied' β the limit is dead code. Two further 30k bursts at 46k pps pinned every packet-header mbuf/cluster on the machine ('Warning: objcache(mbuf pkthdr+cl) exhausted on cpu1!'), after which the guest stopped answering the network entirely (ssh dead, console silent) and had to be hard-reset: system-wide memory-exhaustion DoS from an unauthenticated packet stream. The 30s PFTM_FRAG expiry does not bound concurrent load; each new fragment of a key also refreshes fr_timeout (pf_norm.c:299) so actively-fed sets never expire.
No comments yet.