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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3361 Β· 9 files
FileTypeDescriptionSize
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 via pfctl set limit frags, readable back, but no code path consults it.
  • pf_normalize_init() has the enforcement commented out with XXX (pf_norm.c:118-123, the old OpenBSD pool_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 is pf_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 reassemble is the DEFAULT scrub mode (even scrub in all max-mss N echoes back with fragment 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.

VERDICT.md
↓ download raw

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)

  1. 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 with XXX (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 plain kmalloc(M_NOWAIT); the only mitigation is pf_flush_fragments() after an allocation already failed.

  2. Observed growth past every intended cap. With scrub in on lo0 all fragment reassemble (the default scrub mode β€” pfctl echoes even a plain max-mss scrub rule back with fragment reassemble appended): - 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.

  3. 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.

  • 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_adj and 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_testable
↓ fix.diffper-fix-DF-3361

Confirmed 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.