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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0789 Β· 17 files
FileTypeDescriptionSize
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
README.md readme build/run/expected + how to reproduce
↓ download 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_ntfs on ntfs_0789.img causes kernel hang (infinite loop into poisoned slab) or panic (OOB page fault). Guest dies.
  • Live (patched kernel): mount_ntfs returns Invalid argument (RC=71), guest stays UP.

Preconditions

  • mount_ntfs is root-only (vfs.usermount=0 on 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).
VERDICT.md verdict full narrative: mechanism, reachability, harness + live reproduction, fix validation
↓ download raw

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_fill with 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 of ntfs_runtovrun:582-634 against a guard-page-backed buffer. Modes: clean, oob_short, oob_fill, oob_infinite. Optional apply_fix flag 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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. 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.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel unchanged; only standalone ntfs.ko rebuilt with fix.diff and installed to /boot/kernel/ntfs.ko, sha256=e7a2eac3e2e136ab8be1430a9699d23d6f00b088ca593e91c91008dbe879fe83)

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 offrunlen->EINVAL, caller passes reclen-a_dataoff. build.sh/run.sh/README.md/VERDICT.md/manifest.json: full reproducible evidence pack.

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 runlen -> return EINVAL) mirroring the disabled ntfs_parserun() at :1760/:1770. Supersedes the finding markdown's sketch ('thread runlen parameter, bound both loops off<runlen') -- same intent, complete tested implementation.

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