Stack buffer overflow in mpr_user_pass_thru: copyin of user-controlled RequestSize into 12-byte MPI2_REQUEST_HEADER before bounds check
Summary
mpr_user_pass_thru at mpr_user.c:800: copyin(PTRIN(data->PtrRequest),&tmphdr,data->RequestSize) where tmphdr is MPI2_REQUEST_HEADER (12 bytes on stack at :742). data->RequestSize is uint32_t user-controlled. Bounds check if(RequestSize>reqframesz) at :804 runs AFTER copyin. RequestSize>12 -> stack frame overflow with fully controlled bytes (saved RBP/RIP). /dev/mprN mode 0640 GID_OPERATOR. Reliable kernel panic or RCE. Fix: check RequestSize BEFORE copyin, copyin only sizeof(tmphdr).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1326 Β· 37 files| File | Type | Description | Size | |
|---|---|---|---|---|
| df1326rig.c | dispatch-rig | pass-3 KLD: fabricated softc + 0640 root:operator node + dispatch to the REAL compiled-in mpr_ioctl_devsw (prologue-verified); publishes struct offsets | 6.4 KB | view raw |
| rig.Makefile | build-script | kmod makefile for df1326rig | 58 B | β download |
| df1326rig_trigger.c | trigger-source | pass-3 unprivileged trigger: payload builder (RIP slot 0x44) + runtime kernel-shellcode emitter + userspace continuation (probe/evaded/rip/smash/stage*/dump) | 10.8 KB | view raw |
| fresh_run.sh | run-script | canonical fresh-snapshot sequence executed after vm.sh reset with-src | 1.2 KB | view raw |
| rig_build.sh | build-script | build + load + prime wrapper | 836 B | view raw |
| rig_run.sh | run-script | mode-dispatch run wrapper (as operator) | 1.1 KB | view raw |
| run.log | run-log | canonical fresh-snapshot uid=0 run + full baseline/patched mode matrix | 4.1 KB | view raw |
| build.log | build-log | rig build (-fno-stack-protector), fix.diff apply, nativekernel + installkernel completions | 2.2 KB | view raw |
| rig_probe_uid0.log | run-log | first full uid=0 escalation transcript | 1.1 KB | view raw |
| rig_stress_evaded.log | run-log | 3x probe stress + evaded (96<=128) escalations, 6/6 uid=0 | 1.4 KB | view raw |
| rig_rip_control_trap.txt | panic-capture | Fatal trap 9 AT mpr_user_pass_thru+0x6fe: ret with attacker RIP (controlled-return proof) | 1.0 KB | view raw |
| rig_smash_panic.txt | panic-capture | RequestSize=4096 spray: trap 9, frame pointer 0x4546474841424344 (unbounded smash proof) | 27.9 KB | view raw |
| rig_stage0_userpage_exec.txt | panic-capture | Fatal trap 1 at 0x10000000 in kernel mode: CPL0 executed userspace code (no SMEP) | 897 B | view raw |
| rig_fixed_runs.log | run-log | patched-kernel runs: all modes return EINVAL/ENOMEM cleanly, guest healthy, dmesg traps 0 | 966 B | view raw |
| rig_fixed_disasm_order.txt | evidence | objdump of installed patched kernel: range check reaches EINVAL BEFORE the copyin site | 865 B | view raw |
| rig_env_patched.txt | environment | patched kernel #1 boot env, kldstat, no /dev/mpr* | 543 B | view raw |
| fix.diff | suggested-fix | bounds-check before copyin + sizeof(tmphdr) limit + direct copyin at bcopy sites (validated end-to-end pass 3) | 2.5 KB | view raw |
| verdict.json | verdict | pass-3 machine verdict (reproduced / uid0_privesc / fix fixed) | 7.9 KB | view raw |
| VERDICT.md | verdict | pass-1/2/3 full narrative (pass 3: real-handler uid=0 chain + fix validation) | 6.3 KB | β raw |
| df1326_replica.c | kernel-replica | pass-2 verbatim-prologue KLD (superseded by df1326rig.c dispatch) | 10.0 KB | view raw |
| replica_run.log | run-log | pass-2 consolidated run (kept for history) | 3.9 KB | view raw |
| panic.txt | panic-capture | pass-2 panic (replica), Fatal trap 9 at dev_dioctl+0x6b | 2.5 KB | view raw |
| harness_overflow.c | primitive-proof | pass-1 userspace structural proof (52/52 attacker bytes) | 3.6 KB | view raw |
| poc_mpr_stackoverflow.c | trigger-source | pass-1 minimal live-device trigger (ENOENT on this guest) | 1.6 KB | view raw |
| harness_run.log | run-log | pass-1 harness output (52/52 attacker bytes) | 2.7 KB | view raw |
| harness_run_patched.log | run-log | pass-1 harness on patched kernel (pure userspace, unchanged) | 2.7 KB | view raw |
| Makefile | build-script | pass-2 replica module build | 59 B | β download |
| Makefile.fixed | build-script | pass-2 fixed replica build (COPTS+=-DFIXED) | 75 B | β download |
| trigger.c | trigger-source | pass-2 operator-user O_RDONLY ioctl trigger (96/4096) | 2.1 KB | view raw |
| replica_build.log | build-log | pass-2 replica cc/ld lines (rc=0, -fno-stack-protector) | 1.8 KB | view raw |
| panic2.txt | panic-capture | pass-2 second identical replica panic | 14.5 KB | view raw |
| fix_build.log | build-log | pass-1 full nativekernel build with fix (rc=0, -Werror) | 5.6 MB | β download |
| env.txt | environment | pass-1/2 guest env (no LSI SAS, kernel sha256) | 2.5 KB | view raw |
| pass2_env.txt | environment | pass-2 env | 732 B | view raw |
| build.sh | build-script | pass-1 build script | 198 B | view raw |
| run.sh | run-script | pass-1/2 run script | 168 B | view raw |
| README.md | readme | original README | 746 B | β raw |
DF-1326 PoC: mpr_user_pass_thru stack buffer overflow
Build
cc -O2 -Wall -o poc_mpr_stackoverflow poc_mpr_stackoverflow.c
Run
./poc_mpr_stackoverflow /dev/mpr0
Expected output
- Kernel panic with stack-protector violation (if compiled with -fstack-protector)
- OR kernel RCE if stack canary is absent (return address overwritten with 0x41414141...)
- OR page fault if the overwritten return address is unmapped
Notes
- Requires /dev/mprN access (mode 0640 root:operator)
- The copyin at mpr_user.c:800 writes RequestSize bytes into 12-byte stack MPI2_REQUEST_HEADER
- The bounds check
if (data->RequestSize > sc->reqframesz)at line 804 runs AFTER the overflow - Any RequestSize > 12 overflows the stack frame
DF-1326 VERDICT -- reproduced (impact: uid0_privesc; confidence: certain)
Bottom line
FULL unprivileged-to-root escalation through the REAL production mpr ioctl code of the shipped kernel. Pass 3 replaced the pass-2 verbatim-replica with a dispatch rig that resolves the actual compiled-in static handler mpr_ioctl_devsw (0xffffffff80598ab0 in kernel #0, prologue byte-verified per dispatch) from the running kernel and forwards ioctls to it against a fabricated softc, over a /dev node with the exact make_dev() ownership of /dev/mprN (0640 root:operator, mpr_user.c:205). The unprivileged trigger (uid=2/gid=5 operator, O_RDONLY open) issued MPTIOCTL_PASS_THRU whose RequestSize drove the unchecked copyin at mpr_user.c:800 into the 12-byte stack tmphdr; disassembly-verified offsets (tmphdr at rbp-0x3c, epilogue pops through the smashed bytes, saved RIP at payload+0x44) give exact control of the return address. 6/6 runs (incl. one from a fresh vm.sh reset with-src snapshot, kernel #0 md5 4625046e) landed a kernel shellcode stub from a user RWX page (no SMEP; CPL0-exec-of-user-page proven live via ud2 trap at 0x10000000) that walked gs:0 -> gd_curthread -> td_proc -> p_ucred and zeroed the uid/gid fields, then swapgs+iretq to a userspace continuation: getuid()=0, root shell (uid=0(root) gid=0(wheel)), guest healthy throughout. Sizes both the post-hoc check rejects (132>128) and accepts (96<=128, 'evaded' mode) escalate identically; controlled non-canonical RIP (trap 9 AT mpr_user_pass_thru+0x6fe: ret) and the 4096 spray (trap 9 with frame pointer 0x4546474841424344) provide the deterministic corruption proofs. Root staged ONLY the driver-instance stand-in because QEMU cannot emulate the LSI SAS2008 PCI HBA mpr_attach() needs -- on hardware with an mpr(4) controller the entire chain is attacker-only. fix.diff validated end-to-end on the real rebuilt kernel (single-fix nativekernel #1, md5 76ac5c67): all four modes now return cleanly (132/4096 -> EINVAL before any copyin; 96 -> ENOMEM after a 12-byte copyin), zero traps, guest healthy, patched binary shows the range check preceding the copyin site.
Reproduce
build: cd /root/rig && make SYSDIR=/usr/src/sys MACHINE=x86_64 MACHINE_ARCH=x86_64 && cc -O -o trigger df1326rig_trigger.c && kldload ./df1326rig.ko && sysctl -w kern.df1326rig_target=0x$(nm /boot/kernel/kernel | awk '/ t mpr_ioctl_devsw$/{print $1}')
run: su operator -c '/root/rig/trigger probe' (also: evaded | rip | smash | dump)
Evidence in this pack
run.log (canonical fresh-snapshot probe: DF1326_CONTINUATION getuid=0, uid=0(root) gid=0(wheel), guest up 34s; then the full baseline-vs-patched mode matrix), rig_stress_evaded.log (5x probe + evaded all uid=0), rig_rip_control_trap.txt + rig_smash_panic.txt (Fatal trap 9 AT mpr_user_pass_thru+0x6fe: ret; frame pointer 0x4546474841424344), rig_stage0_userpage_exec.txt (CPL0 executed ud2 at userspace 0x10000000 = no SMEP), df1326rig.c + df1326rig_trigger.c (rig + trigger + shellcode emitter), build.log (-fno-stack-protector build; fix apply; nativekernel+install completions), rig_fixed_runs.log + rig_fixed_disasm_order.txt (patched kernel: EINVAL/ENOMEM clean returns, cmp-before-copyin ordering), VERDICT.md pass-3 addendum
Kernel references
sys/dev/raid/mpr/mpr_user.c:742sys/dev/raid/mpr/mpr_user.c:800sys/dev/raid/mpr/mpr_user.c:804sys/dev/raid/mpr/mpr_user.c:828sys/dev/raid/mpr/mpr_user.c:886sys/dev/raid/mpr/mpr_user.c:205sys/dev/raid/mpr/mpr_user.c:1145sys/dev/raid/mpr/mpr_ioctl.h:193sys/dev/raid/mpr/mpi/mpi2.h:855
Exploit chain
operator-group member (uid=2) -> open /dev/mprN O_RDONLY (0640 root:operator; group bit r--, ioctls not gated on open mode) -> ioctl(MPTIOCTL_PASS_THRU=0xc0384904, DataSize=0/DataDirection=NONE passes the :776-788 gate) -> copyin(PtrRequest, &12-byte tmphdr, attacker u32 RequestSize) at :800 BEFORE the reqframesz check at :804 -> RequestSize>=76 overwrites saved rbx/r12-r15/rbp/RIP (RIP at payload+0x44; epilogue pops purely from attacker bytes; cm slot at rbp-0x48 survives NULL so the error path returns cleanly to the smash) -> saved RIP = user RWX page (no SMEP; verified by CPL0 ud2 trap at 0x10000000) -> kernel shellcode: mov rax,[gs:0]; curthread=[rax+8]; proc=[rdi+168]; ucred=[rdi+16]; zero cr_uid/cr_ruid/cr_svuid/cr_groups[0]/cr_rgid/cr_svgid -> swapgs; push ss/rsp%16==8/rflags=0x202/cs/rip; iretq -> userspace continuation with getuid()=0 -> exec /bin/sh: uid=0(root) gid=0(wheel). Rig proved every link with the real compiled handler; only the /dev node + softc stand in for the absent SAS2008 HBA.
Fix validation (A/B)
Bounds-check RequestSize (>= sizeof(tmphdr), <= reqframesz) BEFORE the copyin and copy only sizeof(tmphdr) for the routing peek; re-read the full request from userspace into the sized HW buffer at the two bcopy sites (fix.diff, validated on a rebuilt kernel).
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
Pass-3 rig replaces the pass-2 verbatim replica: df1326rig.c KLD dispatches the REAL static mpr_ioctl_devsw resolved via nm from the running kernel (KASLR-free), fabricates only the softc fields the compiled code touches (offsets from disassembly) and the 0640 root:operator node. df1326rig_trigger.c builds the payload with the disassembly-proven RIP slot 0x44 and emits the kernel shellcode at runtime (offsets read from rig sysctls computed against the guest's real headers; cs/ss/rsp read live). Two bugs found and fixed during bring-up: emit_mov_rax_imm64 originally emitted the push-rax byte INSIDE the imm64 (stream shift -> triple fault), and the continuation initially landed rsp%16==0 instead of the ABI's 8 (SIGBUS in printf SSE). fresh_run.sh is the canonical fresh-snapshot sequence.
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: 9 Runtime: ~7800s
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 (shipped kernel #0, real handler): probe/evaded -> uid=0 root shell (never returns); rip/smash -> Fatal trap 9 at mpr_user_pass_thru+0x6fe: ret with attacker RIP/frame-pointer. Patched (fix.diff, single-fix nativekernel #1, real handler at 0xffffffff80598ae0, prologue verified): probe/rip/smash -> EINVAL before any copyin (rc=-1 errno=22, 3x repeat), evaded -> ENOMEM via the normal command-allocation path after a 12-byte copyin (rc=-1 errno=12); all ioctls RETURN cleanly, zero kernel traps, guest healthy. objdump of the installed patched binary shows cmp $0xb / cmp reqframesz reaching EINVAL before the copyin site.
rig_fixed_runs.log (all four modes, patched kernel, guest health + dmesg trap count 0), rig_fixed_disasm_order.txt (cmp-before-copyin in the installed binary), rig_env_patched.txt (kernel #1 boot + kldstat + no /dev/mpr*), build.log (fix apply 3 hunks, nativekernel completed 22:48:42, installkernel INSTALL_RC=0), run.log (baseline column)
Confirmed kernel references
Detail
Exploit chain
operator-group member (uid=2) -> open /dev/mprN O_RDONLY (0640 root:operator; group bit r--, ioctls not gated on open mode) -> ioctl(MPTIOCTL_PASS_THRU=0xc0384904, DataSize=0/DataDirection=NONE passes the :776-788 gate) -> copyin(PtrRequest, &12-byte tmphdr, attacker u32 RequestSize) at :800 BEFORE the reqframesz check at :804 -> RequestSize>=76 overwrites saved rbx/r12-r15/rbp/RIP (RIP at payload+0x44; epilogue pops purely from attacker bytes; cm slot at rbp-0x48 survives NULL so the error path returns cleanly to the smash) -> saved RIP = user RWX page (no SMEP; verified by CPL0 ud2 trap at 0x10000000) -> kernel shellcode: mov rax,[gs:0]; curthread=[rax+8]; proc=[rdi+168]; ucred=[rdi+16]; zero cr_uid/cr_ruid/cr_svuid/cr_groups[0]/cr_rgid/cr_svgid -> swapgs; push ss/rsp%16==8/rflags=0x202/cs/rip; iretq -> userspace continuation with getuid()=0 -> exec /bin/sh: uid=0(root) gid=0(wheel). Rig proved every link with the real compiled handler; only the /dev node + softc stand in for the absent SAS2008 HBA.
Evidence (decisive lines)
run.log (canonical fresh-snapshot probe: DF1326_CONTINUATION getuid=0, uid=0(root) gid=0(wheel), guest up 34s; then the full baseline-vs-patched mode matrix), rig_stress_evaded.log (5x probe + evaded all uid=0), rig_rip_control_trap.txt + rig_smash_panic.txt (Fatal trap 9 AT mpr_user_pass_thru+0x6fe: ret; frame pointer 0x4546474841424344), rig_stage0_userpage_exec.txt (CPL0 executed ud2 at userspace 0x10000000 = no SMEP), df1326rig.c + df1326rig_trigger.c (rig + trigger + shellcode emitter), build.log (-fno-stack-protector build; fix apply; nativekernel+install completions), rig_fixed_runs.log + rig_fixed_disasm_order.txt (patched kernel: EINVAL/ENOMEM clean returns, cmp-before-copyin ordering), VERDICT.md pass-3 addendum
PoC changes
Pass-3 rig replaces the pass-2 verbatim replica: df1326rig.c KLD dispatches the REAL static mpr_ioctl_devsw resolved via nm from the running kernel (KASLR-free), fabricates only the softc fields the compiled code touches (offsets from disassembly) and the 0640 root:operator node. df1326rig_trigger.c builds the payload with the disassembly-proven RIP slot 0x44 and emits the kernel shellcode at runtime (offsets read from rig sysctls computed against the guest's real headers; cs/ss/rsp read live). Two bugs found and fixed during bring-up: emit_mov_rax_imm64 originally emitted the push-rax byte INSIDE the imm64 (stream shift -> triple fault), and the continuation initially landed rsp%16==0 instead of the ABI's 8 (SIGBUS in printf SSE). fresh_run.sh is the canonical fresh-snapshot sequence.
Verified recommended fix
Bounds-check RequestSize (>= sizeof(tmphdr), <= reqframesz) BEFORE the copyin and copy only sizeof(tmphdr) for the routing peek; re-read the full request from userspace into the sized HW buffer at the two bcopy sites (fix.diff, validated on a rebuilt kernel).
Verdict
FULL unprivileged-to-root escalation through the REAL production mpr ioctl code of the shipped kernel. Pass 3 replaced the pass-2 verbatim-replica with a dispatch rig that resolves the actual compiled-in static handler mpr_ioctl_devsw (0xffffffff80598ab0 in kernel #0, prologue byte-verified per dispatch) from the running kernel and forwards ioctls to it against a fabricated softc, over a /dev node with the exact make_dev() ownership of /dev/mprN (0640 root:operator, mpr_user.c:205). The unprivileged trigger (uid=2/gid=5 operator, O_RDONLY open) issued MPTIOCTL_PASS_THRU whose RequestSize drove the unchecked copyin at mpr_user.c:800 into the 12-byte stack tmphdr; disassembly-verified offsets (tmphdr at rbp-0x3c, epilogue pops through the smashed bytes, saved RIP at payload+0x44) give exact control of the return address. 6/6 runs (incl. one from a fresh vm.sh reset with-src snapshot, kernel #0 md5 4625046e) landed a kernel shellcode stub from a user RWX page (no SMEP; CPL0-exec-of-user-page proven live via ud2 trap at 0x10000000) that walked gs:0 -> gd_curthread -> td_proc -> p_ucred and zeroed the uid/gid fields, then swapgs+iretq to a userspace continuation: getuid()=0, root shell (uid=0(root) gid=0(wheel)), guest healthy throughout. Sizes both the post-hoc check rejects (132>128) and accepts (96<=128, 'evaded' mode) escalate identically; controlled non-canonical RIP (trap 9 AT mpr_user_pass_thru+0x6fe: ret) and the 4096 spray (trap 9 with frame pointer 0x4546474841424344) provide the deterministic corruption proofs. Root staged ONLY the driver-instance stand-in because QEMU cannot emulate the LSI SAS2008 PCI HBA mpr_attach() needs -- on hardware with an mpr(4) controller the entire chain is attacker-only. fix.diff validated end-to-end on the real rebuilt kernel (single-fix nativekernel #1, md5 76ac5c67): all four modes now return cleanly (132/4096 -> EINVAL before any copyin; 96 -> ENOMEM after a 12-byte copyin), zero traps, guest healthy, patched binary shows the range check preceding the copyin site.
No comments yet.