ffs_indirtrunc wild and attacker-aimed kernel bzero via unvalidated on-disk fs_nindir and unclamped lastbn
Summary
ffs_indirtrunc zeroes the tail of the fs_bsize-byte indirect-block buffer with bzero(&bap[last+1], (uint)(NINDIR(fs)-(last+1))*sizeof(ufs_daddr_t)) (:535-536). 'last' derives from the requested truncate length via lastiblock[] (:311-314, bounded only by length<=fs_maxfilesize :157-158) and NINDR(fs)=fs->fs_nindir is raw superblock data - ffs_mountfs validates only fs_magic and sizeof(struct fs)<=fs_bsize<=MAXBSIZE (ffs_vfsops.c:642-646) and adopts fs_nindir verbatim (:729). One crafted int32 (fs_nindir 1024->512 on a legit newfs -b 4096 image) breaks the invariant the mount-time fs_maxfilesize clamp (:753-758) is built on: the clamp permits lbns up to 2^30 while the crafted tree capacity is 134,480,396 blocks, so ftruncate to that size+4096 drives lastiblock[TRIPLE] to exactly NINDIR^3, last to exactly NINDIR, and the size computes to (uint)-1*4 = ~16GiB - a kernel zero-march off the end of the 4096-byte buffer. Because last=(lastbn+1) at the SINGLE level, the primitive is also aimable: with fs_nindir=2^28 the attacker chooses start=b_data+(lastbn+1)*4 anywhere in a ~4GiB window above the buffer and an arbitrary (mod 2^32) length. Sibling sinks sharing the root cause: ffs_balloc.c:297 bap[in_off] OOB-reads up to b_data+4GiB during extend, and the :550-563 free loop scans/ffs_blkfree()s NINDIR entries regardless of buffer capacity. Threat: root mounts attacker-supplied media (same model as DF-3035/DF-3078); afterwards any unpriv local user who owns a file on that fs triggers with a single ftruncate(2). VERIFIED stock INVARIANTS guest: (A) wild march - sparse-extend 560GB under legit geometry, patch fs_nindir 1024->512, remount, unpriv ftruncate -> Fatal trap 12, Stopped at memset+0xd5 repe stosq; (B) aimed - fs_nindir=2^28 + di_size craft -> 4100-byte zero at b_data+1GiB, rip=memset+0xf0. Bonus: extend under fs_nindir=2^28 alone panics in ffs_balloc+0x5e7 (bap[in_off] read at +1GiB). uid0 route rigorously blocked (zero-only primitive anchored in buffer KVA submap; aimed variant needs a disclosure companion) - documented. Fix validated in-guest (mount-time fs_nindir==bsize/4 check + representable-maxfilesize clamp + in-file last clamp): crafted mounts EINVAL, legit 560GB sparse truncate unregressed, guest clean.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-3172 Β· 21 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger3172.c | β | 2.3 KB | view raw | |
| aim3172.c | β | 1.8 KB | view raw | |
| mkaim.c | β | 892 B | view raw | |
| inopatch.c | β | 2.0 KB | view raw | |
| craft64.c | β | 926 B | view raw | |
| off.c | β | 639 B | view raw | |
| setup1.sh | β | 713 B | view raw | |
| setup2.sh | β | 1.0 KB | view raw | |
| setupB.sh | β | 720 B | view raw | |
| run.log | β | 1.6 KB | view raw | |
| run.aimed.log | β | 1.9 KB | view raw | |
| run.patched.log | β | 1.9 KB | view raw | |
| boot_after_trigger.log | β | 2.4 KB | view raw | |
| panic_aimed.txt | β | 744 B | view raw | |
| panic_wild.txt | β | 0 B | β download | |
| fix.diff | β | 2.3 KB | view raw | |
| fix_build.log | β | 5.6 MB | β download | |
| env.txt | β | 204 B | view raw | |
| VERDICT.md | β | 7.3 KB | β raw | |
| verdict.json | β | 6.8 KB | view raw | |
| manifest.json | β | 1.6 KB | view raw |
DF-3172 VERDICT β reproduced (panic; controlled zero-write primitive; uid0 not achieved)
Bottom line
ffs_indirtrunc()'s tail-zering bzero() (sys/vfs/ufs/ffs_inode.c:535-536)
computes its start index and length from lastbn (attacker-chosen through
the truncate length) and NINDIR(fs) (raw superblock data, never validated
at mount). On a filesystem whose superblock carries a crafted fs_nindir,
an unprivileged ftruncate(2) by the file's owner produces an
attacker-sized (up to ~16 GiB) and attacker-placed (start anywhere in a
~4 GiB window above the buffer, 4-byte granularity) kernel zero-write.
Proven on the stock INVARIANTS guest by two deterministic panics:
-
Wild march (run A) β
fs_nindir1024β512 (one int32 in the sb): shrink of a 560 GB sparse file toSigma'*4096+4096= 550,831,706,112 βlastiblock[TRIPLE] == NINDIRΒ³βlast == NINDIRβbzero(&bap[NINDIR+1], (uint)-1 * sizeof(ufs_daddr_t))= 16 GiB-4 march starting 2052 bytes into the 4096-byte indirect-block buffer:Fatal trap 12, supervisor write data, page not present,Stopped at memset+0xd5: repe stosq %es:(%rdi), fault VA 0xfffff80066de7000 (first non-resident page above the buffer), guest down atdb>. -
Aimed write (run B) β
fs_nindir = 2^28+ on-disk-crafteddi_size(so no balloc is needed on the trigger path): shrink driveslastbn = NINDIR-1026at the SINGLE level β a 4100-byte zero atb_data + 1,073,737,724(+1.000 GiB) βpanic: assertion "obj != NULL" failed in vm_object_hold_shared,rip = memset+0xf0β the fault is in the bzero itself, at the aimed address (unmapped β fault; a mapped target would be silent corruption instead).
Bonus (same root cause, sibling sink): with fs_nindir = 2^28, growing the
file through the kernel first makes ufs_getlbns return
in_off = (lbn-12) % 2^28 and ffs_balloc reads
bap[268434431] (ffs_balloc.c:297) = b_data + 1 GiB OOB β
ffs_balloc+0x5e7 β vm_fault β vm_object_hold_shared(obj==NULL) panic.
The ffs_inode.c:550-563 free loop has the same NINDIR-bound OOB-read
shape (reads then ffs_blkfree()s values past the buffer).
Why the mount-time clamps do not save you
ffs_mountfschecks onlyfs_magicandsizeof(struct fs) <= fs_bsize <= MAXBSIZE(ffs_vfsops.c:642-646);fs_nindiris adopted verbatim (ump->um_nindir, :729) and governsNINDIR(fs)everywhere (bmaparray levels, balloc offsets, indirtrunc bounds).- The
fs_maxfilesizeclamp (:753-758,min(2^30*bsize-1, 2^43-1)) caps lbns at 2^30 β exactly the triple-indirect capacitySigmaof an honest NINDIR=bsize/4 filesystem β₯ 4096-byte blocks. It was verified numerically and empirically that an honest geometry is safe (an honestfs_maxfilesize = Sigma*bsize-1from newfs keepslast <= NINDIR-1, and the first PoC attempt via craftedfs_maxfilesizealone did NOT fire β the clamp neutralizes it for every bsize newfs can produce). The invariant collapses as soon asfs_nindiritself is a lie: with M=512 the tree capacity is 134,480,396 blocks while the clamp still permits lbns up to 2^30 β an 8x gap the attacker closes with the same truncate that the clamp was supposed to bound.
uid0 push β what was tried, where it stops
The write primitive is zero-only, forward-marching, and anchored at
bp->b_data, which lives in the buffer KVA submap
(platform/pc64/x86_64/machdep.c:499-505: kernel_map β clean_map β
buffer_map, nbuf*MAXBSIZE β 838 MB on this guest, 12634 buffers;
kern/vfs_bio.c:638 assigns each struct buf a 64K window of which only the
buffer's own pages are mapped).
- The march from run A dies at the first non-resident page (observed: within one page above the 4096-byte buffer). Victims before that point are buffer-cache page images (cached file data/metadata), not kernel objects: zeroing them can only clear bits β it cannot set ISUID or forge a credential; at worst it corrupts written-back filesystem state.
struct proc/ucredlive in kernel_map kmem slab allocations that are not contiguous with the resident buffer windows; a march that survives the arena would have to cross unmapped pager_map/clean_map reservations first (it cannot β it faults at the first hole).- The aimed variant (start anywhere in a 4 GiB window above b_data,
arbitrary length) is the exploitation direction: with no KASLR the guest
layout is boot-deterministic, so a two-boot campaign (probe boot leaks
b_data from the fault VA, target boot zeroes a sprayed ucred at
b_data + deltaand a child process pollsgeteuid()==0) is theoretically viable. It was not completed: (a) locating one's own ucred needs a disclosure companion β the only candidate here is the OOB-readβffs_blkfreeβcg-bitmap oracle, whose values are fsblock-cast int32s and whose INVARIANTS checks abort the run long before a usable sample; (b) each probe costs a full boot cycle; (c) an aimed zero that crosses live kernel objects between the anchor and the ucred zeroes spinlocks/list pointers first, so the race window closes beforeexecof a root shell can complete. Documented as a hard blocker for this primitive shape; impact recorded aspanic(deterministic DoS + controlled kernel heap corruption window), severity High under the crafted-media threat model (same class as DF-3035/DF-3078).
Fix validation (fix.diff)
- Baseline (stock #0): run A panic + run B aimed panic (above).
- Patched (#1,
make nativekernelin-guest, 0 warnings): - T1 regression: legit
newfs -b 4096mount; unpriv user extends sparse file to 560,000,000,000 then truncates to 550,831,706,112 β returns 0,statsize exact, guest up. - T2: run-A craft (fs_nindir 1024β512) β
mount_ufs: /dev/vn0 on /mnt/poc: incorrect super block(EINVAL), guest up. - T3: run-B craft (fs_nindir = 2^28) β same EINVAL rejection, guest up.
- fix.diff = mount-time
fs_nindir == fs_bsize/sizeof(ufs_daddr_t)check (mirrors newfs/mkfs.c:316 exactly, cannot reject honest media) +fs_maxfilesizecapped at the representableSigma*bsize-1(same formula as mkfs.c:332-335) + defense-in-depth clamp oflasttoNINDIR(fs)-1inffs_indirtrunc(also protects MNT_RELOAD geometry re-adoption Γ la DF-3078).
Kernel references
- sys/vfs/ufs/ffs_inode.c:535-536 β the bzero sink (start+len from media)
- sys/vfs/ufs/ffs_inode.c:311-314 β lastblock/lastiblock from length
- sys/vfs/ufs/ffs_inode.c:157-158 β only length validation (fs_maxfilesize)
- sys/vfs/ufs/ffs_inode.c:550-563 β NINDIR-bounded OOB read loop + blkfree
- sys/vfs/ufs/ffs_vfsops.c:642-646 β insufficient superblock validation
- sys/vfs/ufs/ffs_vfsops.c:729 β um_nindir adopted raw
- sys/vfs/ufs/ffs_vfsops.c:753-758 β maxfilesize clamp (invariant needs honest nindir)
- sys/vfs/ufs/ufs_bmap.c:315 β in_off = (bn/blockcnt) % MNINDIR
- sys/vfs/ufs/ffs_balloc.c:297 β sibling OOB read sink
- sys/vfs/ufs/ufs_types.h:41 β ufs_daddr_t is int32 (NINDIR = bsize/4)
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
not_testableConfirmed kernel references
- sys/vfs/ufs/ffs_inode.c:535
- sys/vfs/ufs/ffs_inode.c:311
- sys/vfs/ufs/ffs_inode.c:157
- sys/vfs/ufs/ffs_inode.c:550
- sys/vfs/ufs/ffs_vfsops.c:642
- sys/vfs/ufs/ffs_vfsops.c:729
- sys/vfs/ufs/ffs_vfsops.c:753
- sys/vfs/ufs/ufs_bmap.c:315
- sys/vfs/ufs/ffs_balloc.c:297
- sys/vfs/ufs/ufs_types.h:41
- sys/platform/pc64/x86_64/machdep.c:499
Detail
Exploit chain
root mounts crafted media (fs_nindir patched) -> unprivileged file owner ftruncates a file whose di_size exceeds the crafted geometry's representable range -> ffs_indirtrunc bzero with (start=b_data+(lastbn+1)4, len=(uint)(M-(lastbn+1))4) β start attacker-chosen within a ~4GiB window above a buffer-cache KVA address, length arbitrary (mod 2^32) -> kernel zero-write primitive. uid0 NOT completed: zero-only primitive anchored in the buffer KVA submap (machdep.c:499-505, vfs_bio.c:638 β 64K windows, only resident pages mapped) marches at most to the first non-resident page; proc/ucred slabs are not contiguous with it; the aimed variant could in principle target a sprayed ucred on this no-KASLR guest but needs a KVA disclosure companion (only candidate: the ffs_inode.c:550-563 OOB-read -> ffs_blkfree -> cg-bitmap oracle, aborted by INVARIANTS before usable samples) and zeroes intervening live objects before a root shell can exec. Documented as a rigorous hard blocker in VERDICT.md; impact recorded as deterministic panic + controlled corruption window.
Evidence (decisive lines)
['findings/poc/DF-3172/run.log β full baseline run: setup, unpriv extend to 560GB, single-int32 craft, shrink -> Fatal trap 12 supervisor write, Stopped at memset+0xd5 repe stosq, fault VA 0xfffff80066de7000', 'findings/poc/DF-3172/boot_after_trigger.log β serial console capture of the wild-march panic', 'findings/poc/DF-3172/panic_aimed.txt β aimed variant: panic assertion obj!=NULL with rip=memset+0xf0 (the bzero itself faulted at the aimed +1GiB target)', 'findings/poc/DF-3172/run.aimed.log / run.patched.log β aimed run + sibling ffs_balloc OOB-read panic + full patched-kernel validation matrix (T1 regression OK, T2/T3 crafted mounts EINVAL)', 'findings/poc/DF-3172/trigger3172.c + setup1.sh + setup2.sh + craft64.c β minimal unprivileged trigger + image builder + single-int32 superblock patcher', 'findings/poc/DF-3172/aim3172.c + inopatch.c + mkaim.c + setupB.sh β aimed (+1GiB) variant harness', 'findings/poc/DF-3172/fix.diff + fix_build.log β mount validation + maxfilesize representable clamp + in-file clamp; 35848-line in-guest kernel build, 0 warnings']
PoC changes
Seed authored fresh in this run. Two false premises were corrected by experiment: (1) NINDIR is fs_bsize/sizeof(ufs_daddr_t) with ufs_daddr_t=int32 (bsize/4 = 1024 for 4K blocks, not 512) β the first variant (crafted fs_maxfilesize alone, honest nindir) did NOT fire because the mount clamp 2^30bsize-1 exactly equals the honest tree capacity in lbns; (2) with crafted nindir the kernel cannot grow the file past the crafted geometry (ufs_bmap.c:283 EFBIG + ffs_balloc.c:297 OOB panic), so the oversized di_size is placed on disk (inopatch) under the legit geometry and the superblock is patched afterwards. Aimed variant derived after observing that start=(lastbn+1)4 with factor=1 at SINGLE level gives a ~4GiB aiming window.
Verified recommended fix
Validate fs_nindir == fs_bsize/sizeof(ufs_daddr_t) at mount (ffs_mountfs), cap fs_maxfilesize at the representable Sigma*bsize-1 (mkfs.c formula), and clamp ffs_indirtrunc's `last' to NINDIR(fs)-1 (defense in depth, also covers MNT_RELOAD geometry re-adoption).
Verdict
Reproduced on the stock INVARIANTS guest: ffs_indirtrunc's tail-zering bzero (ffs_inode.c:535-536) takes its start index from the truncate length (lastbn/factor, ffs_inode.c:311-314,487) and its length from NINDIR(fs) = fs->fs_nindir β raw superblock data that ffs_mountfs never validates (ffs_vfsops.c:642-646 checks only fs_magic and bsize bounds; fs_nindir is adopted verbatim into ump->um_nindir at :729). Patching ONE int32 in the superblock (fs_nindir 1024->512 on a newfs -b 4096 image) makes the mount-time fs_maxfilesize clamp (ffs_vfsops.c:753-758, lbn cap 2^30) exceed the crafted tree capacity 8x, so an unprivileged ftruncate(2) by the file owner to Sigma'4096+4096 = 550,831,706,112 drives lastiblock[TRIPLE] to exactly NINDIR^3 and the bzero computes (uint)(-1)sizeof(ufs_daddr_t) = ~16GiB: a wild kernel zero-march off the 4096-byte indirect-block buffer -> Fatal trap 12 (supervisor write, page not present), Stopped at memset+0xd5 repe stosq, guest down (run.log, boot_after_trigger.log). An aimed variant (fs_nindir=2^28 + on-disk-crafted di_size) demonstrates attacker-chosen placement: a 4100-byte zero at b_data + 1.000 GiB -> panic with rip = memset+0xf0 (panic_aimed.txt). The same unvalidated fs_nindir also OOB-reads in ffs_balloc.c:297 via ufs_bmap.c:315 in_off=(lbn/..)%M (reproduced: ffs_balloc+0x5e7 panic) and in the ffs_inode.c:550-563 free loop. Fixed by fix.diff (mount validation + representable maxfilesize clamp + in-file last clamp): validated in-guest with make nativekernel β crafted mounts rejected EINVAL, legit 560GB sparse truncate works, no panic.
No comments yet.