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 (
Xinvltlbignores 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/cmpsetserialise ALL PTE mutations (usermunmap/mprotect/exit, kernelpmap_kenter, COW, pagetable teardown), the stale-VA surface is the entire address space of the affected pmap. - Required config or capabilities: Default kernel.
options LOOPRECOVERis 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).
- Boot a >= 2-vCPU DragonFly guest.
- From the host, dedicate host pCPU 1 to vCPU 1
(
qemu -smp 2 -vcpu 0:affinity=0 -vcpu 1:affinity=1ortaskseton the vCPU threads) and run aSCHED_FIFObusy loop on host pCPU 1 for ~2.5 s to starve vCPU 1 of all host time β vCPU 1 cannot service its queuedXinvltlb. - 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. anothermmapthat grabs the just-freed page) or directly triggerspmap_kenteron the freed page. - At t = 2 s the watchdog "A" fires on vCPU 0 (set
pmap_inval_watchdog_print = 1first to see theipilost-A!kprintfvialoopdebugat:197), force-clearsinfo->done, and vCPU 0's pmap operation returns "success" and frees the page. - 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):
- Confirm
CPUMASK_ASSZERO(info->done)atpmap_inval.c:348and:498are the force-clears in the "A" and "B" paths. - Confirm
info->failedis never read by any caller (grep info->failedinpmap.candpmap_inval.creturns only writers, no readers). - Confirm the target clears its done bit only AFTER
cpu_invlpg()(pmap_inval.c:760β:764). - Confirm the "C" path at
:768-799does 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).
Recommended fix
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
sys/platform/pc64/x86_64/pmap_inval.c:342-349βpmap_inval_smp"A" watchdog force-clearsys/platform/pc64/x86_64/pmap_inval.c:491-499βpmap_inval_smp_cmpset"B" watchdog force-clearsys/platform/pc64/x86_64/pmap_inval.c:760-764β target clearsdoneonly AFTERcpu_invlpg()sys/platform/pc64/x86_64/pmap_inval.c:696β target testsdonebit for the NEW commandsys/platform/pc64/x86_64/pmap_inval.c:768-799β sibling "C" path re-broadcasts instead of abandoningsys/platform/pc64/x86_64/pmap_inval.c:78βLOOPRECOVER_TIMEOUT1 = 2 ssys/platform/pc64/x86_64/pmap_inval.c:73-77β author comment: "VMs could be very slow at handling IPIs"- CWE-754 Improper Check for Unusual or Exceptional Conditions
- CWE-669 Transfer of Incomplete Resource State
Timeline
- 2026-07-14 Discovered during automated audit.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1061 Β· 8 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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 |
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:
Xinvltlbfires at interrupt level and ignores critical sections (sys/platform/pc64/x86_64/mp_machdep.c,XinvltlbIPI handler). Even aSCHED_FIFObusy-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:
sys/platform/pc64/x86_64/pmap_inval.c:342βwhile (CPUMASK_TESTNZERO(info->done))sys/platform/pc64/x86_64/pmap_inval.c:491β same in cmpset variant
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):
sys/platform/pc64/x86_64/pmap_inval.c:344-348β A-path force-clearc if (loopwdog(info)) { info->failed = 1; loopdebug("A", info); /* XXX recover from possible bug */ CPUMASK_ASSZERO(info->done); /* <-- silent abandonment */ }sys/platform/pc64/x86_64/pmap_inval.c:491-497β B-path force-clear (identical)
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:
Xinvltlbis serviced at IPI interrupt level bypmap_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/cpuctllet 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_testablecompile 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).
No comments yet.