โฌข DragonFlyBSD Kernel Audit
โ† dashboard
DF-0035

Integer underflow in sysctl_kern_msgbuf causes kernel heap OOB read via copyout

Field Value
ID DF-0035
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:L
CWE CWE-191 Integer Underflow; CWE-125 Out-of-bounds Read
File sys/kern/subr_prf.c
Lines 1177-1184
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

In the third branch of sysctl_kern_msgbuf(), the copy length passed to sysctl_handle_opaque is computed as n - rindex_modulo (mixing a byte count n with a buffer offset rindex_modulo, both u_int) instead of n. This branch is entered only when xindex_modulo == 0 (i.e. msg_bufx is an exact multiple of msg_size), in which case n == msg_size - rindex_modulo, so n - rindex_modulo == msg_size - 2*rindex_modulo, which underflows to ~4 GiB whenever rindex_modulo > msg_size/2. The copyout (capped at the user's oldlen) then reads from msg_ptr + rindex_modulo past msg_ptr + msg_size into adjacent kernel heap, leaking kernel memory to userspace. With unprivileged_read_msgbuf = 1 (the default), any local user can poll kern.msgbuf to hit the window.

Root cause

sys/kern/subr_prf.c:1177-1184:

} 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);
}

Geometry of branch 3 (rindex_modulo > xindex_modulo and n <= msg_size - rindex_modulo): the latter, combined with n = (msg_size - rindex_modulo) + xindex_modulo, holds only when xindex_modulo == 0, so n == msg_size - rindex_modulo. The valid linear section from rindex_modulo is exactly n bytes; the code passes n - rindex_modulo (= msg_size - 2*rindex_modulo), which underflows for rindex_modulo > msg_size/2. Compare branch 1 (xindex_modulo - rindex_modulo, a correct offset difference) and branch 4 (msg_size - rindex_modulo then n).

Threat model & preconditions

  • Attacker position: any local unprivileged user (unprivileged_read_msgbuf default 1; ptr_restrict default 0 so kernel pointers in the msgbuf itself are also unmasked).
  • Privileges gained or impact: information disclosure. The OOB read leaks kernel heap adjacent to the msgbuf allocation โ€” may include kernel pointers (KASLR bypass), credential/crypto structures, or other sensitive data. The window is narrow: msg_bufx (incremented once per character) crosses a msg_size boundary ~once per ~1 MiB of kernel log, while the reader lags past the buffer midpoint; winnable by sustained sysctl polling.
  • Required config or capabilities: none; default kernel.
  • Reachability: sysctl kern.msgbuf (or sysctlbyname) in a polling loop.

Proof of concept

PoC source: findings/poc/DF-0035/msgbuf_oob.c

Build & run (unprivileged, disposable VM)

cc -o msgbuf_oob findings/poc/DF-0035/msgbuf_oob.c
./msgbuf_oob

Expected output

Some reads return non-text / binary (adjacent heap) content beyond the valid msgbuf. On a fixed kernel the length is always <= msg_size with valid text.

Impact

Unprivileged kernel-heap info leak (race-window-bounded). Medium (AC:H reflects the window; the leak itself can disclose kernel pointers/secret material).

Pass n (the valid byte count) โ€” matching branch 1/4's correct offset math:

--- a/sys/kern/subr_prf.c
+++ b/sys/kern/subr_prf.c
@@ -1180,7 +1180,7 @@
        error = sysctl_handle_opaque(oidp,
                         mbp->msg_ptr + rindex_modulo,
-                        n - rindex_modulo,
+                        n,
                         req);

References

Timeline

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

PoC verification

Evidence pack

findings/poc/DF-0035 ยท 17 files
FileTypeDescriptionSize
msgbuf_oob.c trigger-source original unprivileged kern.msgbuf poller (harmless) 2.7 KB view raw
msgbuf_diag.c trigger-source sharper unprivileged diagnostic with detailed reporting 5.2 KB view raw
dump_msgbuf.c diagnostic kvm(3)-based reader of msg_bufx/msg_bufr + branch-3 decision 3.6 KB view raw
msgbuf_oob_decisive.c exploit-trigger DECISIVE root-only trigger: kvm_write bad geometry + sysctl read -> panic 6.7 KB view raw
msgbuf_trigger.c trigger-source earlier timing-based natural-path trigger (superseded by decisive) 6.1 KB view raw
msgbuf_brute.c trigger-source root-only brute-forcer: 1-byte-step console writes + tight read loop 2.3 KB view raw
run_brute.sh repro-script wrapper: arrange stale msg_bufr via msgbuf_clear, then brute-force 2.0 KB view raw
build.sh build-script builds all five binaries 1.7 KB view raw
run.sh run-script run.sh unprivileged | run.sh decisive 1.5 KB view raw
panic.txt panic-signature tight panic signature from both decisive runs (proof) 1.6 KB view raw
leak_sample.txt leak-sample explanation of the panic signature and what it proves 1.6 KB view raw
run.unprivileged.log run-log 1.5M-read unprivileged poll on fresh boot: 0 hits 335 B view raw
env.txt environment uname, cc version, sysctls 779 B view raw
fix.diff suggested-fix git-apply-able: n - rindex_modulo -> n in branch 3 293 B view raw
VERDICT.md verdict full narrative: reproduced (with caveats) + reachability analysis 9.6 KB โ†“ raw
README.md readme human-facing readme with reproduce instructions 2.8 KB โ†“ raw
manifest.json manifest this catalog 3.4 KB view raw
README.md readme human-facing readme with reproduce instructions
โ†“ download raw

DF-0035 โ€” PoC

sysctl_kern_msgbuf (sys/kern/subr_prf.c) 3rd branch passes the wrong length to sysctl_handle_opaque. This evidence pack proves the bug is a real kernel OOB read, AND clarifies that the unprivileged-reachability claim in the finding is incorrect (the OOB window requires root to open).

The bug

At sys/kern/subr_prf.c:1183:

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

n is the valid byte count; subtracting rindex_modulo (a buffer offset) silently wraps to ~4 GiB (both are u_int) whenever rindex_modulo > n. The resulting copyout then reads past msg_ptr+msg_size into adjacent kernel memory.

Reproduce

./build.sh
./run.sh unprivileged    # as any user; 2M poll of kern.msgbuf.  Expected: NO OOB
                         # (the bug is unreachable in normal operation).
                         # Exit 2 == "no leak observed" (the honest result).

# DECISIVE (root only; PANICS the kernel; use a disposable guest):
ssh dfbsd
cd /root/poc/DF-0035 && ./run.sh decisive
# -> kernel panic in std_copyout reading past msgbuf (proof of OOB read)
# -> reset the guest with `dfbsd-qemu/vm.sh reset` afterwards.

What you'll see

  • Unprivileged path: no leak. In normal operation msg_bufr tracks msg_bufx - msg_size + 2048, which forces branch 3 to fire only at xindex_modulo==0 with rindex_modulo=2048. The buggy n - rindex_modulo then equals msg_size - 4096 โ€” a 2048-byte under-read, no OOB. Confirmed empirically: 2,000,000 sysctl reads as maxx, 0 hits.

  • Decisive path (kvm_write-forced geometry): the kernel panics in std_copyout because the underflowed length (~4 GiB, clipped to oldlen) drives a copyout that walks off the end of msg_ptr's mapped pages into adjacent unmapped kernel memory. Trap 0xc (page fault), rip inside std_copyout+0x15a. Reproduced twice; see panic.txt. Had the adjacent memory been mapped, the same OOB read would have leaked heap residue instead of crashing.

Why unprivileged-only doesn't work

The OOB underflow condition (rindex_modulo > msg_size/2 in branch 3) requires msg_bufr to be "stale" at a value whose modulo exceeds msg_size/2. That staleness is produced only by sysctl_kern_msgbuf_clear (subr_prf.c:1213-1218, root-only โ€” verified: sysctl kern.msgbuf_clear=1 as maxx โ†’ EPERM). Without root, the steady-state geometry makes the bug a benign under-read. See VERDICT.md for the full geometry trace.

Fix

fix.diff โ€” one-line change: n - rindex_modulo โ†’ n, matching branches 1 and 4. Matches the finding's ## Recommended fix proposal exactly.

VERDICT.md verdict full narrative: reproduced (with caveats) + reachability analysis
โ†“ download raw

DF-0035 โ€” Verification verdict

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

Verdict: REPRODUCED (with caveats) โ€” the buggy length math is real and produces a kernel OOB read; but the unprivileged-reachability claim in the finding is INCORRECT. The OOB only fires after a root-initiated kern.msgbuf_clear=1 opens a narrow (1-byte-per-msg_size) window. In normal operation the same bug is a benign 2048-byte under-read with no leak.

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:1321), 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 whatever kernel memory is adjacent.

2. Decisive empirical proof โ€” kernel panic

msgbuf_oob_decisive.c (root-only) uses kvm_write to place msg_bufx and msg_bufr in the exact geometry that the natural post-clear path produces (msg_bufx = msg_size, msg_bufr = msg_size/2 + 100000 โ€” so xindex_modulo==0, rindex_modulo>msg_size/2), then issues a single sysctlbyname("kern.msgbuf", buf, big_oldlen). The kernel panics:

panic: assertion "obj != NULL" failed in vm_object_hold_shared
  vm_fault -> trap_pfault -> trap -> calltrap
  --- trap 0xc, rip=ffffffff80bca5ba ---
  std_copyout() at std_copyout+0x15a

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). The panic was reproduced twice with byte-identical signatures (panic.txt). 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 exactly, i.e. rindex_modulo = (msg_bufx + 2048) % msg_size 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: sysctl kern.msgbuf_clear=1 as maxx โ†’ EPERM).

In steady-state geometry, branch 3 fires only when xindex_modulo == 0 (see analysis in VERDICT.md ยง4). 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 (the 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" โ€” i.e. set to a value whose modulo exceeds msg_size/2. That is only reachable after root writes kern.msgbuf_clear=1 (which sets msg_bufr := msg_bufx). The finding's claim that "any local user can poll kern.msgbuf to hit the window" is false โ€” the window requires root to open.

Empirical confirmation of unreachability

  • msgbuf_oob (original PoC) run as maxx: 2,000,000 sysctl reads, 0 hits.
  • msgbuf_diag (sharper diagnostic) run as maxx on a fresh boot: 1,500,000 sysctl reads, 0 over-long reads, 0 suspect tails. Maximum returned length = 8573 bytes (= the actual boot-log size). See run.unprivileged.log.

4. Geometry trace (why steady state can't underflow)

In sysctl_kern_msgbuf (sys/kern/subr_prf.c:1119-1200) with no shrink (n <= msg_size - 1024; the steady-state case where msg_bufr tracks msg_bufx - msg_size + 2048):

  • rindex_modulo = (msg_bufx + 2048) % msg_size
  • xindex_modulo = msg_bufx % msg_size
  • n = msg_size - 2048

Branch decision: - branch 1 (rindex_modulo < xindex_modulo): holds when xindex_modulo >= msg_size - 2048 (the +2048 wraps). - branch 2 (rindex_modulo == xindex_modulo): impossible (would need 2048 โ‰ก 0). - branch 3 (rindex_modulo > xindex_modulo AND n <= msg_size - rindex_modulo): the first clause holds when xindex_modulo < msg_size - 2048; combined with n <= msg_size - rindex_modulo โŸบ msg_size - 2048 <= msg_size - 2048 - xindex_modulo โŸบ xindex_modulo <= 0 โŸบ xindex_modulo == 0 (and then rindex_modulo = 2048). - branch 4: otherwise (xindex_modulo in [1, msg_size - 2048)).

At the branch-3 moment in steady state: - buggy length = n - rindex_modulo = (msg_size - 2048) - 2048 = msg_size - 4096 - correct length = n = msg_size - 2048 - difference = 2048 bytes UNDER-read - copyout reads msg_size - 4096 bytes starting at msg_ptr + 2048, ending at msg_ptr + msg_size - 2048. In bounds โ€” no leak.

For an OOB read we need rindex_modulo > n. With branch-3's geometry that requires rindex_modulo > msg_size - rindex_modulo โŸบ rindex_modulo > msg_size/2. But steady state pins rindex_modulo = 2048. Contradiction.

To get rindex_modulo > msg_size/2 in branch 3, msg_bufr must be stale at a value whose modulo exceeds msg_size/2. That staleness is produced only by sysctl_kern_msgbuf_clear (subr_prf.c:1213-1218), which is root-only.

5. Realistic severity

The bug is a real code defect (wrong length passed to sysctl_handle_opaque) and produces a genuine OOB read when the geometry is right. But:

  • The geometry requires root to open (kern.msgbuf_clear=1).
  • Even after root opens it, the OOB window is exactly one msg_bufx value wide per msg_size (~1 MiB) bytes of new kernel log output. Catching it from a polled sysctl read is a tight race; a 3,000,000-iter brute-force attempt with 1-byte-step console writes did not catch it within the time budget. (The kvm_write trigger above shortcuts this timing deterministically by writing msg_bufx and msg_bufr directly to the kernel struct.)
  • An attacker who already has root (to write kern.msgbuf_clear) does not need an OOB read to escalate. The realistic impact ceiling of THIS bug alone is therefore local kernel info-leak / DoS only after root msgbuf_clear โ€” not the unauthenticated/unprivileged leak claimed.

Suggested severity refinement: Low (rather than Medium) given the root-only window. The code fix is still warranted (it's a real defect that turns an otherwise-correct branch into a latent OOB).

6. The fix

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

7. Files in this evidence pack

file role
msgbuf_oob.c original unprivileged PoC (polls kern.msgbuf; harmless)
msgbuf_diag.c sharper unprivileged diagnostic (reports over-long/suspect reads)
dump_msgbuf.c kvm(3) reader: dumps msg_bufx/bufr and the branch-3 decision
msgbuf_oob_decisive.c DECISIVE root-only trigger: kvm_write bad geometry + sysctl read โ†’ panic
msgbuf_trigger.c earlier natural-path trigger attempt (timing-based; superseded)
msgbuf_brute.c root-only natural-path brute-forcer (1-byte-step + read loop)
run_brute.sh wrapper: arrange stale msg_bufr via msgbuf_clear, then brute-force
build.sh builds all five binaries
run.sh runs unprivileged (./run.sh unprivileged) or decisive (./run.sh decisive)
panic.txt tight panic signature from both decisive runs (proof)
leak_sample.txt explanation of the panic signature and what it proves
run.unprivileged.log full unprivileged poll log (1.5M reads, 0 hits โ€” fresh boot)
env.txt guest uname, cc version, relevant sysctls
fix.diff git-apply-able fix: n - rindex_modulo โ†’ n in branch 3
README.md (updated) human-facing readme
manifest.json machine-readable catalog

Confirmed kernel references

Detail

Exploit chain

Trigger: arrange msg_bufr stale at modulo>msg_size/2 (only via root kern.msgbuf_clear=1), then time a sysctl kern.msgbuf read to land on the single msg_bufx value per msg_size (~1MiB) where xindex_modulo==0. At that instant branch 3 fires with underflowed length ~4GiB; sysctl_old_user (kern_sysctl.c:1337) clips to oldlen and copyouts that many bytes from msg_ptr+rindex_modulo. If adjacent memory is mapped -> kernel-heap info leak (pointers, possibly creds/crypto); if unmapped -> kernel panic. The kvm_write decisive trigger short-cuts the timing and panics the guest. No further primitive: the only reachable effect is a one-shot leak/panic gated behind root, so an attacker who can clear msgbuf already has root and gains nothing additional. Not a viable privilege-escalation primitive.

Evidence (decisive lines)

Decisive: panic.txt holds the tight kernel panic signature from both decisive runs -- 'panic: assertion "obj != NULL" failed in vm_object_hold_shared at vm/vm_object.c:330', stack vm_object_hold_shared<-vm_object_hold_shared<-vm_fault<-trap_pfault<-trap<-calltrap, '--- trap 0xc, rip ffffffff80bca5ba ---', 'std_copyout() at std_copyout+0x15a'. msgbuf_oob_decisive.c is the trigger; run.sh decisive runs it. Negative: run.unprivileged.log shows 1,500,000 sysctl reads as maxx on a fresh boot, max-returned-length 8573, 0 over-long, 0 suspect tails (RC=2 'no OOB'). VERDICT.md has the full geometry trace and reachability analysis.

PoC changes

Added msgbuf_diag.c (sharper unprivileged diagnostic), dump_msgbuf.c (kvm reader for msg_bufx/bufr and the branch-3 decision), msgbuf_oob_decisive.c (DECISIVE root-only kvm_write trigger that panics the kernel -- the actual reproduction), msgbuf_trigger.c + msgbuf_brute.c + run_brute.sh (natural-path brute-force attempts that did NOT catch the 1-byte window in the time budget, kept for completeness), build.sh / run.sh (run.sh unprivileged | run.sh decisive), VERDICT.md (full analysis), leak_sample.txt, panic.txt, run.unprivileged.log, env.txt, fix.diff, manifest.json. The original msgbuf_oob.c is unchanged.

Verified recommended fix

fix.diff: change the third argument of sysctl_handle_opaque in sysctl_kern_msgbuf's 3rd branch from 'n - rindex_modulo' to 'n' (sys/kern/subr_prf.c:1183), matching branches 1 (xindex_modulo - rindex_modulo) and 4 (msg_size - rindex_modulo then n) which already use correct offset math. Matches the finding's ## Recommended fix proposal exactly; verified git apply --check passes.

Verdict

REPRODUCED with a material caveat: the buggy length math (n - rindex_modulo instead of n) at sys/kern/subr_prf.c:1183 IS a real OOB read. Forced into the bad geometry via kvm_write (msg_bufx=msg_size, msg_bufr=msg_size/2+100000 -> xindex_modulo=0, rindex_modulo>msg_size/2), a single sysctlbyname('kern.msgbuf', buf, 1MiB) panics the kernel deterministically in std_copyout (trap 0xc page fault, rip ffffffff80bca5ba) -- reproduced twice with identical signatures (panic.txt). The copyout walks past msg_ptr+msg_size into adjacent unmapped kernel memory; had it been mapped the same read would leak heap residue. HOWEVER, the finding's threat model ('any local user can poll kern.msgbuf to hit the window') is INCORRECT. In normal operation msg_bufr tracks msg_bufx - msg_size + 2048 (subr_prf.c:1067-1071), which pins branch 3 to fire only at xindex_modulo==0 with rindex_modulo==2048; there the buggy length is msg_size-4096 (a 2048-byte UNDER-read, no OOB). The OOB underflow condition (rindex_modulo>n) requires msg_bufr to be stale, which ONLY kern.msgbuf_clear=1 produces (subr_prf.c:1213-1218) -- and that sysctl is root-only (verified: 'sysctl kern.msgbuf_clear=1' as maxx -> EPERM). Empirically confirmed: 2,000,000 + 1,500,000 unprivileged sysctl reads as maxx produced 0 over-long reads and 0 non-text tails (run.unprivileged.log). Severity should be Low (root-only window), not Medium as filed.