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 singlede_reclen(e.g.0xFFFF), pushesdeppastbp->b_data + 2048, the next evaluation ofdep->de_flagand thehpfs_cmpfnameread ofdep->de_name[0..de_namelen-1]dereference kernel memory beyond the buffer. A single first entry withde_reclen=0xFFFF(andde_flagwithoutDE_END) jumps ~64 KiB past the buffer on the very first step. - (b)
DE_DOWNLSNunderflow. 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)). Ifde_reclen < 4, the unsigned subtraction wraps to a huge offset and thelsnread is a far OOB read;depitself may already be OOB from (a). - (c) Infinite loop #1 (
de_reclen == 0). If the on-diskde_reclenis0, line 96 does not advancedep, so if the entry's name sorts before the lookup target (hpfs_cmpfnamereturns>0) andDE_ENDis clear, thewhile-loop spins on the same entry forever β a kernel lockup/hang. - (d) Infinite loop #2 (
DE_DOWNcycle). The dive loop (lines74,99-102) followsDE_DOWNLSN(dep)to a new block and re-entersdivewith nolevel/depthcounter and no visited set. I confirmed viagrepthathpfs_readdir(hpfs_vnops.c:776,821,886,913,922-927) andhpfs_validateparent(hpfs_subr.c:532,549,601,616,624-627) both carry anint levelto bound descent, buthpfs_genlookupbynamecarries none. Two dirblks whoseDE_DOWNpointers reference each other (AβB, BβA) make the kernelbreadthe 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.usermountper-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-747hpfs_de_uiomovecopiesdep->de_nameto auio;hpfs_vnops.c:1068-1070bcopysdep->de_nameintohp->h_namelater exposed viagetattr/readdir), yielding a kernel memory disclosure (C:H). 2. Two deterministic infinite-loop paths (de_reclen==0andDE_DOWNcycle) 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/readdiron any name in the crafted directory reacheshpfs_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_genlookupbynamereadingdep->de_flagfar pastbp->b_data(panic.txt). - Variant B (hang #1):
statnever returns; serial console / NMI /ddbbacktrace showspcinsidehpfs_genlookupbyname:96. - Variant C (hang #2):
statnever returns; backtrace shows the kernel bouncing betweenbread(D0)andbread(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/lson the malicious directory.
Recommended fix
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
sys/vfs/hpfs/hpfs.h:114,117,127,133βDE_DOWNLSN,de_reclen,de_namelen,D_BSIZEdefinitions.sys/vfs/hpfs/hpfs_subr.c:835,168-186βhpfs_breaddirblk(no chain validation),hpfs_cmpfname(unbounded name read).sys/vfs/hpfs/hpfs_vnops.c:776,821,913,922-927βhpfs_readdirwhich DOES carry alevelcounter (the patternhpfs_genlookupbynameshould mirror).sys/vfs/hpfs/hpfs_subr.c:532,549,601,616,624-627βhpfs_validateparentwhich also carries alevel.
Timeline
- 2026-07-05 Discovered during automated audit.
- pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0927 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 = 0xFFFFandDE_ENDclear β first stride jumps ~64 KiB past the 2048-bytebread'd buffer β kernel page-fault panic readingdep->de_flag. (CWE-125 OOB read.) - Variant B (hang #1):
de_reclen = 0βdepnever advances β infinitewhileloop (kernel thread wedged). (CWE-835.) - Variant C (hang #2): two dirblks whose
DE_DOWNpointers reference each other βdiveloop 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-747hpfs_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 usesmount -t hpfs -o ro(nouid=/gid=remapping) βmaxx(uid 1001) canstat /mnt/df0927/zzzbecause the mountpoint dir is world-readable. The trigger is fully unprivileged post-mount. - The same unbounded
dep += dep->de_reclenpattern exists inhpfs_readdir(hpfs_vnops.c:823-915) β same bug class, would need the same fix; out of scope for this finding'sfix.diff.
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.usermountper-user grants). The finding's CVSSPR:L(notPR:N) reflects this. - Post-mount, the trigger is fully unprivileged. Once root mounts the
image at a world-traversable path (the PoC uses
/mnt/df0927withchown maxx:maxx),maxx(uid 1001, NOT in wheel) issuesstat /mnt/df0927/zzzand 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/
getattrname-copy path (hpfs_vnops.c:745-747hpfs_de_uiomoveand:1068-1070bcopy(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_genlookupbynameholds VFS locks and wedges the guest (ssh stops responding).
The fix (fix.diff)
Two files patched:
-
sys/vfs/hpfs/hpfs_lookup.c(the finding's primary target): adds -dlimit = (caddr_t)dp + D_BSIZEhard 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(returnsEINVAL), - re-validation of the terminator dirent beforeDE_DOWN/DE_DOWNLSN, -int depthcounter on thediveloop withHPFS_DIRDEPTH_MAX 64cap β defeats the DE_DOWN cycle. -
sys/vfs/hpfs/hpfs_subr.c(hpfs_validateparent, where the panic actually fires on the stat path): adds the samedlimitbuffer bound and per-iteration check at all threedep += dep->de_reclensites, plus alevel > HPFS_DIRDEPTH_MAXcap at the top of thediveloop (this function already had alevelcounter, 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.pyexpected abase.hpfsthat does not exist in the evidence pack. Replaced withcraft_img.pywhich 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 ofhpfs_genlookupbyname:82-102against the exacthpfs.hstruct 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 reportsoob=63507). - Added
fix.diffextending the finding's proposed fix to also patchhpfs_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
fixedVALIDATED. 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.
Confirmed kernel references
- sys/vfs/hpfs/hpfs_lookup.c:80
- sys/vfs/hpfs/hpfs_lookup.c:82
- sys/vfs/hpfs/hpfs_lookup.c:87
- sys/vfs/hpfs/hpfs_lookup.c:96
- sys/vfs/hpfs/hpfs_lookup.c:99-102
- sys/vfs/hpfs/hpfs.h:114
- sys/vfs/hpfs/hpfs.h:117
- sys/vfs/hpfs/hpfs.h:127
- sys/vfs/hpfs/hpfs.h:133
- sys/vfs/hpfs/hpfs_subr.h:78
- sys/vfs/hpfs/hpfs_subr.c:835
- sys/vfs/hpfs/hpfs_subr.c:168-186
- sys/vfs/hpfs/hpfs_subr.c:522-650
- sys/vfs/hpfs/hpfs_vnops.c:466-467
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.
No comments yet.