Kernel KVM virtualization development
 help / color / mirror / Atom feed
From: David Ballesteros <davimaba.v@proton.me>
To: pbonzini@redhat.com
Cc: kvm@vger.kernel.org, security@kernel.org,
	David Ballesteros <davimaba.v@proton.me>
Subject: [PATCH v3 0/2] KVM: unbounded per-VM kernel memory growth via KVM_SET_MEMORY_ATTRIBUTES
Date: Fri, 11 Sep 2026 22:13:11 +0000	[thread overview]
Message-ID: <20260911221302.53013-1-davimaba.v@proton.me> (raw)
In-Reply-To: <20260911203238.30088-1-davimaba.v@proton.me>

The report and series are intentionally public (AI-assisted finding, cf.
Documentation/process/security-bugs.rst).

Changes in v3 (from review of v2):

- The clear path now iterates with xa_for_each_range(), which takes
  and releases the RCU lock on every step.  v2 held a single RCU
  read-side critical section across the whole loop, which made
  cond_resched() illegal on non-PREEMPT_RCU configurations and
  wrongly spanned the post-set MMU invalidation handler.  The
  per-step re-find also makes erasing the current entry safe with
  plain xa_erase(); v2 mixed xa_erase() into a lockless xas_for_each()
  iteration whose state could point at nodes the erase unlinked.
- The set path charges the budget exactly, using xa_cmpxchg() (i.e.
  xa_reserve() with the old entry returned) so only GFNs that were
  actually absent are counted.  v2 charged the full range of every
  request, so overlapping sets overcounted permanently and a caller
  could exhaust its own budget with an empty array.

Changes in v2 (from review of v1):

- Clear path reworked from reserve-then-store-NULL to erase-only,
  closing a bound bypass (a clear of a partially populated range went
  through xa_reserve() for the whole range with no bound check).
- Counter maintained incrementally instead of a per-mutating-ioctl
  rescan of the whole array.


Affected version/commit: v7.2-rc5 (commit 699594e8888d is not needed here;
verified on v7.2-rc5 as of 2026-09-09 and on v6.18.48; the code paths are
unchanged between them and present in mainline. The ioctl was introduced
in v6.8 with the per-page memory attributes series).

Problem
-------

kvm_vm_ioctl_set_mem_attributes() (virt/kvm/kvm_main.c) validates the
user-provided range only for representability (zero size, 64-bit wrap,
alignment, supported attributes) and not for magnitude.  With attributes
!= 0, kvm_vm_set_mem_attributes() then walks every GFN in the range:

        for (i = start; i < end; i++) {
                r = xa_reserve(&kvm->mem_attr_array, i, GFP_KERNEL_ACCOUNT);
                if (r)
                        goto out_unlock;
                cond_resched();
        }

xa_reserve() materializes the full root-to-leaf path of xa_nodes for each
GFN even though the GFNs need no backing memory and no memslots need to
exist.  Each xa_node is 576 bytes from radix_tree_node_cachep; densely
filled, the tree costs ~9.3 bytes per GFN.  On ENOMEM the loop bails out
without undoing prior reservations, and the only release path is
kvm_destroy_vm(); the memory is therefore retained across calls and lives
for as long as the VM fd.

Additionally, the intended memcg accounting does not take effect: the
nodes are allocated with GFP_NOWAIT in xas_alloc(), gaining __GFP_ACCOUNT
only when the xarray carries XA_FLAGS_ACCOUNT, which kvm does not set
(kvm_create_vm() uses plain xa_init()).  Measured: a process inside a
cgroup limited to 256 MiB grew host SReclaimable by ~450 MiB while its
memory.current stayed at ~488 KiB.

Triggering conditions
---------------------

- An unprivileged user with write access to /dev/kvm (sysfs default and
  the common udev default are 0666).
- A VM whose type sets private memory support: sw protected VM
  (CONFIG_KVM_SW_PROTECTED_VM, depends on EXPERT), TDX or SNP.
- No memslots, no guest memory and no guest CPUs are required.
- Verified outcomes on v6.18.48 (KASAN builds, isolated VMs):
  * ~9.29 bytes/GFN and ~75 MiB/s of sustained growth per VM; linear
    scaling with parallel VMs (4 VMs: 3.56x).
  * global OOM killing unrelated third-party processes while the
    attacker's own memcg stays uncharged.
  * when all surviving processes are OOM-disabled, select_bad_process()
    finds no candidate and the kernel panics ("System is deadlocked on
    memory", mm/oom_kill.c).  Reproduced twice.
  * one ioctl holds kvm->slots_lock for the whole reservation loop, so a
    large range blocks KVM_GET_DIRTY_LOG and memslot updates of that VM
    for the duration (measured GET_DIRTY_LOG latency 1us -> 7.7ms with a
    single concurrent call); KVM_RUN is unaffected.
  * replicated through a real container runtime (podman/crun) with
    --memory 512m: the container grew host slab by ~450 MiB while its
    memory.current stayed at ~488 KiB.

Reproducer status
-----------------

A reproducer is available on request (KVM_CREATE_VM + the ioctl in a
loop, plus slab/ accounting measurement), but following security-bugs.rst it is
not attached to this report.

Test environment
----------------

All dynamic results were collected in isolated inspection VMs on a
single AMD host with nested virtualization enabled
(kvm_amd nested=1): QEMU -cpu host, Debian 13 guests, guest kernels
built from the audited tree (v6.18.48) and from v7.2-rc5 with
CONFIG_KVM_SW_PROTECTED_VM=y / CONFIG_KVM_AMD=y / CONFIG_EXPERT=y and
KASAN (with CONFIG_KASAN_VMALLOC=y where noted); the PoC runs as root
inside the guest, host networking restricted (user-mode net,
restrict=on).  Nothing in the host kernels themselves was modified or
attacked.  The container replication used podman 5.4.2/crun inside the
guest with the host kernel having the gate enabled (a stock host kernel
without KVM_SW_PROTECTED_VM rejects KVM_CREATE_VM and the surface is
absent, as expected).

Proposed fix
------------

Patch 1/2 bounds the number of attribute entries materialized per VM
to KVM_MEM_ATTR_MAX_GFNS (2^25 GFNs = 128 GiB of GPA, ~300 MiB of
xa_nodes), enforced under kvm->slots_lock after the idempotency
early-out.  Clear requests are reworked to erase-only iteration over
the present entries in the range: erasing cannot allocate, so a clear
cannot fail partway and cannot materialize new entries (this closes a
bound bypass present in the first revision, where a clear of a
partially populated range still went through xa_reserve() unchecked).
The per-VM counter is maintained incrementally: sets charge the full
range, clears subtract the exact number of entries erased, and a full
rescan runs only on the ENOMEM path.  Runtime-verified on v7.2-rc5
(KASAN build, isolated VM): growth stops with a clean -ENOSPC at 2^25
GFNs; eight consecutive clears of 2^52-GFN ranges containing a
populated GFN leave Slab unchanged; 50 alternating 4 KiB set/clear
conversions at a near-full array take 0.01 ms each; clearing a range
returns budget exactly.

Patch 2/2 restores the intended memcg accounting with a one-liner:

-       xa_init(&kvm->mem_attr_array);
+       xa_init_flags(&kvm->mem_attr_array, XA_FLAGS_ACCOUNT);

Design notes for reviewers: the budget value (2^25 GFNs) is a trade-off
— it is well above any current consumer's usage but small next to the
GPA space TDX/SNP guests can have; a tunable or a symmetric decrement on
clear are alternatives if maintainers prefer.  The -ENOSPC errno
distinguishes the bound from the malformed-input -EINVAL paths.

Why a constant and not a derived bound?

We considered bounding materialization by what the VM has actually
declared instead of a constant, and rejected it because the declaration
itself is not proportional.  Two candidate signals:

- Memslot coverage ("reject ranges not covered by any memslot"):
  semantically attractive — attributes on unbacked GFNs are inert state
  with no consumer (kvm_handle_gfn_range only walks real memslots), and
  the only in-tree consumer (tools/testing/selftests/kvm, private_mem_
  conversions_test.c) sets attributes strictly inside its gmem memslot.
  But it is not a bound: a memslot requires only a MAP_NORESERVE userspace
  VMA (no committed pages); its kernel cost is the lpage_info metadata
  (4 bytes/entry, __vcalloc proportional to npages: ~16 MiB for a
  maximum 8 TiB memslot) while the same memslot's GPA span materializes
  ~19.9 GiB of xa_nodes — a ~1200:1 amplification of declared metadata.
  Three or four maximum-size NORESERVE memslots suffice to exhaust a
  64 GiB host.  As a semantic follow-up that eliminates the zero-setup
  variant of the attack it is worth considering on top of the bound.

- Sum of memslot pages as a dynamic budget: same flaw — the attacker
  raises his own budget by declaring free memslots; without a constant
  the ceiling becomes the physical address space.

Per-VM accounting to the caller's memcg (patch 2/2) attributes the
memory but bounds only cgroup-limited tenants; with the common
/dev/kvm 0666 default and unlimited cgroups it does not bound the host.
Hence the layering we propose: accounting (2/2) + hard per-VM bound
(1/2), with memslot-coverage rejection as an optional semantic layer
the maintainers may prefer on top.

Mitigations
-----------

- Restrict /dev/kvm (mode 0660 root:kvm) where the default 0666 is in
  place.
- Monitor SReclaimable and /sys/kernel/slab/radix_tree_node/objects, not
  SUnreclaim: the growth is reclaimable-accounted and never reclaimed.
- Per-tenant memcg limits do NOT mitigate the common path today (see
  above), which is why the counter bound is the effective fix.

Notes
-----

- Not verified: the TDX/SNP hardware paths end-to-end (no hardware);
  the software-protected path was verified end-to-end on both versions.

--
2.55.0



  parent reply	other threads:[~2026-09-11 22:13 UTC|newest]

Thread overview: 16+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-09-11 18:48 [PATCH 0/2] KVM: unbounded per-VM kernel memory growth via KVM_SET_MEMORY_ATTRIBUTES David Ballesteros
2026-09-11 18:48 ` [PATCH 1/2] KVM: Bound per-VM GFN materialization in KVM_SET_MEMORY_ATTRIBUTES David Ballesteros
2026-09-11 19:05   ` sashiko-bot
2026-09-11 18:48 ` [PATCH 2/2] KVM: Account mem_attr_array nodes to the caller's memcg David Ballesteros
2026-09-11 19:02   ` sashiko-bot
2026-09-11 20:32 ` [PATCH v2 0/2] KVM: unbounded per-VM kernel memory growth via KVM_SET_MEMORY_ATTRIBUTES David Ballesteros
2026-09-11 21:19   ` David Ballesteros
2026-09-11 22:13   ` David Ballesteros [this message]
2026-09-11 22:13     ` [PATCH v3 1/2] KVM: Bound per-VM GFN materialization in KVM_SET_MEMORY_ATTRIBUTES David Ballesteros
2026-09-11 22:32       ` sashiko-bot
2026-09-11 22:13     ` [PATCH v2 2/2] KVM: Account mem_attr_array nodes to the caller's memcg David Ballesteros
2026-09-11 20:32 ` [PATCH v2 1/2] KVM: Bound per-VM GFN materialization in KVM_SET_MEMORY_ATTRIBUTES David Ballesteros
2026-09-11 20:45   ` sashiko-bot
2026-09-11 20:32 ` [PATCH v2 2/2] KVM: Account mem_attr_array nodes to the caller's memcg David Ballesteros
2026-09-11 20:44   ` sashiko-bot
2026-09-12  8:48 ` [syzbot ci] Re: KVM: unbounded per-VM kernel memory growth via KVM_SET_MEMORY_ATTRIBUTES syzbot ci

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260911221302.53013-1-davimaba.v@proton.me \
    --to=davimaba.v@proton.me \
    --cc=kvm@vger.kernel.org \
    --cc=pbonzini@redhat.com \
    --cc=security@kernel.org \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox