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

set_dbregs() stores DR7 with architecturally-reserved bits 63:32 unvalidated; the value is loaded by cpu_heavy_restore during a context switch, raising a ring-0 #GP (fatal trap, panic) from an unprivileged ptrace() of the attacker's own child

Summary

set_dbregs validates only the R/W fields and (non-root) the dr0-3 addresses, then stores the raw 64-bit dr7 into pcb_dr7; cpu_heavy_restore merges only bits 10-15 before the MOV, so reserved bits 63:32 flow straight into it - under KVM (kvm_set_dr rejects them unconditionally) and on Intel bare metal this is a ring-0 #GP inside the context switch, outside the doreti_iret redirect, falling to trap_fatal. Reproduced as uid 1001 on the stock kernel: PT_SETDBREGS(dr7=0xdeadbeef00000000) accepted then PT_CONTINUE panics at cpu_heavy_restore+0x1ab (movl %eax,%dr7), guest down; baseline dr7=0 clean. Deterministic unpriv local DoS on all virtualized deployments + Intel bare metal (AMD ignores the writes). reject-high-bits fix validated on a rebuilt kernel (EINVAL, legit values unregressed). Ceiling is DoS - clean fault, no write primitive, ddb console-only.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5657 Β· 15 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 4.7 KB ↓ raw
poc.c β€” 2.2 KB view raw
build.sh β€” 167 B view raw
run.sh β€” 258 B view raw
build.log β€” 94 B view raw
run.log β€” 368 B view raw
run.2.log β€” 1.0 KB view raw
panic.txt β€” 579 B view raw
env.txt β€” 341 B view raw
fix.diff β€” 862 B view raw
fix_build.log β€” 664 B view raw
fix_run.log β€” 798 B view raw
manifest.json β€” 1.6 KB view raw
verdict.json β€” 5.2 KB view raw

DF-5657 β€” pc64 set_dbregs() stores DR7 with reserved bits 63:32 unvalidated β†’ ring-0 #GP inside cpu_heavy_restore on context switch β†’ fatal kernel trap (panic) from an unprivileged ptrace() of the attacker's own child

File under audit (owner file): sys/platform/pc64/x86_64/procfs_machdep.c (procfs_write_dbregs β†’ set_dbregs plumbing) Root cause: sys/platform/pc64/x86_64/machdep.c set_dbregs() + sys/platform/pc64/x86_64/swtch.s cpu_heavy_restore()

TL;DR

An unprivileged user (uid 1001) calls ptrace(PT_SETDBREGS) on its own stopped child with dbregs.dr[7] = 0xdeadbeef00000000. set_dbregs() validates only the R/W fields (bits 16–29) and the dr0–dr3 addresses vs VM_MAX_USER_ADDRESS; it stores the raw 64-bit dr7 into pcb->pcb_dr7 and sets PCB_DBREGS (machdep.c:3215-3222). On the next context switch into the tracee, cpu_heavy_restore() reloads %dr7 masking ONLY bits 10–15 (andq $~0x0000fc00), so the reserved bits 63:32 flow straight into movq %rax,%dr7 (swtch.s:597-617). On Intel CPUs β€” and unconditionally under KVM, where the host kvm_set_dr() injects #GP for any set bit in DR7[63:32] β€” this raises a ring-0 #GP in the middle of a context switch. The fault is not at doreti_iret, so trap.c's kernel-mode T_PROTFLT redirect does not apply; the trap falls through to trap_fatal() (trap.c:770) β†’ fatal trap, all CPUs stopped, system down.

Reproduced on the stock guest kernel; fix (reject dr7 with bits 63:32 set in set_dbregs) validated on a rebuilt kernel: same PoC now gets EINVAL, guest stays up, legitimate dr7 values (0, 0x401) still accepted.

Reproduce

Guest: dfbsd-qemu/vm.sh up (QEMU/KVM), then as unprivileged user (maxx):

./build.sh          # cc -O -o /tmp/poc poc.c
./run.sh            # baseline (dr7=0) then attack (dr7=0xdeadbeef00000000)

Expected (stock kernel): - baseline: PT_SETDBREGS(dr7=0) ACCEPTED, child resumes, guest stays up; - attack: PT_SETDBREGS(dr7=0xdeadbeef00000000) ACCEPTED, then on PT_CONTINUE the kernel panics β€” serial console shows: Fatal trap 9: general protection fault while in kernel mode … Stopped at cpu_heavy_restore+0x1ab: movl %eax,%dr7 (see panic.txt); ssh dies; vm.sh status β†’ down.

On the fix-patched kernel: attack run prints PT_SETDBREGS: Invalid argument, guest stays up (see fix_run.log).

Impact

Unprivileged local kernel panic / DoS (deterministic under KVM and on Intel bare metal; AMD bare metal ignores writes to DR7[63:32] and is not affected by this particular variant). No memory-corruption primitive: the fault is a clean

GP(0) taken to ddb β€” the ddb prompt is console-only and not reachable from an

ssh session, so the ceiling is denial of service.

VERDICT.md
↓ download raw

DF-5657 VERDICT β€” reproduced (kernel panic from unprivileged local user), fix validated

What was tested

sys/platform/pc64/x86_64/procfs_machdep.c is the owner file; its procfs_write_dbregs() (procfs_machdep.c:97-101) and the ptrace compat plumbing (kern_ptrace PT_SETDBREGS β†’ procfs_dodbregs, sys/kern/sys_process.c:531-560) both funnel into set_dbregs() in sys/platform/pc64/x86_64/machdep.c:3133.

Root cause (path:line)

  1. set_dbregs() (machdep.c:3166-3169) validates only the four R/W fields of dr7 (rejects the 0b10 I/O-breakpoint pattern) and machdep.c:3189-3213 bounds dr0–dr3 to user space when enabled (gated on caps_priv_check(SYSCAP_RESTRICTEDROOT)). Nothing checks the architecturally-reserved bits. pcb->pcb_dr7 = dbregs->dr[7]; (machdep.c:3220) stores the raw 64-bit user value and pcb->pcb_flags |= PCB_DBREGS (machdep.c:3222) arms the reload path.
  2. On the next context switch into the tracee, cpu_heavy_restore (sys/platform/pc64/x86_64/swtch.s:597-617) reloads the debug registers: %dr7 ← (current_dr7 & 0x0000fc00) | (pcb_dr7 & ~0x0000fc00). Only bits 10–15 (incl. the GD bit) are masked; bits 63:32 pass through raw into movq %rax,%dr7.
  3. DR7 bits 63:32 are reserved: Intel raises #GP(0) on MOV DR7 with them set, and under KVM the host kvm_set_dr() (Linux arch/x86/kvm/x86.c) unconditionally returns 1 (β†’ injected #GP) if (val & 0xffffffff00000000ULL) for DR7 β€” independent of host vendor.
  4. The #GP lands in ring 0 inside the context-switch path. The kernel-mode T_PROTFLT handler only redirects faults whose RIP is doreti_iret (trap.c:675-681, matching exception.S IDTVEC(prot)'s special case at exception.S:351-371). A fault at swtch.s is not at doreti_iret, has no pcb_onfault, so it falls through to trap_fatal(frame, 0) (trap.c:770).

Observation (decisive)

Stock guest kernel, unprivileged uid 1001:

[+] PT_SETDBREGS(dr7=0xdeadbeef00000000) ACCEPTED by kernel
[*] PT_CONTINUE -> tracee switches in -> movq %rax,%dr7
<ssh dies>
vm.sh status -> down
serial: Fatal trap 9: general protection fault while in kernel mode
         current process = 1136          <- the traced child
         Stopped at cpu_heavy_restore+0x1ab: movl %eax,%dr7
         db>  (all CPUs stopped)

Baseline control on the same kernel: dr7=0 accepted, tracee resumed, guest healthy (run.log). The panic is caused specifically by the unvalidated reserved bits of dr7 surviving into the context-switch DR7 load.

Exploitability ceiling

This is a clean #GP(0) fatal trap, not a memory-corruption primitive: no attacker-controlled write, no state survives the trap (system stops in ddb / reboots). The ddb prompt is on the serial console only β€” an ssh-local attacker cannot drive it. Under KVM and on Intel bare metal the DoS is deterministic; AMD bare metal ignores writes to DR7[63:32] (no fault) and is not affected by this variant. Ceiling: unprivileged local kernel panic (DoS). Impact class: panic/dos β€” no uid0 route exists for this bug.

Note the GD bit (dr7 bit 13) is accidentally defused by the swtch.s 0x0000fc00 mask (taken from the in-CPU dr7, always GD=0), so the classic "debug-register-access #DB in kernel" escalation of this family does NOT apply here β€” only the reserved-high-bits #GP does.

Fix validation

fix.diff adds to set_dbregs() (machdep.c, before the R/W loop):

if (dbregs->dr[7] & 0xFFFFFFFF00000000LU)
        return (EINVAL);
  • Rebuilt the guest kernel with only this change (make nativekernel KERNCONF=X86_64_GENERIC, BUILD_RC=0), installed, rebooted (#1: Fri Oct 9 11:27:30 UTC 2026).
  • Exact same PoC on patched kernel: PT_SETDBREGS: Invalid argument, guest stays up (fix_run.log).
  • Regression on patched kernel: dr7=0 and dr7=0x401 still accepted, tracee resumes, no panic (fix_run.log).
  • Guest reset to clean snapshot afterwards.

Distinction from known findings

  • DF-3258 / DF-0993: dbregs dr[8..15] read-side uninit leak β€” different direction, different sink; not re-reported.
  • DF-1315: guest DR persistence across #VMEXIT (vkernel host interaction) β€” different mechanism entirely.
  • DF-5335: bad-%ss iretq fault machine (sigreturn/set_regs) β€” different fault site (doreti_iret) and different unvalidated field (tf_ss); not re-reported.
  • The vkernel64 twin implements set_dbregs as ENOSYS (cpu_regs.c:788-792), so no twin divergence weakens this analysis.

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 (reject dr7 with bits 63:32 set in set_dbregs) applied to the guest /usr/src, kernel rebuilt with make nativekernel KERNCONF=X86_64_GENERIC (BUILD_RC=0), installed, rebooted. Baseline behavior on stock kernel reproduced first (fatal trap 9 at cpu_heavy_restore+0x1ab). On the patched kernel the exact PoC gets PT_SETDBREGS: Invalid argument and the guest stays up; regression runs with dr7=0x0000000000000000 and dr7=0x0000000000000401 are still accepted and resume cleanly. Bad behavior gone, no functional regression.

fix_run.log (attack -> EINVAL, regressions accepted, vm.sh status up); fix_build.log (patch applied hunk#1 @3153, BUILD_RC=0, uname #1 Fri Oct 9 11:27:30 UTC 2026); fix.diff (git-applyable, machdep.c set_dbregs).
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Oct 9 11:27:30 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

unprivileged user fork()s a child, PT_TRACE_ME + raise(SIGSTOP); parent waitpid(), ptrace(PT_SETDBREGS) with dr[7]=0xdeadbeef00000000 (passes the R/W-field loop because bits 16-29 are 0 and the address checks because no breakpoint is enabled); kernel stores raw dr7 into pcb_dr7 and sets PCB_DBREGS; ptrace(PT_CONTINUE) schedules the tracee; cpu_heavy_restore loads %dr7 with the reserved high bits intact; KVM/Intel raise #GP(0) in ring 0 inside the context switch; trap_fatal() -> fatal trap 9, all CPUs stopped, system down. (DoS only - no memory write primitive, no uid0 escalation path.)

Evidence (decisive lines)

panic.txt: 'Fatal trap 9 ... Stopped at cpu_heavy_restore+0x1ab: movl %eax,%dr7', current process 1136 (uid-1001 tracee). run.2.log: PT_SETDBREGS(dr7=0xdeadbeef00000000) ACCEPTED then guest down. run.log: baseline dr7=0 accepted, no crash, guest up. fix_run.log: patched kernel rejects attack with EINVAL, accepts dr7=0/0x401, guest up. fix_build.log: nativekernel BUILD_RC=0 with fix.diff.

PoC changes

No seed PoC existed for this pass-2 finding; written fresh. Guest-side trivia: PT_SETDBREGS is 38 on pc64 (sys/platform/pc64/include/ptrace.h: PT_FIRSTMACH+6) and struct dbreg is dr[16] u64s, so the PoC passes its own 128-byte struct directly.

Verified recommended fix

Reject DR7 values with reserved high bits in set_dbregs(): if (dbregs->dr[7] & 0xFFFFFFFF00000000LU) return (EINVAL); (fix.diff)

Verdict

set_dbregs() (sys/platform/pc64/x86_64/machdep.c:3133, reached from procfs_machdep.c:97-101 and PT_SETDBREGS via sys/kern/sys_process.c:531) validates only dr7's R/W fields and dr0-dr3 addresses; it stores the raw 64-bit dr[7] into pcb_dr7 (machdep.c:3220). cpu_heavy_restore() reloads %dr7 masking only bits 10-15 (swtch.s:612-617), so reserved bits 63:32 flow into 'movq %rax,%dr7'. On Intel (and deterministically under KVM via the host kvm_set_dr check) that raises a ring-0 #GP mid-context-switch; the fault is not at doreti_iret so trap.c:675-681 does not apply and it falls to trap_fatal() (trap.c:770). Verified as uid 1001 on the stock guest: ptrace(PT_SETDBREGS, dr7=0xdeadbeef00000000) is accepted, PT_CONTINUE panics the kernel at cpu_heavy_restore+0x1ab (movl %eax,%dr7) with current process = the traced child; baseline dr7=0 does not crash. Clean fatal trap, no write primitive; ceiling is unprivileged local kernel panic/DoS (no uid0 route; ddb prompt is console-only). Fix (reject dr7 with bits 63:32 set in set_dbregs) validated on a rebuilt kernel: same PoC now gets EINVAL, guest stays up, dr7=0/0x401 still accepted.