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)
PoC verification
Evidence pack
findings/poc/DF-2586 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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): bumpsmsg_bufrtoxindex - msg_size + 2048only whenn = xindex - msg_bufr > msg_size - 1024. So in steady statemsg_bufr β msg_bufx - msg_size + 2048, i.e.rindex_modulo = 2048andn = msg_size - 2048.sysctl_kern_msgbuf_clear(line 1214): setsmsg_bufr := msg_bufxβ a write that requires root (kern.msgbuf_clearrejects non-wheel users; verified this session:sysctl kern.msgbuf_clear=1asmaxxβ 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_diagrun asmaxxon a fresh#0boot: 300,000 sysctl reads, 0 over-long reads, 0 suspect tails, max returned length = 8679 bytes (= the actual boot-log size). Seerun_unpriv.log.sysctl kern.msgbuf_clear=1asmaxxβ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
#0that ~4 GiB length reachessysctl_old_userwhich clips it tooldlen(1 MiB) andcopyouts 1 MiB starting atmsg_ptr + rindex_modulo, running pastmsg_ptr + msg_sizeinto unmapped kernel memory β page fault β panic. - On
#1the fix passesn(= 424272) tosysctl_handle_opaque; the copyout reads exactlymsg_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.diffapplied withpatch -p1 --forwardcleanly (Hunk #1 at line 1180).- Build:
cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERICβrc=0(full log infix_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
fixedVALIDATED. 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).
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.)
No comments yet.