Subnode dive in ntfs_ntlookupfile reads 8 bytes OOB when iep->reclen < sizeof(cn_t) or entry straddles buffer end
Summary
ntfs_subr.c:1007-1011 if(iep->ie_flag&NTFS_IEFLAG_SUBNODE){ cn=*(cn_t*)(rdbuf+aoff+iep->reclen-sizeof(cn_t)) }. aoff uint32 iep->reclen uint16 sizeof(cn_t)=8 size_t. reclen<8: subtraction underflows to ~SIZE_MAX rdbuf+huge OOB read. reclen large aoff+reclen>rdsize: 8-byte VCN read past rdbuf allocation. Walk loop :900-902 only guards rdsize>aoff (entry start) not rdsize>=aoff+iep->reclen (entry end). Same issue ntfs_ntreaddir:1176-1178. Trigger: crafted NTFS image B-tree index entry reclen=4 or reclen=0xFFFF. Fix: validate reclen>=sizeof(cn_t) and aoff+reclen<=rdsize before subnode dive.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0791 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | deterministic guard-page replication of the ntfs_ntlookupfile subnode dive (clean/oob/tiny x apply_fix) | 7.4 KB | view raw |
| gen_ntfs_0791.py | trigger-source | crafted NTFS image generator; root-dir INDEX_ROOT entry 'zzzzzz' SUBNODE reclen=0xFFFF | 7.2 KB | view raw |
| ntfs_0791.img | crafted-image | generated crafted NTFS image (256 KB) | 256.0 KB | β download |
| build.sh | build-script | cc -O2 -o harness harness.c | 207 B | view raw |
| run.sh | run-script | runs harness across modes with/without fix | 414 B | view raw |
| build.log | build-log | harness build output | 95 B | view raw |
| run.log | run-log | decisive harness run (oob SIGSEGV; fix EINVAL) | 1.3 KB | view raw |
| baseline_run.log | run-log | unpatched baseline: live stat sibling-blocked by lockmgr panic | 557 B | view raw |
| fix_build.log | fix-build-log | patched ntfs.ko build + disassembly of bounds check | 1.1 KB | view raw |
| fix_run.log | fix-run-log | patched ntfs.ko live test: dive still sibling-blocked | 1.2 KB | view raw |
| panic.txt | panic-signature | sibling lockmgr self-lock at ntfs_ntlookupfile+0x57 that blocks the live dive | 1.4 KB | view raw |
| env.txt | environment | uname, cc, ntfs.ko, vfs.usermount | 478 B | view raw |
| fix.diff | suggested-fix | validate reclen>=8 and aoff+reclen<=rdsize before the subnode VCN read | 978 B | view raw |
| VERDICT.md | verdict | full narrative: root cause, reachability, sibling-block, harness, fix validation | 8.6 KB | β raw |
| README.md | readme | summary + how to reproduce | 9.0 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-0791 β Subnode dive in ntfs_ntlookupfile reads 8 bytes OOB
Verdict: REPRODUCED (deterministic harness + source trace); FIX VALIDATED (harness + disassembly)
Status: reproduced
Impact: leak β 8-byte heap OOB read (CWE-125) of the index-entry subnode VCN
into a kernel-local cn_t (read-only primitive; no write, no escalation). The live
directory-lookup path that hosts the dive is sibling-blocked on this kernel by a
lockmgr: locking against itself panic that fires at the top of ntfs_ntlookupfile
(ntfs_ntget, +0x57) before the INDEX_ROOT walk / dive β so the dive's OOB read is
latent on the running kernel; the primitive is proven deterministically by the
guard-page harness (the accepted reproduction when a sibling panic blocks the live path).
Confidence: certain (source + disassembly + harness).
Severity: Medium (matches finding).
The bug (confirmed by source trace)
File: sys/vfs/ntfs/ntfs_subr.c:1006-1011 (function ntfs_ntlookupfile)
888: rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK); // blsize = ir_size
...
900: for (; !(iep->ie_flag & NTFS_IEFLAG_LAST) && (rdsize > aoff);
901: aoff += iep->reclen, // <-- only entry-START guarded
902: iep = (struct attr_indexentry *) (rdbuf + aoff))
{ ... NTFS_UASTRICMP ... if (res > 0) break; ... }
1006: /* Dive if possible */
1007: if (iep->ie_flag & NTFS_IEFLAG_SUBNODE) {
1010: cn = *(cn_t *) (rdbuf + aoff + // aoff = u_int32_t
1011: iep->reclen - sizeof(cn_t)); // reclen = u_int16_t off disk
// sizeof(cn_t) = 8 (u_int64_t)
... ntfs_readattr(... ntfs_cntob(cn) ...) ...
iep->reclen is a raw u_int16_t read straight off disk with no bounds
validation before the subnode-VCN dereference. The walk loop (:900-902) only
guards the entry start (rdsize > aoff), never the entry end or the
trailing 8-byte VCN. Two malformed shapes:
| Shape | Crafted reclen |
Effect |
|---|---|---|
| overshoot | large (e.g. 0xFFFF) |
aoff+reclen-8 blows past rdbuf β 8-byte heap OOB read into cn (CWE-125). With 0xFFFF the read lands ~65 KB past a 4 KB buffer. |
| under-sized | < 8 |
the finding's summary claims a size_t underflow; in practice aoff >= sizeof(attr_indexroot)=32, so aoff+reclen-8 stays >= 0 and reads a wrong but in-bounds offset (a semantic bug, not OOB). The harness confirms tiny (reclen=2) reads in-bounds. The real OOB is the overshoot. |
The leaked bytes land in the kernel-local cn, then used as a cluster offset
(ntfs_cntob(cn)) for the next ntfs_readattr β i.e. the OOB read influences
control flow / disk reads, it is not directly exfiltrated to userspace.
Reachability
ntfs_ntlookupfile is reached from ntfs_lookup (sys/vfs/ntfs/ntfs_vnops.c:712,
vop_old_lookup) on any name lookup inside an NTFS directory:
stat /mnt/ntfs/<name> -> namei -> VOP_LOOKUP(rootdir) -> ntfs_lookup
-> ntfs_ntlookupfile -> INDEX_ROOT walk -> (res>0) break -> SUBNODE dive
ntfs_ntlookupattr (DF-0786's path) is not entered for a plain name lookup
(no :attr spec), so DF-0786 does not gate this path.
However, on this guest kernel a sibling lockmgr self-lock fires first:
stat/lookup of any name in a directory whose INDEX_ROOT holds a malformed entry
panics with panic: lockmgr: locking against itself at
ntfs_ntlookupfile+0x57 β disassembly shows +0x57 is the inlined
ntfs_ntget β LOCKMGR(&ip->i_lock, LK_EXCLUSIVE) (the very first action of the
function, addl $0x1,0x70(%r14) usecount++ then lea 0x48(%r14),%rdi =
&i_lock, then callq lockmgr), which is before the ntfs_ntvattrget(INDEX_ROOT)
at +0x75 and long before the dive. The dive is therefore latent on the live
kernel. (A clean-image lookup does not panic, so the self-lock is triggered by the
malformed-image node state; it is a separate NTFS directory-lookup defect, not
DF-0791.) Per the run brief, a deterministic code-level harness is the accepted
reproduction when a sibling panic blocks the live path.
Reproduction β deterministic harness (harness.c)
Mirrors the exact kernel walk + dive against a buffer placed at the end of a
writable page immediately before a PROT_NONE guard page, so any overshoot faults
deterministically (SIGSEGV):
=== BUGGY dive (kernel behaviour on default GENERIC #0) === mode=clean apply_fix=0 -> rc=0 dive read OK, cn=0xaaaa007a007a007a (in bounds) mode=oob apply_fix=0 -> rc=2 SIGSEGV -> OOB READ past rdbuf (dive offset 65559 >= rdsize 128) mode=tiny apply_fix=0 -> rc=0 dive read OK (reclen=2 -> offset 26, in bounds: not OOB) === FIXED dive (proposed fix rejects malformed entries) === mode=clean apply_fix=0 -> rc=0 dive read OK (in bounds) mode=oob apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=65535) -> EINVAL in kernel mode=tiny apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=2) -> EINVAL in kernel
oob(reclen=0xFFFF) β SIGSEGV: the dive reads 65 KB past the buffer. Proves the OOB read.tiny(reclen=2) β reads in-bounds at offset 26: the "reclen<8 underflow" variant is not an OOB givenaoff>=32; the fix still rejects it (reclen < sizeof(cn_t)).- With
apply_fix=1, both malformed shapes are rejected with EINVAL before the dereference.
Escalation assessment (no chain β read-only primitive)
This is a pure read (CWE-125). No write to attacker-chosen kernel memory: the only
effect is reading adjacent heap into a cn_t used as a disk offset. Per Phase 6, a
read-only primitive has no escalation chain to uid=0; the correct deliverable is
the characterized impact ceiling: heap info-leak / influence-on-control-flow, and DoS
when the OOB-derived offset faults. No uid0 chain is applicable.
Fix β fix.diff
Adds a bounds check inside the SUBNODE dive, before the VCN dereference:
iep->reclen >= sizeof(cn_t)β the trailing 8-byte VCN must fit inside the entry.aoff <= rdsize && iep->reclen <= rdsize - aoffβ the entry (incl. VCN) must fit inside the valid data region (overflow-safe: checked via subtraction). Sincerdsize <= blsizefor sane images this also keeps the read inside thekmalloc(blsize)allocation.- On violation:
error = EINVAL,kprintfa diagnostic,goto fail(clean return;rdbuffreed by the existingfail:label).
Fix validation (Phase 8)
NTFS is optional ntfs (sys/conf/files) β ships as /boot/kernel/ntfs.ko, a loadable
module, not compiled into GENERIC. So the fix only required rebuilding ntfs.ko
(KERNCONF=X86_64_GENERIC make in sys/vfs/ntfs/, warm obj) and installing it β no
kernel rebuild or reboot.
- Compiles:
makerc=0; 43 text symbols (same as stock). - Bounds check present: disassembly of the patched
ntfs_ntlookupfileshows, before the cn read, three checks all branching to the EINVAL path (0x16):cmp %edi,-0x7c(%rbp); jb(aoff>rdsize),cmp $0x7,%ax; jbe(reclen<8),cmp %edx,%esi; ja(reclen>rdsize-aoff); the error path doesmov $0x16,%r12d(EINVAL) +kprintf("...malformed index entry...")+ goto fail. The stringntfs_ntlookupfile: malformed index entry (reclen %u, aoff %u, rdsize %u)is in the patched module. - Harness before/after: unpatched logic β OOB read (SIGSEGV); fixed logic β EINVAL (no OOB read). Clean before/after at the code/harness level.
- Live dive path: sibling-blocked (the lockmgr self-lock at
+0x57fires before the dive on both the unpatched and patched kernels, so a live before/after of the dive itself is not exercisable on this guest). The fix is therefore validated at the harness + disassembly level; it demonstrably closes the vulnerable code path.
Files
harness.cβ deterministic guard-page replication of the dive read (clean/oob/tiny Γ fix).gen_ntfs_0791.pyβ crafted NTFS image generator (extends DF-0786gen_ntfs.py); root-dir (ino 5) INDEX_ROOT holds one SUBNODE entry "zzzzzz" withreclen=0xFFFF.ntfs_0791.imgβ generated crafted image (256 KB).build.sh/run.shβ exact build/run.fix.diffβ standalonegit apply-able fix.build.log,run.log,fix_build.log,fix_run.log,panic.txt,env.txt,manifest.json,VERDICT.md.
How to reproduce
# 1. deterministic harness (no root needed)
scp -F dfbsd-qemu/config -q findings/poc/DF-0791/{harness.c,build.sh,run.sh} dfbsd-maxx:poc/DF-0791/
ssh -F dfbsd-qemu/config dfbsd-maxx 'cd poc/DF-0791 && sh build.sh && sh run.sh'
# expect: mode=oob apply_fix=0 -> SIGSEGV (OOB READ); apply_fix=1 -> EINVAL
# 2. fix validation (rebuild ntfs.ko module, install, confirm bounds check)
scp -F dfbsd-qemu/config -q findings/poc/DF-0791/fix.diff dfbsd:/root/
ssh -F dfbsd-qemu/config dfbsd 'cd /usr/src && patch -p1 < /root/fix.diff && \
cd sys/vfs/ntfs && KERNCONF=X86_64_GENERIC make && cp ntfs.ko /boot/kernel/ntfs.ko'
strings /boot/kernel/ntfs.ko | grep "malformed index" # confirm fix present
DF-0791 β VERDICT
Verdict: REPRODUCED (deterministic harness + source trace + disassembly); FIX VALIDATED (harness + disassembly)
| field | value |
|---|---|
| status | reproduced |
| reproduced | true (primitive proven deterministically; live dive path sibling-blocked) |
| impact | leak β 8-byte heap OOB read (CWE-125) of the subnode VCN into a kernel-local cn_t |
| confidence | certain |
| severity | Medium (matches finding) |
1. Root cause (confirmed by source trace)
ntfs_ntlookupfile() in sys/vfs/ntfs/ntfs_subr.c walks an NTFS directory's
B-tree index looking up a name. When a comparison indicates the target name
sorts after the current entry (NTFS_UASTRICMP > 0 β break), the code checks
whether that entry has a subnode (NTFS_IEFLAG_SUBNODE) and, if so, reads the
child pointer (a cn_t = u_int64_t, 8 bytes) stored in the last 8 bytes of
the entry:
/* sys/vfs/ntfs/ntfs_subr.c */
888: rdbuf = kmalloc(blsize, M_TEMP, M_WAITOK); /* blsize = ir_size */
...
900: for (; !(iep->ie_flag & NTFS_IEFLAG_LAST) && (rdsize > aoff);
901: aoff += iep->reclen,
902: iep = (struct attr_indexentry *) (rdbuf + aoff))
{ ... if (res > 0) break; ... }
1006: /* Dive if possible */
1007: if (iep->ie_flag & NTFS_IEFLAG_SUBNODE) {
1010: cn = *(cn_t *) (rdbuf + aoff + iep->reclen - sizeof(cn_t));
1012: rdsize = blsize;
1014: error = ntfs_readattr(ntmp, ip, NTFS_A_INDX, "$I30",
1015: ntfs_cntob(cn), rdsize, rdbuf, NULL);
iep->reclen is a raw u_int16_t read directly off the crafted image
(sys/vfs/ntfs/ntfs.h:173) with zero validation before the dereference. The
walk-loop guard (:900, rdsize > aoff) only checks the entry start is
in-bounds; it never checks the entry end (aoff + reclen) or the trailing
8-byte VCN. Consequences:
- Overshoot (
reclenlarge, e.g.0xFFFF):aoff + reclen - 8lands far pastrdbuf(akmalloc(blsize)allocation) β 8-byte heap OOB read (CWE-125). Withreclen=0xFFFFthe read is ~65 KB past a 4 KB buffer. - Under-sized (
reclen < 8): the finding summary speculates asize_tunderflow to ~SIZE_MAX. In the INDEX_ROOT walkaoffstarts atsizeof(struct attr_indexroot) = 32and only grows, soaoff+reclen-8stays>= 0and reads a wrong but in-bounds offset (a semantic bug, not OOB). The harness confirmstiny(reclen=2) reads in-bounds β the real OOB is the overshoot.
The leaked 8 bytes land in the kernel-local cn, used as
ntfs_cntob(cn) (a cluster offset) for the following ntfs_readattr. So the
read influences control flow / disk reads; it is not directly exfiltrated to
userspace. No write primitive β no escalation chain (read-only class).
2. Reachability
ntfs_ntlookupfile is the vop_old_lookup implementation, reached from
ntfs_lookup (sys/vfs/ntfs/ntfs_vnops.c:712) on any name lookup inside an
NTFS directory. ntfs_ntlookupattr (DF-0786's path) is entered only for an
attribute-qualified name (name:attr), so a plain stat /mnt/ntfs/<name> does
not hit DF-0786. Threat model: a root-mountable crafted NTFS image
(mount_ntfs is SYSCAP_RESTRICTEDROOT, vfs.usermount=0 verified) β the
standard filesystem-image model (admin mounts / makes mountable an untrusted
image; the lookup itself is unprivileged).
3. Live-path assessment β sibling-blocked
On this guest kernel (6.5-DEVELOPMENT #0), a sibling lockmgr self-lock
fires at the very top of ntfs_ntlookupfile, before the INDEX_ROOT walk and
the dive:
panic: lockmgr: locking against itself lockmgr_exclusive() at lockmgr_exclusive+0x3e0 ntfs_ntlookupfile() at ntfs_ntlookupfile+0x57 ; <- inlined ntfs_ntget ntfs_lookup() at ntfs_lookup+0x63
Disassembly (ntfs.ko, ntfs_ntlookupfile @ 0x4bd0) pins +0x57 to the
inlined ntfs_ntget:
4c15: addl $0x1,0x70(%r14) ; ip->i_usecount++ 4c1a: lea 0x48(%r14),%rdi ; &ip->i_lock 4c22: callq 4c27 ; -> lockmgr(LK_EXCLUSIVE) [+0x57] 4c40: callq (ntfs_ntvattrget, NTFS_A_INDXROOT=0x90) ; [+0x75] INDEX_ROOT fetch is AFTER
So the self-lock happens at the first LOCKMGR(&ip->i_lock, LK_EXCLUSIVE), before
the INDEX_ROOT is even fetched β long before the dive at +0x2c0. The dive (and
DF-0791's OOB read) is therefore latent on the running kernel. It reproducibly
fires for the malformed image (3/3 patched attempts, plus unpatched attempts); a
clean-image lookup does not panic, confirming the self-lock is a separate
malformed-image/node-state directory-lookup defect, not DF-0791.
Per the run brief: "If a sibling panic blocks the live path, a deterministic code-level harness reproducing the unvalidated subnode-pointer read is acceptable." β the harness below is that reproduction.
4. Reproduction β deterministic harness (harness.c)
Mirrors the exact kernel walk + dive against a buffer placed at the end of a
writable page immediately before a PROT_NONE guard page, so any overshoot faults
deterministically (SIGSEGV). Output (unprivileged maxx):
=== BUGGY dive (kernel behaviour on default GENERIC #0) === mode=clean apply_fix=0 -> rc=0 dive read OK, cn=0xaaaa007a007a007a (in bounds) mode=oob apply_fix=0 -> rc=2 SIGSEGV -> OOB READ past rdbuf (offset 65559 >= rdsize 128) mode=tiny apply_fix=0 -> rc=0 dive read OK (reclen=2 -> offset 26, in bounds: not OOB) === FIXED dive (proposed fix rejects malformed entries) === mode=oob apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=65535) -> EINVAL mode=tiny apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=2) -> EINVAL
oob(reclen=0xFFFF) β SIGSEGV: the dive reads 65 KB past the buffer. Proves the OOB read.tiny(reclen=2) β reads in-bounds: the "reclen<8 underflow" variant is not OOB givenaoff>=32; the fix still rejects it (reclen < sizeof(cn_t)).apply_fix=1rejects both malformed shapes with EINVAL before the dereference.
5. Escalation assessment
Pure read (CWE-125). No write to attacker-chosen kernel memory. Per Phase 6 a
read-only primitive has no escalation chain to uid=0; the deliverable is the
characterized ceiling: heap info-leak / control-flow influence / DoS when the
OOB-derived offset faults. No uid0 chain is applicable (valid hard blocker:
read-only primitive).
6. Fix β fix.diff
Adds a bounds check inside the SUBNODE dive, before the VCN dereference:
iep->reclen >= sizeof(cn_t)β the trailing 8-byte VCN fits inside the entry.aoff <= rdsize && iep->reclen <= rdsize - aoffβ the entry (incl. VCN) fits inside the valid data region (overflow-safe via subtraction; sincerdsize <= blsizefor sane images this also keeps the read inside thekmalloc(blsize)allocation).- On violation:
error = EINVAL,kprintfdiagnostic,goto fail.
Minimal and targeted at the root cause (the missing bounds check). Does not touch the lockmgr sibling bug, the on-disk format, or the happy path for valid images.
7. Fix validation (Phase 8)
NTFS is optional ntfs (sys/conf/files) β /boot/kernel/ntfs.ko, a loadable
module (not in GENERIC). Fix = rebuild ntfs.ko only (no kernel rebuild/reboot).
- Applies + compiles:
patch -p1hunk succeeded at line 1007;makerc=0; 43 text symbols (same as stock). - Bounds check present (disassembly): before the cn read, three checks all
branch to the EINVAL path (
mov $0x16,%r12d= EINVAL 22) +kprintf:cmp %edi,-0x7c(%rbp); jb(aoff>rdsize),cmp $0x7,%ax; jbe(reclen<8),cmp %edx,%esi; ja(reclen>rdsize-aoff). Stringntfs_ntlookupfile: malformed index entry (reclen %u, aoff %u, rdsize %u)present. - Harness before/after: unpatched logic β OOB read (SIGSEGV); fixed logic β EINVAL (no OOB read). Clean before/after at the code/harness level.
- Live dive path: the sibling lockmgr self-lock fires at
+0x57on both unpatched and patched kernels (same panic, same offset, verified on the patched module at0x4a60+0x57=0x4ab7), so a live before/after of the dive itself is not exercisable on this guest. The fix is validated at the harness + disassembly level; it demonstrably closes the vulnerable code path.
8. PoC changes
Authored from scratch (no prior PoC existed for DF-0791):
- harness.c β deterministic guard-page replication of the dive (clean/oob/tiny Γ fix).
- gen_ntfs_0791.py β crafted NTFS image generator extending DF-0786's
gen_ntfs.py; root-dir (ino 5) INDEX_ROOT holds one SUBNODE entry "zzzzzz"
with reclen=0xFFFF.
- ntfs_0791.img β generated crafted image.
- build.sh / run.sh β exact build/run.
- fix.diff β standalone git apply-able fix at the root cause.
Fix verification
fixedVALIDATED at the harness + disassembly level. The bug is a read-only OOB whose live directory-lookup path is sibling-blocked by a lockmgr self-lock panic at ntfs_ntlookupfile+0x57 (inlined ntfs_ntget) that fires BEFORE the dive on BOTH the unpatched and patched kernels (verified: patched module at 0x4a60+0x57=0x4ab7 panics identically, 3/3 attempts). So a live before/after of the dive itself is not exercisable on this guest, and the deterministic harness is the test vehicle. The patched ntfs.ko: (a) applies + compiles (make rc=0, 43 text symbols), (b) the bounds check is confirmed compiled in before the cn read by disassembly (cmp %edi,-0x7c; jb / cmp $0x7,%ax; jbe / cmp %edx,%esi; ja, all -> EINVAL path 'mov $0x16,%r12d' + kprintf 'ntfs_ntlookupfile: malformed index entry'), (c) the harness shows a clean before/after: unpatched logic -> OOB read SIGSEGV (mode=oob rc=2); fixed logic -> EINVAL (mode=oob apply_fix=1 rc=-1). The fix demonstrably closes the vulnerable code path.
BEFORE (unpatched harness): mode=oob apply_fix=0 -> rc=2 SIGSEGV -> OOB READ past rdbuf (dive offset 65559 >= rdsize 128) AFTER (fixed harness): mode=oob apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=65535) -> EINVAL in kernel BEFORE (unpatched, tiny): mode=tiny apply_fix=0 -> rc=0 (reads in-bounds; not OOB) AFTER (fixed, tiny): mode=tiny apply_fix=1 -> rc=-1 FIX REJECTED (reclen=2 < 8) -> EINVAL Disassembly of patched ntfs_ntlookupfile: bounds checks (aoff>rdsize / reclen<8 / reclen>rdsize-aoff) precede the cn read; failure -> EINVAL(0x16)+kprintf+goto fail.
Confirmed kernel references
Detail
Exploit chain
none (read-only primitive). DF-0791 is a pure 8-byte heap OOB READ (CWE-125): the leaked bytes land in a kernel-local cn_t used as ntfs_cntob(cn) (a cluster offset) for the following ntfs_readattr -- i.e. the read influences control flow / disk reads, it is NOT directly exfiltrated to userspace and is NOT a write. Per Phase 6 a read-only primitive has no escalation chain to uid=0; the valid deliverable is the characterized impact ceiling (heap info-leak / control-flow influence; DoS when the OOB-derived offset faults). No chain file authored (not applicable for a read-only class).
Evidence (decisive lines)
=== BUGGY dive === mode=clean apply_fix=0 -> rc=0 dive read OK, cn=0xaaaa007a007a007a (in bounds) mode=oob apply_fix=0 -> rc=2 SIGSEGV -> OOB READ past rdbuf (dive offset 65559 >= rdsize 128) mode=tiny apply_fix=0 -> rc=0 dive read OK (reclen=2 -> offset 26, in bounds: NOT OOB) === FIXED dive === mode=oob apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=65535) -> EINVAL in kernel mode=tiny apply_fix=1 -> rc=-1 FIX REJECTED malformed entry (reclen=2) -> EINVAL in kernel --- live (sibling-blocked) --- panic: lockmgr: locking against itself | ntfs_ntlookupfile() at ntfs_ntlookupfile+0x57 (inlined ntfs_ntget, BEFORE the dive at +0x2c0) | ntfs_lookup+0x63
PoC changes
Authored the full evidence pack from scratch (no prior DF-0791 PoC existed): harness.c (deterministic guard-page replication of the walk+dive with clean/oob/tiny modes and an apply_fix flag for direct before/after), gen_ntfs_0791.py (crafted NTFS image generator extending DF-0786's gen_ntfs.py; root-dir ino-5 INDEX_ROOT holds one SUBNODE entry 'zzzzzz' with reclen=0xFFFF), ntfs_0791.img (generated), build.sh/run.sh, VERDICT.md, README.md, manifest.json, and fix.diff. The harness models the exact kernel arithmetic (rdbuf+aoff+reclen-sizeof(cn_t)) and proved the overshoot is a genuine OOB read while the under-sized-reclen variant is not (aoff>=32).
Verified recommended fix
In ntfs_ntlookupfile's SUBNODE dive (sys/vfs/ntfs/ntfs_subr.c:1007), before cn=(cn_t)(rdbuf+aoff+iep->reclen-sizeof(cn_t)), validate the entry+VCN fits in the valid data: reject (error=EINVAL, kprintf diagnostic, goto fail) if iep->reclen < sizeof(cn_t) OR aoff > rdsize OR (u_int32_t)iep->reclen > rdsize - aoff (overflow-safe). matches finding proposal (the DB summary proposed 'validate reclen>=sizeof(cn_t) and aoff+reclen<=rdsize before subnode dive'). The full git-apply-able diff is in findings/poc/DF-0791/fix.diff. Note: ntfs_ntreaddir:1176 (also cited by the finding) has the same weak loop guard (rdsize>aoff, entry-start only) but does NOT perform the cn dereference, so it is a lesser hardening gap, not the same OOB read.
Verdict
REPRODUCED. The bug is real: ntfs_ntlookupfile's B-tree subnode dive at sys/vfs/ntfs/ntfs_subr.c:1010-1011 reads cn=(cn_t)(rdbuf+aoff+iep->reclen-8) where iep->reclen is a raw u_int16_t read straight off a crafted image with NO bounds validation, and the walk loop (:900) only guards the entry START (rdsize>aoff), never the entry END or the trailing 8-byte VCN. A crafted INDEX_ROOT entry with NTFS_IEFLAG_SUBNODE + reclen=0xFFFF makes the dive read ~65KB past the kmalloc(blsize) buffer (8-byte heap OOB read, CWE-125). Confirmed three ways: (1) source trace (no guard before the deref), (2) disassembly of ntfs.ko (the cn read has no preceding bounds check; the inlined ntfs_ntget/lockmgr is at +0x57, the cn read is at +0x2c0), and (3) a deterministic guard-page harness that SIGSEGVs on the OOB dive read. NOTE on the live kernel: the directory-lookup path that hosts the dive is sibling-blocked -- a lockmgr 'locking against itself' panic fires at ntfs_ntlookupfile+0x57 (the inlined ntfs_ntget, BEFORE the INDEX_ROOT fetch at +0x75 and long before the dive), reproducibly (3/3 patched + several unpatched attempts) for the malformed image; a clean-image lookup does not panic, confirming the self-lock is a separate malformed-image node-state directory-lookup defect, not DF-0791. Per the run brief, the deterministic harness is the accepted reproduction when a sibling panic blocks the live path. The finding's 'reclen<sizeof(cn_t) underflow' variant is technically inaccurate: aoff starts at sizeof(attr_indexroot)=32 and only grows, so aoff+reclen-8 stays in-bounds for small reclen (harness confirms tiny/reclen=2 reads in-bounds at offset 26 -- a semantic bug, not OOB); the genuine OOB is the overshoot (large reclen).
No comments yet.