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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3172 Β· 21 files
FileTypeDescriptionSize
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
VERDICT.md
↓ download 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:

  1. Wild march (run A) β€” fs_nindir 1024β†’512 (one int32 in the sb): shrink of a 560 GB sparse file to Sigma'*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 at db>.

  2. Aimed write (run B) β€” fs_nindir = 2^28 + on-disk-crafted di_size (so no balloc is needed on the trigger path): shrink drives lastbn = NINDIR-1026 at the SINGLE level β†’ a 4100-byte zero at b_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_mountfs checks only fs_magic and sizeof(struct fs) <= fs_bsize <= MAXBSIZE (ffs_vfsops.c:642-646); fs_nindir is adopted verbatim (ump->um_nindir, :729) and governs NINDIR(fs) everywhere (bmaparray levels, balloc offsets, indirtrunc bounds).
  • The fs_maxfilesize clamp (:753-758, min(2^30*bsize-1, 2^43-1)) caps lbns at 2^30 β€” exactly the triple-indirect capacity Sigma of an honest NINDIR=bsize/4 filesystem β‰₯ 4096-byte blocks. It was verified numerically and empirically that an honest geometry is safe (an honest fs_maxfilesize = Sigma*bsize-1 from newfs keeps last <= NINDIR-1, and the first PoC attempt via crafted fs_maxfilesize alone did NOT fire β€” the clamp neutralizes it for every bsize newfs can produce). The invariant collapses as soon as fs_nindir itself 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/ucred live 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 + delta and a child process polls geteuid()==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 before exec of a root shell can complete. Documented as a hard blocker for this primitive shape; impact recorded as panic (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 nativekernel in-guest, 0 warnings):
  • T1 regression: legit newfs -b 4096 mount; unpriv user extends sparse file to 560,000,000,000 then truncates to 550,831,706,112 β†’ returns 0, stat size 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_maxfilesize capped at the representable Sigma*bsize-1 (same formula as mkfs.c:332-335) + defense-in-depth clamp of last to NINDIR(fs)-1 in ffs_indirtrunc (also protects MNT_RELOAD geometry re-adoption Γ  la DF-3078).

Kernel references

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_testable
↓ fix.diffper-fix-DF-3172

Confirmed kernel references

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.