/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)
PoC verification
Evidence pack
findings/poc/DF-5673 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 β 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:
- fork inherits it β
cpu_fork()copies the parent trap frame verbatim (sys/platform/pc64/x86_64/vm_machdep.c:158-159),PSL_IOPLincluded. - the last fd close clears an arbitrary, usually wrong LWP β
mmclose(sys/kern/kern_memio.c:210-212) callscpu_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). - privilege drop does not revoke it β nothing between
cpu_set_iopl()and user mode re-checks the grant: noPSL_IOPLclearing exists in the setuid/cred-change path; onlyexec_setregs(machdep.c:1481-1490) and theEFL_SECUREsanitizers at machdep.c:1023 (sigreturn) and machdep.c:3025 (ptrace/procfsset_regs) touch rflags, and none of those runs onsetuid().
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) returnsEOPNOTSUPPfor 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/ioopen as uid 1001 βEPERM(priv gate intact, kern_memio.c:184-195).- sigreturn forge:
setcontext()withuc_mcontext.mc_rflags |= 0x3000is rejected β the guest console showssigreturn: rflags = 0x3206, printed only inside theEFL_SECURErejection branch (machdep.c:1023-1026);PSL_USERCHANGE(sys/cpu/x86_64/include/psl.h:81-82) excludesPSL_IOPL. The userland SIGSEGV afterwards is DF's setcontext trampoline dying on the EINVAL return (no error path), not a kernel fault. SameEFL_SECUREcheck covers ptrace/procfsset_regs(machdep.c:3025). No unprivileged forge of IOPL exists. execclears 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/iocrw------- 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/outat 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 issizeof(tss)-1(machdep.c:1698) withtss_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
fixedfix.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)
Confirmed kernel references
- sys/platform/pc64/x86_64/sysarch.c:46-49
- sys/platform/pc64/x86_64/sysarch.c:52-57
- sys/platform/pc64/x86_64/sysarch.c:59-65
- sys/kern/kern_memio.c:184-195
- sys/kern/kern_memio.c:210-212
- sys/platform/pc64/x86_64/vm_machdep.c:158-159
- sys/platform/pc64/x86_64/machdep.c:1023-1026
- sys/platform/pc64/x86_64/machdep.c:1481-1490
- sys/platform/pc64/x86_64/machdep.c:3025
- sys/cpu/x86_64/include/psl.h:81-82
- share/man/man4/io.4
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).
No comments yet.