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)
PoC verification
Evidence pack
findings/poc/DF-5007 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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)
- tpm20.c:78
callout_stop()executes unconditionally on entry totpm20_read, before thesc->owner_tidcheck at :80-83 returns EPERM for non-owners. - 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 clearspending_data_length(tpm20.c:159). tpm20_writeat :126-127 loops oncv_wait(&sc->buf_cv, &sc->dev_lock)whilepending_data_length != 0; DFcv_waitis 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, insidedev_lock(primary fix β a non-owner read can no longer kill the timer); - makes both
cv_waitloops interruptible viacv_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 (
waitpidreturns 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 stockdevice_printf(sc->dev, ...)at tpm20.c:164 faulted in the callout (device_print_prettyname(NULL)β Fatal trap 12). Real backends setsc->devat attach (tpm_crb.c/tpm_tis.c attach), so this is not reachable in the shipped driver; harness fixed by borrowingroot_bus. make_devbeforesc->sc_cdev->si_drv1 = sc(tpm20.c:203-205) leaves a tiny NULL-si_drv1window andowner_tidis 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
fixedfix.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
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.
No comments yet.