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

Use-after-free of struct ngd_private in all cdevsw methods: disconnect frees priv immediately after DragonFly's asynchronous destroy_dev()

Summary

ng_device_disconnect() calls destroy_dev() which in DragonFly is purely asynchronous (kern_conf.c:345-354 -> devfs fire-and-forget; no draining, no ops revocation, si_drv1 never cleared), then immediately IF_DRAIN+kfree(priv). Every cdevsw method fetches priv from dev->si_drv1 and an open fd keeps dispatching through it after destroy: ngdopen/ngdclose do a UAF WRITE (priv->flags |= NGDF_OPEN), ngdread IF_DEQUEUEs the drained-then-freed readq yielding dangling mbufs whose m_free is a double free and msleeps on a destroyed freed mutex, ngdwrite dereferences freed priv->hook into netgraph dispatch, ngdpoll UAF-reads the queue. LATENT: guest-verified non-compilable unwired FreeBSD-port rot (64-error build log; absent from Makefile SUBDIR; no /boot/kernel/ng_device.ko) and both trigger sides require root - hence Medium not High. Root-gated heap UAF read/write in M_NETGRAPH chunks + mbuf double-free + freed-hook walk if the port is completed without the lifetime fix; fix.diff (refcounted priv, si_drv1 cleared under lock, last-reference free) authored but not compile-testable (same rot). v1 twin sets DF-0660..666/DF-2570..2574 target the unrelated 633-LOC v1 implementation and carry nothing into this import (no VLA, no connection list, no m_devget).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3547 Β· 10 files
FileTypeDescriptionSize
README.md β€” 2.1 KB ↓ raw
VERDICT.md β€” 6.6 KB ↓ raw
stalefd.c β€” 1.5 KB view raw
build.sh β€” 162 B view raw
run.sh β€” 192 B view raw
build.log β€” 12.6 KB view raw
env.txt β€” 1.4 KB view raw
fix.diff β€” 4.4 KB view raw
verdict.json β€” 3.9 KB view raw
manifest.json β€” 968 B view raw

DF-3547 β€” use-after-free of struct ngd_private in ng7 ng_device cdevsw methods vs ng_device_disconnect

One-line summary

ng_device_disconnect() calls DragonFly's asynchronous destroy_dev() and then immediately frees priv; every cdevsw method (ngdopen/ngdclose/ngdread/ngdwrite/ngdpoll) still reaches priv through dev->si_drv1, producing a kernel heap use-after-free (read+write) once the node type is built.

IMPORTANT STATUS NOTE (read first)

sys/netgraph7/ng_device.c is dead code in this tree: it does not compile against DragonFly headers (it is unmodified FreeBSD porting rot from the 2008 ng7 import β€” struct cdevsw, d_open_t, msleep, IF_LOCK, _IF_ENQUEUE, IFQ_IS_EMPTY, IFQ_SET_MAXLEN, m_uiotombuf, MTX_DEF, ifqueue.ifq_mtx, unit2minor do not exist in DF), it is absent from sys/netgraph7/Makefile SUBDIR, and no kernel config defines NETGRAPH7_DEVICE. ./build.sh reproduces the compile failure on the guest. Therefore the UAF cannot be triggered on any buildable kernel today; the verdict is build_failed with the defect proven by source-level analysis (see VERDICT.md).

Build (reproduces non-compilability)

sh build.sh        # guest, root β€” out-of-tree kmod build of ng_device.c
                   # β†’ 64 compile errors, full log in build.log

Run (would trigger the UAF if the module existed)

cc -O2 -o stalefd stalefd.c     # on the guest
sh run.sh                       # documents the intended sequence:
                                #   open /dev/ngd0 -> keep fd -> ngctl rmhook
                                #   -> read()/write() on the stale fd => UAF

Success criterion (if the node type ever becomes loadable): kernel panic / INVARIANTS double-free / objcache corruption from IF_DEQUEUE on the freed queue or NG_SEND_DATA_ONLY(..., priv->hook /* freed */, ...).

Fix

fix.diff β€” reference-counted priv (acquire/release around every cdevsw method, si_drv1 cleared under the lock before destroy_dev, free deferred to last release). Cannot be compile-validated for the same reason (file rot) β†’ fix_status not_testable.

VERDICT.md
↓ download raw

DF-3547 VERDICT β€” UAF of struct ngd_private (ng7 ng_device) vs async destroy_dev()

Status: build_failed / not runtime-testable (the containing file cannot be compiled into any DragonFly kernel β€” proven on the guest, see build.log). The defect itself is certain by source-level analysis; every link in the chain is cited below.

The bug

ng_device_disconnect() (sys/netgraph7/ng_device.c:292-312) tears the node down when its hook is removed:

299:  destroy_dev(priv->ngddev);          /* (1) ASYNCHRONOUS in DragonFly */
300:  mtx_destroy(&priv->ngd_mtx);
302:  IF_DRAIN(&priv->readq);             /* frees every queued mbuf */
303:  mtx_destroy(&(priv)->readq.ifq_mtx);
305:  free_unr(ngd_unit, priv->unit);
307:  kfree(priv, M_NETGRAPH);            /* (2) priv freed immediately */

(1) In DragonFly, destroy_dev() (sys/kern/kern_conf.c:345-354) only calls devfs_destroy_dev() (sys/vfs/devfs/devfs_core.c:759-763), which calls devfs_msg_send() (devfs_core.c:1113-1127) β€” a fire-and-forget lwkt_sendmsg to the devfs core thread. It returns immediately: it does not wait for in-flight cdevsw method invocations to drain, does not wait for blocked readers, and does not revector the device's ops. devfs_destroy_dev_worker() (devfs_core.c:1440-1459) merely unlinks the cdev and drops sysrefs. This is unlike FreeBSD, whose destroy_dev() drains cdevsw users β€” the ng7 file was imported from FreeBSD assuming those semantics.

(2) Meanwhile every device method fetches the private structure straight from the cdev:

  • ngdopen ng_device.c:334 priv = dev->si_drv1; β†’ :338-340 mtx_lock(&priv->ngd_mtx); priv->flags |= NGDF_OPEN; β€” UAF write into the freed chunk
  • ngdclose ng_device.c:351 priv = dev->si_drv1; β†’ :354-356 same write
  • ngdread ng_device.c:411 priv = dev->si_drv1; β†’ :419 IF_DEQUEUE(&priv->readq, m) (queue drained+freed at :302 β†’ dangling mbufs β†’ m_free() of freed mbufs = double free), :423-427 msleep(priv, &priv->ngd_mtx, ...) on a destroyed, freed mutex
  • ngdwrite ng_device.c:454 priv = dev->si_drv1; β†’ :469 NG_SEND_DATA_ONLY(error, priv->hook, m) β€” dereferences priv->hook (freed memory read) and, if the stale value survives, hands the mbuf to a freed hook pointer, walking NG_HOOK_PEER/NG_PEER_NODE (ng_address_hook, ng_base.c:3062-3065)
  • ngdpoll ng_device.c:481 priv = dev->si_drv1; β†’ :485 IFQ_IS_EMPTY(&priv->readq) β€” UAF read

Why a stale path exists at all: an open file descriptor keeps the vnode β†’ v_rdev alive (sys/vfs/devfs/devfs_vnops.c:1272-1295 devfs_spec_read β†’ reference_dev(dev) β†’ dev_dread(dev, ...) β†’ release_dev(dev)), and the destroy path never clears si_drv1 nor revokes ops β€” so every later read(2)/write(2)/poll(2) on the still-open fd dispatches ngdread/ngdwrite/ngdpoll with priv already freed. Additionally, because destroy_dev() is async, even a fresh open("/dev/ngd0") that wins the race against the devfs core thread's unlink reaches ngdopen with dangling si_drv1.

Trigger (if the node type were buildable)

ngctl mkpeer . device dl dl      # root: creates ng_device node + hook, /dev/ngd0 appears
./stalefd &                      # opens /dev/ngd0, parks on the fd
ngctl rmhook <device-node> dl    # -> ng_device_disconnect -> destroy_dev(async) + kfree(priv)
# resume stalefd: read(fd)/write(fd) on the freed priv

read() first dequeues from the drained-then-freed queue (double free of the mbufs that IF_DRAIN already freed) — an INVARIANTS/objcache panic is the expected signature; write() drives the freed priv->hook into netgraph dispatch. With heap grooming (spraying M_NETGRAPH-sized chunks — e.g. more ng node privates) this is a controlled-ish UAF-write/arb-walk primitive. Both sides require root (device is UID_ROOT/GID_WHEEL 0600, ng control sockets require caps_priv_check(SYSCAP_RESTRICTEDROOT) — sys/netgraph7/socket/ng_socket.c:182-184), so the realistic ceiling is root→kernel corruption, not unpriv→root.

Why it could NOT be verified at runtime (hard blocker, demonstrated)

  1. sys/netgraph7/Makefile SUBDIR has no device entry β†’ no module is ever built (/boot/kernel/ng_device.ko absent on the guest, ls in env.txt).
  2. sys/conf/files:1699 gates it behind optional netgraph7_device; no kernel config in the tree defines it (grep -rn NETGRAPH7 /usr/src/sys/config/* β†’ 0 hits).
  3. The file does not compile: out-of-tree kmod build on the guest produces 64 errors β€” 'struct cdevsw' has no member named 'd_open', unknown type name 'd_poll_t', 'D_VERSION' undeclared, 'MTX_DEF' undeclared, 'struct ifqueue' has no member named 'ifq_mtx', implicit declaration of 'IF_LOCK', implicit declaration of 'IFQ_IS_EMPTY', implicit declaration of 'msleep'… (full log: build.log). These are FreeBSD-only primitives that never existed in DragonFly.

A faithful runtime demonstration would require first completing the FreeBSDβ†’DF port (rewriting the cdevsw as struct dev_ops, msleepβ†’ssleep/tsleep with the mutex-releasing semantics, the ifq shim…), i.e. executing the maintainer's porting work and then testing code that is no longer byte-identical to the tree. That was judged to add no evidentiary value beyond the cited source chain above, so the honest classification is build_failed / not runtime-testable, defect certain by code reading.

Kernel references

Reference-count priv: acquire under ngd_mtx in every cdevsw method (check a dying flag), drop the node's base reference in disconnect after clearing si_drv1 under the same lock, and free priv only when the last method reference returns β€” see fix.diff. (The file also needs its port finished or removed before any of this is reachable; tracked as DF-3549.)

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
baseline no→ patch + rebuild →patched clean

Fix validation impossible on this guest: the unmodified file itself does not compile (64 errors, build.log), so neither a baseline nor a single-fix kernel exercising this code can be built. fix.diff is a source-level reference fix (refcounted priv lifetime, si_drv1 detached under lock before async destroy_dev, free deferred to last cdevsw reference); it should be re-validated as part of completing the ng7 device port (DF-3549).

build.log (file rot); VERDICT.md 'Why it could NOT be verified'; no fix_build.log/fix_run.log possible
↓ fix.diffper-fix-DF-3547

Confirmed kernel references

Detail

Evidence (decisive lines)

build.log (64 compile errors, out-of-tree kmod build on guest); env.txt (no ng_device.ko, no NETGRAPH7 in any kernel config, ng_etf/ng_rfc1490 load fine); VERDICT.md full path:line chain; fix.diff reference fix

PoC changes

PoC authored fresh (stalefd.c + build.sh); no seed existed. Build harness had to replicate the in-tree include environment via symlinks because the file is unwired.

Verified recommended fix

Refcount struct ngd_private: acquire under ngd_mtx in every cdevsw method with a dying flag; clear si_drv1 under the lock before async destroy_dev; free priv on last release instead of in disconnect (fix.diff).

Verdict

Certain use-after-free by source analysis, not runtime-testable: ng_device_disconnect() (ng_device.c:299-307) calls DragonFly's asynchronous destroy_dev() (kern_conf.c:345-354 -> devfs_core.c:1113-1127 fire-and-forget lwkt message; no draining, no ops revocation, si_drv1 never cleared) and then immediately kfree()s priv, while ngdopen/ngdclose/ngdread/ngdwrite/ngdpoll (ng_device.c:334/351/411/454/481) still fetch priv via dev->si_drv1 β€” an open fd keeps dispatching through it (devfs_vnops.c:1272-1295). Runtime verification is impossible because sys/netgraph7/ng_device.c is FreeBSD-port rot that cannot compile (64 errors: no struct cdevsw/d_open_t/msleep/IF_LOCK/_IF_ENQUEUE/IFQ_IS_EMPTY/MTX_DEF/ifq_mtx in DF β€” build.log) and is wired into no build (no Makefile SUBDIR, no NETGRAPH7_DEVICE config). Root-only trigger bounds impact; latent until the port is completed. fix.diff (refcounted priv + si_drv1 cleared under lock) also not compile-testable for the same reason.