[PATCH 2/2] KVM: selftests: Add a test for lost nested APF VM-Exits
From: Loc Nguyen
Date: Fri Oct 02 2026 - 01:43:31 EST
Exercise delivery of an asynchronous page fault from L2 to L1 as a
synthetic #PF VM-Exit. Hold L2's backing page missing with userfaultfd
and bound the critical KVM_RUN with a signal-based timeout.
On an affected kernel, KVM queues the nested VM-Exit without requesting
event processing, re-enters L2, and blocks on the same missing page. The
test detects the timeout and verifies that L1's APF record still contains
KVM_PV_REASON_PAGE_NOT_PRESENT.
On a fixed kernel, verify that L1 receives an exception VM-Exit with a #PF,
error code zero, a nonzero APF token, and a matching page-not-present
reason.
Skip the test when VMX, EPT, the nested APF features, or userfaultfd fault
handling are unavailable.
Signed-off-by: Loc Nguyen <loc@xxxxxxxxxx>
---
tools/testing/selftests/kvm/Makefile.kvm | 1 +
.../selftests/kvm/x86/nested_apf_event_test.c | 306 ++++++++++++++++++
2 files changed, 307 insertions(+)
create mode 100644 tools/testing/selftests/kvm/x86/nested_apf_event_test.c
diff --git a/tools/testing/selftests/kvm/Makefile.kvm b/tools/testing/selftests/kvm/Makefile.kvm
index b31ee61af284..a882c1f8a5fb 100644
--- a/tools/testing/selftests/kvm/Makefile.kvm
+++ b/tools/testing/selftests/kvm/Makefile.kvm
@@ -92,6 +92,7 @@ TEST_GEN_PROGS_x86 += x86/kvm_pv_test
TEST_GEN_PROGS_x86 += x86/kvm_buslock_test
TEST_GEN_PROGS_x86 += x86/monitor_mwait_test
TEST_GEN_PROGS_x86 += x86/msrs_test
+TEST_GEN_PROGS_x86 += x86/nested_apf_event_test
TEST_GEN_PROGS_x86 += x86/nested_close_kvm_test
TEST_GEN_PROGS_x86 += x86/nested_dirty_log_test
TEST_GEN_PROGS_x86 += x86/nested_emulation_test
diff --git a/tools/testing/selftests/kvm/x86/nested_apf_event_test.c b/tools/testing/selftests/kvm/x86/nested_apf_event_test.c
new file mode 100644
index 000000000000..97e23ef91ca6
--- /dev/null
+++ b/tools/testing/selftests/kvm/x86/nested_apf_event_test.c
@@ -0,0 +1,306 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (C) 2026, Ryzome
+ *
+ * Reproduce a lost nested asynchronous page-fault notification.
+ *
+ * L2 faults on a page held missing by userfaultfd. KVM writes
+ * PAGE_NOT_PRESENT to L1's APF record and queues a synthetic #PF VM-exit.
+ * The vulnerable path omits KVM_REQ_EVENT, re-enters L2, and loses the
+ * notification before L1 can consume it. A fixed L0 exits to L1 immediately.
+ */
+#include <errno.h>
+#include <fcntl.h>
+#include <linux/userfaultfd.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdint.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/ioctl.h>
+#include <sys/mman.h>
+#include <sys/syscall.h>
+#include <unistd.h>
+
+#include <asm/kvm_para.h>
+
+#include "kvm_util.h"
+#include "processor.h"
+#include "test_util.h"
+#include "vmx.h"
+
+#define APF_VECTOR 0xF1U
+#define RUN_TIMEOUT_SECONDS 3U
+#define VMX_INTR_INFO_VECTOR_MASK 0xFFU
+#define VMX_INTR_INFO_TYPE_MASK 0x700U
+#define VMX_INTR_INFO_HARD_EXCEPTION (3U << 8)
+#define VMX_INTR_INFO_DELIVER_CODE BIT(11)
+#define VMX_INTR_INFO_VALID BIT(31)
+
+enum test_stage {
+ STAGE_APF_ARMED = 1,
+ STAGE_APF_DELIVERED,
+};
+
+static struct kvm_vcpu_pv_apf_data apf_data __aligned(64);
+static gva_t l2_target_gva;
+
+static void l2_guest_code(void)
+{
+ u64 value;
+
+ value = READ_ONCE(*(u64 *)READ_ONCE(l2_target_gva));
+ GUEST_FAIL("L2 resumed after the missing page was resolved: %#lx", value);
+}
+
+static void l1_guest_code(struct vmx_pages *vmx, gpa_t apf_data_gpa)
+{
+ u64 apf_msr;
+ u64 intr_info;
+ u64 reason;
+ u64 token;
+
+ GUEST_ASSERT(this_cpu_has(X86_FEATURE_KVM_ASYNC_PF));
+ GUEST_ASSERT(this_cpu_has(X86_FEATURE_KVM_ASYNC_PF_VMEXIT));
+ GUEST_ASSERT(this_cpu_has(X86_FEATURE_KVM_ASYNC_PF_INT));
+ prepare_for_vmx_operation(vmx);
+ load_vmcs(vmx);
+ prepare_vmcs(vmx, l2_guest_code);
+ vmwrite(GUEST_RFLAGS,
+ X86_EFLAGS_FIXED | X86_EFLAGS_IF);
+
+ wrmsr(MSR_KVM_ASYNC_PF_INT, APF_VECTOR);
+ apf_msr = apf_data_gpa | KVM_ASYNC_PF_ENABLED |
+ KVM_ASYNC_PF_SEND_ALWAYS |
+ KVM_ASYNC_PF_DELIVERY_AS_PF_VMEXIT |
+ KVM_ASYNC_PF_DELIVERY_AS_INT;
+ wrmsr(MSR_KVM_ASYNC_PF_EN, apf_msr);
+
+ /*
+ * Let userspace discard and userfaultfd-register the target only after
+ * all L1 setup exits have completed. The following KVM_RUN therefore
+ * has no pending event request to accidentally deliver the nested APF.
+ */
+ GUEST_SYNC(STAGE_APF_ARMED);
+
+ vmlaunch();
+ GUEST_ASSERT_EQ(vmread(VM_EXIT_REASON), EXIT_REASON_EXCEPTION_NMI);
+
+ intr_info = vmread(VM_EXIT_INTR_INFO);
+ GUEST_ASSERT(intr_info & VMX_INTR_INFO_VALID);
+ GUEST_ASSERT_EQ(intr_info & VMX_INTR_INFO_VECTOR_MASK, PF_VECTOR);
+ GUEST_ASSERT_EQ(intr_info & VMX_INTR_INFO_TYPE_MASK,
+ VMX_INTR_INFO_HARD_EXCEPTION);
+ GUEST_ASSERT(intr_info & VMX_INTR_INFO_DELIVER_CODE);
+ GUEST_ASSERT_EQ(vmread(VM_EXIT_INTR_ERROR_CODE), 0);
+
+ token = vmread(EXIT_QUALIFICATION);
+ reason = READ_ONCE(apf_data.flags);
+ GUEST_ASSERT(token);
+ GUEST_ASSERT_EQ(reason, KVM_PV_REASON_PAGE_NOT_PRESENT);
+
+ /* Release the APF record slot before userspace resolves the page. */
+ WRITE_ONCE(apf_data.flags, 0);
+ GUEST_SYNC_ARGS(STAGE_APF_DELIVERED, token, reason, 0, 0);
+}
+
+#ifdef __NR_userfaultfd
+static int register_missing_page(void *hva)
+{
+ struct uffdio_register uffd_register = {
+ .range = {
+ .start = (uintptr_t)hva,
+ .len = PAGE_SIZE,
+ },
+ .mode = UFFDIO_REGISTER_MODE_MISSING,
+ };
+ struct uffdio_api uffd_api = {
+ .api = UFFD_API,
+ };
+ int uffd;
+
+ uffd = syscall(__NR_userfaultfd, O_CLOEXEC | O_NONBLOCK);
+ if (uffd < 0 && (errno == EPERM || errno == EACCES))
+ ksft_exit_skip("userfaultfd is unavailable: %s\n", strerror(errno));
+ TEST_ASSERT(uffd >= 0, "userfaultfd failed: %s", strerror(errno));
+
+ TEST_ASSERT(ioctl(uffd, UFFDIO_API, &uffd_api) == 0,
+ "UFFDIO_API failed: %s", strerror(errno));
+ TEST_ASSERT(ioctl(uffd, UFFDIO_REGISTER, &uffd_register) == 0,
+ "UFFDIO_REGISTER failed: %s", strerror(errno));
+ TEST_ASSERT(uffd_register.ioctls & (1ULL << _UFFDIO_COPY),
+ "userfaultfd does not support UFFDIO_COPY");
+
+ return uffd;
+}
+
+static bool read_missing_page_event(int uffd, void *target_hva)
+{
+ struct pollfd pollfd = {
+ .fd = uffd,
+ .events = POLLIN,
+ };
+ struct uffd_msg msg;
+ ssize_t bytes;
+ int ret;
+
+ ret = poll(&pollfd, 1, 3000);
+ if (ret != 1 || !(pollfd.revents & POLLIN))
+ return false;
+
+ bytes = read(uffd, &msg, sizeof(msg));
+ if (bytes != sizeof(msg) || msg.event != UFFD_EVENT_PAGEFAULT)
+ return false;
+
+ return (msg.arg.pagefault.address & ~(PAGE_SIZE - 1ULL)) ==
+ (uintptr_t)target_hva;
+}
+
+static void resolve_missing_page(int uffd, void *target_hva)
+{
+ struct uffdio_copy uffd_copy = {
+ .dst = (uintptr_t)target_hva,
+ .len = PAGE_SIZE,
+ };
+ void *page = NULL;
+ int ret;
+
+ ret = posix_memalign(&page, PAGE_SIZE, PAGE_SIZE);
+ TEST_ASSERT(!ret, "posix_memalign failed: %s", strerror(ret));
+ memset(page, 0, PAGE_SIZE);
+ uffd_copy.src = (uintptr_t)page;
+
+ TEST_ASSERT(ioctl(uffd, UFFDIO_COPY, &uffd_copy) == 0,
+ "UFFDIO_COPY failed: %s", strerror(errno));
+ free(page);
+}
+
+static void alarm_handler(int signal)
+{
+ (void)signal;
+}
+
+static void assert_sync_stage(struct kvm_vcpu *vcpu, u64 expected_stage)
+{
+ struct ucall uc;
+
+ TEST_ASSERT_KVM_EXIT_REASON(vcpu, KVM_EXIT_IO);
+ switch (get_ucall(vcpu, &uc)) {
+ case UCALL_SYNC:
+ TEST_ASSERT_EQ(uc.args[1], expected_stage);
+ break;
+ case UCALL_ABORT:
+ REPORT_GUEST_ASSERT(uc);
+ break;
+ default:
+ TEST_FAIL("Unexpected ucall command %lu", uc.cmd);
+ }
+}
+
+static void run_test(void)
+{
+ struct kvm_vcpu_pv_apf_data *apf_hva;
+ struct sigaction action = {
+ .sa_handler = alarm_handler,
+ };
+ struct kvm_vcpu *vcpu;
+ struct kvm_vm *vm;
+ struct ucall uc = {};
+ sigset_t blocked_signals;
+ sigset_t old_signals;
+ gva_t nested_gva;
+ gva_t target_gva;
+ gpa_t apf_gpa;
+ void *target_hva;
+ u32 stale_reason;
+ bool uffd_event;
+ bool run_timed_out;
+ int run_errno = 0;
+ int run_ret;
+ int uffd;
+ u64 ucall_cmd = UCALL_NONE;
+
+ vm = vm_create_with_one_vcpu(&vcpu, l1_guest_code);
+ vm_enable_tdp(vm);
+ vcpu_alloc_vmx(vm, &nested_gva);
+ target_gva = vm_alloc_page(vm);
+ tdp_identity_map_default_memslots(vm);
+
+ l2_target_gva = target_gva;
+ sync_global_to_guest(vm, l2_target_gva);
+ apf_gpa = addr_gva2gpa(vm, (gva_t)&apf_data);
+ TEST_ASSERT(!(apf_gpa & 63U), "APF data GPA %#lx is misaligned",
+ apf_gpa);
+ apf_hva = addr_gva2hva(vm, (gva_t)&apf_data);
+ target_hva = addr_gva2hva(vm, target_gva);
+ vcpu_args_set(vcpu, 2, nested_gva, apf_gpa);
+
+ vcpu_run(vcpu);
+ assert_sync_stage(vcpu, STAGE_APF_ARMED);
+
+ TEST_ASSERT(madvise(target_hva, PAGE_SIZE, MADV_DONTNEED) == 0,
+ "madvise failed: %s", strerror(errno));
+ uffd = register_missing_page(target_hva);
+
+ TEST_ASSERT(sigaction(SIGALRM, &action, NULL) == 0,
+ "sigaction failed: %s", strerror(errno));
+ sigfillset(&blocked_signals);
+ sigdelset(&blocked_signals, SIGALRM);
+ TEST_ASSERT(sigprocmask(SIG_SETMASK, &blocked_signals, &old_signals) == 0,
+ "sigprocmask failed: %s", strerror(errno));
+
+ alarm(RUN_TIMEOUT_SECONDS);
+ run_ret = __vcpu_run(vcpu);
+ run_errno = errno;
+ alarm(0);
+ run_timed_out = run_ret < 0 && run_errno == EINTR;
+ TEST_ASSERT(sigprocmask(SIG_SETMASK, &old_signals, NULL) == 0,
+ "sigprocmask restore failed: %s", strerror(errno));
+
+ stale_reason = READ_ONCE(apf_hva->flags);
+ uffd_event = read_missing_page_event(uffd, target_hva);
+ if (uffd_event)
+ resolve_missing_page(uffd, target_hva);
+ close(uffd);
+
+ if (!run_ret)
+ ucall_cmd = get_ucall(vcpu, &uc);
+ if (ucall_cmd == UCALL_ABORT)
+ REPORT_GUEST_ASSERT(uc);
+ kvm_vm_free(vm);
+
+ TEST_ASSERT(uffd_event,
+ "L2 access did not produce the held userfaultfd fault");
+ TEST_ASSERT(!run_timed_out ||
+ stale_reason == KVM_PV_REASON_PAGE_NOT_PRESENT,
+ "KVM_RUN timed out without a stale APF reason: reason=%u",
+ stale_reason);
+ TEST_ASSERT(!run_timed_out,
+ "Lost nested APF event: KVM re-entered L2 and blocked; APF reason=%u",
+ stale_reason);
+ TEST_ASSERT(!run_ret, "KVM_RUN failed: %s", strerror(run_errno));
+ TEST_ASSERT_EQ(ucall_cmd, UCALL_SYNC);
+ TEST_ASSERT_EQ(uc.args[1], STAGE_APF_DELIVERED);
+ TEST_ASSERT_EQ(uc.args[3], KVM_PV_REASON_PAGE_NOT_PRESENT);
+ TEST_ASSERT(uc.args[2], "KVM returned a zero APF token");
+}
+#endif
+
+int main(int argc, char *argv[])
+{
+ (void)argc;
+ (void)argv;
+
+#ifndef __NR_userfaultfd
+ ksft_exit_skip("__NR_userfaultfd is unavailable\n");
+#else
+ TEST_REQUIRE(kvm_cpu_has(X86_FEATURE_VMX));
+ TEST_REQUIRE(kvm_cpu_has_ept());
+ TEST_REQUIRE(kvm_cpu_has(X86_FEATURE_KVM_ASYNC_PF));
+ TEST_REQUIRE(kvm_cpu_has(X86_FEATURE_KVM_ASYNC_PF_VMEXIT));
+ TEST_REQUIRE(kvm_cpu_has(X86_FEATURE_KVM_ASYNC_PF_INT));
+
+ run_test();
+#endif
+ return 0;
+}
--
2.43.0