# DF-0001 — PoC: reachable KASSERT panic in kern_truncate()/kern_ftruncate()

`kern_truncate()` / `kern_ftruncate()` unconditionally `KASSERT` that
`VOP_GETATTR` succeeded whenever `vfs_quota_enabled` is on
(`sys/kern/vfs_syscalls.c:4036-4042` and `:4111-4117`). On an `INVARIANTS`
kernel, any `VOP_GETATTR` failure on the target vnode panics the kernel —
reachable from any local user with write access to the file via
`truncate(2)` / `ftruncate(2)`, no privilege required. CWE-617 reachable
assertion. **Severity Low (local DoS only; no memory corruption).**

## Verdict

**REPRODUCED** — deterministic `panic: kern_ftruncate(): VOP_GETATTR didn't
return 0` at `kern_ftruncate+0x152`, confirmed across two runs including one
from a fresh `vm.sh reset`. See `VERDICT.md` for the full mechanism and
`panic.txt` / `run.log` for the evidence.

## Preconditions (all three are required)

1. **`options INVARIANTS` kernel.** The audited guest's `X86_64_GENERIC`
   kernel *does* ship `INVARIANTS` — verified by the presence of both panic
   strings in `/boot/kernel/kernel`. On a non-INVARIANTS kernel the KASSERT
   is a compiled-out no-op and nothing happens.
2. **`vfs.quota_enabled=1`.** The sysctl is `CTLFLAG_RD`, so it is a
   **boot loader tunable** (`/boot/loader.conf`: `vfs.quota_enabled="1"`)
   and requires a reboot. Default is `0`.
3. **A filesystem whose `VOP_GETATTR` returns a nonzero error.** Local
   hammer2/UFS GETATTR is effectively infallible. The realistic case is
   **NFS returning ESTALE for GETATTR on a stale open-fd filehandle**
   (the clean trigger; see below). *Note: a merely-dead NFS server does NOT
   trip the bug on master — the client's attribute cache serves cached/local
   attrs with `error=0`, so the KASSERT never sees a failure. An
   application-level GETATTR error (ESTALE) is required.*

## Build

```
./build.sh        # ships sources to the guest and cc's them as user maxx
```
Or manually on the guest:
```
cc -O0 -g -o estale_trig estale_trig.c
cc -O0 -g -o trunc_panic trunc_panic.c
cc -O0 -g -o trunc_only  trunc_only.c
```

## Run (full choreography)

```
./run.sh          # enables quota+reboots, stands up loopback NFS, triggers panic
```
`run.sh` does, end to end:

1. verifies the running kernel has the live KASSERT (`INVARIANTS` on);
2. sets `vfs.quota_enabled="1"` in `/boot/loader.conf` and reboots
   (non-reverting `vm.sh down && vm.sh up`);
3. stands up a loopback NFS server (`rpcbind`/`mountd`/`nfsd`) exporting
   `/export`, and NFS-mounts it **soft, UDP, attribute-cache disabled**
   (`mount_nfs -U -s -x 1 -t 1 -o acregmin=0,acregmax=0,...`);
4. as the unprivileged user (`maxx`, uid 1001, not in `wheel`), opens
   `/mnt/estale_target`, holding a fixed filehandle on the `fd`;
5. **invalidates that filehandle server-side** (`rm` + `touch` → new inode,
   client `fd` now references a stale handle, server still UP);
6. the process wakes and calls `ftruncate(fd, 0)` → `kern_ftruncate` →
   `VOP_GETATTR_FP` → GETATTR RPC on the stale handle → server returns
   `NFSERR_STALE` → `nfs_getattr` returns `ESTALE` →
   `KASSERT(error==0)` at `vfs_syscalls.c:4113` → **panic**.

### Expected output (bug present)

```
panic: kern_ftruncate(): VOP_GETATTR didn't return 0
kern_ftruncate() at kern_ftruncate+0x152
...
Debugger("panic")
db>
```
The guest halts in DDB; ssh dies. Recover with `dfbsd-qemu/vm.sh reset`.

### Expected output (bug absent — non-INVARIANTS kernel)

The KASSERT compiles to a no-op; `truncate(2)`/`ftruncate(2)` simply return
the `GETATTR` error (`ESTALE`/`EIO`). No memory-safety impact — which is why
this is rated **Low**.

## Files

| file              | role                                                                   |
|-------------------|------------------------------------------------------------------------|
| `estale_trig.c`   | **the trigger that fires the panic** (open fd + server-side stale-FH invalidation + ftruncate) |
| `trunc_panic.c`   | original reviewer PoC (path truncate), sharpened to print errnos       |
| `trunc_only.c`    | errno diagnostic proving the dead-server path returns `GETATTR=0` (negative evidence) |
| `build.sh`/`run.sh` | reproducible build + full multi-step run                              |
| `VERDICT.md`      | full mechanism + why dead-server doesn't fire but ESTALE does          |
| `panic.txt`       | serial-console panic signature (the crash proof)                       |
| `run.log`         | decisive confirmation run, step by step                                |
| `boot.log.full`   | full untrimmed serial log of the panicking boot                        |
| `build.log`/`env.txt` | build output + guest environment (incl. INVARIANTS check)          |
| `fix.diff`        | `git apply`-able fix: KASSERT → proper error-return + cleanup          |
| `manifest.json`   | machine-readable artifact catalog                                      |
