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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4534 Β· 10 files
FileTypeDescriptionSize
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 immediately kfree(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:

  1. callout window β€” callout_stop() does not wait for a callout that is executing on another CPU. lptout re-arms itself (lpt.c:416-420; at 1-tick intervals due to the DF-1680 clamp typo) and calls lptintr() β†’ lpt_intr(), which dereferences sc->sc_cp (lpt.c:856) when sc_xfercnt > 0. The callout path runs without the mplock, so it is not serialized against lptclose.
  2. 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 (INTERRUPTED set, lpt.c:585 skips the drain loop), sc_xfercnt > 0 and QEMU's parallel emulation sustains an IRQ per strobed byte, so a firing lpt_intr in 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.

VERDICT.md
↓ download raw

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() frees sc_inbuf/sc_statbuf at sys/dev/misc/lpt/lpt.c:598-599 before the interrupt handler is torn down: the teardown (BUS_TEARDOWN_INTR) happens inside ppb_release_bus() (sys/bus/ppbus/ppbconf.c, ppb_release_bus), which lptclose only calls at lpt.c:605.
  • callout_stop() at lpt.c:596 does 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 invokes lptintr β†’ lpt_intr, which dereferences sc->sc_cp at lpt.c:856 whenever sc_xfercnt > 0. The callout path takes no mplock (only crit_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_close runs only after write returns), but the callout and hardware-IRQ contexts are not reference-counted: they are exactly the contexts that can be inside lpt_intr when 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 at lpt.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

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_testable
baseline no→ patch + rebuild →patched clean

Fix 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/)
↓ fix.diffper-fix-DF-4534

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).