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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3248 Β· 22 files
FileTypeDescriptionSize
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
README.md file
↓ download 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 hop de_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 at origin_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, invokes hpfs_updateparent(hp) β€” the exact call hpfs_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 β€” direct mount(2) RW helper (stock mount_hpfs forces 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 garbage reclen==0 chains; documented as the DoS variant).
  • fix.diff β€” the fix; validated by in-guest kernel rebuild (fix_build.log, fix_run.log).
VERDICT.md file
↓ download raw

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)

  1. Crafted image: root dirblk holds 24 in-bounds D* dirents, an XSTEP dirent and an END|DE_DOWN marker that dives into a second dirblk (root2); root2's XSTEP2 dirent carries the OOB hop (de_reclen, u16). 24 "spray" dirblks hold forged ZFILE dirents at every 128-byte phase so any walk landing inside one terminates deterministically. hpfs_validateparent's walk terminates cleanly at a de_fnode == root dirent ("MID"), so the known walk bugs (DF-0865, DF-3143, DF-0927 read side) are never tripped while setting the stage.
  2. 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.
  3. 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 forged ZFILE dirent at origin_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.
  4. Unprivileged trigger (uid 1001): open("/mp/ZFILE") β€” namei walks the root dirblk, dives into the origin chunk, and cmpfname matches the dirent 2048 bytes past the chunk (poc: open_ZFILE_ok_(OOB dirent_matched)); the vnode for fnode 0x58 is created with h_name="ZFILE", H_PARVALID set (hpfs_vnops.c:1065-1071). futimes(fd, …) passes the owner check (hpfs_setattr, vnops:530-544) and stores h_mtime = 0x4242424242424242, h_atime = 0x4141414141414141 β€” arbitrary 64-bit attacker values β€” and sets H_PARCHANGE. close(fd) leaves the parent update pending (DragonFly's vnode layer defers VOP_INACTIVE: vrele 1β†’0 merely caches the vnode, vfs_lock.c:379-392).
  5. Forced deactivation + verify (harness KLD df3248fin2): locates the planted dirent and the ZFILE hpfsnode (exported hpfs hash), prints the pending unprivileged values, then invokes hpfs_updateparent(hp) β€” the identical call hpfs_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 the futimes() 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 down reports 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

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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1 (in-guest rebuild with fix.diff, per VERDICT.md 'Fix validation')

Confirmed kernel references

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.