cd9660_readdir returns uninitialized idp->uio_off as the directory offset: 8 bytes of stale kernel heap disclosed to unprivileged users via lseek(SEEK_CUR) after a short getdents
Summary
idp = kmalloc(sizeof(*idp), M_TEMP, M_WAITOK) (:447) has no M_ZERO and idp->uio_off (:332) is only ever assigned inside iso_uiodir (:362) when an entry is actually shipped. An 8-byte getdents buffer is smaller than the first dirent's _DIRENT_DIRSIZ, so iso_uiordir returns -1, the walk ends with error==0, and uio->uio_offset = idp->uio_off (:601) copies uninit kernel heap into the uio. kern_getdirentries stores it in fp->f_offset and lseek(fd,0,SEEK_CUR) hands it to any unpriv user - on ANY mounted cd9660, no crafted image needed. VERIFIED cross-object: after a full walk of filesystem B (ends at offset 1670), a fresh unpriv process's 8-byte getdents on unrelated filesystem A returns lseek(SEEK_CUR)=0x686 where A's own walk ends at 338 - the stale uio_off of the previous isoreaddir tenant. Repeatable, 8 stale bytes per call. Fix validated (idp->uio_off = uio->uio_offset init): returns 0.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3147 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | β | 3.1 KB | view raw | |
| build.sh | β | 96 B | view raw | |
| run.sh | β | 210 B | view raw | |
| run.log | β | 1.5 KB | view raw | |
| env.txt | β | 835 B | view raw | |
| fix.diff | β | 319 B | view raw | |
| fix_run.log | β | 1.9 KB | view raw | |
| VERDICT.md | β | 2.9 KB | β raw | |
| README.md | β | 2.8 KB | β raw |
DF-3147 β cd9660_readdir returns uninitialized idp->uio_off as the directory offset
What (root cause)
cd9660_readdir() (sys/vfs/isofs/cd9660/cd9660_vnops.c:421-609) allocates
idp = kmalloc(sizeof(*idp), M_TEMP, M_WAITOK); /* :447 no M_ZERO */
...
idp->curroff = uio->uio_offset; /* :473 */
...
uio->uio_offset = idp->uio_off; /* :601 read */
idp->uio_off (declared :332) is only ever assigned inside
iso_uiodir() (:362), i.e. when at least one directory entry is actually
shipped to userspace. If the walk ends without shipping anything β
an 8-byte getdents buffer (smaller than _DIRENT_DIRSIZ of the first
entry, so iso_uiodir returns -1 at :345-348), an empty directory, or a
directory whose entries are all filtered out β idp->uio_off is
uninitialized kmalloc content, and it is stored into uio->uio_offset.
kern_getdirentries() (sys/kern/vfs_syscalls.c:4644-4646) then does
fp->f_offset = auio.uio_offset, and the value is disclosed to any
unprivileged user via lseek(fd, 0, SEEK_CUR) β 8 bytes of stale kernel
heap per call, on any mounted cd9660 filesystem (no crafted image
needed), repeatable at will.
The leaked value is the previous tenant of the recycled M_TEMP chunk β
typically a prior struct isoreaddir (kernel-internal directory offsets),
but whatever else the allocator hands out for that size class in general.
Reproduce (guest, stock kernel)
vnconfig -c -S 2048 /dev/vn0 /root/df3146_none.iso # any plain ISO works mount_cd9660 /dev/vn0 /mnt/a vnconfig -c -S 2048 /dev/vn1 /root/df3147_big.iso # second image, dir ends at 1670 mount_cd9660 /dev/vn1 /mnt/b su -m nobody -c "/tmp/trigger dirent /mnt/b" # full read of /mnt/b (uio_off=1670) su -m nobody -c "/tmp/trigger tinyseek /mnt/a" # 8-byte getdents on /mnt/a
Expected (observed 2026-09-07)
getdents(8) rc=0 ; lseek(SEEK_CUR) = 0x686 (1670) errno=0
/mnt/a's own walk would end at offset 338 β the returned 1670 is the
stale uio_off of the unrelated isoreaddir used for /mnt/b on the
other filesystem: cross-object kernel-heap disclosure through the file
offset. Reproduced in fresh processes, repeatedly (run.log).
Impact
Low-severity kernel heap info leak (8 stale bytes per call, unprivileged,
any cd9660 mount) + polluted f_offset semantics (subsequent getdents
continue from the garbage offset). Same isoreaddir allocation as DF-0866
(pad bytes) but a different field and a different sink (uio_offset/
f_offset vs uiomove padding).
Fix
Initialize idp->uio_off = uio->uio_offset; next to the curroff
initialization (fix.diff). Validated on the patched kernel: tiny getdents
now reports offset 0 until an entry is actually shipped (fix_run.log).
DF-3147 β VERDICT
reproduced β unprivileged disclosure of uninitialized kernel heap memory (8 bytes) through the directory file offset, on a stock cd9660 mount, no crafted image required beyond two ordinary ISO filesystems.
Mechanism (line-accurate)
cd9660_vnops.c:447βidp = kmalloc(sizeof(*idp), M_TEMP, M_WAITOK)(noM_ZERO);struct isoreaddir.uio_off(:332) is not initialized anywhere incd9660_readdir.:345-348βiso_uiodir()returns -1 without touchinguio_offwhenuio_resid < _DIRENT_DIRSIZ(dp)(an 8-byte getdents buffer is smaller than the 24-byte first dirent).:573-584β the -1 becomeserror = 0; the function returns success having shipped nothing.:601βuio->uio_offset = idp->uio_off;β uninitialized heap word.sys/kern/vfs_syscalls.c:4644-4646βkern_getdirentriescopies it intofp->f_offset;lseek(fd, 0, SEEK_CUR)hands it to userspace.
Observation (decisive run, 2026-09-07, guest 6.5-DEVELOPMENT #0)
- As nobody: full
getdentswalk of/mnt/b(cd9660 image B; its directory walk ends at byte offset 1670 β 20 crafted entries). The freedisoreaddirchunk retainsuio_off = 1670. - As nobody, fresh process: one 8-byte
getdentson/mnt/a(cd9660 image A on a different vn device; its own walk would end at 338), thenlseek(fd, 0, SEEK_CUR):
getdents(8) rc=0 ; lseek(SEEK_CUR) = 0x686 (1670) errno=0
The value 1670 cannot come from /mnt/a β it is the stale heap content of
the previous, unrelated isoreaddir allocation (cross-object disclosure).
Repeated calls return the same value (same recycled chunk); a follow-up
full getdents then continues from the garbage offset (observed benign here
because 1670 < i_size, but the offset is attacker-unknowable kernel state
in general).
Why Low
8 stale bytes per call; the recycled 912-byte M_TEMP chunk is in practice
usually a previous isoreaddir, so typical content is kernel-internal
directory offsets rather than pointers. (With other tenants of the same
size class, or NFS-cookie readdirs on exported cd9660, pointer-class data
can surface.) No memory corruption; deterministic fix. Rated Low /
kernleak, consistent with DF-0866 (same allocation, different field+sink).
Fix validation
fix.diff adds idp->uio_off = uio->uio_offset; at the curroff
initialization (:473 area). Built together with the DF-3146 fix
(make -j8 nativekernel && make installkernel, reboot). On the patched
kernel the same tiny-getdents probe reports offset 0 (the uio's own
offset) instead of stale heap β leak eliminated (fix_run.log).
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 (uio_off init) built into kernel #1 together with the DF-3146 fix; the identical probe on the patched kernel returns offset 0 instead of the stale 1670 - leak eliminated. fix_run.log holds the side-by-side.
fix_run.log final section (patched: getdents(8) rc=0 ; lseek(SEEK_CUR) = 0x0 (0))
Confirmed kernel references
Detail
Exploit chain
unprivileged: open(cd9660 mount dir) -> getdents(fd, buf, 8) [returns 0 bytes, success] -> lseek(fd, 0, SEEK_CUR) returns 8 bytes of stale kernel heap (prior tenant of the recycled 912-byte M_TEMP chunk). Repeatable at will; each call re-uses the same recycled chunk.
Evidence (decisive lines)
run.log (full walk of /mnt/b ends at 1670, then fresh-process tiny getdents on /mnt/a yields lseek(SEEK_CUR)=0x686 (1670), twice, plus doubletiny follow-up); fix_run.log (patched kernel: lseek(SEEK_CUR)=0x0)
PoC changes
No seed pack; authored from scratch. trigger.c modes: dirent (full walk), tinyseek (8-byte getdents + lseek), doubletiny (follow-up read from the garbage offset). df3147_big.iso is a plain RRIP image whose root directory walk ends at a distinctive offset (1670) to make the stale-value provenance unambiguous.
Verified recommended fix
Initialize idp->uio_off = uio->uio_offset next to the curroff initialization in cd9660_readdir (fix.diff).
Verdict
Reproduced on the stock guest: cd9660_readdir() kmallocs struct isoreaddir without M_ZERO (cd9660_vnops.c:447) and never initializes idp->uio_off (:332) - it is only assigned inside iso_uiodir (:362) when an entry is actually shipped. A getdents(2) with an 8-byte buffer (smaller than the 24-byte first dirent) ships nothing and returns success with uio->uio_offset = uninitialized heap (:601); kern_getdirentries stores it into fp->f_offset (vfs_syscalls.c:4646) and lseek(fd,0,SEEK_CUR) discloses it to any unprivileged user. Demonstrated cross-object: after a full walk of a cd9660 directory on filesystem B (ends at offset 1670), a fresh unprivileged process doing an 8-byte getdents on an unrelated filesystem A (own end offset 338) reads lseek(SEEK_CUR)=0x686 (1670) - the stale uio_off of the previous, unrelated isoreaddir tenant. Works on any mounted cd9660, no crafted metadata needed. Low severity: 8 stale bytes per call, in practice prior-isoreaddir offsets (pointer-class data can surface with other tenants of the size class / NFS-cookie readdirs); also pollutes f_offset for subsequent reads. Fix validated: initializing uio_off from the uio offset makes the same probe return 0 on the patched kernel #1 (fix_run.log).
No comments yet.