mps MPTIOCTL_REG_ACCESS: unvalidated u32 RegOffset gives operator-group users arbitrary kernel read/write in the 4 GiB window above the register BAR
Summary
mps_user_reg_access passes the raw user RegOffset straight into mps_regread/mps_regwrite = plain volatile u32 loads/stores at bhandle+offset on the x86_64 memory bus. No bound on the offset: a member of group operator (0640 root:operator node, ioctls not gated on open mode) can read AND write any kernel virtual address in [BAR, BAR+4GiB) and panic at will. Proven LIVE on the guest with a verbatim KLD replica driven by uid=2: write 0xdeadbeef at BAR+0x2000 read back; kernel-arena words leaked past the allocation; Fatal trap 12 inside the raw movl on unmapped offsets. Cleanest privesc route in the mps ioctl surface: REG_MEM_READ scan -> locate cred in window -> REG_MEM_WRITE patch -> uid=0. The mpr sibling has the identical bug (mpr_user.c:2193-2198). rman_get_size bound fix compiled+booted in combined nativekernel validation.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4012 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df4012rig.c | β | 13.9 KB | view raw | |
| df4012rig_trigger.c | β | 15.0 KB | view raw | |
| rig.Makefile | β | 58 B | β download | |
| rig_build.log | β | 835 B | view raw | |
| rig_run.log | β | 3.5 KB | view raw | |
| leak_sample.txt | β | 1.4 KB | view raw | |
| panic.txt | β | 716 B | view raw | |
| panic2.txt | β | 2.1 KB | view raw | |
| fix_run.log | β | 1.9 KB | view raw | |
| fix_kernel_build2.log | β | 1.1 KB | view raw | |
| fix.diff | β | 980 B | view raw | |
| env.txt | β | 722 B | view raw | |
| VERDICT.md | β | 5.9 KB | β raw | |
| verdict.json | β | 6.7 KB | view raw |
DF-4012 VERDICT (pass 3 -- production-handler dispatch, superseding the pass-2 replica)
Status: REPRODUCED (impact ceiling demonstrated on production code: arbitrary-offset kernel write w/ full value control, kernel-memory disclosure, deterministic operator-triggered panic; uid=0 last hop blocked on this rig by scan-vs-churn timing, not by the primitive -- see "uid=0 chain" below).
What was verified this run (2026-10-12)
The pass-2 pack proved the bug with a replica dispatch. This run
upgraded to the DF-1326/DF-1359 rig class: a KLD (df4012rig.ko) that
creates /dev/df4012rig with the EXACT ownership mps_attach_user()
gives /dev/mpsN (0640 root:operator, mps_user.c:188) and forwards
every ioctl verbatim to the REAL production handler mps_ioctl_devsw
(resolved from the running kernel binary via nm, prologue byte-verified
before every dispatch). Everything from dev_dioctl() down -- the ioctl
switch (mps_user.c:2177), mps_lock/mps_unlock, mps_user_reg_access
(mps_user.c:1959), the inlined mps_regread/mps_regwrite
(mpsvar.h:462-475) and the x86_64 bus_space inlines
(cpu/x86_64/include/bus_dma.h:153-158, 401-408) -- is production code
executing from the shipped kernel image. The rig stands in ONLY for
the LSI HBA + its PCI BAR (a 4 MiB kmalloc block; first 4 KiB =
"aperture", DWARF-proven softc offsets +0x118/+0x120/+0x128/+0x190).
Disassembly of the shipped binary (inlined REG_MEM_READ inside
mps_ioctl_devsw @ 0xffffffff80599be0):
cmpq $0x0,0x170(%r12) ; if (btag == X86_64_BUS_SPACE_IO) mov 0x4(%r13),%eax ; RegOffset (u32, zero-extended) mov 0x168(%r12),%rdx ; bhandle je <port path> add %rdx,%rax ; addr = bhandle + (u64)RegOffset <-- NO BOUND mov (%rax),%eax ; *(volatile u32 *)addr mov %eax,0x8(%r13) ; data->RegData = result
Demonstrated, as uid=2 over a 0640 node opened O_RDONLY
- Arbitrary-offset kernel WRITE with full value control:
REG_MEM_WRITE RegOffset=0x2000 RegData=0xdeadbeefandRegOffset=0x3000 RegData=0x41414141-- both 2x/3x past the 0x1000 aperture -- executed by the production handler and READ BACK viaREG_MEM_READ(rig_run.log RUN 1/2). There is no bound check anywhere on the path; the write lands atbhandle + (u64)u32, i.e. anywhere in [BAR, BAR+4 GiB). - Kernel-memory disclosure: a 384 KiB scan at BAR+4 MiB (rig_run.log RUN 3, leak_sample.txt) disclosed 639 kernel pointers, including kernel-image addresses (0xffffffff81457d40, 0xffffffff807b1af0) -- KASLR-defeating disclosure at ~270k production ioctls/s.
- Deterministic panic (4 captures): any RegOffset whose
bhandle+offset is unmapped faults INSIDE the production inline --
Fatal trap 12 ... rip = mps_ioctl_devsw+0x10dd/vm_fault: fault on stack guard/ teardown-raceobj != NULLasserts (panic.txt, panic2.txt). Unprivileged, repeatable, kernel-down.
uid=0 chain (progress + hard blocker)
With the rig loaded at BOOT (loader.conf -- the hardware-faithful condition: a real mps_attach maps the BAR during boot, so the ENTIRE post-boot kernel heap, including every process/proc/cred zone slab, lies ABOVE the BAR inside the +4 GiB window):
- the OOB READ scan located the forked brood's
struct procarray at BAR+~148 MiB (0xfffff801179d36dc..., 16 children at 0x500 stride, p_pid/p_ppid verified -- rig_run.log RUN 4): kernel structure location via the primitive works; - the chain's remaining hop -- patch that child's p_ucred (two u32 REG_MEM_WRITEs) to a fake root cred pre-built with in-aperture writes -- never executed because the full-window scan takes minutes at ioctl speed (~3 us/access) and the live kernel heap recycles pages during the scan; 4 scan attempts each eventually hit a page that unmapped between the rig-side mapped-check and the production read, panicking the guest (panic2.txt captures 2-4). This is a timing artifact of the rig's slow oracle, not a property of the target: the primitive itself (read/write anything in-window) is fully proven by items 1-3, and on hardware the same scan has the whole boot-to-attack window and arbitrary retries.
Fix validation
fix.diff bounds RegOffset by rman_get_size(sc->mps_regs_resource)
before any bus_space access. Validated on a full rebuilt kernel
(both twins' fix.diffs applied to /usr/src, make nativekernel,
installkernel, reboot): the rig then dispatches the FIXED production
handler. Result (fix_run.log): in-aperture R/W still works; every
out-of-aperture offset -- including the previously-panicking large
offsets and the max-u32 0xfffffffc -- returns EINVAL with the guest
healthy.
Bottom line
The cited path is real, unprivileged, and fully demonstrated on production code: operator-group users get read+write over the whole 4 GiB KVA window above the register BAR plus a deterministic panic. Severity Critical stands (CVSS AV:L/AC:L/PR:L/S:C/C:H/I:H/A:H); the realistic full-impact route is the cred/function-pointer write documented above.
Fix-validation negative control (worth noting)
During validation the rig's fake struct resource was initially
mis-fabricated (r_start/r_end written at +0x20/+0x28; DWARF truth is
+0x28/+0x30), so rman_get_size() computed 0 - 0xfff + 1 = a
wrapped huge size and the FIXED kernel still executed the OOB write
(Fatal trap 12, supervisor WRITE, mps_ioctl_devsw+0x14dd). This
accidentally proves the fix's check path runs on every dispatch and
that its strength is exactly the resource's true size. After
correcting the fabrication, all out-of-aperture offsets return EINVAL
(fix_run.log).
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedBaseline (kernel #0 Jul 2): OOB writes at +0x2000/+0x3000 executed and read back; 639-pointer OOB disclosure; 4 deterministic panics inside the production inline (rip mps_ioctl_devsw+0x10dd). Patched (kernel #1 Oct 12 05:11:38, both twins' fix.diffs, full nativekernel + installkernel + reboot, rig dispatching the FIXED production handler): in-aperture read/write still works; every out-of-aperture offset -- +0x2000, +0x3000, and max-u32 0xfffffffc (which faulted the vulnerable kernel) -- returns errno=22 EINVAL before any bus_space access; guest healthy. Negative control during validation: an (initially) mis-fabricated struct resource made rman_get_size wrap huge and the fixed kernel then executed the OOB write (Fatal trap 12, supervisor write, mps_ioctl_devsw+0x14dd) -- demonstrating the fix's check path runs and that its strength is exactly the true aperture size; after correcting the fabrication to DWARF-proven r_start@+0x28/r_end@+0x30 all OOB is rejected.
fix_run.log, fix_kernel_build2.log, fix.diff
Confirmed kernel references
Detail
Exploit chain
operator-group member -> open /dev/mpsN O_RDONLY -> ioctl(MPTIOCTL_REG_ACCESS, REG_MEM_WRITE, RegOffset=delta, RegData=value) stores a fully controlled u32 at BAR+delta for any delta in [0,4GiB); REG_MEM_READ likewise discloses kernel memory (639 pointers incl. KASLR-relevant image addresses); boot-loaded BAR (real hardware: driver attach at boot) places the entire process/cred heap above the BAR, so scan->locate victim object (demonstrated: child struct proc at +148MiB)-> patch p_ucred/cred fields -> uid=0; any unmapped offset = deterministic kernel panic (4 captures).
Evidence (decisive lines)
rig_run.log RUN1-4 (OOB write/readback x2, 639-pointer leak, child-proc location at +148MiB); leak_sample.txt; panic.txt + panic2.txt (rip = mps_ioctl_devsw+0x10dd x4); fix_run.log (fixed kernel: all OOB -> errno=22, in-aperture OK); fix_kernel_build2.log; df4012rig.c/df4012rig_trigger.c; VERDICT.md
PoC changes
replaced the pass-2 verbatim-replica dispatch with a production-handler KLD dispatch rig (DF-1326/1359 class): real mps_ioctl_devsw resolved via nm at runtime, 8-byte prologue verification, fabricated softc at DWARF-proven offsets (+0x118 regs_resource, +0x120 bhandle, +0x128 btag=MEM, +0x190 lock), fake BAR = 4 MiB kmalloc with 0x1000 aperture + fake struct resource (r_start@+0x28/r_end@+0x30) for the fixed-kernel run; trigger gained leak/uid0 modes, a pid-bitmap (pid,ppid) pair scanner, and rig-internal refresh/page-mapped/run-list ioctls; boot-time rig loading via loader.conf reproduces the hardware BAR-at-boot condition.
Verified recommended fix
Bound RegOffset by rman_get_size(sc->mps_regs_resource) in mps_user_reg_access before any bus_space access (fix.diff; validated live on a rebuilt kernel).
Verdict
REPRODUCED on production code (pass-3 rig, DF-1326/1359 class): a KLD forwarding ioctls verbatim to the REAL shipped mps_ioctl_devsw over a 0640 root:operator node, driven by uid=2, proves (a) arbitrary-offset kernel WRITE with full value control in [BAR, BAR+4GiB): REG_MEM_WRITE at +0x2000 (0xdeadbeef) and +0x3000 (0x41414141), both past the 0x1000 aperture, accepted and read back -- mps_user.c:1979 passes the raw u32 RegOffset straight into the inline volatile store with no bound (disassembly: add %rdx,%rax; mov; store at mps_ioctl_devsw); (b) kernel-memory disclosure: 639 kernel pointers read out at BAR+4MiB in 0.06s, incl. kernel-image addresses 0xffffffff81457d40 / 0xffffffff807b1af0; (c) deterministic unprivileged kernel panic: 4 captures of Fatal trap 12 / vm_fault-inside-the-inline (rip = mps_ioctl_devsw+0x10dd) on unmapped offsets. uid=0 push: with the rig boot-loaded (loader.conf; the hardware-faithful condition -- a real mps_attach maps the BAR at boot so the whole process heap lies above it in-window), the OOB read scan LOCATED the forked brood's struct proc slab at BAR+~148MiB (16 procs at 0x500 stride, p_pid/p_ppid verified); the final hop (two u32 REG_MEM_WRITEs redirecting the child's p_ucred to a fake root cred built with in-aperture writes) never executed because minute-long ioctl-speed scans of the live heap kept hitting pages that unmap mid-scan (4 panics of exactly this class, panic2.txt) -- a rig timing artifact, not a limit of the primitive (read/write-anything-in-window fully proven). Fix validated live: both twins' fix.diffs built into a full nativekernel (#1 Oct 12 05:11:38), the rig then dispatches the FIXED production handler: in-aperture R/W intact, every out-of-aperture offset incl. 0xfffffffc returns errno=22, guest healthy; a mis-fabricated-resource negative control additionally showed the fixed check executes rman_get_size on the resource and its strength is the aperture size.
No comments yet.