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)
PoC verification
Evidence pack
findings/poc/DF-3175 Β· 14 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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:
-
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+0x190Guest dies in DDB (vm.sh statusβ down). Seepanic.txt. -
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 26i.e. a live data block of another file was freed and is now allocatable to any future file whilevictimstill 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'sva_sizeswitch: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 nonzerooip->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 isbno < 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
fixedApplied 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
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.
No comments yet.