โฌข DragonFlyBSD Kernel Audit
โ† 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.

PoC verification

Evidence pack

findings/poc/DF-0011 ยท 11 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 narrative: NULL-check missing -> deref at 0x10, with path:line 6.7 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) 318 B view raw
build.log build-log final successful build, full output 13 B view raw
run.log run-log decisive run record (build+launch+serial panic excerpt) 2.1 KB view raw
panic.txt panic-signature Fatal trap 12, fault vaddr 0x10, Stopped at unp_internalize+0x11 (reproduced twice from fresh resets) 2.8 KB view raw
env.txt environment uname, cc version, kern.ipc sysctls (nmbclusters/nmbufs/maxsockbuf) 307 B view raw
fix.diff suggested-fix git-apply-able: NULL-check sbcreatecontrol at uipc_usrreq.c:694, ENOBUFS + unp_free + break 409 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 narrative: NULL-check missing -> deref at 0x10, with path:line
โ†“ 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.

Confirmed kernel references

Detail

Exploit chain

none -- clean NULL deref (read of mh_data from a NULL mbuf), not memory corruption. Ceiling impact = reliable unprivileged local DoS (panic). Triggered by exhausting the plain 'mbuf' objcache (nmbufs~72904) via pinned AF_UNIX SOCK_DGRAM datagrams -- each costs 2 plain mbufs (MT_CONTROL + MT_SONAME) but only 1 pkthdr (MT_DATA), so the 2:1 plain:pkthdr ratio exhausts plain while leaving the pkthdr cache ~half full, letting the trigger's data mbuf allocate (M_WAITOK in sosend) so execution reaches sbcreatecontrol->NULL->panic. The PoC runs pinner+trigger concurrently to land on the exhaustion crossover (a naive pin-then-fire overshoots into a wedge).

Evidence (decisive lines)

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 trigger, run as maxx)
kernel: type 12 trap, code=0
Stopped at      unp_internalize.isra.12+0x11:   movq    0x10(%rdi),%rbx
db>   (reproduced identically twice from fresh vm.sh resets; full excerpt in findings/poc/DF-0011/panic.txt)

PoC changes

The seeded nopasscred_panic.c had (a) a compile bug (#define N SOCK 4096 -> NSOCK undeclared) and (b) a flawed strategy that exhausted the pkthdr cache (datagram data) instead of the plain 'mbuf' cache sbcreatecontrol uses, so it only wedged the guest. Rewrote it to pin plain mbufs via SCM_CREDS-bearing datagrams at the 2:1 plain:pkthdr ratio and fire the no-control SO_PASSCRED trigger CONCURRENTLY during the ramp so it lands on the plain-cache-exhaustion crossover instead of overshooting into a wedge. Kept flood_trigger.c as an auxiliary hold-open variant. Added build.sh/run.sh, captured build.log/run.log/panic.txt/env.txt, wrote VERDICT.md + manifest.json, authored fix.diff (NULL-check sbcreatecontrol).

Verified recommended fix

fix.diff (git-apply-able, git apply --check verified) adds the missing NULL check at sys/kern/uipc_usrreq.c:694-699: after ncon = sbcreatecontrol(...), if (ncon == NULL) { error = ENOBUFS; unp_free(unp2); break; }. unp_free(unp2) is explicit because the break skips the normal SOCK_DGRAM path's unp_free(unp2) at :717; the function's release: epilogue already frees m and control and disposes control when error!=0, so no leak. This SUPERSEDES the finding markdown's ## Recommended fix (which used a bare break that would leak the unp2 reference) while preserving its intent. Full diff in findings/poc/DF-0011/fix.diff.

Verdict

REPRODUCED. The bug is real: in uipc_send (sys/kern/uipc_usrreq.c:694-699) the SOCK_DGRAM SO_PASSCRED synthesis path uses sbcreatecontrol()'s return value WITHOUT a NULL check; sbcreatecontrol (uipc_sockbuf.c:585-594) returns NULL when its m_getl(M_NOWAIT, MT_CONTROL) fails (plain 'mbuf' objcache exhausted), and unp_internalize(NULL) at uipc_usrreq.c:1706 does cm = mtod(control,...) = load of mh_data from a NULL mbuf, faulting at virtual address 0x10 (offsetof(struct m_hdr, mh_data) = mh_next[8]+mh_nextpkt[8] = 0x10; sys/sys/mbuf.h:79-90). Confirmed by two kernel panics from independent fresh vm.sh resets with the identical, mechanism-matching serial signature: 'objcache(mbuf) exhausted', 'Fatal trap 12: page fault while in kernel mode', 'fault virtual address = 0x10', 'Stopped at unp_internalize.isra.12+0x11: movq 0x10(%rdi),%rbx' (%rdi==0==NULL control), current process = the unprivileged trigger (nopasscred_panic run as maxx). The finding markdown guessed fault address 0x0; the real address is 0x10 (mh_data offset) -- same bug, sharper.