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

Missing return after m_freem in netisr_characterize causes UAF read and NULL-deref panic

Field Value
ID DF-0609
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:A/AC:H/PR:N/UI:N/S:U/C:L/I:L/A:H
CWE CWE-416 Use After Free; CWE-476 NULL Pointer Dereference
File sys/net/netisr.c
Lines 515-525
Area net (core network software-interrupt dispatch)
Confidence certain
Discovered 2026-07-02
Reported pending

Summary

In netisr_characterize(), when netisrs[num].ni_handler == NULL the function calls m_freem(m) and sets *mp = NULL (lines 517-518), but then falls through (no return) to if ((m->m_flags & M_HASH) == 0) at line 524 β€” reading through the now-dangling local m (UAF read on the freed mbuf) β€” and on to ni->ni_hashfn(mp, hoff) at line 525 with *mp == NULL, which in the default hashfn (netisr_hashfn0 β†’ m_sethash(*mp, 0)) dereferences NULL and panics. The sibling functions netisr_queue() and netisr_handle() get this right (they return EIO after m_freem); netisr_characterize() is missing the equivalent return.

Root cause

sys/net/netisr.c:514-525:

514:    ni = &netisrs[num];
515:    if (ni->ni_handler == NULL) {
516:        kprintf("%s: Unregistered isr %d\n", __func__, num);
517:        m_freem(m);
518:        *mp = NULL;
519:    }                        /* <-- NO return! falls through */
...
524:    if ((m->m_flags & M_HASH) == 0) {   /* UAF read on freed m */
525:        ni->ni_hashfn(mp, hoff);    /* *mp == NULL -> m_sethash(NULL,0) -> panic */

The local m was loaded from *mp at line 500 and is NOT reloaded between the free and the read at line 524, so m->m_flags is a read of freed memory (the mbuf returned by ether_input from a driver RX path). If the freed slot's M_HASH bit happens to be clear (the common case for a freshly freed mbuf), control enters the body at line 525 and calls ni->ni_hashfn(mp, hoff) with *mp == NULL. The default ni_hashfn is netisr_hashfn0 (set by netisr_register at netisr.c:544 for any protocol registering with hashfn==NULL), which does m_sethash(*mp, 0) β€” and m_sethash (sys/sys/mbuf.h:571-575) unconditionally dereferences m->m_flags and m->m_pkthdr.hash, so a NULL *mp panics the kernel.

Even before the NULL deref, the m->m_flags access on line 524 is a use-after-free read whose value is whatever the allocator (or an attacker who can win a reallocation race) places back into that memory.

Contrast with netisr_queue (line 404-408) and netisr_handle (line 464-468), both of which return EIO; immediately after the same m_freem pattern.

Threat model & preconditions

  • Attacker position: remote adjacent-network attacker (any L2 peer of a victim NIC). Only ether_characterize() (sys/net/if_ethersubr.c:1641) calls netisr_characterize(), passing num ∈ {NETISR_IP=2, NETISR_ARP=18, NETISR_IPV6=28, NETISR_MPLS=21, NETISR_MAX=32}.
  • Privileges gained or impact: minimum is a deterministic kernel panic (DoS) via the NULL deref in m_sethash; speculative escalation is possible if the UAF read of m->m_flags can be groomed (the mbuf allocator recycles freed mbufs aggressively, and the read value drives a control-flow decision and a function-pointer indirect call into ni_hashfn).
  • Required config or capabilities: the bug is only reachable when ni->ni_handler == NULL for the affected protocol β€” i.e. the handler registration window:
  • (a) a network protocol module that publishes one of these ISRs is in the middle of being kldload/kldunload (handler cleared/never-set window while ether_input is still pulling packets of the matching ether_type off the wire),
  • (b) a kernel is built with e.g. device mpls but the MPLS domain init hasn't run yet (early-boot race before SI_SUB_PROTO_DOMAIN),
  • (c) any future in-tree or out-of-tree caller passes a num whose registration has lapsed.
  • Reachability: emit an Ethernet frame of the affected ether_type (e.g. ETHERTYPE_MPLS = 0x8847 if MPLS's handler is transiently NULL) during the registration window.

Proof of concept

PoC source: findings/poc/DF-0609/send_frame.py + a small kld trigger module that NULLs the handler to simulate the unregistered-ISR window deterministically.

Build & run

# Build the trigger kld (NULLs netisrs[NETISR_MPLS].ni_handler):
cd findings/poc/DF-0609 && make

# Load it on the victim:
kldload ./buggy_netisr.ko

# From a peer on the same L2 segment, send one MPLS frame:
python3 send_frame.py vtnet0

Expected output

Kernel panic with a backtrace pointing at m_sethash (sys/sys/mbuf.h:573) called from netisr_hashfn0 (netisr.c:667) called from netisr_characterize (netisr.c:525). dmesg will show netisr_characterize: Unregistered isr 21 immediately before the fault, proving the buggy branch was entered:

netisr_characterize: Unregistered isr 21
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
[code] m_sethash+0x...: mov ...
db> tr
    m_sethash+0x...
    netisr_hashfn0+0x...
    netisr_characterize+0x...
    ether_characterize+0x...
    ether_input+0x...

Impact

  • Blast radius: any DragonFly system where a network protocol's netisr handler can be transiently NULL while packets of the matching ether_type are being received (modular protocol drivers, MPLS/IPv6 early-boot races, out-of-tree protocol modules).
  • Severity rationale: Medium. Certain code-level defect, deterministic panic once triggered, but requires a specific registration window (AC:H). CVSS 3.1 base β‰ˆ 5.7. On a monolithic kernel with all protocols compiled in and registered at boot, the bug is not reachable from normal operation; it becomes reachable during module load/unload or early boot.
  • Reliability: 100% once the path is entered β€” no race within the function itself.

Add the missing return; after *mp = NULL; so the function stops touching the freed mbuf. This mirrors the early-return that netisr_queue (netisr.c:407) and netisr_handle (netisr.c:467) already perform for the same ni->ni_handler == NULL condition.

--- a/sys/net/netisr.c
+++ b/sys/net/netisr.c
@@ -514,6 +514,7 @@ netisr_characterize(int num, struct mbuf **mp, int hoff)
    ni = &netisrs[num];
    if (ni->ni_handler == NULL) {
        kprintf("%s: Unregistered isr %d\n", __func__, num);
        m_freem(m);
        *mp = NULL;
+       return;
    }

    /*

Optional hardening (independent of the fix): the KASSERT bounds on netisr_queue:400, netisr_handle:462, netisr_register:539, netisr_register_hashcheck:559, and schednetisr:701 read num <= NELEM(netisrs) which permits num=32 against a netisrs[NETISR_MAX] (= [32], valid 0..31) array; they should read num < NELEM(netisrs) for consistency with the correct runtime checks at netisr.c:503 and 835. Not a live vulnerability today (every in-tree caller passes a NETISR_* constant <= 31), but a latent foot-gun worth tightening in the same pass.

References

Timeline

  • 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
  • 2026-07-02 Reported to DragonFlyBSD security contact (pending).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0609 Β· 15 files
FileTypeDescriptionSize
trigger_kmod.c trigger-source KLD module: sysctl hw.df0609.trigger=1 calls netisr_characterize(NETISR_NETGRAPH=30, &m, 0) to fire the missing-return bug 2.8 KB view raw
Makefile build-config DragonFlyBSD KLD Makefile using bsd.kmod.mk 108 B ↓ download
build.sh build-script Repro: build the KLD module 286 B view raw
run.sh run-script Repro: kldload + sysctl trigger 405 B view raw
build.log build-log Full compiler output of KLD build (final successful) 5.5 KB view raw
run.log run-log Decisive unpatched-kernel run output incl panic 1015 B view raw
panic.txt panic-signature Fatal trap 12 page fault at address 0x0 from netisr_characterize NULL-deref 770 B view raw
fix_run.log run-log Patched #1 kernel: clean return, no panic (two runs) 1.1 KB view raw
fix_build.log build-log Full nativekernel build output of single-fix kernel 5.6 MB ↓ download
fix.diff suggested-fix git-apply-able one-line fix: add return; after *mp=NULL at netisr.c:518 253 B view raw
env.txt environment uname, cc version, kldstat 498 B view raw
VERDICT.md verdict Full narrative: mechanism, trigger, fix validation 5.8 KB ↓ raw
README.md readme Original PoC scaffold README 2.0 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme Original PoC scaffold README
↓ download raw

DF-0609 β€” PoC: netisr_characterize missing return after m_freem

Remote adjacent-network DoS PoC. netisr_characterize() is missing a return after m_freem(m); *mp = NULL; when ni->ni_handler == NULL. The fall-through reads m->m_flags from freed memory (UAF) then calls ni->ni_hashfn(mp, ...) with *mp == NULL, which panics in m_sethash(NULL, 0).

Files

  • trigger_kmod.c β€” kld that NULLs netisrs[NETISR_MPLS].ni_handler to deterministically enter the buggy branch.
  • send_frame.py β€” scapy one-liner sending an ETHERTYPE_MPLS frame.
  • Makefile, build.sh, run.sh.
  • (added by per-PoC verifier) build.log, run.log, VERDICT.md, manifest.json, fix.diff, panic.txt.

Build & run

# Build the trigger kld:
make

# On the victim:
kldload ./buggy_netisr.ko

# From a peer on the same L2 segment:
python3 send_frame.py vtnet0

Expected outcome

netisr_characterize: Unregistered isr 21
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
db> tr
    m_sethash+0x...
    netisr_hashfn0+0x...
    netisr_characterize+0x...
    ether_characterize+0x...
    ether_input+0x...

Notes for the per-PoC verifier

  • The trigger requires netisrs[<num>].ni_handler == NULL at the moment the frame arrives. The kld deterministically NULLs the MPLS handler; in a real scenario the window is during kldload/kldunload of a protocol module, or an early-boot race before SI_SUB_PROTO_DOMAIN.
  • On a monolithic kernel with all protocols compiled in and registered at boot, the bug is not reachable from normal operation.
  • Verify the fix with git apply findings/poc/DF-0609/fix.diff (adding the missing return; after *mp = NULL;); after the fix the frame should be dropped cleanly with netisr_characterize: Unregistered isr 21 and no panic.
  • The UAF read at line 524 (m->m_flags from freed memory) is a speculative escalation vector if the mbuf slab is groomed; document in VERDICT.md if a controlled value can be placed in the freed slot before the read.
VERDICT.md verdict Full narrative: mechanism, trigger, fix validation
↓ download raw

DF-0609 β€” Verdict: REPRODUCED (panic) β†’ FIX VALIDATED

Verdict

REPRODUCED. The missing return; after m_freem(m); *mp = NULL; in netisr_characterize() is a real, deterministic UAF-read + NULL-deref bug. Triggered via a KLD module that calls netisr_characterize(NETISR_NETGRAPH=30, &m, 0) (a slot whose ni_handler is NULL on the default GENERIC kernel because netgraph is not loaded). The fix β€” adding return; β€” eliminates the panic and was validated on a single-fix kernel (#1).

Mechanism (trigger β†’ primitive β†’ effect)

The bug is in sys/net/netisr.c lines 514–525. Confirmed by source tracing:

  1. Entry: netisr_characterize(num=30, mp=&m, hoff=0) is called. m is loaded from *mp at line 500. ni = &netisrs[30].
  2. Buggy branch (line 515–519): ni->ni_handler == NULL (NETISR_NETGRAPH never registered) β†’ kprintf("Unregistered isr 30") β†’ m_freem(m) (line 517, mbuf freed) β†’ *mp = NULL (line 518) β†’ } (line 519, NO return β€” falls through).
  3. UAF read (line 524): if ((m->m_flags & M_HASH) == 0) β€” m is the local variable still pointing at the freed mbuf. This is a use-after-free read. The freed mbuf's m_flags residue was 0x8002 (M_PKTHDR|M_EXT), and M_HASH (0x2000) was clear β†’ condition TRUE.
  4. NULL-deref (line 525): ni->ni_hashfn(mp, hoff) β€” for a never-registered ISR, ni_hashfn is also NULL (zero-initialized static array). The CPU attempts to execute code at address 0x0 β†’ page fault at NULL.

The sibling functions netisr_queue (line 407: return (EIO)) and netisr_handle (line 467: return EIO) both return immediately after the same m_freem pattern β€” confirming the missing return in netisr_characterize is the defect.

Observed panic (unpatched #0 kernel)

DF-0609: m=0xfffff80118649000 m_flags=0x8002 β€” calling netisr_characterize(NETISR_NETGRAPH=30, &mp, 0)
netisr_characterize: Unregistered isr 30
Fatal user address access from kernel mode from sysctl at 0000000000000000
Fatal trap 12: page fault while in kernel mode
fault virtual address    = 0x0
instruction pointer      = 0x8:0x0

instruction pointer = 0x8:0x0 confirms the NULL function-pointer call through ni->ni_hashfn (NULL for an unregistered ISR). This is preceded by the UAF read of m->m_flags from freed memory (confirmed by the freed mbuf's 0x8002 residue).

Trigger approach

A KLD module (trigger_kmod.c) registers a sysctl hw.df0609.trigger. Writing 1 to it allocates an mbuf via m_gethdr(M_WAITOK, MT_DATA) and calls netisr_characterize(NETISR_NETGRAPH, &m, 0). NETISR_NETGRAPH (30) has ni_handler == NULL on the default GENERIC kernel (netgraph not loaded), so the buggy branch is entered deterministically.

Why a KLD trigger? On the default X86_64_GENERIC kernel, no ether_type reachable through ether_characterize() maps to a NETISR slot with a NULL handler β€” IP/ARP/IPv6 all have registered handlers, and MPLS frames fall to default: NETISR_MAX which exits cleanly at line 504–507 (MPLS not compiled in). The only way to reach the buggy branch is to call netisr_characterize() with a num whose handler is NULL, which requires kernel-context code (KLD module). This mirrors the real-world attack vector described in the finding: a protocol module's kldload/kldunload window where ni_handler is transiently NULL while packets of the matching ether_type are being received.

Exploit chain

Not a memory-corruption exploitation chain. The bug is a deterministic panic (DoS) via NULL function-pointer call. The UAF read of m->m_flags (line 524) reads one field from a freed mbuf before the NULL-deref crash; it drives a branch decision but the value cannot be attacker-controlled in this single-shot path (the freed mbuf's stale m_flags is used immediately, with no reallocation window between m_freem and the read). The realistic impact ceiling is local/adjacent DoS (kernel panic).

Fix

Add return; after *mp = NULL; (line 518), mirroring the early-return pattern in netisr_queue:407 and netisr_handle:467:

--- a/sys/net/netisr.c
+++ b/sys/net/netisr.c
@@ -516,6 +516,7 @@
        kprintf("%s: Unregistered isr %d\n", __func__, num);
        m_freem(m);
        *mp = NULL;
+       return;
    }

This is a one-line fix, matches the finding's ## Recommended fix proposal.

Fix validation (Phase 8)

Before (unpatched #0 kernel)

kldload df0609_trigger.ko  β†’ OK
sysctl hw.df0609.trigger=1 β†’ kernel PANIC
  netisr_characterize: Unregistered isr 30
  Fatal trap 12: page fault while in kernel mode
  fault virtual address = 0x0
  instruction pointer   = 0x8:0x0
  Guest: DOWN (DDB)

After (single-fix #1 kernel)

kldload df0609_trigger.ko  β†’ OK
sysctl hw.df0609.trigger=1 β†’ returned cleanly, NO panic
  netisr_characterize: Unregistered isr 30
  DF-0609: returned OK, mp=0 (NULL == mbuf freed cleanly, no panic β€” FIX WORKS)
  Guest: UP

Ran twice β€” both clean, no panic. Module unloaded cleanly afterward.

Patched kernel: DragonFly 6.5-DEVELOPMENT #1: Fri Jul 3 03:11:19 UTC 2026 sha256(/boot/kernel/kernel) = 6745b72b035f1a2b9f3602c2886fabfc61d2e657a917b5614335cb9c6785dd5f

fix_status: FIXED β€” bad behavior (panic) is gone on the patched kernel and present on the unpatched baseline. Clean before/after.

PoC changes

  • trigger_kmod.c β€” written from scratch (the scaffold referenced a non- existent file). A KLD module that registers hw.df0609.trigger sysctl; writing 1 allocates an mbuf and calls netisr_characterize(NETISR_NETGRAPH, &m, 0). Fixed two compile errors during iteration: added #include <sys/malloc.h> (for M_WAITOK) and removed CTLFLAG_MPSAFE (not defined on DragonFly).
  • Makefile β€” DragonFlyBSD KLD Makefile using bsd.kmod.mk.
  • build.sh / run.sh β€” repro scripts.
  • fix.diff β€” the one-line return; fix, git-apply-able.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: on the unpatched #0 baseline kernel, sysctl hw.df0609.trigger=1 caused a kernel panic (Fatal trap 12, page fault at address 0x0, instruction pointer 0x8:0x0, guest DOWN). On the single-fix #1 kernel (return; added after *mp=NULL), the same trigger returned cleanly with 'DF-0609: returned OK, mp=0 (NULL == mbuf freed cleanly, no panic β€” FIX WORKS)' and the guest stayed UP (confirmed twice, deterministic). The fix closes the bug.

BASELINE (#0): netisr_characterize: Unregistered isr 30 / Fatal trap 12: page fault while in kernel mode / fault virtual address = 0x0 / instruction pointer = 0x8:0x0 / db>  (guest DOWN). PATCHED (#1): netisr_characterize: Unregistered isr 30 / DF-0609: returned OK, mp=0 (NULL == mbuf freed cleanly, no panic β€” FIX WORKS) / guest UP (ran 2x, module unloaded cleanly).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 3 03:11:19 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC

Confirmed kernel references

Detail

Exploit chain

none. Not a memory-corruption exploitation chain. The UAF read of m->m_flags (line 524) reads one field from a freshly-freed mbuf immediately before the NULL-deref crash β€” there is no attacker-controlled reallocation window between m_freem (line 517) and the read (line 524), so the stale value cannot be groomed. The realistic impact ceiling is deterministic local/adjacent DoS (kernel panic via NULL function-pointer call). No privilege escalation primitive derivable from this single-shot path.

Evidence (decisive lines)

UNPATCHED #0 kernel: netisr_characterize: Unregistered isr 30 / Fatal user address access from kernel mode from sysctl at 0000000000000000 / Fatal trap 12: page fault while in kernel mode / fault virtual address = 0x0 / instruction pointer = 0x8:0x0 / Stopped at 0: / db>  (guest DOWN). PATCHED #1 kernel: netisr_characterize: Unregistered isr 30 / DF-0609: returned OK, mp=0 (NULL == mbuf freed cleanly, no panic β€” FIX WORKS) (guest UP, ran twice deterministically).

PoC changes

Written trigger_kmod.c from scratch (the scaffold README referenced trigger_kmod.c/send_frame.py/Makefile that did not exist). Changed approach from a remote-scapy-frame kld trigger to a sysctl-triggered KLD module that calls netisr_characterize(NETISR_NETGRAPH=30, &m, 0) directly β€” NETISR_NETGRAPH handler is NULL on default GENERIC (netgraph not loaded). Fixed two compile errors on iteration 1: added #include (M_WAITOK defined there, not in mbuf.h) and removed CTLFLAG_MPSAFE (not defined on DragonFly). Wrote Makefile (bsd.kmod.mk), build.sh, run.sh.

Verified recommended fix

Add return; after *mp = NULL; at sys/net/netisr.c:518, so netisr_characterize stops touching the freed mbuf after the unregistered-ISR branch β€” mirroring the early-return pattern already present in netisr_queue:407 (return EIO) and netisr_handle:467 (return EIO). Matches finding proposal exactly. Full git-apply-able diff in findings/poc/DF-0609/fix.diff.

Verdict

REPRODUCED. The missing return; after m_freem(m); *mp = NULL; in netisr_characterize() (sys/net/netisr.c:515-519) is a real, deterministic UAF-read + NULL-deref bug. Confirmed by source tracing: when ni->ni_handler==NULL, m_freem(m) frees the mbuf and *mp is set to NULL, but execution falls through (no return) to read m->m_flags from freed memory (UAF, line 524) then call ni->ni_hashfn(mp, hoff) (line 525) β€” for a never-registered ISR, ni_hashfn is also NULL, so the CPU jumps to address 0x0 -> page fault. Triggered deterministically via a KLD module calling netisr_characterize(NETISR_NETGRAPH=30, &m, 0) where the handler is NULL on the default GENERIC kernel (netgraph not loaded). Panic captured: 'Fatal trap 12: page fault, fault virtual address=0x0, instruction pointer=0x8:0x0'. Sibling functions netisr_queue:407 and netisr_handle:467 both correctly return EIO after the same m_freem pattern, confirming netisr_characterize is the outlier. The one-line fix (add return;) eliminates the panic.