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)
PoC verification
Evidence pack
findings/poc/DF-0861 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 |
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=1plus 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 cleanuid=0on 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 /ucredoverwrite. That is a non-default-kernel characterization, not a default-GENERICuid0.
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 SpareBlocksp_cpinum=1+ cpisecs_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 scriptsfix.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, thisVERDICT.md
Fix verification
fixedVALIDATED 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.
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; i
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.
No comments yet.