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

hpfs_cpinit unbounded code-page inner loop writes attacker data past hpm_cpdblk allocation (heap overflow)

Summary

hpfs_subr.c:274 cpicnt=sp_cpinum (on-disk u32 unvalidated). :276 kmalloc(cpicnt*sizeof(cpdblk)). :292 inner for(i=0;i<cpisp->s_cpicnt;i++,cpicnt--,cpdbp++,cpibp++) bounded by s_cpicnt per-sector NOT sp_cpinum. Craft sp_cpinum=1 sector s_cpicnt=0x1F: writes 30*136=4080 bytes past 136-byte allocation. Each iter hpfs_cpload bcopy(d_cpdblk[i],cpdbp,136) controlled disk data into heap. Mount-time heap overflow = potential priv-esc via slab grooming.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0861 Β· 21 files
FileTypeDescriptionSize
harness.c trigger-source deterministic loop+bcopy transcription with poisoned allocator; proves 4080-byte OOB write 11.7 KB view raw
craft_img.py trigger-source HPFS image crafter: forged sp_cpinum=1 + cpisec s_cpicnt=0x1F 10.1 KB view raw
df861.img disk-image crafted 64KB HPFS image (binary) for live mount test 64.0 KB ↓ download
df861-huge.img disk-image variant sp_cpinum=1000 to exercise the fix's EINVAL gate 64.0 KB ↓ download
mount_hpfs_simple.c trigger-source mount(2) helper (RO mount triggers cpinit) 1.2 KB view raw
build.sh build-script cc -O2 -Wall -o harness harness.c 159 B view raw
run.sh run-script ./harness 110 B view raw
build.log build-log final successful harness build 480 B view raw
run.log run-log decisive harness run: BUG_CONFIRMED=1, OOB=4080, ATTACKER_CONTROLLED=1 823 B view raw
panic.txt panic-signature Fatal trap 9 in kqueue_register from slab-corruption cascade (live mount run 1) 774 B view raw
dmesg.txt dmesg live guest dmesg excerpt 858 B view raw
boot.log.baseline boot-log baseline serial log before the panic (clean boot, no panic) 12.9 KB ↓ download
env.txt environment uname, cc version, INVARIANTS-in-GENERIC confirmation 376 B view raw
fix.diff suggested-fix bound inner loop (&& cpicnt>0), cap s_cpicnt to CPIS_NCPI, validate sp_cpinum (HPFS_SP_CPINUM_MAX=256) 1.7 KB view raw
fix_build.log build-log Phase 8 build of patched hpfs.ko module (rc=0) 9.6 KB view raw
fix_run.log run-log Phase 8 patched-module validation: clean mount 3 cycles + EINVAL gate for sp_cpinum=1000 2.2 KB view raw
VERDICT.md verdict full narrative: mechanism, trigger, impact, fix validation 7.9 KB ↓ raw
README.md readme finding summary, build/run, expected vs fixed behaviour 3.9 KB ↓ raw
manifest.json manifest this artifact catalog 3.9 KB 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 finding summary, build/run, expected vs fixed behaviour
↓ download raw

DF-0861 β€” Hand-crafted HPFS image triggering the hpfs_cpinit heap OOB write

Finding: sys/vfs/hpfs/hpfs_subr.c:274,276,282,292,297 (+ bcopy at :230) β€” hpfs_cpinit() at mount time sizes its hpm_cpdblk array with the on-disk sp_cpinum (unvalidated u32) but bounds its inner fill loop with the per-sector cpisp->s_cpicnt (a separate on-disk u32). A crafted HPFS image whose SpareBlock carries sp_cpinum=1 and whose single code-page-info sector carries s_cpicnt=0x1F (31) causes the inner loop to run 31 times against a 1-entry array, writing 30 Γ— 136 = 4080 bytes of attacker-controlled disk data past the 136-byte kmalloc β€” a kernel heap OOB write.

This evidence pack contains:

file what it is
harness.c Deterministic userspace transcription of the loop + bcopy with a poisoned allocator; proves byte-for-byte the 4080-byte OOB write and that the fix reduces it to 0.
craft_img.py HPFS image crafter: minimal image with forged sp_cpinum/s_cpicnt.
df861.img Crafted 64 KB HPFS image (sp_cpinum=1, s_cpicnt=0x1F) (binary).
df861-huge.img Variant (sp_cpinum=1000) to exercise the fix's EINVAL gate.
mount_hpfs_simple.c Tiny mount(2) helper (RO mount is enough; cpinit runs regardless of flags).
build.sh cc -O2 -Wall -o harness harness.c
run.sh ./harness
build.log Final successful harness build.
run.log Decisive harness run (full output).
panic.txt boot.log panic signature: Fatal trap 9 in kqueue_register from the slab-corruption cascade (live mount of df861.img).
dmesg.txt Live guest dmesg excerpt.
env.txt Guest environment.
fix.diff git apply-able fix: bound inner loop (&& cpicnt > 0), cap s_cpicnt per sector, validate sp_cpinum.
fix_build.log Phase 8 build of patched hpfs.ko.
fix_run.log Phase 8 re-run with patched module: clean mount (3 cycles), EINVAL gate fires for sp_cpinum=1000.
manifest.json Artifact catalog.
VERDICT.md Full narrative: mechanism, trigger, impact, fix validation.

How to reproduce

# 1. deterministic OOB-proof harness (proves byte-exact 4080-byte overflow)
./build.sh && ./run.sh

# 2. live trigger on a DragonFly guest (root for mount)
ssh dfbsd 'cc -O2 -I/usr/src/sys -o /root/mount_hpfs_simple /path/to/mount_hpfs_simple.c'
ssh dfbsd 'kldload hpfs; vnconfig -c vn1 /path/to/df861.img; /root/mount_hpfs_simple /dev/vn1 /mnt'
# mount returns 0 (cpinit completes) -> 4080-B heap OOB write -> slab corruption
# -> probabilistic panic (e.g. Fatal trap 9 in kqueue_register); see panic.txt

Expected vs fixed behaviour

kernel / module live mount of df861.img harness
unpatched hpfs.ko 4080-B OOB write β†’ slab corruption β†’ panic (probabilistic); mount itself returns 0 DF_0861_BUG_CONFIRMED=1 OOB=4080
patched hpfs.ko clean mount (1 in-bounds write); 3 cycles OK; guest UP. Forged sp_cpinum=1000 β†’ EINVAL (hpfs_cpinit: forged sp_cpinum 1000 > max 256) DF_0861_FIX_REJECTS_OVERFLOW=1 OOB=0
VERDICT.md verdict full narrative: mechanism, trigger, impact, fix validation
↓ download raw

DF-0861 β€” hpfs_cpinit unbounded code-page inner loop β†’ heap OOB write

Verdict

REPRODUCED. The heap OOB write in hpfs_cpinit is real and confirmed both by a deterministic userspace harness (4080 bytes, attacker-controlled) and by live mounting of a crafted HPFS image on the DragonFly #0 GENERIC kernel (slab corruption β†’ Fatal trap 9 panic in kqueue_register). The authored fix.diff is VALIDATED by hot-swapping a rebuilt hpfs.ko: the crafted image mounts cleanly (1 in-bounds write, no OOB, guest survives 3 cycles) and a forged sp_cpinum=1000 is now rejected at the validation gate with EINVAL.

Root cause (confirmed path:line)

hpfs_cpinit() runs at mount time (hpfs_vfsops.c:305) and loads code-page data into a kernel heap array:

line code role
sys/vfs/hpfs/hpfs_subr.c:274 cpicnt = hpmp->hpm_sp.sp_cpinum; total CP count, unvalidated on-disk u32
sys/vfs/hpfs/hpfs_subr.c:276 kmalloc(cpicnt * sizeof(struct cpdblk), …) array sized by cpicnt
sys/vfs/hpfs/hpfs_subr.c:282 while (cpicnt > 0) outer loop bound = cpicnt
sys/vfs/hpfs/hpfs_subr.c:292 for (i=0; i<cpisp->s_cpicnt; i++, cpicnt--, cpdbp++, cpibp++) inner loop bound = per-sector s_cpicnt (separate on-disk u32), NOT the remaining cpicnt
sys/vfs/hpfs/hpfs_subr.c:297 hpfs_cpload(hpmp, cpibp, cpdbp) per-entry loader
sys/vfs/hpfs/hpfs_subr.c:230-231 bcopy(cpdsp->d_cpdblk + i, cpdbp, sizeof(struct cpdblk)) writes 136 B of attacker disk data into cpdbp

The outer while decrements cpicnt (the total), but the inner for is bounded by cpisp->s_cpicnt (a per-sector count) and runs to completion even after cpicnt has reached 0 (it goes negative; cpicnt is int). cpdbp is advanced on every inner iteration, so a single code-page-info sector that advertises s_cpicnt > sp_cpinum writes sizeof(struct cpdblk) (= 136 B) through cpdbp for s_cpicnt iterations, overshooting the cpicnt-sized hpm_cpdblk array.

Trigger values & primitive

forged field value effect
sp_cpinum (SpareBlock) 1 cpicnt=1 β†’ kmalloc(1Γ—136 = 136 B)
cpisec.s_cpicnt (code-page-info sector) 0x1F (31) inner for runs 31 times
  • iteration 0: cpdbp = &hpm_cpdblk[0] β€” in-bounds (136 B)
  • iterations 1..30: cpdbp = &hpm_cpdblk[1..30] β€” 30 OOB writes Γ— 136 B = 4080 B

Each write is a bcopy of a struct cpdblk (136 B) from a crafted code-page-data sector, of which b_upcase[0x80] (128 B) is freely forgeable β†’ the OOB content is fully attacker-controlled.

Reproduction

1. Deterministic harness (harness.c)

A faithful userspace transcription of the loop + bcopy against the exact on-disk struct layouts from sys/vfs/hpfs/hpfs.h, with a poisoned allocator modelling the slab neighbourhood. Output (decisive):

--- BUG MODE (unpatched hpfs_cpinit) ---
  sp_cpinum=1 -> cpicnt=1 -> kmalloc(hpm_cpdblk)=136 bytes
  cpisec.s_cpicnt=31 -> inner for() runs 31 times
  result: in-bounds writes=1  OOB writes=30  OOB bytes=4080
  first-OOB cpdblk b_upcase[0..1] = 0x41 0x42 (marker) -> ATTACKER-CONTROLLED
  DF_0861_BUG_CONFIRMED=1
  DF_0861_BUG_OOB_WRITE_BYTES=4080

2. Live trigger on #0 GENERIC (INVARIANTS ON)

Crafted df861.img (SpareBlock sp_cpinum=1, cpisec s_cpicnt=0x1F), vnconfig'd and mounted read-only. hpfs_cpinit runs unconditionally at mount time (hpfs_vfsops.c:305) regardless of mount flags, so an RO mount suffices. The mount returns success (cpinit completes), then the 4080-byte OOB write has corrupted a neighbouring slab object; a subsequent kernel operation panics:

Fatal trap 9: general protection fault while in kernel mode
instruction pointer = 0x8:0xffffffff8063b1c6
current process     = 996
Stopped at      kqueue_register+0x526:  movq    (%r15),%rdx
db>

(The cascade timing is probabilistic, as is normal for slab corruption: run 1 panicked in kqueue_register; runs 2 & 3 silently corrupted and the guest survived until reset. The deterministic harness proves the 4080-byte OOB write happens on every mount; the live runs confirm the code path is exercised and the corruption is real.)

Impact / exploitability

  • Primitive: 4080-byte, attacker-controlled heap OOB write at mount time, into the slab neighbourhood of a 136-byte kmalloc(…, M_HPFSMNT).
  • Trigger: mount-time only. Mounting requires root by default; the realistic unprivileged path requires vfs.usermount=1 plus a root-created
  • chowned image (an acceptable precondition per the threat model, but not the default config).
  • GENERIC (INVARIANTS ON): the write itself does not trip INVARIANTS (slab checks fire on alloc/free, not per-bcopy); the corruption silently damages neighbours and probabilistically panics (DoS) when a victim object is next touched. A clean uid=0 on GENERIC would require slab grooming that evades INVARIANTS β€” the demonstrated, realistic impact on the default kernel is heap corruption β†’ panic (DoS).
  • Non-default (noinv, INVARIANTS OFF): the corruption is silent and the primitive is a strong candidate for slab grooming β†’ function-pointer / ucred overwrite. That is a non-default-kernel characterization, not a default-GENERIC uid0.

Realistic impact ceiling: local DoS / heap corruption from a mount operation (root, or usermount precondition); a credible privesc primitive only under the usermount precondition on a non-INVARIANTS kernel.

Fix

fix.diff (git-apply-able, validated): 1. Bound the inner loop β€” for (i=0; i<cpisp->s_cpicnt && cpicnt > 0; …) so cpdbp can never advance past the allocated array (sys/vfs/hpfs/hpfs_subr.c:307). 2. Cap s_cpicnt per sector to CPIS_NCPI (0x1F, the s_cpi[] array bound) so cpibp reads stay in-bounds (:305-306). 3. Validate sp_cpinum against HPFS_SP_CPINUM_MAX (256), returning EINVAL for forged superblocks (:283-287) β€” also prevents the cpicnt * sizeof(struct cpdblk) kmalloc from wrapping/huge on a hostile sp_cpinum.

The constants are added to sys/vfs/hpfs/hpfs.h.

Fix validation (Phase 8 β€” module hot-swap)

Applied fix.diff to in-guest /usr/src, rebuilt only the hpfs.ko KLD module (make in /usr/src/sys/vfs/hpfs, ~25 s), installed to /boot/kernel/hpfs.ko (sha256 01434d8ffb77c5963649a3236f117e79535ccb39369e76d566987875c892cb65), kldunload/kldload to hot-swap on the running #0 kernel, then:

image unpatched hpfs.ko patched hpfs.ko
df861.img (sp_cpinum=1, s_cpicnt=0x1F) 4080-B OOB write β†’ slab corruption β†’ panic in kqueue_register (run 1) clean mount, 1 in-bounds write, 3 mount/unmount cycles OK, guest UP
df861-huge.img (sp_cpinum=1000) 136 000-B kmalloc + 30 OOB writes EINVAL rejected at the gate; dmesg: hpfs_cpinit: forged sp_cpinum 1000 > max 256; guest UP

Fix closes the bug: the inner loop can no longer overshoot the array, and forged superblocks are rejected before any allocation/loop runs. No full kernel rebuild was required (KLD module hot-swap).

PoC changes

The runner created the entire evidence pack from scratch (the finding folder did not exist). Files authored:

  • craft_img.py β€” HPFS image crafter (forged SpareBlock sp_cpinum=1 + cpisec s_cpicnt=0x1F + a cpdsec sector with attacker marker content)
  • harness.c β€” deterministic OOB-write proof (faithful loop+bcopy transcription + poisoned allocator + fixed-mode control)
  • df861.img / df861-huge.img β€” crafted 64 KB HPFS images (binary)
  • mount_hpfs_simple.c β€” mount(2) helper (RO mount is enough; cpinit runs regardless of mount flags)
  • build.sh / run.sh β€” exact repro scripts
  • fix.diff β€” git-apply-able fix (bound inner loop + cap s_cpicnt + validate sp_cpinum)
  • panic.txt, dmesg.txt, env.txt, build.log, run.log, fix_build.log, fix_run.log, manifest.json, this VERDICT.md

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix via KLD module hot-swap (no full kernel rebuild needed). BEFORE: on the unpatched #0 + stock hpfs.ko, mounting df861.img (sp_cpinum=1, s_cpicnt=0x1F) writes 4080 bytes OOB (harness-proven) and the slab corruption cascaded to a Fatal trap 9 panic in kqueue_register (run 1). AFTER: applying fix.diff to /usr/src, rebuilding only hpfs.ko (rc=0), and hot-swapping (kldunload id4 -> kldload id5), the SAME df861.img mounts cleanly (1 in-bounds cpdblk write, mount_rc=0), survives 3 mount/unmount cycles with the guest UP, and the df861-huge.img variant (sp_cpinum=1000) is now rejected at the validation gate with EINVAL (dmesg: 'hpfs_cpinit: forged sp_cpinum 1000 > max 256') => fix closes the bug.

baseline(unpatched): mount df861.img -> 4080-B OOB write -> 'Fatal trap 9: general protection fault ... Stopped at kqueue_register+0x526' (guest DOWN). patched(hot-swapped hpfs.ko): mount df861.img -> 'mounted /dev/vn1 on /mnt/test861 (RO)' x3 cycles, guest UP; mount df861-huge.img -> 'mount(hpfs,...): Invalid argument' + dmesg 'hpfs_cpinit: forged sp_cpinum 1000 > max 256', guest UP.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (baseline kernel with hot-swapped patched hpfs.ko, sha256 01434d8ffb77c5963649a3236f117e79535ccb39369e76d566987875c892cb65)

Confirmed kernel references

Detail

Exploit chain

Primitive: 4080-byte, fully attacker-controlled (b_upcase[0x80]=128B per write) heap OOB write at MOUNT time into the slab neighbourhood of a 136-byte kmalloc(M_HPFSMNT). Trigger is mount-only (hpfs_vfsops.c:305 calls hpfs_cpinit unconditionally, even on RO mount). Mounting requires root by default; the realistic unprivileged path requires vfs.usermount=1 + a root-created+chowned image (acceptable precondition per threat model, but not default config). On GENERIC (INVARIANTS ON) the bcopy itself does not trip slab checks (they fire on alloc/free, not per-bcopy), so the corruption silently damages slab neighbours and PANICS probabilistically when a victim object (here a kqueue structure) is next dereferenced -- demonstrated live (run 1). A clean uid=0 on GENERIC would require slab grooming that evades INVARIANTS, which is the documented hard bar for default-GENERIC write bugs; under the usermount precondition on a non-INVARIANTS (noinv) kernel this is a strong privesc primitive (non-default-kernel characterization). Honest impact on default GENERIC: heap corruption -> panic (DoS) from a mount operation. Valid hard blocker for a default-GENERIC uid0: the trigger (mount) is root-only by default, so there is no privilege boundary to cross on the default config. Chain characterized, not escalated to uid=0 -- no exploit.c written because the trigger is mount-time/root-only and the realistic GENERIC ceiling is DoS.

Evidence (decisive lines)

harness: 'in-bounds writes=1  OOB writes=30  OOB bytes=4080 ... DF_0861_BUG_CONFIRMED=1 ... first-OOB cpdblk b_upcase[0..1] = 0x41 0x42 -> ATTACKER-CONTROLLED'. Live mount of df861.img -> 'mounted /dev/vn1 on /mnt/test861 (RO)' then panic: 'Fatal trap 9: general protection fault while in kernel mode / instruction pointer = 0x8:0xffffffff8063b1c6 / current process = 996 / kernel: type 9 trap, code=0 / Stopped at kqueue_register+0x526: movq (%r15),%rdx / db>'. (runs 2 & 3 silently corrupted, guest survived until reset -- probabilistic cascade.)

PoC changes

Created the entire evidence pack from scratch (folder did not exist). craft_img.py: HPFS image crafter forging SpareBlock sp_cpinum=1 + a cpisec sector with s_cpicnt=0x1F + a cpdsec sector whose d_cpdblk[0].b_upcase carries an attacker marker. harness.c: faithful userspace transcription of the cpinit loop + cpload bcopy with a poisoned allocator, proving the 4080-byte OOB write byte-for-byte and that the fix reduces it to 0. mount_hpfs_simple.c: mount(2) helper (RO mount suffices since cpinit is flag-independent). df861.img/df861-huge.img: crafted images. fix.diff: git-apply-able fix. All logs (build/run/panic/dmesg/fix_build/fix_run/env) saved untrimmed.

Verified recommended fix

In sys/vfs/hpfs/hpfs_subr.c hpfs_cpinit: (1) bound the inner loop so cpdbp can never advance past the array -- change the for at :292/307 to 'for (i=0; is_cpicnt && cpicnt > 0; ...)'; (2) cap cpisp->s_cpicnt to CPIS_NCPI (0x1F, the s_cpi[] array bound) so cpibp reads stay in-bounds; (3) validate sp_cpinum against HPFS_SP_CPINUM_MAX (256) and return EINVAL for forged superblocks (also prevents the cpicnt*sizeof(cpdblk) kmalloc from wrapping/huge). Constants added to sys/vfs/hpfs/hpfs.h. Supersedes the finding's proposal (which suggested only bounding the loop) by adding the s_cpicnt per-sector cap and the sp_cpinum allocation guard. Full git-apply-able diff in findings/poc/DF-0861/fix.diff.

Verdict

REPRODUCED. The bug is real: hpfs_cpinit (sys/vfs/hpfs/hpfs_subr.c:274) sizes its hpm_cpdblk array with the unvalidated on-disk sp_cpinum (kmalloc at :276) but bounds its inner fill loop with the SEPARATE per-sector cpisp->s_cpicnt (:292), advancing cpdbp on every iteration; a crafted image with sp_cpinum=1 and cpisec.s_cpicnt=0x1F writes 30*136=4080 bytes of attacker-controlled disk data past the 136-byte allocation (bcopy at hpfs_subr.c:230-231 via hpfs_cpload). Confirmed by a deterministic userspace harness (DF_0861_BUG_CONFIRMED=1, OOB=4080, ATTACKER_CONTROLLED=1) and by live RO mount of df861.img on the #0 GENERIC kernel, where the heap corruption cascaded into Fatal trap 9 (general protection fault) in kqueue_register+0x526 (process 996). No guard was added since filing -- the cited lines are unchanged in master.