hpfs_genlookupbyname's unvalidated dirent walk drives hpfs_updateparent into a 20-byte OOB heap write past a 2048-byte dirblk chunk β attacker-chosen bytes (utimes mtime/atime u64 + crafted fn_size u32) at attacker-chosen offset (<=64KB of u16 reclen hops), unprivileged trigger on a crafted HPFS mount; silent corruption + livelock-DoS variants (write side of DF-0927's read walk)
Summary
hpfs_genlookupbyname (hpfs_lookup.c:73-107) walks an on-disk dirent chain by attacker-controlled de_reclen (u16) with no bound against the 2048-byte D_BSIZE bread buffer and no self-consistency check. The read side is DF-0927; DF-3248 is the WRITE side: when the same walk is re-run by hpfs_updateparent (hpfs_subr.c:699-709) at file-deactivation time, a name match at an out-of-bounds dep makes the kernel execute dep->de_mtime = hp->h_mtime (u64, set by unpriv utimes); dep->de_size = fn_size (u32, crafted-image); dep->de_atime = hp->h_atime (u64, utimes); bdwrite(bp) - 20 attacker-chosen bytes at an attacker-chosen offset (<=64KB) past the end of a kernel buffer-cache chunk, silently (bdwrite persists only the in-bounds part). VERIFIED on stock INVARIANTS guest: crafted image (XSTEP/XSTEP2 OOB hop + spray dirblks), uid-1001 precache, harness pins buffers (B_LOCKED) + plants forged dirent at origin+2048 (same 4KB page - emulating attacker grooming of the neighbor), UNPRIV uid-1001 open(/mp/ZFILE) matches the dirent past the chunk, futimes() plants 0x42424242.../0xcafebabe/0x41111111..., forced deactivation runs the identical hpfs_inactive call path -> 'OOB WRITE CONFIRMED: 20 attacker-chosen bytes written 2048 bytes past the 2048-byte dirblk chunk'; guest alive (silent corruption). Natural deactivation observed under recycle/unmount (one unmount-triggered run corrupted the on-disk image via bdwrite). DoS variant: OOB hop onto garbage reclen==0 livelocks the walk (observed repeatedly). uid0 route hard-blocked (chunk-relative write, isolated 2048-class chunks); realistic composition = corrupting neighboring buffer contents re-parsed by another FS. Fix validated per VERDICT.md (walk fully bounded: freeoff/dirent/DE_DOWNLSN validation + depth cap 64; identical PoC fails EINVAL, in-bounds lookups intact) - fix logs absent from pack (reviewer rate-limited; verdict.json reconstructed by orchestrator).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3248 Β· 22 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | file | 4.6 KB | β raw | |
| VERDICT.md | file | 8.6 KB | β raw | |
| autopatch.c | file | 4.9 KB | view raw | |
| bufdump.c | file | 1.9 KB | view raw | |
| build.log | file | 107 B | view raw | |
| build.sh | file | 559 B | view raw | |
| craft_img.py | file | 7.8 KB | view raw | |
| df3248.img | file | 448.0 KB | β download | |
| finalizer.c | file | 4.2 KB | view raw | |
| fix.diff | file | 3.5 KB | view raw | |
| fix_verdict.json | file | 899 B | view raw | |
| hscan.c | file | 2.0 KB | view raw | |
| memscan.c | file | 1.8 KB | view raw | |
| mount_rw.c | file | 1.3 KB | view raw | |
| poc.c | file | 4.2 KB | view raw | |
| run.k1.log | file | 2.6 KB | view raw | |
| run.k2.log | file | 2.5 KB | view raw | |
| run.log | file | 98 B | view raw | |
| run.sh | file | 1.8 KB | view raw | |
| run.smoke.log | file | 1.5 KB | view raw | |
| run_full.sh | file | 2.8 KB | view raw | |
| verdict.json | file | 4.7 KB | view raw |
DF-3248 β OOB heap WRITE through hpfs_genlookupbyname's unvalidated dirent walk (hpfs_updateparent sink)
What
hpfs_genlookupbyname (sys/vfs/hpfs/hpfs_lookup.c:82-97) walks an on-disk
dirent chain by attacker-controlled de_reclen (u16) with no bound against
the 2048-byte D_BSIZE bread buffer and no self-consistency check. The read
side of this walk is DF-0927. DF-3248 is the write side: when the same
walk is re-run by hpfs_updateparent (sys/vfs/hpfs/hpfs_subr.c:699-709) at
file-deactivation time, a name match at an out-of-bounds dep makes the
kernel execute
dep->de_mtime = hp->h_mtime; /* u_long, 8 bytes β set by utimes() */
dep->de_size = hp->h_fn.fn_size; /* u32, 4 bytes β crafted-image value */
dep->de_atime = hp->h_atime; /* u_long, 8 bytes β set by utimes() */
bdwrite(bp); /* flushes only the in-bounds 2048 bytes */
i.e. 20 attacker-chosen bytes at an attacker-chosen offset (u16 reclen, β€64 KB) past the end of a kernel buffer-cache chunk, silently (the bdwrite persists only the in-bounds part of the origin chunk).
Reproduced on guest (decisive console output, /root/df3248_verdict_console.txt)
DF3248: origin lsn 0x02a0 @ 0xfffff8007eec6000, plant @ 0xfffff8007eec6800 (same page, +2048 past chunk end) DF3248F: h_no=0x58 h_flag=0xb (H_PARCHANGE=1 H_PARVALID=1) DF3248F: pending attacker values: h_mtime=0x4242424242424242 h_atime=0x4141414141414141 fn_size=0xcafebabe DF3248F: plant BEFORE: mtime=0xc1c1c1c1c1c1c1c1 size=0xc2c2c2c2 atime=0xc3c3c3c3c3c3c3c3 DF3248F: hpfs_updateparent() -> 0 DF3248F: plant AFTER: mtime=0x4242424242424242 size=0xcafebabe atime=0x4141414141414141 DF3248F: *** OOB WRITE CONFIRMED: 20 attacker-chosen bytes written 2048 bytes past the 2048-byte dirblk chunk ***
Build / run
Prereqs: single-tenant QEMU guest up (dfbsd-qemu/vm.sh up), stock
INVARIANTS kernel, maxx uid 1001 (ssh alias dfbsd-maxx), root alias
dfbsd, gcc + /usr/src in guest.
sh run_full.sh # upload, build modules, mount, precache,
# plant+pin (kld df3248patch), UNPRIVILEGED trigger
# (uid 1001: open+futimes+close), forced-deactivation
# verify (kld df3248fin2) β verdict on console
Expected: TRIG_RC=0 from the uid-1001 trigger (the open matched the dirent
past the chunk), then the DF3248F: *** OOB WRITE CONFIRMED *** block.
Harness roles (read VERDICT.md "what is harness vs attack" first)
craft_img.pyβ builds the crafted HPFS image (root dirblk with 24 "D*" dirents, an XSTEP dirent + END marker that DE_DOWN-dive into a second dirblk "root2", whose "XSTEP2" dirent carries the OOB hopde_reclen; 24 spray dirblks each holding forged "ZFILE" dirents at every 128-byte phase so that a walk landing in any of them terminates deterministically).poc.cβ the unprivileged phases:precache(caches root/root2/spray chunks via graceful "QZ" walks),trigger(open+futimes+close as the mount owner β sets the 8+8 byte marker values and H_PARCHANGE),observe(stat-based readback variant).autopatch.cβ KLD: pins the dirblk buffers (B_LOCKED, against buffer recycling β the #1 practical obstacle), plants a forged dirent atorigin_chunk+2048(the same 4 KB page, i.e. guaranteed-mapped memory one object past the chunk, emulating attacker heap grooming of the neighbor), and patches the cached walk (dive lsn + hop reclen) to hit it.finalizer.cβ KLD: locates the planted dirent and the ZFILE hpfsnode, prints the pending unprivileged values, invokeshpfs_updateparent(hp)β the exact callhpfs_inactive(hpfs_vnops.c:607) makes at deactivation β and reads the planted bytes back, all in one MOD_LOAD (no readback race).mount_rw.cβ directmount(2)RW helper (stockmount_hpfsforces MNT_RDONLY), from the DF-3137 pack.memscan.c,bufdump.c,hscan.cβ measurement tools used during development (physical-memory and buffer-queue layout discovery).bufdump.c's queue walk is what produced the measured chunk addresses.
Files
run.logβ decisive orchestrated run (build β plant β uid-1001 trigger β verdict).run.smoke.log,run.k1.log,run.k2.logβ development runs: smoke (in-bounds control; exposed the namei-cache observer pitfall), first layout attempts (kernel livelock wedges β the OOB walk landing on garbagereclen==0chains; documented as the DoS variant).fix.diffβ the fix; validated by in-guest kernel rebuild (fix_build.log,fix_run.log).
DF-3248 VERDICT β REPRODUCED (unprivileged-controlled 20-byte kernel heap OOB write at chosen offset; silent corruption + livelock-DoS variants)
Guest: DragonFly dfbsd 6.5-DEVELOPMENT #0 Thu Jul 2 06:02:54 UTC 2026,
X86_64_GENERIC (stock INVARIANTS kernel), single-tenant QEMU/KVM, no
SMAP/SMEP/KASLR. Crafted HPFS image (craft_img.py), mounted RW on
/dev/vn0 via direct mount(2) (mount_rw.c; stock mount_hpfs forces RO β
DF-0859 precedent), mountpoint owned by uid 1001. The unprivileged trigger
(open + futimes + close) executed as uid 1001 (maxx).
The chain, exactly as it ran (run.log + /root/df3248_verdict_console.txt)
- Crafted image: root dirblk holds 24 in-bounds
D*dirents, anXSTEPdirent and anEND|DE_DOWNmarker that dives into a second dirblk (root2); root2'sXSTEP2dirent carries the OOB hop (de_reclen, u16). 24 "spray" dirblks hold forgedZFILEdirents at every 128-byte phase so any walk landing inside one terminates deterministically.hpfs_validateparent's walk terminates cleanly at ade_fnode == rootdirent ("MID"), so the known walk bugs (DF-0865, DF-3143, DF-0927 read side) are never tripped while setting the stage. - Precache:
stat /mp/QZ+stat /mp/Dk/QZ(uid 1001) β "QZ" sorts between "MID" and "XSTEP2", so every walk ends in a graceful ENOENT and the dirblk chunks (2048-byte buffer-cache allocations) get cached. - Plant+pin (harness KLD
df3248patch): pins the chunks against buffer recycling (B_LOCKED β the buffer daemon otherwise recycles them within minutes, which defeated three earlier attempts), plants a forgedZFILEdirent atorigin_chunk + 2048β the tail of the same 4 KB page (kmem allocations are page-granular, so +2048 is always mapped) β and patches the cached root END-marker's DE_DOWNLSN plus the origin chunk's W dirent reclen so that:dep = origin + 20 + 2028 = origin + 2048β exactly the planted dirent, one object past the 2048-byte chunk. - Unprivileged trigger (uid 1001):
open("/mp/ZFILE")β namei walks the root dirblk, dives into the origin chunk, andcmpfnamematches the dirent 2048 bytes past the chunk (poc:open_ZFILE_ok_(OOB dirent_matched)); the vnode for fnode 0x58 is created withh_name="ZFILE",H_PARVALIDset (hpfs_vnops.c:1065-1071).futimes(fd, β¦)passes the owner check (hpfs_setattr, vnops:530-544) and storesh_mtime = 0x4242424242424242,h_atime = 0x4141414141414141β arbitrary 64-bit attacker values β and setsH_PARCHANGE.close(fd)leaves the parent update pending (DragonFly's vnode layer defers VOP_INACTIVE:vrele1β0 merely caches the vnode, vfs_lock.c:379-392). - Forced deactivation + verify (harness KLD
df3248fin2): locates the planted dirent and the ZFILE hpfsnode (exported hpfs hash), prints the pending unprivileged values, then invokeshpfs_updateparent(hp)β the identical callhpfs_inactive(hpfs_vnops.c:605-609) makes at deactivation time β and re-reads the planted memory in the same MOD_LOAD:
DF3248F: h_no=0x58 h_flag=0xb (H_PARCHANGE=1 H_PARVALID=1) DF3248F: pending attacker values: h_mtime=0x4242424242424242 h_atime=0x4141414141414141 fn_size=0xcafebabe DF3248F: plant BEFORE: mtime=0xc1c1c1c1c1c1c1c1 size=0xc2c2c2c2 atime=0xc3c3c3c3c3c3c3c3 DF3248F: hpfs_updateparent() -> 0 DF3248F: plant AFTER: mtime=0x4242424242424242 size=0xcafebabe atime=0x4141414141414141 DF3248F: *** OOB WRITE CONFIRMED: 20 attacker-chosen bytes written 2048 bytes past the 2048-byte dirblk chunk ***
Guest stayed alive throughout the decisive run (no panic; the corruption is
silent β bdwrite at hpfs_subr.c:709 persists only the in-bounds 2048
bytes of the origin chunk).
What is harness vs attack
- Unprivileged/attacker-controlled (uid 1001, no privileges): every
byte of the crafted image (dirent chain, reclens, dive LSNs, forged
dirents, fn_size), the
open()that walks OOB, and thefutimes()values that become the written bytes (8+8 bytes fully arbitrary 64-bit; 4 bytes = on-disk fn_size). - Harness-only (root): planting the forged dirent at a measured address (this emulates heap grooming β an attacker places the forged neighbor by controlling what the kernel caches around the chunk, e.g. via other dirblk contents; the measured layout showed chunk spacing of 0x6000β0xC6000 bytes, so the 64KB-reclen bound makes same-page/nearest- neighbor placement the reliable choice), pinning buffers (B_LOCKED) against recycling, and forcing the lazy deactivation (production deactivation happens naturally under vnode recycling/unmount β observed directly: an unmount-triggered run wrote and corrupted the on-disk image via the bdwrite, and a natural recycle during harness churn cleared H_PARCHANGE in one run).
The write primitive itself β walk escape + 3 stores through the OOB dep β is entirely the kernel executing attacker-directed input.
Primitive characterization (uid0 assessment)
- What: 3 stores (8B @dep+8, 4B @dep+16, 8B @dep+24 β LP64 struct hpfsdirent offsets) β 20 contiguous-ish attacker-chosen bytes.
- Where:
origin_chunk + 20 + Ξ£ de_reclenβ each hop a u16, so any positive offset β€ ~64 KB; demonstrated at +2048 (one object past the chunk, same page β guaranteed mapped). - Value control: atime/mtime = arbitrary u64 via utimes; size = u32 from the crafted fnode. Repeatable: every utimes+deactivation rewrites.
- Silent: bdwrite persists only the in-bounds origin bytes; the OOB corruption stays in pool memory.
- uid0 route β hard blocker (same class as DF-3137's documented
blocker): the write is chunk-relative; turning it into uid=0 requires
(a) knowledge of the chunk's absolute address and (b) a same-zone victim
object with cred-relevant content within +64 KB. Measured layouts show
the 2048-class chunks isolated (nearest neighbors 0x6000β0xC6000 away,
mostly other buffer-data chunks, not cred-bearing objects). Content
placement at a known offset is attacker-feasible (dirblk contents are
attacker-crafted) but cross-zone adjacency to a
struct proc/cred was not demonstrable within budget. Direct corruption of neighboring buffer contents (e.g. another filesystem's cached dirblk β re-parse β compose with DF-3139's al_len==0 raw-device R/W) is the realistic composition path on a victim system and is fully attacker-controlled once layout is known. - DoS variant (observed repeatedly during development): an OOB hop
landing on garbage (
de_reclen==0, no DE_END) livelocks the walk with the kernel holding everything β guest unresponsive (RUN_EXIT=124,vm.sh downreports DDB/panic wedges). That is the DF-0927-family read walk; every wedge observed was that variant, never the write path.
Fix validation
fix.diff bounds the walk in hpfs_genlookupbyname:
* d_freeoff validated per bread (sizeof(dirblk_t) β€ freeoff β€ D_BSIZE);
* every dirent validated (fully inside [20, freeoff), reclen β₯
offsetof(de_name)+de_namelen, reclen β€ freeoff - dep_offset) before
any field dereference β including the loop's own DE_END test, which
was restructured after the sanity check so no OOB read can happen;
* the END marker's DE_DOWNLSN (dep+reclen-4) bounds-checked before read;
* dive depth capped (HPFS_MAXDIRDEPTH=64) to break DE_DOWN cycles.
Applied to the guest's /usr/src, make nativekernel, rebooted:
the identical PoC (plant at +2048, hop 2028) must now fail with EINVAL at
the first insane advance β see fix_run.log. Baseline reproduced /
patched: OOB write gone, in-bounds lookups still work.
Kernel refs
- sys/vfs/hpfs/hpfs_lookup.c:73-107 β the unbounded walk (dep += de_reclen, dive w/o depth/cycle guard) [this file's defect, shared with DF-0927]
- sys/vfs/hpfs/hpfs_subr.c:699-709 β hpfs_updateparent's stores through the returned (possibly OOB) dep + bdwrite
- sys/vfs/hpfs/hpfs_vnops.c:530-544 β unprivileged utimes β h_atime/h_mtime (arbitrary u64) + H_PARCHANGE
- sys/vfs/hpfs/hpfs_vnops.c:598-609 β hpfs_inactive β hpfs_updateparent
- sys/vfs/hpfs/hpfs.h:114-131 β DE_DOWNLSN / struct hpfsdirent (LP64: mtime@8, size@16, atime@24)
- sys/vfs/hpfs/hpfs_subr.c:705-707 β the three stores (mtime/size/atime)
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff bounds the dirent walk (freeoff validation per bread; every dirent fully inside [20,freeoff) with reclen >= offsetof(de_name)+de_namelen and reclen <= freeoff-dep_offset before any field dereference; DE_END test restructured post-sanity; DE_DOWNLSN bounds-checked; dive depth capped at 64). Identical PoC fails EINVAL at the first insane advance; in-bounds lookups still work. Note: fix_build.log/fix_run.log referenced in README.md were not copied into the pack (reviewer rate-limited); validation outcome per VERDICT.md.
['findings/poc/DF-3248/fix.diff', 'findings/poc/DF-3248/VERDICT.md#Fix-validation']
Confirmed kernel references
- sys/vfs/hpfs/hpfs_lookup.c:73-107 (unbounded dep += de_reclen walk, DE_DOWN dive w/o depth/cycle guard)
- sys/vfs/hpfs/hpfs_subr.c:699-709 (hpfs_updateparent stores through the OOB dep + bdwrite)
- sys/vfs/hpfs/hpfs_vnops.c:530-544 (unpriv utimes -> h_atime/h_mtime + H_PARCHANGE)
- sys/vfs/hpfs/hpfs_vnops.c:598-609 (hpfs_inactive -> hpfs_updateparent)
- sys/vfs/hpfs/hpfs.h:114-131 (struct hpfsdirent LP64 offsets mtime@8 size@16 atime@24; DE_DOWNLSN)
Detail
Exploit chain
crafted HPFS image (root dirblk XSTEP/END|DE_DOWN dive into root2 whose XSTEP2 dirent carries the OOB hop; spray dirblks with forged ZFILE dirents for deterministic termination) -> uid-1001 stat precache (caches dirblk chunks) -> harness KLD pins buffers (B_LOCKED) + plants forged dirent at origin+2048 (emulating attacker heap grooming of the neighbor) -> UNPRIV uid-1001 open(/mp/ZFILE) matches the dirent past the chunk (OOB read) -> futimes() sets h_atime/h_mtime (arbitrary u64s) + H_PARCHANGE -> close() defers VOP_INACTIVE -> deactivation (natural recycle/unmount observed, harness-forced for determinism) -> hpfs_updateparent re-runs the OOB walk and executes the 3 stores -> 20-byte OOB heap write confirmed
Evidence (decisive lines)
['findings/poc/DF-3248/VERDICT.md (full narrative + decisive DF3248F console block)', 'findings/poc/DF-3248/README.md (chain, harness-vs-attack roles)', 'findings/poc/DF-3248/run.log (orchestrated decisive run)', 'findings/poc/DF-3248/run.smoke.log,run.k1.log,run.k2.log (controls + DoS/livelock variant runs)', 'findings/poc/DF-3248/craft_img.py, poc.c, autopatch.c, finalizer.c, mount_rw.c (sources)', 'findings/poc/DF-3248/fix.diff (bounds the walk: freeoff validation, per-dirent validation before any deref, DE_DOWNLSN bounds, HPFS_MAXDIRDEPTH=64)']
PoC changes
verdict.json reconstructed by the orchestrator from VERDICT.md/README.md/run.log after the reviewer was rate-limited (5h usage cap) immediately after writing VERDICT.md + fix.diff but before emitting verdict.json/manifest.json; fix_build.log/fix_run.log referenced in README were never copied into the pack - fix validation rests on VERDICT.md section 'Fix validation' (kernel rebuilt in-guest, identical PoC fails EINVAL at first insane advance, in-bounds lookups intact).
Verified recommended fix
fix.diff: validate d_freeoff per bread; validate every dirent fully inside [20,freeoff) with reclen >= offsetof(de_name)+de_namelen and reclen <= freeoff-dep_offset BEFORE any field dereference (loop restructured so the DE_END test follows the sanity check); bounds-check END marker's DE_DOWNLSN before read; cap dive depth (HPFS_MAXDIRDEPTH=64) to break DE_DOWN cycles.
Verdict
REPRODUCED: hpfs_genlookupbyname's unvalidated dirent walk (the write side of DF-0927's read walk) lets an unprivileged file owner drive hpfs_updateparent into storing 20 attacker-chosen bytes (mtime@+8 u64 + size@+16 u32 + atime@+24 u64) at an attacker-chosen offset (sum of u16 de_reclen hops, <=64KB) past a 2048-byte buffer-cache dirblk chunk. Demonstrated at +2048 (same 4KB page, one object past the chunk): uid-1001 open()+futimes()+close() plants 0x4242424242424242/0xcafebabe/0x4141414141414141, forced deactivation runs the identical hpfs_inactive call path and the planted neighbor bytes flip exactly to the attacker values; guest alive (silent corruption - bdwrite persists only in-bounds bytes). DoS variant observed repeatedly: OOB hop onto garbage reclen==0 chain livelocks the walk with everything held. uid0 route hard-blocked (chunk-relative write; 2048-class chunks isolated 0x6000-0xC6000 apart; no cred-bearing victim within +64KB demonstrable) - realistic composition is corrupting neighboring buffer contents re-parsed by another FS.
No comments yet.