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)
PoC verification
Evidence pack
findings/poc/DF-4929 Β· 15 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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) acquiressc->dev_lockEXCLUSIVE at :124 and holds it across the wholesc->transmit()call (:135) β which for CRB meanstpmcrb_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) callstpm20_releasewithout acquiringdev_lockand without draining:tpm20_release(tpm20.c:211-226) doeskfree(sc->buf),lockuninit,cv_destroy,destroy_devunconditionally.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, sokldunload 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 thediscard_buffer_calloutarmed bytpm20_write(tpm20.c:138-139) keep using the freed softc:sc->pending_data_lengthstore (tpm_crb.c:402),callout_reset/owner_tid/LK_RELEASE(tpm20.c:138-143), and β the deterministic killer βtpm20_discard_buffer~500 ms later:lockmgron the freed lock andmemset(sc->buf, 0, TPM_BUFSIZE)withsc->buf(andsc->devfordevice_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_bufferfired on the freed softc βFatal trap 12indevice_print_prettyname(device_printf(sc->dev,...)reading freed memory), callout context (current process = Idle). Guest down. - Run 2 (detach mid-transmit,
lockstatus=8held): 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
fixedfix.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
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.
No comments yet.