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

m_move_pkthdr() never clears M_PKTHDR on the source mbuf β€” live stale packet-header (stale len/rcvif/csum/pf) glued mid-chain on every M_MOVE_PKTHDR, contradicting its documented contract

Summary

The function moves the pkthdr to 'to' but leaves M_PKTHDR and the full stale m_pkthdr (len, rcvif, csum_*, fw_flags, pf) on 'from' β€” mbuf.h:406-410 documents 'from no longer has a pkthdr' and FreeBSD's macro clears the flag; in-tree callers (icmp6.c:556 saves n0len first) are written to that contract. Reproduced on the production guest: after M_MOVE_PKTHDR the source keeps flags=0x8002 (M_PKTHDR|M_PHCACHE) and m_adj then mutates the 'moved' pkthdr.len 30->22 β€” the exact icmp6.c:559/563 sequence any ICMPv6 echo request drives. Producers span remote (icmp6 echo reflect, ip6_input.c:255, ip6_splithdr) and local (m_prepend, m_unshare, if_stge.c, ieee80211) paths; all current pkthdr readers operate on chain heads so no in-tree memory-safety sink β€” kernel-state/contract corruption feeding stale metadata into remote-reachable paths. One-line flag-clear fix proposed.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5795 Β· 11 files
FileTypeDescriptionSize
mbuf_probe.c β€” 4.7 KB view raw
Makefile β€” 60 B ↓ download
build.sh β€” 84 B view raw
run.sh β€” 108 B view raw
build.log β€” 1.0 KB view raw
run.log β€” 748 B view raw
env.txt β€” 158 B view raw
VERDICT.md β€” 2.9 KB ↓ raw
README.md β€” 2.7 KB ↓ raw
fix.diff β€” 1.2 KB view raw
verdict.json β€” 3.2 KB view raw

DF-5795 β€” m_move_pkthdr() does not clear M_PKTHDR on the source mbuf

What

m_move_pkthdr() (sys/kern/uipc_mbuf.c:2551-2558) moves the packet header from from to to but never clears the M_PKTHDR flag on from β€” and leaves from's m_pkthdr content (stale len, rcvif, csum_*, pf, emptied tags) fully intact. Its documented contract in sys/sys/mbuf.h (lines 405-410) states: "from should have M_PKTHDR set, and to must be empty. from no longer has a pkthdr after this operation." FreeBSD's M_MOVE_PKTHDR macro, from which this API is derived, does (from)->m_flags &= ~M_PKTHDR.

The in-tree callers were written to the documented (FreeBSD) semantics β€” e.g. icmp6.c:556 saves n0len = n0->m_pkthdr.len before the move "for use below", precisely because the move is supposed to invalidate the source's pkthdr.

Result

After every M_MOVE_PKTHDR in the tree, a mid-chain mbuf keeps a live, wrong packet header:

  • the moved-from mbuf claims M_PKTHDR with a stale total length (m_adj() on it mutates that stale header β€” reproduced: 30 β†’ 22),
  • stale rcvif, csum_*, fw_flags, pf metadata rides along until the mbuf is freed,
  • remote-reachable producers: icmp6.c:559 (ICMPv6 echo reflect β€” any ping6), ip6_input.c:255, ip6_output.c:3072 (ip6_splithdr), if_stge.c:1535, ieee80211_output.c:1271, ieee80211_dragonfly.c:643.

No in-tree sink turns the stale header into memory corruption (all pkthdr readers use the chain head), so this is a kernel-state/contract bug, not an exploitable primitive on its own β€” but it silently breaks the invariant every consumer and every auditor (and FreeBSD) assumes.

Build / run / expected (guest, root)

cd /tmp/df5795 && sh build.sh      # builds mbuf_probe.ko via bsd.kmod.mk
sh run.sh                          # kldload, kldunload, dmesg tail

Expected decisive lines in dmesg:

DF5795: after  move : from.flags=00008002 ... M_PKTHDR-on-from=1
DF5795: REPRODUCED: source mbuf still carries M_PKTHDR after M_MOVE_PKTHDR
DF5795: m_adj(from, 8) mutated the 'moved' pkthdr.len: 30 -> 22 (MUTATED)

With the one-line fix the first two lines would report M_PKTHDR-on-from=0 / NOT REPRODUCED, and m_adj would leave the (no-longer-existent) header untouched.

Fix

fix.diff (single functional line): clear M_PKTHDR on from. Deliberately does not touch from's M_EXT/data pointer: DF callers (m_unshare uipc_mbuf.c:2037, m_prepend:1513) rely on from keeping its cluster attachment β€” the comment in m_unshare claiming M_MOVE_PKTHDR "will smash the data pointer and drop the M_EXT marker" describes the old FreeBSD macro and is wrong for DF's function; the diff corrects that comment too.

VERDICT.md
↓ download raw

DF-5795 VERDICT

Status: reproduced (deterministic invariant break; impact: none β€” no memory corruption demonstrated or claimed)

What was run

mbuf_probe.ko (this directory) on the live DF 6.5-DEVELOPMENT guest (X86_64_GENERIC, non-INVARIANTS production kernel, hammer2 root), root-loaded KLD; production trigger needs no KLD (any ICMPv6 echo request drives icmp6.c:559 M_MOVE_PKTHDR(n, n0) + m_adj(n0, …)).

What happened (run.log)

DF5795: before move : from.flags=00008002 from.pkthdr.len=30
DF5795: after  move : from.flags=00008002 from.pkthdr.len=30 M_PKTHDR-on-from=1
DF5795: REPRODUCED: source mbuf still carries M_PKTHDR after M_MOVE_PKTHDR
DF5795: m_adj(from, 8) mutated the 'moved' pkthdr.len: 30 -> 22 (MUTATED)

0x8002 = M_PKTHDR|M_PHCACHE. After M_MOVE_PKTHDR(to, from):

  1. from still has M_PKTHDR set β€” sys/sys/mbuf.h:406-410 says it must not ("from no longer has a pkthdr after this operation"); FreeBSD's macro clears it.
  2. from->m_pkthdr.len is stale (30) and live: m_adj(from, 8) (uipc_mbuf.c:1882-1883 reads mp->m_flags & M_PKTHDR) decremented it to 22 β€” the exact call sequence of the remote-reachable ICMPv6 echo-reflect path (icmp6.c:559,563).

Why it matters / ceiling

Every M_MOVE_PKTHDR call site (icmp6.c:559/1331, ip6_input.c:255, ip6_output.c:3072, if_stge.c:1535, ieee80211_output.c:1271, ieee80211_dragonfly.c:643, uipc_mbuf.c:1513 m_prepend, uipc_mbuf.c:2037 m_unshare) leaves a duplicate packet-header with stale len/rcvif/csum/pf metadata glued mid-chain. Audited every in-file and grep-visible consumer: all pkthdr readers operate on chain heads, m_free only touches the (emptied) tag list, so no in-tree memory-safety sink was found β€” hence severity Low (kernel-state/contract bug feeding stale metadata into remote-reachable paths), not Medium+.

False-positive check

None of this is INVARIANTS-assert-guarded (KKASSERT is a no-op on this kernel, sys/sys/systm.h:94-118), so nothing masks or fires on the divergence; behavior is the shipped production behavior.

Fix validation

Not performed (no kernel rebuild): the finding is not a reproduced memory-corruption, so the mandatory rebuild cycle does not apply. fix.diff is a one-line flag clear (+ stale-comment correction in m_unshare). M_EXT/m_data on from are deliberately untouched β€” DF's m_free/m_unshare depend on from keeping its cluster attachment, unlike the FreeBSD macro the stale comment describes.

Reproduce

guest# cd /tmp/df5795 && sh build.sh && sh run.sh

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-validation kernel rebuild not performed: finding is not a reproduced memory-corruption (severity Low), so the mandatory rebuild cycle does not apply. fix.diff is a one-line flag clear audited against all seven in-tree M_MOVE_PKTHDR call sites; expected patched behavior: M_PKTHDR-on-from=0, m_adj leaves the source untouched.

fix.diff in this directory; call-site audit in VERDICT.md.
↓ fix.diffper-fix-DF-5795

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log decisive lines: 'DF5795: after move : from.flags=00008002 ... M_PKTHDR-on-from=1', 'DF5795: REPRODUCED: source mbuf still carries M_PKTHDR after M_MOVE_PKTHDR', 'DF5795: m_adj(from, 8) mutated the moved pkthdr.len: 30 -> 22 (MUTATED)'. Source: mbuf_probe.c probe_5795(); root-cause at sys/kern/uipc_mbuf.c:2551-2558.

PoC changes

Seed concept converted to a KLD invariant probe; added missing #include for M_WAITOK (first build failed without it); used m_gethdr/m_get chain with pkthdr.len=30 to show the stale header being mutated by m_adj() post-move.

Verified recommended fix

Clear M_PKTHDR on the source in m_move_pkthdr(): from->m_flags &= ~M_PKTHDR; (do NOT touch M_EXT/m_data - DF callers rely on the cluster staying attached; see fix.diff which also corrects the stale FreeBSD-macro comment in m_unshare).

Verdict

Deterministically reproduced on the production (non-INVARIANTS) guest: m_move_pkthdr() (sys/kern/uipc_mbuf.c:2551) never clears M_PKTHDR on the source mbuf, violating its documented contract (sys/sys/mbuf.h:406-410, "from no longer has a pkthdr after this operation") and FreeBSD macro semantics. The source keeps a live stale pkthdr (demonstrated: pkthdr.len 30 mutated to 22 by m_adj() after the move, the exact icmp6.c:559/563 sequence reachable by any ICMPv6 echo request); every M_MOVE_PKTHDR call site in the tree glues this duplicate header mid-chain. No in-tree memory-safety sink was found after auditing all pkthdr readers (heads only), so impact is kernel-state/contract corruption with stale metadata on remote-reachable paths, not memory corruption.