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)
PoC verification
Evidence pack
findings/poc/DF-0863 Β· 16 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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.
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; i
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
fixedVALIDATED 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.
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.
No comments yet.