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

SET_MBUF_DESTRUCTOR/generic_mbuf_destructor is ABI-incompatible with DragonFly mbufs: ext_free receives the 16-byte mbcluster, not the mbuf (OOB read -> wild ifp deref + permanent cluster leak)

Summary

SET_MBUF_DESTRUCTOR casts generic_mbuf_destructor(struct mbuf *) into m->m_ext.ext_free, but DragonFly invokes ext_free(ext_arg) where ext_arg is the 16-byte struct mbcluster. The destructor's MBUF_IFP(m) reads m_pkthdr.rcvif at offset 40 of the 16-byte object - a 32-byte OOB read into adjacent objcache memory - and feeds the bogus pointer to netmap_generic_irq -> netmap_common_irq (ifp->if_capenable, then NA(ifp)/nm_notify): wild-pointer deref/call chain. It also writes m->m_ext.ext_free=NULL through the confused pointer, and by shadowing m_mclfree never drops mcl_refs: every event mbuf leaks its 2KB cluster permanently and mclmeta_dtor's KKASSERT(mcl_refs==0) panics INVARIANTS kernels on objcache reclaim. Reached from generic_set_tx_event on dispatch failure or ring exhaustion. Same /dev/netmap precondition as DF-3426 (stacked on it). Latent until buildable. Fix: drop the destructor interception (rely on tx_clean polling).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3427 Β· 24 files
FileTypeDescriptionSize
README.md β€” 1.8 KB ↓ raw
VERDICT.md β€” 4.8 KB ↓ raw
run.log β€” 3.0 KB view raw
panic_uaf_run2_serial.txt β€” 16.2 KB view raw
run_evt.log β€” 3.3 KB view raw
trigger_txpool_uaf.c β€” 6.4 KB view raw
build.sh β€” 93 B view raw
run.sh β€” 161 B view raw
build.log β€” 10.8 KB view raw
env.txt β€” 593 B view raw
wna_shim.h β€” 712 B view raw
wna_shim.c β€” 630 B view raw
Makefile β€” 693 B ↓ download
rig_trace.patch β€” 2.2 KB view raw
rig_port2.patch β€” 357 B view raw
fix.diff β€” 1.6 KB view raw
fix3426-rig.diff β€” 365 B view raw
fix2627-rig.diff β€” 788 B view raw
fix_run.log β€” 1.5 KB view raw
fix_build.log β€” 10.8 KB view raw
fix_validation27.log β€” 1.5 KB view raw
verdict.json β€” 6.5 KB view raw
netmap_build.previous_attempt.log β€” 8.8 KB view raw
trigger_destructor_abi.c.previous_attempt β€” 2.7 KB ↓ download

DF-3427 β€” SET_MBUF_DESTRUCTOR / generic_mbuf_destructor ABI incoherence (VERIFIED)

The rig, build and run instructions are shared with DF-3426 (this pack carries its own copies of the sources; see ../DF-3426/README.md).

Build (guest, root)

./build.sh          # verbatim-production netmap.ko + ./trigger

Run (guest, root)

./run.sh evt                  # arming in isolation (dormant, no panic)
./run.sh uaf 512 4096 16384   # detonation: destructor fires inside a
                              # recycled LIVE UDP mbuf -> Fatal trap 12

Expected output

  • evt: RIG trace generic_set_tx_event e=511 (a slot already freed in the same lap) then nothing; guest alive but netstat -m underflows at close (the DF-3426 double frees).
  • uaf: RIG: generic_mbuf_destructor CALLED m=<mbcluster> followed by the m_free ← m_freem ← udp_input ← ip_input ← netmsg_service_loop backtrace and Fatal trap 12: page fault while in kernel mode, Stopped at generic_mbuf_destructor+0x3f: testb $0x80,0x75(%rdi), fault address 0x76 / 0xffffffff00000076 β€” the OOB-read rcvif garbage dereferenced as ifp->if_capenable. Guest dead in DDB.

Fix validation

cd netmap-src && patch -p4 < ../fix.diff (macro no-op + __unused)
# combined with the DF-3426 fix; all PoC modes rc=0, flat counters,
# ZERO destructor callbacks (fix_run.log / fix_build.log)

Verdict

reproduced β€” the ABI incoherence detonated at runtime with the exact predicted crash signature; mechanism refined (see VERDICT.md): the armed mbuf's own free never calls ext_free (CLCACHE branch); the stale ext_free=generic_mbuf_destructor survives objcache magazine recycling (no ctor re-run) and detonates in whichever subsystem next recycles the mbuf β€” demonstrated in udp_input. Full narrative: VERDICT.md.

VERDICT.md
↓ download raw

DF-3427 β€” SET_MBUF_DESTRUCTOR ABI incoherence β€” VERIFIED (mechanism refined)

status: reproduced | reproduced: 1 | impact: panic | confidence: certain

Same rig as DF-3426 (the production netmap module, verbatim sources + two infrastructure port shims, driven through the real /dev/netmap ABI; see ../DF-3426/ for build/run). This file records what the runtime showed about the destructor ABI specifically.

What the finding claimed vs. what the runtime shows

Claim: generic_mbuf_destructor receives ext_arg (the 16-byte struct mbcluster) instead of the mbuf, reads m_pkthdr.rcvif at offset 40 (32+ bytes OOB) and derefs the garbage through netmap_generic_irq()->ifp->if_capenable.

Runtime: exactly that happened β€” with one mechanistic refinement that makes it worse than filed:

  1. The armed pool mbuf's own m_freem() (in generic_set_tx_event, netmap_generic.c:443) does not invoke ext_free: pool mbufs are m_getcl() combined-cache objects (M_CLCACHE|M_EXT|M_EXT_CLUSTER, sharecount 1), and that branch of m_free() (sys/kern/uipc_mbuf.c:1371-1407) returns the mbuf+cluster pair to the cache without calling ext_free. So the write m->m_ext.ext_free = generic_mbuf_destructor (netmap_generic.c:101-104) is not a leak on this path β€” it is a landmine left in a freed object: objcache_put runs no destructor, and objcache_get's per-CPU magazine fast path (sys/kern/kern_objcache.c:493-517) hands the object to the next allocator without re-running the cache ctor, so nothing restores ext_free = m_mclfree.

  2. Whoever allocates that recycled mbuf+cluster inherits the netmap destructor. In the decisive run it was the trigger's UDP spray (uaf mode): a live UDP packet mbuf was allocated from the booby-trapped cache entry, and when udp_input() finished with it β€” m_freem β†’ m_free taking an ext_free-invoking branch (uipc_mbuf.c:1412/1433) β€” generic_mbuf_destructor was called with m = ext_arg = 0xfffff8008d402cc0, a 16-byte mbcluster:

RIG: generic_set_tx_event e=1023 mbuf=0xfffff8012d4a0600 (...) RIG: generic_mbuf_destructor CALLED m=0xfffff8008d402cc0 (...) Trace: m_free() at m_free+0x123 m_free() at m_free+0x123 m_freem() at m_freem+0x15 udp_input() at udp_input+0x2f8 ip_input() ... netmsg_service_loop() Fatal trap 12: page fault while in kernel mode fault virtual address = 0xffffffff00000076 (run 1: 0x76) Stopped at generic_mbuf_destructor+0x3f: testb $0x80,0x75(%rdi)

The destructor read m_pkthdr.rcvif (offset 40) out of the 16-byte mbcluster β†’ garbage ifp (0x1 / 0xffffffff00000001) β†’ ifp->if_capenable at offset 0x75 β†’ page fault. Guest dead in DDB.

  1. Arming in isolation (evt mode): ring exhaustion (nr_hwavail == 0, netmap_generic.c:567-574) armed the event on e=511 β€” a slot whose mbuf had already been dispatched and freed in the same lap β€” so SET_MBUF_DESTRUCTOR is a UAF write into freed memory and the following m_freem a double free (both counted under DF-3426). Without traffic to recycle the header the destructor stayed dormant and the guest survived; the detonation is deferred and cross-subsystem (any allocation from mbuf(pkthdr+)cluster caches).

Not observed at runtime: the finding's secondary "permanent 2KB cluster leak / mclmeta_dtor INVARIANTS panic" (uipc_mbuf.c:632) β€” that would require m_mclfree to actually be shadowed on an ext_free-invoking path, which the pool mbufs' CLCACHE free branch never takes. The OOB read / wild deref is the real, demonstrated impact.

Fix (fix.diff, validated)

SET_MBUF_DESTRUCTOR becomes a no-op on DragonFly (the function is kept, marked __unused, for documentation): the ext_free ABI on this OS cannot express the Linux skb-destructor concept the 2013 port assumed, and TX-completion notification is already provided by generic_netmap_tx_clean() polling. Built as the combined module (fix26+fix27), the full DF-3426/DF-3427 PoC matrix runs rc=0 with flat counters and zero destructor callbacks (fix_run.log = fix_validation27.log; fix_build.log). The fix applies cleanly to the pristine tree (patch -p4 --dry-run, sys/net/netmap/netmap_generic.c). Note DF-3426's slot-clearing fix alone also prevents arming (the event slot is always NULL), so fix.diff here is defense-in-depth against any future caller of the macro.

Gating

Identical to DF-3426: /dev/netmap 0660 root:wheel.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff (SET_MBUF_DESTRUCTOR no-op + generic_mbuf_destructor marked __unused; validated as the combined fix26+fix27 module) applied and rebuilt: the identical PoC matrix (uaf 512 4096 16384, evt, unreg 8) runs rc=0 with netstat -m exactly flat and dmesg shows ZERO generic_mbuf_destructor callbacks (fix_run.log = fix_validation27.log, fix_build.log). The macro can no longer plant ext_free landmines in recycled mbufs. fix.diff applies cleanly to the pristine sys tree (patch -p4 --dry-run). Note: DF-3426's slot-clearing fix alone also prevents this arming (event slot always NULL); this fix is the defense-in-depth that kills the ABI-incoherent primitive itself.

fix_run.log (= fix_validation27.log: combined-fix PoC matrix, 0 destructor calls, flat counters, uptime OK); fix_build.log (fixed-module build); fix.diff (tree patch, macro no-op + __unused); fix2627-rig.diff (rig variant)
↓ fix.diffper-fix-DF-3427

Confirmed kernel references

Detail

Exploit chain

root/wheel -> open /dev/netmap -> NIOCREGIF tap0 -> NIOCTXSYNC until nr_hwavail==0 (ring exhaustion) -> generic_set_tx_event writes ext_free=generic_mbuf_destructor into a pool mbuf (already freed by driver TX completion in the same lap) and m_freem()s it (double free, DF-3426 family) -> the freed combined mbuf+cluster object carries the stale destructor through objcache_put/get (magazine fast path, no ctor re-run) -> any subsystem's allocation inherits it (demonstrated: UDP spray) -> that mbuf's normal free calls ext_free(ext_arg=mbcluster) -> generic_mbuf_destructor reads m_pkthdr.rcvif 32+ bytes past the 16-byte mbcluster -> garbage ifp -> netmap_generic_irq derefs ifp->if_capenable -> kernel page fault / panic. Cross-subsystem deferred detonation; attacker-controlled heap grooming determines the OOB-read garbage (here 0x1 / 0xffffffff00000001 -> non-canonical deref).

Evidence (decisive lines)

run.log + panic_uaf_run2_serial.txt (decisive: 'RIG: generic_mbuf_destructor CALLED m=0xfffff8008d402cc0' with the m_free<-m_free<-m_freem<-udp_input<-ip_input<-netmsg_service_loop backtrace, then Fatal trap 12 at generic_mbuf_destructor+0x3f testb $0x80,0x75(%rdi), fault 0xffffffff00000076); panic_uaf_run1_serial.txt in ../DF-3426 (first reproduction, fault 0x76); run_evt.log (arming in isolation: generic_set_tx_event e=511 on an already-freed slot, destructor dormant, guest survives); fix_run.log = fix_validation27.log (combined fix: 0 destructor callbacks, all PoC modes rc=0); VERDICT.md (full mechanism)

PoC changes

Shared rig with DF-3426 (verbatim production module + WNA side-table shim + a_fpp port shim + RIG_TRACE; see that pack). Trigger rewritten from the documentation stub into the real userspace rig; RIG_TRACE adds a print_backtrace(-6) at generic_mbuf_destructor entry which captured the udp_input detonation path.

Verified recommended fix

Make SET_MBUF_DESTRUCTOR a no-op on DragonFly (ext_free receives ext_arg = mbcluster, ABI-incompatible with the mbuf-typed destructor) and keep generic_mbuf_destructor __unused for documentation; TX completion is already reported by generic_netmap_tx_clean() polling. See fix.diff (validated).

Verdict

REPRODUCED at runtime with the exact predicted crash signature, plus one mechanism refinement that widens the blast radius. generic_set_tx_event (armed on ring exhaustion, netmap_generic.c:567-574, and on dispatch failure :538) installs SET_MBUF_DESTRUCTOR (ext_free = generic_mbuf_destructor) and m_freem()s the pool entry. Runtime shows the armed mbuf's own free does NOT invoke ext_free (m_free's combined-cache branch, sys/kern/uipc_mbuf.c:1371-1407, caches mbuf+cluster without calling ext_free), so the stale ext_free is a landmine in a freed object: objcache_put runs no destructor and objcache_get's magazine fast path (kern_objcache.c:493-517) does not re-run the cache ctor, so the booby-trapped mbuf is handed to the next allocator as-is. In the decisive run a live UDP spray mbuf inherited the destructor; when udp_input()'s m_freem/m_free took an ext_free-invoking branch (uipc_mbuf.c:1412/1433), generic_mbuf_destructor was called with m = ext_arg = a 16-byte struct mbcluster, read m_pkthdr.rcvif at offset 40 (OOB), and dereferenced the garbage pointer as ifp->if_capenable: 'Fatal trap 12: page fault', fault address 0x76 / 0xffffffff00000076, 'Stopped at generic_mbuf_destructor+0x3f: testb $0x80,0x75(%rdi)', guest dead in DDB. The finding's secondary claims (permanent cluster leak, mclmeta_dtor INVARIANTS panic at uipc_mbuf.c:632) were NOT observed: m_mclfree shadowing never happens because the pool mbufs' free path never calls ext_free. Gating identical to DF-3426 (/dev/netmap 0660 root:wheel).