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

pf_fragcache overlap-chop path: INVARIANTS KASSERT panics on multi-mbuf fragments (m_adj tail-trim leaves zero-length mbufs chained)

Summary

pf_fragcache()'s precut>0 'chop' path duplicates the incoming fragment and tail-trims the duplicate to the IP header with m_adj(*m0, hlen - pkthdr.len). m_adj(mp, negative) zeroes the lengths of trailing mbufs WITHOUT unlinking them, so for any fragment arriving as a multi-mbuf chain the trimmed dup still has m_next != NULL and KASSERT((*m0)->m_next == NULL) panics; the twin KASSERT at :684 rests on the same single-mbuf assumption. Multi-mbuf fragments are the normal output of tapwrite() (MGETHDR head + MGET chain) and several tunnel/virtual input paths. VERIFIED: deterministic two-packet remote panic on INVARIANTS kernels with a fragcrop scrub rule ('panic: (*m0)->m_next != NULL: pf_fragcache', guest died in DDB); production kernels compile the KASSERTs out and proceed benignly. Unauthenticated remote attacker on a segment feeding a fragcrop-scrubbed interface whose input path yields chained mbufs. Fix validated (reorder m_dup NULL check ahead of m_adj - also closes DF-0391's deref - unlink zero-length trailing mbufs, assert on m_pkthdr.len): identical trigger completes, scrub max-mss and reassemble regression controls pass.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3362 Β· 11 files
FileTypeDescriptionSize
README.md β€” 2.5 KB ↓ raw
VERDICT.md β€” 3.5 KB ↓ raw
fragcache_kassert.c β€” 3.6 KB view raw
build.sh β€” 102 B view raw
run.sh β€” 193 B view raw
panic.txt β€” 619 B view raw
fix.diff β€” 1.6 KB view raw
fix_validation.md β€” 1.7 KB ↓ raw
fixbuild_excerpt.txt β€” 1.8 KB view raw
verdict.json β€” 3.9 KB view raw
env.txt β€” 554 B view raw

DF-3362 β€” pf_fragcache overlap-chop path: INVARIANTS KASSERT panics on multi-mbuf fragments

What

pf_fragcache()'s precut>0 ("chop") path (sys/net/pf/pf_norm.c:653-690) duplicates the incoming fragment and tail-trims the duplicate down to the IP header before re-attaching the chopped payload:

*m0 = m_dup(m, M_NOWAIT);
m_adj(*m0, (h->ip_hl << 2) - (*m0)->m_pkthdr.len);   /* negative */
...
KASSERT(((*m0)->m_next == NULL), ...);               /* pf_norm.c:667 */

m_adj(mp, negative) (sys/kern/uipc_mbuf.c:1859) tail-trims by zeroing the lengths of trailing mbufs β€” it does not unlink them. For any fragment that arrives as a multi-mbuf chain, the trimmed duplicate still has m_next != NULL β†’ KASSERT panics on every INVARIANTS kernel. The second KASSERT at :684 ((int)m->m_len == ntohs(h->ip_len) - precut) is built on the same single-mbuf assumption and fires instead whenever the payload does not fit into the header mbuf.

Multi-mbuf fragments are not exotic: tapwrite() builds every frame as an MGETHDR + MGET chain (sys/net/tap/if_tap.c tapwrite), and various tunnel/virtual/aggregate input paths do the same.

Preconditions

  • pf enabled with scrub ... fragment crop (fragcrop) β€” the non-buffering fragment cache mode.
  • Attacker sends an overlapping fragment (precut>0) that arrives in a multi-mbuf chain, e.g. via a tap/VM-facing interface. Two packets: first fills the cache range, second overlaps it.
  • INVARIANTS kernel (DFly development/debug kernels; GENERIC release kernels compile KASSERTs out β€” there the code proceeds with the zero-length mbufs chained, benign for the packet but violating the author's invariants).

Reproduce (see panic.txt β€” observed)

# root on the guest:
kldload pf
ifconfig tap create && ifconfig tap0 up
printf 'scrub in on tap0 all fragment crop\n' > /root/poc/fragcrop.conf
pfctl -f /root/poc/fragcrop.conf && pfctl -e
/root/poc/fragcache_kassert    # writes two Ethernet frames to /dev/tap0

Expected / observed output

panic: (*m0)->m_next != NULL: pf_fragcache
cpuid = 3
pf_fragcache() at pf_fragcache+0xcef
pf_normalize_ip() at pf_normalize_ip+0x49d
pf_test() at pf_test+0x114
pf_check_in() at pf_check_in+0x2e
pfil_run_hooks() at pfil_run_hooks+0x6f
Debugger("panic")

Guest dies on the second frame (the first only creates the cache entry).

Fix

Unlink the zero-length trailing mbufs after the tail-trim and assert on the recomputed packet total instead of the head mbuf length β€” see fix.diff (validated: kernel rebuilt with the fix in-guest, the same trigger no longer panics).

VERDICT.md
↓ download raw

DF-3362 VERDICT

Reproduced? Yes β€” deterministic kernel panic on the second injected frame.

Reproduction narrative

Setup (guest, root):

kldload pf
ifconfig tap create && ifconfig tap0 up
printf 'scrub in on tap0 all fragment crop\n' > fragcrop.conf
pfctl -f fragcrop.conf && pfctl -e
./fragcache_kassert

fragcache_kassert writes two Ethernet frames to /dev/tap0: frame 1 = MF fragment, offset 0, payload 8000 (creates the cache range [0,8000)); frame 2 = MF fragment, offset 496, payload 8000 (overlaps: precut = 8000-496 = 7504 < 8000 β†’ the chop path).

tapwrite() builds every frame as an MGETHDR head + MGET chain of plain internal mbufs (sys/net/tap/if_tap.c), so both frames arrive at pf as ~35-mbuf chains. pf_fragcache() then does m_dup() of the chain and tail-trims the dup to the 20-byte IP header with m_adj(*m0, hlen - pkthdr.len); m_adj(mp, negative) zeroes the lengths of trailing mbufs without unlinking them (sys/kern/uipc_mbuf.c:1859 "toss data from remaining mbufs on chain"), so (*m0)->m_next != NULL and the KASSERT at pf_norm.c:667 fires:

panic: (*m0)->m_next != NULL: pf_fragcache
cpuid = 3
pf_fragcache() at pf_fragcache+0xcef
pf_normalize_ip() at pf_normalize_ip+0x49d
pf_test() at pf_test+0x114
pf_check_in() at pf_check_in+0x2e
pfil_run_hooks() at pfil_run_hooks+0x6f
Debugger("panic")

(see panic.txt β€” captured from the serial console; guest died in DDB, required vm.sh reset.)

The second KASSERT (pf_norm.c:684, (int)m->m_len == ip_len - precut) rests on the same single-mbuf assumption and would fire instead for any payload that does not fit the header mbuf after m_cat().

Threat model

  • Precondition: pf scrub in fragcrop mode on an interface whose input path delivers multi-mbuf packets (tap/VM-facing and several tunnel/virtual drivers do exactly this).
  • Trigger: two packets, unauthenticated, remote on that segment.
  • Effect: instant panic on INVARIANTS kernels (DFly development/debug kernels; the audited guest's stock kernel is INVARIANTS). Production non-INVARIANTS kernels compile the KASSERTs out and proceed with zero-length mbufs chained into the packet β€” benign for the packet but violating the invariants the author relied on (and wasting mbufs).
  • Impact class: panic (DoS), debug-kernel-gated β†’ severity Low.

Fix validation (see fix_validation.md)

fix.diff: (1) move the m_dup NULL check ahead of the m_adj (also closes DF-0391's deref), (2) unlink zero-length trailing mbufs after the tail-trim, (3) assert on the recomputed m_pkthdr.len instead of the head mbuf's m_len.

Baseline: panic above (reproduced pre-fix, stock kernel #0). Patched: kernel rebuilt in-guest with fix.diff applied (cd /usr/src && patch -p1 < fix.diff && make nativekernel && make installkernel, rebooted into kernel #1 of Fri Sep 11 10:20:27 UTC 2026), same trigger re-run β†’ no panic: both frames written and processed, EXIT=0, guest alive (see fix_validation.md). Regression controls passed: scrub max-mss TCP path (5000 pkts) and the reassemble path (6000-fragment burst, DF-3361 deliberately still present in this single-fix build) both behave as before.

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_validation.md
↓ download raw

DF-3362 fix validation (single-fix kernel, in-guest)

Build

  • fix.diff applied to the guest's /usr/src (patch -p1, hunks 1+2 succeeded).
  • cd /usr/src && make nativekernel && make installkernel (full log /root/poc/build.log on the guest; pf_norm.c compiled with the module's -Werror flag set and linked into pf.ko β€” see fixbuild_excerpt.txt; install completed 10:37:04 UTC 2026).
  • Rebooted; uname -v: DragonFly 6.5-DEVELOPMENT #1: Fri Sep 11 10:20:27 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Baseline (pre-fix, stock kernel #0)

Same setup (kldload pf; tap0 up; scrub in on tap0 all fragment crop; pfctl -e) and same trigger: panic: (*m0)->m_next != NULL: pf_fragcache pf_fragcache() at pf_fragcache+0xcef pf_normalize_ip() at pf_normalize_ip+0x49d pf_test() ... pfil_run_hooks() ... Guest died in DDB (panic.txt).

Patched (kernel #1 with fix.diff)

Same setup, same trigger: wrote frag off=0 len=8000 mf=1 (8034 bytes) wrote frag off=496 len=8000 mf=1 (8034 bytes) done EXIT=0 uptime: up 42 secs, load averages: 0.25 ... (guest alive, no panic)

Regression controls on the patched kernel: - scrub max-mss TCP path: 5000 malformed-MSS packets, EXIT=0, no panic. - scrub fragment reassemble path (untouched by this fix, negative control): 6000-fragment burst in 0.1s β†’ 5.86K concurrent pffrag/ pffrent entries (DF-3361 still present, as expected for a single-fix build), guest healthy.

Conclusion

fix_status: fixed β€” the previously-observed panic is gone, the chop path completes, and neighboring pf normalization paths still function. (The fix also reorders the m_dup NULL check ahead of the m_adj argument evaluation, closing DF-0391's NULL deref in the same hunk.)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline kernel #0 panicked on the trigger (panic.txt). Single-fix kernel #1 with fix.diff: identical trigger completes with EXIT=0, no panic, guest healthy; scrub max-mss and reassemble regression controls pass. The panic is gone and the chop path works.

['findings/poc/DF-3362/fix_validation.md', 'findings/poc/DF-3362/fixbuild_excerpt.txt', 'findings/poc/DF-3362/panic.txt']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Fri Sep 11 10:20:27 UTC 2026 x86_64 (X86_64_GENERIC + fix.diff)

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3362/panic.txt (serial console: panic string + backtrace)', 'findings/poc/DF-3362/fragcache_kassert.c (two-frame tap injector)', 'findings/poc/DF-3362/fix_validation.md (baseline panic vs patched no-panic, same trigger)', 'sys/kern/uipc_mbuf.c:1859 (m_adj tail-trim zeroes trailing mbufs without unlinking)', 'sys/net/tap/if_tap.c tapwrite (MGETHDR+MGET chain input path)']

PoC changes

Seed rewritten: lo0 raw-socket injection cannot carry >16KB multi-mbuf fragments (lo0 MTU cap 16384, TAPMRU-style limits, local-source validation); switched to /dev/tap0 Ethernet-frame injection which reproduces the multi-mbuf input shape exactly (tapwrite builds MGETHDR+MGET chains).

Verified recommended fix

See fix.diff: reorder m_dup NULL check before m_adj, unlink zero-length trailing mbufs after the tail-trim, and assert on m_pkthdr.len instead of m->m_len. Validated in-guest.

Verdict

Reproduced deterministically: with scrub in on tap0 all fragment crop, two tap-injected IPv4 fragments (first fills cache range [0,8000), second overlaps at off=496 with 8000B payload, arriving as the ~35-mbuf MGET chain tapwrite() always builds) hit pf_fragcache's precut>0 chop path: m_dup() of the multi-mbuf chain, tail-trim via m_adj(dup, negative) which zeroes but does NOT unlink trailing mbufs, then KASSERT((m0)->m_next == NULL) at pf_norm.c:667 fires -> 'panic: (m0)->m_next != NULL: pf_fragcache' with backtrace pf_fragcache -> pf_normalize_ip -> pf_test -> pf_check_in -> pfil_run_hooks. Guest died in DDB; hard reset required. The twin KASSERT at :684 rests on the same single-mbuf assumption. INVARIANTS/debug kernels only: production non-INVARIANTS kernels compile the KASSERTs out and continue with zero-length mbufs chained (benign for the packet, violates the invariants). Fix validated in-guest: single-fix kernel (#1, 2026-09-11) with fix.diff (NULL-check reorder + unlink zero-length tail + assert on m_pkthdr.len) β€” same trigger no longer panics, regression controls (max-mss path, reassemble path) pass. The reorder also closes known DF-0391's m_dup NULL deref in the same hunk.