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)
PoC verification
Evidence pack
findings/poc/DF-3362 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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).
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
fragcropmode 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.
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
fixedBaseline 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']
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.
No comments yet.