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

Stack buffer overflow in mps_user_pass_thru via unbounded copyin into 12-byte tmphdr

Summary

mps_user_pass_thru at mps_user.c:802: copyin(PtrRequest,&tmphdr,RequestSize). tmphdr is MPI2_REQUEST_HEADER 12 bytes. RequestSize uint32 user-controlled. Bound check at :806 AFTER copyin. Identical twin of DF-1326 (mpr_user.c). Also bcopy(&tmphdr,task/hdr,RequestSize) at :830/:878 reads past tmphdr off stack. Operator group (/dev/mpsN 0640). Fix: check RequestSize BEFORE copyin, copyin only sizeof(tmphdr).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1359 Β· 19 files
FileTypeDescriptionSize
df1359rig.c β€” 8.4 KB view raw
df1359rig_trigger.c β€” 11.1 KB view raw
rig.Makefile β€” 58 B ↓ download
rig_build.sh β€” 864 B view raw
rig_run.sh β€” 1.1 KB view raw
run.log β€” 5.4 KB view raw
rig_probe_first.log β€” 638 B view raw
rig_probe_uid0.log β€” 638 B view raw
rig_evaded_uid0.log β€” 638 B view raw
rig_rip_control_trap.txt β€” 2.5 KB view raw
rig_smash_panic.txt β€” 2.4 KB view raw
rig_fixed_runs.log β€” 1.2 KB view raw
rig_fixed_disasm_order.txt β€” 1.3 KB view raw
rig_env_patched.txt β€” 473 B view raw
env.txt β€” 1.8 KB view raw
fix.diff β€” 2.5 KB view raw
fix_build.log β€” 5.6 MB ↓ download
VERDICT.md β€” 6.8 KB ↓ raw
verdict.json β€” 8.5 KB view raw
VERDICT.md
↓ download raw

DF-1359 VERDICT -- reproduced (impact: uid0_privesc; confidence: certain)

Bottom line

REPRODUCED (pass 3, 2026-10-11): full unprivileged-to-root escalation through the REAL production mps ioctl handler of the shipped kernel. An operator-group user (uid=2/gid=5, no other privilege) opened the rig's 0640 root:operator node O_RDONLY and issued MPTIOCTL_PASS_THRU; the real mps_user_pass_thru (0xffffffff805b0be0 in kernel #0, md5 4625046e...) executed copyin(PtrRequest, &tmphdr, RequestSize) at mps_user.c:802 into its 12-byte stack tmphdr (rbp-0x3c) with the only bounds check (:806, facts->IOCRequestFrameSize*4) running AFTER the copy. RequestSize=132 wrote 120 attacker-controlled bytes through the frame, overwriting the saved RIP at &tmphdr+0x44 (epilogue at mps_user_pass_thru+0x62d pops rbx,r12,r13,r14,r15,rbp,ret entirely from the copyin'd bytes). The EINVAL error return then executed the attacker's RIP: a user RWX page (no SMEP) whose shellcode walked gs:0 -> gd_curthread(+8) -> td_proc(+168) -> p_ucred(+16), zeroed cr_uid/cr_ruid/cr_svuid/cr_groups[0]/cr_rgid/cr_svgid, and swapgs+iretq'd to a userspace continuation reporting getuid()=0 and spawning a root shell. 5/5 probe successes (incl. 1 from a fresh vm.sh reset with-src snapshot) + 1/1 evaded (RequestSize=96, which the post-hoc check ACCEPTS -- dmesg even records the real production 'unknown: mps_user_pass_thru: no mps requests' ENOMEM print immediately before the takeover). Deterministic destructive proofs: rip mode -> Fatal trap 9 at mps_user_pass_thru+0x63e: ret with the attacker tag as the popped RIP; smash mode (RequestSize=4096, unbounded because the check runs after the copy) -> same trap with frame pointer 0x4546474841424344. The single root-staged element is the KLD rig standing in for the LSI Fusion-MPT PCI attach QEMU cannot emulate (fabricated softc with facts block at +0x148 holding IOCRequestFrameSize=32, mps_lock +0x190, free-list head +0xd0 NULL, 0640 make_dev exactly as mps_attach_user); the rig byte-verifies the production handler prologue AND the vulnerable copyin site before every dispatch, and everything from dev_dioctl down is shipped-kernel code. On real mps hardware the HBA performs that attach and the whole chain is attacker-only from open("/dev/mps0", O_RDONLY). Fix validated on a rebuilt kernel (fix.diff 3/3 hunks, nativekernel rc=0, kernel #1 md5 06407b51...): all four modes return cleanly (EINVAL x5, ENOMEM x1), zero traps, guest healthy; disassembly confirms the range check now precedes the copyin, which is limited to sizeof(tmphdr)=12 bytes.

Reproduce

build: sh rig_build.sh  (make -f rig.Makefile SYSDIR=/usr/src/sys MACHINE=x86_64 MACHINE_ARCH=x86_64; cc -O -Wall -o trigger df1359rig_trigger.c; kldload ./df1359rig.ko; sysctl -w kern.df1359rig_target=0x$(nm /boot/kernel/kernel | awk '/ t mps_ioctl_devsw$/{print $1}'))
run:   su operator -c '/root/rig1359/trigger probe'

Evidence in this pack

run.log (consolidated canonical log); rig_probe_first.log + rig_probe_uid0.log (uid=0 outputs, first + fresh-snapshot); rig_evaded_uid0.log; rig_rip_control_trap.txt (Fatal trap 9 at mps_user_pass_thru+0x63e: ret, ip=0xffffffff805b121e, frame pointer 0x0); rig_smash_panic.txt (same trap, frame pointer 0x4546474841424344); rig_fixed_runs.log + rig_fixed_disasm_order.txt + rig_env_patched.txt (patched kernel: EINVAL/ENOMEM matrix, check-before-copyin with mov $0xc,%edx); env.txt (symbols/offsets/disassembly ground truth); VERDICT.md pass-3 addendum

Kernel references

Exploit chain

operator uid=2/gid=5 -> open 0640 root:operator node O_RDONLY (ioctls not gated on open mode) -> ioctl(MPTIOCTL_PASS_THRU, RequestSize=132) -> dev_dioctl -> REAL mps_ioctl_devsw @0xffffffff805b16d0 -> mps_user_pass_thru @0xffffffff805b0be0 -> copyin into 12-byte tmphdr at rbp-0x3c (site +0x7b) smashes 120 bytes -> saved RIP at &tmphdr+0x44 = user RWX page 0x10000000 (no SMEP, no KASLR, no stack canary) -> post-hoc check rejects 132>128, EINVAL path converges at +0x723 -> epilogue ret through smashed RIP -> shellcode: mov rax,[gs:0]; rax+8=curthread; +168=proc; +16=ucred; zero cr_uid/cr_ruid/cr_svuid/cr_groups/cr_rgid/cr_svgid; swapgs; push iretq frame (ss,rsp,rflags,cs,rip) -> userspace continuation -> getuid()=0 -> execl /bin/sh -> uid=0(root) gid=0(wheel). Evaded variant: RequestSize=96 passes the post-hoc check, ENOMEM path (free-list head sc+0xd0 NULL) returns through the same smashed RIP -> same uid=0.

Fix validation (A/B)

Bounds-check RequestSize BEFORE the copyin and limit the copy to sizeof(tmphdr); re-read the full request directly from userspace into the sized HW buffer at the bcopy sites (see fix.diff).

Baseline vs post-fix behavior is in run.log / run_fixed*.log (userspace) or panic.txt/hang.txt vs run_fixed_kernel.log (kernel); the identical rig ran both sides.

poc_changes

Re-derived the proven DF-1326 (mpr) rig for the mps twin rather than reusing it: (1) new symbol addresses from nm of the shipped kernel (mps_ioctl_devsw @0xffffffff805b16d0 -- mps_user_pass_thru is a real out-of-line call at 0xffffffff805b0be0, not inlined as in mpr); (2) mps-specific softc offsets from disassembly: mps_dev +0x0, mps_flags +0x10, mps_debug +0x14, free-list head +0xd0, facts POINTER +0x148 (key delta: mps's check dereferences sc->facts->IOCRequestFrameSize, U16 @facts+0x24 -- fabricated facts block set to 32 so the post-hoc bound is 128, matching the twin), mps_lock +0x190 (mpr twin: +0x1d8, reqframesz +0x1c); (3) saved-RIP offset re-derived: 5 pushes + sub 0x28 with tmphdr at rbp-0x3c gives RIP at &tmphdr+0x44 (coincidentally identical to mpr); (4) added a byte-check of the vulnerable copyin site (lea -0x3c(%rbp),%rsi; call copyin at +0x7b) plus a kern.df1359rig_vulncheck toggle so fix-validation can dispatch the PATCHED handler explicitly rather than silently; (5) trigger renamed to mps_pass_thru_t / /dev/df1359rig / kern.df1359rig_* sysctls; evaded-mode Function byte 0x20 (anything != MPI2_FUNCTION_SCSI_TASK_MGMT 0x01 takes the mps_alloc_command ENOMEM path).

Guest: DragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 Attempts: 1 Runtime: ~1500s

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied cleanly (3/3 hunks) to the in-guest /usr/src; single-fix nativekernel X86_64_GENERIC BUILD_RC=0 and installkernel INSTALL_RC=0; patched kernel #1 (md5 06407b51b4faf526c26e1c25c198d2fd) boots with the rig re-primed to the patched mps_ioctl_devsw @0xffffffff805b16f0 (prologue byte-identical, dispatching into PATCHED production code with kern.df1359rig_vulncheck=0). Same unprivileged operator, same trigger: probe/rip/smash return rc=-1 EINVAL (5 runs total), evaded returns rc=-1 ENOMEM (12-byte copyin only, flow proceeds and returns normally); zero Fatal traps in dmesg; guest healthy. objdump proves the new ordering: cmp $0xb,%r8d + facts-deref range check reach the EINVAL block BEFORE the copyin site, which is now mov $0xc,%edx (sizeof(tmphdr) only). The exact behavior that produced uid=0 and two fatal traps on the baseline kernel is GONE on the patched kernel.

rig_fixed_runs.log (EINVAL/ENOMEM matrix + uptime + dmesg trap count 0); rig_fixed_disasm_order.txt (patched check-before-copyin disassembly with mov $0xc,%edx copyin); rig_env_patched.txt (patched kernel identity); run.log section 7
↓ fix.diffper-fix-DF-1359

Confirmed kernel references

Detail

Exploit chain

operator uid=2/gid=5 -> open 0640 root:operator node O_RDONLY (ioctls not gated on open mode) -> ioctl(MPTIOCTL_PASS_THRU, RequestSize=132) -> dev_dioctl -> REAL mps_ioctl_devsw @0xffffffff805b16d0 -> mps_user_pass_thru @0xffffffff805b0be0 -> copyin into 12-byte tmphdr at rbp-0x3c (site +0x7b) smashes 120 bytes -> saved RIP at &tmphdr+0x44 = user RWX page 0x10000000 (no SMEP, no KASLR, no stack canary) -> post-hoc check rejects 132>128, EINVAL path converges at +0x723 -> epilogue ret through smashed RIP -> shellcode: mov rax,[gs:0]; rax+8=curthread; +168=proc; +16=ucred; zero cr_uid/cr_ruid/cr_svuid/cr_groups/cr_rgid/cr_svgid; swapgs; push iretq frame (ss,rsp,rflags,cs,rip) -> userspace continuation -> getuid()=0 -> execl /bin/sh -> uid=0(root) gid=0(wheel). Evaded variant: RequestSize=96 passes the post-hoc check, ENOMEM path (free-list head sc+0xd0 NULL) returns through the same smashed RIP -> same uid=0.

Evidence (decisive lines)

run.log (consolidated canonical log); rig_probe_first.log + rig_probe_uid0.log (uid=0 outputs, first + fresh-snapshot); rig_evaded_uid0.log; rig_rip_control_trap.txt (Fatal trap 9 at mps_user_pass_thru+0x63e: ret, ip=0xffffffff805b121e, frame pointer 0x0); rig_smash_panic.txt (same trap, frame pointer 0x4546474841424344); rig_fixed_runs.log + rig_fixed_disasm_order.txt + rig_env_patched.txt (patched kernel: EINVAL/ENOMEM matrix, check-before-copyin with mov $0xc,%edx); env.txt (symbols/offsets/disassembly ground truth); VERDICT.md pass-3 addendum

PoC changes

Re-derived the proven DF-1326 (mpr) rig for the mps twin rather than reusing it: (1) new symbol addresses from nm of the shipped kernel (mps_ioctl_devsw @0xffffffff805b16d0 -- mps_user_pass_thru is a real out-of-line call at 0xffffffff805b0be0, not inlined as in mpr); (2) mps-specific softc offsets from disassembly: mps_dev +0x0, mps_flags +0x10, mps_debug +0x14, free-list head +0xd0, facts POINTER +0x148 (key delta: mps's check dereferences sc->facts->IOCRequestFrameSize, U16 @facts+0x24 -- fabricated facts block set to 32 so the post-hoc bound is 128, matching the twin), mps_lock +0x190 (mpr twin: +0x1d8, reqframesz +0x1c); (3) saved-RIP offset re-derived: 5 pushes + sub 0x28 with tmphdr at rbp-0x3c gives RIP at &tmphdr+0x44 (coincidentally identical to mpr); (4) added a byte-check of the vulnerable copyin site (lea -0x3c(%rbp),%rsi; call copyin at +0x7b) plus a kern.df1359rig_vulncheck toggle so fix-validation can dispatch the PATCHED handler explicitly rather than silently; (5) trigger renamed to mps_pass_thru_t / /dev/df1359rig / kern.df1359rig_* sysctls; evaded-mode Function byte 0x20 (anything != MPI2_FUNCTION_SCSI_TASK_MGMT 0x01 takes the mps_alloc_command ENOMEM path).

Verified recommended fix

Bounds-check RequestSize BEFORE the copyin and limit the copy to sizeof(tmphdr); re-read the full request directly from userspace into the sized HW buffer at the bcopy sites (see fix.diff).

Verdict

REPRODUCED (pass 3, 2026-10-11): full unprivileged-to-root escalation through the REAL production mps ioctl handler of the shipped kernel. An operator-group user (uid=2/gid=5, no other privilege) opened the rig's 0640 root:operator node O_RDONLY and issued MPTIOCTL_PASS_THRU; the real mps_user_pass_thru (0xffffffff805b0be0 in kernel #0, md5 4625046e...) executed copyin(PtrRequest, &tmphdr, RequestSize) at mps_user.c:802 into its 12-byte stack tmphdr (rbp-0x3c) with the only bounds check (:806, facts->IOCRequestFrameSize*4) running AFTER the copy. RequestSize=132 wrote 120 attacker-controlled bytes through the frame, overwriting the saved RIP at &tmphdr+0x44 (epilogue at mps_user_pass_thru+0x62d pops rbx,r12,r13,r14,r15,rbp,ret entirely from the copyin'd bytes). The EINVAL error return then executed the attacker's RIP: a user RWX page (no SMEP) whose shellcode walked gs:0 -> gd_curthread(+8) -> td_proc(+168) -> p_ucred(+16), zeroed cr_uid/cr_ruid/cr_svuid/cr_groups[0]/cr_rgid/cr_svgid, and swapgs+iretq'd to a userspace continuation reporting getuid()=0 and spawning a root shell. 5/5 probe successes (incl. 1 from a fresh vm.sh reset with-src snapshot) + 1/1 evaded (RequestSize=96, which the post-hoc check ACCEPTS -- dmesg even records the real production 'unknown: mps_user_pass_thru: no mps requests' ENOMEM print immediately before the takeover). Deterministic destructive proofs: rip mode -> Fatal trap 9 at mps_user_pass_thru+0x63e: ret with the attacker tag as the popped RIP; smash mode (RequestSize=4096, unbounded because the check runs after the copy) -> same trap with frame pointer 0x4546474841424344. The single root-staged element is the KLD rig standing in for the LSI Fusion-MPT PCI attach QEMU cannot emulate (fabricated softc with facts block at +0x148 holding IOCRequestFrameSize=32, mps_lock +0x190, free-list head +0xd0 NULL, 0640 make_dev exactly as mps_attach_user); the rig byte-verifies the production handler prologue AND the vulnerable copyin site before every dispatch, and everything from dev_dioctl down is shipped-kernel code. On real mps hardware the HBA performs that attach and the whole chain is attacker-only from open("/dev/mps0", O_RDONLY). Fix validated on a rebuilt kernel (fix.diff 3/3 hunks, nativekernel rc=0, kernel #1 md5 06407b51...): all four modes return cleanly (EINVAL x5, ENOMEM x1), zero traps, guest healthy; disassembly confirms the range check now precedes the copyin, which is limited to sizeof(tmphdr)=12 bytes.