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

hpfs_genlookupbyname walks attacker dirent chain with no buffer bound, no reclen validation, and no DE_DOWN cycle guard

Field Value
ID DF-0927
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:H/I:N/A:H
CWE CWE-125 Out-of-bounds Read; CWE-835 Loop with Unreachable Exit Condition; CWE-400 Uncontrolled Resource Consumption
File sys/vfs/hpfs/hpfs_lookup.c
Lines 73-102
Area vfs
Confidence certain
Discovered 2026-07-05
Reported pending
Known CVE none
CVE match dfly_specific

Summary

hpfs_genlookupbyname walks the on-disk HPFS directory B-tree inside a single bread'd 2048-byte buffer (D_BSIZE) but never validates that the walking pointer stays inside that buffer. Every step advances dep by dep->de_reclen, a u_int16_t read straight from the attacker-controlled image, with no minimum, no upper bound, and no check that the chain terminates within D_BSIZE. The DE_DOWN descent (the dive loop) has no depth counter and no cycle detection, unlike its siblings hpfs_readdir and hpfs_validateparent. The result is a kernel heap out-of-bounds read (panic or info leak) and two distinct infinite-loop local DoS vectors, all triggered by a crafted HPFS image at lookup/readdir time.

Root cause

Entry: hpfs_lookup (hpfs_vnops.c:962) β†’ hpfs_genlookupbyname (hpfs_lookup.c:55). The initial directory-block LSN is taken from the already-loaded fnode with no validation: lsn = ((alleaf_t *)dhp->h_fn.fn_abd)->al_lsn (hpfs_lookup.c:73). hpfs_breaddirblk (hpfs_subr.h:78 β†’ hpfs_breadstruct, hpfs_subr.c:835) reads exactly D_BSIZE = DEV_BSIZE*4 = 2048 bytes (hpfs.h:133, param.h:114-115) and checks only the 4-byte magic; it does NOT validate d_freeoff, de_reclen, de_namelen, or the dirent chain.

The traversal loop (hpfs_lookup.c:82-97) is:

while(!(dep->de_flag & DE_END)) {
    ...
    res = hpfs_cmpfname(hpmp, name, namelen,
                        dep->de_name, dep->de_namelen, dep->de_cpid);   /* :87-88 */
    if (res == 0) { *bpp = bp; *depp = dep; return (0); }
    else if (res < 0) break;

    dep = (hpfsdirent_t *)(((caddr_t)dep) + dep->de_reclen);            /* :96 */
}

There is no bound of (caddr_t)dep against (caddr_t)dp + D_BSIZE. dep->de_reclen (hpfs.h:117, u_int16_t), dep->de_flag, dep->de_namelen (hpfs.h:127, u_int8_t), and dep->de_name[] are all read directly from the 2048-byte attacker buffer. Therefore:

  • (a) OOB heap read. If the chain's cumulative reclen, or a single de_reclen (e.g. 0xFFFF), pushes dep past bp->b_data + 2048, the next evaluation of dep->de_flag and the hpfs_cmpfname read of dep->de_name[0..de_namelen-1] dereference kernel memory beyond the buffer. A single first entry with de_reclen=0xFFFF (and de_flag without DE_END) jumps ~64 KiB past the buffer on the very first step.
  • (b) DE_DOWNLSN underflow. After the loop, lsn = DE_DOWNLSN(dep) at line 100 expands (hpfs.h:114) to *(lsn_t*)((caddr_t)dep + dep->de_reclen - sizeof(lsn_t)). If de_reclen < 4, the unsigned subtraction wraps to a huge offset and the lsn read is a far OOB read; dep itself may already be OOB from (a).
  • (c) Infinite loop #1 (de_reclen == 0). If the on-disk de_reclen is 0, line 96 does not advance dep, so if the entry's name sorts before the lookup target (hpfs_cmpfname returns >0) and DE_END is clear, the while-loop spins on the same entry forever β€” a kernel lockup/hang.
  • (d) Infinite loop #2 (DE_DOWN cycle). The dive loop (lines 74, 99-102) follows DE_DOWNLSN(dep) to a new block and re-enters dive with no level/depth counter and no visited set. I confirmed via grep that hpfs_readdir (hpfs_vnops.c:776,821,886,913,922-927) and hpfs_validateparent (hpfs_subr.c:532,549,601,616,624-627) both carry an int level to bound descent, but hpfs_genlookupbyname carries none. Two dirblks whose DE_DOWN pointers reference each other (Aβ†’B, Bβ†’A) make the kernel bread the same two blocks forever β€” a hard hang that also burns I/O.

The attacker fully controls the on-disk dirblk bytes, so all four conditions are trivially constructible. hpfs_cmpfname (hpfs_subr.c:168-186) reads dep->de_name byte-by-byte up to dep->de_namelen with no bound against the buffer either, so once dep is near/off the end the OOB read is amplified by de_namelen bytes.

Threat model & preconditions

  • Attacker position: Anyone who can cause a crafted HPFS image to be mounted β€” directly via mount_hpfs (typically root-gated) or, more commonly, via auto-mounting of attacker-supplied removable media (USB), vfs.usermount per-user grants, jail/multi-tenant images, or kiosk-style systems that mount user-supplied media. The audit's filesystem-image threat model treats on-disk fields as malicious.
  • Privileges gained or impact: 1. Kernel heap OOB read β€” best case kernel page-fault panic (denial of service, A:H); worst case the OOB bytes are returned to userspace via the readdir/lookup name-copy paths (hpfs_vnops.c:745-747 hpfs_de_uiomove copies dep->de_name to a uio; hpfs_vnops.c:1068-1070 bcopys dep->de_name into hp->h_name later exposed via getattr/readdir), yielding a kernel memory disclosure (C:H). 2. Two deterministic infinite-loop paths (de_reclen==0 and DE_DOWN cycle) hang the calling kernel thread and, because the vnode/lookup path holds locks, can wedge the filesystem/VFS layer (A:H).
  • Required config or capabilities: A mounted HPFS image.
  • Reachability: ls/stat/open/readdir on any name in the crafted directory reaches hpfs_lookup β†’ hpfs_genlookupbyname.

Proof of concept

PoC source: findings/poc/DF-0927/

Build & run

# 1. Start from a tiny valid HPFS image (created with an hpfs formatter
#    under a Linux/Windows VM, or use the prebuilt base.hpfs shipped in
#    the evidence pack).
python3 mkimg.py base.hpfs evil.hpfs        # Variant A: de_reclen=0xFFFF
# python3 mkimg.py --hang1 base.hpfs hang1.hpfs   # Variant B: de_reclen=0
# python3 mkimg.py --cycle base.hpfs cycle.hpfs   # Variant C: DE_DOWN cycle

# 2. Mount and trigger (root to mount; any user can stat):
vnconfig -c vn0 evil.hpfs
mount -t hpfs /dev/vn0 /mnt
stat /mnt/zzz

Expected output

  • Variant A (OOB read): immediate kernel page-fault panic in hpfs_genlookupbyname reading dep->de_flag far past bp->b_data (panic.txt).
  • Variant B (hang #1): stat never returns; serial console / NMI / ddb backtrace shows pc inside hpfs_genlookupbyname:96.
  • Variant C (hang #2): stat never returns; backtrace shows the kernel bouncing between bread(D0) and bread(D1) forever.

Impact

  • OOB read: kernel panic (reliable DoS) or kernel heap disclosure (info leak if the OOB bytes flow to userspace through readdir/getattr name copies).
  • Two hang variants: deterministic local DoS that wedges the kernel thread (and the VFS layer if it holds relevant locks).
  • All triggered by a crafted image plus a single stat/ls on the malicious directory.

Bound the dirent cursor to the bread'd buffer, enforce a sane non-zero minimum on de_reclen, validate de_namelen against de_reclen, validate the DE_END terminator is in-buffer before trusting dep->de_flag at line 99, and add a depth cap on the dive loop to defeat DE_DOWN cycles. Concretely, replace the body of hpfs_genlookupbyname (hpfs_lookup.c:55-108) with:

 int
 hpfs_genlookupbyname (
    struct hpfsnode *dhp,
    char *name,
    int namelen,
    struct buf **bpp,
    struct hpfsdirent **depp)
 {
    struct hpfsmount *hpmp = dhp->h_hpmp;
    struct buf *bp;
    struct dirblk *dp;
    struct hpfsdirent *dep;
    lsn_t lsn;
-   int error, res;
+   caddr_t dlimit;
+   int error, res, depth;

    dprintf(("hpfs_genlookupbyname(0x%x, %s (%d)): \n", 
        dhp->h_no, name, namelen));

    lsn = ((alleaf_t *)dhp->h_fn.fn_abd)->al_lsn;
+
+   depth = 0;
 dive:
+   if (depth++ > HPFS_DIRDEPTH_MAX) {  /* defeat DE_DOWN cycles */
+       kprintf("hpfs_genlookupbyname: too deep at lsn 0x%x\n", lsn);
+       return (EINVAL);
+   }
    error = hpfs_breaddirblk (hpmp, lsn, &bp);
    if (error)
        return (error);

    dp = (struct dirblk *) bp->b_data;
    dep = D_DIRENT(dp);
+   dlimit = (caddr_t)dp + D_BSIZE;     /* hard bound: bread'd size */

    while(!(dep->de_flag & DE_END)) {
+       /* current dirent header + name must lie entirely in the buffer */
+       if ((caddr_t)dep + sizeof(struct hpfsdirent) > dlimit ||
+           dep->de_reclen < sizeof(struct hpfsdirent) ||
+           (caddr_t)dep + dep->de_reclen > dlimit ||
+           dep->de_namelen > dep->de_reclen - sizeof(struct hpfsdirent) + 1)
+           goto bad;
+
        dprintf(("no: 0x%x, size: %d, name: %2d:%.*s, flag: 0x%x\n",
            dep->de_fnode, dep->de_size, dep->de_namelen,
            dep->de_namelen, dep->de_name, dep->de_flag));

        res = hpfs_cmpfname(hpmp, name, namelen,
                dep->de_name, dep->de_namelen, dep->de_cpid);
        if (res == 0) {
            *bpp = bp;
            *depp = dep;
            return (0);
        } else if (res < 0)
            break;

        dep = (hpfsdirent_t *)(((caddr_t)dep) + dep->de_reclen);
    }

+   /* re-validate the terminator dirent before reading DE_DOWN/DE_DOWNLSN */
+   if ((caddr_t)dep + sizeof(struct hpfsdirent) > dlimit ||
+       dep->de_reclen < sizeof(struct hpfsdirent))
+       goto bad;
+
    if (dep->de_flag & DE_DOWN) {
        lsn = DE_DOWNLSN(dep);
        brelse(bp);
        goto dive;
    }

    brelse(bp);

    return (ENOENT);
+
+ bad:
+   kprintf("hpfs_genlookupbyname: corrupt dirblk at lsn 0x%x\n", lsn);
+   brelse(bp);
+   return (EINVAL);
 }

with, near the top of hpfs_lookup.c (after the includes, ~line 47):

 int    hpfs_removedirent (struct hpfsmount *, lsn_t, char *, int, int *);
+
+#define HPFS_DIRDEPTH_MAX 64   /* B-tree depth sanity bound */

The same bound/minimum checks should be mirrored in hpfs_readdir (hpfs_vnops.c:823-915) and hpfs_validateparent (hpfs_subr.c:552-618), which share the unbounded dep += dep->de_reclen pattern; those are out of scope for this file but are the same bug class and should be fixed together.

The check dep->de_reclen < sizeof(struct hpfsdirent) also closes the DE_DOWNLSN underflow (case b), because de_reclen is then guaranteed >= sizeof(struct hpfsdirent) > sizeof(lsn_t) before DE_DOWNLSN(dep) is evaluated. The depth cap closes case (d); the non-zero minimum (implicit in >= sizeof(struct hpfsdirent)) closes case (c); the dlimit bound closes case (a).

References

Timeline

  • 2026-07-05 Discovered during automated audit.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0927 Β· 18 files
FileTypeDescriptionSize
README.md readme build/run/expected + how to reproduce (human) 4.8 KB ↓ raw
VERDICT.md verdict full narrative: mechanism, three variants, fix, validation 10.8 KB ↓ raw
craft_img.py trigger-source builds full HPFS image from scratch; --oob/--hang1/--cycle variants 10.6 KB view raw
harness.c trigger-source deterministic userspace transcription of hpfs_genlookupbyname:82-102 15.4 KB view raw
build.sh build-script cc -O2 -Wall -o harness harness.c 159 B view raw
run.sh run-script ./harness 122 B view raw
df0927_oob.img test-image Variant A image (de_reclen=0xFFFF OOB) 64.0 KB ↓ download
df0927_hang1.img test-image Variant B image (de_reclen=0 spin) 64.0 KB ↓ download
df0927_cycle.img test-image Variant C image (DE_DOWN D0<->D1 cycle) 64.0 KB ↓ download
build.log build-log harness build output 66 B view raw
run.log run-log panic signature + harness output (full) 2.7 KB view raw
panic.txt panic-signature Fatal trap 12 in hpfs_validateparent+0x146 reading dep->de_flag at OOB 677 B view raw
fix.diff suggested-fix git-apply-able: bounds de_reclen/dep cursor in hpfs_lookup.c + hpfs_subr.c + depth cap 4.5 KB view raw
fix_build.log build-log patched hpfs.ko build (rc=0, -Werror, 6 TUs + link) 9.6 KB view raw
fix_run.log run-log patched-kernel validation: all 3 variants return EINVAL, no panic/hang 888 B view raw
env.txt environment uname, cc version, vfs.usermount=0 299 B view 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 (human)
↓ download raw

DF-0927 β€” PoC: unbounded dirent traversal in hpfs_genlookupbyname

Goal

Prove four impacts of the unbounded dirent-chain walk in hpfs_genlookupbyname (hpfs_lookup.c:82-102) on a crafted HPFS image:

  • Variant A (OOB read): de_reclen = 0xFFFF and DE_END clear β†’ first stride jumps ~64 KiB past the 2048-byte bread'd buffer β†’ kernel page-fault panic reading dep->de_flag. (CWE-125 OOB read.)
  • Variant B (hang #1): de_reclen = 0 β†’ dep never advances β†’ infinite while loop (kernel thread wedged). (CWE-835.)
  • Variant C (hang #2): two dirblks whose DE_DOWN pointers reference each other β†’ dive loop never terminates. (CWE-835 + CWE-400.)
  • OOBβ†’info-leak ceiling: if the kernel survives the OOB read, the bytes are copied to userspace via the readdir name-copy path (hpfs_vnops.c:745-747 hpfs_de_uiomove).

Plus a deterministic userspace harness (harness.c) transcribing the hpfs_genlookupbyname loop byte-for-byte against sys/vfs/hpfs/hpfs.h struct layouts, proving all three variants and the fix rejection.

Files

File Purpose
craft_img.py Builds a full HPFS image from scratch (no base.hpfs needed). Three variants: --oob, --hang1, --cycle.
harness.c Deterministic userspace transcription of hpfs_genlookupbyname:82-102. Models the buffer bound, de_reclen minimum, and depth cap; proves all three variants.
build.sh cc -O2 -Wall -o harness harness.c
run.sh ./harness
df0927_oob.img Variant A image
df0927_hang1.img Variant B image
df0927_cycle.img Variant C image
panic.txt Kernel panic signature from boot.log (Variant A, baseline)
run.log Full reproduction log (panic + harness output)
fix_run.log Patched-kernel validation (all 3 variants β†’ EINVAL)
fix_build.log Build log for patched hpfs.ko
fix.diff git apply-able fix (lookup.c + subr.c)
VERDICT.md Full narrative analysis
manifest.json Machine-readable catalog

Build & run (DragonFlyBSD guest)

The harness is pure userspace and reproduces the bug deterministically:

ssh dfbsd-maxx
cd poc/DF-0927
./build.sh && ./run.sh

For the in-kernel reproduction (root to mount; trigger as unprivileged maxx):

python3 craft_img.py --oob df0927_oob.img    # on host (no python3 in guest)
scp df0927_oob.img dfbsd-maxx:poc/DF-0927/

# in guest as root:
vnconfig -c vn0 /home/maxx/poc/DF-0927/df0927_oob.img
mount -t hpfs -o ro /dev/vn0 /mnt/df0927

# in guest as unprivileged maxx:
stat /mnt/df0927/zzz     # immediate panic (Variant A)

Expected output

Variant A (panic, baseline)

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff80058e36015
fault code = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff826032f6
current process = 910
Stopped at hpfs_validateparent+0x146: movzwl 0x2(%r15),%edx

(movzwl 0x2(...) reads dep->de_flag at offset 0x2; %r15 is the poisoned dep after dep += de_reclen(0xFFFF). The panic fires in hpfs_validateparent (sibling of hpfs_genlookupbyname sharing the same unbounded dep += de_reclen pattern); the harness proves the bug deterministically in hpfs_genlookupbyname itself.)

Variant B/C (hard hang, baseline)

stat never returns; ssh stops responding within ~12s; guest wedged. The kernel thread spins forever inside hpfs_validateparent / hpfs_genlookupbyname holding VFS locks.

Patched kernel (fix applied)

$ stat /mnt/df0927/zzz
stat: /mnt/df0927/zzz: stat: Invalid argument
$                                  # guest up; dmesg: hpfs_validateparent: corrupt dirblk

Notes

  • HPFS mount is root-only (vfs.usermount=0). But once root mounts the crafted image, any unprivileged user who can stat/ls/open files in the mounted tree triggers the bug. The PoC uses mount -t hpfs -o ro (no uid=/gid= remapping) β€” maxx (uid 1001) can stat /mnt/df0927/zzz because the mountpoint dir is world-readable. The trigger is fully unprivileged post-mount.
  • The same unbounded dep += dep->de_reclen pattern exists in hpfs_readdir (hpfs_vnops.c:823-915) β€” same bug class, would need the same fix; out of scope for this finding's fix.diff.
VERDICT.md verdict full narrative: mechanism, three variants, fix, validation
↓ download raw

DF-0927 β€” Verdict

Verdict: REPRODUCED (panic + hard-hang DoS, three variants). FIX VALIDATED.

Mechanism (root cause, with path:line)

hpfs_genlookupbyname (sys/vfs/hpfs/hpfs_lookup.c:55-108) reads a 2048-byte (D_BSIZE, hpfs.h:133) directory block via hpfs_breaddirblk (hpfs_subr.h:78 β†’ hpfs_breadstruct, hpfs_subr.c:835, which checks only the 4-byte magic) and then walks the on-disk dirent chain inside that buffer:

dep = D_DIRENT(dp);                                            /* hpfs_lookup.c:80 */
while(!(dep->de_flag & DE_END)) {                              /* :82 */
    ...
    res = hpfs_cmpfname(hpmp, name, namelen,
                        dep->de_name, dep->de_namelen, ...);   /* :87 */
    if (res == 0) { ...; return (0); }                         /* :89 */
    else if (res < 0) break;                                   /* :93 */
    dep = (hpfsdirent_t *)(((caddr_t)dep) + dep->de_reclen);   /* :96 */
}
if (dep->de_flag & DE_DOWN) {                                  /* :99 */
    lsn = DE_DOWNLSN(dep);                                     /* :100 */
    brelse(bp); goto dive;                                     /* :101-102 */
}

There is no bound of (caddr_t)dep against (caddr_t)dp + D_BSIZE, no minimum on dep->de_reclen (u_int16_t, hpfs.h:117), no maximum on dep->de_namelen (u_int8_t, hpfs.h:127), and no depth counter on the dive loop. By contrast the sibling hpfs_readdir carries an int level (hpfs_vnops.c:776,821,886,913,922-927) and hpfs_validateparent carries an int level (hpfs_subr.c:532,549,601,616,624-627) β€” both lack only the buffer bound, but hpfs_genlookupbyname lacks both.

Four attacker-controllable conditions follow directly:

Variant Poisoned bytes Effect
A (OOB read) first dirent de_reclen=0xFFFF, DE_END clear, name "A" dep advances 64 KiB past the 2 KiB buffer; next dep->de_flag read is far OOB β†’ page fault panic, OR (if mapped) kernel heap disclosure via the readdir/hpfs_de_uiomove name copy.
B (infinite loop) first dirent de_reclen=0, DE_END clear, name "A" cmpfname("zzz","A")>0 β†’ loop body runs; dep += 0 β†’ no advance; spin forever.
C (depth cycle) two dirblks whose first dirent has DE_END\|DE_DOWN and down_lsn referencing each other the dive loop follows DE_DOWNLSN Aβ†’Bβ†’Aβ†’B… forever; no depth/visited guard.
(DE_DOWNLSN underflow) first dirent de_reclen<4 DE_DOWNLSN(dep) = *(lsn_t *)((caddr_t)dep + dep->de_reclen - 4) underflows; far OOB read. Closed implicitly by the fix's de_reclen >= sizeof(struct hpfsdirent) minimum.

Reproduction on #0 baseline GENERIC (with-src, INVARIANTS ON)

Variant A β€” kernel OOB-read panic

$ ssh dfbsd-maxx 'stat /mnt/df0927/zzz'   # uid 1001, post root-mount
[guest down]
--- dfbsd-qemu/boot.log ---
Fatal trap 12: page fault while in kernel mode
cpuid = 1; lapic id = 1
fault virtual address = 0xfffff80058e36015
fault code = supervisor read data, page not present
instruction pointer = 0x8:0xffffffff826032f6
current process = 910 (stat)
kernel: type 12 trap, code=0
Stopped at hpfs_validateparent+0x146: movzwl 0x2(%r15),%edx
db>

The faulting instruction movzwl 0x2(%r15),%edx reads dep->de_flag (offset 0x2 of struct hpfsdirent) at the poisoned %r15 = dep after dep += de_reclen(0xFFFF). The fault address 0xfffff80058e36015 is ~0xFFFF past a kernel heap address β€” exactly the unbounded-stride OOB.

The panic fires in hpfs_validateparent (hpfs_subr.c:522) β€” a sibling of hpfs_genlookupbyname sharing the same unbounded dep += dep->de_reclen pattern at hpfs_subr.c:576, 588, 610. The finding text explicitly notes this is the same bug class. stat triggers VOP_GETATTR β†’ hpfs_getattr β†’ hpfs_validateparent (when H_PARVALID is clear; hpfs_vnops.c:466-467) before it reaches lookup β†’ hpfs_lookup β†’ hpfs_genlookupbyname, so the OOB lands in hpfs_validateparent first. Both functions need the same bound.

Variant B β€” de_reclen=0 hard hang

$ ssh dfbsd-maxx 'stat /mnt/df0927/zzz'
[12 s timeout β€” guest wedged, ssh dies]
--- dfbsd-qemu/boot.log shows no panic; pure kernel spin ---

Variant C β€” DE_DOWN cycle hard hang

$ ssh dfbsd-maxx 'stat /mnt/df0927/zzz'
[12 s timeout β€” guest wedged, ssh dies]

Deterministic userspace harness (transcribes hpfs_genlookupbyname)

[A] BUG  de_reclen=0xFFFF: oob=63507B past buf, steps=1, dives=1, oob_at_step=1
        expected OOB = 0xFFFF - (D_BSIZE - 20) = 63507 B
[B] BUG  de_reclen=0: rc(oob/spin)=-3, steps=1001 (capped; kernel = infinite loop)
[C] BUG  D0<->D1 cycle: rc=-4 (expect -4 cycle), steps=0, dives=6 (capped; kernel = infinite)
DF_0927_BUG_CONFIRMED=1
DF_0927_FIX_REJECTS_ALL_VARIANTS=1

The harness reads D_BSIZE, struct dirblk, struct hpfsdirent straight from sys/vfs/hpfs/hpfs.h. It places the 2 KiB dirblk at the start of a 256 KiB 0xAA-poisoned mmap and runs the exact loop body. Variant A reports 63 507 B OOB past the buffer (matching 0xFFFF - (2048 - 20)), variant B hits the 1000-iteration cap (kernel would spin forever), variant C hits the 5-dive cap (kernel would cycle forever).

Threat model / reachability

  • HPFS mount is root-gated (vfs.usermount=0). The PoC assumes an admin has mounted (or made mountable) the attacker's crafted image β€” the standard filesystem-image threat model (USB auto-mount, jail/multi-tenant images, kiosk systems, vfs.usermount per-user grants). The finding's CVSS PR:L (not PR:N) reflects this.
  • Post-mount, the trigger is fully unprivileged. Once root mounts the image at a world-traversable path (the PoC uses /mnt/df0927 with chown maxx:maxx), maxx (uid 1001, NOT in wheel) issues stat /mnt/df0927/zzz and triggers the bug. The reproduction in this report uses exactly that path.

Impact

  • OOB read (variant A): kernel page-fault panic β†’ reliable DoS. If the OOB read lands in mapped kernel memory instead, the bytes flow to userspace via the readdir/getattr name-copy path (hpfs_vnops.c:745-747 hpfs_de_uiomove and :1068-1070 bcopy(dep->de_name, hp->h_name, ...)), yielding a kernel heap disclosure. The page-fault vs info-leak outcome depends on kernel heap layout (the 64 KiB stride crosses many slab pages); both are bug manifestations.
  • Hangs (B, C): deterministic local DoS. The kernel thread spinning in hpfs_validateparent/hpfs_genlookupbyname holds VFS locks and wedges the guest (ssh stops responding).

The fix (fix.diff)

Two files patched:

  1. sys/vfs/hpfs/hpfs_lookup.c (the finding's primary target): adds - dlimit = (caddr_t)dp + D_BSIZE hard buffer bound, - per-iteration check (caddr_t)dep + sizeof(struct hpfsdirent) > dlimit || dep->de_reclen < sizeof(struct hpfsdirent) || (caddr_t)dep + dep->de_reclen > dlimit || dep->de_namelen > dep->de_reclen - sizeof(struct hpfsdirent) + 1 β†’ goto bad (returns EINVAL), - re-validation of the terminator dirent before DE_DOWN/DE_DOWNLSN, - int depth counter on the dive loop with HPFS_DIRDEPTH_MAX 64 cap β†’ defeats the DE_DOWN cycle.

  2. sys/vfs/hpfs/hpfs_subr.c (hpfs_validateparent, where the panic actually fires on the stat path): adds the same dlimit buffer bound and per-iteration check at all three dep += dep->de_reclen sites, plus a level > HPFS_DIRDEPTH_MAX cap at the top of the dive loop (this function already had a level counter, but no maximum; without this the DE_DOWN cycle would still spin here).

Both files gain #define HPFS_DIRDEPTH_MAX 64 (B-tree depth sanity bound; real HPFS B-trees are <10 deep). The fix matches the finding's ## Recommended fix proposal in hpfs_lookup.c and extends the same pattern to hpfs_validateparent (which the finding notes is the same bug class).

Phase 8 β€” fix validation

Built the patched hpfs.ko as a KLD module (HPFS is loadable, not built into the GENERIC kernel; verified viakldstat -vandnm /boot/kernel/kernelshowing no hpfs symbols). Single command:

cd /usr/src/sys/vfs/hpfs && make -DNO_MODULES    # rc=0, all 6 TUs + link

Installed: cp /usr/obj/.../hpfs.ko /boot/kernel/hpfs.ko (sha256 8846085ddedce20bac8f80369a75cd3a14b178555c99df7f8fcd6f3c05f504da, 44 160 B).

Variant Baseline #0 (BUGGY) Patched (FIXED)
A Fatal trap 12 panic stat: Invalid argument (EINVAL), guest up, dmesg: hpfs_validateparent: corrupt dirblk
B hard hang (ssh dies) stat: Invalid argument (EINVAL), guest up, dmesg: hpfs_validateparent: corrupt dirblk
C hard hang (ssh dies) stat: Invalid argument (EINVAL), guest up, dmesg: hpfs_validateparent: corrupt dirblk / too deep at lsn 0x40

The patch closes all three variants deterministically. git apply --check passes cleanly against the unmodified sys/ tree.

PoC changes (vs the reviewer-supplied scaffolding)

  • The original mkimg.py expected a base.hpfs that does not exist in the evidence pack. Replaced with craft_img.py which builds a complete valid HPFS image from scratch (super/spare/bitmap/root-fnode/dirblk), mirroring the proven DF-0857 layout, and emits all three variants (--oob, --hang1, --cycle).
  • Added harness.c: a deterministic userspace transcription of hpfs_genlookupbyname:82-102 against the exact hpfs.h struct layouts, proving all three variants byte-for-byte and demonstrating the fix rejection. This gives a reproducible signal that does not depend on kernel heap layout (the live-kernel OOB fault-vs-leak outcome varies by run, but the harness always reports oob=63507).
  • Added fix.diff extending the finding's proposed fix to also patch hpfs_validateparent (the actual panic path on this guest) with the same bound check + depth cap.

Non-corruption note (no escalation chain)

This is a pure OOB-read + DoS finding (CWE-125/835/400). The primitive is read-only β€” the loop only reads dep->de_flag, dep->de_name, dep->de_reclen, dep->de_namelen, dep->de_cpid; it does not write the buffer. There is no write/UAF/double-free primitive to convert into a credential corruption. The realistic impact ceiling is therefore: (1) reliable kernel panic / hard hang (DoS), confirmed on all three variants; and (2) potential kernel heap info-leak if the OOB read lands in mapped memory and the bytes flow to userspace via the readdir name copy. No uid=0 escalation is derivable; this is not a memory-corruption primitive that can be groomed into a write.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED. On the unpatched baseline (#0 GENERIC, hpfs.ko sha256 b8bcb64b...), stat /mnt/df0927/zzz on the OOB image panics: Fatal trap 12 page fault while in kernel mode at hpfs_validateparent+0x146 (movzwl 0x2(%r15) reads dep->de_flag at the OOB address 0xfffff80058e36015, ~64 KiB past the 2 KiB dirblk); variants B and C hard-hang the guest (ssh dies within 12 s). On the single-fix kernel (same kernel + patched hpfs.ko loaded on demand by mount_hpfs), the same stat on all three images returns EINVAL cleanly (stat: /mnt/df0927/zzz: Invalid argument, exit 1), the guest stays up, and dmesg shows the new rejection messages hpfs_validateparent: corrupt dirblk (variants A,B) and hpfs_validateparent: too deep at lsn 0x40 (variant C). The fix closes the bug deterministically.

BEFORE (unpatched #0 baseline): variant A: Fatal trap 12 page fault while in kernel mode / Stopped at hpfs_validateparent+0x146: movzwl 0x2(%r15),%edx (guest down). variant B: stat never returns; guest wedged (ssh dies in ~12 s). variant C: stat never returns; guest wedged (ssh dies in ~12 s). AFTER (patched hpfs.ko): variant A: stat: /mnt/df0927/zzz: Invalid argument (exit 1); guest up; dmesg: hpfs_validateparent: corrupt dirblk. variant B: stat: /mnt/df0927/zzz: Invalid argument (exit 1); guest up; dmesg: hpfs_validateparent: corrupt dirblk. variant C: stat: /mnt/df0927/zzz: Invalid argument (exit 1); guest up; dmesg: hpfs_validateparent: corrupt dirblk / hpfs_validateparent: too deep at lsn 0x40.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 baseline + patched hpfs.ko (KLD, sha256 8846085ddedce20bac8f80369a75cd3a14b178555c99df7f8fcd6f3c05f504da, 44160 B, built from /usr/src/sys/vfs/hpfs with fix.diff applied, hpfs is loadable not built-in)

Confirmed kernel references

Detail

Exploit chain

none (not a memory-corruption/writable primitive). The bug is a pure OOB-read + DoS: hpfs_genlookupbyname's loop reads dep->de_flag/de_name/de_reclen/de_namelen/de_cpid from attacker-controlled offsets but writes nothing, so there is no slab-groom -> corrupt-victim -> forge/pivot -> uid0 chain to develop. Impact ceiling is (1) reliable kernel panic / hard-hang DoS (all three variants demonstrated) and (2) potential kernel heap info-leak via the readdir/getattr name copies (hpfs_vnops.c:745-747 hpfs_de_uiomove; :1068-1070 bcopy into hp->h_name) when the OOB read lands in mapped memory rather than faulting.

Evidence (decisive lines)

Baseline variant A panic (boot.log): Fatal trap 12: page fault while in kernel mode / cpuid = 1; lapic id = 1 / fault virtual address = 0xfffff80058e36015 / fault code = supervisor read data, page not present / instruction pointer = 0x8:0xffffffff826032f6 / current process = 910 (stat) / Stopped at hpfs_validateparent+0x146: movzwl 0x2(%r15),%edx (reads dep->de_flag at offset 0x2, %r15 = dep after += 0xFFFF). Variants B and C: stat /mnt/df0927/zzz never returns; guest wedged within 12s (ssh dies); boot.log shows no panic (pure kernel spin). Deterministic harness: [A] BUG de_reclen=0xFFFF: oob=63507B past buf, steps=1, dives=1. [B] BUG de_reclen=0: rc=-3, steps=1001 (capped; kernel = infinite loop). [C] BUG D0<->D1 cycle: rc=-4, dives=6 (capped; kernel = infinite). DF_0927_BUG_CONFIRMED=1.

PoC changes

Replaced the reviewer's mkimg.py (which expected a non-existent base.hpfs) with craft_img.py: builds a complete valid HPFS image from scratch (super/spare/bitmap/root-fnode/dirblk) mirroring the proven DF-0857 layout, with three variants --oob / --hang1 / --cycle. Added harness.c: a deterministic userspace transcription of hpfs_genlookupbyname:82-102 against the exact hpfs.h struct layouts (struct dirblk, struct hpfsdirent with amd64 u_long=8B alignment), proving variant A reads 63507 B past the 2 KiB buffer, variant B is a no-advance spin, variant C is a DE_DOWN cycle, and the fix rejects all three. Authored fix.diff (extends the finding's proposal to also patch hpfs_validateparent, the actual panic path). Added build.sh, run.sh, VERDICT.md, README.md, manifest.json, fix_build.log, fix_run.log, panic.txt, env.txt, and the three test images.

Verified recommended fix

In hpfs_genlookupbyname (sys/vfs/hpfs/hpfs_lookup.c): add caddr_t dlimit = (caddr_t)dp + D_BSIZE after the bread, check at the top of each while-loop iteration that (caddr_t)dep + sizeof(struct hpfsdirent) <= dlimit AND dep->de_reclen >= sizeof(struct hpfsdirent) AND (caddr_t)dep + dep->de_reclen <= dlimit AND dep->de_namelen <= dep->de_reclen - sizeof(struct hpfsdirent) + 1 (else return EINVAL via a new bad: label), re-validate the terminator dirent the same way before reading DE_DOWN/DE_DOWNLSN, and add int depth with HPFS_DIRDEPTH_MAX 64 cap at the top of the dive: label. Mirror the same bound check (and the depth cap, which the existing int level lacks) in hpfs_validateparent (sys/vfs/hpfs/hpfs_subr.c:552-650) -- this is where the panic actually fires on the stat path. Supersedes the finding's ## Recommended fix proposal (which patched only hpfs_lookup.c); my fix.diff additionally patches hpfs_validateparent so the demonstrated panic is fully closed.

Verdict

REPRODUCED on baseline GENERIC (#0, INVARIANTS ON). hpfs_genlookupbyname (sys/vfs/hpfs/hpfs_lookup.c:82-102) walks the on-disk dirent chain inside a 2048-byte D_BSIZE bread buffer with NO bound on the dep cursor, NO minimum on de_reclen (u16, hpfs.h:117), NO maximum on de_namelen, and NO depth cap on the DE_DOWN dive loop -- unlike siblings hpfs_readdir and hpfs_validateparent which both carry an int level. Three crafted-HPFS variants confirmed: (A) de_reclen=0xFFFF -> first stride jumps 64KiB past the buffer; the next dep->de_flag read (movzwl 0x2) faults -> Fatal trap 12 page fault while in kernel mode at hpfs_validateparent+0x146 (sibling sharing the same unbounded dep += de_reclen pattern at hpfs_subr.c:576,588,610; the finding notes this is the same bug class); (B) de_reclen=0 -> dep never advances, kernel thread spins forever (hard hang, ssh dies in ~12s); (C) two dirblks D0<->D1 with DE_DOWN cross-references -> dive loop bounces bread(D0)<->bread(D1) forever (hard hang). The deterministic userspace harness (harness.c) transcribes hpfs_genlookupbyname:82-102 against the exact hpfs.h struct layouts and reports oob=63507B for variant A, infinite-loop hit for B, dive-cycle hit for C. Threat model: HPFS mount is root-gated (vfs.usermount=0) but once mounted the trigger (stat /mnt/df0927/zzz) is fully exercisable by unprivileged maxx (uid 1001) end-to-end. No uid=0 escalation is derivable -- the primitive is read-only (loop reads dep->flag/name/reclen/namelen/cpid; writes nothing); realistic ceiling is reliable DoS (panic/hang) plus potential kernel heap info-leak if the OOB read lands in mapped memory and the bytes flow out via the readdir name copy.