# DF-5007 VERDICT — reproduced (dos), fix validated

Finding: `tpm20_read()` runs `callout_stop(&sc->discard_buffer_callout)` at
sys/dev/crypto/tpm/tpm20.c:78 **before** the ownership check at :80-83.
A non-owner `read(2)` fails with EPERM yet permanently disarms the 0.5 s
discard callout — the only mechanism (besides a successful owner read)
that clears `sc->pending_data_length`. Every later `write(2)` then blocks
forever in the `cv_wait` loop at tpm20.c:126-127; DragonFly `cv_wait`
maps to `_cv_timedwait(cv, lk, 0, 0)` → `lksleep(..., flags=0)` with no
PCATCH (sys/kern/kern_condvar.c:23-25,41), so the block is unkillable.

## How it was reproduced

Guest: DragonFly 6.5-DEVELOPMENT #0 (Thu Jul 2 2026) QEMU/KVM; **no
physical TPM** (host lacks swtpm — tpmcrb/tpmtis never attach, /dev/tpm0
absent). Following the DF-4929 methodology, `hangsim.ko` compiles the
**stock** `/usr/src/sys/dev/crypto/tpm/tpm20.c` verbatim (sha256
b3eea3fcaf1e5e8a1b547353e8bdbeded1cd1d07e8ad7be4387197e8a6ee5460 —
byte-identical to the audited host tree; recorded in build.log) with a
stub transport whose transmit epilogue matches tpm_crb.c:384-402
(response copied into sc->buf, `pending_data_length = length`). The
cdev `/dev/hangsim0` is created by the stock `tpm20_ops` via
`make_dev(..., 0600, "hangsim0")` — i.e. every byte of the char-layer
protocol under test is the shipped code.

Run 1 (baseline, run.log): ARM1 control proves the discard timer works
(2 dmesg "root0: User failed to read buffer in time" messages, writes
succeed after each discard). ARM2: a non-owner read returns EPERM;
dmesg then shows **no further discard message**, proving the EPERM read
disarmed the timer; the victim `write(2)` never returns. `SIGKILL` is
delivered and ignored (no PCATCH); `waitpid(WNOHANG) == 0` 3 s later →
`POC_RC=42`. `ps axww` (minutes later): victim pid 1010 state `D1`,
wchan `TPM buff` — the `buf_cv` initialized at tpm20.c:194 — still
alive. The ssh channel itself hung because the unkillable victim held
the pipe open (host-side timeout observed).

Run 2 (run.2.log): a **fresh** process hung at its very first write
(ARM1 write#1) because `pending_data_length` is still set with no timer
to clear it — the wedge persists across processes until reboot. Even
`timeout(1)`'s SIGTERM was ignored (its supervisor process stuck too).

Guest impact: kernel stays alive and responsive (dmesg/ps/ssh all work)
— classification `dos`, not `panic`.

## Root cause chain (path:line)

1. tpm20.c:78 `callout_stop()` executes unconditionally on entry to
   `tpm20_read`, before the `sc->owner_tid` check at :80-83 returns
   EPERM for non-owners.
2. The discard callout (armed only on successful transmit,
   tpm20.c:137-141, `TPM_READ_TIMEOUT`=500000 µs = 50 ticks) is the only
   automatic path that clears `pending_data_length` (tpm20.c:159).
3. `tpm20_write` at :126-127 loops on `cv_wait(&sc->buf_cv, &sc->dev_lock)`
   while `pending_data_length != 0`; DF `cv_wait` is non-interruptible
   (kern_condvar.c:23-25,41) → permanent unkillable sleep.

Preconditions: root/wheel opener (cdev 0600, tpm20.h:95), a TPM backend
attached (any of tpm_crb/tpmtis/tpm_isa), two threads/processes (one
owner that writes and exits without reading, one non-owner that reads —
the disarming EPERM read). No hardware malice required: the legitimate
TPM responses are what set `pending_data_length`.

## Fix validation

`fix.diff` (authored against the read-only host tree; applied only inside
the guest's /usr/src copy):

- moves `callout_stop()` after the owner check, inside `dev_lock`
  (primary fix — a non-owner read can no longer kill the timer);
- makes both `cv_wait` loops interruptible via `cv_wait_sig` (write
  path returns the signal error; harvest path returns) as
  defense-in-depth so no future path can wedge writers unkillably.

Note on build scope: tpm20.c is **not** part of X86_64_GENERIC (KMOD-only
driver), so validation was done at the module level — `patch -p1` inside
the guest /usr/src, `hangsim.ko` rebuilt from the patched file (copied
sha256 e132b7c3fc045a836569e63243686c67062e9265f64a44ef694bf2562d4e7559,
recorded in fix_build.log — proving the patched source is what got
compiled), reboot, `kldload`, same `poc` binary re-run:

- Baseline (stock): victim stuck forever, survives SIGKILL (run.log).
- Patched (fix_run.log): ARM2 victim write **completes rc=12**, victim
  exits (`waitpid` returns w=830 st=0), no stuck process, dmesg shows
  the discard timer firing normally through/after the EPERM read
  (6 discard messages). The non-owner read still correctly returns
  EPERM — no semantic regression.

fix_status: **fixed** (baseline_reproduced=1, patched_reproduced=0).

## Not a finding (harness artifacts, for the record)

- First harness iteration omitted `sc->dev`, so the stock
  `device_printf(sc->dev, ...)` at tpm20.c:164 faulted in the callout
  (`device_print_prettyname(NULL)` → Fatal trap 12). Real backends set
  `sc->dev` at attach (tpm_crb.c/tpm_tis.c attach), so this is not
  reachable in the shipped driver; harness fixed by borrowing `root_bus`.
- `make_dev` before `sc->sc_cdev->si_drv1 = sc` (tpm20.c:203-205) leaves
  a tiny NULL-`si_drv1` window and `owner_tid` is never cleared on
  close — both considered and downgraded to hardening notes (root-only,
  no demonstrated trigger), not filed.

## Severity rationale

Low: the device node is 0600 root:wheel, so the "attacker" is already
privileged; impact is a local unkillable-process/device DoS, no memory
corruption, no info leak. Consistent with DF-2409/DF-4860 precedent.

## Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest `make -j6 nativekernel KERNCONF=X86_64_GENERIC` completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
