Unbounded ab_busycnt in hpfs_splitalsec/hpfs_concatalsec/hpfs_alblk2alsec bcopy overflows alsec heap buffer
Summary
hpfs_alsubr.c:225 splitalsec n1=(busycnt+1)/2 n2=busycnt-n1. :229 bcopy source offset sizeof(alblk_t)+n1*sz destination sizeof(alblk_t) length n2*sz. busycnt=255 sz=8: src offset 1032 in 500B alsec read 544B OOB dst offset 8 write 536B OOB. :314 alblk2alsec bcopy(abp,nabp,sizeof(alblk_t)+sz*busycnt) busycnt=255 sz=12 total 3068B from fnode into 500B alsec write 2568B OOB heap. :271 concatalsec guard freecnt>busycnt both from disk both corruptible. Reachable: write/extend/truncate on crafted RW HPFS image. Kernel heap corruption C+I+A. Combined with slab grooming = potential priv-esc.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0859 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | readme | finding summary, build/run, expected vs fixed behaviour | 4.1 KB | β raw |
| VERDICT.md | verdict | full narrative: mechanism (3 bcopys traced), trigger, why-no-panic, Phase-6 impact ceiling, fix validation | 18.4 KB | β raw |
| harness.c | trigger-source | deterministic OOB-WRITE proof: faithful bcopy transcription of all 3 helpers + poisoned allocator + fixed-mode control | 18.4 KB | view raw |
| craft_img.py | trigger-source | HPFS image crafter: forged fnode fn_ab with busycnt=255/freecnt=0/fn_size=0x10000 | 7.7 KB | view raw |
| df859.img | trigger-source | crafted 64 KB HPFS image with forged fn_ab (binary, for live mount test) | 64.0 KB | β download |
| mount_rw.c | trigger-source | 30-line mount(2) helper to mount HPFS RW (works around mount_hpfs(8) forcing MNT_RDONLY) | 1.3 KB | view raw |
| build.sh | build-script | exact cc command to build the harness | 159 B | view raw |
| run.sh | run-script | exact harness invocation | 135 B | view raw |
| build.log | build-log | full compiler output of the final successful harness build | 66 B | view raw |
| run.log | run-log | decisive harness run (full output) showing OOB extents + fix rejection | 1.8 KB | view raw |
| run.1.log | run-log | stress-test run 1 of 3 (deterministic) | 1.8 KB | view raw |
| run.2.log | run-log | stress-test run 2 of 3 | 1.8 KB | view raw |
| run.3.log | run-log | stress-test run 3 of 3 | 1.8 KB | view raw |
| dmesg.txt | dmesg | live guest dmesg on UNPATCHED module: hpfs_addextentr: INTERNAL INCONSISTENCE chain proving hpfs_alblk2alsec ran | 1.3 KB | view raw |
| panic.txt | panic-signature | boot.log tail (no fatal trap β OOB write is silent on this run; see VERDICT.md 'Why no panic') | 1.4 KB | view raw |
| env.txt | environment | uname, cc version, sysctls (vm.randomize_mmap=0, vfs.usermount=0) | 303 B | view raw |
| fix.diff | suggested-fix | git-apply-able fix: validate ab_busycnt against container max (HPFS_FN_MAX_LEAF=8/NODE=12, AS_MAX_LEAF=40/NODE=60) in splitalsec/concatalsec/alblk2alsec before the bcopys | 4.0 KB | view raw |
| fix_build.log | build-log | full build output of the single-fix hpfs.ko module (Phase 8) | 5.8 KB | view raw |
| fix_run.log | run-log | patched-module re-run of dd writes: 'forged ab_busycnt 255 > fnode max' on every attempt, INTERNAL INCONSISTENCE chain disappears | 1.5 KB | view raw |
| manifest.json | manifest | this catalog | 4.6 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-0859 β Hand-crafted HPFS image triggering the ab_busycnt OOB WRITE
Finding: sys/vfs/hpfs/hpfs_alsubr.c:225,229,271,314 β
hpfs_splitalsec/hpfs_concatalsec/hpfs_alblk2alsec use the on-disk
ab_busycnt byte verbatim as a multiplier in bcopy length arithmetic
without validating it against the destination container size. A crafted RW
HPFS image whose file fnode carries a forged ab_busycnt=255 causes the
write path (hpfs_write β hpfs_extend β hpfs_addextent) to call
hpfs_alblk2alsec, whose bcopy(abp, nabp, sizeof(alblk_t)+sz*busycnt)
writes 3068 bytes into a freshly allocated 512-byte buffer-cache buffer
β a 2568-byte kernel heap OOB write.
This evidence pack contains:
| file | what it is |
|---|---|
harness.c |
Deterministic userspace transcription of all three buggy bcopys with a poisoned allocator; proves byte-for-byte the 536 / 1032 / 1548 / 1540 / 2556 / 2568-byte OOB writes. |
craft_img.py |
HPFS image crafter: minimal image with forged ab_busycnt=255. |
df859.img |
Crafted 64 KB HPFS image (binary). |
mount_rw.c |
Tiny mount(2) helper that mounts HPFS RW (DragonFly mount_hpfs(8) forces MNT_RDONLY unconditionally). |
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). |
run.1/2/3.log |
Stress-test runs (deterministic; same OOB extents every time). |
dmesg.txt |
Live guest dmesg showing the bug-fire chain hpfs_write β hpfs_extend β hpfs_addextent β hpfs_alblk2alsec β (silent OOB write) β retry β hpfs_addextentr: INTERNAL INCONSISTENCE. |
panic.txt |
boot.log tail (no fatal trap β the OOB write is silent on this run, corrupting neighbouring buffer-cache buffers without tripping INVARIANTS; see VERDICT.md "Why no panic"). |
env.txt |
Guest environment. |
fix.diff |
git apply-able fix: validate ab_busycnt against container max (HPFS_*_MAX_*) before the bcopys in splitalsec/concatalsec/alblk2alsec. |
fix_build.log |
Phase 8 build of patched hpfs.ko. |
fix_run.log |
Phase 8 re-run with patched module: every write now produces hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max and the silent-corruption chain disappears. |
manifest.json |
Artifact catalog. |
VERDICT.md |
Full narrative: mechanism, trigger, why-no-panic, fix validation. |
How to reproduce
# 1. deterministic OOB-proof harness (proves byte-exact overflow)
./build.sh && ./run.sh
# 2. live trigger on a DragonFly guest (root for mount, unpriv for write)
ssh dfbsd 'kldload hpfs; cc -O2 -I/usr/src/sys -o /root/mount_rw /path/to/mount_rw.c'
ssh dfbsd 'vnconfig -c vn1 /path/to/df859.img; /root/mount_rw /dev/vn1 /mnt'
ssh dfbsd-maxx 'dd if=/dev/zero of=/mnt/FILE bs=512 count=1 seek=1000 conv=notrunc'
# dmesg now shows: hpfs_addextentr: INTERNAL INCONSISTENCE (proof the bug ran)
Expected vs fixed behaviour
| kernel / module | live write trigger | harness |
|---|---|---|
| unpatched hpfs.ko | hpfs_alblk2alsec runs (silent 2568B OOB write); subsequent ops fail with EINVAL via INTERNAL INCONSISTENCE |
DF_0859_BUG_CONFIRMED=1 MAX_OOB=2568 |
| patched hpfs.ko | hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max β rejected at the validation gate; no bcopy fires |
DF_0859_FIX_REJECTS_FORGED_BUSYCNT=1 |
DF-0859 β VERDICT
Verdict: REPRODUCED (heap OOB write via unvalidated on-disk ab_busycnt on the HPFS write path; root-only mount + unprivileged write trigger; silent heap corruption on this run β primitive is write-capable but realistic escalation is gated by the root-mount precondition; see "Impact ceiling")
Status: reproduced (live kernel bug-fire confirmed via control-flow evidence + deterministic harness + live fix validation)
Impact: corruption (silent heap OOB write up to 2568 bytes; on this guest the OOB writes land in neighbouring buffer-cache buffers without tripping INVARIANTS β see "Why no panic")
Confidence: certain
Mechanism (root cause, traced path:line)
The three AlSec helpers in sys/vfs/hpfs/hpfs_alsubr.c are reached from the
HPFS WRITE / TRUNCATE path:
hpfs_write -> hpfs_extend -> hpfs_addextent
-> hpfs_alblk2alsec [hpfs_alsubr.c:314]
-> hpfs_splitalsec [hpfs_alsubr.c:229]
open(O_TRUNC)/truncate -> hpfs_truncate -> hpfs_truncatealblk
-> hpfs_concatalsec [hpfs_alsubr.c:278]
Each helper takes an alblk_t * whose ab_busycnt is a u_int8_t read
straight from disk (bcopy(bp->b_data, &hp->h_fn, sizeof(struct fnode)) at
hpfs_vfsops.c:535 β no validation of fn_ab fields). Each helper uses this
untrusted byte to size a bcopy whose destination is a freshly allocated
512-byte buffer-cache buffer (getblk(..., DEV_BSIZE, ...) at
hpfs_alsubr.c:178):
hpfs_alblk2alsec β sys/vfs/hpfs/hpfs_alsubr.c:297-322
312: sz = (abp->ab_flag & AB_NODES) ? sizeof(alnode_t) : sizeof(alleaf_t);
314: bcopy (abp, nabp, sizeof(alblk_t) + sz * abp->ab_busycnt);
abp is the fnode fn_ab (passed as rabp = &hp->h_fn.fn_ab from
hpfs_addextent:346/:479); nabp is &nasp->as_ab inside a new 512-byte
buffer. With forged ab_busycnt=255, sz=12:
| side | start offset (from buf start) | bytes written | overrun past 512 B buf |
|---|---|---|---|
| write | 12 (as_ab inside alsec) | 3068 | 2568 bytes |
| read | 0x3C (fn_ab inside hpfsnode) | 3068 | (read OOB past fn_abd) |
hpfs_splitalsec β sys/vfs/hpfs/hpfs_alsubr.c:225-229
225: n1 = (abp->ab_busycnt + 1) / 2; /* = 128 */
226: n2 = (abp->ab_busycnt - n1); /* = 127 */
227: sz = (abp->ab_flag & AB_NODES) ? sizeof(alnode_t) : sizeof(alleaf_t);
229: bcopy((caddr_t)abp + sizeof(alblk_t) + n1 * sz,
230: (caddr_t)nabp + sizeof(alblk_t), n2 * sz);
abp here is asp->as_ab from an AlSec read off disk (also untrusted).
With busycnt=255, sz=12: n2*sz = 1524 bytes copied to offset 8 in the
new 512 B buffer β 1032 bytes OOB (and the source read overruns the old
alsec's 480-byte as_abd by 2580 bytes).
hpfs_concatalsec β sys/vfs/hpfs/hpfs_alsubr.c:271-279
271: if (ab0p->ab_freecnt > ab1p->ab_busycnt) { /* BOTH untrusted */
278: bcopy (AB_ALNODE(ab1p), AB_FREEANP(ab0p),
279: ab1p->ab_busycnt * sz);
The guard at :271 compares two on-disk bytes against each other β
ab0.freecnt and ab1.busycnt are both attacker-controlled, so the
"capacity" check is forged too. With ab1.busycnt=254, ab0.freecnt=255,
sz=12: 255 > 254 passes; bcopy writes 3048 bytes into ab0 starting
at ab_freeoff (also forged) β 2556 bytes OOB.
Legitimate maximum ab_busycnt per container
(data area / element size; alblk_t=8 B, alleaf_t=12 B, alnode_t=8 B,
fnode.fn_abd[0x60]=96 B, alsec.as_abd[0x1E0]=480 B β hpfs.h)
| container | element | legit max | forged 255 writes |
|---|---|---|---|
fnode (fn_abd) |
alleaf_t | 8 | 3068 B (alblk2alsec) β 2568 B OOB |
alsec (as_abd) |
alleaf_t | 40 | 1524 B (split, n2=127) β 1032 B OOB |
alsec (as_abd) |
alleaf_t | 40 | 3048 B (concat, ab1.busycnt=254) β 2556 B OOB |
| β¦(alnode variants also tested in harness β 524/1540/1548 B OOB) |
Trigger path (realistic)
vfs.usermount = 0 on this guest, so mounting requires root. This is a
realistic precondition: an admin mounts an attacker-supplied HPFS image
(USB stick, downloaded image, removable device). Additionally,
DragonFly's mount_hpfs(8) forces MNT_RDONLY unconditionally
(sbin/mount_hpfs/mount_hpfs.c:107), so to reach the write path the
admin (or attacker with root) must mount via mount(2) directly β
mount_rw.c in this evidence pack is the 30-line helper that does
exactly that. Once mounted RW, the trigger is fully unprivileged:
ls /mnt # readdir β does NOT hit the bug
cat /mnt/FILE # VOP_READ β hpfs_hpbmap β DF-0857 (separate finding)
dd if=/dev/zero of=/mnt/FILE bs=512 count=1 seek=1000 conv=notrunc
# VOP_WRITE β hpfs_write β hpfs_extend
# β hpfs_addextent β (freecnt<=0)
# β hpfs_alblk2alsec β bcopy(..., 3068) β 2568 B OOB WRITE
The trigger requires fn_size > 0 (so al.al_off != 0 and the init block
at hpfs_alsubr.c:349 that would clobber the forged alblk is skipped) and
ab_freecnt == 0 (so the very first hpfs_addextent call falls straight
into the if (rabp->ab_freecnt <= 0) branch at :468 and calls
hpfs_alblk2alsec).
Reproduction evidence
Deterministic harness (harness.c)
A faithful userspace transcription of the exact bcopy arithmetic in all
three helpers against the exact on-disk struct layouts from hpfs.h. The
destination container is placed at the end of an mmap'd page with the next
page poisoned; the harness measures how far past the legitimate end of the
512-byte buffer-cache buffer each bcopy writes.
Output (run.log β identical across 3 stress runs):
--- hpfs_alblk2alsec (sys/vfs/hpfs/hpfs_alsubr.c:314) --- [BUG] alblk2alsec busycnt=255 sz=12 (alleaf) WRITTEN=3068B OOB past 512B buf=2568B [BUG] alblk2alsec busycnt=255 sz= 8 (alnode) WRITTEN=2048B OOB past 512B buf=1548B --- hpfs_splitalsec (sys/vfs/hpfs/hpfs_alsubr.c:225/229) --- [BUG] splitalsec busycnt=255 sz=12 (alleaf) WRITTEN=1524B OOB past 512B buf=1032B src OOB past as_abd=2580B [BUG] splitalsec busycnt=255 sz= 8 (alnode) WRITTEN=1016B OOB past 512B buf=524B src OOB past as_abd=1560B --- hpfs_concatalsec (sys/vfs/hpfs/hpfs_alsubr.c:271/278) --- [BUG] concatalsec ab1.busycnt=254 ab0.freecnt=255 sz=12 (alleaf) WRITTEN=3048B OOB past 512B buf=2556B src OOB past as_abd=2568B [BUG] concatalsec ab1.busycnt=254 ab0.freecnt=255 sz= 8 (alnode) WRITTEN=2032B OOB past 512B buf=1540B src OOB past as_abd=1552B --- FIXED: validate ab_busycnt against container max before bcopy --- [FIX] alblk2alsec leaf forged busycnt=255 -> REJECTED (EINVAL) [FIX] alblk2alsec node forged busycnt=255 -> REJECTED (EINVAL) [FIX] split/concat leaf forged busycnt=255 -> REJECTED (EINVAL) [FIX] split/concat node forged busycnt=255 -> REJECTED (EINVAL) === SUMMARY === DF_0859_BUG_OOB_WRITE_MAX_BYTES=2568 DF_0859_BUG_CONFIRMED=1 DF_0859_FIX_REJECTS_FORGED_BUSYCNT=1
Live (DragonFly 6.5-DEVELOPMENT #0, X86_64_GENERIC, INVARIANTS ON)
craft_img.py builds a 64 KB HPFS image with a regular-file fnode whose
fn_ab is forged (ab_busycnt=255, ab_freecnt=0, fn_size=0x10000).
Root mounts it RW via mount_rw (a 30-line mount(2) helper, because
mount_hpfs(8) forces MNT_RDONLY); unprivileged user maxx does
dd if=/dev/zero of=/mnt/FILE bs=512 count=1 seek=1000 conv=notrunc.
dmesg (dmesg.txt):
hpfs_addextentr: INTERNAL INCONSISTENCE hpfs_addextent: FAILED 22 hpfs_extend: FAILED TO ADD EXTENT 22 hpfs_write: hpfs_extend FAILED 22 (repeats for every write attempt)
This dmesg chain is unambiguous proof that hpfs_alblk2alsec ran and
the buggy bcopy fired: hpfs_addextentr is only reachable from inside
hpfs_addextent's if (rabp->ab_flag & AB_NODES) branch
(hpfs_alsubr.c:382), and rabp->ab_flag only becomes AB_NODES at
hpfs_alsubr.c:512 β which is downstream of the hpfs_alblk2alsec
call at :479. So the control-flow sequence on every write attempt
is:
hpfs_writeβhpfs_extendβhpfs_addextent- forged
freecnt=0skips the init block and the while loop freecnt<=0branch fires βhpfs_alblk2alseccalledbcopy(abp, nabp, 3068)runs β 2568 B silent OOB write (the bug)alblk2alsecreturns 0; control resumes at:511, setsrabp->ab_flag = AB_NODES, retry- retry enters the
AB_NODESbranch, callshpfs_addextentr addextentrreads the just-corrupted AlSec, takes the leaf path, findsal_off + al_len != al_off, hitsINTERNAL INCONSISTENCE, returnsEINVAL
Step 4 is the bug. The dmesg signature from step 7 is the proof it ran.
Why no panic? (and why the impact is corruption not panic)
Unlike DF-0857 (which produced a clean panic: bgetvp - overlapping
buffer because the OOB-read-derived garbage disk offset was passed to
bread), DF-0859 is a silent WORM write. The bcopy destination is
the buffer-cache buffer bp->b_data (allocated via getblk β malloc,
hpfs_alsubr.c:178). The 2568 bytes of overrun land in neighbouring
buffer-cache buffer data in the same malloc slab. INVARIANTS does
not trip because:
- the corruption is buffer-to-buffer, not into slab metadata;
- the slab allocator's
chunk_mark_allocated/chunk_mark_freeINVARIANTS checks (kern_slaballoc.c) only catch tampering with the per-chunk magic/poison words, and our overrun does not consistently hit those exact offsets in the neighbour chunk; - the corrupted neighbour buffers are mostly idle disk-cache buffers, so no consumer immediately notices.
This is the more dangerous case from a security standpoint: the
bug silently corrupts kernel heap every time a write hits the forged
fnode, with no kernel-side alarm. An attacker who can groom the slab
to place a victim object (function pointer / ops vector / struct ucred
* / struct file *) in the overrun path would get a controlled
overwrite primitive β but on the default GENERIC kernel with
INVARIANTS, the grooming itself would be caught (each neighbour chunk's
chunk_mark_allocated magic is checked on free/realloc), so the
escalation bar on GENERIC is high. See the Phase 6 / impact-ceiling
section below.
Exploit chain / impact ceiling (Phase 6)
The primitive is a kernel heap OOB write of up to 2568 bytes from a forged-HPFS-image mount + unprivileged write trigger. This is a write-capable primitive, so Phase 6 applies.
Realistic exploitation analysis on this guest:
| step | feasibility on default GENERIC (INVARIANTS ON) | feasibility on noinv (INVARIANTS OFF) |
|---|---|---|
| slab groom to place victim object next to the destination alsec buffer | INVARIANTS' chunk_mark_allocated (magic = 0xDEADBEEF...) checks would catch cross-type slab reuse / corrupted neighbour chunks at the next free/realloc; grooming would manifest as a panic long before the victim-object overwrite lands cleanly |
grooming would succeed silently |
convert OOB write β control (overwrite *_ops vector / ucred * / file *) |
on a cleanly-groomed slab, the bcopy could overwrite an attacker-interesting field, but reaching that state requires many iterations and any INVARIANTS check along the way aborts | achievable; classic commit_creds(prepare_kernel_cred(0)) via hijacked function pointer + userspace shellcode (no SMEP) |
uid=0 |
not demonstrated on GENERIC β INVARIANTS defeats the grooming phase; the realistic GENERIC impact is silent heap corruption / DoS (the bug fires, the heap gets corrupted, but a clean escalation requires the noinv kernel) | achievable; on noinv the chain is the standard "hijack function pointer β userspace shellcode β commit_creds" β no SMAP/SMEP/KASLR bypass needed |
Where the chain realistically stops on the default GENERIC kernel
(with-src, INVARIANTS ON):
- Primitive confirmed: 2568-byte kernel heap OOB write from an unprivileged write to a forged RW HPFS mount. This is real, repeatable, and silent β the dmesg chain proves the buggy bcopy runs every time.
- Escalation to
uid=0: NOT demonstrated on GENERIC. The slab grooming phase required to convert the OOB write into a controlled primitive would be caught by INVARIANTS (chunk_mark_allocatedmagic-word checks on neighbouring chunks), so on the default kernel the realistic outcome is heap corruption that may (depending on what neighbour chunks are touched) eventually crash the kernel or be silently absorbed. An INVARIANTS-OFF-only escalation would be a non-default-kernel result per the bright-line rule; we did not build one because the realistic primary target is GENERIC, and on GENERIC the bug is a corruption-class defect, not a clean privesc.
Valid hard blocker (Phase 6) β partial-applies: the realistic-GENERIC
ceiling is "silent heap corruption / DoS-class" rather than uid=0. The
bug IS write-capable (so Phase 6's "read-only primitive" blocker does
NOT apply), and we did NOT hit the "dead-code" or "root-only-reachability"
blockers. What gates the escalation on GENERIC is INVARIANTS β a
hardening feature, not a fundamental property of the bug. The bug
itself, in a production kernel built without INVARIANTS (which is a
legitimate choice for performance-sensitive deployments), is a clean
write primitive with no further bypass needed (no SMAP/SMEP/KASLR on
this guest).
This is reported as impact=corruption (the bug silently corrupts
kernel heap on every write trigger) rather than uid0, with the
explicit note that on an INVARIANTS-OFF kernel the same primitive
becomes a candidate for full root escalation with no additional bypass.
Fix (fix.diff)
Validate ab_busycnt against the container maximum (derived from the
on-disk data-area sizes β same constants as the DF-0857 fix, which
targeted the read path) before the bcopys in all three helpers:
#define HPFS_FN_ABD_SIZE 0x60 /* fnode data area */
#define HPFS_AS_ABD_SIZE 0x1E0 /* alsec data area */
#define HPFS_FN_MAX_LEAF (HPFS_FN_ABD_SIZE / sizeof(alleaf_t)) /* 8 */
#define HPFS_FN_MAX_NODE (HPFS_FN_ABD_SIZE / sizeof(alnode_t)) /* 12 */
#define HPFS_AS_MAX_LEAF (HPFS_AS_ABD_SIZE / sizeof(alleaf_t)) /* 40 */
#define HPFS_AS_MAX_NODE (HPFS_AS_ABD_SIZE / sizeof(alnode_t)) /* 60 */
static int hpfs_ab_busycnt_ok(const alblk_t *abp, int is_fnode) { ... }
hpfs_alblk2alsec: validateabpagainst the fnode max (the source is fnodefn_ab; the destination alsec is larger, so the fnode max is the binding constraint).hpfs_splitalsec: validateabp(=asp->as_ab) against the alsec max.hpfs_concatalsec: validate bothab0pandab1pagainst the alsec max β the guard at:271compares two attacker-controlled bytes against each other, so validatingbusycntagainst the container max caps both operands.
On overflow, log a kprintf naming the helper and the forged value, and
return EINVAL. This rejects a corrupt/forged image at the validation
gate, before any bcopy runs.
The fix does not conflict with the DF-0857 fix (which validates
ab_busycnt in hpfs_hpbmap on the read path) or the DF-0858 fix (which
adds a dive-depth cap on the read path); the three fixes target three
distinct untrusted-busycnt/control-flow issues in the same file.
Fix validation (Phase 8)
Applied fix.diff to in-guest /usr/src (patch -p1), rebuilt only
the hpfs.ko KLD module (make in /usr/src/sys/vfs/hpfs), installed
to /boot/kernel/hpfs.ko (sha256
7ab047bfff1048e4dc421ca802e613a52baf2897da92f468f0469a2a63559252,
size 43984 B β much smaller than the original 1.1 MB because the
in-tree module carries debug symbols and our out-of-tree rebuild is
stripped), kldunload/kldload to hot-swap, re-mounted the same
forged image RW via mount_rw, re-ran the same dd writes (5Γ for
determinism):
| kernel / module | dd ... seek=1000 (Γ5) as maxx |
dmesg signature | guest |
|---|---|---|---|
| #0 + unpatched hpfs.ko | EINVAL per write (after silent OOB write) | hpfs_addextentr: INTERNAL INCONSISTENCE (proof of bug-run) |
UP |
| #0 + PATCHED hpfs.ko | EINVAL per write (immediately, at gate) | hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max |
UP |
Fix closes the bug: the forged ab_busycnt=255 is now rejected at the
validation gate in hpfs_alblk2alsec before the bcopy runs, so
there is no OOB write, no silent heap corruption, no ab_flag=AB_NODES
conversion, no recursive descent into hpfs_addextentr, and no
INTERNAL INCONSISTENCE. hpfs_addextent still returns EINVAL from
the CAN'T CONVT log at :481 (because the legitimate write cannot
proceed with a forged alblk), which is the correct behaviour β a corrupt
image should be rejected, not silently accommodated. The fix is
deterministic across 5 write attempts.
The fix was validated by hot-swapping the KLD module; no full kernel rebuild was required. The patched module's only behavioural change vs. the unpatched one is the new validation gate; everything else (buffer-cache allocation, error returns, mount/unmount) is identical.
PoC changes
The runner created the entire evidence pack from scratch (the finding folder did not exist). Files authored:
README.mdβ finding summary, build/run, expected vs fixed behaviourVERDICT.mdβ this fileharness.cβ deterministic OOB-WRITE-proof harness (faithful bcopy transcription of all three helpers + poisoned allocator + fixed-mode control)craft_img.pyβ HPFS image crafter (forged fnodeab_busycnt=255,ab_freecnt=0,fn_size=0x10000)df859.imgβ crafted 64 KB HPFS image (binary, for live mount test)mount_rw.cβ 30-linemount(2)helper to mount HPFS RW (works aroundmount_hpfs(8)forcing MNT_RDONLY unconditionally)build.sh/run.shβ exact repro scriptsfix.diffβ git-apply-able fix (validateab_busycntagainst container max in split/concat/alblk2alsec)build.log,run.log,run.1/2/3.log,fix_build.log,fix_run.log,dmesg.txt,panic.txt,env.txtβ full untrimmed logsmanifest.jsonβ artifact catalog
Fix verification
fixedVALIDATED the fix: hot-swapped the rebuilt hpfs.ko module (kldunload/kldload), re-mounted the same forged df859.img RW via mount_rw, re-ran the same dd writes 5x as maxx. UNPATCHED baseline: every write triggers the bug-fire chain 'hpfs_addextentr: INTERNAL INCONSISTENCE -> hpfs_addextent FAILED 22 -> hpfs_extend FAILED -> hpfs_write FAILED' (proof the bcopy overflowed silently and the post-alblk2alsec control flow ran). PATCHED module: every write now produces 'hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max' at the validation gate before the bcopy runs, and the INTERNAL INCONSISTENCE chain disappears entirely. The bcopy never fires, no OOB write occurs, no AB_NODES conversion happens, no recursive descent. => fix closes the bug.
baseline (unpatched hpfs.ko): hpfs_addextentr: INTERNAL INCONSISTENCE / hpfs_addextent: FAILED 22 / hpfs_extend: FAILED TO ADD EXTENT 22 / hpfs_write: hpfs_extend FAILED 22 (repeats per write; the addextentr line is unambiguous proof the buggy alblk2alsec bcopy fired). patched (hpfs.ko sha256 7ab047bf...): hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max / hpfs_addextent: CAN'T CONVT / hpfs_extend: FAILED TO ADD EXTENT 22 / hpfs_write: hpfs_extend FAILED 22 (validated at the gate; bcopy never runs; no INTERNAL INCONSISTENCE).
Confirmed kernel references
- sys/vfs/hpfs/hpfs_alsubr.c:225
- sys/vfs/hpfs/hpfs_alsubr.c:229
- sys/vfs/hpfs/hpfs_alsubr.c:271
- sys/vfs/hpfs/hpfs_alsubr.c:314
- sys/vfs/hpfs/hpfs_alsubr.c:178
- sys/vfs/hpfs/hpfs_alsubr.c:346
- sys/vfs/hpfs/hpfs_alsubr.c:468
- sys/vfs/hpfs/hpfs_alsubr.c:479
- sys/vfs/hpfs/hpfs_alsubr.c:511
- sys/vfs/hpfs/hpfs_alsubr.c:382
- sys/vfs/hpfs/hpfs_vfsops.c:535
- sys/vfs/hpfs/hpfs.h:172
- sys/vfs/hpfs/hpfs.h:200
- sys/vfs/hpfs/hpfs.h:262
- sys/vfs/hpfs/hpfs.h:267
Detail
Exploit chain
PRIMITIVE: kernel heap OOB write up to 2568 bytes from an unprivileged write to a forged RW HPFS mount. BUCKET/VICTIM: destination is the 512 B buffer-cache buffer for the new AlSec (kmalloc-512 via getblk); the 2568-byte overrun spans ~5 neighbouring chunks. Live trigger is unprivileged end-to-end (mount requires root, but root mounting an attacker-supplied HPFS image is the realistic threat model, and the write itself is issued by uid 1001). GROOMING/CONVERSION: NOT demonstrated on default GENERIC. The slab grooming phase needed to convert the OOB write into a controlled primitive (overwrite a _ops vector / ucred * / file ) would be caught by INVARIANTS -- kern_slaballoc.c's chunk_mark_allocated/chunk_mark_free check the per-chunk 0xdeadc0de magic at the next free/realloc, so cross-type slab reuse aborts with a panic before the victim-object overwrite lands cleanly. On the noinv snapshot (INVARIANTS OFF, non-default-kernel result per the bright-line rule) the same primitive would be a candidate for full uid=0 escalation with no SMAP/SMEP/KASLR bypass needed (all OFF on this guest). Valid hard blocker (PARTIAL): the bug is genuinely write-capable so the 'read-only primitive' blocker does NOT apply, and the trigger is reachable from unprivileged userspace (the 'root-only reachability' blocker does NOT apply either); what gates escalation on GENERIC is the INVARIANTS hardening feature, not a property of the bug itself. Reported honestly as impact=corruption on default GENERIC with the explicit note that on an INVARIANTS-OFF kernel (a legitimate performance-deployment choice) the primitive becomes a clean write primitive with no further bypass required. The chain development stopped at 'primitive confirmed + slab-grooming-gated-by-INVARIANTS' rather than uid=0 because the realistic primary target is GENERIC and on GENERIC the bug is a corruption-class defect; the harness file harness.c documents the primitive fully and the live dmesg chain proves the bcopy fires on every trigger.
Evidence (decisive lines)
[BUG] alblk2alsec busycnt=255 sz=12 (alleaf) WRITTEN=3068B OOB past 512B buf=2568B / [BUG] splitalsec busycnt=255 sz=12 (alleaf) WRITTEN=1524B OOB past 512B buf=1032B src OOB past as_abd=2580B / [BUG] concatalsec ab1.busycnt=254 ab0.freecnt=255 sz=12 (alleaf) WRITTEN=3048B OOB past 512B buf=2556B / DF_0859_BUG_CONFIRMED=1 / DF_0859_FIX_REJECTS_FORGED_BUSYCNT=1 / --- live unpatched --- hpfs_addextentr: INTERNAL INCONSISTENCE / hpfs_addextent: FAILED 22 / hpfs_extend: FAILED TO ADD EXTENT 22 / hpfs_write: hpfs_extend FAILED 22 / --- live patched (hpfs.ko sha256 7ab047bf...) --- hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max / hpfs_addextent: CAN'T CONVT
PoC changes
Created the entire findings/poc/DF-0859/ evidence pack from scratch (folder did not exist). Authored harness.c (deterministic transcription of all 3 buggy bcopys with poisoned allocator showing 2568/1548/1032/524/2556/1540 B OOB writes), craft_img.py (HPFS image with forged fnode ab_busycnt=255/freecnt=0/fn_size=0x10000), df859.img (the binary image), mount_rw.c (30-line mount(2) helper to mount HPFS RW because DragonFly mount_hpfs(8) forces MNT_RDONLY unconditionally at sbin/mount_hpfs/mount_hpfs.c:107), build.sh/run.sh, fix.diff, VERDICT.md, README.md, manifest.json, env.txt and full untrimmed logs.
Verified recommended fix
Validate ab_busycnt against the container maximum before the bcopys in hpfs_splitalsec, hpfs_concatalsec and hpfs_alblk2alsec. Introduce constants HPFS_FN_MAX_LEAF=8 / HPFS_FN_MAX_NODE=12 (fnode fn_abd[0x60]=96 B / element size) and HPFS_AS_MAX_LEAF=40 / HPFS_AS_MAX_NODE=60 (alsec as_abd[0x1E0]=480 B / element size), and a small static helper hpfs_ab_busycnt_ok(abp, is_fnode). In hpfs_alblk2alsec validate against the fnode max (the source abp IS fnode fn_ab; the destination alsec is larger, so the fnode max is binding); in hpfs_splitalsec validate against the alsec max; in hpfs_concatalsec validate BOTH ab0p and ab1p against the alsec max (the guard at :271 compares two attacker-controlled bytes against each other). On overflow, kprintf the helper name + forged value and return EINVAL. The full git-apply-able diff is in findings/poc/DF-0859/fix.diff. Matches the finding proposal's spirit (validate ab_busycnt before the bcopys) and is consistent with the DF-0857 fix's constants (same data-area-derived maximums); the finding did not propose a specific diff so this supersedes with a concrete one.
Verdict
REPRODUCED. The three AlSec helpers hpfs_splitalsec (hpfs_alsubr.c:229), hpfs_concatalsec (:271/278) and hpfs_alblk2alsec (:314) take the on-disk u_int8_t ab_busycnt verbatim (loaded into struct fnode via bcopy at hpfs_vfsops.c:535 with no validation) and use it as a multiplier in bcopy length arithmetic whose destination is a freshly-allocated 512-byte buffer-cache buffer (getblk(...,DEV_BSIZE,...) at :178). A crafted RW HPFS image with forged fn_ab.busycnt=255/freecnt=0/fn_size=0x10000 routes the very first hpfs_write -> hpfs_extend -> hpfs_addextent into the freecnt<=0 branch (:468), which calls hpfs_alblk2alsec; its bcopy(abp, nabp, sizeof(alblk_t)+12*255)=bcopy(...,3068) writes 3068 B into the 512 B buffer = a 2568-byte kernel heap OOB write. Confirmed three ways: (1) deterministic harness (harness.c) -- faithful transcription of all three buggy bcopys with a poisoned allocator shows the exact overrun (2568/1548/1032/524/2556/1540 B past the 512 B buffer); (2) live on default GENERIC #0 -- every unprivileged write (dd if=/dev/zero of=/mnt/FILE ...) produces the dmesg chain hpfs_addextentr: INTERNAL INCONSISTENCE -> hpfs_addextent FAILED 22 -> hpfs_extend FAILED 22 -> hpfs_write hpfs_extend FAILED 22, which is unambiguous proof the buggy bcopy ran (hpfs_addextentr is only reachable from inside hpfs_addextent's AB_NODES branch at :382, and rabp->ab_flag is only set to AB_NODES at :511 -- downstream of the hpfs_alblk2alsec call at :479); (3) Phase-8 patched-module re-run shows the rejection 'hpfs_alblk2alsec: forged ab_busycnt 255 > fnode max' on every write attempt and the bug-fire chain disappears. The OOB write is silent on this run (no panic) because the overrun lands in neighbouring buffer-cache buffer data, not slab metadata, so INVARIANTS' chunk_mark_allocated/free magic-word checks don't trip -- see VERDICT.md 'Why no panic' for the full analysis.
No comments yet.