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

ext2 MNT_RELOAD adopts a new block size without re-deriving um_nindir/um_bptrtodb/um_seqinc/f_iosize β€” mis-mapped I/O (deterministic panic), OOB read, and 4-byte-granular OOB heap write in the indirect-block walks of ext2_bmaparray/ext2_getlbns/ext2_balloc

Summary

ext2_reload() calls ext2_compute_sb_data() (ext2_vfsops.c:821) which replaces the whole geometry, but the cached mount constants um_nindir/um_bptrtodb/um_seqinc are set only in ext2_mountfs (:991-993) and mnt_stat.f_iosize (the readindir buffer size, ext2_bmap.c:126) only refreshes on the next statfs(2). After a reload that shrinks bsize 4096->1024, the mount runs with MNINDIR=1024 against 256-slot 1KB indirect blocks: ext2_getlbns emits in_off in [256..1023]; ext2_bmaparray reads bap[in_off] OOB (:255, run loops :257-269); stale um_bptrtodb mis-maps every blkptrtodb result so data I/O goes to 4x the device offset - the first post-reload directory lookup panics deterministically (ext2_dirbad); and after mount -u -o rw (which also never re-derives um_*), ext2_balloc writes bap[indirs[i].in_off]=htole32(nb) as 4-byte OOB heap writes at b_data+1024..+4044 past a 1KB buffer-cache buffer, offset chosen by the write() file offset, value = the allocated block number (groomable). VERIFIED stock guest: swap image -> reload -> lookup => 'panic: ext2_dirbad: bad dir ino 2 at offset 0: mangled entry'; instrumented module proves ~741 OOB-read allocations consuming heap garbage as block numbers and 15 documented OOB writes ('DF3216[WRITE-OOB]: bap[997] <- 1351 at b_data+3988 (buffer size 1024)'); on-disk forensics: victim's indirect block holds 0/256 entries while 757KB of allocated blocks hold the written data - permanent fs corruption. Unpriv chain with vfs.usermount=1 (mount own crafted RO image, rewrite backing file, MNT_UPDATE|MNT_RELOAD as original mounter, mount -u -o rw); root variant needs no config. uid=0 not pursued to completion; primitive (offset-controlled, value-groomable, repeatable hundreds of times, adjacent buffer-cache chunks as victims, no-SMAP/SMEP guest) fully characterized. Fix validated: re-derive um_* + f_iosize in ext2_reload eliminates panic/mis-map/OOB; on-disk mapping correct. Distinct sinks/trigger from DF-3047.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3216 Β· 17 files
FileTypeDescriptionSize
README.md β€” 7.3 KB ↓ raw
VERDICT.md β€” 7.1 KB ↓ raw
craft.py β€” 3.5 KB view raw
trigger.c β€” 4.2 KB view raw
vntool.c β€” 1.1 KB view raw
build.sh β€” 231 B view raw
run.sh β€” 198 B view raw
build.log β€” 9 B view raw
run.log β€” 3.6 KB view raw
run.2.log β€” 5.8 KB view raw
fix_run.log β€” 5.5 KB view raw
panic.txt β€” 2.2 KB view raw
env.txt β€” 224 B view raw
instrument.diff β€” 3.5 KB view raw
fix.diff β€” 901 B view raw
verdict.json β€” 7.3 KB view raw
manifest.json β€” 1.5 KB view raw

DF-3216 β€” ext2 MNT_RELOAD adopts a new block size but never re-derives um_nindir/um_bptrtodb/um_seqinc/f_iosize β†’ mis-mapped reads (kernel panic), OOB read, and OOB heap write in the indirect-block walks

Summary

ext2_reload() (sys/vfs/ext2fs/ext2_vfsops.c:783) re-reads the superblock and calls ext2_compute_sb_data() (ext2_vfsops.c:821), which replaces the whole geometry β€” fs->e2fs_bsize, e2fs_bshift, e2fs_fsize, e2fs_fsbtodb, e2fs_gd, e2fs_gcount β€” with values derived from the new superblock (validated: log_bsize ≀ 2 β‡’ bsize ∈ {1024,2048,4096}, fsize==bsize, bpg==bsize*8, …). But the cached mount constants

ump->um_nindir   = EXT2_ADDR_PER_BLOCK(fs);          /* ext2_vfsops.c:991  */
ump->um_bptrtodb = log_bsize + 1;                    /* ext2_vfsops.c:992  */
ump->um_seqinc   = EXT2_FRAGS_PER_BLOCK(fs);         /* ext2_vfsops.c:993  */

are set only in ext2_mountfs() β€” ext2_reload() never touches them, and mp->mnt_stat.f_iosize (the size readindir() uses for indirect-block buffers, ext2_bmap.c:126) is only refreshed by the next statfs(2).

After a reload that shrinks the block size 4096β†’1024 the live mount runs with MNINDIR(ump) = 1024 while every indirect block is now 1024 bytes (256 pointers). Every consumer of ext2_getlbns() then computes in_off = (bn/blockcnt) % MNINDIR(ump) (ext2_bmap.c:354) in [256..1023] and indexes past the end of 1 KB buffers:

  • ext2_bmaparray() ext2_bmap.c:255 β€” bap[ap->in_off] OOB read; run loops at :257-269 also scan to MNINDIR(ump);
  • stale um_bptrtodb (3 instead of 1) mis-maps every blkptrtodb() result (ext2_bmap.c:210/:276) β€” file data is read/written at 4Γ— the correct device offset (deterministic ext2_dirbad panic on the first post-reload directory lookup β€” see panic.txt);
  • after mount -u -o rw (the MNT_UPDATE/WANTRDWR path, ext2_vfsops.c:183-223, which also does not re-derive um_), ext2_balloc() writes bap[indirs[i].in_off] = htole32(newb) (ext2_balloc.c:267, and :233 for deeper trees) β€” a 4-byte OOB heap write at attacker-chosen offset* (+4Β·in_offβˆ’1024 bytes past a 1 KB buffer-cache buffer, in_off = f(write offset)), with the freshly-allocated block number as the value.

This is the um_* twin of DF-3047 (which covers the gcount-sized cluster arrays + gd leak, firing when the group count grows). DF-3216 fires on the block-size change with the group count unchanged (1β†’1), i.e. both bugs are independently triggerable; DF-3047's fix (identical-geometry gate) also blocks this one, but the constants divergence is a separate root cause with separate sinks.

Reachability (same as DF-3047)

mount(2) with MNT_UPDATE|MNT_RELOAD on a read-only ext2 mount is permitted to root or to the user who performed the original mount (sys_mount(), sys/kern/vfs_syscalls.c:259-264, f_owner == cr_uid). With vfs.usermount=1 an unprivileged user mounts their own crafted 4096-bsize image read-only on a user-owned vn device, rewrites the backing file with a 1024-bsize image, reloads, then remounts read-write (VOP_EACCESS(VREAD|VWRITE) on the device they own passes the non-root check, ext2_vfsops.c:191-199). The runs in this pack were performed as root (guest default vfs.usermount: 0); the privilege mechanics are identical to DF-3047's verified chain.

Reproduced (stock INVARIANTS kernel #0)

Run 1 β€” pure stock module: deterministic panic

vn attach dev64.img; mount -t ext2fs -o ro /dev/vn0 /mnt/e2
cat imgB8.img > dev64.img        # 4096-bsize fs -> 1024-bsize fs
./trigger reload /mnt/e2         # mount(2) MNT_UPDATE|MNT_RELOAD  (rc=0)
ls /mnt/e2                       # (or any open) -> first lookup of the root dir
panic: ext2_dirbad: /mnt/e2: bad dir ino 2 at offset 0: mangled entry
 ext2_dirbad() <- ext2_search_dirblock() <- ext2_lookup()

(panic.txt β€” the root dir block is read at 4Γ— its real offset through the stale um_bptrtodb, so the first post-reload path resolution dies.)

Run 3 β€” instrumented module (kernel #0 stock; ext2fs.ko rebuilt with

printf-only probes, instrument.diff): full primitive characterization

Sequence: mount imgA(4096) ro β†’ sanity reads (markers correct) β†’ swap to imgB(1024) with a decoy dir block at the mis-mapped offset so lookups survive β†’ reload+statfs β†’ reads β†’ mount -u -o rw β†’ sparse pwrite of 0xAA at every 1 KB block lbn 268..1023 of a fresh file β†’ readback scan.

  • post-reload reads of the SAME logical blocks return ZERO content (NOT-A-MARKER) β€” mis-mapped through the stale shift;
  • the write path: ~741 of 756 allocations read their "existing block" pointer out of bounds (bap[in_off], in_off 256..996 β†’ live heap garbage used as a block number: user data is written to heap-chosen device blocks β€” stray 0xAA blocks found on disk at 611+);
  • the remaining allocations hit zero-filled adjacent memory and performed 15 documented OOB writes: DF3216[WRITE-OOB]: bap[997] <- 1351 at b_data+4*997=3988 (buffer size 1024, b_data=0xfffff80079376000) β€” 4-byte writes at b_data+3988..+4044, values = sequential allocated block numbers (groomable), offsets chosen by the write() offset;
  • on-disk forensics of the post-run image: the victim file's on-disk single-indirect block contains 0/256 entries while 757 KB of blocks are allocated and hold the 0xAA data β€” the block pointers exist only in kernel memory past the buffer (permanent fs corruption + orphaned blocks).

Fix validation (same probes + fix.diff)

fix.diff re-derives um_nindir/um_bptrtodb/um_seqinc and refreshes mnt_stat.f_iosize in ext2_reload() right after ext2_compute_sb_data() succeeds. Rebuilt ext2fs.ko, identical run:

  • reload rc=0; post-reload reads return the correct markers (lbn 100,101, 268-271) β€” geometry-consistent mapping;
  • write demo: zero live OOB prints (the DF3216 lines in fix_run.log are dmesg | tail -30 replay of the pre-fix run's messages β€” dmesg survives kldunload/kldload; they appear only after the post-run dmesg tail marker);
  • on-disk forensics: the victim file now has a proper double-indirect tree (609 β†’ 610 β†’ 256 entries β†’ 611.. 0xAA) β€” fully consistent;
  • no panic, unmount clean.

Files

  • craft.py β€” builds imgA64.img (bsize 4096, 1 group), imgB8.img (bsize 1024, 1 group, /f with per-block markers + empty /w + decoy copy of the first root-dir block at 4Γ— its number), dev64.img
  • trigger.c β€” reload (mount(2)+statfs(2)) / read (marker check) / write (sparse 0xAA spray + readback scan) driver
  • vntool.c β€” vn(4) attach/detach (from DF-3047 pack)
  • build.sh/run.sh β€” exact build & run
  • run.log β€” run 2 (stock module; buffer-cache anomaly documented in VERDICT.md)
  • run.2.log β€” run 3, instrumented baseline (decisive; contains the live WRITE-OOB lines and the mis-directed reads)
  • fix_run.log β€” fixed-module rerun (correct markers, zero live prints)
  • panic.txt β€” run 1 stock-kernel panic
  • instrument.diffβ€” printf-only probes (verification aid, never part of fix)
  • fix.diff β€” the fix (re-derive um_* + f_iosize in ext2_reload)
  • env.txt β€” guest uname/config
VERDICT.md
↓ download raw

DF-3216 VERDICT

reproduced β€” deterministic kernel panic on the pure-stock kernel plus a fully characterized kernel-heap OOB read+write primitive in the ext2 indirect-block walk after MNT_RELOAD adopts a superblock with a different block size. Fixed and fix-validated on the same guest.

Mechanism (path:line)

  1. ext2_mountfs() derives the cached mount constants once, from the mounted (old) superblock: um_nindir = EXT2_ADDR_PER_BLOCK(fs) = bsize/4, um_bptrtodb = log_bsize+1, um_seqinc (sys/vfs/ext2fs/ext2_vfsops.c:991-993), and mnt_stat.f_iosize = bsize via the post-mount VFS_STATFS (ext2_vfsops.c:1126).
  2. ext2_reload() (ext2_vfsops.c:783) re-reads the superblock and calls ext2_compute_sb_data() (:821) which replaces fs->e2fs_bsize, e2fs_bshift, e2fs_fsize, e2fs_fsbtodb etc. with the new geometry β€” all fully re-validated (log_bsize ≀ 2 at :487-491, fsize==bsize at :507, bpg==bsize*8 at :568) β€” but nothing re-derives um_nindir/um_bptrtodb/ um_seqinc, and f_iosize only tracks the new bsize after the next statfs(2) (kern_statfs β†’ VFS_STATFS(mp, &mp->mnt_stat), sys/kern/vfs_syscalls.c:1430-1436).
  3. Shrinking bsize 4096β†’1024 leaves MNINDIR(ump)=1024 against 256-slot indirect blocks and blkptrtodb shift 3 against the correct 1: * ext2_getlbns() computes in_off=(bn/blockcnt)%MNINDIR(ump) (sys/vfs/ext2fs/ext2_bmap.c:354) ∈ [256..1023] for lbn β‰₯ 268 β€” the array a[EXT2_NIADDR+1] itself stays in bounds; it is the offsets that become out-of-range for the real blocks; * ext2_bmaparray() reads ((e2fs_daddr_t *)bp->b_data)[ap->in_off] (ext2_bmap.c:255) past the 1 KB buffer sized mp->mnt_stat.f_iosize (readindir, ext2_bmap.c:126) β€” OOB read; runp/runb loops (:257-269) scan to MNINDIR(ump) too; * every blkptrtodb() result (ext2_bmap.c:210 direct, :276 indirect) is shifted with the stale constant β†’ data I/O at 4Γ— the device offset (deterministic ext2_dirbad panic on the first post-reload directory lookup β€” panic.txt, run 1, pure stock kernel); * ext2_balloc() (RW after mount -u -o rw, which likewise does not re-derive um_ β€” ext2_vfsops.c:183-223) reads bap[indirs[i].in_off] (ext2_balloc.c:198) OOB and writes* bap[indirs[i].in_off] = htole32(nb) (ext2_balloc.c:267; :233 for deeper levels) β€” 4-byte heap writes at b_data + 4*in_off, i.e. up to 3068 bytes past the 1 KB buffer, in_off chosen via the write() offset, value = allocated block number (sequential under allocation pressure, groomable).

Reproduction

Run 1 (stock, no instrumentation): swap 4096-bsize image for 1024-bsize image behind a mounted vn device; mount(2) MNT_UPDATE|MNT_RELOAD (rc=0); first path resolution on the mount:

panic: ext2_dirbad: /mnt/e2: bad dir ino 2 at offset 0: mangled entry
 ext2_dirbad() <- ext2_search_dirblock() <- ext2_lookup()

Run 3 (kernel #0 stock; ext2fs.ko rebuilt with printf-only probes, instrument.diff): with a decoy directory block planted at the mis-mapped offset so lookups survive, the deeper primitives were exercised:

  • post-reload reads of previously-correct blocks return zero content (mis-mapped) β€” run.2.log lines 32-39;
  • sparse pwrite at lbn 268..1023: ~741 allocations consumed OOB-read heap garbage as block numbers (user data written to heap-chosen device blocks; stray 0xAA blocks confirmed on disk at 611+ in the post-run image);
  • 15 allocations hit zero-filled neighbors and performed OOB writes: bap[997] <- 1351 at b_data+3988 (buffer size 1024) … bap[1011] <- 1365 at +4044 β€” kernel heap corruption past a 1 KB buffer-cache buffer;
  • on-disk forensics: victim file's single-indirect block holds 0/256 entries while 757 KB of blocks are allocated with the written data β€” pointers exist only in kernel memory (permanent fs corruption).

Run 2 (run.log, stock) documented a buffer-cache anomaly: reads whose ranges were covered by pre-reload 4 KB buffers returned correct content from stale cache β€” the cold post-reload reads and run 1's panic are the true behavior.

Reachability

MNT_UPDATE|MNT_RELOAD on a read-only ext2 mount is grantable to the user who performed the original mount (sys/kern/vfs_syscalls.c:259-264, f_owner == cr_uid); the RW upgrade additionally checks VOP_EACCESS(devvp, VREAD|VWRITE) for non-root (ext2_vfsops.c:191-199), which passes for a user-owned vn device. With vfs.usermount=1 the whole chain (mount own image ro β†’ rewrite backing β†’ reload β†’ remount rw β†’ write) is unprivileged; these runs used root (guest ships vfs.usermount: 0), matching DF-3047's precedent.

Exploitability

Panic is reliable (any lookup post-reload). The corruption primitive is a 4-byte-granular heap write past a 1 KB buffer at offsets +1024..+4044 chosen by the write() file offset, value = the block number the allocator returns (sequential, i.e. predictable/groomable via prior allocations), repeatable hundreds of times per file; the OOB read feeds heap contents into block pointers (heap-layout oracle written into the user's own image). On this no-SMAP/SMEP/no-KASLR guest, adjacent 1 KB buffer-cache chunks are the direct victims (other buffers' data confirmed corrupted); converting to uid=0 would require grooming a victim object with a pointer/function-pointer into the adjacent kmalloc/buf-data region β€” not pursued to completion in this run (time-bounded; the primitive, its offset control, and its value control are fully characterized above, and the panic + on-disk corruption already meet the High bar).

Fix validation

fix.diff re-derives the three um_* constants and refreshes mnt_stat.f_iosize inside ext2_reload() immediately after ext2_compute_sb_data() succeeds (the new geometry is fully validated by then). Same instrumented kernel+module minus/plus fix:

  • baseline: panic (stock) / mis-mapped reads + 15 OOB writes + 0/256-entry on-disk indirect (instrumented);
  • fixed: post-reload reads return the correct markers (lbn 100,101,268-271), write demo completes with zero live DF3216 prints (the lines visible in fix_run.log are dmesg | tail -30 replay of the pre-fix run β€” dmesg persists across kldunload/kldload; they appear only after the post-run dmesg tail marker), victim file maps correctly on disk (double-indirect 609 β†’ 610 β†’ 256 entries β†’ 0xAA data), unmount clean.

fix_status: fixed (module-level rebuild on kernel #0; DF-3047's identical-geometry gate would also block this vector as defense-in-depth).

impact=panic (stock-kernel observable) over a verified memcorrupt primitive (OOB read + offset/value-controlled 4-byte OOB heap write); confidence=certain; attempts=4 (run1 panic; run2 cache-anomaly; run3 decisive instrumented; run4 fix validation).

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 applied on the guest's clean /usr/src (instrumentation re-applied for observability), ext2fs.ko rebuilt via 'make -j4 nativekernel' and installed; identical PoC sequence: reload rc=0, post-reload reads return the CORRECT BLK markers (baseline returned zeros/garbage), write demo completes with ZERO live OOB prints (baseline: 15 OOB writes + ~741 OOB-read-sourced block pointers), on-disk victim file maps correctly (609->610->256 entries->0xAA data vs baseline 0/256 entries + orphaned blocks), no panic, clean unmount.

fix_run.log (correct markers lines 14-19 & 32-43; all DF3216 lines appear only after the 'post-run dmesg tail' marker = dmesg replay of the pre-fix run, not live); VERDICT.md fixed-image forensics paragraph
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64 (stock kernel; ext2fs.ko rebuilt from patched /usr/src)

Confirmed kernel references

Detail

Exploit chain

vfs.usermount=1 unprivileged user mounts own crafted 4096-bsize ext2 read-only on user-owned vn device; rewrites backing file with 1024-bsize ext2; mount(2) MNT_UPDATE|MNT_RELOAD (permitted to the original mounter, vfs_syscalls.c:259-264) adopts new bsize while um_* stay stale; (a) any lookup => ext2_dirbad panic; or with a decoy dir block: (b) mount -u -o rw passes the non-root VOP_EACCESS check on the user's device; sparse pwrite at lbn>=268 drives ext2_getlbns to in_off 256..1023: OOB reads return heap garbage consumed as block numbers (data written to heap-chosen blocks) and, where adjacent memory is zero, 4-byte OOB heap writes bap[in_off]= at b_data+1024..+4044 (offset chosen by write offset, value groomable via allocation order, repeatable hundreds of times). Not escalated to uid=0 in this run; primitive fully characterized (no-SMAP/SMEP guest, adjacent 1KB buffer-cache chunks confirmed as victims).

Evidence (decisive lines)

["panic.txt: 'panic: ext2_dirbad: /mnt/e2: bad dir ino 2 at offset 0: mangled entry' from ext2_lookup on first post-reload lookup (pure stock kernel, run 1)", "run.2.log lines ~53+: live 'DF3216[WRITE-OOB]: bap[997] <- 1351 at b_data+4*997=3988 (buffer size 1024, b_data=0xfffff80079376000)' x15 plus ~741 'balloc-OOB' lines (instrumented run 3)", 'run.2.log lines 32-39: post-reload reads of lbn 100/101/268-271 return zero content (NOT-A-MARKER) vs correct markers pre-reload (lines 14-19)', "VERDICT.md 'on-disk forensics': post-run image victim file /w single-indirect block 609 has 0/256 entries while i_size=1MB and 0xAA data sits orphaned at blocks 611+ (pointers exist only past the kernel buffer)", "fix_run.log: post-reload reads return correct markers (lbn 100,101,268-271), zero live DF3216 prints (the visible ones are dmesg|tail-30 replay of the pre-fix run; they start after the 'post-run dmesg tail' marker); fixed-image forensics: /w maps 609->610->256 entries->0xAA correctly"]

PoC changes

Finding authored in this pass-2 run (no prior seed). Harness reuses DF-3047's vntool.c/vn(4) approach; new craft.py builds the bsize 4096->1024 pair with equal gcount (1) so DF-3047's cluster-array OOB does not fire first, adds /f with per-1KB-block markers (misdirection oracle) and empty /w (write victim), and plants a decoy copy of the first root-dir block at 4x its block number so post-reload lookups survive the stale-shift mis-map and reach the indirect-walk OOB layers. trigger.c performs reload+statfs (f_iosize refresh), marker reads, and the sparse 0xAA write/readback scan. Instrumentation (instrument.diff) is printf-only probes in ext2_bmap/ext2_balloc/ext2_reload; fix.diff is the real fix without probes.

Verified recommended fix

In ext2_reload(), after ext2_compute_sb_data() succeeds, re-derive ump->um_nindir/um_bptrtodb/um_seqinc from the adopted geometry and set mp->mnt_stat.f_iosize = fs->e2fs_bsize (fix.diff); alternatively/redundantly gate reload on identical geometry as DF-3047's fix does.

Verdict

ext2_reload() adopts a re-validated superblock with a new block size but never re-derives ump->um_nindir/um_bptrtodb/um_seqinc (set only in ext2_mountfs, ext2_vfsops.c:991-993) nor mnt_stat.f_iosize (readindir's buffer size, ext2_bmap.c:126). After a 4096->1024 reload the live mount walks indirect blocks with MNINDIR=1024 against 256-slot 1KB blocks: ext2_getlbns (ext2_bmap.c:354) emits in_off 256..1023; ext2_bmaparray reads bap[in_off] OOB (ext2_bmap.c:255); blkptrtodb mis-maps all data I/O 4x (deterministic ext2_dirbad panic on the first post-reload lookup on the pure-stock kernel); and after mount -u -o rw, ext2_balloc writes bap[in_off]=newb (ext2_balloc.c:267) as 4-byte OOB heap writes up to +3068 bytes past a 1KB buffer-cache buffer at write()-offset-chosen positions with allocated-block-number values. Demonstrated: stock panic (run 1), mis-mapped reads + ~741 OOB-read-sourced block pointers + 15 documented OOB writes + 0/256-entry on-disk indirect with 757KB of orphaned allocated blocks (instrumented run 3 + on-disk forensics). fix.diff (re-derive um_* + f_iosize in ext2_reload) eliminates all of it: correct markers, zero OOB prints, consistent on-disk mapping.