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

sysctl_kern_msgbuf OOB kernel-memory read via unsigned underflow in linear-section length

Summary

Branch 3 of sysctl_kern_msgbuf computes copy length as n-rindex_modulo instead of n. Mathematically branch 3 entered only when rindex_modulo+n==msg_size so correct length is n==msg_size-rindex_modulo. Buggy formula evaluates to msg_size-2*rindex_modulo unsigned 32-bit underflow ~4GiB when rindex_modulo>msg_size/2. Underflowed length funneled through SYSCTL_HANDLER_ARGS arg2(int) into sysctl_old_user size_t l so copyout reads min(~4GiB attacker_buffer_size) bytes starting at msg_ptr+rindex_modulo past msgbuf allocation into adjacent kernel VM. Data reaches userspace before ENOMEM returned userland_sysctl swallows. Local unpriv attacker security.unprivileged_read_msgbuf=1 default. Custom MSGBUF_SIZE(2048-4096) kernels deterministic. Default 1MiB needs privileged msgbuf_clear pin high-modulo.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2586 Β· 17 files
FileTypeDescriptionSize
msgbuf_oob_decisive.c trigger-source DECISIVE root-only trigger: kvm_write bad msgbuf geometry + sysctl read -> panic/#0, rc=0/#1 6.7 KB view raw
dump_msgbuf.c trigger-source kvm reader: dumps msg_bufx/bufr and the sysctl_kern_msgbuf branch-3 decision 3.6 KB view raw
msgbuf_diag.c trigger-source unprivileged poll: reports any over-long/suspect reads (0 observed) 5.2 KB view raw
build.sh build-script cc -O2 builds all three binaries (decisive/dump need -lkvm) 513 B view raw
run.sh run-script run.sh {decisive|unpriv|geometry} 1.2 KB view raw
build.log build-log final successful build of the three PoC binaries 216 B view raw
panic.txt panic-signature fatal trap 0xc in std_copyout+0x15a / vm_object_hold_shared obj!=NULL assert on #0 1.6 KB view raw
geometry_steady.txt diagnostic steady-state msgbuf geometry + branch decision (branch 1, no bug) 426 B view raw
run_unpriv.log run-log unprivileged poll: 300k reads, 0 OOB, max len = boot-log size 353 B view raw
leak_sample.txt leak-sample interpretation of the panic signature + honest reachability note 2.0 KB view raw
env.txt environment uname, cc version, security.unprivileged_read_msgbuf=1 234 B view raw
fix.diff suggested-fix git-apply-able one-line fix: n - rindex_modulo -> n in branch 3 293 B view raw
fix_build.log build-log full nativekernel output for the single-fix kernel (rc=0, 36014 lines) 5.6 MB ↓ download
fix_run.log run-log decisive PoC on patched #1: sysctl rc=0, l=424272 (=n), no panic 422 B view raw
fix_run.2.log run-log 2nd decisive run on #1: deterministic (rc=0, l=424272) 163 B view raw
VERDICT.md verdict full narrative: reproduced? how/why? reachability? fix validated 9.2 KB ↓ raw
README.md readme human-facing readme + reproduce instructions 4.4 KB ↓ raw
README.md readme human-facing readme + reproduce instructions
↓ download raw

DF-2586 β€” sysctl_kern_msgbuf branch-3 integer underflow (OOB read)

Severity (finding): Medium Verified verdict: REPRODUCED β€” bug is real; OOB read proven by kernel panic. Realistic impact ceiling: local kernel OOB read / panic, root-triggerable only (stale msg_bufr geometry requires kern.msgbuf_clear=1, which is wheel-only). The unprivileged-reachability claim in the finding is not supported.

The bug

sys/kern/subr_prf.c:1177-1184, third branch of sysctl_kern_msgbuf:

} else if (n <= mbp->msg_size - rindex_modulo) {
    /* Can handle in one linear section. */
    error = sysctl_handle_opaque(oidp,
                                 mbp->msg_ptr + rindex_modulo,
                                 n - rindex_modulo,     /* BUG: should be n */
                                 req);
}

Branches 1 and 4 correctly pass a byte length (xindex_modulo - rindex_modulo, and n / msg_size - rindex_modulo). Branch 3 mixes a byte count (n) with a buffer offset (rindex_modulo). When rindex_modulo > n, the u_int subtraction silently wraps to ~4 GiB. sysctl_old_user (sys/kern/kern_sysctl.c) clips that to the user-supplied oldlen and copyouts up to oldlen bytes from msg_ptr + rindex_modulo β€” past msg_ptr + msg_size into adjacent kernel memory.

The branch-3 condition is rindex_modulo > xindex_modulo (else of branch 1) AND n <= msg_size - rindex_modulo ⟺ rindex_modulo + n <= msg_size. In the wrap case where xindex_modulo == 0, rindex_modulo + n == msg_size, so the correct length is exactly n (== msg_size - rindex_modulo). The buggy formula is n - rindex_modulo == msg_size - 2*rindex_modulo, which underflows whenever 2*rindex_modulo > msg_size, i.e. rindex_modulo > msg_size/2.

Reproduce

./build.sh                 # cc -O2 -o msgbuf_oob_decisive ... -lkvm ; + dump_msgbuf, msgbuf_diag
./run.sh geometry          # dump current msgbuf + branch decision (root, non-destructive)
./run.sh decisive          # DECISIVE root-only trigger -> PANIC on #0, rc=0/in-bounds on #1
./run.sh unpriv            # unprivileged poll (300k reads) -> 0 OOB (run as maxx)

Expected (bug present, unpatched #0 kernel)

./run.sh decisive (as root) sets msg_bufx = msg_size, msg_bufr = msg_size/2 + 100000 via kvm_write (the same stale geometry kern.msgbuf_clear=1 produces), then reads kern.msgbuf with oldlen = 1 MiB. The kernel panics:

panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm_object.c:330
--- trap 0xc, rip = std_copyout+0x15a ---

trap 0xc is a page fault raised inside std_copyout's source-side read β€” the underflowed length made copyout walk off msg_ptr's mapped pages into unmapped kernel memory. Had the adjacent memory been mapped, the same read would have leaked kernel-heap residue to userspace instead of crashing. (Decisive run stdout is lost to the panic β€” ssh dies before flush β€” but dfbsd-qemu/boot.log captures the signature; see panic.txt.)

Expected (FIXED, single-fix #1 kernel)

Same PoC returns sysctl rc=0, returned length l=424272 (= n, exactly in-bounds), no panic, guest stays up. Deterministic across 2 runs (fix_run.log, fix_run.2.log).

Honest reachability note

The OOB-underflow geometry requires rindex_modulo > msg_size/2, i.e. msg_bufr to be "stale" (lagging far behind msg_bufx). In steady state msgaddchar keeps msg_bufr = msg_bufx - msg_size + 2048, so rindex_modulo == 2048 and the bug is only a benign 2048-byte under-read (no OOB, no leak). The stale state is produced only by root writing kern.msgbuf_clear=1 (which sets msg_bufr := msg_bufx) β€” confirmed EPERM for the unprivileged maxx user. A 300k-iteration unprivileged poll produced 0 over-long / 0 suspect-tail reads (run_unpriv.log). So the bug does not cross a privilege boundary; its realistic ceiling is root-triggerable local kernel OOB read / DoS (panic). The code fix is still warranted (latent OOB defect, wrong length math).

The fix

One line β€” n - rindex_modulo β†’ n in branch 3 (matches branches 1 and 4). See fix.diff. Validated on a single-fix kernel built from with-src + fix.diff only (make installkernel): panic on #0, in-bounds rc=0 on #1. See VERDICT.md.

Note: this is the same defect as DF-0035 (identical claim, file, lines, fix). This evidence pack reproduces and validates it independently for DF-2586.

VERDICT.md verdict full narrative: reproduced? how/why? reachability? fix validated
↓ download raw

DF-2586 β€” Verification verdict

Finding: Integer underflow in sysctl_kern_msgbuf 3rd branch causes a kernel OOB read via copyout (sys/kern/subr_prf.c:1177-1184).

Verdict: REPRODUCED β€” the buggy length math is real and produces a kernel OOB read (proven by panic). The unprivileged-reachability claim is incorrect: the OOB-underflow geometry requires a stale msg_bufr, reachable only after root writes kern.msgbuf_clear=1 (wheel-only). In normal operation the same bug is a benign 2048-byte under-read with no leak.

DF-2586 is the same defect as DF-0035 (identical file, lines, mechanism, fix). This run reproduces and validates it independently.

1. The bug is real in source

sys/kern/subr_prf.c:1177-1184 (third branch of sysctl_kern_msgbuf):

} else if (n <= mbp->msg_size - rindex_modulo) {
    /* Can handle in one linear section. */
    error = sysctl_handle_opaque(oidp,
                                 mbp->msg_ptr + rindex_modulo,
                                 n - rindex_modulo,     /* BUG: should be n */
                                 req);
}

The valid data length here is n (as branches 1 and 4 correctly use); passing n - rindex_modulo mixes a byte count with a buffer offset. Because both are u_int, the subtraction silently wraps to a ~4 GiB value when rindex_modulo > n. That huge length reaches sysctl_old_user (sys/kern/kern_sysctl.c), which clips it to req->oldlen and then copyouts up to oldlen bytes from msg_ptr + rindex_modulo β€” a read that runs past msg_ptr + msg_size into adjacent kernel memory.

Branch-3 condition: rindex_modulo > xindex_modulo AND n <= msg_size - rindex_modulo ⟺ rindex_modulo + n <= msg_size. In the wrap case (xindex_modulo == 0), rindex_modulo + n == msg_size, so the correct length is n. The buggy value n - rindex_modulo = msg_size - 2*rindex_modulo underflows when rindex_modulo > msg_size/2.

2. Decisive empirical proof β€” kernel panic (this session, #0 baseline)

msgbuf_oob_decisive.c (root-only) uses kvm_write to place msg_bufx and msg_bufr in the exact geometry that the natural post-msgbuf_clear path produces (msg_bufx = msg_size, msg_bufr = msg_size/2 + 100000 β€” so xindex_modulo==0, rindex_modulo = 624272 > msg_size/2 = 524272, n = 424272), then issues a single sysctlbyname("kern.msgbuf", buf, 1MiB_oldlen). The kernel panics (captured from dfbsd-qemu/boot.log):

panic: assertion "obj != NULL" failed in vm_object_hold_shared at /usr/src/sys/vm/vm_object.c:330
vm_object_hold_shared() ... vm_fault() ... trap_pfault() ... trap() ... calltrap()
--- trap 0xc, rip=ffffffff80bcaeaa, rsp=..., rbp=... ---
std_copyout() at std_copyout+0x15a 0xffffffff80bcaeaa

trap 0xc is a page fault raised inside the copyout source-side read (walking off the msgbuf's mapped pages into adjacent unmapped kernel memory). Had the adjacent memory been mapped, the same OOB read would have leaked kernel-heap residue to userspace instead of crashing. This is decisive proof that the buggy branch-3 length math produces an OOB read.

The kvm_write does not change the bug execution β€” it only shortcuts the state-setup that the natural path also produces (msgbuf_clear sets msg_bufr := msg_bufx; subsequent logging then advances msg_bufx to the next msg_size boundary). When the kernel runs sysctl_kern_msgbuf in that state, branch 3 executes identically and the underflow happens.

3. Why the finding's threat model is wrong (unreachable from unprivileged)

msg_bufr is only ever modified in two places (sys/kern/subr_prf.c):

  • msgaddchar (~line 1070): bumps msg_bufr to xindex - msg_size + 2048 only when n = xindex - msg_bufr > msg_size - 1024. So in steady state msg_bufr β‰ˆ msg_bufx - msg_size + 2048, i.e. rindex_modulo = 2048 and n = msg_size - 2048.
  • sysctl_kern_msgbuf_clear (line 1214): sets msg_bufr := msg_bufx β€” a write that requires root (kern.msgbuf_clear rejects non-wheel users; verified this session: sysctl kern.msgbuf_clear=1 as maxx β†’ EPERM).

In steady-state geometry, branch 3 fires only when xindex_modulo == 0. At that moment rindex_modulo = 2048 and n = msg_size - 2048, so the buggy n - rindex_modulo = msg_size - 4096 β€” a positive, in-bounds value. The bug becomes a 2048-byte under-read (returned msgbuf is 2048 bytes shorter than it should be), not an OOB read. No leak, no panic.

The OOB underflow condition (rindex_modulo > n, equivalently rindex_modulo > msg_size/2) requires msg_bufr to be "stale" at a value whose modulo exceeds msg_size/2. That is only reachable after root writes kern.msgbuf_clear=1.

Empirical confirmation of unreachability (this session)

  • msgbuf_diag run as maxx on a fresh #0 boot: 300,000 sysctl reads, 0 over-long reads, 0 suspect tails, max returned length = 8679 bytes (= the actual boot-log size). See run_unpriv.log.
  • sysctl kern.msgbuf_clear=1 as maxx β†’ Operation not permitted.

So the bug's realistic impact ceiling is a root-triggerable local kernel OOB read / DoS (panic) β€” it does not cross a privilege boundary. The code fix is still warranted (real latent OOB defect / wrong length math). Suggested severity refinement: Low (rather than Medium) given the root-only window.

4. The fix

Replace n - rindex_modulo with n in the 3rd branch β€” matching branches 1 and 4 which already use the correct length. One-line change; see fix.diff (standalone git apply-able unified diff against sys/kern/subr_prf.c). git apply --check passes. This matches the finding's ## Recommended fix proposal exactly.

5. Fix validation (Phase 8) β€” single-fix kernel built & booted

The fix was validated end-to-end on a single-fix kernel built from the with-src baseline + fix.diff only.

Before / after contrast (identical buggy geometry)

Kernel Decisive PoC result
#0 unpatched (with-src) kernel PANIC: obj != NULL in vm_object_hold_shared, trap 0xc in std_copyout+0x15a (copyout walks off msg_ptr's mapped pages). Guest down. (panic.txt)
#1 + fix.diff sysctl rc=0, returned l=424272 bytes (= n, exactly in-bounds), no panic, guest stays up. Deterministic across 2 runs. (fix_run.log, fix_run.2.log)

The decisive PoC places msg_bufx = msg_size, msg_bufr = msg_size/2 + 100000 (so xindex_modulo==0, rindex_modulo=624272 > msg_size/2), then issues a single sysctlbyname("kern.msgbuf", buf, 1MiB_oldlen). The PoC prints bug_len=4294767296 (= n - rindex_modulo as u_int, the OLD buggy length) to prove the same path is being exercised.

  • On #0 that ~4 GiB length reaches sysctl_old_user which clips it to oldlen (1 MiB) and copyouts 1 MiB starting at msg_ptr + rindex_modulo, running past msg_ptr + msg_size into unmapped kernel memory β†’ page fault β†’ panic.
  • On #1 the fix passes n (= 424272) to sysctl_handle_opaque; the copyout reads exactly msg_ptr + rindex_modulo .. msg_ptr + msg_size β€” in bounds. No fault, no leak.

Normal unprivileged reads are unaffected: as maxx, sysctl -n kern.msgbuf returned 9027 bytes (the boot log) on the patched kernel β€” no regression.

Build / boot details

  • fix.diff applied with patch -p1 --forward cleanly (Hunk #1 at line 1180).
  • Build: cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC β†’ rc=0 (full log in fix_build.log, 36014 lines).
  • Install: make installkernel KERNCONF=X86_64_GENERIC β†’ "Kernel install for X86_64_GENERIC completed".
  • kern.version: #0: Thu Jul 2 06:02:54 UTC 2026 β†’ #1: Sat Aug 8 18:13:42 UTC 2026.
  • Patched kernel sha256: 50f0d9c0df2cd24d5bc593cad206a042d45fee77f2a6a88144416fd45ca92142.

Fix status

fixed. The bad-behavior marker (panic / OOB read past msg_size) is present on the unpatched #0 baseline and absent on the single-fix #1 kernel. The one-line change (n - rindex_modulo β†’ n) matches the finding's ## Recommended fix proposal exactly; no refinement was needed.

6. Files in this evidence pack

file role
msgbuf_oob_decisive.c DECISIVE root-only trigger: kvm_write bad geometry + sysctl read β†’ panic on #0, rc=0 on #1
dump_msgbuf.c kvm(3) reader: dumps msg_bufx/bufr and the branch-3 decision (geometry_steady.txt)
msgbuf_diag.c unprivileged poll: reports over-long/suspect reads (none observed)
build.sh builds all three binaries
run.sh runs decisive / unpriv / geometry
panic.txt tight panic signature from the decisive run on #0 (proof)
leak_sample.txt interpretation of the panic signature + reachability note
geometry_steady.txt steady-state msgbuf geometry + branch decision (branch 1, no bug)
run_unpriv.log full unprivileged poll log (300k reads, 0 OOB β€” fresh #0 boot)
env.txt guest uname, cc version, relevant sysctls
fix.diff git-apply-able fix: n - rindex_modulo β†’ n in branch 3
fix_build.log full nativekernel output for the single-fix kernel (rc=0)
fix_run.log decisive PoC on patched #1: returns n=424272 bytes, no panic
fix_run.2.log 2nd decisive run on #1: deterministic (rc=0, l=424272)
README.md human-facing readme
manifest.json machine-readable catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. Decisive PoC (msgbuf_oob_decisive) PANICS on unpatched #0 with-src baseline (assert obj != NULL in vm_object_hold_shared, trap 0xc in std_copyout+0x15a β€” underflowed ~4GiB length clipped to 1MiB oldlen runs copyout past msg_ptr+msg_size into unmapped kernel memory) and does NOT panic on single-fix #1 kernel (sysctl rc=0, returned l=424272 == n, exactly in-bounds, guest up, deterministic 2 runs). fix.diff applied patch -p1 --forward cleanly; make -j6 nativekernel KERNCONF=X86_64_GENERIC rc=0; make installkernel; kern.version bumped #0->#1. Normal unprivileged kern.msgbuf reads unaffected on #1 (9027 bytes as maxx). One-line fix closes the OOB read.

baseline #0: panic 'assertion obj != NULL failed in vm_object_hold_shared' at vm_object.c:330 / trap 0xc rip=std_copyout+0x15a / Stopped at Debugger+0x7c / db>. patched #1: verify bufx=1048544 bufr=624272, n=424272, bug_len=4294767296 (underflow!) / sysctl rc=0, returned l=424272 (msg_size=1048544) / restored bufx=9026 bufr=0 / POC_EXIT=2 (no panic, guest up).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sat Aug 8 18:13:42 UTC 2026 (sha256 50f0d9c0df2cd24d5bc593cad206a042d45fee77f2a6a88144416fd45ca92142)

Confirmed kernel references

Detail

Exploit chain

none β€” read-only OOB primitive (copyout reads past msg_ptr+msg_size; no kernel write, no corruption), so per Phase 6 no uid=0 escalation chain derivable. Additionally blocked by valid hard blocker: OOB-underflow geometry reachable ONLY from already-root context (kern.msgbuf_clear=1 wheel-only β€” root->kernel game-over by definition; unprivileged user cannot open window). No privilege boundary to cross. Realistic impact ceiling: root-triggerable local kernel OOB heap read / panic; if memory adjacent to msgbuf were mapped it would leak kernel-heap residue rather than crash.

Evidence (decisive lines)

BEFORE (#0 unpatched, decisive root-only geometry bufx=1048544/bufr=624272): panic: assertion 'obj != NULL' failed in vm_object_hold_shared at vm_object.c:330 / trap 0xc, rip=ffffffff80bcaeaa / std_copyout() at std_copyout+0x15a / Stopped at Debugger+0x7c. AFTER (#1 patched, same geometry): verify n=424272, bug_len=4294767296 (underflow!) / sysctl rc=0, returned l=424272 == n (in-bounds) / no panic, guest up. Unprivileged poll (maxx, 300k reads): max returned length 8679 bytes, 0 suspect tails, no OOB. msgbuf_clear=1 as maxx -> EPERM.

PoC changes

Authored DF-2586 evidence pack from scratch. Adapted msgbuf_oob_decisive.c (root-only kvm_write geometry + sysctl read -> decisive panic), dump_msgbuf.c (kvm geometry + branch-3 decision), msgbuf_diag.c (unprivileged OOB poll) from proven DF-0035 sources (same bug); build.sh, run.sh (decisive/unpriv/geometry), README.md, VERDICT.md, manifest.json, fix.diff (n - rindex_modulo -> n). No source logic changed from validated DF-0035 PoCs.

Verified recommended fix

One-line change in sys/kern/subr_prf.c branch 3 (line 1183): change copyout length from 'n - rindex_modulo' to 'n' (correct byte count, matching branches 1 and 4). When xindex_modulo==0 the branch-3 invariant gives rindex_modulo + n == msg_size, so correct length is exactly n; old formula underflowed to msg_size - 2*rindex_modulo. Validated on single-fix kernel (panic on #0 -> rc=0 in-bounds on #1). Matches finding proposal exactly; matches DF-0035 fix exactly. Full git-apply-able diff in findings/poc/DF-2586/fix.diff.

Verdict

REPRODUCED. The 3rd branch of sysctl_kern_msgbuf (sys/kern/subr_prf.c:1177-1184) passes the wrong length to sysctl_handle_opaque: n - rindex_modulo instead of n. Both are u_int, so when rindex_modulo > n the subtraction underflows to ~4GiB; sysctl_old_user clips it to oldlen and copyouts past msg_ptr+msg_size into adjacent kernel memory. Decisive proof: msgbuf_oob_decisive (root-only, kvm_write sets the stale geometry that kern.msgbuf_clear=1 naturally produces: msg_bufx=msg_size, msg_bufr=msg_size/2+100000) then a single sysctlbyname('kern.msgbuf', buf, 1MiB) PANICS on #0 baseline: panic 'assertion obj != NULL failed in vm_object_hold_shared', trap 0xc, rip=std_copyout+0x15a β€” page fault inside copyout's source-side read walking off msgbuf's mapped pages. Honest scope caveat: OOB-underflow geometry requires msg_bufr stale (modulo > msg_size/2); in steady state msgaddchar pins rindex_modulo=2048 so bug is only a benign 2048-byte under-read. Stale state reachable ONLY via root writing kern.msgbuf_clear=1 (msg_bufr := msg_bufx), confirmed EPERM for unprivileged maxx; 300k-iteration unprivileged poll returned 0 over-long / 0 suspect-tail reads. Finding's unprivileged-reachability claim NOT supported β€” realistic ceiling root-triggerable local kernel OOB read / DoS (panic). (DF-2586 is the same defect as DF-0035; reproduced+validated independently here.)