ntfs_runtovrun walks attacker run buffer with no length bound β OOB read
Summary
ntfs_subr.c:582 ntfs_runtovrun(cn_t**,cn_t**,u_long*,u_int8_t*run) β NO length parameter. :595-598 while(run[off]){off+=(run[off]&0xF)+((run[off]>>4)&0xF)+1;cnt++} β walks until zero byte. :605-629 decode loop same. Crafted non-resident attribute run list without zero terminator: both loops read past MFT record buffer into adjacent kernel heap. cnt attacker-influenced feeds kmalloc(cnt*sizeof(cn_t)) :599-600. Compare disabled ntfs_parserun :1745-1780 correctly takes len and bounds-checks :1760/:1770. Trigger: crafted NTFS image non-resident $DATA run no terminator. Fix: thread runlen parameter bound both loops off<runlen.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0789 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | userspace replication of ntfs_runtovrun:582-634 with guard page; modes: clean/oob_short/oob_fill/oob_infinite + apply_fix | 9.8 KB | view raw |
| gen_ntfs_0789.py | trigger-source | crafted NTFS image generator; compound mode (fills entire record) and isolated mode (run-list-only corruption) | 11.3 KB | view raw |
| ntfs_0789.img | trigger-image | compound crafted image β triggers DF-0789+DF-0787, panics at mount | 256.0 KB | β download |
| ntfs_0789_isolated.img | trigger-image | isolated crafted image β triggers only DF-0789, mount succeeds (latent corruption) on unpatched, EINVAL on patched | 256.0 KB | β download |
| build.sh | build-script | cc -O2 -o harness harness.c | 177 B | view raw |
| run.sh | run-script | runs harness in buggy+fixed modes; run.sh live for mount test | 1.1 KB | view raw |
| build.log | build-log | harness build output | 43 B | view raw |
| run.log | run-log | harness run output: oob_fill/oob_infinite β SIGSEGV (rc=2) | 988 B | view raw |
| panic.txt | panic-signature | kernel panic from compound image: NTFS vattr: malloc limit exceeded through ntfs_attrtontvattr β ntfs_loadntode | 526 B | view raw |
| env.txt | environment | uname, cc version, vfs.usermount=0, vm.randomize_mmap=0 | 192 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: thread runlen parameter, bound both loops, entry-straddle check β EINVAL | 3.4 KB | view raw |
| fix_build.log | build-log | patched ntfs.ko build, 0 errors | 15.7 KB | view raw |
| fix_run.log | run-log | before/after: isolated image mount RC=0 (unpatched) β RC=71 EINVAL (patched); clean image RC=0 (no regression) | 1.6 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism, reachability, harness + live reproduction, fix validation | 9.9 KB | β raw |
| README.md | readme | build/run/expected + how to reproduce | 2.8 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0789 β Reproduce
Bug
Unbounded run-list walk in ntfs_runtovrun (sys/vfs/ntfs/ntfs_subr.c:582-634).
The function takes NO length parameter; both loops walk run[off] until a zero
byte (:595-598 count, :605-629 decode) with no bound check against the
attribute's data-extent length. A crafted NTFS image with a non-resident
attribute whose run list has no zero terminator causes an OOB read past the
MFT record buffer into adjacent kernel heap, or (on default GENERIC with
INVARIANTS) an infinite loop into 0xdeadc0de-poisoned freed slab.
The disabled ntfs_parserun at :1745-1780 shows the correct pattern: it
takes a len parameter and bounds-checks at :1760 and :1770.
Files
| file | purpose |
|---|---|
harness.c |
userspace replication of ntfs_runtovrun:582-634 with a guard page; accepts clean|oob_short|oob_fill|oob_infinite and apply_fix |
gen_ntfs_0789.py |
builds a crafted NTFS image with ino 0's non-resident $DATA run list filled with 0x11 (no terminator) to the end of the MFT record |
ntfs_0789.img |
the crafted image (regenerated by build.sh if python3 present) |
build.sh |
compiles the harness + (if python3 available) regenerates the image |
run.sh |
runs the harness; run.sh live (root) also runs the live mount |
fix.diff |
git-apply-able fix: thread a runlen parameter and bound both loops |
VERDICT.md |
full narrative + evidence |
manifest.json |
artifact catalog |
Build
./build.sh # builds ./harness (+ image if python3 present)
Run
./run.sh # userspace harness, deterministic (no root needed)
./run.sh live # + live mount test (REQUIRES ROOT, panics/hangs the guest)
Expected
- Harness, buggy mode:
cleanβ rc=0 (terminator found);oob_shortβ rc=0 (stops at zero byte after entry β proves no internal bound, but adjacent zeros save it);oob_fill/oob_infiniteβ SIGSEGV (rc=2, OOB read past guard page) or ITERATION CAP (rc=1, infinite loop). - Harness, fix mode:
oob_fill/oob_infiniteβ "FIX REJECTED input" (EINVAL); clean/oob_short unchanged. - Live (unpatched kernel):
mount_ntfsonntfs_0789.imgcauses kernel hang (infinite loop into poisoned slab) or panic (OOB page fault). Guest dies. - Live (patched kernel):
mount_ntfsreturnsInvalid argument(RC=71), guest stays UP.
Preconditions
mount_ntfsis root-only (vfs.usermount=0on stock DragonFly). Threat model: admin mounts attacker-supplied NTFS image / USB stick.- Reachable at mount time during
VFS_VGET(NTFS_MFTINO=0)βntfs_loadntnodeβntfs_attrtontvattrβntfs_runtovrun. Does NOT require the post-mount lookup path (so the DF-0786 lockmgr panic does not block this finding).
DF-0789 β ntfs_runtovrun walks attacker run buffer with no length bound β OOB read
Verdict: REPRODUCED (harness + live kernel) β FIX VALIDATED
The bug (confirmed by source trace + live panic + harness)
File: sys/vfs/ntfs/ntfs_subr.c:582-634 (function ntfs_runtovrun)
Class: CWE-125 (Out-of-bounds Read) + CWE-835 (infinite loop, if adjacent memory has no zero byte)
Severity (per finding): Medium. Confirmed: realistic impact ceiling is kernel panic / DoS (no write primitive).
582: int
583: ntfs_runtovrun(cn_t **rcnp, cn_t **rclp, u_long *rcntp, u_int8_t *run)
584: {
...
595: while (run[off]) { // NO length bound β walks until zero byte
596: off += (run[off] & 0xF) + ((run[off] >> 4) & 0xF) + 1;
597: cnt++;
598: }
599: cn = kmalloc(cnt * sizeof(cn_t), M_NTFSRUN, M_WAITOK); // cnt is attacker-influenced
600: cl = kmalloc(cnt * sizeof(cn_t), M_NTFSRUN, M_WAITOK);
...
605: while (run[off]) { // decode loop β same missing bound
...
629: }
ntfs_runtovrun takes NO length parameter. Both the count loop (:595-598) and the
decode loop (:605-629) walk run[off] until they encounter a zero byte. If the
on-disk run list has no zero terminator, the walk reads past the attribute's data
extent, past the MFT record buffer (kmalloc(4096, M_TEMP)), into adjacent kernel
heap β an OOB read. On default GENERIC (INVARIANTS ON), adjacent freed slab chunks
are poisoned with 0xdeadc0de (all nonzero), so the walk never terminates β
infinite loop / eventual slab-exhaustion panic.
The disabled ntfs_parserun at :1745-1780 shows the correct pattern: it takes
a len parameter and bounds-checks at :1760 ((sz & 0xF) > 8 || (*off)+(sz&0xF) > len)
and :1770.
Caller / reachability
ntfs_runtovrun is called from ntfs_attrtontvattr at :551-553:
551: error = ntfs_runtovrun(&(vap->va_vruncn), &(vap->va_vruncl),
552: &(vap->va_vruncnt),
553: (caddr_t) rap + rap->a_nr.a_dataoff);
ntfs_attrtontvattr is called from the attribute walk in ntfs_loadntnode:311.
For non-resident attributes (vap->va_flag & NTFS_AF_INRUN at :544),
ntfs_runtovrun decodes the on-disk run list into in-memory cluster arrays.
Reachability at mount time:
ntfs_mountfs (ntfs_vfsops.c:393-403) β VFS_VGET(NTFS_MFTINO=0) β ino 0 is first system node loaded β ntfs_vgetex (ntfs_vfsops.c:718) β ntfs_loadntnode (ntfs_subr.c:253) β reads MFT record from disk (system node) β ntfs_attrtontvattr (ntfs_subr.c:311) β for ino 0's non-resident $DATA β ntfs_runtovrun (ntfs_subr.c:551) β OOB walk on malformed run list
ino 0 ($MFT) is a system node (ino < NTFS_SYSNODESNUM=11), so its MFT record is
read directly from the boot-sector MFT cluster via bread() (ntfs_subr.c:265-281),
NOT via the $DATA run list. The run list is decoded AFTER the record is in memory,
so ntfs_runtovrun fires before any lookup. The sibling DF-0786 lockmgr panic
(fires during directory LOOKUP) does NOT block this finding.
Reproduction β userspace guard-page harness (deterministic)
harness.c replicates ntfs_runtovrun:582-634 against a run-list buffer placed
at the end of a writable page with a PROT_NONE guard page after it. Output:
=== BUGGY walk (kernel behaviour on default GENERIC #0) === mode=clean apply_fix=0 -> rc=0 clean exit (terminator found within buffer) mode=oob_short apply_fix=0 -> rc=0 clean exit (terminator found within buffer) mode=oob_fill apply_fix=0 -> rc=2 SIGSEGV -> OOB read past run-list buffer mode=oob_infinite apply_fix=0 -> rc=2 SIGSEGV -> OOB read past run-list buffer === FIXED walk (proposed fix: bound by runlen) === mode=clean apply_fix=1 -> rc=0 clean exit (terminator found within buffer) mode=oob_short apply_fix=1 -> rc=0 clean exit (terminator found within buffer) mode=oob_fill apply_fix=1 -> rc=-1 FIX REJECTED input (EINVAL) mode=oob_infinite apply_fix=1 -> rc=0 clean exit (bounded walk)
oob_fill(200-byte buffer, all 0x11): the walk runs off the end into the guard page β SIGSEGV. This is the OOB read past the allocation.- The fixed walk rejects
oob_fillwith EINVAL because entries straddle the buffer end.
Reproduction β live kernel (default GENERIC #0)
Compound image (ntfs_0789.img)
Fills ino 0's entire MFT record (bytes 136-4095) with 0x11 (no terminator):
vnconfig -c vn0 ntfs_0789.img
mount_ntfs -o ro /dev/vn0 /mnt/ntfs
β panic: NTFS vattr: malloc limit exceeded
_kmalloc() at _kmalloc+0xb09
_kmalloc() at _kmalloc+0xb09
ntfs_attrtontvattr() at ntfs_attrtontvattr+0x35
ntfs_loadntnode() at ntfs_loadntode+0x178
ntfs_vgetex() β ntfs_vget()
Debugger("panic")
This compounds DF-0789 (inner ntfs_runtovrun OOB walk) with DF-0787 (outer
attribute-walk OOB after reclen=4024 advances past the record). The panic is
from the outer walk's repeated struct ntvattr allocations exhausting the
M_NTFSNTVATTR slab (kern_slaballoc.c:877). (On a second run, a different
manifestation appeared: vm_object_hold_shared assertion failure β both are
OOB-read consequences whose exact form depends on adjacent slab content.)
Isolated image (ntfs_0789_isolated.img)
Keeps the outer attribute walk properly bounded (reclen=88, proper end marker) but corrupts only the 24-byte run-list data within the attribute (0x22, no terminator). Isolates DF-0789 from DF-0787:
mount_ntfs -o ro /dev/vn0 /mnt/ntfs β MOUNT_RC=0 (mount SUCCEEDS β corrupt run list silently accepted)
ntfs_runtovrun walks past the 24-byte run-list extent into the end-of-attributes
marker (0xFF bytes) and zeros, reads garbage, returns SUCCESS. Since system nodes
(ino<11) are read directly from disk, the corrupt run data doesn't affect mount.
The corruption is latent β it would surface if a non-system MFT record (inoβ₯11)
were accessed.
Escalation assessment (no chain possible β read/loop only)
This is a read-only / control-flow primitive. There is no write to
attacker-chosen kernel memory: the only writes are kernel-internal cn[]/cl[]
arrays filled from OOB-read garbage bytes. Per Phase 6, a pure read/loop primitive
has no escalation chain to uid=0; the correct deliverable is the characterized
impact ceiling:
- Kernel panic / DoS (demonstrated: compound image panics at mount time)
- Latent corruption (isolated image: mount succeeds with corrupt run data)
- Theoretical info-leak: OOB bytes read from adjacent slab flow into the
run-list arrays and could be exposed via subsequent file reads on the mounted
volume. On default GENERIC this is masked by the panic.
No uid=0 is achievable; reported as impact=panic.
Fix β fix.diff
Threads a size_t runlen parameter into ntfs_runtovrun. The caller
ntfs_attrtontvattr passes rap->a_hdr.reclen - rap->a_nr.a_dataoff (the
run-list extent within the attribute). Both loops check off < runlen before
reading, and each entry's total byte consumption is validated against runlen
before advancing (mirrors the disabled ntfs_parserun:1760/1770).
Changes:
1. ntfs_subr.h:87 β add size_t runlen to declaration
2. ntfs_subr.c:551-553 β caller computes and passes runlen (with underflow guard)
3. ntfs_subr.c:582 β function signature gains size_t runlen
4. ntfs_subr.c:601 (count loop) β while (off < runlen && run[off]), with
off + adv > runlen β EINVAL straddle check
5. ntfs_subr.c:621 (decode loop) β same bound + per-field off + sz > runlen β EINVAL
6. Empty-run-list rejection: cnt == 0 β EINVAL
Minimal and targeted at the root cause (the missing bound). Does not change the on-disk format or the happy path for valid images.
This supersedes the finding markdown's sketch ("thread runlen parameter, bound both loops off<runlen") β same intent, expressed as a complete, tested implementation with proper error handling and diagnostics.
Fix validation (Phase 8) β VALIDATED
Built standalone ntfs.ko (make KERNCONF=X86_64_GENERIC in sys/vfs/ntfs/)
using the warm obj. 0 compile errors. Installed to /boot/kernel/ntfs.ko.
Before (unpatched ntfs.ko, kernel #0)
mount_ntfs ntfs_0789_isolated.img β MOUNT_RC=0 (mount SUCCEEDS β corrupt data accepted)
After (patched ntfs.ko, kernel #0 β only module rebuilt)
mount_ntfs ntfs_0789_isolated.img β mount_ntfs: /dev/vn0: Invalid argument MOUNT_RC=71
dmesg: ntfs_runtovrun: malformed run list at offset 20
ntfs_loadntnode: failed to load attr ino: 0
guest: UP, no panic
Regression check (clean NTFS image)
mount_ntfs ntfs_clean.img β CLEAN_MOUNT_RC=0 (mount succeeds; no regression)
Compound image note
The compound image (ntfs_0789.img) still panics on the patched kernel because
the sibling DF-0787 outer-walk bug is NOT fixed by this diff. The DF-0789 fix
closes ntfs_runtovrun's OOB walk (validated with the isolated image), but the
outer walk still advances past the record via off += reclen and reads OOB.
Applying DF-0787's fix in addition would close that path too.
PoC changes
harness.cβ userspace replication ofntfs_runtovrun:582-634against a guard-page-backed buffer. Modes:clean,oob_short,oob_fill,oob_infinite. Optionalapply_fixflag runs the proposed fixed walker.gen_ntfs_0789.pyβ crafted-image generator with two modes:compound(ntfs_0789.img): fills ino 0's entire MFT record with 0x11 (no terminator), triggering both DF-0789 and DF-0787.isolated(ntfs_0789_isolated.img): corrupts only the 24-byte run-list data within a properly-bounded attribute, isolating DF-0789 from DF-0787. Uses nonzero fixup replacement values (0x1111) so sector-boundary bytes don't introduce zeros into the run-list region.build.sh/run.shβ exact reproducible build & run.fix.diffβ standalone,git apply-able unified diff fixing the bug.
Fix verification
fixedVALIDATED. On the unpatched #0 kernel, the isolated image (ntfs_0789_isolated.img) mounts successfully (MOUNT_RC=0) -- ntfs_runtovrun silently accepts the corrupt 24-byte run list (0x22, no terminator) and returns SUCCESS with garbage run data. On the patched ntfs.ko (only the module rebuilt), the same image fails cleanly: mount_ntfs returns 'Invalid argument' (MOUNT_RC=71) and dmesg shows 'ntfs_runtovrun: malformed run list at offset 20' (the 5th 5-byte entry straddles runlen=24). Guest stays UP. Clean image regression check: CLEAN_MOUNT_RC=0 (no regression). NOTE: the compound image (ntfs_0789.img, which fills the entire record and also triggers sibling DF-0787's outer-walk OOB) still panics on the patched kernel because DF-0787 is NOT fixed by this diff -- the DF-0789 fix closes ntfs_runtovrun's inner OOB walk (validated with the isolated image), but the outer attribute walk in ntfs_loadntode still advances past the record via off+=reclen and reads OOB.
BEFORE (unpatched ntfs.ko): mount_ntfs ntfs_0789_isolated.img -> MOUNT_RC=0 (corrupt run list silently accepted, latent corruption). AFTER (patched ntfs.ko): mount_ntfs ntfs_0789_isolated.img -> 'Invalid argument' MOUNT_RC=71 / dmesg: ntfs_runtovrun: malformed run list at offset 20 / ntfs_loadntode: failed to load attr ino: 0. REGRESSION: mount_ntfs ntfs_clean.img -> CLEAN_MOUNT_RC=0.
Confirmed kernel references
Detail
Exploit chain
none -- this is a read-only/control-flow primitive (OOB read + potential infinite loop). There is no write to attacker-chosen kernel memory; the only writes are kernel-internal cn[]/cl[] arrays filled from OOB-read garbage. Per Phase 6, a pure read/loop primitive has no escalation chain to uid=0. The realistic impact ceiling is kernel panic / DoS (demonstrated: compound image panics at mount time) and latent corruption (isolated image: mount succeeds with corrupt MFT run data that would surface on non-system-node access). No uid=0 achievable.
Evidence (decisive lines)
HARNESS: mode=oob_fill apply_fix=0 -> rc=2 SIGSEGV (OOB read past run-list buffer); mode=oob_fill apply_fix=1 -> rc=-1 FIX REJECTED (EINVAL). LIVE PANIC: panic: NTFS vattr: malloc limit exceeded / _kmalloc+0xb09 / ntfs_attrtontvattr+0x35 / ntfs_loadntode+0x178 / ntfs_vgetex / ntfs_vget. ISOLATED IMAGE before: MOUNT_RC=0 (corrupt data accepted). ISOLATED IMAGE after fix: mount_ntfs: Invalid argument MOUNT_RC=71 / dmesg: ntfs_runtovrun: malformed run list at offset 20.
PoC changes
Created the entire evidence pack from scratch (finding had no prior poc folder). harness.c: userspace replication of ntfs_runtovrun:582-634 with PROT_NONE guard page, modes clean/oob_short/oob_fill/oob_infinite + apply_fix flag. gen_ntfs_0789.py: crafted NTFS image generator with two modes -- compound (fills ino 0's entire MFT record with 0x11, triggers DF-0789+DF-0787, uses nonzero fixup replacements 0x1111) and isolated (corrupts only 24-byte run-list within properly-bounded attribute with 0x22 fill, isolates DF-0789 from DF-0787). fix.diff: threads size_t runlen into ntfs_runtovrun, bounds both loops with off
Verified recommended fix
Thread a size_t runlen parameter into ntfs_runtovrun() (ntfs_subr.c:582, declaration at ntfs_subr.h:87). The caller ntfs_attrtontvattr() at :551 computes runlen = rap->a_hdr.reclen - rap->a_nr.a_dataoff (with underflow guard) and passes it. Both loops change from while(run[off]) to while(off
Verdict
REPRODUCED. The bug is real: ntfs_runtovrun() (sys/vfs/ntfs/ntfs_subr.c:582-634) takes NO length parameter and both loops (count at :595-598, decode at :605-629) walk run[off] until a zero byte with no bound check against the attribute's data-extent length. A crafted NTFS image with a non-resident attribute whose run list has no zero terminator causes an OOB read past the MFT record buffer (kmalloc(4096,M_TEMP)) into adjacent kernel heap. Confirmed by: (1) userspace guard-page harness -- oob_fill/oob_infinite modes produce SIGSEGV (rc=2) deterministically; (2) live mount of compound image ntfs_0789.img on default GENERIC #0 -- kernel panic 'NTFS vattr: malloc limit exceeded' through ntfs_attrtontvattr->ntfs_runtovrun->ntfs_loadntode at mount time (VFS_VGET NTFS_MFTINO=0); (3) live mount of isolated image ntfs_0789_isolated.img -- mount SUCCEEDS (MOUNT_RC=0) on unpatched kernel because ntfs_runtovrun silently accepts the corrupt run data. The disabled ntfs_parserun() at :1745-1780 shows the correct bounded pattern (takes len, checks at :1760/:1770).
No comments yet.