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

/dev/io IOPL grant is unaccounted per-LWP trap-frame state: inherited verbatim across fork, never revoked on last fd close (cpu_clr_iopl clears whichever LWP closes, not the grantees), and survives setuid() privilege drop

Summary

cpu_set_iopl sets IOPL=3 in only the opening LWP's rflags with no accounting: cpu_fork bcopies the frame so children inherit full ring-3 port I/O without opening anything; the last fd close clears an arbitrary unrelated LWP leaving IOPL=3 live in ring 3 after zero fds remain (violating the io(4) contract); and no setuid/cred path ever re-checks PSL_IOPL - a root process that opens /dev/io and drops privileges keeps raw port I/O permanently. This is the state machine that gates DF-5646-class port hazards behind privilege - the gate never closes. Reproduced 4/4 on stock: child IOPL=3 with zero fds system-wide AND after setuid(65534); closer-side semantics proven (parent's last close cleared PARENT). strip-in-cpu_fork fix validated on a rebuilt kernel (child IOPL=0, two boots). sysarch itself is a pure EOPNOTSUPP stub (surface live-probed, 9 cmds). Root-gated persistence demonstrated to the class maximum: uid=65534 process with permanent raw port I/O to all 65536 ports.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5673 Β· 16 files
FileTypeDescriptionSize
sysarch_surface.c β€” 2.7 KB view raw
iopl_lifecycle.c β€” 3.6 KB view raw
build.sh β€” 163 B view raw
run.sh β€” 140 B view raw
build.log β€” 207 B view raw
run.surface.log β€” 1.1 KB view raw
run.lifecycle.log β€” 1022 B view raw
run.2.log β€” 500 B view raw
fix.diff β€” 827 B view raw
fix_build.log β€” 347.1 KB ↓ download
fix_run.log β€” 498 B view raw
fix_run.surface.log β€” 1.0 KB view raw
env.txt β€” 455 B view raw
rig-flip.txt β€” 2.0 KB view raw
VERDICT.md β€” 6.2 KB ↓ raw
verdict.json β€” 6.2 KB view raw
VERDICT.md
↓ download raw

VERDICT β€” DF-5673

Finding: the pc64 IOPL grant issued via cpu_set_iopl() (sys/platform/pc64/x86_64/sysarch.c:52-57, reachable only through /dev/io open at sys/kern/kern_memio.c:193, gated SYSCAP_RESTRICTEDROOT + securelevel <= 0) is unaccounted per-LWP state:

  1. fork inherits it β€” cpu_fork() copies the parent trap frame verbatim (sys/platform/pc64/x86_64/vm_machdep.c:158-159), PSL_IOPL included.
  2. the last fd close clears an arbitrary, usually wrong LWP β€” mmclose (sys/kern/kern_memio.c:210-212) calls cpu_clr_iopl() on whichever LWP happens to close the last reference; LWPs that inherited the grant (forked children, other threads) keep IOPL=3 with no fd open anywhere β€” violating the documented io(4) contract ("Any process that holds a file descriptor on /dev/io open will get its IOPL bits in the flag register set", share/man/man4/io.4).
  3. privilege drop does not revoke it β€” nothing between cpu_set_iopl() and user mode re-checks the grant: no PSL_IOPL clearing exists in the setuid/cred-change path; only exec_setregs (machdep.c:1481-1490) and the EFL_SECURE sanitizers at machdep.c:1023 (sigreturn) and machdep.c:3025 (ptrace/procfs set_regs) touch rflags, and none of those runs on setuid().

Threat: a privileged process that opens /dev/io (or issues KDENABIO, syscons.c:1383-1395 β€” same inline idiom) and later drops privileges, forks worker children, or passes the fd away leaves permanent, unrevokable full ring-3 port I/O in LWP frames that no longer correspond to any open fd. This is the state-machine that gate DF-5646-class port hazards behind privilege; the gate does not close.

Result: REPRODUCED (baseline), FIXED (patched kernel)

Baseline (stock ISO kernel #0 Thu Jul 2 06:02:54 UTC 2026)

run.lifecycle.log + run.2.log (3 consecutive runs, identical):

[step1] open(/dev/io) ok, IOPL now = 3            <- cpu_set_iopl hit (sysarch.c:54)
[step3] parent closed fd (last close), parent IOPL=0  <- cpu_clr_iopl hit closer (sysarch.c:62)
[step4] child: all /dev/io fds closed, IOPL=3     <- fork inheritance + no revocation
[step5] child: uid=65534 IOPL=3                    <- survives setuid() privilege drop

Child closed its inherited fd before the parent's last close, so at the moment of mmclose no child fd existed; the child still shows IOPL=3 by pushfq β€” the grant outlived every fd reference, exactly as predicted from the code path.

Negative results (verified live, run.surface.log)

  • sysarch (syscall 165) returns EOPNOTSUPP for every cmd (0,1,2,3,101, 102,103,127,0x1234) as uid 1001 β€” pc64 has no IOPERM/LDT dispatch, no copyin, nothing to abuse (sys/platform/pc64/x86_64/sysarch.c:46-49).
  • /dev/io open as uid 1001 β†’ EPERM (priv gate intact, kern_memio.c:184-195).
  • sigreturn forge: setcontext() with uc_mcontext.mc_rflags |= 0x3000 is rejected β€” the guest console shows sigreturn: rflags = 0x3206, printed only inside the EFL_SECURE rejection branch (machdep.c:1023-1026); PSL_USERCHANGE (sys/cpu/x86_64/include/psl.h:81-82) excludes PSL_IOPL. The userland SIGSEGV afterwards is DF's setcontext trampoline dying on the EINVAL return (no error path), not a kernel fault. Same EFL_SECURE check covers ptrace/procfs set_regs (machdep.c:3025). No unprivileged forge of IOPL exists.
  • exec clears IOPL (exec_setregs, machdep.c:1490) β€” only the no-exec paths keep it.

Fix validation (fix.diff, fix_build.log, fix_run.log)

fix.diff strips PSL_IOPL from the child trap frame in cpu_fork() (vm_machdep.c, 1 hunk, git apply --check clean against sys/). Applied to the guest's /usr/src copy, make quickkernel KERNCONF=X86_64_GENERIC (6976-line log), make installkernel, reboot into custom #1 Fri Oct 9 19:21:17 UTC 2026:

[step4] child: all /dev/io fds closed, IOPL=0     <- was 3 on baseline
[step5] child: uid=65534 IOPL=0

Observed on two separate boots of the patched kernel (boot 2 and boot 3) β€” the fork-inheritance vector is closed. Residual (documented, out of scope of the 1-line fix): the opener itself still keeps IOPL=3 across its own setuid() until it execs or exits (FreeBSD-documented io(4) semantics for the opener), and KDENABIO (syscons.c:1394) has the same shape. A complete fix would track the grant per-LWP and revoke on cred change; that is upstream-design territory.

Guest / rig notes

  • Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 X86_64_GENERIC x86_64, kern.securelevel: -1, /dev/io crw------- root wheel. Guest was reset to the clean-source snapshot after validation (uname back to #0).
  • Rig anomaly (important): in this QEMU/KVM host, CPL3 in/out at IOPL=0 sometimes succeeds β€” it flipped 24/24 faults β†’ 80/80 OK β†’ mixed within one shell (rig-flip.txt), uncorrelated with uid or handlers. The guest kernel never reloads TR after boot (the private-TSS path in swtch.s:482-512 is #if 0 /* JG */), the TSS descriptor limit is sizeof(tss)-1 (machdep.c:1698) with tss_iobase = sizeof(tss) (machdep.c:2881) = architectural "no IO bitmap, deny all" β€” on real hardware these accesses always #GPβ†’SIGBUS (trap.c:512-515). The flips are therefore a virtual-CPU/rig artifact. Consequence: every "port access OK/SIGSEGV" line in the logs is corroboration only; the decisive observable is the RFLAGS IOPL value read via pushfq (pure kernel-grant state), which was deterministic across all runs (3Γ— baseline IOPL=3 chain, 2Γ— patched IOPL=0).

Why not higher severity

Entry requires an already-privileged open (RESTRICTEDROOT + securelevel). The escape is real but post-privilege: persistence/policy violation against the documented fd-scoped contract, not an unprivβ†’priv boundary crossing. Low per the project rubric ("requires … already-privileged user"); CVSS vector recorded for triage comparability.

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 (1 hunk: lp2->lwp_md.md_regs->tf_rflags &= ~PSL_IOPL after the frame bcopy in cpu_fork) applied to the guest /usr/src, quickkernel built (fix_build.log, 6976 lines), installed, rebooted into custom kernel #1 Fri Oct 9 19:21:17 UTC 2026. Re-running the exact baseline PoC: child IOPL=0 at step4 and step5 (was IOPL=3 on baseline, 3/3 runs) on two separate boots of the patched kernel β€” fork-inheritance vector closed; opener-side setuid persistence remains and is documented as residual (matches FreeBSD io(4) opener semantics; full revocation needs per-LWP grant tracking, upstream-design scope).

findings/poc/DF-5673/fix.diff; fix_build.log (buildkernel log); fix_run.log (patched-kernel PoC run: '[step4] child: ... IOPL=0', '[step5] child: uid=65534 IOPL=0'); fix_run.surface.log (post-fix surface regression: sysarch still EOPNOTSUPP, /dev/io still EPERM, sigreturn forge still rejected)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Fri Oct 9 19:21:17 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root opens /dev/io (mmopen -> cpu_set_iopl, IOPL=3 in opener LWP frame) -> fork (vm_machdep.c:159 frame bcopy grants child IOPL=3; child holds fd) -> child closes its fd; parent's close is the last reference -> mmclose -> cpu_clr_iopl() clears the PARENT (closer) frame -> child retains IOPL=3 with no /dev/io fd open in the system -> child setuid(65534): grant survives the privilege drop -> unprivileged-uid process retains full raw port I/O (in/out on all 65536 ports) until it execs or exits. Patched: cpu_fork strips PSL_IOPL from the child frame -> chain broken at step 2 (observed IOPL=0).

Evidence (decisive lines)

findings/poc/DF-5673/run.lifecycle.log + run.2.log (baseline chain: step4 'child: all /dev/io fds closed, IOPL=3', step5 'uid=65534 IOPL=3'); run.surface.log (sysarch EOPNOTSUPP x9, /dev/io EPERM as uid 1001); fix_run.log (patched kernel: step4/5 IOPL=0); fix_build.log (6976-line quickkernel build of custom #1); fix.diff (1-hunk cpu_fork PSL_IOPL strip); rig-flip.txt (rig enforcement caveat); VERDICT.md for the full narrative

PoC changes

No orchestrator seed pack existed (empty reservation); both programs written fresh. Debug iterations required: (1) unbuffered stdout (ssh pipe buffering swallowed output on crash), (2) catch SIGBUS not just SIGSEGV β€” DragonFly delivers usermode #GP (T_PROTFLT) as SIGBUS per trap.c:512-515, (3) atoi() does not parse hex port arguments, (4) child output lost to _exit() buffering β€” setvbuf at main. sysarch cmd constants are irrelevant on pc64 (hard stub), so FreeBSD i386 IOPERM values were probed as representative garbage.

Verified recommended fix

Strip PSL_IOPL from the child trap frame in cpu_fork() (vm_machdep.c) so the /dev/io IOPL grant cannot be inherited across fork; a complete fix additionally revokes the grant on credential/privilege drop.

Verdict

REPRODUCED on the stock guest kernel (3/3 runs) and FIXED by fix.diff on a rebuilt kernel (2 boots): the /dev/io IOPL grant (cpu_set_iopl, sys/platform/pc64/x86_64/sysarch.c:52-57) is unaccounted per-LWP trap-frame state. A child forked while a parent holds /dev/io inherits PSL_IOPL via the verbatim frame bcopy in vm_machdep.c:158-159; when the LAST fd closes, mmclose (kern_memio.c:210-212) clears only the closing LWP, so the child keeps IOPL=3 with zero fds open anywhere (io(4) contract violated), and a setuid() privilege drop never revokes it (nothing re-checks PSL_IOPL outside exec_setregs/EFL_SECURE). Demonstrated: root opens /dev/io -> forks -> child closes its fd, parent last-closes -> child pushfq IOPL=3, then child setuid(65534) -> IOPL still 3. Unprivileged reachability was hunted and is closed: syscall 165 is a hard EOPNOTSUPP stub on pc64 (no IOPERM/LDT dispatch, verified live for 9 cmd values), /dev/io open as uid 1001 gives EPERM, and sigreturn/ptrace rflags forging is rejected by EFL_SECURE (kernel console shows the machdep.c:1024 rejection line). Severity Low: entry requires an already-privileged open; the escaped grant is permanent ring-3 port I/O that outlives the fd and the privilege. Primary observable is the RFLAGS IOPL bits (pushfq); port-access lines in the logs are corroboration only because this QEMU rig intermittently fails to enforce CPL3 IOPL=0 port faults (documented in rig-flip.txt).