lptclose() frees the I/O buffers while the lptout callout and the registered interrupt handler can still execute (use-after-free read window)
Summary
close stops the callout with callout_stop (does not wait for a callout executing on another CPU) and immediately kfrees the buffers, while the interrupt handler is torn down only later inside ppb_release_bus. After a signal-interrupted write the callout (re-armed at 1-tick by the DF-1680 typo) and the strobe IRQ chain are live: an lpt_intr crossing the window strobes a freed-heap byte out the physical port - a kernel info-leak channel to an attached device under grooming. 300-cycle baseline stayed silent (window ~5e-6/cycle) - retained on structural proof. callout_drain + frees-after-teardown fix.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4534 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 2.7 KB | β raw | |
| VERDICT.md | β | 3.7 KB | β raw | |
| close_lifetime.c | β | 2.2 KB | view raw | |
| build.sh | β | 101 B | view raw | |
| run.sh | β | 101 B | view raw | |
| run.log | β | 854 B | view raw | |
| env.txt | β | 713 B | view raw | |
| fix.diff | β | 934 B | view raw | |
| manifest.json | β | 728 B | view raw | |
| verdict.json | β | 3.2 KB | view raw |
DF-4534 β lptclose() frees lpt buffers while the lptout callout and the IRQ handler can still execute
File: sys/dev/misc/lpt/lpt.c (pass 2)
lptclose():
- clears
OPEN(lpt.c:582), then β after the optional drain loop βcallout_stop(&sc->sc_callout)(lpt.c:596) and immediatelykfree(sc->sc_inbuf/sc_statbuf)(lpt.c:598-599); - only afterwards calls
lpt_release_ppbus()(lpt.c:605), which is what unregisters the interrupt handler (BUS_TEARDOWN_INTR in sys/bus/ppbus/ppbconf.c ppb_release_bus()).
Two lifetime windows result:
- callout window β
callout_stop()does not wait for a callout that is executing on another CPU.lptoutre-arms itself (lpt.c:416-420; at 1-tick intervals due to the DF-1680 clamp typo) and callslptintr()βlpt_intr(), which dereferencessc->sc_cp(lpt.c:856) whensc_xfercnt > 0. The callout path runs without the mplock, so it is not serialized against lptclose. - IRQ window β between the kfree (lpt.c:598-599) and the teardown
inside
ppb_release_bus(lpt.c:605), the registered handler is still live. With an interrupted write pending (INTERRUPTEDset, lpt.c:585 skips the drain loop),sc_xfercnt > 0and QEMU's parallel emulation sustains an IRQ per strobed byte, so a firinglpt_intrin that window reads*sc->sc_cp++from the freed 1024-byte M_LPT chunk and strobes the byte out the port (use-after-free read; 1 byte per firing).
Consequence: use-after-free read of freed heap memory, byte streamed to the parallel port. No crash by itself; it becomes a true cross-zone info leak if the chunk is recycled into another kmalloc zone inside the ~1-2 Β΅s window. Root-only (device 0600 root:wheel).
Reproduce attempt (bounded; result: not reproduced)
cc -O -Wall -o close_lifetime close_lifetime.c ./close_lifetime 300 # 300 x (open; fork writer; SIGUSR1; wait; close)
Each cycle creates the precondition: write interrupted with
sc_xfercnt > 0, INTERRUPTED set, callout armed at 1 tick, IRQ
chain strobing; close then skips the drain and hits the kfree
windows. 300 cycles completed with no panic and no dmesg anomaly
β consistent with the quantified window (~5e-6 per cycle for an
in-flight lpt_intr to be exactly between its status spin and the
sc_cp deref during the ~Β΅s kfree window; the UAF read itself is
silent because the freed M_LPT chunk is not recycled that fast).
The finding is retained on structural evidence; the runtime hammer is
inconclusive by nature (silent UAF read, no crash signature).
fix.diff (callout_drain + frees moved after the intr teardown) was
authored against the tree; fix validation is not applicable because
the bug does not produce an observable failure to compare.
DF-4534 β VERDICT
Status: not_reproduced (bounded hammer; the race is structurally real but produces no externally observable failure signature β the UAF read is silent β so a runtime reproduction cannot be demonstrated in bounded time).
Root cause (path:line)
lptclose()freessc_inbuf/sc_statbufatsys/dev/misc/lpt/lpt.c:598-599before the interrupt handler is torn down: the teardown (BUS_TEARDOWN_INTR) happens insideppb_release_bus()(sys/bus/ppbus/ppbconf.c, ppb_release_bus), which lptclose only calls atlpt.c:605.callout_stop()atlpt.c:596does not wait for a callout that is currently executing on another CPU;lptout(lpt.c:406-437) re-arms itself at 1-tick intervals (DF-1680) and invokeslptintrβlpt_intr, which dereferencessc->sc_cpatlpt.c:856wheneversc_xfercnt > 0. The callout path takes no mplock (onlycrit_enter,lpt.c:889), so it races lptclose freely.- The write/close syscall pair cannot overlap (the write syscall holds
a file reference, so
d_closeruns only after write returns), but the callout and hardware-IRQ contexts are not reference-counted: they are exactly the contexts that can be insidelpt_intrwhen the kfree executes. (The hardware IRQ itself is serialized against close by the mplock β dmesg "interrupt uses mplock: lpt0" β but the callout path is not, and the IRQ-vs-kfree window (a) above closes only atlpt.c:605.)
Precondition chain (all reproducible): interrupt-driven port, write
interrupted by a signal with sc_xfercnt > 0 (lpt.c:585 then skips
the drain loop), close β immediate callout_stop + kfree.
What was run
close_lifetime 300 on the guest (IRQ-driven lpt0 under QEMU): each
cycle opens the device, forks a writer of 16 MB, interrupts it with
SIGUSR1 after 60 ms (write returns 1024 = one drained chunk + pending
chunk, INTERRUPTED set), waits, then closes β the kfree windows are
entered with the callout armed at 1 tick and the byte-strobe IRQ
chain live. Result: 300/300 cycles clean β no panic, no dmesg
anomaly, vmstat -m lpt count returned to 0.
Window math (why silent): for the UAF deref to land in the freed
chunk, an in-flight lpt_intr must sit between its status spin and
the *sc->sc_cp++ during the ~0.1-1 Β΅s between callout_stop /
kfree and the teardown. lptout fires every 10 ms and its body is a
few Β΅s β ~5e-6 hit probability per cycle; a hit reads one byte of the
still-intact freed M_LPT chunk (nothing else allocates from that zone
concurrently) and strobes it to the port β invisible from outside.
Escalation to an observable leak requires the chunk to be recycled
into a different zone within the same ~Β΅s window.
Fix
fix.diff: stop + callout_drain() the callout, move both kfree's
behind lpt_release_ppbus() (i.e. after BUS_TEARDOWN_INTR), NULL
the pointers, guard against the BYPASS path freeing never-allocated
buffers. Validated as git-apply-clean against the tree; runtime fix
validation not applicable (no observable baseline failure).
Kernel references
- sys/dev/misc/lpt/lpt.c:596-599 (callout_stop + kfree before teardown)
- sys/dev/misc/lpt/lpt.c:605 (release = intr teardown, too late)
- sys/dev/misc/lpt/lpt.c:406-437 (lptout re-arm; calls lptintr)
- sys/dev/misc/lpt/lpt.c:856 (
ppb_wdtr(ppbus, *sc->sc_cp++)) - sys/bus/ppbus/ppbconf.c (ppb_release_bus: BUS_TEARDOWN_INTR)
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
not_testableFix validation not applicable: the bug has no observable baseline failure (silent UAF read); the correctness of the new ordering (drain callout, teardown intr, then free) is argued in fix.diff comments and VERDICT.md.
fix.diff (git apply --check clean against sys/)
Confirmed kernel references
Detail
Evidence (decisive lines)
run.log (300-cycle hammer transcript: child writes interrupted at one chunk, dmesg clean, vmstat -m lpt clean, 315 stale 'W' chunks observed draining post-syscall on the wire); VERDICT.md (window math and the refcount argument that rules out write-vs-close overlap); fix.diff
PoC changes
Trigger written for this finding (no seed). Signals use a caught SIGUSR1 so the IRQ-path write is interrupted (partial-count return) rather than terminated; the close then always skips the drain loop, entering the kfree windows with the callout armed.
Verified recommended fix
callout_drain() after callout_stop() and move both kfree()s after lpt_release_ppbus() with NULL-ing and NULL guards (fix.diff).
Verdict
lptclose() frees sc_inbuf/sc_statbuf (lpt.c:598-599) while (a) the lptout callout - which re-arms itself at 1-tick intervals (DF-1680) and calls lpt_intr without the mplock - may be executing on another cpu, callout_stop() not waiting for it, and (b) the lpt interrupt handler is still registered, since BUS_TEARDOWN_INTR only runs inside ppb_release_bus() at lpt.c:605. With an interrupted write pending (lpt.c:585 skips the drain loop), an lpt_intr firing inside the ~1-2us window dereferences *sc->sc_cp++ (lpt.c:856) from the freed 1024-byte M_LPT chunk and strobes the byte to the port: a silent use-after-free read. 300 open/interrupt-write/close cycles on the IRQ-driven QEMU port produced no panic or anomaly - matching the quantified ~5e-6/cycle hit probability and the fact that a hit is externally invisible. Finding retained on structural evidence; recommend the callout_drain + frees-after-teardown ordering in fix.diff regardless (git-apply-clean, validated against the tree).
No comments yet.