# DF-0931 — VERDICT

## Verdict: REPRODUCED (uid0 escalation achieved on unpatched; fix validated)

The finding is **real and exploitable**. A non-root user with write access to a
`setuid`-root binary on an FFS filesystem can modify the binary's content on
disk **without the kernel clearing the `ISUID` bit**, then `execv()` the
modified binary to obtain `uid=0`. The full `unprivileged → root` chain was
demonstrated end-to-end on the default `X86_64_GENERIC` (`#0`) kernel.

**However**, the finding's root-cause analysis is **incomplete**: the `int
resid` truncation alone does *not* cause the ISUID bypass — the real root cause
is that `uiomove()` returns `EFAULT` without decrementing `uio->uio_resid`
after a partial `copyin`, so the post-write check `resid > uio->uio_resid`
evaluates to **false** for *any* write that faults partway through (even a
100-byte write), not just 4 GiB+ writes. The proposed one-line type fix (`int
resid` → `size_t resid`) was verified to be **insufficient** — ISUID is still
preserved after applying it. A corrected fix that also tracks whether any
buffer was queued for write was authored and validated.

## Mechanism

### The vulnerability path

1. `sys_write()` (`sys/kern/sys_generic.c:336`) checks `(ssize_t)nbyte < 0`
   but omits the `return error;` — a separate bug. For `nbyte ~ 4 GiB`
   (positive as `ssize_t`, bit 63 clear) the write proceeds regardless.

2. `ffs_write()` (`sys/vfs/ufs/ufs_readwrite.c`) receives `uio_resid` as a
   `size_t` (64-bit unsigned, per `sys/sys/_uio.h:69`).

3. Line 220: `int resid` is declared as 32-bit signed `int`.
   Line 271: `resid = uio->uio_resid;` silently truncates the 64-bit value.

4. The write loop (line 290) calls `uiomovebp()` → `uiomove()`. When the
   user buffer spans an unmapped page boundary, `std_copyin`
   (`sys/platform/pc64/x86_64/support.s:290`) uses `rep movsb` which copies
   the valid bytes **before** faulting. But `uiomove()`
   (`sys/kern/kern_subr.c:148-149`) breaks on the `EFAULT` **before**
   decrementing `uio->uio_resid`. So `uio_resid` stays at its original value
   even though attacker bytes reached the buffer cache.

5. The dirty buffer (with attacker content) is `bdwrite()`'d to disk at
   `ufs_readwrite.c:389` — this happens **before** the error check at
   line 391, so it is unconditional.

6. Line 400: `if (resid > uio->uio_resid && ...)` — the ISUID-clearing
   check. Because `uio_resid` was never decremented:
   - **Unpatched (`int resid`):** `resid` = low 32 bits (e.g. `pagesz`).
     `pagesz > (4 GiB + pagesz)` → **false** → ISUID preserved.
   - **Finding's proposed fix (`size_t resid`):** `resid` = full value.
     `(4 GiB + pagesz) > (4 GiB + pagesz)` → **false** (equal) → ISUID
     *still* preserved.

### Why the type change alone is insufficient

The comparison `resid > uio_resid` detects "did `uio_resid` decrease?" — which
is the kernel's proxy for "were any bytes written to disk?" This proxy fails
whenever `uiomove` returns an error without updating `uio_resid`, because the
partially-copied bytes are invisible to the accounting. This was confirmed
empirically: on a kernel with `size_t resid` only, both a 4 GiB+ EFAULT write
*and* a 100-byte EFAULT write preserve ISUID.

### Confirmed broader impact

The ISUID bypass works with **any** write size that EFAULTs partway — not
just > INT_MAX. The `int` truncation is a type-safety defect worth fixing,
but the security bypass is caused by the `uiomove`-on-EFAULT accounting gap.
This makes the vulnerability easier to trigger than the finding suggests (no
4 GiB write needed).

## Exploit chain (demonstrated `uid=0`)

| Step | Action | Result |
|------|--------|--------|
| Setup (root) | FFS mount + `cp /bin/sh /mnt/ffs/target; chmod 04775; chgrp <attacker>` | setuid-root, group-writable target on FFS |
| Stage 1 (maxx) | `./exploit /mnt/ffs/target` — writes a hand-built minimal ELF64 (setuid(0)+setgid(0)+execve("/bin/sh") shellcode, 179 bytes) via `write(fd, buf, 4GiB+4096)` | ELF payload on disk, ISUID preserved |
| Stage 2 (maxx) | `echo id \| /mnt/ffs/target` — exec the modified binary | `uid=0(root) gid=0(wheel)` |

**Bucket / primitive class:** Not a heap/slab corruption — this is a logic
bypass of a defense-in-depth file-permission control. The "primitive" is the
ISUID bit surviving a content modification by a non-root writer. The
escalation is direct: write attacker ELF → exec → root. No slab grooming,
no ROP, no info leak needed.

**Precondition (realistic):** A non-root user with write permission to a
`setuid`-root binary on an FFS filesystem. Not the default DragonFlyBSD
configuration (system setuid binaries are `root:wheel 4755`), but arises with
custom/admin setuid tools, group-writable setuid binaries, or permissive
container/jail images. Once the precondition is met, the attack is
deterministic and trivial.

## PoC files

| File | Purpose |
|------|---------|
| `suid_bypass.c` | Minimal trigger: 4 GiB+pagesz write, confirms ISUID preserved |
| `small_eFault.c` | Variant trigger: 100-byte write with EFAULT, proves truncation isn't required |
| `exploit.c` | Full chain: hand-built ELF64 payload + truncation write → uid0 |

## Fix

### Finding's proposed fix (INSUFFICIENT)

Change `int resid` → `size_t resid` at `ufs_readwrite.c:220`. This fixes the
type-safety issue but does **not** close the ISUID bypass because
`uiomove` still doesn't decrement `uio_resid` on partial `copyin` error.

### Corrected fix (in `fix.diff`, VALIDATED)

1. `int resid` → `size_t resid` (type safety, as proposed).
2. `int orig_resid` → `size_t orig_resid` in `ffs_read` (defensive, same pattern).
3. Add `int xferred = 0;` — set to 1 after `VOP_BALLOC()` succeeds (line 331),
   guaranteeing the buffer will be queued to disk.
4. Change the ISUID/NOTE_WRITE/IO_SYNC checks from
   `resid > uio->uio_resid` to `(xferred || resid > uio->uio_resid)`.

This catches partial writes where buffers reached disk but `uio_resid` wasn't
decremented. The fix is minimal, targeted, and adds no new locking or
complexity.

### Fix validation (Phase 8)

| Test | Unpatched `#0` | Fixed `#1` |
|------|----------------|------------|
| Huge write EFAULT (4 GiB+pagesz) | ISUID preserved (**BUG**) | ISUID **cleared** ✓ |
| Small write EFAULT (100 bytes) | ISUID preserved (**BUG**) | ISUID **cleared** ✓ |
| Normal write (8 KiB) | ISUID cleared | ISUID **cleared** ✓ (no regression) |
| Full exploit chain | `uid=0(root)` | `uid=1001(maxx)` — **defeated** ✓ |

**Fixed kernel:** `6.5-DEVELOPMENT #1: Sun Jul 12 11:10:52 UTC 2026`
SHA256: `336768d6d4fda955ff1d83a0f5510bfcec6408ba53931461170511e87214e79e`

## Kernel references (confirmed)

- `sys/vfs/ufs/ufs_readwrite.c:220` — `int resid` declaration (type bug)
- `sys/vfs/ufs/ufs_readwrite.c:271` — `resid = uio->uio_resid;` (truncating assignment)
- `sys/vfs/ufs/ufs_readwrite.c:290` — write loop entry
- `sys/vfs/ufs/ufs_readwrite.c:329-332` — `VOP_BALLOC` + error check (where `xferred` is set)
- `sys/vfs/ufs/ufs_readwrite.c:356` — `uiomovebp` call (where EFAULT originates)
- `sys/vfs/ufs/ufs_readwrite.c:389` — `bdwrite(bp)` unconditional buffer write
- `sys/vfs/ufs/ufs_readwrite.c:400-401` — ISUID/ISGID clearing check (the bypass)
- `sys/kern/kern_subr.c:148-149` — `uiomove` breaks on error **before** decrementing `uio_resid`
- `sys/platform/pc64/x86_64/support.s:290-328` — `std_copyin` (partial copy via `rep movsb` then fault)
- `sys/sys/_uio.h:69` — `size_t uio_resid` (the source type)
- `sys/kern/sys_generic.c:336-337` — missing `return error;` (separate bug, not the cause)
