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

Off-by-one heap overflow in ntfs_ntlookupattr: NUL terminator written one byte past kmalloc(namelen)

Summary

ntfs_subr.c:826 (*attrname)=kmalloc(namelen). :827 memcpy(*attrname,name,namelen). :828 (*attrname)[namelen]=0 β€” index namelen one past end of allocation (valid 0..namelen-1). Single NUL-byte heap overflow into M_TEMP slab. namelen from user pathname with : separator (cnp->cn_namelen-fnamelen-1) attacker-controlled up to NAME_MAX. Trigger: stat /mnt/file:AAAA on ANY mounted NTFS volume no malicious image needed. Fix: kmalloc(namelen+1).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0786 Β· 13 files
FileTypeDescriptionSize
trigger.c trigger-source Guard-page harness + live NTFS trigger for the off-by-one 5.2 KB view raw
gen_ntfs.py trigger-source Python NTFS image generator (minimal valid NTFS volume) 15.3 KB view raw
ntfs.img test-data Generated 256KB NTFS image (mounts successfully) 256.0 KB ↓ download
build.sh build-script Build trigger + generate NTFS image 510 B view raw
run.sh run-script Run the harness or live trigger 348 B view raw
run_harness.log run-log Guard-page harness output: SIGSEGV at all namelen values 833 B view raw
panic.txt panic-signature Pre-existing NTFS lockmgr panic blocking live lookup 1.0 KB view raw
fix.diff suggested-fix kmalloc(namelen+1) β€” allocate room for NUL terminator 361 B view raw
fix_build.log build-log Single-fix kernel build output (35303 lines, rc=0) 5.6 MB ↓ download
env.txt environment Guest uname, compiler, sysctl state 640 B view raw
VERDICT.md verdict Full analysis: mechanism, impact, fix 6.1 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
VERDICT.md verdict Full analysis: mechanism, impact, fix
↓ download raw

DF-0786: Off-by-one heap overflow in ntfs_ntlookupattr

Verdict: REPRODUCED (off-by-one confirmed via source analysis + guard-page harness; live NTFS lookup blocked by pre-existing lockmgr panic)

The Bug

File: sys/vfs/ntfs/ntfs_subr.c:826-828
Function: ntfs_ntlookupattr()
CWE: CWE-787 Out-of-bounds Write

825:    if (namelen) {
826:        (*attrname) = kmalloc(namelen, M_TEMP, M_WAITOK);   // alloc exactly namelen bytes (valid: 0..namelen-1)
827:        memcpy((*attrname), name, namelen);                  // fills 0..namelen-1 (OK)
828:        (*attrname)[namelen] = '\0';                         // OFF-BY-ONE: writes at index namelen = 1 byte past end
829:    }

The allocation is exactly namelen bytes. The valid indices are 0 through namelen-1. Writing '\0' at index namelen is one byte past the end of the allocation β€” a classic off-by-one heap overflow.

Reachability

The trigger path is: 1. An NTFS volume is mounted (root mount threat model β€” vfs.usermount=0, root-only) 2. A file is looked up with a : in its name: stat /mnt/ntfs/existingfile:ATTRNAME 3. ntfs_lookup() β†’ ntfs_ntlookupfile() splits the name at :, extracting aname="ATTRNAME" with anamelen=strlen("ATTRNAME") 4. When the filename portion matches a directory index entry, ntfs_ntlookupattr(ntmp, aname, anamelen, ...) is called 5. The off-by-one fires: kmalloc(anamelen) + buf[anamelen]='\0'

Source trace: - sys/vfs/ntfs/ntfs_vnops.c:712 β€” ntfs_lookup calls ntfs_ntlookupfile - sys/vfs/ntfs/ntfs_subr.c:877-884 β€” name split at : β†’ aname/anamelen - sys/vfs/ntfs/ntfs_subr.c:924-927 β€” if (aname) ntfs_ntlookupattr(ntmp, aname, anamelen, ...) - sys/vfs/ntfs/ntfs_subr.c:826-828 β€” the off-by-one

The anamelen is attacker-controlled up to NAME_MAX (255) minus the filename length. A value of 8, 16, 32, 64, etc. (slab bucket boundaries) causes the NUL byte to overflow into the adjacent slab chunk.

Reproduction Method

1. Guard-page harness (deterministic proof)

trigger.c -h replicates the exact allocation logic with a guard page: - Places the buffer at the END of a writable page, followed by a PROT_NONE guard page - buf[namelen] provably falls into the guard page β†’ SIGSEGV - Tested at namelen=1,2,4,8,16,32 β€” ALL fault, proving the write is ALWAYS past the allocation

2. Live NTFS mount + trigger (blocked by pre-existing lockmgr panic)

A minimal valid NTFS image (gen_ntfs.py) was crafted that mounts successfully: - Boot sector, MFT records (ino 0-10), $UpCase, $AttrDef, $Bitmap, root directory with index entry "a" - mount_ntfs -o ro /dev/vnN /mnt/ntfs succeeds

However, stat /mnt/ntfs/a:AAAAAAAA panics BEFORE reaching the off-by-one:

panic: lockmgr: locking against itself
ntfs_ntlookupfile() at ntfs_ntlookupfile+0x57
ntfs_lookup() at ntfs_lookup+0x63

This is a pre-existing DragonFly NTFS locking bug β€” ntfs_ntget() tries to exclusively lock ip->i_lock when it is already held by the current thread. It affects ALL file lookups on mounted NTFS volumes in this kernel version (6.5-DEVELOPMENT #0), not just the off-by-one trigger. Both the lookup path (ntfs_ntlookupfile) and the readdir path (ntfs_ntreaddir) exhibit this panic.

Impact Assessment

On default GENERIC (INVARIANTS ON, use_weird_array=0, use_malloc_pattern=0):

  • The off-by-one produces SILENT heap corruption β€” no panic, no KASSERT
  • The slab allocator's INVARIANTS checks are bitmap-based (allocation status only, not content)
  • debug.use_weird_array=0 means freed chunks are NOT poisoned β†’ no content check on reallocation
  • The NUL byte (0x00) overwrites the first byte of the adjacent slab chunk

Slab bucket analysis:

  • kmalloc(namelen) rounds up to bucket sizes: 8, 16, 32, 64, 128, 256, 512, ...
  • When namelen equals a bucket boundary (e.g., 8), the NUL byte overflows into the NEXT chunk
  • When namelen is NOT a boundary (e.g., 7), the NUL byte lands in padding within the same chunk (still technically OOB but no adjacent-object corruption)

Escalation assessment:

  • Primitive: 1-byte NUL write at a slab-bucket boundary, content=0x00 (not attacker-controlled)
  • Preconditions: root-mounted NTFS volume (threat model: crafted image or existing NTFS)
  • Live reachability: BLOCKED by pre-existing lockmgr panic (separate DragonFly NTFS bug)
  • uid=0 assessment: Not achievable on this guest. The live NTFS lookup path is dead (lockmgr panic). Even if reachable, a 1-byte NUL write is an extremely weak primitive β€” zeroing the first byte of an adjacent object could theoretically corrupt a pointer's low byte, a refcount, or a flag, but converting this to privilege escalation would require: 1. Precise heap grooming to place a sensitive victim object adjacent 2. The victim's first byte being security-critical (uid low byte, function pointer low byte) 3. The zeroed value leading to a controllable condition None of these are achievable through the dead NTFS lookup path on this kernel.

Realistic impact ceiling:

  • Silent heap corruption on a mounted NTFS volume when a filename with :attrname is looked up
  • Potential DoS if the corrupted adjacent object causes a later crash
  • Theoretical privesc with extreme difficulty (weak primitive, dead lookup path)
  • Rated Medium by the finding (appropriate given the preconditions and weak primitive)

Fix

fix.diff: Change kmalloc(namelen, ...) to kmalloc(namelen + 1, ...) β€” allocate one extra byte for the NUL terminator.

This is a minimal, targeted fix at the root cause. The namelen + 1 allocation provides valid indices 0 through namelen, so buf[namelen] = '\0' writes within bounds.

PoC Changes

  • gen_ntfs.py β€” Python NTFS image generator (crafts a minimal valid NTFS volume that mounts successfully, with a root directory index entry for "a")
  • trigger.c β€” C program with two modes: guard-page harness (proves the off-by-one deterministically) and live NTFS trigger (attempts stat on mounted volume)
  • fix.diff β€” git-apply-able fix: kmalloc(namelen + 1, M_TEMP, M_WAITOK)
  • build.sh / run.sh β€” reproducible build and run scripts

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

NOT TESTABLE at runtime: the live NTFS lookup PoC cannot be exercised on the guest because the DragonFly NTFS lookup path panics with a pre-existing lockmgr error ('locking against myself' in ntfs_ntlookupfile->ntfs_ntget) before reaching the vulnerable ntfs_ntlookupattr code. The fix was validated at the code level: (1) fix.diff applies cleanly (patch -p1 succeeded, hunk #1 at line 823), (2) the single-fix kernel compiled successfully (make -j6 nativekernel rc=0, 35303-line build log), (3) the fixed kernel boots and runs (kern.version shows #1 with today's timestamp), (4) source inspection confirms the fix: line 826 now reads kmalloc(namelen + 1, M_TEMP, M_WAITOK), providing valid indices 0..namelen so buf[namelen]='\0' writes within bounds. The guard-page harness (trigger.c -h) proves the off-by-one exists in the original code logic; the fix changes that logic to eliminate it by construction.

Baseline (#0, unpatched) source:
  826: (*attrname) = kmalloc(namelen, M_TEMP, M_WAITOK);
  828: (*attrname)[namelen] = '\0';   // OFF-BY-ONE: namelen past allocation

Fixed (#1, patched) source:
  826: (*attrname) = kmalloc(namelen + 1, M_TEMP, M_WAITOK);
  828: (*attrname)[namelen] = '\0';   // now within bounds (0..namelen valid)

Guard-page harness (proves the off-by-one on unpatched logic):
  namelen=8: buf[8] = SIGSEGV (OOB WRITE CONFIRMED)

Fixed kernel build: rc=0 (35303 lines, NK_DONE)
Fixed kernel boot: DragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 05:52:07 UTC 2026
Live runtime test: BLOCKED by pre-existing NTFS lockmgr panic (not the off-by-one)
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Fri Jul 10 05:52:07 UTC 2026

Confirmed kernel references

Detail

Exploit chain

BLOCKED by valid hard blocker (dead code path): The live NTFS lookup path (ntfs_lookup -> ntfs_ntlookupfile -> ntfs_ntlookupattr) is unreachable on this kernel because ntfs_ntlookupfile+0x57 panics with 'lockmgr: locking against myself' in ntfs_ntget() before reaching the vulnerable code at line 826. This is a pre-existing DragonFly NTFS locking bug, not a limitation of the PoC. Even if the path were reachable, the primitive is extremely weak: a single NUL byte (0x00) written at a slab-bucket boundary (when namelen=8,16,32,...) into the adjacent slab chunk, with zero attacker control over the content. On default GENERIC (INVARIANTS ON, use_weird_array=0, use_malloc_pattern=0), this produces SILENT heap corruption with no panic or KASSERT -- the NUL byte overwrites the first byte of the adjacent object without detection. Converting a 1-byte NUL write to uid=0 would require: (1) precise heap grooming to place a security-critical victim adjacent (blocked by dead lookup path), (2) the victim's first byte being exploitable when zeroed. The dead code path is a valid hard blocker per Phase 6. The code-level harness (trigger.c) is the proof of the primitive.

Evidence (decisive lines)

Guard-page harness output (run as unprivileged maxx):
  namelen=  1: buf[1] = SIGSEGV (OOB WRITE CONFIRMED)
  namelen=  8: buf[8] = SIGSEGV (OOB WRITE CONFIRMED)
  namelen= 16: buf[16] = SIGSEGV (OOB WRITE CONFIRMED)
  namelen= 32: buf[32] = SIGSEGV (OOB WRITE CONFIRMED)

Source (ntfs_subr.c:826-828):
  (*attrname) = kmalloc(namelen, M_TEMP, M_WAITOK);
  memcpy((*attrname), name, namelen);
  (*attrname)[namelen] = '\0';   // OFF-BY-ONE

Live NTFS mount succeeds but lookup panics (pre-existing lockmgr bug):
  panic: lockmgr: locking against myself
  ntfs_ntlookupfile() at ntfs_ntlookupfile+0x57
  ntfs_lookup() at ntfs_lookup+0x63

PoC changes

Created the entire PoC evidence pack from scratch (finding had no existing PoC folder). Wrote: (1) gen_ntfs.py -- Python NTFS image generator that crafts a minimal valid NTFS volume (boot sector + 11 MFT records + $UpCase + $AttrDef + $Bitmap + root dir with index entry 'a'); the image mounts successfully via mount_ntfs. (2) trigger.c -- C program with guard-page harness mode (proves off-by-one via SIGSEGV at buf[namelen]) and live NTFS trigger mode. (3) fix.diff -- kmalloc(namelen+1). (4) VERDICT.md, build.sh, run.sh, manifest.json. The live NTFS lookup path panics due to a pre-existing lockmgr bug in ntfs_ntget, which is documented in panic.txt and VERDICT.md.

Verified recommended fix

Change line 826 of sys/vfs/ntfs/ntfs_subr.c from kmalloc(namelen, M_TEMP, M_WAITOK) to kmalloc(namelen + 1, M_TEMP, M_WAITOK). This allocates one extra byte for the NUL terminator written at buf[namelen], eliminating the off-by-one. The fix is in findings/poc/DF-0786/fix.diff and supersedes any finding proposal (no finding markdown existed).

Verdict

REPRODUCED. The off-by-one heap overflow in ntfs_ntlookupattr (sys/vfs/ntfs/ntfs_subr.c:826-828) is confirmed via source analysis and a deterministic guard-page harness. The code allocates exactly namelen bytes via kmalloc(namelen, M_TEMP, M_WAITOK) then writes a NUL terminator at index namelen -- one byte past the allocation boundary. The guard-page harness (trigger.c -h) places the buffer at a page boundary and demonstrates SIGSEGV at buf[namelen] for ALL tested sizes (1,2,4,8,16,32), proving the write is ALWAYS out of bounds. The live NTFS trigger path (mount crafted image + stat /mnt/ntfs/a:AAAAAAAA) was fully staged: a minimal valid NTFS image was crafted (gen_ntfs.py) that mounts successfully, but the DragonFly NTFS lookup path panics with 'lockmgr: locking against myself' in ntfs_ntlookupfile->ntfs_ntget BEFORE reaching the vulnerable ntfs_ntlookupattr code. This is a pre-existing DragonFly NTFS locking bug affecting ALL file lookups on mounted NTFS volumes in this kernel version, not specific to the off-by-one. The off-by-one is therefore confirmed at the code/logic level but not exerciseable through the live filesystem path on this kernel.