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_msgbufdefault 1;ptr_restrictdefault 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 amsg_sizeboundary ~once per ~1 MiB of kernel log, while the reader lags past the buffer midpoint; winnable by sustainedsysctlpolling. - Required config or capabilities: none; default kernel.
- Reachability:
sysctl kern.msgbuf(orsysctlbyname) 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).
Recommended fix
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
sys/kern/subr_prf.c:1177-1184โ the underflowingn - rindex_modulo.sys/kern/subr_prf.c:1164-1171,1185-1198โ the correct length math in the other branches.- CWE-191 Integer Underflow; CWE-125 Out-of-bounds Read.
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| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
} 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_bufrtracksmsg_bufx - msg_size + 2048, which forces branch 3 to fire only atxindex_modulo==0withrindex_modulo=2048. The buggyn - rindex_modulothen equalsmsg_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_copyoutbecause the underflowed length (~4 GiB, clipped to oldlen) drives acopyoutthat walks off the end ofmsg_ptr's mapped pages into adjacent unmapped kernel memory. Trap 0xc (page fault), rip insidestd_copyout+0x15a. Reproduced twice; seepanic.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.
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): bumpsmsg_bufrtoxindex - msg_size + 2048only whenn = xindex - msg_bufr > msg_size - 1024. So in steady statemsg_bufr โ msg_bufx - msg_size + 2048exactly, i.e.rindex_modulo = (msg_bufx + 2048) % msg_sizeandn = 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:sysctl kern.msgbuf_clear=1as 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). Seerun.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_sizexindex_modulo = msg_bufx % msg_sizen = 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_bufxvalue wide permsg_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 writingmsg_bufxandmsg_bufrdirectly 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.