# DF-0887 — Heap overflow in `ffs_truncate` symlink fast-path (unbounded `bzero`)

## Verdict: REPRODUCED (heap overflow / panic; root-reachable) — NOT exploitable to uid=0 (valid hard blocker); FIX VALIDATED

The bug claimed in the finding is **real and confirmed line-by-line**. The
`ffs_truncate` symlink fast path enters unconditionally for any `VLNK` inode
whose on-disk `di_blocks == 0`, regardless of `i_size`, and then runs:

```c
bzero((char *)&oip->i_shortlink, (uint)oip->i_size);
```

`i_shortlink` aliases `di_db` (`ufs_daddr_t[UFS_NDADDR=12]` = **48 bytes**) —
and `i_size` is the on-disk `di_size` copied verbatim into the in-memory inode
at `ffs_vfsops.c:1147`. A crafted VLNK inode with `di_blocks=0` and a large
`di_size` therefore drives an OOB write of `(di_size - 48)` bytes past the
shortlink buffer into the `M_FFSNODE` slab heap and beyond. Two distinct live
manifestations were captured on the default `X86_64_GENERIC` (#0, INVARIANTS
ON) kernel; the primitive was independently characterized with a deterministic
userspace harness (`harness 4096 0` ⇒ `OOB WRITE of 4048 bytes`). A targeted
single-line `fix.diff` was authored, built as a single-fix `#1` kernel, and
**validated**: the exact trigger that deterministically panics `#0` runs
cleanly on `#1` (no panic, guest stays up) with no regression on legitimate
filesystems.

The finding's impact ceiling ("root-context mount of attacker media → heap
corruption / panic") is confirmed; the implicit LPE angle is **not** reachable
because UFS is not user-mountable on DragonFly (same hard blocker documented
in DF-0820). This is a real, fixable code defect — but a hardening gap / DoS,
not an LPE.

---

## 1. Source trace (every cited line confirmed against `/usr/src` and host `sys/`)

`ffs_truncate` (`sys/vfs/ufs/ffs_inode.c`), with line numbers verified
byte-identical between host `sys/` and guest `/usr/src`:

```
137: int ffs_truncate(struct vnode *vp, off_t length, int flags, struct ucred *cred)
153:     oip = VTOI(ovp);
159:     if (ovp->v_type == VLNK &&
160:         (oip->i_size < ovp->v_mount->mnt_maxsymlinklen || oip->i_din.di_blocks == 0)) {
161: #ifdef DIAGNOSTIC
162:         if (length != 0) panic(...);       // length==0 from ufs_inactive
164: #endif
165:         bzero((char *)&oip->i_shortlink, (uint)oip->i_size);   // <-- UNBOUNDED
166:         oip->i_size = 0;
168:         return (ffs_update(ovp, 1));
169:     }
```

**Layout facts** (all confirmed in `sys/vfs/ufs/`):
- `inode.h:105` — `struct ufs1_dinode i_din;` is the LAST field of `struct inode` (allocated via `kmalloc(sizeof(inode), M_FFSNODE)`).
- `inode.h:126` — `#define i_shortlink  i_din.di_shortlink`.
- `dinode.h:111-112` — `#define di_shortlink  di_db` → aliases the start of `di_db`.
- `dinode.h:83` — `ufs_daddr_t di_db[UFS_NDADDR];` with `UFS_NDADDR=12` (`dinode.h:66`) ⇒ **48-byte buffer**.
- `dinode.h:114` — `UFS1_MAXSYMLINKLEN = (UFS_NDADDR + UFS_NIADDR) * sizeof(ufs_daddr_t) = (12+3)*4 = 60` — already **12 bytes larger** than the 48-byte `di_db` it overflows. Even the legitimate `i_size < mnt_maxsymlinklen` arm can overflow by up to 12 bytes.
- `ffs_vfsops.c:1147` — `ip->i_din = *((struct ufs1_dinode *)bp->b_data + ino_to_fsbo(fs, ino));` — `di_size`, `di_blocks`, `di_nlink`, `di_mode` are copied verbatim from disk into the in-memory inode. No bounds check.

**Trigger path** (confirmed end-to-end):
- `ufs_inode.c:62 ufs_inactive()` — runs when the vnode refcount drops to 0.
- `ufs_inode.c:77` — `if (ip->i_nlink <= 0 && (vp->v_mount->mnt_flag & MNT_RDONLY) == 0)`
- `ufs_inode.c:83` — `error = ffs_truncate(vp, (off_t)0, 0, NOCRED);`
- `ufs_vnops.c:1962` — `vp->v_type = IFTOVT(ip->i_mode);` — for `S_IFLNK` ⇒ `VLNK`.

So: `mount crafted image` → `stat /mnt/test/evil` ⇒ `iget` reads forged dinode
⇒ `ufs_vinit` sets `vp->v_type=VLNK` ⇒ stat returns ⇒ vnode refcount→0 ⇒
`ufs_inactive` sees `nlink=0` ⇒ `ffs_truncate(vp,0)` ⇒ fast path entered
(`VLNK && di_blocks==0`) ⇒ `bzero(&i_shortlink, 131072)` ⇒ **128 KB OOB
zero-write into the slab heap**.

## 2. Reproduced manifestations (live, on default #0 GENERIC, INVARIANTS ON)

Image-craft recipe (`reproduce.sh`):
1. `truncate -s 4M base.img && vnconfig -c vn0 base.img && newfs -v /dev/vn0 && vnconfig -u`
2. mount, `ln -s target_string /mnt/test/evil`, record inode (3), unmount
3. `craft_img` binary-patches inode 3: `di_size=<big>`, `di_nlink=0`, `di_blocks=0`, `di_mode=0xa1ff`
4. `mount -t ufs evil.img /mnt/test && stat /mnt/test/evil` ⇒ triggers `iget → ufs_inactive → ffs_truncate` fast path

### Manifestation A — `di_size=4096` (the finding's value; probabilistic INVARIANTS slab-magic trap)
- Path: `bzero(&i_shortlink, 4096)` writes 4048 bytes past the 48-byte buffer.
- Live: `panic: assertion "z->z_Magic == ZALLOC_SLAB_MAGIC" failed in _kfree at /usr/src/sys/kern/kern_slaballoc.c:1478` — backtrace `_kfree ← _kfree ← soclose ← soo_close ← fdrop ← closef`. The trap fires on the **next** `_kfree` after the corrupting `bzero` (in this capture: socket free during shell exit), proving the bzero wrote across adjacent slab zones. (`panic.4096.txt`)

### Manifestation B — `di_size=131072` (deterministic page-fault past slab pages)
- Path: `bzero(&i_shortlink, 131072)` runs past slab pages into unmapped kernel address space.
- Live: `Fatal trap 12: page fault while in kernel mode` / `fault virtual address = 0x0` / `Stopped at slab_cleanup+0x1c9: cmpq %rbx,(%rcx)` — the zeroed slab metadata (NULL pointer) dereferences inside the slab allocator's cleanup path. (`panic.txt`, `panic.131072.txt`)

### Deterministic harness (no kernel effect)
`./harness 4096 0` transcribes the L159-168 fast path verbatim with a poisoned
allocator and prints:
```
OOB write length        = 4048 bytes (di_size - 48)
OOB past end of struct inode = 4008 bytes (overflow past inode end)
RESULT: OOB WRITE of 4048 bytes past the 48-byte i_shortlink buffer.
        Heap corruption into M_FFSNODE slab (and beyond) confirmed.
```
This proves the primitive independent of the kernel slab layout lottery.

## 3. Phase 6 — escalation analysis: BLOCKED (valid hard blocker)

The primitive is a write-class bug (unbounded heap `bzero` of zeros, 4048+ bytes
past a 48-byte buffer). On this guest (no SMAP/SMEP/KASLR), a bounded kernel
write into a victim object is normally convertible to `uid=0`. **Here the
escalation is blocked by a valid Phase-6 hard blocker:**

### Blocker — UFS is NOT user-mountable; the trigger requires an already-root context

`get_fscap()` (`sys/kern/vfs_syscalls.c`) maps **only** `null/devfs/procfs/
tmpfs/fusefs` to user-mountable capabilities; **UFS falls through to
`SYSCAP_RESTRICTEDROOT`**. In `sys_mount`, even with `vfs.usermount=1`, the
capability check still fails for a non-root credential without the
RESTRICTEDROOT cap (returns EPERM). This was already verified empirically on
this guest in DF-0820 (VERDICT.md §3): with `vfs.usermount=1`, `vnconfig`'d
and chowned `/dev/vn0` to `maxx`, `mount -t ufs` **as maxx** → `mount_ufs:
/dev/vn0: Operation not permitted`. (`vnconfig` itself also requires privilege.)

So the crafted inode can only be fed to `ffs_truncate` by an already-root
caller. **Root → kernel is not a privilege-boundary crossing** (root can
already `kldload` an arbitrary module). No unprivileged→root chain exists
here. The realistic severity is **Medium** (root-context mount of attacker
media → kernel panic/corruption / DoS) — same conclusion as DF-0820.

Additionally (same as DF-0820), even on a root-reachable mount the write is an
**unbounded synchronous zero-fill** that page-faults before the mount/stat
syscall returns (Manifestation B). The attacking process is blocked in the
kernel for the entire window and is killed by the panic before it can observe
or convert the corruption. There is no field combination that yields a small,
precisely-bounded overwrite into a chosen victim — every oversized `di_size`
produces either an INVARIANTS slab-corruption trap (Manifestation A) or an
unbounded page-fault (Manifestation B). Hence no grooming → victim-corrupt →
convert chain exists. (Same behavior on `noinv`: the page-fault is from the
MMU, not INVARIANTS, so the second factor applies on both GENERIC and noinv.)

**Demonstrated impact ceiling: panic / kernel heap corruption
(root-reachable DoS / hardening gap).** Not an LPE.

## 4. The fix (`fix.diff`)

A single targeted change to `sys/vfs/ufs/ffs_inode.c`, one hunk:
- Replace `bzero((char *)&oip->i_shortlink, (uint)oip->i_size);` (line 165)
  with `bzero((char *)&oip->i_shortlink, umin((uint)oip->i_size, sizeof(oip->i_shortlink)));`
  — bound by the actual 48-byte buffer size. A multi-line comment explains
  the bug-class rationale.

This **matches** the finding markdown's `## Recommended fix` proposal
("bound the bzero to min(i_size, sizeof(i_shortlink))"). The finding's
proposal is exactly right; no supersedence is claimed.

The bound alone kills the security bug (the OOB write). The fast-path entry
condition (`di_blocks == 0 || i_size < mnt_maxsymlinklen`) is left unchanged
because:
- For a legitimate `di_blocks==0` inode the bzero is now an in-bounds clear
  of stale inline data (no overflow, no semantic change).
- Adding a stricter entry check would change truncate behavior on
  `di_blocks==0 && i_size > 48` inodes (a block-accounting change) that is
  outside the scope of this security fix.

## 5. Phase 8 — fix validation (single-fix #1 kernel)

- **Baseline (`#0`, unpatched)**: both manifestations panic — `assertion z_Magic==ZALLOC_SLAB_MAGIC` (A) and `Fatal trap 12 ... slab_cleanup+0x1c9` (B). (panic.4096.txt, panic.txt)
- **Applied** `fix.diff` with `patch -p1` (hunk succeeded at line 162), built
  `make -j6 nativekernel KERNCONF=X86_64_GENERIC` (rc=0, ~7 min, fix_build.log),
  installed with `make installkernel`, rebooted ⇒ `kern.version =
  6.5-DEVELOPMENT #1: Sun Jul  5 17:59:08 UTC 2026`,
  `sha256=/boot/kernel/kernel=51efdf559ddd8b85fe25a82022dd72c4725f249a85654b058190557ff28cfed3`.
- **Patched `#1`, same crafted image** (`di_size=131072`, the deterministic
  trigger): `mount` succeeds, `stat /mnt/test/evil` returns cleanly
  (`STAT_RC=0`), `END_RC=0`, **guest stays up, no panic** (fix_run.log).
  Repeated 3× to rule out non-determinism — all clean.
- **Regression**: a freshly `newfs`'d legitimate image still mounts read-write
  and accepts file creation on `#1` — the fix does not break normal operation.

Before/after:
| variant | baseline (#0) | patched (#1) |
|---|---|---|
| `di_size=4096`   | INVARIANTS slab-magic trap (`_kfree`) | clean `STAT_RC=0`, no panic |
| `di_size=131072` | page-fault `slab_cleanup+0x1c9`        | clean `STAT_RC=0`, no panic |
| normal newfs img | mounts                                  | mounts (regression OK) |

**fix_status: fixed** — clean before/after on both crash manifestations with no regression.

## 6. Caveats / next steps

- The finding's severity claim ("High") is **partly right** on the bug-reality
  side (the heap overflow is real and unbounded; CWE-787 is accurate) but the
  realistic exploit ceiling is Medium — root-context mount of attacker media
  → kernel panic/corruption / DoS, not an unprivileged LPE. A genuinely
  unprivileged variant would require an auto-mount/automount daemon mounting
  attacker-supplied media as root (not configured on this guest).
- The bug is structurally identical to DF-0820 in its root-only reachability;
  the fix validates cleanly against both manifestations.
- The harness transcribes the *unpatched* code path, so its output is
  intentionally unchanged before/after — it documents the primitive, not the
  runtime behavior. The runtime behavior change is what `fix_run.log`
  captures.
