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

ftruncate()/unlink on special-file inodes (FIFO/device/socket) runs the ext2 truncate engine over their i_db[] overlay β€” kernel panic or silent free of live blocks (cross-file aliasing)

Summary

kern_ftruncate rejects only VDIR (vfs_syscalls.c:4105); ext2_setattr's va_size switch uses 'default: break' (ext2_vnops.c:338-339) so VCHR/VBLK/VFIFO/VSOCK fall through to ext2_truncate, which has no vnode-type gate (ext2_inode.c:468). ext2_ind_truncate then frees every nonzero i_db[i]/i_ib[] slot via ext2_blkfree (:383-393, :362-374) - slots that for special files are not block pointers (devices: they overlay i_rdev). ext2_blkfree's only validation is bno < e2fs_bcount (ext2_alloc.c:1414): a clear bitmap bit -> unconditional panic 'freeing free block' (:1429); a set bit -> a live block owned by another file is silently freed and becomes allocatable while still referenced. Unlink reaches the same frees via ext2_inactive (:511-512). FreeBSD's ufs_setattr returns 0 for special files - DragonFly ext2 diverges. On legit images the lengthen path instead overwrites i_db[0]=i_rdev via ext2_balloc - persistent device-identity corruption by any user with O_RDWR access to a device node. VERIFIED both variants on guest (unpriv maxx): (A) crafted FIFO with i_db[0..11]=free blocks 499-510 -> ftruncate(fd,0) -> 'panic: ext2_blkfree: freeing free block 499', guest dead; (B) crafted FIFO with i_db[0]=26 (live block of file victim) -> ftruncate returns 0, f_bfree 483->484, debugfs proves 'Block 26 not in use' while victim still references it - silent cross-file free. Fix validated (setattr default: return 0 + VREG/VDIR/VLNK gate in ext2_truncate, kernel #1): both variants clean no-ops, block 26 still in use, guest up.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3175 Β· 14 files
FileTypeDescriptionSize
craft_img.py β€” 5.8 KB view raw
df3175.ext2 β€” 8.0 MB ↓ download
trigger.c β€” 2.1 KB view raw
build.sh β€” 157 B view raw
run.sh β€” 188 B view raw
build.log β€” 43 B view raw
run.log β€” 652 B view raw
run.2.log β€” 525 B view raw
panic.txt β€” 788 B view raw
env.txt β€” 167 B view raw
fix.diff β€” 1.4 KB view raw
fix_build.log β€” 5.5 MB ↓ download
VERDICT.md β€” 5.5 KB ↓ raw
verdict.json β€” 5.2 KB view raw
VERDICT.md
↓ download raw

DF-3175 β€” ftruncate() on special-file (FIFO/device) inodes frees their

i_db[] overlay as disk blocks β†’ kernel panic or silent cross-file block free

Verdict

REPRODUCED on the stock GENERIC kernel (6.5-DEVELOPMENT #0, INVARIANTS ON), both variants, from an unprivileged trigger (user maxx, uid 1000) on a root-mounted crafted ext2 image:

  1. panic variant (targetA) β€” deterministic kernel panic: panic: ext2_blkfree: freeing free block 499, fs=/mnt/df3175 ext2_blkfree() at ext2_blkfree+0x28d ext2_truncate() at ext2_truncate+0x3aa ext2_setattr() at ext2_setattr+0x21a vop_setattr() at vop_setattr+0x54 kern_ftruncate() at kern_ftruncate+0x190 Guest dies in DDB (vm.sh status β†’ down). See panic.txt.

  2. silent-free variant (targetB) β€” no panic, ftruncate returns 0: [trigger] pre : f_bfree=483 f_blocks=492 [trigger] ftruncate returned rc=0 errno=0 (success) [trigger] post: f_bfree=484 (delta 1) [trigger] SILENT FREE CONFIRMED: 1 block(s) freed ... On-disk proof after unmount (host-side debugfs): Block 26 not in use <- bitmap says FREE victim BLOCKS: (0):26 <- victim still references block 26 i.e. a live data block of another file was freed and is now allocatable to any future file while victim still reads/writes it β€” cross-file block aliasing (confidentiality/integrity break inside the fs, silent, no kernel message).

Root cause (line-accurate)

  • sys/kern/vfs_syscalls.c:4105 β€” kern_ftruncate rejects only VDIR.
  • sys/vfs/ext2fs/ext2_vnops.c:330-341 β€” ext2_setattr's va_size switch: case VLNK/VREG: … break; default: break; β†’ VCHR/VBLK/VFIFO/VSOCK fall through to ext2_truncate(). FreeBSD's ufs_setattr returns 0 here ("Just ignore the request in other cases") β€” DragonFly diverges.
  • sys/vfs/ext2fs/ext2_inode.c:458-491 β€” ext2_truncate has no vnode-type gate; for a FIFO with i_size=0x10000 > 0, ext2_ind_truncate (ext2_inode.c:225) runs the full shorten path.
  • sys/vfs/ext2fs/ext2_inode.c:383-393 β€” the direct-block free loop frees every nonzero oip->i_db[i] via ext2_blkfree(); for special files those slots are NOT block pointers (for devices they overlay i_rdev).
  • sys/vfs/ext2fs/ext2_alloc.c:1414-1431 β€” ext2_blkfree's only validation is bno < e2fs_bcount; a clear bitmap bit β†’ unconditional panic(); a set bit β†’ the block is silently freed even though another file still owns it.

Also reachable via unlink: ext2_inactive (ext2_inode.c:511-512) calls ext2_truncate(vp, 0, …) on any zero-link inode β€” deleting the crafted FIFO hits the same frees.

Reachability / impact ceiling

  • Mounting the crafted image requires root (ext2 is not user-mountable on DragonFly) β€” same ceiling as DF-0888. The trigger itself is unprivileged whenever a user-accessible FIFO/device node exists on an ext2 mount (the crafted image ships mode-0666 FIFOs owned by root).
  • Legitimate (non-crafted) images: any writable device node on an ext2 fs can have its i_db[0]=i_rdev clobbered by the lengthen path (ext2_balloc writes a fresh block number into i_db[0]) β€” persistent device-identity corruption by any user who can open the node O_RDWR.
  • Variants: deterministic reboot-loopable panic (DoS) OR silent arbitrary in-range block free β†’ cross-file aliasing β†’ in-fs data disclosure and corruption. No kernel memory corruption (all frees are in-bcount), so no uid0 chain β€” ceiling is fs integrity/DoS, matching DF-3083/DF-3065 severity.

Fix

fix.diff: (1) ext2_setattr default: β†’ return (0) (match FreeBSD); (2) defense-in-depth vnode-type gate in ext2_truncate itself (allow VREG/VDIR/VLNK β€” rename/rmdir/ext2_inactive legit callers only use those). Validated on a rebuilt kernel: ftruncate on both FIFOs returns 0 as a no-op, no panic, f_bfree unchanged, guest stays up (fix_run.log).

Files

file purpose
craft_img.py host-side crafter (mke2fs + debugfs raw inode patch)
df3175.ext2 crafted image (victim=ino12, targetA=ino13, targetB=ino14)
trigger.c open(O_RDWR)+ftruncate(0) with f_bfree before/after
build.sh/run.sh exact build/run commands
build.log guest cc output
run.log baseline decisive run (variant B + mount listing)
run.2.log fix-validation run (A+B on patched kernel)
panic.txt fatal panic signature from serial console
fix.diff git-apply-able fix (never applied to the read-only sys/)
fix_build.log nativekernel build output of patched kernel
env.txt guest uname / mount state

Reproduce

HOST: python3 craft_img.py df3175.ext2 GUEST (image at /root/df3175.ext2, trigger built in /home/maxx/poc/DF-3175):

vm.sh run_root  'vnconfig -c vn0 /root/df3175.ext2 && mount -t ext2fs /dev/vn0 /mnt/df3175'
vm.sh run_user  'cd poc/DF-3175 && ./trigger /mnt/df3175/targetB'   # silent free
vm.sh run_root  'umount /mnt/df3175 && vnconfig -u vn0'             # then debugfs testb 26
vm.sh run_root  'vnconfig -c vn0 /root/df3175.ext2 && mount -t ext2fs /dev/vn0 /mnt/df3175'
vm.sh run_user  'cd poc/DF-3175 && ./trigger /mnt/df3175/targetA'   # panic

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

Applied fix.diff inside the guest /usr/src (never to the read-only sys/), make nativekernel + installkernel -> kernel #1. Re-ran the exact PoC: variant A no longer panics (ftruncate rc=0 no-op, guest stays up, STILL-ALIVE), variant B f_bfree delta 0, and the unmounted image still shows 'Block 26 marked in use' (no on-disk corruption). Baseline #0 had panicked (guest down) / freed the live block.

run.2.log (patched A+B outputs), env.txt (uname #1, vfs.usermount=0), fix_build.log (full nativekernel build), VERDICT.md fix section
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Sep 7 21:02:49 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root mounts crafted ext2 (8MiB, 3 patched inodes) -> unprivileged user opens targetA/targetB (FIFO 0666) O_RDWR -> ftruncate(fd,0) -> ext2_setattr default-case falls through -> ext2_truncate/ext2_ind_truncate frees i_db[0..11] as blocks -> ext2_blkfree: clear bit => panic (DoS); set bit => live block freed while owned (cross-file aliasing, in-fs data disclosure on reallocation). Also reachable via unlink through ext2_inactive (ext2_inode.c:511-512).

Evidence (decisive lines)

["panic.txt: 'panic: ext2_blkfree: freeing free block 499' + full backtrace kern_ftruncate->ext2_setattr->ext2_truncate->ext2_blkfree", "run.log: variant B f_bfree 483->484 (delta 1) with rc=0 + host-side debugfs proof 'Block 26 not in use' while victim BLOCKS: (0):26", 'run.2.log: patched kernel #1 β€” variant A no panic (STILL-ALIVE rc=0), variant B delta 0', 'VERDICT.md: full narrative with path:line citations']

PoC changes

n/a (pack authored fresh in this run); craft_img.py needed two parser fixes vs its first draft (debugfs 'BLOCKS:(0):NN' continuation line; free-block scan restricted to blocks < s_blocks_count because debugfs answers 'not in use' for out-of-range numbers too).

Verified recommended fix

ext2_setattr va_size switch: default: return (0) (match FreeBSD ufs_setattr); plus defense-in-depth VREG/VDIR/VLNK vnode-type gate at the top of ext2_truncate covering the ext2_inactive unlink path β€” see fix.diff.

Verdict

REPRODUCED on stock 6.5-DEVELOPMENT #0 (INVARIANTS): an unprivileged ftruncate() on a crafted FIFO inode (mode 0666) on a root-mounted ext2 image runs the full ext2 truncate engine over the inode's i_db[] overlay β€” kern_ftruncate rejects only VDIR (vfs_syscalls.c:4105), ext2_setattr's default:break passes special files to ext2_truncate (ext2_vnops.c:338), and ext2_truncate has no vnode-type gate (ext2_inode.c:468). Variant A (i_db[]=clear-bit blocks): panic("ext2_blkfree: freeing free block 499") with backtrace kern_ftruncate->vop_setattr->ext2_setattr->ext2_truncate->ext2_blkfree; guest dead in DDB. Variant B (i_db[0]=live block of file 'victim'): ftruncate returns 0, f_bfree 483->484, and after unmount the bitmap shows block 26 'not in use' while victim still references it β€” silent cross-file block free/aliasing (in-fs disclosure+corruption), no kernel message. No kernel-memory corruption (ext2_blkfree bounds-checks bno < bcount), so no uid0 chain; ceiling = deterministic DoS panic + fs-integrity break, trigger unprivileged post-mount, mount itself root-only (same reachability ceiling as DF-0888). Fix (ext2_setattr default: return 0 matching FreeBSD's ufs_setattr, plus VREG/VDIR/VLNK gate in ext2_truncate) validated on rebuilt kernel #1: both variants return 0 as no-ops, f_bfree unchanged, block 26 still marked in use, guest stays up.