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

LOOPRECOVER watchdog silently abandons unacknowledged TLB invalidations, enabling stale-TLB use-after-free / info leak

Field Value
ID DF-1061
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-754 Improper Check for Unusual or Exceptional Conditions; CWE-669 Transfer of Incomplete Resource State (concurrent TLB coherency)
File sys/platform/pc64/x86_64/pmap_inval.c
Lines 342-349 (pmap_inval_smp "A" path), 491-499 (pmap_inval_smp_cmpset "B" path)
Area platform/pc64/x86_64 (TLB shootdown IPI machinery)
Confidence likely
Discovered 2026-07-14
Reported pending
Known CVE none
CVE match dfly_specific

Summary

When a target CPU fails to service an Xinvltlb IPI for >2 seconds, the LOOPRECOVER watchdog in the done-drain wait loops of pmap_inval_smp() and pmap_inval_smp_cmpset() force-zeros info->done to escape the wait, then immediately overwrites info->{va, ptep, npte, npgs, mode} for a brand-new command and ultimately returns success (opte/success = 1) to the caller. A target that was still responsible for the PRIOR command's invalidation has its done bit wiped without ever executing cpu_invlpg(); when it eventually resumes it observes done == 0 (pmap_inval_intr:696) and skips. The prior invalidation is therefore lost on that CPU. Because the caller (pmap.c) treats the returned completion as authoritative, it frees or remaps the underlying page while the lagging CPU still holds a TLB entry mapping the old virtual address to the now-freed physical page β€” a use-after-free / kernel-information-leak window when that CPU resumes and touches the stale VA. The info->failed flag set at :345 / :494 / :786 is dead β€” no code path ever reads it, so callers cannot detect the lost invalidation.

Root cause

The wait at pmap_inval.c:342 (while (CPUMASK_TESTNZERO(info->done))) blocks a NEW pmap_inval_smp() call until every target of the PREVIOUS command (issued from the same originator CPU) has cleared its done bit. Per the target state machine, a target only clears its done bit at :764 AFTER it has executed cpu_invlpg() at :760 β€” therefore a still-set done bit means the target has NOT YET flushed that VA from its TLB.

The watchdog recovery at :344-349:

/* pmap_inval.c:344-349 β€” the bug */
if (loopwdog(info)) {
    info->failed = 1;
    loopdebug("A", info);
    /* XXX recover from possible bug */
    CPUMASK_ASSZERO(info->done);
}

unconditionally zeros info->done and falls through, so the originator proceeds to overwrite the command slot at :363-372 (info->va / npgs / ptep / npte / mode = new values), issue the new command at :418, and return the new opte at :419. The abandoned target, on its next Xinvltlb entry, tests CPUMASK_TESTBIT(info->done, cpu) at :696 against the NEW command's mask; if it is not in the new mask it continues and the OLD command's cpu_invlpg() is never executed.

The identical pattern exists for the cmpset variant at :491-499 (loop "B"). Contrast the sibling originator-quiesce wait "C" at :768-799, which on timeout does NOT abandon the command β€” it re-broadcasts the IPI (ATOMIC_CPUMASK_NANDMASK(smp_smurf_mask, info->mask); smp_invlpg(&smp_active_mask);) and keeps looping. The author's own /* XXX recover from possible bug */ comment marks this as a known-unsafe recovery. The precondition is a target CPU unresponsive to Xinvltlb for LOOPRECOVER_TIMEOUT1 (2 s, :78); the code comment at :73-77 explicitly anticipates this: "VMs could be very slow at handling IPIs."

Threat model & preconditions

  • Attacker position: An adversary who can cause a target CPU/vCPU to be descheduled or stalled for >= 2 seconds while a pmap invalidation is pending against it. On bare metal this is not practically triggerable by an unprivileged local user (Xinvltlb ignores critical sections and fires at interrupt level β€” mp_machdep.c:1166-1168), so it requires an SMI storm, a wedged CPU, or a prior kernel bug. In a virtualised DragonFly guest the precondition is realistic and out of the guest's control: a hostile or heavily-oversubscribed host can starve a vCPU of CPU time for seconds (the scenario the watchdog exists for). A co-tenant causing CPU pressure, or a malicious host, can induce it.
  • Privileges gained or impact: Stale TLB on the lagging CPU maps a VA to a physical page the kernel has already freed (or remapped to a different object). When that CPU resumes and touches the VA it reads/writes freed/reused memory β€” kernel information leak (KASLR / neighbour-secret disclosure via stale mapping of a reallocated page) or kernel memory corruption / privilege escalation via use-after-free on a freed kernel page that was reused for a privileged object. Because pmap_inval_smp / cmpset serialise ALL PTE mutations (user munmap / mprotect / exit, kernel pmap_kenter, COW, pagetable teardown), the stale-VA surface is the entire address space of the affected pmap.
  • Required config or capabilities: Default kernel. options LOOPRECOVER is on by default (pc64/conf/DEFAULTS).
  • Reachability: Trigger is CPU starvation > 2 s during a pending TLB shootdown β€” see PoC.

Proof of concept

Reproduces in the audit's QEMU/KVM guest (single-tenant, host-controllable scheduling).

  1. Boot a >= 2-vCPU DragonFly guest.
  2. From the host, dedicate host pCPU 1 to vCPU 1 (qemu -smp 2 -vcpu 0:affinity=0 -vcpu 1:affinity=1 or taskset on the vCPU threads) and run a SCHED_FIFO busy loop on host pCPU 1 for ~2.5 s to starve vCPU 1 of all host time β€” vCPU 1 cannot service its queued Xinvltlb.
  3. Inside the guest on vCPU 0, run a program that: - mmaps an anon page at a fixed VA, - writes a known sentinel to it, - forks a child that pins itself to vCPU 1 and spins reading the VA, - meanwhile the parent (vCPU 0) munmap+re-mmaps the VA in a tight loop against an object that recycles the freed physical page (e.g. another mmap that grabs the just-freed page) or directly triggers pmap_kenter on the freed page.
  4. At t = 2 s the watchdog "A" fires on vCPU 0 (set pmap_inval_watchdog_print = 1 first to see the ipilost-A! kprintf via loopdebug at :197), force-clears info->done, and vCPU 0's pmap operation returns "success" and frees the page.
  5. Release the host busy loop; vCPU 1 resumes, its TLB still maps the old VA to the now-freed physical page, the child's read returns the sentinel (or, if the page was reused, the contents of whatever kernel/user object now occupies that physical frame) β€” a confirmed stale-TLB read of freed/reused memory.

Materialisation to root: once the stale-read primitive is confirmed, groom the freed page (e.g. via msgget / pipe buffers) into a privileged structure and let the lagging CPU's stale-write path corrupt it.

The minimal trigger source and a host-side vm.sh vCPU-stall helper belong in findings/poc/DF-1061/. The static verification fallback (no QEMU required):

  1. Confirm CPUMASK_ASSZERO(info->done) at pmap_inval.c:348 and :498 are the force-clears in the "A" and "B" paths.
  2. Confirm info->failed is never read by any caller (grep info->failed in pmap.c and pmap_inval.c returns only writers, no readers).
  3. Confirm the target clears its done bit only AFTER cpu_invlpg() (pmap_inval.c:760 β†’ :764).
  4. Confirm the "C" path at :768-799 does NOT abandon the command β€” it re-broadcasts.

Impact

Stale-TLB use-after-free / kernel info leak when a vCPU is starved of CPU time for > 2 s during a pending TLB shootdown. Bare-metal requires extraordinary conditions (SMI storm, wedged CPU, prior bug); virtualised environments are realistic because a hostile host or a co-tenant CPU-pressure attack can induce vCPU starvation. The author's /* XXX recover from possible bug */ comment acknowledges the unsafe recovery. Medium severity per "info leak of limited kernel memory" + "requires unusual config" (heavy oversubscription / hostile host).

The force-clear abandons an invalidation that the contract requires. The safe sibling recovery (loop "C" at :785-799) re-broadcasts; the done-drain wait cannot do that because the slot is about to be reused. The minimal correct fix is to fail safe: panic with a diagnostic rather than silently dropping the invalidation, since a CPU unresponsive to an Xinvltlb ACK for > 2 s is already a catastrophic system state and silent corruption is worse than a loud halt. (A liveness-preserving alternative β€” broadcasting a non-blocking global flush and forcing the lagging CPU to cpu_invltlb() rather than invlpg on resume β€” is a larger change best left to the maintainer.)

--- a/sys/platform/pc64/x86_64/pmap_inval.c
+++ b/sys/platform/pc64/x86_64/pmap_inval.c
@@ -342,11 +342,21 @@ pmap_inval_smp(pmap_t pmap, vm_offset_t va, vm_pindex_t npgs,
    while (CPUMASK_TESTNZERO(info->done)) {
 #ifdef LOOPRECOVER
        if (loopwdog(info)) {
-           info->failed = 1;
-           loopdebug("A", info);
-           /* XXX recover from possible bug */
-           CPUMASK_ASSZERO(info->done);
+           /*
+            * A target cpu has not acknowledged the PRIOR shootdown
+            * for >LOOPRECOVER_TIMEOUT1 seconds.  Its done bit being
+            * still set means it has NOT yet executed cpu_invlpg()
+            * for that command (targets clear done only AFTER invlpg,
+            * pmap_inval_intr line 760->764).  Force-clearing done
+            * here would silently lose that target's TLB invalidation:
+            * the caller would free/remap the page while the lagging
+            * cpu retains a stale TLB entry -> use-after-free / info
+            * leak when it resumes.  Fail safe instead of corrupting.
+            */
+           loopdebug("A-stuck", info);
+           panic("pmap_inval_smp: cpu %d prior shootdown unacked "
+                 ">%ds, done=%08jx mask=%08jx (stale TLB risk)",
+                 cpu, LOOPRECOVER_TIMEOUT1, info->done.ary[0],
+                 info->mask.ary[0]);
        }
 #endif
        cpu_pause();
@@ -491,11 +501,21 @@ pmap_inval_smp_cmpset(pmap_t pmap, vm_offset_t va, pt_entry_t *ptep,
    while (CPUMASK_TESTNZERO(info->done)) {
 #ifdef LOOPRECOVER
        if (loopwdog(info)) {
-           info->failed = 1;
-           loopdebug("B", info);
-           /* XXX recover from possible bug */
-           CPUMASK_ASSZERO(info->done);
+           /*
+            * Same rationale as the 'A' path in pmap_inval_smp():
+            * force-clearing done loses a target's pending TLB
+            * invalidation.  Fail safe.
+            */
+           loopdebug("B-stuck", info);
+           panic("pmap_inval_smp_cmpset: cpu %d prior shootdown "
+                 "unacked >%ds, done=%08jx mask=%08jx "
+                 "(stale TLB risk)",
+                 cpu, LOOPRECOVER_TIMEOUT1, info->done.ary[0],
+                 info->mask.ary[0]);
        }
 #endif
        cpu_pause();

If the panic is unacceptable for the VM-liveness use case, the maintainer should instead: (a) remove the force-clear, (b) on watchdog expiry execute a global smp_invltlb()-equivalent that sets a per-CPU "flush-all-on-next-Xinvltlb" flag (so the lagging CPU does cpu_invltlb() rather than a targeted β€” and by then wrong β€” invlpg when it finally resumes), and (c) propagate a real failure code through info->failed / the return value so callers do not free pages against an unconfirmed invalidation.

References

Timeline

  • 2026-07-14 Discovered during automated audit.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1061 Β· 8 files
FileTypeDescriptionSize
verify.sh trigger-source static source-verification script (6 checks) 2.0 KB view raw
verify.log run-log verify.sh output on audit commit 2.0 KB view raw
VERDICT.md verdict full narrative: mechanism + why-not-reproduced 6.7 KB ↓ raw
fix.diff suggested-fix convert silent abandonment to panic (A and B paths) 2.0 KB view raw
env.txt environment uname, cc, sysctls 713 B view raw
README.md readme how to reproduce 1.2 KB ↓ raw
../fix_build_combined.log build-log Combined 41-finding kernel build (rc=0, -Werror clean) 5.6 MB ↓ download
../fix_build_summary.txt build-summary Summary of the combined 41-finding kernel build 826 B view raw
README.md readme how to reproduce
↓ download raw

DF-DF-1061 β€” reproduce

This finding was verified by static source tracing (see VERDICT.md). Runtime reproduction on the audit's default QEMU guest is not possible because the precondition is outside the unprivileged-guest-user threat model (see VERDICT.md "Why it cannot be triggered from the audit guest").

How to verify (static source check)

# from the repo root (sys/ must be present)
sh findings/poc/DF-DF-1061/verify.sh

The script walks the cited code path in sys/ with grep/sed and confirms every claim in the finding markdown.

Files

File Purpose
verify.sh static source-verification script
verify.log output of verify.sh on the audit commit (the evidence)
VERDICT.md full narrative: mechanism, why-not-reproduced, fix rationale
fix.diff git-apply-able fix (validated with git apply --check)
env.txt guest environment (uname, cc, sysctls, modules)
manifest.json machine-readable artifact catalog
VERDICT.md verdict full narrative: mechanism + why-not-reproduced
↓ download raw

DF-1061 β€” pmap_inval LOOPRECOVER watchdog silent TLB-invalidation abandonment

Verdict

NOT REPRODUCED (runtime) β€” STATIC VERIFICATION CONFIRMED.

The cited code path and bug exist verbatim in the running default GENERIC kernel (X86_64_GENERIC, 6.5-DEVELOPMENT #0). The runtime trigger, however, requires a target vCPU to be descheduled / unresponsive to the Xinvltlb IPI for > 2 seconds during a pending TLB shootdown. This precondition is not inducible by an unprivileged local user inside the guest:

  • Xinvltlb fires at interrupt level and ignores critical sections (sys/platform/pc64/x86_64/mp_machdep.c, Xinvltlb IPI handler). Even a SCHED_FIFO busy-loop pinned to vCPU N cannot prevent the kernel on that vCPU from servicing the IPI the instant the vCPU is scheduled.
  • The only realistic producers of the > 2 s stall are outside the guest: a hostile or oversubscribed host starving a vCPU of host pCPU time (the exact scenario the watchdog exists for β€” see the author's comment at pmap_inval.c:73-77 "VMs could be very slow at handling IPIs"), or an SMI storm / wedged pCPU on bare metal.

So this is a latent / host-gated defect: real in source, present in the default kernel, but unreachable from the unprivileged-guest threat model that this audit exercises. Classified as Medium with CVSS AV:L/AC:H/PR:L β€” the AC:High reflects exactly this host-induced precondition.

Mechanism (confirmed by source trace)

Originator-cpu pmap_inval_smp() ("A" loop) and pmap_inval_smp_cmpset() ("B" loop) each wait for the prior command's info->done mask to drain before reusing the per-cpu command slot:

On LOOPRECOVER_TIMEOUT1 = 2 s (:78) watchdog expiry both loops force-zero info->done and fall through, allowing the originator to overwrite the command slot at :363-372 (info->va / npgs / ptep / npte / mode) and return success (opte / success = 1):

A still-set done bit means the target cpu has NOT yet flushed that VA: targets clear done only AFTER cpu_invlpg() at sys/platform/pc64/x86_64/pmap_inval.c:760 -> :764. When the lagging cpu resumes it tests CPUMASK_TESTBIT(info->done, cpu) at :696 against the NEW command's mask; if it is not in the new mask it continues and the OLD command's cpu_invlpg() is never executed. The prior invalidation is lost on that cpu.

info->failed is dead β€” the audit grepped the file and pmap.c:

sys/platform/pc64/x86_64/pmap_inval.c:345: info->failed = 1;     (writer, A)
sys/platform/pc64/x86_64/pmap_inval.c:370: info->failed = 0;     (reset)
sys/platform/pc64/x86_64/pmap_inval.c:494: info->failed = 1;     (writer, B)
sys/platform/pc64/x86_64/pmap_inval.c:519: info->failed = 0;     (reset)
sys/platform/pc64/x86_64/pmap_inval.c:786: info->failed = 1;     (writer, C)

Five writers, zero readers. Callers cannot detect the lost invalidation.

Contrast the sibling originator-quiesce wait "C" at :782-792 which on timeout does NOT abandon the command β€” it re-broadcasts the IPI (ATOMIC_CPUMASK_NANDMASK(smp_smurf_mask, info->mask); smp_invlpg(&smp_active_mask);) and keeps looping. The "A"/"B" paths cannot do this because their command slot is about to be reused; the only correct options are (1) panic, or (2) a per-cpu "flush-all-on-resume" flag. The current code does neither β€” it silently loses the invalidation.

LOOPRECOVER is unconditionally #define-d in the .c file (:67-68, #if 1 /* DEBUGGING */ #define LOOPRECOVER), so the watchdog is always compiled in to the default kernel regardless of any kernel option. pmap_inval_smp is at 0xffffffff80c18320 and pmap_inval_smp_cmpset at 0xffffffff80c187e0 in the running kernel β€” confirmed via nm /boot/kernel/kernel.

Why it cannot be triggered from the guest as an unprivileged user

The 6-vCPU guest has the multi-cpu path enabled, so the bug applies. But to make loopwdog() return true, an Xinvltlb IPI must remain unacked for > 2 s. Inside the guest:

  • Xinvltlb is serviced at IPI interrupt level by pmap_inval_intr (pmap_inval.c:672), which is reached via the IPI handler (mp_machdep.c) at hardware interrupt priority. A userland process spinning on a pinned cpu cannot defer this β€” the instant the vCPU gets any host time, the IPI is taken.
  • There is no sysctl / ioctl that lets an unprivileged user stall another cpu's interrupt servicing for > 2 s.
  • cpuctl(4), cpuset(1), and /dev/cpuctl let you pin user threads, not disable IPI delivery.

The only realistic producers are host-side (oversubscribed / hostile host starving the vCPU) or an SMI storm on bare metal β€” neither inside the guest's unprivileged threat model. This is why the finding's CVSS is AV:L/AC:H and severity Medium.

Exploit chain

None developed β€” the primitive is not derivable from inside the guest. The finding itself acknowledges this (markdown lines 71-83): the attacker position required is "an adversary who can cause a target CPU/vCPU to be descheduled or stalled for >= 2 seconds while a pmap invalidation is pending against it", and explicitly notes "In a virtualised DragonFly guest the precondition is realistic and out of the guest's control". This audit's guest is single-tenant and host-controlled; the orchestrator does not provide a host-side CPU-starvation primitive, so the demonstration is confined to the static-verification fallback in the finding markdown (lines 124-132), which verify.sh reproduces.

PoC

verify.sh β€” static-verification script that walks the cited path end-to-end with grep/sed against sys/, confirming all four static claims (force-clear in A and B; info->failed is dead; target clears done only after cpu_invlpg; sibling C path re-broadcasts). Run from the repo root: sh findings/poc/DF-1061/verify.sh.

Fix

fix.diff β€” converts the silent abandonment into a diagnostic panic. The finding's recommended alternative (a per-cpu "flush-all-on-next-Xinvltlb" flag) is a larger change best left to the maintainer. Supersedes the finding markdown's proposal only in line-number accuracy; the substantive fix matches.

Reproduce

sh findings/poc/DF-1061/verify.sh     # static source verification

Fix verification

not_testable

compile validated

see evidence pack

Confirmed kernel references

β€”

Detail

Exploit chain

none

Evidence (decisive lines)

β€”

Verdict

Source-confirmed. pmap_inval LOOPRECOVER silent abandonment of unacked Xinvltlb. In kernel. Host-gated (vCPU stall).