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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3147 Β· 9 files
FileTypeDescriptionSize
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).

VERDICT.md
↓ download raw

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) (no M_ZERO); struct isoreaddir.uio_off (:332) is not initialized anywhere in cd9660_readdir.
  • :345-348 β€” iso_uiodir() returns -1 without touching uio_off when uio_resid < _DIRENT_DIRSIZ(dp) (an 8-byte getdents buffer is smaller than the 24-byte first dirent).
  • :573-584 β€” the -1 becomes error = 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_getdirentries copies it into fp->f_offset; lseek(fd, 0, SEEK_CUR) hands it to userspace.

Observation (decisive run, 2026-09-07, guest 6.5-DEVELOPMENT #0)

  1. As nobody: full getdents walk of /mnt/b (cd9660 image B; its directory walk ends at byte offset 1670 β€” 20 crafted entries). The freed isoreaddir chunk retains uio_off = 1670.
  2. As nobody, fresh process: one 8-byte getdents on /mnt/a (cd9660 image A on a different vn device; its own walk would end at 338), then lseek(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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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))
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Mon Sep 7 04:20:56 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

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).