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)
PoC verification
Evidence pack
findings/poc/DF-5841 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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
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)
sys_ioctl(sys_generic.c:537-543) passesuap->comverbatim tomapped_ioctlwithmap == NULLβ the user fully controlscom.size = IOCPARM_LEN(com)(:668) is attacker-chosen in [0, 8191] (sys/sys/ioccom.h:38-46); thesize > IOCPARM_MAXcheck (:669) can never fire sinceIOCPARM_LENis already masked to 13 bits β it does NOT constrain direction combinations.- The allocation (
:674) is skipped wheneverIOC_VOIDis set, regardless of size βdata = ubuf.stkbuf(128-byte stack buffer,:572-576). - The
IOC_VOIDbranch (:681-682) initializes only the firstsizeof(caddr_t)= 8 bytes; thebzerofor OUT commands (:691-696) is in anelse ifchain that the VOID branch short-circuits. - On
fo_ioctl()success,copyout(data, uspc_data, size)(:729-730) runs for anyIOC_OUTcommand β readingsizebytes from the 128-byte stack buffer. No capability/priv check anywhere on this path;comis raw.
Why it did not reproduce here
Every unprivileged-openable fo_ioctl dispatches on exact command constants
and errors for the crafted values:
vn_ioctl(sys/kern/vfs_vnops.c:983): FIONREAD/FIOASYNC exact, elseVOP_IOCTLβ spec βdev_dioctlβ driver switch β ENOTTY.pipe_ioctl(sys/kern/sys_pipe.c:993): exact switch, default ENOTTY.soo_ioctl(sys/kern/sys_socket.c:140+): exact switch; theIOCGROUP=='i'/'r'defaults go toifioctl/rtioctl, also exact-constant.kqueue_ioctl(sys/kern/kern_event.c:1660): default ENOTTY.ttioctl(sys/kern/tty.c:853): exact TIOC* constants, default ENOTTY.
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_testablefix.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).
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).
No comments yet.