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) callsnetisr_characterize(), passingnumβ {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 ofm->m_flagscan be groomed (the mbuf allocator recycles freed mbufs aggressively, and the read value drives a control-flow decision and a function-pointer indirect call intoni_hashfn). - Required config or capabilities: the bug is only reachable when
ni->ni_handler == NULLfor 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 whileether_inputis still pulling packets of the matchingether_typeoff the wire), - (b) a kernel is built with e.g.
device mplsbut the MPLS domain init hasn't run yet (early-boot race beforeSI_SUB_PROTO_DOMAIN), - (c) any future in-tree or out-of-tree caller passes a
numwhose registration has lapsed. - Reachability: emit an Ethernet frame of the affected
ether_type(e.g.ETHERTYPE_MPLS = 0x8847if 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
netisrhandler can be transiently NULL while packets of the matchingether_typeare 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.
Recommended fix
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
sys/net/netisr.c:404-408(netisr_queue) and:464-468(netisr_handle) β the correct early-return pattern afterm_freem.sys/net/if_ethersubr.c:1641(ether_characterize) β the sole caller.sys/sys/mbuf.h:571-575(m_sethash) β the NULL-deref site.
Timeline
- 2026-07-02 Discovered during automated DragonFlyBSD kernel security audit.
- 2026-07-02 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0609 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 NULLsnetisrs[NETISR_MPLS].ni_handlerto deterministically enter the buggy branch.send_frame.pyβ scapy one-liner sending anETHERTYPE_MPLSframe.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 == NULLat the moment the frame arrives. The kld deterministically NULLs the MPLS handler; in a real scenario the window is duringkldload/kldunloadof a protocol module, or an early-boot race beforeSI_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 missingreturn;after*mp = NULL;); after the fix the frame should be dropped cleanly withnetisr_characterize: Unregistered isr 21and no panic. - The UAF read at line 524 (
m->m_flagsfrom freed memory) is a speculative escalation vector if the mbuf slab is groomed; document inVERDICT.mdif a controlled value can be placed in the freed slot before the read.
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:
- Entry:
netisr_characterize(num=30, mp=&m, hoff=0)is called.mis loaded from*mpat line 500.ni = &netisrs[30]. - 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). - UAF read (line 524):
if ((m->m_flags & M_HASH) == 0)βmis the local variable still pointing at the freed mbuf. This is a use-after-free read. The freed mbuf'sm_flagsresidue was0x8002(M_PKTHDR|M_EXT), and M_HASH (0x2000) was clear β condition TRUE. - NULL-deref (line 525):
ni->ni_hashfn(mp, hoff)β for a never-registered ISR,ni_hashfnis 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 registershw.df0609.triggersysctl; writing 1 allocates an mbuf and callsnetisr_characterize(NETISR_NETGRAPH, &m, 0). Fixed two compile errors during iteration: added#include <sys/malloc.h>(forM_WAITOK) and removedCTLFLAG_MPSAFE(not defined on DragonFly).Makefileβ DragonFlyBSD KLD Makefile usingbsd.kmod.mk.build.sh/run.shβ repro scripts.fix.diffβ the one-linereturn;fix, git-apply-able.
Fix verification
fixedVALIDATED 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).
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
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.
No comments yet.