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

sc->outq mbuf-queue race in ng_h4: IF_DEQUEUE in ng_h4_start (tty ctx) vs IF_DRAIN in disconnect/shutdown (netgraph ctx); NG_H4_LOCK is only per-CPU crit_enter

Field Value
ID DF-0589
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-362 Race Condition (Concurrent Execution using Shared Resource); CWE-416 Use After Free
File sys/netgraph7/bluetooth/drivers/h4/ng_h4.c
Lines 88-89 (var.h), 579-647, 729-735, 886, 773
Area netgraph7 (Bluetooth H4 driver)
Confidence likely
Discovered 2026-07-02
Reported pending

Summary

The per-node mbuf output queue sc->outq is mutated from two independent execution contexts that hold no common cross-CPU synchronization primitive. ng_h4_start() (invoked from the tty l_start line-discipline callback) performs IF_DEQUEUE/IF_PREPEND on sc->outq while holding only tp->t_token, with NG_H4_LOCK not held. ng_h4_disconnect(), ng_h4_rcvmsg(NGM_H4_NODE_RESET), and ng_h4_shutdown() perform IF_DRAIN on the same queue while holding only NG_H4_LOCK. But NG_H4_LOCK is defined (sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h:88-89) as merely crit_enter()/crit_exit() β€” a per-CPU critical section that provides zero cross-CPU exclusion. On an SMP system, two CPUs can both read sc->outq.ifq_head in IF_DEQUEUE before either writes it back, causing both to "dequeue" the same mbuf. One context then frees it (m_freem in IF_DRAIN), the other continues to use it (clist_btoq reads m_data/m_len, m_free re-frees) β€” a use-after-free / double-free on kernel mbuf heap objects.

Root cause

  1. NG_H4_LOCK is defined at sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h:88-89 as merely crit_enter()/crit_exit(). DragonFlyBSD critical sections are per-CPU: they prevent preemption and defer IPIs on the current cpu only, but do not prevent another cpu from concurrently executing inside its own critical section.

  2. The outq is a struct ifqueue (sys/net/if_var.h:120-126) β€” a plain singly-linked list of mbufs threaded through m_nextpkt, manipulated by the non-atomic macros IF_DEQUEUE, IF_PREPEND, IF_ENQUEUE, IF_DRAIN. Each is a multi-instruction read-modify-write sequence with no atomicity.

  3. ng_h4_start (sys/netgraph7/bluetooth/drivers/h4/ng_h4.c:572) is registered as the tty l_start method. When the tty layer calls it, it acquires tp->t_token (:579) but does not acquire NG_H4_LOCK around the IF_DEQUEUE at :592 or the IF_PREPEND at :615. The only NG_H4_LOCK acquisitions in ng_h4_start are brief crit_enter/crit_exit pairs for stat counter updates (:601-603, :620-622, :638-644).

  4. ng_h4_disconnect (:716) acquires NG_H4_LOCK (:729, crit_enter) and calls IF_DRAIN(&sc->outq) at :735. It does not acquire tp->t_token. Since crit_enter is per-CPU, this provides no protection against ng_h4_start running concurrently on another CPU.

  5. The concrete interleaving that produces a double-dequeue: both CPUs execute IF_DEQUEUE simultaneously, both read ifq_head=mbuf_A. CPU0 then writes ifq_head=A->m_nextpkt and sets A->m_nextpkt=NULL. CPU1 reads A->m_nextpkt after CPU0 nulled it, sees NULL, sets ifq_head=NULL. Both CPUs now hold mbuf A. CPU1 (in IF_DRAIN) calls m_freem(A). CPU0 (in ng_h4_start) proceeds to use A: clist_btoq(mtod(A,...)) reads freed memory, and m_free(A) double-frees. The intermediate mbuf B (formerly A->m_nextpkt before CPU0 nulled it) is also leaked from the queue.

  6. The same race exists between ng_h4_start's IF_DEQUEUE and the IF_DRAIN in ng_h4_rcvmsg's NGM_H4_NODE_RESET handler (:886) and ng_h4_shutdown (:773, which drains without even holding NG_H4_LOCK).

Threat model & preconditions

  • Attacker position: local user holding the SYSCAP_NONET_NETGRAPH capability (required by ng_h4_open at sys/netgraph7/bluetooth/drivers/h4/ng_h4.c:157 to set the BTUARTDISC line discipline). This capability is distinct from root and may be delegated to services or non-root users via DragonFlyBSD's caps(9) framework.
  • Privileges gained or impact:
  • Minimum reliable: kernel panic (double-free detected by INVARIANTS allocator, or freed-mbuf KASSERT). System-wide DoS.
  • Speculative escalation: with mbuf-cluster heap grooming (spraying controlled content into the freed mbuf's slab slot), an attacker could control the mbuf's m_ext.ext_free function pointer, which is invoked by m_free β€” redirecting execution to a controlled address in ring 0 β†’ full local privilege escalation to uid 0. This chain is not yet verified.
  • Required config or capabilities: SYSCAP_NONET_NETGRAPH capability, SMP DragonFly system, and a serial/tty device reachable from the user (real UART, USB-serial, or pty pair).
  • Reachability: 1. Open a serial tty or pty pair, set BTUARTDISC ldisc via TIOCSETD (ng_h4_open creates the h4 netgraph node). 2. Open a netgraph control/data socket pair (NgMkSockNode) and connect a peer node to the h4 node's hook. 3. Send a burst of mbufs via the data socket to fill sc->outq (ng_h4_rcvdata enqueues them at :822). 4. Immediately issue an ngctl disconnect on the hook, triggering ng_h4_disconnect β†’ IF_DRAIN. 5. If the tty layer happens to call ng_h4_start (l_start) concurrently to drain the queue, the IF_DEQUEUE/IF_DRAIN race fires.

Proof of concept

PoC source: findings/poc/DF-0589/race.c (sketch β€” full driver to be materialized by the per-PoC verifier using ngctl(8) library calls or raw ng_socket sendmsg).

Build & run

cc -O2 -lpthread -o race race.c
./race          # as user with SYSCAP_NONET_NETGRAPH capability, SMP box

Expected output

Kernel panic: double-free detected by mbuf allocator INVARIANTS
    ...
backtrace:
    m_free+0x...
    ng_h4_start+0x...   (sys/netgraph7/bluetooth/drivers/h4/ng_h4.c:610)

Or, on a non-INVARIANTS kernel, silent corruption of the mbuf slab leading to a later panic from use-after-free in clist_btoq / m_freem.

For the escalation variant, the verifier would: (a) groom the mbuf slab between the m_freem (CPU1) and the m_free (CPU0) by spraying mbuf clusters with a fake m_ext containing a controlled ext_free (e.g. pointing at a ROP gadget or kernel function that overwrites ucred); (b) when CPU0 calls m_free on the reclaimed mbuf, ext_free fires in ring 0 β†’ code execution. The precise slab-size and victim-object analysis belongs in the per-PoC VERDICT.md.

Impact

  • Blast radius: any SMP DragonFly system that exposes the BTUARTDISC line discipline to a SYSCAP_NONET_NETGRAPH-capable user (Bluetooth services, jail setups delegating netgraph capabilities, etc.).
  • Severity rationale: Medium. The race is real and the resulting memory corruption is genuine (UAF/double-free), but exploitation is gated by a capability, the race window is narrow (the outq is only 12 mbufs deep and tty draining is typically bursty not continuous), and the escalation chain from "race wins" to "code exec" requires sophisticated heap grooming that is unverified. CVSS 3.1 base β‰ˆ 6.8 (Medium). The AGENT.md rubric's "kernel memory corruption β†’ High" bar is tempered here by the high race complexity (AC:H) and capability gate.
  • Reliability: race is retryable indefinitely; minimum-case panic likely reproducible within seconds-to-minutes on SMP hardware; escalation reliability is unverified.

The root cause is that NG_H4_LOCK is crit_enter (per-CPU, no cross-CPU exclusion) and is not even held during the IF_DEQUEUE/IF_PREPEND in ng_h4_start. The fix requires two changes:

  1. Upgrade NG_H4_LOCK from crit_enter/crit_exit to a real cross-CPU spinlock so that all outq operations are mutually exclusive across CPUs.
  2. Wrap the IF_DEQUEUE and IF_PREPEND in ng_h4_start with NG_H4_LOCK/NG_H4_UNLOCK.

Lock-ordering is safe: the only nesting is tp->t_token β†’ sc->lock (in ng_h4_start), which is a consistent order across all call sites. ng_h4_disconnect/rcvmsg/shutdown acquire only sc->lock (no tp->t_token), so no inversion.

diff --git a/sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h b/sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h
--- a/sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h
+++ b/sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h
@@ -83,8 +83,10 @@ typedef struct ng_h4_info {

    struct ifqueue      outq;   /* Queue of outgoing mbuf's */
 #define NG_H4_DEFAULTQLEN   12     /* XXX max number of mbuf's in outq */
-#define    NG_H4_LOCK(sc)      crit_enter();
-#define NG_H4_UNLOCK(sc)   crit_exit();
+   struct spinlock     sc_lock;    /* Protects outq + parser state */
+#define    NG_H4_LOCK(sc)      spin_lock(&(sc)->sc_lock)
+#define NG_H4_UNLOCK(sc)   spin_unlock(&(sc)->sc_lock)

 #define NG_H4_IBUF_SIZE        1024    /* XXX must be big enough to hold full
                       frame */
diff --git a/sys/netgraph7/bluetooth/drivers/h4/ng_h4.c b/sys/netgraph7/bluetooth/drivers/h4/ng_h4.c
--- a/sys/netgraph7/bluetooth/drivers/h4/ng_h4.c
+++ b/sys/netgraph7/bluetooth/drivers/h4/ng_h4.c
@@ -174,6 +174,8 @@ ng_h4_open(struct cdev *dev, struct tty *tp)
    sc->outq.ifq_maxlen = NG_H4_DEFAULTQLEN;
    ng_callout_init(&sc->timo);

+   spin_init(&sc->sc_lock, "ng_h4");
+
    NG_H4_LOCK(sc);

    /* Setup netgraph node */
@@ -589,9 +591,13 @@ ng_h4_start(struct tty *tp)
 #else
    while (1) {
 #endif
        /* Remove first mbuf from queue */
+       NG_H4_LOCK(sc);
        IF_DEQUEUE(&sc->outq, m);
+       NG_H4_UNLOCK(sc);
        if (m == NULL)
            break;

@@ -612,8 +618,11 @@ ng_h4_start(struct tty *tp)

        /* Put remainder of mbuf chain (if any) back on queue */
        if (m != NULL) {
+           NG_H4_LOCK(sc);
            IF_PREPEND(&sc->outq, m);
+           NG_H4_UNLOCK(sc);
            break;
        }

Note: the same pattern (crit_enter as the only outq lock, unprotected IF_DEQUEUE in the l_start method) exists in sys/netgraph7/tty/ng_tty.c and should be fixed there as well β€” out of scope for this audit but flagged for the maintainer.

References

  • DragonFlyBSD crit_enter(9): blocks local-CPU preemption only, not other CPUs.
  • DragonFlyBSD spinlock(9): cross-CPU mutual exclusion.
  • sys/net/if_var.h:120-126 (struct ifqueue) and IF_DEQUEUE/IF_DRAIN macros in sys/net/if_var.h β€” non-atomic RMW on singly-linked list.
  • The same class of bug was historically present in ppp(4) over tty and fixed in the network-stack push to per-queue locks; ng_h4 was missed.

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-0589 Β· 12 files
FileTypeDescriptionSize
race.c trigger-source netgraph7-format race harness (data flood + RESET vs pty/tty outq) 9.6 KB view raw
README.md readme original PoC description and build/run instructions 2.5 KB ↓ raw
VERDICT.md verdict full analysis: race confirmed at code level, not triggerable on this guest 9.1 KB ↓ raw
fix.diff suggested-fix git-apply-able fix: upgrade NG_H4_LOCK to spinlock + wrap IF_DEQUEUE/IF_PREPEND 1.7 KB view raw
build.sh build-script build netgraph7 modules + PoC 554 B view raw
run.sh run-script load modules + run race PoC 789 B view raw
fix_build.log build-log fixed module compilation output 623 B view raw
run.log run-log PoC run output (30s, no panic on pty) 366 B view raw
panic.txt panic-signature UNRELATED panic in socket receive path (chunk_mark_allocated), NOT the DF-0589 race 607 B view raw
env.txt environment uname, cc version, kern.version 247 B view 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 description and build/run instructions
↓ download raw

DF-0589 β€” PoC: race sc->outq IF_DEQUEUE (ng_h4_start) vs IF_DRAIN (ng_h4_disconnect)

Privileged-capability local race. The per-node mbuf output queue sc->outq is mutated from two execution contexts (tty l_start callback vs netgraph disconnect/reset/shutdown) that hold no common cross-CPU lock: NG_H4_LOCK is just crit_enter() (per-CPU), and ng_h4_start doesn't even take that around its IF_DEQUEUE/IF_PREPEND. On SMP, concurrent dequeue and drain can both return the same mbuf β†’ UAF / double-free.

Files

  • race.c β€” sketch driver (flood outq via ng data socket, race disconnect against tty l_start).
  • (added by per-PoC verifier) full race.c rewrite with proper ng_socket / ngctl plumbing, build.sh, run.sh, build.log, run.log, VERDICT.md, manifest.json, and β€” if escalation is developed β€” exploit.c plus the mbuf-slab grooming recipe.

Build & run

cc -O2 -lpthread -o race race.c
./race /dev/cuaU0          # user with SYSCAP_NONET_NETGRAPH capability, SMP

Expected first outcome

Kernel panic from double-free (mbuf allocator INVARIANTS, or freed-mbuf KASSERT), with backtrace pinning the fault inside m_free called from ng_h4_start (sys/netgraph7/bluetooth/drivers/h4/ng_h4.c:610):

Kernel panic: mbuf double-free / use-after-free
    m_free+0x...
    ng_h4_start+0x...
    l_start ...

Or, on a non-INVARIANTS kernel, silent corruption of the mbuf slab leading to a later panic from use-after-free in clist_btoq/m_freem.

Notes for the per-PoC verifier

  • The race window is narrow: the outq is only 12 mbufs deep, and tty draining is typically bursty not continuous. Plan for many flood+disconnect iterations (thousands-to-millions) on SMP hardware. Adjust FLOOD_COUNT and the inter-disconnect pause to maximize overlap with the tty l_start callback.
  • The escalation variant (mbuf-slab grooming β†’ controlled m_ext.ext_free β†’ ring-0 code execution) requires a sizeof(struct mbuf) and victim-object analysis; document the chosen victim in VERDICT.md. If the heap layout does not admit a stable primitive, the verdict should reflect DoS_confirmed / escalation_unverified and the finding stays at Medium.
  • Verify the fix with git apply findings/poc/DF-0589/fix.diff (upgrading NG_H4_LOCK to a spinlock and wrapping IF_DEQUEUE/IF_PREPEND in ng_h4_start); after the fix the race should no longer fire.
  • The same pattern exists in sys/netgraph7/tty/ng_tty.c (out of this file's scope but flagged in the finding markdown for the maintainer).
VERDICT.md verdict full analysis: race confirmed at code level, not triggerable on this guest
↓ download raw

DF-0589 β€” VERDICT

Verdict: RACE CONFIRMED (code-level); NOT TRIGGERABLE ON THIS GUEST (sio-console-gated)

The race condition described in the finding is real at the code level β€” all the structural preconditions hold. However, triggering it live requires a real serial port with the sio driver (NOT a pty), and the only sio port on this guest is the kernel console (sio0), which cannot be freed for BTUARTDISC. The race is also gated behind SYSCAP_NONET_NETGRAPH (not a default unprivileged capability) and requires the netgraph7 module stack to be loaded by root (netgraph7 is compiled OUT of the default X86_64_GENERIC kernel).


What was confirmed

1. NG_H4_LOCK is per-CPU only (the root cause)

sys/netgraph7/bluetooth/drivers/h4/ng_h4_var.h:88-89:

#define NG_H4_LOCK(sc)    crit_enter();
#define NG_H4_UNLOCK(sc)  crit_exit();

DragonFly crit_enter(9) blocks preemption and defers IPIs on the current CPU only β€” it provides zero cross-CPU exclusion. Two CPUs can both be inside NG_H4_LOCK simultaneously.

2. ng_h4_start does IF_DEQUEUE without NG_H4_LOCK

sys/netgraph7/bluetooth/drivers/h4/ng_h4.c:572-648: - Line 579: lwkt_gettoken(&tp->t_token); β€” acquires the tty token only. - Line 592: IF_DEQUEUE(&sc->outq, m); β€” no NG_H4_LOCK held. - Line 615: IF_PREPEND(&sc->outq, m); β€” no NG_H4_LOCK held. - Lines 601/620/638: NG_H4_LOCK is acquired only briefly for stat counter updates.

3. ng_h4_disconnect / rcvmsg / shutdown do IF_DRAIN with crit_enter only

  • ng_h4_disconnect (line 729-743): NG_H4_LOCK (crit_enter) β†’ IF_DRAIN (line 735) β†’ unlock. Does NOT acquire tp->t_token.
  • ng_h4_rcvmsg NGM_H4_NODE_RESET (line 886): IF_DRAIN inside NG_H4_LOCK.
  • ng_h4_shutdown (line 773): IF_DRAIN without even NG_H4_LOCK.

4. The ONLY concurrent execution path: sio siopoll β†’ l_start

The finding claims ng_h4_start runs from the tty l_start line-discipline callback concurrently with netgraph methods. This is correct β€” but with a critical DragonFly-specific nuance:

Exhaustive search of the entire sys/ tree reveals that linesw[].l_start is called from exactly one place in the kernel:

sys/dev/serial/sio/sio.c:2241    (*linesw[tp->t_line].l_start)(tp);

This is inside siopoll() (line 2176), which is registered as a Software Interrupt (SWI) handler:

sys/dev/serial/sio/sio.c:1209    register_swi_mp(SWI_TTY, siopoll, ...);

The general tty layer's ttstart() (sys/kern/tty.c:1549) does NOT call l_start β€” it only calls tp->t_oproc. On DragonFly, pty-based triggering does not work because l_start is never invoked for pty devices.

So the race fires ONLY on systems with a real sio serial port where: 1. siopoll (SWI context, CPU A) calls l_start β†’ ng_h4_start β†’ IF_DEQUEUE 2. The netgraph writer thread (CPU B) processes ng_h4_disconnect/RESET β†’ IF_DRAIN 3. Both operate on sc->outq with no common cross-CPU lock β†’ double-free / UAF

5. FORCE_WRITER serializes netgraph-internal calls

NG_NODE_FORCE_WRITER(sc->node) at line 215 ensures all netgraph methods (rcvdata, rcvmsg, disconnect, shutdown) are serialized. The callout path (ng_h4_process_timeout β†’ ng_h4_start) also goes through ng_callout which sets NGQF_WRITER (sys/netgraph7/netgraph/ng_base.c:~3260), so it is also serialized. Only the sio siopoll β†’ l_start path bypasses FORCE_WRITER.


Why it was NOT triggered live on this guest

  1. pty doesn't call l_start: The DragonFly tty layer's ttstart() calls tp->t_oproc, not linesw[].l_start. A pty-based PoC cannot exercise the race. (Confirmed: 80 seconds Γ— 4M RESET + 4M data packets on pty β†’ no panic.)

  2. sio0 is the console: The guest has one sio port (sio0 at 0x3f8, IRQ4) which is the kernel console. Opening it for BTUARTDISC returns EBUSY because the console subsystem holds it. Getty was killed but the kernel console driver retains the reference.

  3. Netgraph7 compiled out of GENERIC: NNETGRAPH7_BLUETOOTH_H4 = 0 in the default kernel config. Requires root kldload of netgraph7 core + ng_socket + ng_h4 modules.

  4. Capability gate: ng_h4_open (line 157) requires SYSCAP_NONET_NETGRAPH β€” not a default unprivileged capability. Verified: maxx (uid 1001) gets EPERM on TIOCSETD BTUARTDISC.


Impact assessment

Factor Assessment
Race real? YES β€” confirmed by exhaustive source analysis
Triggerable via pty? NO β€” DragonFly tty layer doesn't call l_start for ptys
Triggerable via sio? YES β€” siopoll SWI calls l_start; requires real serial port
Reachable unprivileged? NO β€” needs SYSCAP_NONET_NETGRAPH + netgraph7 loaded (root)
Default kernel? NO β€” netgraph7 compiled out of X86_64_GENERIC
Exploitation chain? DoS/panic via mbuf double-free (INVARIANTS catches it)
Privilege escalation? NOT assessed β€” not reachable from unprivileged on default kernel
Severity Medium is appropriate (HW + capability + module-loading gated)

Exploit chain (Phase 6 assessment)

This is a memory-corruption class (potential mbuf double-free / UAF), so Phase 6 escalation assessment is required. However:

Valid hard blocker β€” root-only reachability: The bug's write primitive (IF_DEQUEUE / IF_DRAIN on a shared mbuf queue) is reachable ONLY when: 1. An admin has loaded the netgraph7 module stack (root kldload) 2. The user holds SYSCAP_NONET_NETGRAPH (delegated capability) 3. A real sio serial port is available and not the console

On the default GENERIC kernel, netgraph7 is not compiled in and the code path is dead. There is no unprivileged path to the write without root module loading. This meets the Phase 6 valid hard blocker criterion: "The write is reachable only from an already-root context (kldload / wheel-only ioctl)".

Even with root-loaded modules + delegated capability, the race is narrow (2-3 instruction window in IF_DEQUEUE vs IF_DRAIN) and the realistic outcome is a DoS panic (INVARIANTS mbuf double-free detection), not reliable code execution. The speculative m_ext.ext_free hijack chain described in the finding is unverified.

No uid0 escalation was attempted because the bug is not reachable from an unprivileged user on the default kernel. Documented as a root→kernel hardening gap with DoS impact.


PoC changes

Rewrote race.c from the non-compiling sketch into a working harness that: - Constructs netgraph7-format ng_mesg manually (NG_VERSION=8, 32-bit arglen) since the system's libnetgraph/ngctl use the incompatible old-netgraph ABI - Creates pty + BTUARTDISC + ng socket node + hook connection correctly - Races data flooding + NGM_H4_NODE_RESET (IF_DRAIN) for 60-80 seconds - Also created race_sio.c variant targeting real sio ports (not usable on this guest due to console conflict, but correct for systems with a free serial port)

The pty-based PoC confirmed 4M+ RESET + 4M+ data iterations with no panic, which is expected β€” the pty path doesn't exercise l_start. The PoC is kept as a functional harness; on a system with a free sio port and the right capabilities, it would exercise the actual race via race_sio.


Upgrade NG_H4_LOCK from crit_enter/crit_exit to a real spinlock, and wrap the IF_DEQUEUE/IF_PREPEND in ng_h4_start with it. This matches the finding's recommendation. See fix.diff.

The same pattern (crit_enter as the only outq lock, unprotected IF_DEQUEUE in the l_start method) also exists in sys/netgraph7/tty/ng_tty.c and should be fixed there as well (flagged for the maintainer, out of scope for this finding).


Fix validation

The fix (fix.diff) was applied to the in-guest source and the ng_h4.ko module was rebuilt successfully. All three netgraph7 modules (netgraph core, ng_socket, ng_h4) loaded correctly. The PoC ran for 30 seconds (1.37M data packets + 1.36M RESET messages) on the fixed module with no panic and the guest stayed up.

Note: Since the pty-based PoC cannot exercise the actual race path (l_start is only called by siopoll for real serial ports), this validates that the fix compiles, loads, and does not introduce regressions β€” but cannot directly demonstrate the fix preventing the race (which requires a free sio serial port).

fix_status: not_testable β€” the race cannot be triggered on this guest (no free sio port, pty doesn't call l_start), so the fix's effectiveness against the specific race cannot be verified live. The fix is compile-validated, load-validated, and correct by code inspection.


Unrelated panic observed

During heavy PoC operations (1M+ ng_socket control messages), an unrelated panic occurred in the socket receive path (NOT in ng_h4):

panic: memory chunk 0xfffff8004f0a407f is already allocated!
chunk_mark_allocated β†’ _kmalloc β†’ dup_sockaddr β†’ soreceive β†’ kern_recvmsg

This is a separate issue (likely a double-allocation or UAF in the ng_socket/ netgraph7 message handling under heavy load), not the DF-0589 outq race. Documented in panic.txt for reference.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

not_testable: the DF-0589 race cannot be triggered on this guest (pty doesn't call l_start; sio0 is the console), so the fix's effectiveness against the specific race cannot be verified live. The fix.diff was applied to in-guest source, the ng_h4.ko module compiled successfully (22424 bytes, was 19432), all three netgraph7 modules loaded correctly, and the PoC ran 30s (1.37M data + 1.36M RESETs) on the fixed module without panic or regression. The fix is compile-validated, load-validated, and correct by code inspection (spinlock provides cross-CPU exclusion that crit_enter lacked).

Fixed module build: cc ... -c ng_h4.c -> ng_h4.ko (22424 bytes) BUILD=0. Module load: kldload ng_h4.ko -> H4=0, kldstat shows 3000 size. PoC on fixed module: 30s run, data=1369497 resets=1363264, RACE_EXIT=0, guest up (no regression). Baseline: same PoC on unpatched module also exits 0 (pty doesn't exercise the race path on either).
↓ fix.diffn/a (module-level fix, not kernel build; netgraph7 compiled out of GENERIC)

Confirmed kernel references

Detail

Exploit chain

none -- valid hard blocker: root-only reachability. The write primitive (IF_DEQUEUE/IF_DRAIN on shared mbuf outq) is reachable ONLY when: (1) root has kldloaded the netgraph7 module stack (compiled out of default GENERIC), (2) the user holds SYSCAP_NONET_NETGRAPH (not a default unprivileged capability), and (3) a real sio serial port is available. There is no unprivileged path to the write without root module loading -- meets Phase 6 valid hard blocker criterion ('reachable only from already-root context via kldload'). Even with root setup, the race is narrow (2-3 instruction IF_DEQUEUE vs IF_DRAIN window) and only the sio siopoll->l_start path bypasses FORCE_WRITER. Realistic outcome is DoS panic (INVARIANTS mbuf double-free detection), not reliable code execution. No uid0 escalation assessed because bug is unreachable from unprivileged on default kernel.

Evidence (decisive lines)

pty PoC (80s, 4M+ iters): data=4097982 resets=4053430, RACE_EXIT=0, no panic. Guest stayed up. Unprivileged test: TIOCSETD BTUARTDISC -> EPERM (SYSCAP_NONET_NETGRAPH gate). sio0 console conflict: open /dev/cuaa0 -> EBUSY. Unrelated panic: 'panic: memory chunk is already allocated!' in chunk_mark_allocated->_kmalloc->dup_sockaddr->soreceive->kern_recvmsg (NOT ng_h4). Fixed module: compiles OK (22424 bytes), loads OK (kldstat 3000 size), PoC runs 30s without regression.

PoC changes

Rewrote race.c from the non-compiling sketch into a working harness: (1) constructs netgraph7-format ng_mesg manually (NG_VERSION=8, 32-bit arglen) since system libnetgraph/ngctl use the incompatible old-netgraph ABI; (2) correctly creates pty + BTUARTDISC ldisc + ng socket node (bind csock, connect dsock to same node) + NGM_CONNECT hook; (3) races data flooding + NGM_H4_NODE_RESET (IF_DRAIN) with ng_h4_start (IF_DEQUEUE). Added build.sh, run.sh, VERDICT.md, fix.diff, manifest.json, and all logs.

Verified recommended fix

Upgrade NG_H4_LOCK from crit_enter/crit_exit to a real spinlock (struct spinlock sc_lock) in ng_h4_var.h, init it with spin_init in ng_h4_open, and wrap the IF_DEQUEUE and IF_PREPEND in ng_h4_start with NG_H4_LOCK/NG_H4_UNLOCK. This matches the finding's proposal. Lock order is safe: tp->t_token -> sc_lock in ng_h4_start; sc_lock alone in disconnect/rcvmsg/shutdown. Full git-apply-able diff in fix.diff (includes sys/spinlock.h + sys/spinlock2.h for the spinlock API).

Verdict

RACE CONFIRMED at code level but NOT TRIGGERABLE on this guest. The race IS real: NG_H4_LOCK is crit_enter (per-CPU only, ng_h4_var.h:88-89), ng_h4_start does IF_DEQUEUE without it (ng_h4.c:592), and ng_h4_disconnect/rcvmsg/shutdown do IF_DRAIN with only crit_enter (ng_h4.c:735/886/773). HOWEVER, exhaustive source analysis revealed that linesw[].l_start is called ONLY from sys/dev/serial/sio/sio.c:2241 (inside siopoll, a SWI handler) -- the DragonFly tty layer's ttstart() calls t_oproc, NOT l_start. This means pty-based triggering does NOT work (confirmed: 80s, 4M+ data + 4M+ RESET iterations on pty -> no panic). The ONLY concurrent execution path that bypasses FORCE_WRITER is sio's siopoll -> l_start -> ng_h4_start on a real serial port. The guest's sio0 is the kernel console and cannot be freed for BTUARTDISC. Additionally: netgraph7 is compiled OUT of the default X86_64_GENERIC kernel (NNETGRAPH7_BLUETOOTH_H4=0), requiring root kldload; and ng_h4_open requires SYSCAP_NONET_NETGRAPH capability (maxx uid 1001 gets EPERM). An unrelated panic occurred in the socket receive path (chunk_mark_allocated->kmalloc->dup_sockaddr->soreceive) during heavy ng_socket operations -- this is NOT the DF-0589 outq race.