[PATCH 2/2] KVM: arm64: selftests: Add a nested S2 MMU realloc test

Karl Mehltretter <[email protected]> Tue, 4 Aug 2026 00:44:05 +0200
Newsgroups dev.linux.lists.kvmarm,org.infradead.lists.linux-arm-kernel,org.kernel.vger.kvm,org.kernel.vger.linux-kernel,org.kernel.vger.linux-kselftest
Message-ID <[email protected]>
Add a regression test for a stale vcpu->arch.hw_mmu reference when
initialising a vCPU grows the nested S2 MMU table.

The test drives vCPU0 into L2 through a minimal L1 stage-2 identity map,
pins it to a second pCPU where it spins in L2, and then initialises
vCPU1.  That initialisation grows the nested MMU table while vCPU0 still
holds one of its entries; keeping vCPU0 on a pCPU of its own means the
reference stays live without relying on hw_mmu being retained across a
schedule-out.  vCPU0 is then released and has to run to completion.

Creating vCPU1 up front is what allows the in-kernel VGIC to be used:
kvm_arch_vcpu_precreate() refuses KVM_CREATE_VCPU once the VGIC has been
initialised, which the test does before its first KVM_RUN.  Creation on
its own increments online_vcpus, so deferring vCPU1's KVM_ARM_VCPU_INIT
until vCPU0 is in L2 still grows the table.

With KASAN enabled, an unfixed kernel reports a slab-use-after-free in
kvm_handle_guest_abort(); with the fix it completes cleanly.

Assisted-by: Claude:claude-fable-5
Signed-off-by: Karl Mehltretter <[email protected]>
---

The test requires nested virtualization and two pCPUs.  Under QEMU TCG
it takes ~233s, exceeding the 120s timeout in
tools/testing/selftests/kvm/settings; psci_test takes ~167s in the same
boot, so emulating the second vCPU is the dominant cost.

 tools/testing/selftests/kvm/Makefile.kvm      |   1 +
 .../kvm/arm64/nested_mmu_realloc_test.c       | 278 ++++++++++++++++++
 2 files changed, 279 insertions(+)
 create mode 100644 tools/testing/selftests/kvm/arm64/nested_mmu_realloc_test.c

diff --git a/tools/testing/selftests/kvm/Makefile.kvm b/tools/testing/selftests/kvm/Makefile.kvm
index 6fc34e9bf8e1..222f37cbeec8 100644
--- a/tools/testing/selftests/kvm/Makefile.kvm
+++ b/tools/testing/selftests/kvm/Makefile.kvm
@@ -175,6 +175,7 @@ TEST_GEN_PROGS_arm64 += arm64/host_sve
 TEST_GEN_PROGS_arm64 += arm64/hypercalls
 TEST_GEN_PROGS_arm64 += arm64/external_aborts
 TEST_GEN_PROGS_arm64 += arm64/mmio_sign_ext
+TEST_GEN_PROGS_arm64 += arm64/nested_mmu_realloc_test
 TEST_GEN_PROGS_arm64 += arm64/page_fault_test
 TEST_GEN_PROGS_arm64 += arm64/psci_test
 TEST_GEN_PROGS_arm64 += arm64/sea_to_user
diff --git a/tools/testing/selftests/kvm/arm64/nested_mmu_realloc_test.c b/tools/testing/selftests/kvm/arm64/nested_mmu_realloc_test.c
new file mode 100644
index 000000000000..5eaf24106eee
--- /dev/null
+++ b/tools/testing/selftests/kvm/arm64/nested_mmu_realloc_test.c
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Regression test for a stale vcpu->arch.hw_mmu pointer when the nested
+ * stage-2 MMU table grows while another vCPU is running in L2.  On affected
+ * kernels, KASAN detects the use-after-free on the first post-resize L2 exit.
+ */
+#include "kvm_util.h"
+#include "processor.h"
+#include "test_util.h"
+#include "ucall.h"
+
+#include <asm/ptrace.h>
+#include <asm/sysreg.h>
+#include <errno.h>
+#include <pthread.h>
+#include <sched.h>
+
+enum {
+	STAGE_L1_STARTED,
+	STAGE_L1_S2_READY,
+	STAGE_L2_ENTERED,
+};
+
+/* A 36-bit IPA and 1GB blocks give L1 a 64-entry stage-2 root table. */
+#define L1_S2_IPA_BITS		36
+#define L1_S2_BLOCK_SHIFT	30
+#define L1_S2_BLOCK_SIZE	BIT_ULL(L1_S2_BLOCK_SHIFT)
+#define L1_S2_ROOT_ENTRIES	BIT(L1_S2_IPA_BITS - L1_S2_BLOCK_SHIFT)
+#define L1_S2_SL0		1
+#define L1_S2_VTCR		(VTCR_EL2_RES1 |					\
+				 FIELD_PREP(VTCR_EL2_T0SZ, 64 - L1_S2_IPA_BITS) |	\
+				 FIELD_PREP(VTCR_EL2_SL0, L1_S2_SL0) |		\
+				 FIELD_PREP(VTCR_EL2_IRGN0, VTCR_EL2_IRGN0_WBWA) |	\
+				 FIELD_PREP(VTCR_EL2_ORGN0, VTCR_EL2_ORGN0_WBWA) |	\
+				 FIELD_PREP(VTCR_EL2_SH0, VTCR_EL2_SH0_INNER) |	\
+				 FIELD_PREP(VTCR_EL2_TG0, VTCR_EL2_TG0_4K) |		\
+				 FIELD_PREP(VTCR_EL2_PS, ID_AA64MMFR0_EL1_PARANGE_36))
+#define L1_S2_MEMATTR_NORMAL	(0xfULL << 2)
+#define L1_S2_S2AP_R		BIT(6)
+#define L1_S2_S2AP_W		BIT(7)
+#define L1_S2_BLOCK_DESC(pa)	(((pa) & GENMASK_ULL(47, L1_S2_BLOCK_SHIFT)) |	\
+				 L1_S2_MEMATTR_NORMAL | L1_S2_S2AP_R |		\
+				 L1_S2_S2AP_W | PTE_SHARED | PTE_AF | PTE_VALID)
+
+struct test_state {
+	u32 running;
+	u32 release;
+	u32 resumed;
+	u32 done;
+};
+
+struct vcpu_thread_args {
+	struct kvm_vcpu *vcpu;
+	int cpu;
+};
+
+#define copy_el2_to_el1(reg)						\
+	write_sysreg_s(read_sysreg_s(SYS_##reg##_EL1), SYS_##reg##_EL12)
+
+static void l2_guest(struct test_state *state)
+{
+	GUEST_ASSERT_EQ(get_current_el(), 1);
+	GUEST_SYNC(STAGE_L2_ENTERED);
+
+	WRITE_ONCE(state->running, 1);
+	while (!READ_ONCE(state->release))
+		cpu_relax();
+
+	WRITE_ONCE(state->resumed, 1);
+	while (!READ_ONCE(state->done))
+		cpu_relax();
+
+	GUEST_DONE();
+}
+
+static void l1_guest(u64 l2_pc, u64 state_gva, u64 s2_root_gpa)
+{
+	u64 sp;
+
+	GUEST_SYNC(STAGE_L1_STARTED);
+
+	copy_el2_to_el1(SCTLR);
+	copy_el2_to_el1(MAIR);
+	copy_el2_to_el1(TCR);
+	copy_el2_to_el1(TTBR0);
+	copy_el2_to_el1(TTBR1);
+
+	asm volatile("mov %0, sp" : "=r" (sp));
+	write_sysreg(sp, sp_el1);
+
+	write_sysreg(l2_pc, elr_el2);
+	write_sysreg(PSR_MODE_EL1h | PSR_D_BIT | PSR_A_BIT |
+		     PSR_I_BIT | PSR_F_BIT, spsr_el2);
+
+	write_sysreg(s2_root_gpa, vttbr_el2);
+	write_sysreg(L1_S2_VTCR, vtcr_el2);
+	isb();
+
+	GUEST_SYNC(STAGE_L1_S2_READY);
+
+	sysreg_clear_set(hcr_el2, HCR_EL2_TGE, HCR_EL2_VM);
+	isb();
+
+	asm volatile("mov x0, %0\n"
+		     "eret\n"
+		     :
+		     : "r" (state_gva)
+		     : "x0", "memory");
+
+	GUEST_ASSERT(0);
+}
+
+/* Sleep to avoid competing with the vCPU; the runner times out stalled tests. */
+static void wait_for_u32(u32 *ptr, u32 val)
+{
+	while (READ_ONCE(*ptr) != val)
+		usleep(1000);
+}
+
+static int pick_two_cpus(int *first_cpu, int *second_cpu)
+{
+	cpu_set_t allowed_mask;
+	int ret;
+
+	ret = sched_getaffinity(0, sizeof(allowed_mask), &allowed_mask);
+	TEST_ASSERT(!ret, "sched_getaffinity() failed, errno=%d", errno);
+
+	*first_cpu = -1;
+	*second_cpu = -1;
+
+	for (int cpu = 0; cpu < CPU_SETSIZE; cpu++) {
+		if (!CPU_ISSET(cpu, &allowed_mask))
+			continue;
+
+		if (*first_cpu < 0)
+			*first_cpu = cpu;
+		else
+			*second_cpu = cpu;
+
+		if (*second_cpu >= 0)
+			return 1;
+	}
+
+	return 0;
+}
+
+/* Mirror KVM's fallback from TGRAN4_2 to TGRAN4. */
+static bool l1_s2_supports_4k(struct kvm_vcpu *vcpu)
+{
+	u64 mmfr0 = vcpu_get_reg(vcpu, KVM_ARM64_SYS_REG(SYS_ID_AA64MMFR0_EL1));
+	u64 s2 = SYS_FIELD_GET(ID_AA64MMFR0_EL1, TGRAN4_2, mmfr0);
+
+	if (s2 == ID_AA64MMFR0_EL1_TGRAN4_2_TGRAN4)
+		return SYS_FIELD_GET(ID_AA64MMFR0_EL1, TGRAN4, mmfr0) !=
+		       ID_AA64MMFR0_EL1_TGRAN4_NI;
+
+	return s2 != ID_AA64MMFR0_EL1_TGRAN4_2_NI;
+}
+
+static void build_l1_s2_idmap(struct kvm_vm *vm, gpa_t root_gpa)
+{
+	u64 *root = addr_gpa2hva(vm, root_gpa);
+
+	for (int i = 0; i < L1_S2_ROOT_ENTRIES; i++)
+		root[i] = L1_S2_BLOCK_DESC((u64)i * L1_S2_BLOCK_SIZE);
+}
+
+static void run_to_sync_stage(struct kvm_vcpu *vcpu, u64 expected_stage)
+{
+	struct ucall uc;
+
+	vcpu_run(vcpu);
+
+	switch (get_ucall(vcpu, &uc)) {
+	case UCALL_SYNC:
+		TEST_ASSERT_EQ(uc.args[1], expected_stage);
+		return;
+	case UCALL_ABORT:
+		REPORT_GUEST_ASSERT(uc);
+		return;
+	default:
+		TEST_FAIL("Unexpected ucall: %lu", uc.cmd);
+	}
+}
+
+static void *vcpu_thread_main(void *data)
+{
+	struct vcpu_thread_args *args = data;
+	struct kvm_vcpu *vcpu = args->vcpu;
+	struct ucall uc;
+
+	pin_self_to_cpu(args->cpu);
+
+	vcpu_run(vcpu);
+
+	switch (get_ucall(vcpu, &uc)) {
+	case UCALL_DONE:
+		return NULL;
+	case UCALL_ABORT:
+		REPORT_GUEST_ASSERT(uc);
+		return NULL;
+	default:
+		TEST_FAIL("Unexpected ucall: %lu", uc.cmd);
+	}
+}
+
+int main(void)
+{
+	struct vcpu_thread_args thread_args;
+	struct kvm_vcpu_init init;
+	struct test_state *state;
+	struct kvm_vcpu *vcpu0;
+	struct kvm_vcpu *vcpu1;
+	struct kvm_vm *vm;
+	pthread_t thread;
+	gva_t state_gva;
+	gpa_t s2_root_gpa;
+	int ctrl_cpu, vcpu_cpu, ret;
+
+	TEST_REQUIRE(kvm_check_cap(KVM_CAP_ARM_EL2));
+	TEST_REQUIRE(kvm_check_cap(KVM_CAP_ARM_VM_IPA_SIZE) >= L1_S2_IPA_BITS);
+
+	TEST_REQUIRE(pick_two_cpus(&ctrl_cpu, &vcpu_cpu));
+	pin_self_to_cpu(ctrl_cpu);
+	pr_info("Running control thread on pCPU %d, vCPU thread on pCPU %d\n",
+		ctrl_cpu, vcpu_cpu);
+
+	vm = vm_create(2);
+
+	kvm_get_default_vcpu_target(vm, &init);
+	init.features[0] |= BIT(KVM_ARM_VCPU_HAS_EL2);
+
+	vcpu0 = aarch64_vcpu_add(vm, 0, &init, l1_guest);
+	TEST_REQUIRE(l1_s2_supports_4k(vcpu0));
+
+	vcpu1 = __vm_vcpu_add(vm, 1);
+	state_gva = vm_alloc_page(vm);
+	state = addr_gva2hva(vm, state_gva);
+	*state = (struct test_state) {};
+	s2_root_gpa = vm_phy_page_alloc(vm, 0, vm->memslots[MEM_REGION_TEST_DATA]);
+	build_l1_s2_idmap(vm, s2_root_gpa);
+
+	vcpu_args_set(vcpu0, 3, (u64)l2_guest, state_gva, s2_root_gpa);
+	kvm_arch_vm_finalize_vcpus(vm);
+
+	run_to_sync_stage(vcpu0, STAGE_L1_STARTED);
+	run_to_sync_stage(vcpu0, STAGE_L1_S2_READY);
+	run_to_sync_stage(vcpu0, STAGE_L2_ENTERED);
+
+	thread_args = (struct vcpu_thread_args) {
+		.vcpu = vcpu0,
+		.cpu = vcpu_cpu,
+	};
+	ret = pthread_create(&thread, NULL, vcpu_thread_main, &thread_args);
+	TEST_ASSERT(!ret, "Failed to create vCPU thread, ret=%d", ret);
+
+	wait_for_u32(&state->running, 1);
+	pr_info("vCPU0 is running in L2; initializing vCPU1 to grow the table\n");
+
+	/*
+	 * vCPU0 holds a nested MMU while vCPU1 initialization grows the table.
+	 * vCPU1 was created before VGIC initialization because KVM_CREATE_VCPU is
+	 * refused afterwards.
+	 */
+	aarch64_vcpu_setup(vcpu1, &init);
+
+	pr_info("vCPU1 initialized; releasing vCPU0\n");
+	WRITE_ONCE(state->release, 1);
+	wait_for_u32(&state->resumed, 1);
+	pr_info("vCPU0 resumed after nested MMU resize\n");
+
+	WRITE_ONCE(state->done, 1);
+	ret = pthread_join(thread, NULL);
+	TEST_ASSERT(!ret, "pthread_join() failed, ret=%d", ret);
+
+	kvm_vm_free(vm);
+	return 0;
+}
-- 
2.39.5 (Apple Git-154)