β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-5007

tpm20_read disarms the discard-buffer callout before the owner check: an EPERM-failing read(2) permanently kills the only recovery that clears pending_data_length, wedging every later write(2) in an unkillable non-PCATCH cv_wait (permanent DoS of the TPM device and the writing process)

Summary

Side effect (callout_stop :78) ordered before the owner check (:80-83); combined with the non-interruptible cv_wait write loop, one EPERM read permanently disarms the 0.5s recovery - every later write blocks forever, unkillable (D1 on buf_cv survives SIGKILL). REPRODUCED on guest via KMOD harness (stock source sha256-verified): victim stuck after SIGKILL, device wedged for fresh processes until reboot; fix validated (patched module: victim completes rc=12, timer keeps firing through EPERM read). 0600 root:wheel device - Low like DF-2409/DF-4860.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5007 Β· 15 files
FileTypeDescriptionSize
hangsim.c β€” 2.7 KB view raw
poc.c β€” 2.8 KB view raw
Makefile β€” 142 B ↓ download
build.sh β€” 244 B view raw
run.sh β€” 316 B view raw
build.log β€” 1.7 KB view raw
run.log β€” 1.4 KB view raw
run.2.log β€” 368 B view raw
fix_build.log β€” 845 B view raw
run_fixed.log β€” 446 B view raw
fix_run.log β€” 446 B view raw
env.txt β€” 384 B view raw
fix.diff β€” 1.5 KB view raw
VERDICT.md β€” 5.8 KB ↓ raw
verdict.json β€” 4.2 KB view raw
VERDICT.md
↓ download raw

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.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.diff applied to guest /usr/src/sys/dev/crypto/tpm/tpm20.c (3 hunks: callout_stop moved after the owner check inside dev_lock; cv_wait_sig in tpm20_write and in the harvest loop); hangsim.ko rebuilt from the patched file (sha256 e132b7c3... in fix_build.log), guest rebooted, module freshly loaded, identical poc binary re-run: ARM2 non-owner read still EPERM (no semantic regression) but the discard timer keeps firing (6 dmesg discard msgs); the victim write COMPLETED rc=12, victim exited cleanly (waitpid w=830 st=0), no stuck process. Baseline 2/2 wedge vs patched 1/1 clean.

fix_run.log (patched run: victim exits, NO_STUCK_POC), run.log (baseline wedge), fix_build.log (patched tpm20.c provenance sha256), VERDICT.md 'Fix validation' section
↓ fix.diffper-fix-DF-5007

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log (POC_RC=42, 'RESULT: VICTIM 1010 STILL STUCK AFTER SIGKILL', dmesg shows exactly 2 discard msgs, none after the EPERM read); run.2.log (fresh process wedges at its first write: persistence; ps shows pids 1010/1038 state D1/D3 wchan 'TPM buff'); fix_run.log (patched module: victim write completes rc=12, no stuck poc); build.log (stock tpm20.c sha256 b3eea3... provenance); VERDICT.md (full narrative + root-cause chain)

PoC changes

Seed PoC concept kept; harness fixes required: (1) KMOD needed generated interface headers (device_if.h/bus_if.h/acpi_if.h copied from /usr/obj kernel tree); (2) first harness build crashed in the stock device_printf(sc->dev,...) at tpm20.c:164 because sc->dev was unset - real backends set it at attach; fixed by sc->dev = root_bus; (3) run.sh kldload made idempotent (|| true); (4) poc output redirected to a file for the fixed run because the unkillable victim holds ssh pipes open.

Verified recommended fix

Move callout_stop(&sc->discard_buffer_callout) in tpm20_read to after the owner_tid check (inside dev_lock), and make the write-side wait interruptible via cv_wait_sig.

Verdict

tpm20_read() stops the discard-buffer callout at tpm20.c:78 BEFORE the sc->owner_tid check at :80-83, so a read(2) that fails EPERM still permanently disarms the only 0.5s recovery that clears pending_data_length; every later write(2) then blocks forever in the cv_wait loop at tpm20.c:126-127, and DragonFly cv_wait carries no PCATCH (kern_condvar.c:23-25,41) so the sleeper survives SIGKILL. Verified on the guest with the stock tpm20.c compiled verbatim into a KMOD harness (no physical TPM in the QEMU guest): victim stuck in state D1 on wchan 'TPM buff' minutes after SIGKILL, device wedged for fresh processes until reboot, kernel alive (dos). Root-only trigger (/dev/tpm0 is 0600 root:wheel) -> severity Low.