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)
PoC verification
Evidence pack
findings/poc/DF-3216 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 toMNINDIR(ump);- stale
um_bptrtodb(3 instead of 1) mis-maps everyblkptrtodb()result (ext2_bmap.c:210/:276) β file data is read/written at 4Γ the correct device offset (deterministicext2_dirbadpanic 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()writesbap[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 -30replay of the pre-fix run's messages β dmesg survives kldunload/kldload; they appear only after thepost-run dmesg tailmarker); - 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.imgtrigger.cβ reload (mount(2)+statfs(2)) / read (marker check) / write (sparse 0xAA spray + readback scan) drivervntool.cβ vn(4) attach/detach (from DF-3047 pack)build.sh/run.shβ exact build & runrun.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 panicinstrument.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
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)
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), andmnt_stat.f_iosize = bsizevia the post-mountVFS_STATFS(ext2_vfsops.c:1126).ext2_reload()(ext2_vfsops.c:783) re-reads the superblock and callsext2_compute_sb_data()(:821) which replacesfs->e2fs_bsize,e2fs_bshift,e2fs_fsize,e2fs_fsbtodbetc. 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-derivesum_nindir/um_bptrtodb/ um_seqinc, andf_iosizeonly tracks the new bsize after the nextstatfs(2)(kern_statfs β VFS_STATFS(mp, &mp->mnt_stat), sys/kern/vfs_syscalls.c:1430-1436).- Shrinking bsize 4096β1024 leaves
MNINDIR(ump)=1024against 256-slot indirect blocks andblkptrtodbshift 3 against the correct 1: *ext2_getlbns()computesin_off=(bn/blockcnt)%MNINDIR(ump)(sys/vfs/ext2fs/ext2_bmap.c:354) β [256..1023] for lbn β₯ 268 β the arraya[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 sizedmp->mnt_stat.f_iosize(readindir, ext2_bmap.c:126) β OOB read; runp/runb loops (:257-269) scan toMNINDIR(ump)too; * everyblkptrtodb()result (ext2_bmap.c:210 direct, :276 indirect) is shifted with the stale constant β data I/O at 4Γ the device offset (deterministicext2_dirbadpanic on the first post-reload directory lookup β panic.txt, run 1, pure stock kernel); *ext2_balloc()(RW aftermount -u -o rw, which likewise does not re-derive um_ β ext2_vfsops.c:183-223) readsbap[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 atb_data + 4*in_off, i.e. up to 3068 bytes past the 1 KB buffer,in_offchosen 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
pwriteat 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 -30replay of the pre-fix run β dmesg persists across kldunload/kldload; they appear only after thepost-run dmesg tailmarker), 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
fixedfix.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
Confirmed kernel references
- sys/vfs/ext2fs/ext2_bmap.c:126
- sys/vfs/ext2fs/ext2_bmap.c:210
- sys/vfs/ext2fs/ext2_bmap.c:255
- sys/vfs/ext2fs/ext2_bmap.c:257
- sys/vfs/ext2fs/ext2_bmap.c:276
- sys/vfs/ext2fs/ext2_bmap.c:354
- sys/vfs/ext2fs/ext2_balloc.c:198
- sys/vfs/ext2fs/ext2_balloc.c:233
- sys/vfs/ext2fs/ext2_balloc.c:267
- sys/vfs/ext2fs/ext2_vfsops.c:821
- sys/vfs/ext2fs/ext2_vfsops.c:991
- sys/vfs/ext2fs/ext2_vfsops.c:992
- sys/vfs/ext2fs/ext2_vfsops.c:993
- sys/vfs/ext2fs/ext2_vfsops.c:1126
- sys/vfs/ext2fs/ext2_vfsops.c:183
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]=
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.
No comments yet.