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

Unbounded attr_indexentry walk in ntfs_readdir inner loop reads kernel heap past f_dirblbuf

Summary

ntfs_vnops.c:585-586 for(;!(iep->ie_flag&NTFS_IEFLAG_LAST);iep=NTFS_NEXTREC(iep,...)). NTFS_NEXTREC=iep+iep->reclen (ntfs.h:273). reclen u16 on-disk. No check iep stays in f_dirblbuf. Matching entry validated by ntfs_subr.c:1176 bounded walk but subsequent entries walked by vnops.c are NOT bounded. reclen=0 infinite loop hang. Large reclen OOB heap read ie_fname/ie_number copied to userspace via vop_write_dirent = info leak.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0874 Β· 21 files
FileTypeDescriptionSize
trigger.c trigger-source unprivileged readdir driver (opendir/readdir + hex dump of dirents) 2.5 KB view raw
craft_img.py trigger-source builds crafted NTFS images (panic/leak modes) with attacker-controlled ie_reclen 12.9 KB view raw
harness.c trigger-source deterministic code-level proof: replicates vnops.c:585 (unbounded) + subr.c:1176 (bounded) walks with guard page 9.4 KB view raw
ntfs_reclen0.img trigger-image reclen=0 -> dirent flood (257 entries) on live GENERIC 512.0 KB ↓ download
ntfs_leak.img trigger-image reclen=0x60 -> OOB walk through heap residue 512.0 KB ↓ download
ntfs_clean.img trigger-image valid image (3 entries + LAST) for regression check 512.0 KB ↓ download
build.sh build-script cc trigger.c + harness.c 302 B view raw
run.sh run-script end-to-end reproduction + fix validation 4.1 KB view raw
build.log build-log trigger + harness build output 280 B view raw
run.log run-log combined baseline + patched decisive run 2.3 KB view raw
run_reclen0.out run-log full 259-line reclen=0 flood output (257 dirents) 28.3 KB view raw
run_oob.out run-log reclen=0xFFF0 OOB-leap run (2 entries + silent OOB) 402 B view raw
harness_A.out run-log harness reclen=0xFFF0 -> BUG CONFIRMED (1 OOB deref) 759 B view raw
harness_B.out run-log harness reclen=0x60 -> BUG CONFIRMED (2 OOB derefs) 759 B view raw
harness_D.out run-log harness reclen=0 -> infinite-loop DoS 823 B view raw
fix.diff suggested-fix bounds ntfs_readdir walk against f_dirblbuf/f_dirblsz + rejects reclen<ie_fname offset 977 B view raw
fix_build.log build-log patched ntfs.ko module build (make, rc=0, sha256) 17.0 KB view raw
fix_run.log run-log Phase-8 before/after contrast + regression 2.7 KB view raw
env.txt environment uname, cc version, vfs.usermount, ntfs.ko hashes 361 B view raw
VERDICT.md verdict full narrative: mechanism, trigger, fix, validation 6.5 KB ↓ raw
README.md readme reproduce instructions + file index 2.3 KB ↓ raw
README.md readme reproduce instructions + file index
↓ download raw

DF-0874 β€” Unbounded attr_indexentry walk in ntfs_readdir

Medium Β· CWE-125 OOB Read Β· sys/vfs/ntfs/ntfs_vnops.c:585-586

ntfs_readdir walks attr_indexentry records using the attacker-controlled on-disk ie_reclen as stride, with no bound check against the INDEX block buffer (fp->f_dirblbuf). A crafted NTFS image walks the pointer into kernel heap β†’ OOB read / info leak / DoS. The sibling walk in ntfs_ntreaddir (ntfs_subr.c:1176) is bounded; ntfs_readdir is not.

Reproduce

./build.sh                       # cc trigger.c + harness.c (guest, maxx)
# root mounts the crafted image (vfs.usermount=0); maxx readdir's it:
ssh dfbsd 'vnconfig -c vn0 /root/poc/DF-0874/ntfs_reclen0.img &&
           mount -t ntfs -o ro,-u=1001,-g=1001 /dev/vn0 /mnt/t'
ssh dfbsd-maxx 'cd poc/DF-0874 && ./trigger /mnt/t'    # 257 duplicate dirents (BUG)
ssh dfbsd-maxx 'cd poc/DF-0874 && ./harness 0xFFF0 0 1' # code-level OOB proof

Expected (bug present, unpatched #0): readdir returns 257 entries (.. + 256Γ— HHHH) because reclen=0 makes the walk never advance. The harness shows the walk dereferences 64 KB past the 4 KB buffer.

Expected (fixed): readdir returns EINVAL (0 entries); the harness walk is bounded; a clean image still returns the correct dirents.

Files

file what
trigger.c unprivileged readdir driver (opendir/readdir + hex dump)
craft_img.py builds crafted NTFS images (panic/leak modes)
harness.c deterministic code-level proof (replicates both kernel walks)
ntfs_reclen0.img reclen=0 β†’ dirent flood (live-GENERIC trigger)
ntfs_oob.img / ntfs_leak.img reclen=0xFFF0 / 0x60 β†’ OOB read variants
ntfs_clean.img valid image for regression check
fix.diff bounds the walk against fp->f_dirblbuf/f_dirblsz
VERDICT.md full analysis, before/after, mechanism
run.log / run_reclen0.out / harness_*.out full untrimmed logs
fix_build.log / fix_run.log Phase-8 module build + validation

Impact

Read-only primitive β€” no escalation. Ceiling: kernel heap info leak / DoS (dirent flood or page-fault panic if the OOB offset escapes mapped kmem). On default GENERIC (INVARIANTS ON) the slab poison typically masks observable exfiltration; on non-INVARIANTS the leak is directly observable.

VERDICT.md verdict full narrative: mechanism, trigger, fix, validation
↓ download raw

DF-0874 β€” Unbounded attr_indexentry walk in ntfs_readdir inner loop

Verdict: REPRODUCED (OOB read / DoS) β€” fix VALIDATED on default GENERIC.

The bug

ntfs_readdir (sys/vfs/ntfs/ntfs_vnops.c:585-586) walks struct attr_indexentry records in the root directory's $INDEX_ROOT:$I30 buffer (fp->f_dirblbuf) using the attacker-controlled on-disk ie_reclen as the stride, with no check that the walking pointer stays inside the buffer:

for (; !(iep->ie_flag & NTFS_IEFLAG_LAST);
    iep = NTFS_NEXTREC(iep, struct attr_indexentry *))
{   ... vop_write_dirent(iep->ie_number, ..., convname(iep->ie_fname)) ...   }

where NTFS_NEXTREC(s, type) = (type)(((caddr_t)s) + (s)->reclen) (ntfs.h:273) and reclen is a u_int16_t read straight off the disk image.

The sibling walk in ntfs_ntreaddir (sys/vfs/ntfs/ntfs_subr.c:1176-1178) is bounded β€” for (; !(iep->ie_flag & LAST) && (rdsize > aoff); aoff += iep->reclen, ...) β€” and uses that bounded walk to find the num-th permitted entry, returning a pointer into f_dirblbuf. ntfs_readdir then restarts an unbounded walk from that returned pointer. That is the hole: the returned entry's reclen is never validated against the buffer, so a crafted image drives the pointer into kernel heap.

Trigger & impact

Precondition (realistic, per AGENT.md threat model): an admin has mounted (or made mountable) a crafted NTFS image. mount_ntfs auto-loads ntfs.ko; the unprivileged user then readdirs the mount point. ntfs is optional ntfs (sys/conf/files) and ships as /boot/kernel/ntfs.ko β€” not compiled into GENERIC.

Three crafted variants, all confirmed on the default GENERIC kernel (6.5-DEVELOPMENT #0, INVARIANTS ON):

image entry[0].reclen effect on unpatched #0
ntfs_reclen0.img 0x0000 DoS / flood: walk never advances (NTFS_NEXTREC = iep+0), re-emits entry[0] as 257 duplicate dirents until the uio buffer fills. Deterministic, visible on GENERIC.
ntfs_oob.img (=hybrid_panic) 0xFFF0 OOB read: walk leaps to f_dirblbuf+0x10022 (~64 KB past the 4 KB buffer) and dereferences ie_flag there. On GENERIC the OOB byte is mapped kmem; if its bit-2 (LAST) is clear the walk continues and copies ie_number/ie_fname residue to userspace (info leak); if set it stops silently.
ntfs_leak.img 0x0060 OOB walk through residue: stride 96 B, processes successive "entries" from heap residue past the 122-byte valid data. On GENERIC the INVARIANTS slab poison (0xdeadc0de, bit-2 set) usually terminates the walk before exfiltration; on a non-INVARIANTS kernel the residue is zeros/live objects and the leak is observable.

This is a read-only primitive (CWE-125 OOB read) β€” no write, so there is no privilege-escalation chain. The realistic impact ceiling is kernel heap info leak / DoS, matching the Medium severity.

Reproduction (decisive evidence)

Live GENERIC (#0 unpatched), reclen=0 image β€” the walk emits 257 duplicate dirents (.. + 256Γ— HHHH, d_ino=0x48484848), proving the stride is unvalidated and never bounds-checked (full 259-line log in run_reclen0.out):

dirent[0]: d_ino=0x5 d_name='..'
dirent[1]: d_ino=0x48484848 d_name='HHHH'
dirent[2]: d_ino=0x48484848 d_name='HHHH'
  ... (256 duplicates) ...
dirent[256]: d_ino=0x48484848 d_name='HHHH'
[+] readdir returned 257 entries

Deterministic code-level harness (harness.c) β€” replicates the exact vnops.c:585 (unbounded) and subr.c:1176 (bounded) walks against the same crafted INDEX_ROOT data with a guard page, proving the vnops walk reads past the buffer while the subr walk does not:

$ ./harness 0xFFF0 0 1   # reclen=0xFFF0, no LAST, INVARIANTS poison
  [subr.c:1176 BOUNDED walk]  walked to off=32 (rdsize=122): IN-BOUNDS (safe)
  [vnops.c:585 UNBOUNDED walk] steps=2 max_off=65552 oob_derefs=1
    => BUG CONFIRMED: ntfs_readdir dereferenced index entries 1 time(s) PAST
       the INDEX_ROOT valid data (rdsize=122) using attacker reclen=0xfff0.

The fix (fix.diff)

Bounds the ntfs_readdir inner walk against fp->f_dirblbuf / fp->f_dirblsz (mirroring the bounded walk already in ntfs_ntreaddir), and rejects a reclen smaller than the fixed entry header (prevents the reclen==0 stall/DoS):

for (; (caddr_t)iep >= fp->f_dirblbuf &&
     (caddr_t)iep + __offsetof(struct attr_indexentry, ie_fname) <=
     fp->f_dirblbuf + fp->f_dirblsz;
     iep = NTFS_NEXTREC(iep, struct attr_indexentry *))
{
    if (iep->ie_flag & NTFS_IEFLAG_LAST)
        break;
    if (iep->reclen < __offsetof(struct attr_indexentry, ie_fname)) {
        error = EINVAL;
        goto done;
    }
    ... existing body unchanged ...

Fix validation (Phase 8)

Built the patched ntfs.ko standalone (cd sys/vfs/ntfs && make, rc=0, 846888df…), hot-swapped /boot/kernel/ntfs.ko, kldload'd it (kernel #0 unchanged β€” ntfs is a module). Re-ran the SAME triggers:

image unpatched #0 patched ntfs.ko
ntfs_reclen0.img (reclen=0) 257 entries (flood) EINVAL, 0 entries βœ…
ntfs_oob.img (reclen=0xFFF0) 2 entries + OOB read at +0xFFF0 2 entries, OOB advance prevented βœ…
ntfs_leak.img (reclen=0x60) 2 entries + OOB walk EINVAL (malformed entry rejected) βœ…
ntfs_clean.img (regression) 4 correct entries 4 correct entries (.. HELLO WORLD FOO) βœ…

Full before/after contrast in fix_run.log; full module build log in fix_build.log.

Notes

  • The DragonFly NTFS driver has a pre-existing lockmgr panic (lockmgr: locking against myself in ntfs_mountfsβ†’vflushβ†’vx_get, the DF-0786 sibling) that fires on some crafted images during mount cleanup. It is unrelated to this bug. A hybrid base (DF-0873's known-mountable image with the DF-0874 evil $INDEX_ROOT swapped in) mounts cleanly and reaches ntfs_readdir, so the live path is exercised without the sibling panic.
  • reclen=0 gives the cleanest live-GENERIC demonstration because it makes the unbounded walk visible (dirent flood) without depending on heap-residue content. The reclen=0xFFF0 / 0x60 OOB-read variants fire on GENERIC but the INVARIANTS slab poison (0xdeadc0de, bit-2 = LAST) typically masks observable exfiltration; the harness proves the OOB deref occurs regardless.
  • No escalation chain: this is a pure OOB read (no write primitive), so the impact ceiling is info-leak / DoS, consistent with the Medium rating.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix: applied fix.diff to /usr/src (patch -p1 hunk #1 succeeded at line 582), rebuilt ntfs.ko standalone (cd sys/vfs/ntfs && make, rc=0), hot-swapped /boot/kernel/ntfs.ko and kldload'd it on the UNCHANGED #0 GENERIC kernel. BEFORE (unpatched ntfs.ko): ntfs_reclen0.img -> readdir returns 257 entries (256 duplicate HHHH dirents, the reclen=0 flood); harness shows the reclen=0xFFF0 walk reaches off=65552 (64KB past the 4KB buffer). AFTER (patched ntfs.ko): ntfs_reclen0.img -> readdir returns EINVAL/0 entries (flood eliminated); ntfs_oob.img -> 2 entries with the OOB advance to +0xFFF0 prevented by the bounds check; ntfs_leak.img -> EINVAL (malformed entry rejected). Regression: a clean valid image (3 proper entries + LAST) returns the correct 4 dirents (.. HELLO WORLD FOO). Fix closes the bug without regressing valid NTFS images.

BEFORE (unpatched ntfs.ko aa8d8384, kernel #0): readdir on ntfs_reclen0.img -> '[+] readdir returned 257 entries' (dirent[1..256]: d_ino=0x48484848 d_name='HHHH'). AFTER (patched ntfs.ko 846888df, kernel #0 unchanged): readdir on ntfs_reclen0.img -> '[!] readdir returned 0 entries; errno=22 (Invalid argument)'. Regression (clean image, patched): readdir returned 4 entries (.. HELLO WORLD FOO). Module build: rc=0 (133-line log); apply: Hunk #1 succeeded at line 582.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (kernel unchanged; patched ntfs.ko sha256 846888df98030684633f83b061abd1b87f3272f023162e6a8a653a1af0ebb1d3 hot-swapped over unpatched aa8d83843beedc5b60956ee4afa048f62a6d96d5396a8fb660490ab83af391d4; ntfs is an optional module, not compiled into GENERIC)

Confirmed kernel references

Detail

Exploit chain

none (non-corruption class: pure OOB read, no write primitive -> no privilege-escalation chain derivable). The bug gives an unbounded heap read of stride=ie_reclen (u16, attacker-controlled) starting from a returned in-buffer indexentry pointer; content read is heap residue past fp->f_dirblbuf. Characterized via harness.c (deterministic, models both kernel walks with a guard page) and the live reclen=0 dirent-flood demonstration. Realistic ceiling: kernel heap info leak (ie_number/ie_fname copied to userspace via vop_write_dirent) / DoS (dirent flood or page-fault panic if the OOB offset escapes mapped kmem).

Evidence (decisive lines)

BASELINE (unpatched #0, reclen=0 image): readdir returned 257 entries -- '..' + 256x (d_ino=0x48484848 d_name='HHHH'); the walk never advances because NTFS_NEXTREC(iep)=iep+0. harness reclen=0xFFF0: '=> BUG CONFIRMED: ntfs_readdir dereferenced index entries 1 time(s) PAST the INDEX_ROOT valid data (rdsize=122) using attacker reclen=0xfff0' (max_off=65552, bufsz=4096). PATCHED ntfs.ko (846888df): reclen=0 -> 'EINVAL, 0 entries' (flood gone); reclen=0xFFF0 -> 2 entries with OOB advance prevented; clean image -> 4 correct entries (.. HELLO WORLD FOO), no regression.

PoC changes

Created findings/poc/DF-0874/ from scratch (no prior scaffolding existed): trigger.c (unprivileged opendir/readdir driver), craft_img.py (NTFS image crafter producing reclen=0/0xFFF0/0x60 variants on a known-mountable hybrid base), harness.c (deterministic code-level proof replicating both kernel walks with a guard page + SIGSEGV handler), fix.diff (bounds the walk), and 4 crafted .img trigger images. Fixed the trigger to use DragonFly's libc dirent (d_fileno/d_namlen, no d_reclen) and the harness to avoid sysconf(). Used a hybrid image base (DF-0873's known-mountable structure + DF-0874 evil INDEX_ROOT) to avoid the sibling DF-0786 lockmgr mount panic.

Verified recommended fix

In sys/vfs/ntfs/ntfs_vnops.c:585, bound the indexentry walk against fp->f_dirblbuf/f_dirblsz (mirror ntfs_subr.c:1176's rdsize>aoff check): change the for-condition to '(caddr_t)iep >= fp->f_dirblbuf && (caddr_t)iep + __offsetof(struct attr_indexentry, ie_fname) <= fp->f_dirblbuf + fp->f_dirblsz', move the LAST check into the body as a break, and add 'if (iep->reclen < __offsetof(struct attr_indexentry, ie_fname)) { error=EINVAL; goto done; }' to reject the reclen=0 stall/DoS. This is a new fix (the finding markdown had no prior fix proposal to supersede). Full git-apply-able diff in findings/poc/DF-0874/fix.diff.

Verdict

REPRODUCED on default GENERIC (#0, INVARIANTS ON). ntfs_readdir (sys/vfs/ntfs/ntfs_vnops.c:585-586) walks attr_indexentry records via NTFS_NEXTREC(iep)=iep+iep->reclen using the attacker-controlled on-disk u16 reclen as stride, with NO bound check against fp->f_dirblbuf. The sibling walk in ntfs_ntreaddir (ntfs_subr.c:1176) IS bounded (rdsize>aoff) and uses it only to find the matching entry; ntfs_readdir then restarts an UNBOUNDED walk from the returned pointer. Live proof: a crafted NTFS image with entry[0].reclen=0 and no LAST entry causes readdir to emit 257 duplicate dirents (.. + 256x HHHH, d_ino=0x48484848) because NTFS_NEXTREC(iep)=iep+0=iep never advances. A reclen=0xFFF0 image drives the walk 64KB past the 4KB buffer (confirmed by harness: oob_derefs=1, max_off=65552). Read-only primitive (CWE-125) -> no escalation; impact ceiling is heap info-leak / DoS. On GENERIC the INVARIANTS slab poison (0xdeadc0de, bit2=LAST) typically masks observable exfiltration of the OOB-read variant, but the reclen=0 flood is fully visible and deterministic.