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

mapped_ioctl: IOC_VOID|IOC_OUT commands copy out size bytes from the 128-byte on-stack ubuf.stkbuf β€” up to ~8KB kernel-stack overread primitive, gated only on fo_ioctl returning 0

Summary

The ioctl scratch-buffer allocation skips the kmalloc whenever IOC_VOID is set, so a command with IOC_VOID|IOC_OUT and IOCPARM_LEN in (128,8191] uses the 128-byte kernel-stack ubuf.stkbuf with only 8 bytes initialized; on fo_ioctl success the copyout transfers up to 8191 bytes to userland β€” up to 8063 bytes of adjacent kernel stack (for sizes <=128 the same shape leaks the uninit remainder adjacent to DF-2734's class). Unpriv user fully controls com. Currently GATED: every reachable fo_ioctl switches on exact constants and returns ENOTTY/EINVAL β€” a 58,496-command sweep across 11 unprivileged fd types returned zero successes; one future permissive driver arms the primitive. Size-by-size + M_ZERO/bzero fix proposed.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5841 Β· 10 files
FileTypeDescriptionSize
README.md β€” 2.7 KB ↓ raw
VERDICT.md β€” 4.1 KB ↓ raw
ioctl_scan.c β€” 3.1 KB view raw
build.sh β€” 97 B view raw
run.sh β€” 110 B view raw
build.log β€” 1.3 KB view raw
run.log β€” 33 B view raw
env.txt β€” 351 B view raw
fix.diff β€” 772 B view raw
verdict.json β€” 3.6 KB view raw

DF-5841 β€” mapped_ioctl() stack-buffer overread primitive (IOC_VOID|IOC_OUT with len>128)

What

sys/kern/sys_generic.c:mapped_ioctl() sizes the ioctl scratch buffer by a condition that skips the allocation for any command with the IOC_VOID (0x20000000) direction bit:

:668    size = IOCPARM_LEN(com);
:669    if (size > IOCPARM_MAX) { error = ENOTTY; goto done; }
:674    if ((com & IOC_VOID) == 0 && size > sizeof(ubuf.stkbuf)) {
:675        memp = kmalloc(size, M_IOCTLOPS, M_WAITOK);
:676        data = memp;
:677    } else {
:678        memp = NULL;
:679        data = ubuf.stkbuf;          /* 128 bytes on the kernel stack */
:680    }
:681    if (com & IOC_VOID) {
:682        *(caddr_t *)data = uspc_data;  /* only 8 bytes initialized */

but the copy-back to userland is guarded only by direction/size:

:729    if (error == 0 && (com & IOC_OUT) != 0 && size != 0)
:730        error = copyout(data, uspc_data, (size_t)size);

A command with both IOC_VOID and IOC_OUT (or IOC_VOID|IOC_IN|IOC_OUT) set and IOCPARM_LEN in (128, 8191] therefore makes the kernel copyout up to 8191 bytes from a 128-byte kernel-stack buffer β€” an up-to-~8 KB adjacent kernel stack disclosure (return addresses, saved registers, adjacent frames). For sizes ≀ 128 the same command shape leaks the uninitialized remainder of ubuf.stkbuf (the bzero at :691-696 is only reached when IOC_VOID is clear). Because com is the raw syscall argument, any unprivileged user can issue the crafted command.

Reachability gate β€” and why this is filed as hardening

The copyout only executes when the underlying fo_ioctl() returns 0 for the crafted command. Every fo_ioctl reachable with default devices switches on exact ioctl constants and returns ENOTTY/EINVAL for anything else (vn_ioctl vfs_vnops.c:983, pipe_ioctl sys_pipe.c:993, soo_ioctl sys_socket.c:140+, kqueue_ioctl kern_event.c:1660, ttioctl tty.c:853 β€” the IOCGROUP('i'/'r') socket branches likewise dispatch to exact-constant switches in ifioctl/rtioctl).

The included scan (ioctl_scan.c) swept 11 unprivileged fd types Γ— 256 groups Γ— 2 numbers Γ— 2 direction combos (58,496 crafted ioctls) on the audit guest (kernel-only config: kernel + ehci.ko + xhci.ko): 0 successes.

So the code defect is certain, the disclosure primitive is real, but it is currently gated behind "some fo_ioctl returns 0 for an unknown/masked command". One future permissive driver turns it into an ~8 KB kernel stack disclosure. Filed as Info / hardening, verdict not_reproduced (reachability-gated on this configuration).

Reproduce

build.sh   # cc -O -o /tmp/ioctl_scan /tmp/ioctl_scan.c   (on the guest)
run.sh     # /tmp/ioctl_scan; echo EXIT=$?
           # expected (this config): "done, hits=0", exit 0
           # a HIT line + hexdump would be the leak
VERDICT.md
↓ download raw

DF-5841 VERDICT β€” not_reproduced (reachability-gated on this configuration)

Bottom line

The buffer-sizing/copyout inconsistency in mapped_ioctl() is certain at the code level: for com with IOC_VOID|IOC_OUT (or IOC_VOID|IOC_IN|IOC_OUT) and IOCPARM_LEN(com) in (128, 8191], the kernel copies size bytes out of the 128-byte on-stack ubuf.stkbuf (sys/kern/sys_generic.c:674-682 vs :729-730) β€” an up-to-8063-byte OOB kernel stack read to userland, with only the first 8 bytes (the user's own pointer) initialized. It did not reproduce on the audit guest because the copyout is gated on the underlying fo_ioctl() returning 0, and none does for a crafted unknown command on the default configuration (58,496-command sweep, 0 hits β€” see run.log).

Why the primitive is real (path:line)

  1. sys_ioctl (sys_generic.c:537-543) passes uap->com verbatim to mapped_ioctl with map == NULL β€” the user fully controls com.
  2. size = IOCPARM_LEN(com) (:668) is attacker-chosen in [0, 8191] (sys/sys/ioccom.h:38-46); the size > IOCPARM_MAX check (:669) can never fire since IOCPARM_LEN is already masked to 13 bits β€” it does NOT constrain direction combinations.
  3. The allocation (:674) is skipped whenever IOC_VOID is set, regardless of size β†’ data = ubuf.stkbuf (128-byte stack buffer, :572-576).
  4. The IOC_VOID branch (:681-682) initializes only the first sizeof(caddr_t) = 8 bytes; the bzero for OUT commands (:691-696) is in an else if chain that the VOID branch short-circuits.
  5. On fo_ioctl() success, copyout(data, uspc_data, size) (:729-730) runs for any IOC_OUT command β€” reading size bytes from the 128-byte stack buffer. No capability/priv check anywhere on this path; com is raw.

Why it did not reproduce here

Every unprivileged-openable fo_ioctl dispatches on exact command constants and errors for the crafted values:

Sweep: 11 fd types (/dev/null, /dev/zero, /dev/urandom, /dev/tty, VREG, VDIR, pipe rd/wr, udp, tcp, unix socket, kqueue) Γ— groups 0..255 Γ— nums {0,127} Γ— dir {VOID|OUT, VOID|IN|OUT} at len=291 β†’ 58,496 ioctls, 0 returned 0 (run.log: "done, hits=0").

Guest modules: kernel + ehci.ko + xhci.ko only β€” no evdev (IOCBASECMD dispatch, sys/dev/misc/evdev/cdev.c:514), no sound (IOCGROUP=='M'/'X', sys/dev/sound/pcm/dsp.c:1112,1141), which are the only masked-command dispatchers in the tree. Even those mask only the length field (IOCBASECMD keeps the direction bits), so they would not match a VOID|OUT crafted command either.

Impact ceiling if a permissive driver exists

~8 KB of adjacent kernel stack disclosed to an unprivileged caller (kernel text/stack pointers, saved state). On the standard audit snapshot (no KASLR) this is an info-leak primitive for exploit chains; not itself privilege escalation. Class: CWE-125 OOB read / CWE-909 info leak.

Verdict

status: not_reproduced (the overread never executed β€” no fo_ioctl returned success), reproduced: 0, impact: none (this configuration), confidence: certain (code defect), severity filed: Info (hardening / defense-in-depth). The fix is one line of allocation logic and removes the primitive entirely; see fix.diff.

Attempts: 1 (58k-command sweep). Guest left clean (no panic, no dirty state).

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

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff not kernel-rebuilt: the finding is Info/hardening and did not reproduce on the default configuration (no permissive fo_ioctl to unlock the overread), so there is no observable baseline behavior to validate a patched kernel against. The diff removes the primitive structurally (allocation no longer skips on IOC_VOID; heap zeroed; stack zeroed for VOID|OUT).

fix.diff (3 hunks against sys/kern/sys_generic.c, generated against the read-only tree, never applied).
↓ fix.diffper-fix-DF-5841

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log: 'fd types: 11' ... 'done, hits=0' (58,496 crafted ioctls, 0 returned 0). VERDICT.md: primitive proof at sys_generic.c:674-682 vs :729-730; per-driver exact-constant dispatch citations (vfs_vnops.c:983, sys_pipe.c:993, sys_socket.c:140+, kern_event.c:1660, tty.c:853).

PoC changes

Wrote scan rig from scratch (no seed): sweeps fd types x cmd groups x numbers x direction combos at len=291, flags buffer markers, reports any rc==0 with hexdump.

Verified recommended fix

Size the ioctl scratch buffer by size regardless of IOC_VOID (kmalloc with M_ZERO when size>128) and bzero the stack buffer for IOC_VOID|IOC_OUT commands so copyout never reads past 128 initialized bytes

Verdict

Code defect certain, primitive reachability-gated: mapped_ioctl() sizes the ioctl scratch buffer with (com & IOC_VOID) == 0 && size > sizeof(ubuf.stkbuf) (sys_generic.c:674) but copies back size bytes for any IOC_OUT command on success (:729-730), so an unprivileged IOC_VOID|IOC_OUT command with IOCPARM_LEN in (128,8191] reads up to 8063 bytes past the 128-byte on-stack ubuf.stkbuf into userland. The copyout only runs when the underlying fo_ioctl() returns 0, and a 58,496-command sweep across 11 unprivileged fd types (/dev/null /dev/zero /dev/urandom /dev/tty VREG VDIR pipe rd/wr udp tcp unix kqueue; groups 0-255, nums 0/127, dir VOID|OUT and VOID|IN|OUT) produced zero successes on the default guest config (kernel+ehci+xhci) - every reachable fo_ioctl dispatches on exact constants and returns ENOTTY/EINVAL. Filed as Info hardening: one future permissive driver turns this into an ~8KB kernel-stack disclosure. Fix is a one-condition allocation change (see fix.diff).