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

hpfs_cpload OOB read of cpdsec via unchecked d_cpcnt past fixed d_cpdblk[3] array

Summary

hpfs_subr.c:228 for(i=d_cpfirst;i<d_cpcnt;i++) d_cpdblk fixed array[3] (hpfs.h:289). d_cpcnt/d_cpfirst unchecked on-disk u16. d_cpcnt=0xFFFF reads far past 512B buffer bcopy 136B OOB into cpdbp. Mount-time.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0863 Β· 16 files
FileTypeDescriptionSize
craft_img.c trigger-source builds crafted HPFS image with d_cpcnt=0xFFFF (configurable) in the CPD sector 7.4 KB view raw
build.sh build-script cc -O2 -Wall -o craft_img craft_img.c 157 B view raw
run.sh run-script kldload hpfs + vnconfig + mount (root; triggers the bug) 1.8 KB view raw
fix.diff suggested-fix validated git-apply-able fix: magic check + bound d_cpfirst/d_cpcnt to nitems(d_cpdblk) 959 B view raw
panic.txt panic-signature unpatched Fatal trap 12 / hpfs_cpload+0xa7 / supervisor read data (crash proof) 779 B view raw
run.log run-log baseline BEFORE run output (panic, run 1) 517 B view raw
run.2.log run-log baseline BEFORE 2nd run (determinism) 517 B view raw
fix_run.log run-log fixed-module AFTER run output (ENOENT, no panic) 577 B view raw
fix_run.2.log run-log fixed-module AFTER 2nd run (determinism) 577 B view raw
fix_build.log build-log full hpfs.ko module build log (rc=0, -Werror clean) 10.5 KB view raw
env.txt environment uname, cc 8.3, fixed-module sha256, vfs.usermount 515 B view raw
VERDICT.md verdict full narrative: mechanism, reachability, exploit-chain assessment, fix, validation 9.0 KB ↓ raw
README.md readme human-readable summary + reproduce instructions 3.7 KB ↓ raw
build.log build-log kernel build log excerpt proving -Werror clean compile of patched source 235 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 human-readable summary + reproduce instructions
↓ download raw

DF-0863 β€” hpfs_cpload OOB read of cpdsec via unchecked d_cpcnt past fixed d_cpdblk[3] array

Verdict: REPRODUCED (panic / OOB read via crafted HPFS image). Fix VALIDATED. Severity: Medium. Impact: kernel panic at mount (local DoS); OOB read of kernel heap.

What the bug is

hpfs_cpload (sys/vfs/hpfs/hpfs_subr.c:228) reads a Code-Page Data Sector (struct cpdsec) off disk into a 512-byte bp->b_data buffer and then walks its d_cpdblk[] array using d_cpfirst/d_cpcnt β€” two u_int16_t values taken verbatim from the attacker-controlled image with no validation:

for (i=cpdsp->d_cpfirst; i<cpdsp->d_cpcnt; i++) {
    if (cpdsp->d_cpdblk[i].b_cpid == cpibp->b_cpid) {     /* OOB READ */
        bcopy(cpdsp->d_cpdblk + i, cpdbp, sizeof(struct cpdblk));

But d_cpdblk[] is a fixed-size array of 3 entries (sys/vfs/hpfs/hpfs.h:289 β€” struct cpdblk d_cpdblk[3];). Setting d_cpcnt = 0xFFFF (and d_cpfirst = 0) makes the loop walk past the array and past the 512-byte bp->b_data buffer, eventually crossing a page boundary β†’ Fatal trap 12, page fault, supervisor read data, page not present. The faulting instruction is the b_cpid comparison at line 229.

Layout: d_cpdblk occupies bytes [26..434) of the on-disk cpd_sec inside the 512-byte buffer. Reading d_cpdblk[i].b_cpid (struct offset 26 + i136 + 2): i=0,1,2 in-bounds; i=3 still inside the buffer; i>=4 reads OOB past bp->b_data*. The same path's bcopy (line 230) can also leak up to 136 bytes of out-of-buffer kernel memory into the hpm_cpdblk[] case-conversion tables (exfiltrable via filenames) if a tighter d_cpcnt is crafted.

Threat model / reachability

HPFS is optional hpfs (not in X86_64_GENERIC) but shipped as /boot/kernel/hpfs.ko. An admin enables it with kldload hpfs (standard FS-enable action). The attacker supplies a crafted image; the mounter (root, or an unprivileged user after vfs.usermount=1 + an owned memory disk) mounts it → kernel panic in hpfs_cpload at mount. Classic filesystem-image-parsing memory-safety bug. No unpriv→root escalation (read-only primitive, valid Phase-6 hard blocker). Impact ceiling = local DoS / heap info-leak.

Reproduce

./build.sh                       # cc -O2 -Wall -o craft_img craft_img.c
./craft_img crafted.img 0xFFFF
# as root (the victim mounting the attacker image):
kldload hpfs
DEV=$(vnconfig -c vn $(pwd)/crafted.img | grep -oE 'vn[0-9]+' | head -1)
mount -t hpfs -o ro /dev/$DEV /mnt/df0863   # UNPATCHED: Fatal trap 12 in hpfs_cpload+0xa7

Expected on the unpatched kernel (serial boot.log):

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xfffff800679c700c
fault code = supervisor read data, page not present
Stopped at hpfs_cpload+0xa7: cmpw %si,-0x6c(%rdx)

Expected on the fixed module: mount_hpfs: /dev/vnN: No such file or directory (ENOENT β€” the existing "no CP match" return propagated cleanly out of hpfs_cpload β†’ hpfs_cpinit β†’ hpfs_mountfs), guest stays up.

The fix (fix.diff)

Single hunk in sys/vfs/hpfs/hpfs_subr.c, immediately after the buffer is cast to struct cpdsec *: 1. Reject any CPD sector whose d_magic != CPD_MAGIC (defense in depth β€” the on-disk magic was previously ignored). 2. If d_cpfirst >= nitems(d_cpdblk), return ENOENT. 3. Clamp d_cpcnt to nitems(d_cpdblk) (== 3) so the loop can never read past the fixed-size array.

nitems() is already in sys/sys/param.h:399 (NELEM). Minimal, one logical change; preserves existing return values for legitimate images.

Validated: built the single-fix hpfs.ko, kldload'd it, re-ran the same PoC β€” panic is gone, mount returns ENOENT promptly, guest stays up (deterministic over 2 runs). See VERDICT.md, fix_run.log, panic.txt.

VERDICT.md verdict full narrative: mechanism, reachability, exploit-chain assessment, fix, validation
↓ download raw

DF-0863 β€” hpfs_cpload OOB read of cpdsec via unchecked d_cpcnt past fixed d_cpdblk[3] array

VERDICT: REPRODUCED (panic / OOB read via crafted HPFS image). FIX VALIDATED.

Severity (per finding): Medium. Impact demonstrated: kernel panic at mount of a crafted HPFS image (Fatal trap 12, page fault in hpfs_cpload+0xa7, "supervisor read data, page not present").


1. THE BUG (root cause)

hpfs_cpload reads a Code-Page Data Sector (struct cpdsec, sys/vfs/hpfs/hpfs.h:283) off disk via bread(... DEV_BSIZE ...) -- a 512-byte buffer -- and then walks its d_cpdblk[] array using two fields that come straight from the attacker-controlled image with NO validation:

sys/vfs/hpfs/hpfs_subr.c:228 for (i=cpdsp->d_cpfirst; id_cpcnt; i++) { if (cpdsp->d_cpdblk[i].b_cpid == cpibp->b_cpid) { <-- OOB READ bcopy(cpdsp->d_cpdblk + i, cpdbp, sizeof(struct cpdblk)); ...

But d_cpdblk[] is a FIXED-SIZE array of exactly 3 entries (sys/vfs/hpfs/hpfs.h:289 -- struct cpdblk d_cpdblk[3];), while d_cpcnt and d_cpfirst are u_int16_t read verbatim off the disk. An attacker setting d_cpcnt = 0xFFFF and d_cpfirst = 0 makes the loop walk past the array AND past the 512-byte bp->b_data buffer.

Layout of struct cpdsec (sizeof == 434 in the on-disk buffer):

+0 d_magic u32 (CPD_MAGIC 0x894521F7) +4 d_cpcnt u16 <-- ATTACKER VALUE +6 d_cpfirst u16 <-- ATTACKER VALUE +8 d_checksum[3] u323 +20 d_offset[3] u163 +26 d_cpdblk[3] struct cpdblk[3] (sizeof(cpdblk)==136 -> 3*136 = 408)

=> d_cpdblk occupies bytes [26 .. 434) of the cpd_sec inside the 512-byte buffer. Reading d_cpdblk[i].b_cpid (at struct offset 26 + i*136 + 2): i=0,1,2 : in-bounds (the legit 3 entries) i=3 : b_cpid at byte 436 -- STILL inside the 512-byte buffer i=4 : b_cpid at byte 572 -- OOB PAST bp->b_data i=5.. : further OOB; eventually crosses a page boundary -> page fault

With d_cpcnt = 0xFFFF the loop runs until it reads into an unmapped kernel page and faults. This is the demonstrated primitive: an OOB read of arbitrary length driven by an attacker-controlled u16. With a smaller crafted d_cpcnt (e.g. 5) the same OOB read occurs but is bounded, and if any OOB byte happens to equal cpibp->b_cpid, the subsequent bcopy(cpdsp->d_cpdblk + i, cpdbp, sizeof(struct cpdblk)) at line 230 also leaks 136 bytes of out-of-buffer kernel memory into the in-memory hpm_cpdblk[] table (used for case conversion in hpfs_toupper), which can then be exfiltrated to userspace via filenames.

Reachability: hpfs_cpload is called from hpfs_cpinit (hpfs_subr.c:297) during hpfs_mountfs (hpfs_vfsops.c:305) -- i.e. at MOUNT time, for any HPFS image whose SpareBlock advertises sp_cpinum > 0 (the default for any real HPFS volume).


2. REACHABILITY / THREAT MODEL

HPFS is optional hpfs -- NOT compiled into X86_64_GENERIC, but shipped as the loadable module /boot/kernel/hpfs.ko. An admin enables HPFS support by kldload hpfs (a one-time, standard action analogous to enabling any filesystem type -- it only registers the parser; it is NOT part of any privilege chain). The attacker supplies a crafted HPFS image; the victim (root, or an unprivileged user after vfs.usermount=1 + an image/memory-disk owned by them -- both realistic admin preconditions per AGENT.md) mounts it and the kernel faults in hpfs_cpload. This is a classic filesystem-image-parsing memory-safety bug. Matches Medium severity.


3. REPRODUCTION EVIDENCE (unpatched #0 baseline)

Trigger: crafted.img with sp_cpinum=1, d_cpcnt=0xFFFF, d_cpfirst=0 (craft_img.c). Run on the default GENERIC kernel 6.5-DEVELOPMENT #0 (INVARIANTS ON):

kldload hpfs; vnconfig -c vn crafted.img; mount -t hpfs -o ro /dev/vnN /mnt

Serial console (boot.log), deterministic across 3 runs: Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffff800679c700c (varies per run) fault code = supervisor read data, page not present current process = 941 Stopped at hpfs_cpload+0xa7: cmpw %si,-0x6c(%rdx)

The faulting instruction cmpw %si,-0x6c(%rdx) is the compiler's lowering of cpdsp->d_cpdblk[i].b_cpid == cpibp->b_cpid (hpfs_subr.c:229). The fault code "supervisor READ data, page not present" confirms an OOB READ -- there is no write on this path before the fault. Guest DOWN. (See panic.txt + run.log + run.2.log.)


4. EXPLOIT-CHAIN ASSESSMENT (why this stops at panic, not uid0)

The reachable primitive at the demonstrated trigger is an OOB READ. The faulting access reads a u16 (b_cpid) past the 512-byte bp->b_data buffer and the bcopy (line 230) is also a READ of 136 bytes into a kernel-owned hpm_cpdblk[] slot -- it does not write into any victim object that could be converted to privilege. There is no controllable kernel WRITE on this path: hpfs_cpload only fills cpdbp (a slot in the freshly-allocated hpm_cpdblk[] array owned by the mounting process) and never mutates a shared/refcounted/ops-vector structure.

This is a VALID hard blocker per Phase 6: "read-only primitive (pure OOB read); no write, no corruption to convert". Impact ceiling = local DoS (mount-time panic) and, with a tighter-crafted d_cpcnt (e.g. 5), an information leak of up to ~136 bytes of out-of-buffer kernel memory into the case-conversion tables (potentially exfiltrable via filenames). No unpriv->root escalation is derivable. Correctly classified as a memory-safety / DoS bug.


5. THE FIX (fix.diff)

Single hunk in sys/vfs/hpfs/hpfs_subr.c, immediately after the buffer is cast to struct cpdsec *:

(a) Reject any CPD sector whose d_magic is not CPD_MAGIC (defense in depth; the on-disk magic was previously ignored entirely).

(b) If d_cpfirst >= nitems(d_cpdblk), return ENOENT (the existing "no match" sentinel) -- the requested code page cannot possibly be in this sector.

(c) Clamp d_cpcnt to nitems(d_cpdblk) (== 3) so the loop can never read past the fixed-size array, regardless of the on-disk value.

nitems() is already defined in sys/sys/param.h:399 (NELEM), so no new include is needed. The fix is minimal (one logical change: bound the loop) and preserves the existing control flow / return values for legitimate images (where d_cpcnt <= 3).


6. FIX VALIDATION (single-fix hpfs.ko)

Because the bug lives entirely in the loadable HPFS module (NOT in the kernel text), the single-fix artifact is the rebuilt /boot/kernel/hpfs.ko (sha256 aba03ecb06837d51e3f89560a22b49325bf7c577ee43825d23d7c789f2d1f126) -- no full kernel rebuild or reboot was required (kldunload/kldload).

BEFORE (unpatched #0, /boot/kernel/hpfs.ko from the audit baseline): mount -> Fatal trap 12, hpfs_cpload+0xa7, "supervisor read data, page not present", guest DOWN. Deterministic over 3 runs.

AFTER (fixed module loaded): mount -> "mount_hpfs: /dev/vnN: No such file or directory" (ENOENT, the existing "no CP match" return propagated cleanly out of hpfs_cpinit -> hpfs_mountfs -> mount(2)). Guest UP, no panic, no dmesg warnings. Deterministic over 2 runs.

=> fix_status: fixed. (See fix_run.log + fix_run.2.log.)


7. FILES IN THIS EVIDENCE PACK

craft_img.c -- builds the crafted HPFS image (d_cpcnt configurable, default 0xFFFF) build.sh -- cc -O2 -Wall -o craft_img craft_img.c run.sh -- kldload hpfs + vnconfig + mount (root; triggers the bug) fix.diff -- the validated git-apply-able fix (hpfs_subr.c) panic.txt -- unpatched panic signature from boot.log (crash proof) run.log -- baseline (BEFORE) run output (panic) run.2.log -- baseline (BEFORE) 2nd run (determinism) fix_run.log -- fixed-module (AFTER) run output (no panic) fix_run.2.log -- fixed-module (AFTER) 2nd run (determinism) fix_build.log -- full module build log (rc=0, -Werror clean) env.txt -- guest uname, cc, module sha256, sysctls VERDICT.md -- this file manifest.json -- machine-readable catalog

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. The SAME PoC (crafted.img with d_cpcnt=0xFFFF) panics on the unpatched /boot/kernel/hpfs.ko (b8bcb64b... audit baseline) with Fatal trap 12 in hpfs_cpload+0xa7 (supervisor read data, page not present) and does NOT panic on the single-fix hpfs.ko (aba03ecb...) -- mount returns 'mount_hpfs: /dev/vnN: No such file or directory' (ENOENT, the existing no-CP-match return propagated cleanly), guest stays up. Deterministic over 2 runs in each direction. Because the bug is module-only, validation used kldunload/kldload of the rebuilt hpfs.ko (no kernel rebuild/reboot required) -- the kernel text and kern.version are unchanged across the before/after comparison. => fix closes the bug.

BEFORE (unpatched hpfs.ko b8bcb64b, kern #0):
  [run] mounting crafted HPFS image ...
  Fatal trap 12: page fault while in kernel mode
  fault virtual address  = 0xfffff800679c700c
  fault code             = supervisor read data, page not present
  Stopped at  hpfs_cpload+0xa7:  cmpw %si,-0x6c(%rdx)
  guest: DOWN.

AFTER (fixed hpfs.ko aba03ecb, same kern #0):
  [run] mounting crafted HPFS image ...
  mount_hpfs: /dev/vn4: No such file or directory
  MOUNT_RC=71
  guest: UP (vm.sh status => up); no 'Fatal trap' in boot.log.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (kernel text unchanged -- the bug lives entirely in the loadable HPFS module; the single-fix artifact is the rebuilt /boot/kernel/hpfs.ko, sha256 aba03ecb06837d51e3f89560a22b49325bf7c577ee43825d23d7c789f2d1f126, kldunload/kldload'd without a reboot)

Confirmed kernel references

Detail

Exploit chain

Read-only primitive -> VALID Phase-6 hard blocker, no escalation chain derivable. The reachable bug is an OOB READ: the faulting access reads a u16 (b_cpid) past the 512-byte bp->b_data buffer (fault code 'supervisor READ data'), and the bcopy at hpfs_subr.c:230 also reads 136 bytes of out-of-buffer kernel memory INTO a slot of the freshly-allocated hpm_cpdblk[] case-conversion table owned by the mounting process. hpfs_cpload never writes into a shared/refcounted/ops-vector/victim object that could be converted to privilege -- it only fills cpdbp. With a tighter d_cpcnt (e.g. 5) the same path can leak up to ~136 bytes of kernel heap into the case-conversion tables (potentially exfiltrable via filenames), so the impact ceiling is local DoS (mount-time panic) + bounded heap info-leak. No unpriv->root escalation. No exploit.c/chain.c written because the primitive is genuinely read-only.

Evidence (decisive lines)

BEFORE (unpatched #0, deterministic over 3 runs):
  Fatal trap 12: page fault while in kernel mode
  fault virtual address  = 0xfffff800679c700c   (varies per run: 547d700c / 52d4700c / 679c700c)
  fault code             = supervisor read data, page not present
  current process        = 941
  Stopped at  hpfs_cpload+0xa7:  cmpw %si,-0x6c(%rdx)
  guest: DOWN.

AFTER (fixed hpfs.ko, deterministic over 2 runs):
  mount_hpfs: /dev/vn4: No such file or directory   (ENOENT -- existing 'no CP match' return propagated cleanly)
  MOUNT_RC=71
  guest: UP, no panic, no dmesg warnings.

PoC changes

findings/poc/DF-0863/ did not exist (DB poc_path pointed at a non-existent dir); created the entire evidence pack from scratch: craft_img.c (HPFS image crafter writing a valid SuperBlock+SpareBlock+BitMap dir+BitMap band+CPI sector with sp_cpinum=1 + a CPD sector carrying d_cpcnt=0xFFFF/d_cpfirst=0), build.sh, run.sh (kldload hpfs + vnconfig + mount), VERDICT.md, README.md, manifest.json, fix.diff, and all logs. Image geometry chosen so hpfs_bminit() succeeds first (small su_btotal=128, valid bitmap pointers) and execution reaches hpfs_cpinit -> hpfs_cpload where the cited OOB fires.

Verified recommended fix

Single hunk in sys/vfs/hpfs/hpfs_subr.c, immediately after the cpdsp cast (line 226): (1) reject any CPD sector whose d_magic != CPD_MAGIC (defense in depth -- the on-disk magic was previously ignored entirely); (2) if d_cpfirst >= nitems(d_cpdblk), brelse + return ENOENT (the existing 'no CP match' sentinel); (3) clamp d_cpcnt to nitems(d_cpdblk) (== 3) so the loop at line 228 can never index past the fixed-size array regardless of the on-disk value. nitems() is already defined in sys/sys/param.h:399 (NELEM) so no new include is needed. Minimal, one logical change; preserves existing return values for legitimate images (d_cpcnt <= 3). This is a new fix (no prior ## Recommended fix proposal in a finding markdown exists -- DF-0863 has no markdown file yet). Full git-apply-able diff in findings/poc/DF-0863/fix.diff.

Verdict

REPRODUCED. The bug is real: hpfs_cpload (sys/vfs/hpfs/hpfs_subr.c:228) iterates for(i=d_cpfirst; i<d_cpcnt; i++) cpdsp->d_cpdblk[i].b_cpid where d_cpfirst/d_cpcnt are u_int16 read verbatim off the attacker-controlled HPFS image, but d_cpdblk[] is a fixed-size array of exactly 3 entries (sys/vfs/hpfs/hpfs.h:289) inside a 512-byte DEV_BSIZE bp->b_data buffer. A crafted image with d_cpcnt=0xFFFF, d_cpfirst=0 makes the loop walk past the array and past bp->b_data. Confirmed deterministically on the default GENERIC kernel 6.5-DEVELOPMENT #0 (INVARIANTS ON) over 3 runs: every mount panics with Fatal trap 12, page fault, supervisor read data, page not present, Stopped at hpfs_cpload+0xa7: cmpw %si,-0x6c(%rdx) -- the compiler's lowering of the b_cpid comparison at hpfs_subr.c:229. Reachable at mount time via hpfs_cpinit(hpfs_subr.c:297) <- hpfs_mountfs(hpfs_vfsops.c:305) for any HPFS image whose SpareBlock advertises sp_cpinum > 0. Threat model: crafted HPFS image mounted by root (or unpriv user after vfs.usermount=1 + owned memory disk) -- classic FS-image-parsing memory-safety bug.