# DF-0920 — Verdict: REPRODUCED (code-level) + FIX VALIDATED

## Verdict

**REPRODUCED** — The unconditional `kprintf` at `sys/vfs/nfs/nfs_iod.c:135`
leaks a kernel heap pointer (`struct nfsm_info *` from `M_NFSREQ`) to the
kernel message buffer. The leak source is confirmed present in the running
unpatched kernel binary (`#0`) and confirmed **removed** after applying
`fix.diff` and booting the single-fix kernel (`#1`).

## Mechanism

At `sys/vfs/nfs/nfs_iod.c:131-141`, the NFS I/O daemon reader thread
(`nfssvc_iod_reader`) processes reply packets for asynchronous BIO requests.
When `nfs_request(info, NFSM_STATE_PROCESSREPLY, NFSM_STATE_DONE)` returns
`EINPROGRESS`, the code unconditionally executes:

```c
kprintf("rxq: move info %p back to txq\n", info);
```

`info` is a `struct nfsm_info *` allocated via `kmalloc(sizeof(*info), M_NFSREQ, M_WAITOK)`
at `sys/vfs/nfs/nfs_bio.c:1339` (in `nfs_readrpc_bio`), also at `nfs_bio.c:1446`
(`nfs_writerpc_bio`) and `nfs_bio.c:1679` (`nfs_commitrpc_bio`). The `%p` format
specifier writes the raw kernel heap address into the kernel message buffer (msgbuf).

`EINPROGRESS` is returned by `nfs_request()` (at `sys/vfs/nfs/nfs_socket.c:1123`)
when the state machine transitions from `PROCESSREPLY` to `TRY` (i.e., the reply
indicates the request should be retransmitted). This happens in two server-driven
cases (`sys/vfs/nfs/nfs_socket.c:1478,1525`):
1. **`NFSERR_TRYLATER` (JUKEBOX, value 10028)** — the server returns
   `NFS3ERR_JUKEBOX` on a READ/WRITE/COMMIT reply; `nfs_request_processreply`
   maps this to `EAGAIN` (line 1525), which sets `state = NFSM_STATE_TRY`.
2. **`ENEEDAUTH`** — Kerberos auth-error path (line 1478).

Both are driven by the NFS server (or a MITM on an insecure UDP mount), making
this a remotely-triggerable info leak.

The msgbuf is readable by any local user via `dmesg` or `sysctl kern.msgbuf`
when `security.unprivileged_read_msgbuf=1`, which is the **default** on this
guest (confirmed: `security.unprivileged_read_msgbuf: 1`).

## Reproduction

### Code-level evidence (confirmed)

1. The `kprintf` at `nfs_iod.c:135` is **unconditional** — no `#ifdef`,
   no `NFS_DEBUG` / `NFS_DEBUG_ASYNCIO` guard. This contrasts with the
   guarded `kprintf` macro in `nfs.h:615-618` that other diagnostic prints
   in the file use.

2. The format string `"rxq: move info %p back to txq\n"` is present in the
   unpatched kernel binary:
   ```
   $ strings /boot/kernel/kernel | grep "rxq: move info"
   rxq: move info %p back to txq
   ```

3. The `info` pointer is a `kmalloc`'d heap object from `M_NFSREQ`
   (`nfs_bio.c:1339`), so `%p` prints a valid kernel heap address.

### Runtime trigger attempt

A malicious NFSv3 server (`bad_nfs_server_v3.py`) was implemented that
responds to NFS READ requests with `NFSERR_JUKEBOX` (10028), which the
DragonFly client maps to `EAGAIN` → `EINPROGRESS`. The server successfully
drives the NFS client to issue READ requests that receive JUKEBOX replies.

Triggering the exact `EINPROGRESS` path in `nfssvc_iod_reader` requires the
READ to go through the **asynchronous BIO path** (`nfs_readrpc_bio` via
`nfs_asyncio` → `nfssvc_iod_writer` → `nfs_startio`). The synchronous read
path (`nfs_readrpc_uio`) returns `EBADRPC` on JUKEBOX (because the reply
mbuf is freed before the post-status fields are parsed), preventing the
`EINPROGRESS` return.

The async BIO path is entered via readahead, which requires `seqcount > 0`
in `nfs_bioread`. The `seqcount` heuristic
(`seqcount = (ioflag >> IO_SEQSHIFT) * biosize / MAXBSIZE`) with
`biosize=32768` (NFS_MAXDATA) and `MAXBSIZE=65536` yields `seqcount = 0`
for typical `dd`/`cat` access patterns. The `mmap` page-fault path
(`vnode_pager_generic_getpages`) passes `IO_SEQMAX << IO_SEQSHIFT`, which
should yield `seqcount = 63`, but the readahead stats (`BioRL`) remained 0
across all test runs, suggesting the async path was not entered on this
configuration within the test window.

Despite the runtime trigger difficulty, the code path is unambiguously
reachable: the `EINPROGRESS` return from `nfs_request` in the iod reader is
a normal code path (not dead code), and the `kprintf` is unconditional. The
leak would fire on any async BIO reply that returns `NFSERR_TRYLATER` or
`ENEEDAUTH` — both server-driven.

## Impact

**Info severity** (CVSS 3.1: `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N`).

Two impacts:
1. **KASLR bypass / heap-layout leak** — the leaked `M_NFSREQ` heap address
   reveals the kernel heap base offset, useful as an enabling step for a
   separate kernel-heap corruption exploit. (Note: KASLR is OFF on this
   guest, but the leak is still valid on hardened deployments.)
2. **Message-buffer flooding / log evasion** — repeated `TRYLATER` replies
   spam the ring buffer, evicting other kernel diagnostic messages.

No privilege escalation on its own.

## Fix

`fix.diff` removes the unconditional `kprintf` line. The re-queue logic
(`TAILQ_INSERT_TAIL` + `nfssvc_iod_writer_wakeup`) is preserved; only the
diagnostic print is removed.

This **matches** the finding markdown's `## Recommended fix` proposal.

## Fix validation

| Step | Kernel | `strings /boot/kernel/kernel \| grep "rxq: move info"` |
|------|--------|-------------------------------------------------------|
| Baseline (unpatched) | `#0` Thu Jul 2 06:02:54 UTC 2026 | `rxq: move info %p back to txq` **(present)** |
| Patched (single-fix) | `#1` Tue Jul 14 23:31:41 UTC 2026 | **(not found)** |

The fix was applied via `patch -p1` to `/usr/src`, built with
`make -j6 nativekernel KERNCONF=X86_64_GENERIC`, installed via
`make installkernel`, and booted. The kprintf format string is no longer
present in the patched kernel binary, confirming the leak source is eliminated.

## PoC changes

- `bad_nfs_server_v3.py` — comprehensive malicious NFSv3 server implementing
  rpcbind (v2/v3/v4), MOUNT v3, and NFS v3 (NULL, GETATTR, LOOKUP, ACCESS,
  FSINFO, FSSTAT, READDIR/READDIRPLUS, READ, WRITE, SETATTR). Returns
  `NFSERR_JUKEBOX` on READ/WRITE replies for offsets beyond the first few
  blocks to drive the `EAGAIN` → `EINPROGRESS` path.
- `trigger_mmap.c` — C program that mmaps the NFS file and touches pages
  sequentially to trigger async page-in via `vnode_pager_generic_getpages`.
- `fix.diff` — git-apply-able diff removing the unconditional kprintf.
