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

ffs_reload (MNT_RELOAD) adopts the re-read superblock's geometry without validating it against the mount-time allocations β€” heap OOB write with attacker-controlled length and content, divide-by-zero, and heap OOB read disclosed to the media

Summary

ffs_reload() re-reads the superblock from the backing device and validates only fs_magic/fs_bsize (:484-488); bcopy(newfs, fs, old_sbsize) (:500) then adopts every other geometry field of the attacker-influenced re-read sb while fs_csp/fs_maxcluster/fs_contigdirs still point into the allocation sized for the OLD geometry at mount time (:671/:685). The comment at :493 ('important parameters (eg fs_ncg) are unchanged') is an unchecked assumption. Consequences: step-3 csum re-read bcopy's howmany(new_cssize,new_fsize) frags of on-disk bytes into the old fs_csp allocation (heap OOB write, length=adopted cssize, content=on-media bytes); the maxcluster refill writes new_ncg int32s past the array (:533-537); howmany() divides by adopted fs_fsize (:515); a later RW upgrade runs ffs_sbupdate's bcopy(fs, bp, adopted fs_sbsize) past the mount-time um_fs (:1292-1295) writing adjacent kernel heap to the raw media; mnt_maxsymlinklen re-imported (:504 - DF-3015's trust boundary). Root mounts attacker-supplied media RO; the sb on media differs from what the mount consumed (trivial for crafted media); an admin/tool issues the documented post-fsck reload (mount(2) MNT_UPDATE|MNT_RELOAD|MNT_RDONLY) -> kernel heap corruption with fully controlled bytes, kernel panic, or kernel-heap disclosure onto attacker-readable media. UFS mounts root-gated on stock configs (empirically proven; vfs.usermount does not help - DF-0820 Blocker 1) - root-context corruption primitive, not an unpriv->root LPE; an automount daemon would make it unpriv-triggerable. The cssize variant is strictly stronger than DF-0820's primitives: a silently-completing, content-and-length-controlled overwrite (groomable to any victim adjacent to fs_csp). GUEST-VERIFIED 6 variants: (a) cssize 2048->0x80000 + pre-patterned csaddr -> RELOAD RETURNED 0 having bcopy'd 524288 pattern bytes into the 2053-byte fs_csp chunk (~522KB silent heap smash, guest kept running); (b) fsize=0 -> Fatal trap 18 idivl; (c) ncg 1->0x20000000 -> stack-guard vm_fault at the *lp++ store; (d) sbsize->65536 + RW upgrade -> Fatal trap 12 in memmove (sbupdate OOB read); (e) sbsize->8192 + RW upgrade -> 6144 bytes beyond um_fs written to media (dd-readable); (f) adoption proven via crafted fs_dsize surfacing in df. uid=0 chain: none - trigger root-gated (documented blocker). Fix validated (geometry-equality gate + fs_fmod=0): all crafted variants -> clean EINVAL, unchanged reloads and RW mounts unaffected.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3078 Β· 18 files
FileTypeDescriptionSize
README.md β€” 4.5 KB ↓ raw
VERDICT.md β€” 5.0 KB ↓ raw
reload.c β€” 667 B view raw
craft.c β€” 821 B view raw
dumpsb.c β€” 966 B view raw
setup.sh β€” 335 B view raw
build.sh β€” 177 B view raw
run.sh β€” 1.9 KB view raw
build.log β€” 962 B view raw
run.log β€” 3.2 KB view raw
run.patched.log β€” 1.8 KB view raw
panic_idivl_fsize0.txt β€” 841 B view raw
panic_maxcluster_ncg.txt β€” 1.0 KB view raw
panic_sbupdate_oob_read.txt β€” 655 B view raw
env.txt β€” 714 B view raw
fix.diff β€” 1.7 KB view raw
manifest.json β€” 1.8 KB view raw
verdict.json β€” 4.9 KB view raw

DF-3078 β€” ffs_reload (MNT_RELOAD) adopts re-read superblock geometry without validation

What

ffs_reload() (sys/vfs/ufs/ffs_vfsops.c:446-548) is the UFS MNT_RELOAD handler (reachable via mount(2) with MNT_UPDATE|MNT_RELOAD|MNT_RDONLY on a read-only-mounted filesystem β€” the classic "fsck just fixed the backing media, re-read everything" path). It:

  1. re-reads the superblock from the device (bread(devvp, SBOFF, SBSIZE), :479);
  2. validates only fs_magic and fs_bsize bounds (:484-488) β€” the same two fields ffs_mountfs checks (DF-0820);
  3. bcopy(newfs, fs, old_fs_sbsize) (:500) β€” adopts every other geometry field of the re-read superblock (fs_cssize, fs_fsize, fs_csaddr, fs_frag, fs_ncg, fs_sbsize, fs_ipg, fs_inopb, shifts, fs_fmod, fs_maxsymlinklen, …) while fs->fs_csp (:495/:516), the fs_maxcluster array and fs_contigdirs still point into the mount-time allocation sized for the OLD geometry (ffs_mountfs :680-709: cssize + ncg*4 + ncg*1 bytes, 2053 for a default small fs).

The comment at :493 claims "important parameters (eg fs_ncg) are unchanged" β€” nothing enforces that; the superblock on the backing device is attacker data whenever the media can change between mount and reload.

Primitives (all verified on the guest, stock #0 INVARIANTS kernel)

variant site primitive
fs_cssize 2048β†’0x80000 step-3 csum loop :515-529 bcopy(bp->b_data, space, size) heap OOB write, length + content fully disk-controlled (512KB of pattern bytes into the 2053-byte fs_csp chunk); run completed silently (RELOAD RETURNED), guest kept running with ~510KB of heap smashed
fs_ncg 1β†’0x20000000 maxcluster refill :533-537 *lp++ = fs->fs_contigsumsize heap OOB write, 2GB of controlled-value stores β†’ panic: vm_fault: fault on stack guard at ffs_mount+0xab9 (panic_maxcluster_ncg.txt)
fs_fsize β†’0 blks = howmany(fs->fs_cssize, fs->fs_fsize) :515 divide-by-zero: Fatal trap 18 ... idivl %ecx,%eax at ffs_mount+0x627 (panic_idivl_fsize0.txt)
fs_sbsize 2048β†’65536 + RW upgrade ffs_sbupdate :1292-1295 bcopy(fs, bp->b_data, fs->fs_sbsize) heap OOB read of 63KB past the mount-time um_fs allocation β†’ Fatal trap 12, supervisor read in memmove (panic_sbupdate_oob_read.txt)
fs_sbsize 2048β†’8192 + RW upgrade same silent variant: 6144 bytes beyond the in-core superblock written to the media at SBOFF β€” a kernel-heap disclosure oracle readable with dd (leak region was zero pages on the sparse test guest; on a busy system it is live heap)

Adoption of the re-read sb in a returning reload is proven independently: patching fs_dsize (sb+40) makes df /mnt/rt report the new value (31108 blocks / -342% avail) immediately after mount(RELOAD).

Also re-imported by the same bcopy: mp->mnt_maxsymlinklen = fs->fs_maxsymlinklen (:504) β€” the DF-3015 symlink-overflow trust boundary gets a second import path, and ump->um_nindir/um_bptrtodb/um_seqinc (:729-731, mount-time only) desynchronize from the new fs_nindir/fs_fsbtodb/fs_frag.

Reachability / threat model

Mounting UFS and issuing MNT_RELOAD both require root on this system (get_fscap() maps ufs to SYSCAP_RESTRICTEDROOT — same blocker as DF-0820; verified there empirically). Realistic threat: root mounts attacker-supplied media (USB stick, VM disk image, downloaded .img), the media content changes (or is different from what was sanity-glanced), root or an admin tool issues mount -u reload → kernel heap corruption / panic / heap disclosure. Not an unpriv→root LPE on the standard configuration; an automount daemon mounting media as root would make it unprivileged-triggerable.

Files

  • reload.c β€” the trigger: mount(2) MNT_UPDATE|MNT_RELOAD|MNT_RDONLY (note: DF's mount_ufs has no -o reload support, so the syscall must be called directly)
  • craft.c / dumpsb.c β€” superblock patcher / dumper (SBOFF=8192)
  • run.sh <variant> β€” full repro driver (see header)
  • panic_*.txt β€” the three panic signatures
  • fix.diff β€” geometry cross-check in ffs_reload (+ force fs_fmod=0); validated: all crafted variants β†’ clean EINVAL, unchanged reload works, fresh mounts work

Build / run (host side)

dfbsd-qemu/vm.sh reset with-src          # or: up
b=$(base64 -w0 setup.sh)  ...            # push setup.sh/craft.c/dumpsb.c/reload.c
vm.sh run_root 'cd /root/rt && sh build.sh && sh run.sh cssize'

Guest

DragonFly 6.5-DEVELOPMENT #0 X86_64_GENERIC (INVARIANTS on), x86_64.

VERDICT.md
↓ download raw

DF-3078 β€” VERDICT

REPRODUCED — heap out-of-bounds write (controlled content and length), divide-by-zero, and heap out-of-bounds read (media-disclosure oracle), all via ffs_reload adopting a re-read superblock without validating its geometry against the mount-time allocations. Root-reachable (mount + MNT_RELOAD), panic/corruption impact; not an unpriv→root LPE on the standard configuration (hard blocker: UFS mounts are root-gated, same as DF-0820).

Root cause (path:line, verified in-tree and on-guest)

ffs_reload (sys/vfs/ufs/ffs_vfsops.c:446):

  • :479-488 β€” re-reads the sb; validates ONLY fs_magic + fs_bsize bounds.
  • :493-495 β€” comment asserts "important parameters (eg fs_ncg) are unchanged"; nothing checks it. The mount-time allocations that depend on the OLD geometry: um_fs = kmalloc(fs_sbsize) (:671), fs_csp = kmalloc(cssize + ncg*[contigsum] + ncg) (:680-685).
  • :500 β€” bcopy(newfs, fs, (uint)fs->fs_sbsize) adopts every field of the re-read sb (the old sbsize bounds the copy, so the copy itself is safe β€” the adopted values are the bug).
  • :515 β€” blks = howmany(fs->fs_cssize, fs->fs_fsize) β€” div-by-zero on adopted fs_fsize==0. Reproduced: Fatal trap 18, idivl, ffs_mount+0x627.
  • :515-529 β€” step-3 loop re-reads howmany(NEW_cssize, NEW_fsize) frags of NEW_csaddr into the OLD fs_csp: bcopy(bp->b_data, space, size) walks space forward β€” heap OOB write, length = adopted cssize, content = on-media bytes (fully attacker-chosen on crafted media). Reproduced (cssize 2048β†’0x80000): 524288 bytes copied into the 2053-byte allocation β€” reload RETURNED 0, guest kept running with ~522 KB of heap smashed by the on-disk pattern. (Notably this variant is a silent, bounded-or-not, attacker-content overwrite β€” a strictly better primitive than DF-0820's same-field-coupled ones.)
  • :533-537 β€” maxcluster refill writes NEW_ncg int32s past the array sized for OLD ncg. Reproduced (ncg 1β†’0x20000000): panic: vm_fault: fault on stack guard, store at ffs_mount+0xab9.
  • :504 β€” re-imports mp->mnt_maxsymlinklen (DF-3015's symlink-overflow trust boundary gains a second import path).
  • :1292-1295 (ffs_sbupdate, reached by RW-upgrade of the reloaded mount) β€” bcopy(fs, bp->b_data, fs->fs_sbsize) with adopted sbsize reads past the mount-time um_fs allocation. Reproduced (sbsize 2048β†’65536): Fatal trap 12, supervisor READ, in memmove; and (sbsizeβ†’8192) the 6144 bytes beyond um_fs landed on the raw media (zero pages on the sparse guest β€” on a busy host, adjacent live heap objects).
  • Adoption in a returning reload proven independently: crafted fs_dsize (sb+40) shows up in df output right after mount(RELOAD).

Why this is a NEW finding (not DF-0820 re-filed)

DF-0820 = ffs_mountfs (initial mount) consumes unvalidated on-disk geometry. DF-3078 = ffs_reload (MNT_RELOAD) fails to cross-check the re-read sb against the ALREADY-ALLOCATED, previously-validated state. Same family (mount-boundary census, cf. DF-3047 the ext2 MNT_RELOAD analog), different function, different fix, and β€” unlike 0820 β€” the cssize variant yields a content-and-length controlled, silently-completing overwrite.

Exploitability to uid=0 β€” BLOCKED (reachability, not primitive)

The write primitive itself (attacker bytes × attacker length, groomable: any cssize delta gives a precise bounded overwrite past fs_csp) is LPE-grade on this no-SMAP/SMEP/KASLR guest. But the only callers are mount(2)-based paths: UFS mounts and their updates are gated by SYSCAP_RESTRICTEDROOT (get_fscap, sys/kern/vfs_syscalls.c:5383+; empirically proven in the DF-0820 pack — vfs.usermount=1 does NOT let a non-root user mount ufs). So the trigger requires root; root→kernel is not a privilege-boundary crossing. An automount/daemon that mounts removable media as root would make the trigger unprivileged — documented, not present on this guest.

Fix validation

fix.diff (in-pack): ffs_reload cross-checks the re-read sb's sizing geometry (fs_sbsize/fs_bsize/fs_fsize/fs_frag/fs_ncg/fs_ipg/fs_cssize/ fs_csaddr/fs_contigsumsize/fs_bshift/fs_fshift) against the mounted one, returning EINVAL on mismatch, and forces newfs->fs_fmod = 0.

Built as kernel #1 (make -j6 nativekernel KERNCONF=X86_64_GENERIC, BUILD_OK) and validated (run.patched.log):

variant baseline #0 patched #1
fs_fsize=0 Fatal trap 18 (idivl) EINVAL, guest up
fs_ncg=0x20000000 vm_fault stack-guard panic EINVAL, guest up
fs_cssize=0x80000 silent 522KB heap smash, rc 0 EINVAL, guest up
fs_sbsize=65536+RW trap 12 supervisor-read fault EINVAL, guest up
fs_sbsize=8192+RW 6KB heap→media EINVAL, guest up, no leak bytes
unchanged reload works works (rc 0)
fresh RW mount + create works works

fix_status: fixed.

Guest / environment

DragonFly 6.5-DEVELOPMENT #0 (baseline) / #1 (fix), X86_64_GENERIC, INVARIANTS on, x86_64 (env.txt). Guest reset clean (vm.sh reset with-src) after validation.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to in-guest /usr/src, make -j6 nativekernel KERNCONF=X86_64_GENERIC + installkernel (BUILD_OK), rebooted into #1: all five crafted variants now return 'mount: Invalid argument' (EINVAL) with the guest staying up and the filesystem still accessible; unchanged-sb reload returns 0; fresh RW mount + file create works. Before/after matrix in VERDICT.md and run.patched.log.

['findings/poc/DF-3078/run.patched.log', 'findings/poc/DF-3078/build.log', 'findings/poc/DF-3078/fix.diff']
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Sun Sep 6 02:02:00 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

['findings/poc/DF-3078/run.log β€” full baseline transcript (all variants)', 'findings/poc/DF-3078/panic_idivl_fsize0.txt β€” Fatal trap 18 idivl (fsize=0 adopted)', 'findings/poc/DF-3078/panic_maxcluster_ncg.txt β€” vm_fault stack-guard panic (ncg=0x20000000 refill store)', 'findings/poc/DF-3078/panic_sbupdate_oob_read.txt β€” trap 12 supervisor READ in memmove (sbsize=65536 sbupdate OOB read)', 'findings/poc/DF-3078/run.patched.log β€” fix validation: all crafted variants EINVAL on #1, regressions pass', 'findings/poc/DF-3078/build.log, env.txt, fix.diff']

PoC changes

Trigger written from scratch: DF's mount_ufs has no '-o reload' option, so the PoC calls mount(2) directly with MNT_UPDATE|MNT_RELOAD|MNT_RDONLY and a zeroed >=sizeof(struct ufs_args) data buffer (a first draft passed a too-small struct and would have made ffs_mount's copyin over-read user memory). craft.c/dumpsb.c built on-guest with -I/usr/src; sb field offsets verified in-guest (fs_cssize=156, fs_fsize=52, fs_ncg=44, fs_sbsize=104, fs_dsize=40, fs_fmod=208).

Verified recommended fix

ffs_reload: reject any re-read superblock whose sizing geometry differs from the mounted one (fs_sbsize/bsize/fsize/frag/ncg/ipg/cssize/csaddr/contigsumsize/bshift/fshift) with EINVAL, and force fs_fmod=0 β€” see fix.diff; validated on a single-fix kernel.

Verdict

REPRODUCED on stock #0: MNT_RELOAD (mount(2) MNT_UPDATE|MNT_RELOAD|MNT_RDONLY on a RO-mounted UFS) re-reads the superblock and adopts its geometry (ffs_vfsops.c:500) while fs_csp/fs_maxcluster/um_fs keep their mount-time sizes (allocated at :671/:685). Crafted re-read sb gives: heap OOB WRITE with fully attacker-controlled length AND content (cssize variant: 524288 pattern bytes bcopy'd into the 2053-byte fs_csp chunk, reload returned 0, ~522KB heap silently smashed); unbounded OOB write of controlled value (ncg variant: vm_fault stack-guard panic in the *lp++ refill); divide-by-zero (fsize=0: Fatal trap 18 idivl at ffs_mount+0x627); heap OOB READ past um_fs via adopted fs_sbsize at RW-upgrade ff_sbupdate bcopy (sbsize=65536: Fatal trap 12 supervisor-read in memmove; sbsize=8192: the 6144 bytes beyond um_fs written to the raw media). Adoption in returning reloads proven via crafted fs_dsize showing in df. NOT an unpriv->root LPE: trigger is root-gated (get_fscap maps ufs to SYSCAP_RESTRICTEDROOT; vfs.usermount=1 does not help β€” same blocker empirically proven in the DF-0820 pack). Impact ceiling: root-context kernel heap corruption + kernel-heap disclosure to media; panic DoS demonstrated.