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

tpmcrb_detach/tpm20_release tear down the TPM device with no dev_lock and no draining while transmit is in flight: softc/buf use-after-free, discard callout fires into freed memory β†’ deterministic kernel panic

Summary

tpm20_write holds dev_lock across the multi-second transmit but the detach-path release takes no lock and drains nothing (destroy_dev is async, lockuninit's assert passes on a held lock): kldunload completes under a transmitting thread, newbus frees the softc, the writer continues on freed memory and the 500ms discard callout lockmgr's/memsets through the freed softc - a groomable wild kernel write. REPRODUCED 2/2 on guest with a KLD that #includes the STOCK tpm20.c verbatim (sha256-proven): Fatal trap 12 in the discard callout on the freed softc. Root-only gates; the tpm_tis teardown shares the root cause. Serialize-release + dying flag + callout drain fix (validated: no panic, guest up).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4929 Β· 15 files
FileTypeDescriptionSize
uafsim.c β€” 4.0 KB view raw
Makefile β€” 140 B ↓ download
build.sh β€” 164 B view raw
run.sh β€” 203 B view raw
build.log β€” 5.7 KB view raw
run.1.log β€” 279 B view raw
run.2.log β€” 279 B view raw
run_fixed.log β€” 1.5 KB view raw
panic.txt β€” 666 B view raw
panic.2.txt β€” 2.1 KB view raw
fix_build_tail.log β€” 312 B view raw
fix.diff β€” 2.3 KB view raw
env.txt β€” 867 B view raw
VERDICT.md β€” 5.0 KB ↓ raw
verdict.json β€” 4.8 KB view raw
VERDICT.md
↓ download raw

DF-4929 VERDICT β€” tpm_crb/tpm20 detach-vs-transmit UAF

Status: REPRODUCED (kernel panic, use-after-free on driver teardown). Fixed by fix.diff (validated on a patched kernel).

Root cause (source trace)

  • tpm20_write (sys/dev/crypto/tpm/tpm20.c:100-145) acquires sc->dev_lock EXCLUSIVE at :124 and holds it across the whole sc->transmit() call (:135) β€” which for CRB means tpmcrb_transmit, polling for up to TPM_TIMEOUT_LONG = 40 s (tpm_crb.c:377, tpm20.h:71).
  • tpmcrb_detach (sys/dev/crypto/tpm/tpm_crb.c:230-243) calls tpm20_release without acquiring dev_lock and without draining: tpm20_release (tpm20.c:211-226) does kfree(sc->buf), lockuninit, cv_destroy, destroy_dev unconditionally.
  • lockuninit's KKASSERT (sys/kern/kern_lock.c:1325-1334) requires only !(count & (LKC_EXREQ|LKC_UPREQ)) && ((count & LKC_SHARED) || (count & LKC_SMASK) == 0) β€” an exclusively-held lock has count==1, which passes: teardown proceeds under the holder. Proven at runtime ("lockuninit assert did NOT fire" with lockstatus=8=LK_EXCLUSIVE).
  • destroy_dev -> devfs_destroy_dev (sys/vfs/devfs/devfs_core.c:758-765) only posts an async node-destruction message; it does not wait for open descriptors or executing cdev handlers, so kldunload tpm (the tpm driver is a KMOD, sys/dev/crypto/tpm/Makefile) completes while the transmit thread is still inside the driver.
  • newbus then frees the softc (device_set_driver(dev, NULL) -> kfree(softc), sys/kern/subr_bus.c:1943-1954). The transmitting thread and the discard_buffer_callout armed by tpm20_write (tpm20.c:138-139) keep using the freed softc: sc->pending_data_length store (tpm_crb.c:402), callout_reset/owner_tid/LK_RELEASE (tpm20.c:138-143), and β€” the deterministic killer β€” tpm20_discard_buffer ~500 ms later: lockmgr on the freed lock and memset(sc->buf, 0, TPM_BUFSIZE) with sc->buf (and sc->dev for device_printf) read from freed memory (tpm20.c:147-166). If the chunk is reclaimed before the callout fires, this is a fully groomable wild kernel write.

Reproduction

uafsim.c #includes the STOCK /usr/src/sys/dev/crypto/tpm/tpm20.c (sha256 b3eea3fcaf1e... identical to the audited tree β€” see build.log), so tpm20_write, tpm20_release, tpm20_discard_buffer, the callout arming and the dev_lock protocol are the shipped code. Only the MMIO transport is stubbed (fake_transmit mimics tpmcrb_transmit's poll-loop timing and its pending_data_length epilogue). The hw.uafsim_detach=1 sysctl replays the kldunload tpm sequence: stock tpm20_release, softc kfree, heap spray.

  • Run 1 (detach after writer finished, callout armed): stock tpm20_discard_buffer fired on the freed softc β†’ Fatal trap 12 in device_print_prettyname (device_printf(sc->dev,...) reading freed memory), callout context (current process = Idle). Guest down.
  • Run 2 (detach mid-transmit, lockstatus=8 held): lockuninit passed on the held lock; writer woke on the freed softc and completed silently (WRITER DONE rc=0 β€” silent UAF writes); ~500 ms later the callout panicked identically. Guest down. 2/2 deterministic panics.

Exploitability ceiling

Trigger requires root twice over: kldunload tpm (or any forced detach) and an open /dev/tpm0 (0600 root:wheel). It is therefore a privileged-actor kernel UAF / reliable local DoS, NOT an unpriv->root escalation. The primitive itself is strong: with heap grooming between the kfree and the 500 ms delayed callout, memset(groomed_ptr, 0, 0x1000) plus lockmgr-on-groomed-lock are attacker-shaped kernel writes; on this intentionally unhardened guest the only missing ingredient for uid=0 is an unprivileged trigger, which does not exist for this path.

Fix validation (mandatory: memory corruption reproduced)

fix.diff (tpm20.c/tpm20.h): tpm20_release now drains the discard callout before and after a critical section that acquires dev_lock EXCLUSIVE, sets sc->dying, destroys the cdev and frees the buffer; tpm20_read, tpm20_write and tpm20_discard_buffer bail on dying. Applied to the guest /usr/src, kernel rebuilt (nativekernel, 0 errors) and installed. Re-running the IDENTICAL reproducer on the patched kernel: same race fires (lockstatus=8 printed), the detach sysctl blocks until the writer completes, teardown is serialized, callout drained β€” no panic, guest up, rc=0 (run_fixed.log). Baseline 2/2 panic vs patched 1/1 clean.

Residual (documented, out of scope of the validated diff): threads blocked in cv_wait inside tpm20_write across an unload are woken by cv_broadcast and bail on dying; a belt-and-braces open-refcount (refuse module unload while /dev/tpm0 is open) would close the last window.

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 (tpm20.c+tpm20.h) applied inside the guest /usr/src, kernel rebuilt with nativekernel (0 errors) and installed; identical reproducer re-run on the patched kernel: same race observed (lockstatus=8), detach serialized behind the writer, callout drained, NO panic, guest stayed up (run_fixed.log). Baseline 2/2 panic vs patched 1/1 clean.

run_fixed.log, fix_build_tail.log (BUILD_INSTALL_OK, install completed Fri Sep 25 05:57:15 UTC 2026), VERDICT.md 'Fix validation' section
↓ fix.diffper-fix-DF-4929

Confirmed kernel references

Detail

Exploit chain

root: (1) open /dev/tpm0 and issue a slow TPM command (CreatePrimary polls up to 40s under dev_lock); (2) concurrently kldunload tpm -> tpmcrb_detach -> tpm20_release frees sc->buf + softc while the writer sleeps in transmit; (3) heap grooming between the kfree and the 500ms delayed callout reclaims the softc, making tpm20_discard_buffer's memset(sc->buf,0,0x1000) and lockmgr(sc->dev_lock) execute with attacker-shaped freed-memory contents (wild kernel write / lock corruption). Demonstrated up to the panic (runs 1-2); grooming-to-controlled-write not needed for the verdict because the trigger itself requires root, capping impact at privileged DoS/memory corruption.

Evidence (decisive lines)

panic.txt (run 1 fatal trap in device_print_prettyname via tpm20_discard_buffer on freed softc), panic.2.txt (run 2: lockstatus=8 during release, lockuninit assert silent, writer completes on freed softc, identical delayed callout panic), run.log/run.1.log/run.2.log (rc=124 ssh hang = guest death), run_fixed.log (patched kernel: no panic, guest up), build.log (stock tpm20.c sha256 provenance), fix_build_tail.log (patched kernel build+install, 0 errors), VERDICT.md (full trace).

PoC changes

PoC authored fresh for this finding (orchestrator seeded none): uafsim KLD that #includes the stock guest /usr/src tpm20.c verbatim so all lifetime-relevant code is shipped code; only the MMIO transport is stubbed with tpmcrb_transmit's timing profile; detach sysctl replays the exact kldunload teardown sequence. Two iterations: sleep tuning to land the detach mid-transmit (run 2) after run 1 landed post-writer.

Verified recommended fix

Serialize tpm20_release under dev_lock, set a dying flag checked by tpm20_read/tpm20_write/tpm20_discard_buffer, and drain discard_buffer_callout before and after the critical section (see fix.diff).

Verdict

REPRODUCED 2/2 as a deterministic kernel panic caused by a use-after-free in the tpm detach path: tpmcrb_detach (tpm_crb.c:230-243) calls tpm20_release (tpm20.c:211-226) with no dev_lock and no draining while tpm20_write (tpm20.c:124-143) holds dev_lock across a multi-second transmit; lockuninit's KKASSERT passes on a held lock (proven at runtime, lockstatus=8), destroy_dev/devfs_destroy_dev does not drain (devfs_core.c:758), so kldunload tpm frees the softc and sc->buf under the transmitting thread and under the armed discard_buffer_callout. The stock tpm20_discard_buffer then fires ~500ms later on the freed softc (callout context, current process Idle) and faults reading freed memory (device_printf(sc->dev)); the writer thread itself completed silently on the freed softc first (WRITER DONE rc=0). Trigger is root-only (kldunload + /dev/tpm0 0600), so this is a privileged-actor UAF/reliable DoS, not unpriv->root. Fixed and validated: with fix.diff, the identical run serializes (detach blocks behind the writer), drains the callout, and completes with no panic and the guest up.