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

Missing NULL check on sbcreatecontrol() in SO_PASSCRED path -> kernel NULL-deref panic

Field Value
ID DF-0011
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:N/A:H
CWE CWE-476 NULL Pointer Dereference
File sys/kern/uipc_usrreq.c
Lines 694-698
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

In the SO_PASSCRED synthesis block of uipc_send() (AF_UNIX SOCK_DGRAM), the return value of sbcreatecontrol() is never checked. sbcreatecontrol() returns NULL on mbuf exhaustion (M_NOWAIT allocation failure); the code then calls unp_internalize(NULL, ...) which does cm = mtod(control, ...) β€” dereferencing NULL and panicking the kernel. This is a local denial-of-service triggerable by an unprivileged user who has induced mbuf pressure and sends to a SO_PASSCRED-marked AF_UNIX datagram socket without including an SCM_CREDS control message.

Root cause

sys/kern/uipc_usrreq.c:694-698:

if (ncon == NULL) {                              /* no existing SCM_CREDS found */
    ncon = sbcreatecontrol(&cred, sizeof(cred),
                           SCM_CREDS, SOL_SOCKET);   /* may return NULL */
    unp_internalize(ncon, msg->send.nm_td);          /* ncon used unchecked  */
    *mp = ncon;
}

sbcreatecontrol() (sys/kern/uipc_sockbuf.c) returns NULL on a CMSG_SPACE > MCLBYTES request or an m_getl(..., M_NOWAIT) allocation failure. The return is not tested. unp_internalize then unconditionally dereferences via mtod(control) at sys/kern/uipc_usrreq.c:1706, i.e. ((struct cmsghdr *)(NULL->m_data)) β€” a NULL page fault that panics the kernel.

Threat model & preconditions

  • Attacker position: unprivileged local user.
  • Privileges gained or impact: full-system denial of service (kernel panic). No memory corruption (clean NULL deref).
  • Required config or capabilities: default kernel; the attacker must induce enough mbuf pressure to make the M_NOWAIT allocation fail (e.g. many sockets with full buffers), then send to an SO_PASSCRED AF_UNIX SOCK_DGRAM receiver without an SCM_CREDS cmsg.
  • Reachability: socketpair(AF_UNIX, SOCK_DGRAM) + SO_PASSCRED + a plain send().

Proof of concept

PoC source: findings/poc/DF-0011/nopasscred_panic.c

Phase 1 exhausts mbufs (many socketpairs with full buffers); Phase 2 fires the trigger sends.

Build & run

cc -o nopasscred_panic findings/poc/DF-0011/nopasscred_panic.c
./nopasscred_panic        # as a non-root user

Expected output

Under mbuf pressure:

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x0
... in unp_internalize ...

(With ample mbufs the allocation succeeds and no panic occurs β€” exit code 2.)

Impact

Local DoS (kernel panic). Reliability depends on inducing allocation failure, hence AC:H / Low. No integrity/confidentiality impact.

Check sbcreatecontrol() for NULL and bail cleanly with ENOBUFS:

--- a/sys/kern/uipc_usrreq.c
+++ b/sys/kern/uipc_usrreq.c
@@ -693,8 +693,13 @@
            if (ncon == NULL) {
                ncon = sbcreatecontrol(&cred, sizeof(cred),
                               SCM_CREDS, SOL_SOCKET);
-               unp_internalize(ncon, msg->send.nm_td);
-               *mp = ncon;
+               if (ncon != NULL) {
+                   unp_internalize(ncon, msg->send.nm_td);
+                   *mp = ncon;
+               } else {
+                   error = ENOBUFS;
+                   break;
+               }
            }

(The surrounding error/cleanup path already handles error != 0; adjust the break/goto to match the loop's cleanup convention.)

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/uipc_usrreq.c.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0011 Β· 15 files
FileTypeDescriptionSize
nopasscred_panic.c trigger-source concurrent plain-mbuf-exhaustion ramp + no-control SO_PASSCRED trigger -> sbcreatecontrol NULL -> panic 6.3 KB view raw
flood_trigger.c auxiliary-source earlier hold-open mbuf-exhaustion variant used during analysis 3.2 KB view raw
README.md readme build/run/expected + root-cause + the 2:1 plain:pkthdr strategy 2.7 KB ↓ raw
VERDICT.md verdict full REPRODUCED + fix-validation narrative: NULL-check missing -> deref at 0x10; before/after kernel build 8.4 KB ↓ raw
build.sh repro-script cc -o nopasscred_panic nopasscred_panic.c -lpthread 141 B view raw
run.sh repro-script ./nopasscred_panic (PANICS the guest on #0) 318 B view raw
build.log build-log final successful PoC build, full output 125 B view raw
run.log run-log baseline run record on UNPATCHED #0: build+launch+serial panic excerpt 3.2 KB view raw
panic.txt panic-signature Fatal trap 12, fault vaddr 0x10, Stopped at unp_internalize+0x11 on #0 baseline 804 B view raw
env.txt environment uname, cc version, kern.ipc sysctls (nmbclusters/nmbufs/maxsockbuf) 630 B view raw
fix.diff suggested-fix git-apply-able: NULL-check sbcreatecontrol at uipc_usrreq.c:694, ENOBUFS + unp_free + break. VALIDATED on built #1 kernel. 409 B view raw
fix_build.log fix-build-log full nativekernel build output for the single-fix kernel (rc=0, 35343 lines) 5.6 MB ↓ download
fix_run.log fix-run-log PoC on patched #1: run1 (no panic, guest up after 2+ min) + run2 (100M trigger sends, exit 2, no panic) 3.7 KB 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 build/run/expected + root-cause + the 2:1 plain:pkthdr strategy
↓ download raw

DF-0011 β€” PoC

nopasscred_panic.c β€” local DoS via the missing sbcreatecontrol() NULL check in the SO_PASSCRED synthesis path of uipc_send(). flood_trigger.c is an auxiliary hold-open variant used during analysis.

The bug

uipc_send() SOCK_DGRAM, sys/kern/uipc_usrreq.c:694-699:

if (ncon == NULL) {                 /* no existing SCM_CREDS cmsg found */
    ncon = sbcreatecontrol(&cred, sizeof(cred), SCM_CREDS, SOL_SOCKET);
    unp_internalize(ncon, msg->send.nm_td);   /* ncon may be NULL */
    *mp = ncon;
}

sbcreatecontrol() (sys/kern/uipc_sockbuf.c:585-604) returns NULL when its m_getl(..., M_NOWAIT, MT_CONTROL, 0, NULL) fails β€” i.e. when the plain "mbuf" objcache (sys/kern/uipc_mbuf.c:798, nmbufs deep) is exhausted. The return is never checked, so unp_internalize(NULL) runs and at uipc_usrreq.c:1706 does cm = mtod(control, ...) = load of mh_data from a NULL mbuf β‡’ offsetof(struct m_hdr, mh_data) == 0x10 β‡’ page fault at vaddr 0x10 in kernel mode β‡’ panic.

Trigger

An unprivileged local user who (a) exhausts the plain "mbuf" objcache and (b) sends to an AF_UNIX SOCK_DGRAM receiver that has SO_PASSCRED set, without an SCM_CREDS cmsg.

How the precondition is met (the crux): each AF_UNIX SOCK_DGRAM datagram pinned in a receiver buffer consumes 2 plain mbufs (MT_CONTROL + MT_SONAME) but only 1 pkthdr mbuf (MT_DATA data). Pinned plain:pkthdr ratio is therefore 2:1. Exhausting the 72904-deep plain cache pins only ~36500 pkthdr, leaving the pkthdr cache ~half empty β€” so the trigger's data pkthdr mbuf still allocates (M_WAITOK in sosend) and execution reaches sbcreatecontrol(), whose plain m_get(M_NOWAIT) then FAILS β‡’ NULL β‡’ panic.

The PoC runs a pinner thread (ramp plain-mbuf pressure) concurrently with a trigger thread (fire no-control SO_PASSCRED sends), so a trigger send lands on the instant of plain-cache exhaustion instead of overshooting into a wedge.

Build

cc -o nopasscred_panic nopasscred_panic.c -lpthread

Run

As an unprivileged user (this PANICS the kernel β€” local DoS):

./nopasscred_panic

Expected output (bug present)

A kernel panic on the serial console:

Warning: objcache(mbuf) exhausted on cpuN!
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x10
Stopped at      unp_internalize.isra.12+0x11:   movq    0x10(%rdi),%rbx

(The finding markdown guessed fault address 0x0; the real address is 0x10, the mh_data offset in struct m_hdr β€” same bug, sharper address.) Reproduced twice from independent fresh vm.sh reset boots with an identical signature. On a patched kernel (NULL check added) the trigger send returns ENOBUFS instead and the program prints "no panic".

VERDICT.md verdict full REPRODUCED + fix-validation narrative: NULL-check missing -> deref at 0x10; before/after kernel build
↓ download raw

DF-0011 β€” VERDICT

Verdict: REPRODUCED (unprivileged local DoS via NULL-deref kernel panic in unp_internalize). Impact: panic (denial of service). Confidence: certain. Reproduced twice from independent fresh vm.sh reset boots with an identical, mechanism-matching panic signature.

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

  1. Missing NULL check (root cause). In uipc_send, SOCK_DGRAM, sys/kern/uipc_usrreq.c:694-699, when no pre-existing SCM_CREDS cmsg is present and the receiver has SO_PASSCRED set: c if (ncon == NULL) { ncon = sbcreatecontrol(&cred, sizeof(cred), SCM_CREDS, SOL_SOCKET); unp_internalize(ncon, msg->send.nm_td); /* ncon used UNCHECKED */ *mp = ncon; } The return of sbcreatecontrol() is never tested.

  2. sbcreatecontrol can return NULL. sys/kern/uipc_sockbuf.c:585-604: c if (CMSG_SPACE(size) > MCLBYTES) return (NULL); m = m_getl(CMSG_SPACE(size), M_NOWAIT, MT_CONTROL, 0, NULL); if (m == NULL) return (NULL); For SCM_CREDS, size = sizeof(struct cmsgcred) = 84, so CMSG_SPACE ~ 104 < MCLBYTES and the first check passes. The only NULL path is the m_getl(M_NOWAIT, MT_CONTROL) failure. MT_CONTROL with this size takes the plain-mbuf branch of m_getl (sys/sys/mbuf.h:589-601), i.e. m_get() from the plain "mbuf" objcache (sys/kern/uipc_mbuf.c:798, limit nmbufs; 72904 on this guest per netstat -m). That objcache returns NULL when exhausted.

  3. NULL deref at offset 0x10. unp_internalize (:1702) opens with c struct cmsghdr *cm = mtod(control, struct cmsghdr *); /* :1706 */ mtod(m, t) = (t)((m)->m_data) (sys/sys/mbuf.h:73) and m_data == m_hdr.mh_data (:221). With control == NULL, this is a load of mh_data from address NULL + offsetof(struct m_hdr, mh_data). struct m_hdr (sys/sys/mbuf.h:79-90) is mh_next[8] + mh_nextpkt[8] + mh_data β‡’ offsetof(mh_data) == 16 == 0x10. β‡’ page fault at virtual address 0x10 in kernel mode β‡’ panic.

Trigger strategy (how the precondition is met by an unprivileged user)

The hard part is making m_get(M_NOWAIT) fail. The plain "mbuf" cache is ~72904 deep and NMBUFS_MIN = NMBUFS/2 β‰ˆ 36516, so it cannot be shrunk at runtime below ~36500. The decisive observation: each AF_UNIX SOCK_DGRAM datagram pinned in a receiver buffer accounts for 2 plain mbufs but only 1 pkthdr mbuf β€” one MT_CONTROL (the cmsg) plus one MT_SONAME (the source sockaddr recorded by ssb_appendaddr) for the data's one MT_DATA pkthdr. So the pinned plain:pkthdr ratio is 2:1. Exhausting the 72904-deep plain cache pins only ~36500 pkthdr mbufs, leaving the pkthdr cache ~half empty. The trigger's no-control send() therefore successfully allocates its data pkthdr mbuf (m_getl(M_WAITOK) in sosend, uipc_socket.c:866), proceeds into uipc_send's SO_PASSCRED block, and calls sbcreatecontrol() whose plain m_get(M_NOWAIT) FAILS β‡’ NULL β‡’ unp_internalize(NULL) β‡’ fault at 0x10.

The PoC runs this concurrently: a pinner thread ramps the pinned-datagram count while a trigger thread continuously fires no-control SO_PASSCRED sends, so a trigger send lands on the instant of plain-cache exhaustion (but before the pkthdr cache is also starved), deterministically hitting the NULL path. (A naive "pin everything, then fire" loop instead overshoots into a full memory-pressure wedge without reaching the trigger β€” that is the race the finding flagged as AC:H.)

Evidence (decisive, reproduced twice)

panic.txt holds the full serial-console excerpt from dfbsd-qemu/boot.log. Both fresh-reset reproductions produced an identical signature:

login: Warning: objcache(mbuf) exhausted on cpu1!
Fatal user address access from kernel mode from nopasscred_panic at ffffffff806cdac1

Fatal trap 12: page fault while in kernel mode
cpuid = 1; lapic id = 1
fault virtual address   = 0x10
fault code      = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff806cdac1
current process     = 1468           (unprivileged: nopasscred_panic, run as maxx)
kernel: type 12 trap, code=0

Stopped at      unp_internalize.isra.12+0x11:   movq    0x10(%rdi),%rbx
db>

This matches the cited path exactly: - objcache(mbuf) exhausted β‡’ the NULL-return precondition of sbcreatecontrol is met. - fault virtual address = 0x10 β‡’ offsetof(struct m_hdr, mh_data). (The finding markdown guessed 0x0; the real fault address is 0x10 β€” the mh_data offset, not the mbuf base pointer. Same bug, sharper address.) - Stopped at unp_internalize.isra.12+0x11: movq 0x10(%rdi),%rbx with %rdi == 0 (NULL control) β‡’ precisely the mtod(control,…) load at uipc_usrreq.c:1706. - current process = 1468 β‡’ the unprivileged trigger, confirming local DoS.

PoC changes

The seeded nopasscred_panic.c had (a) a compile bug (#define N SOCK 4096 β€” NSOCK undeclared) and (b) a flawed exhaustion strategy that targeted the pkthdr cache (datagram data) rather than the plain cache that sbcreatecontrol uses, so it only wedged the guest instead of panicking. I rewrote it to (1) pin plain mbufs via SCM_CREDS-bearing datagrams at the 2:1 plain:pkthdr ratio, and (2) fire the no-control SO_PASSCRED trigger concurrently during the ramp so it lands on the exhaustion crossover instead of overshooting into a wedge. flood_trigger.c is retained as an auxiliary/earlier hold-open variant. The improved PoC reproduces the panic from fresh resets.

Exploit chain

Not a memory-corruption class β€” a clean NULL deref (read of mh_data from a NULL mbuf). No integrity/confidentiality impact; ceiling = reliable local DoS (panic). current process = <unprivileged trigger> and panic from nopasscred_panic confirm unprivileged reachability.

fix.diff (git-apply-able, git apply --check verified) adds the missing NULL check at sys/kern/uipc_usrreq.c:694-699:

if (ncon == NULL) {
    ncon = sbcreatecontrol(&cred, sizeof(cred), SCM_CREDS, SOL_SOCKET);
    if (ncon == NULL) {
        error = ENOBUFS;
        unp_free(unp2);     /* drop ref the normal SOCK_DGRAM path holds */
        break;              /* -> function epilogue frees m & control    */
    }
    unp_internalize(ncon, msg->send.nm_td);
    *mp = ncon;
}

unp_free(unp2) is called explicitly because the break skips the normal path's unp_free(unp2) at :717; the function's release: epilogue (after the switch) already frees m and control and disposes control when error != 0, so no resource leak. This supersedes the finding markdown's ## Recommended fix (which used a bare break without unp_free(unp2), leaking an unp2 reference) while preserving its intent.

Fix validation (Phase 8 β€” built + booted single-fix kernel)

Build: Applied fix.diff to clean with-src /usr/src (patch -p1 succeeded at line 694), built make -j6 nativekernel KERNCONF=X86_64_GENERIC (rc=0, ~4 min), installed kernel.stripped β†’ /boot/kernel/kernel (sha256 ae0ecfae...), rebooted. Booted kernel: #1 Thu Jul 2 18:42:06 UTC 2026 (baseline was #0 Thu Jul 2 06:02:54). Full build log in fix_build.log.

Before (unpatched #0): PoC panicked the kernel within seconds of plain-cache exhaustion β€” identical to the original reproduction:

Warning: objcache(mbuf) exhausted on cpu0!
Fatal trap 12: page fault while in kernel mode
fault virtual address = 0x10
Stopped at unp_internalize.isra.12+0x11: movq 0x10(%rdi),%rbx

Guest DOWN at DDB db> prompt. (run.log, panic.txt.)

After (patched #1) β€” Run 1: PoC pinned 76950 dgrams, the Warning: objcache(mbuf) exhausted warnings fired (proving the allocation-failure precondition was met), but NO panic β€” guest stayed UP, the trigger process kept firing sends for 2+ minutes under exhaustion. On the unpatched kernel the panic fires within seconds.

After (patched #1) β€” Run 2 (clean reboot): PoC ran to completion: trigger fired 100,710,079 no-control SO_PASSCRED sends under mbuf exhaustion, exited 2 cleanly, guest healthy (Fatal trap/Stopped at absent from boot.log).

Verdict: fix_status = FIXED. The before/after contrast is unambiguous: same PoC, same workload, same mbuf-exhaustion precondition β†’ panic on #0, no panic on #1. The NULL check after sbcreatecontrol() converts the NULL-deref into a graceful ENOBUFS return, closing the bug. (fix_run.log.)

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. Baseline #0 (unpatched 6cc80ee9, build 06:02:54) PANICKED: Fatal trap 12, fault vaddr 0x10 in unp_internalize.isra.12+0x11, guest DOWN at db>. Single-fix #1 kernel (build 18:42:06, sha256 ae0ecfae...) does NOT panic: PoC fired 100,710,079 trigger sends under mbuf exhaustion across two runs with no Fatal trap/Stopped at in boot.log, exit 2, guest healthy => fix closes the bug. Build rc=0.

baseline #0: 'Fatal trap 12: page fault while in kernel mode' / 'fault virtual address = 0x10' / 'Stopped at unp_internalize.isra.12+0x11: movq 0x10(%rdi),%rbx' / guest DOWN. patched #1: 'fired=100710079 enobufs=0' / 'RUN_EXIT=2' / no Fatal trap/Stopped at in boot.log / guest UP. build: '>>> Kernel build for X86_64_GENERIC completed' rc=0.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 18:42:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

Not a memory-corruption class β€” a clean NULL-deref read of mh_data (offset 0x10) from a NULL mbuf pointer in unp_internalize. Ceiling = reliable local DoS (kernel panic) by an unprivileged user (current process = the unprivileged trigger). No integrity/confidentiality impact; no further primitive derivable.

Evidence (decisive lines)

BASELINE #0: 'Warning: objcache(mbuf) exhausted on cpu0!' / 'Fatal trap 12: page fault while in kernel mode' / 'fault virtual address = 0x10' / 'Stopped at unp_internalize.isra.12+0x11: movq 0x10(%rdi),%rbx' / guest DOWN at db>. PATCHED #1 run2: trigger fired=100710079 enobufs=0, RUN_EXIT=2, NO Fatal trap/Stopped at in boot.log, guest UP. Build rc=0.

PoC changes

No PoC source changes this session (the concurrent ramp+fire nopasscred_panic.c from the prior session reproduces cleanly). Refreshed all evidence logs: panic.txt (this session's #0 baseline panic), run.log (baseline record), fix_build.log (35343-line nativekernel output, rc=0), fix_run.log (patched #1 run1 no-panic + run2 100M-trigger-sends exit-2), env.txt (both #0 and #1), VERDICT.md (added full Phase-8 fix-validation section), manifest.json (added fix_status/fix_kernel_uname + fix_build.log/fix_run.log artifacts). fix.diff unchanged (already correct: NULL-check + ENOBUFS + unp_free(unp2) + break).

Verified recommended fix

fix.diff adds a NULL check after sbcreatecontrol() at sys/kern/uipc_usrreq.c:694-699: if sbcreatecontrol returns NULL (mbuf exhaustion), set error=ENOBUFS, call unp_free(unp2) to drop the reference the normal SOCK_DGRAM path holds at :717 (which the break skips), and break to the release: epilogue that frees m and control. This supersedes the finding markdown's proposal (bare break without unp_free, leaking unp2). Validated: applies cleanly (git apply --check OK), compiles (nativekernel rc=0), and eliminates the panic on the built #1 kernel. Full diff in findings/poc/DF-0011/fix.diff.

Verdict

REPRODUCED + FIX VALIDATED. On the unpatched #0 kernel the PoC panics with 'Fatal trap 12: page fault while in kernel mode', 'fault virtual address = 0x10', 'Stopped at unp_internalize.isra.12+0x11: movq 0x10(%rdi),%rbx' β€” the exact NULL-deref from the unchecked sbcreatecontrol() return at uipc_usrreq.c:694-699 (mtod(NULL) loads mh_data at offsetof 0x10). The SO_PASSCRED synthesis path calls sbcreatecontrol() whose m_get(M_NOWAIT,MT_CONTROL) returns NULL under plain-mbuf-cache exhaustion; the NULL is passed unchecked to unp_internalize which dereferences it. Confirmed by a fresh before/after: I built a single-fix kernel (#1, the 5-line NULL-check fix.diff) and re-ran the same PoC β€” it fired 100,710,079 no-control SO_PASSCRED sends under mbuf exhaustion with NO panic (exit 2, guest healthy), versus panic-within-seconds on #0. The fix converts the NULL deref into a graceful ENOBUFS return.