# DF-0795 — VERDICT

## Verdict: REPRODUCED (CWE-787 OOB write confirmed) — NOT EXPLOITABLE to uid=0

The OOB write is real and exactly as the finding describes. The
escalation theory ("no kernel stack canary → kernel code execution") is
**wrong** for this specific call site / compilation: the overflow bytes
do **not** reach the saved frame pointer or return address. They land
inside `struct dirent dirbuf` (`msdosfs_readdir`) or in unused stack
padding (`msdosfs_lookup`), and the corrupted `dirbuf` fields are
unconditionally overwritten before use. There is no control-flow
primitive to convert.

## The bug (confirmed line-by-line)

`msdosfs_readdir` (`sys/vfs/msdosfs/msdosfs_vnops.c:1665`) calls
`win2unixfn(&nb, ...)` for each Win95 LFN slot. `win2unixfn`
(`msdosfs_conv.c:642`) validates:

```c
if ((wep->weCnt & WIN_CNT) > howmany(WIN_MAXLEN, WIN_CHARS) || ...)
    return -1;
```

`howmany(255, 13) = ceil(255/13) = 20`. The check is `>`, so
`weCnt & WIN_CNT == 20` is **allowed**. With `WIN_LAST` set, that means
`weCnt = 0x40 | 0x14 = 0x54`, and `id = (weCnt & WIN_CNT) - 1 = 19`.

`mbnambuf_write` (`msdosfs_conv.c:1031`) then does:

```c
slot   = &nbp->nb_buf[id * WIN_CHARS];     /* = &nb_buf[19*13] = &nb_buf[247] */
count  = strlen(name);                      /* = 13 for ASCII */
newlen = nbp->nb_len + count;               /* = 0 + 13 = 13 */
if (newlen > WIN_MAXLEN || newlen > 127) ... /* 13 > 255? no — passes */
if (count > WIN_CHARS && nbp->nb_len != 0)   /* 13 > 13? no — skipped */
    ...
memcpy(slot, name, count);                  /* writes nb_buf[247..259] */
```

`nb_buf` is `char[WIN_MAXLEN+1] = char[256]` (`direntry.h:137`). Bytes
`nb_buf[256..259]` are **4 bytes past the end**. Confirmed.

For the KICONV variant (each of 13 chars becomes 2 bytes via
`win2unixchr` returning a non-zero high byte), `count = 26`, and the
overflow is 17 bytes (`nb_buf[256..272]`).

## Stack-frame analysis (objdump of `/boot/kernel/msdos.ko`, gcc 8.3)

### `msdosfs_readdir` (the dir-listing path)

Frame layout, recovered from the disassembly:

```
rbp+0x00   saved rbp
rbp-0x08   saved r15
rbp-0x10   saved r14
rbp-0x18   saved r13
rbp-0x20   saved r12
rbp-0x28   saved rbx
rbp-0x30   (8-byte gap, no live local)
...
rbp-0x140  struct dirent dirbuf {                ; 272 bytes
              +0   d_ino      (uint64)           ; = nb_buf[256..263]
              +8   d_namlen   (uint16)           ; = nb_buf[264..265]
              +10  d_type     (uint8)            ; = nb_buf[266]
              +11  d_unused1  (uint8)            ; = nb_buf[267]
              +12  d_unused2  (uint32)           ; = nb_buf[268..271]
              +16  d_name[256]                   ; = nb_buf[272..527]
            }
rbp-0x250  struct mbnambuf nb {                  ; 272 bytes
              +0   nb_len     (size_t)
              +8   nb_last_id (int + 4 pad)
              +16  nb_buf[256]                   ; overflows upward into dirbuf
            }
```

Disassembly confirming the adjacency (memset of `dirbuf.d_name` starts at
`-0x130`, i.e. `dirbuf + 0x10`):

```
7662:  lea    -0x140(%rbp),%rax        ; rax = &dirbuf
7669:  lea    0x10(%rax),%rsi          ; rsi = &dirbuf.d_name (= -0x130)
766f:  mov    %rsi,%rdi
7672:  rep stos %rax,%es:(%rdi)        ; memset(dirbuf.d_name, 0, 256)
```

`mbnambuf_init(&nb)` is called with `lea -0x250(%rbp),%rdi` (offset
`-0x250`). `nb_buf` lives at `-0x250 + 16 = -0x240`, and ends at
`-0x240 + 256 = -0x140`, which is **exactly** `&dirbuf`. The 4 ASCII
overflow bytes (`nb_buf[256..259]`) therefore land in `dirbuf.d_ino` low
32 bits. The 17-byte KICONV overflow lands in `d_ino` (8), `d_namlen`
(2), `d_type` (1), `d_unused1` (1), `d_unused2` (4), `d_name[0]` (1) —
**all inside `dirbuf`**, never reaching the saved registers at
`-0x28..-0x01` or the saved rbp/ret at `0x00/+0x08`.

### Why the corruption is benign

`dirbuf` is filled per directory entry. After our malicious LFN slot is
processed (overflow happens), the loop continues. For the **next** entry:

* If it's an 8.3 alias (the normal case): `msdosfs_readdir` overwrites
  `dirbuf.d_fileno` at `vnops.c:1684/1694/1698`, `dirbuf.d_type` at
  `:1696/:1700`, and either `dirbuf.d_namlen = dos2unixfn(...)` at
  `:1704` or `mbnambuf_flush(&nb, &dirbuf)` at `:1712` (which sets
  `dp->d_namlen = nbp->nb_len` and copies `nb_buf[0..nb_len]` into
  `d_name`). All corrupted fields are overwritten.
* If it's `SLOT_EMPTY`: `goto out` — `dirbuf` is not consulted; the
  loop exits.
* If it's another LFN slot: `continue` — `dirbuf` not consulted.

In every branch, the corrupted `dirbuf.d_ino` (and for KICONV,
`d_namlen`/`d_type`/etc.) is either overwritten or never read. No info
leak, no integrity violation observable to userspace, no control-flow
change.

### `msdosfs_lookup` (the path-lookup path)

Same `nb` on the stack at `-0x140`, but here there is **no** `dirbuf`
local — `msdosfs_lookup` doesn't fill a dirent, it just walks slots
looking for a name match. `nb_buf[256]` lands at `-0x30`, which is an
8-byte gap with **no live local** in it (objdump shows no references to
`-0x30..-0x29` in the function body; the nearest live locals are at
`-0x38` and below `-0x140`). So the overflow corrupts 4 bytes of unused
stack padding. No effect.

## Trigger

`craft_img.py` patches a 16 MB FAT16 image (`newfs_msdos -F 16 -C 16m`)
at root-directory offset `33*512 = 16896` with a single 32-byte Win95
LFN slot:

```
00  : 54           weCnt       = WIN_LAST(0x40) | 20  -> id=19
01-0A: 41 00 42 00 43 00 44 00 45 00              "ABCDE" (UTF-16 LE)
0B  : 0f           weAttributes = ATTR_WIN95
0C  : 00           weReserved1
0D  : 42           weChksum
0E-19: 46 00 47 00 48 00 49 00 4a 00 4b 00        "FGHIJK"
1A-1B: 00 00       weReserved2
1C-1F: 4c 00 4d 00                                "LM"
```

Followed by `SLOT_EMPTY` (`0x00 * 32`) so the loop exits. Mount with
`mount -t msdos /dev/vn0 /mnt` and `ls /mnt` as the unprivileged user —
no panic, no message, empty listing. The overflow has fired (verified
by harness), but it's silent.

## Deterministic harness

`df0795_harness.c` reproduces the exact `mbnambuf_write` code with a
poisoned stack frame and prints the byte-by-byte corruption map. Run on
the guest:

```
== DF-0795 ASCII variant (weCnt=0x54, id=19, count=13) ==
mbnambuf_write returned 0 (0 = OK, overflow happened)
slot offset     = 19*13 = 247 (writes nb_buf[247..259])
overflow bytes  = nb_buf[256..259] = 4 bytes past end
byte-by-byte corruption map:
  nb_buf[256] -> dirbuf byte 0 (dirbuf.d_ino) = 0x4a   'J'
  nb_buf[257] -> dirbuf byte 1 (dirbuf.d_ino) = 0x4b   'K'
  nb_buf[258] -> dirbuf byte 2 (dirbuf.d_ino) = 0x4c   'L'
  nb_buf[259] -> dirbuf byte 3 (dirbuf.d_ino) = 0x4d   'M'

== DF-0795 KICONV variant (weCnt=0x54, id=19, count=26) ==
overflow bytes = nb_buf[256..272] = 17 bytes past end
  ... lands in d_ino (8), d_namlen (2), d_type (1), d_unused1 (1),
      d_unused2 (4), d_name[0] (1) -- ALL inside struct dirent
```

## Exploit-chain attempt (Phase 6)

**Not pursued past the characterization step**, because the
characterization itself establishes the hard blocker:

* The 4-byte (ASCII) overflow reaches only `dirbuf.d_ino[0..3]`.
* The 17-byte (KICONV) overflow reaches `dirbuf.d_ino[0..7]`,
  `d_namlen`, `d_type`, `d_unused1`, `d_unused2`, `d_name[0]`.
* No function pointer, no `ucred *`, no `uid`, no refcount, no saved
  `%rbp`, no return address is corrupted.
* All corrupted fields are unconditionally overwritten by
  `msdosfs_readdir` before `vop_write_dirent` consumes them.

This is the **valid Phase-6 hard blocker**: "the overflow only reaches
fields/locals that don't yield a control-flow primitive, and the
corrupted data is unconditionally overwritten before use." There is no
primitive to groom, no victim object to corrupt, no pointer to redirect.
Heap-style grooming is irrelevant — this is a stack buffer overflow
into a known-inert neighbor.

The finding markdown's "no kernel stack canary → kernel code exec"
inference is incorrect for this call site: the absence of a canary
means a stack smash is *undetected*, but the smash still has to *reach*
the saved RIP to hijack control flow, and on this compilation it does
not. (Verified by objdump: `nb_buf` ends at `-0x140`, the saved-RIP
slot is at `+0x08` — a 0x148 = 328-byte gap, of which the first 272
are `dirbuf` and the next ~48 are live locals / saved registers. A
4-byte or 17-byte write starting at `-0x140` cannot reach `+0x08`.)

## Fix

`fix.diff` adds the absolute-offset bounds check the finding proposes,
in `mbnambuf_write` just before the unchecked `memcpy`:

```c
if (id * WIN_CHARS + count > sizeof(nbp->nb_buf))
    return (ENAMETOOLONG);
```

This matches the finding markdown's `## Recommended fix` proposal
verbatim. It is minimal, targeted at the root cause, and does not
change the legitimate LFN reassembly path (legitimate names with ≤19
slots × ≤13 chars stay within `nb_buf`).

## Fix validation (Phase 8)

1. `git apply --check` (well, `patch -p1 --dry-run`) on `/usr/src`:
   `Hunk #1 succeeded at 1058. done  DRYRUN rc=0`.
2. `make` in `sys/vfs/msdosfs` produced a new `msdos.ko` with the check
   compiled in (objdump at `0x1082`: `cmp $0x100,%rbx; ja 0x10c0`).
3. Full `make -j6 nativekernel KERNCONF=X86_64_GENERIC` build: rc=0.
   The rebuilt kernel's `mbnambuf_write` at `0xffffffff80716130` has
   the same `cmp $0x100,%rbx; ja` check at `0xffffffff807161a2`.
4. Booted `#1` kernel, mounted the same malicious image, ran `ls`:
   no panic, no regression, guest stayed up.
5. Deterministic before/after harness:
   * ORIG `mbnambuf_write(id=19, count=13)` → rc=0, 4 bytes corrupted.
   * FIXED `mbnambuf_write(id=19, count=13)` → rc=-63 (ENAMETOOLONG),
     0 bytes corrupted.

The fix closes the bug. **fix_status: fixed.**

## Conclusion

A real memory-safety bug (CWE-787, 4-byte / 17-byte stack OOB write),
correctly diagnosed path:line, correctly fixed. **Not a privilege
escalation** on this kernel compilation — the overflow is contained
within `struct dirent dirbuf` (readdir) or unused stack padding
(lookup), and the corrupted fields are discarded before use. The
absence of a kernel stack canary does not help the attacker here
because the overflow physically cannot reach the saved RIP.
