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)
PoC verification
Evidence pack
findings/poc/DF-0874 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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 myselfinntfs_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_ROOTswapped in) mounts cleanly and reachesntfs_readdir, so the live path is exercised without the sibling panic. reclen=0gives the cleanest live-GENERIC demonstration because it makes the unbounded walk visible (dirent flood) without depending on heap-residue content. Thereclen=0xFFF0/0x60OOB-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
fixedVALIDATED 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.
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.
No comments yet.