# 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.
