
PoC for CVE-2026-53360: guest-triggered heap out-of-bounds read/write in KVM SEV-SNP Page State Change (PSC) handling.
Proof of concept for a heap out-of-bounds read and write in KVM's SEV-SNP Page
State Change (PSC) handling. A malicious SEV-SNP guest makes the host kernel walk
a PSC entry array off the end of its slab allocation. That leaks the layout of
neighbouring kmalloc-cg-32 objects and writes a controlled small value into
them, and the guest can repeat it as often as it likes.
Full writeup: https://cyberstan.co.uk/sev-snp-oob/
| CVE | CVE-2026-53360 |
| Component | KVM SNP host support, arch/x86/kvm/svm/sev.c |
| Introduced | 9b54e248d264 (first KVM SNP PSC handling, May 2024, ~v6.10) |
| Fixed | db3f219 (mainline, May 2026, Cc: stable), tagged Fixes: 4af663c |
| Reported | [email protected], 8 April 2026 |
| Affected | SEV-SNP host path only. KVM does not enable PSC for plain SEV-ES guests. |
Any SEV-SNP guest can corrupt the host kernel's heap and read back information about its layout by sending a malformed PSC request. This is the guest to host direction: SEV-SNP is built to protect the guest from an untrusted host, but the host still has to defend itself against a malicious guest, and this handler does not.
This needs real SEV-SNP hardware. It cannot be reproduced on Intel, and nested virt will not give you an SNP guest.
Hardware:
*.metal,
Hetzner AX, and similar).Host kernel:
CONFIG_KASAN=y
CONFIG_KASAN_GENERIC=y
CONFIG_KVM=y
CONFIG_KVM_AMD=y
CONFIG_KVM_AMD_SEV=y
CONFIG_CRYPTO_DEV_SP_PSP=y
kvm_amd.sev=1 kvm_amd.sev_es=1 kvm_amd.sev_snp=1 kasan_multi_shot
cat /sys/module/kvm_amd/parameters/sev_snp # Y
ls /dev/sev # /dev/sev
Host userspace:
git clone https://github.com/AMDESE/qemu.git
cd qemu && git checkout snp-latest
mkdir build && cd build
../configure --target-list=x86_64-softmmu && make -j$(nproc)
Guest:
build-essential and
linux-headers-$(uname -r) installed so you can build the module in it.SEV-SNP guests talk to the host through the GHCB, a 4 KB shared page. A PSC
request sets SW_EXITCODE to SVM_VMGEXIT_PSC (0x80000010), points SW_SCRATCH
at a descriptor, and puts the descriptor length in SW_EXITINFO2.
The descriptor is a struct psc_buffer: an 8-byte header followed by an array of
8-byte entries. There is no explicit count field. The host processes entries from
hdr->cur_entry to hdr->end_entry, both guest controlled.
struct psc_hdr {
u16 cur_entry;
u16 end_entry;
u32 reserved;
} __packed; /* 8 bytes */
struct psc_entry {
u64 cur_page : 12;
u64 gfn : 40;
u64 operation : 4;
u64 pagesize : 1;
u64 reserved : 7;
} __packed; /* 8 bytes */
A GHCB v2+ guest is supposed to keep its scratch area inside the GHCB's 2032-byte
Shared Buffer, so the host can reuse its existing mapping. (2032 - 8) / 8 = 253
entries fit there, which is where the protocol maximum VMGEXIT_PSC_MAX_COUNT
(253) comes from. That number only makes sense when the buffer really is the
Shared Buffer.
If the guest points the scratch area outside the GHCB, the host cannot use its
mapping, so setup_vmgexit_scratch() allocates a separate buffer of the size the
guest asked for. SNP should never take this path, but nothing stops it:
scratch_va = kvzalloc(len, GFP_KERNEL_ACCOUNT); /* len == exit_info_2, guest-controlled */
len comes straight from the guest, and GFP_KERNEL_ACCOUNT puts the allocation
in the cgroup-accounted kmalloc-cg-N caches. Ask for exit_info_2 = 24 and you
get a 24-byte allocation in the 32-byte kmalloc-cg-32 slot: room for the header
plus two entries. Everything past entries[1] is another object's memory.
Then snp_begin_psc() checks the entry count against the protocol constant, not
against the buffer it actually allocated:
idx_end = hdr->end_entry;
if (idx_end >= VMGEXIT_PSC_MAX_COUNT) { /* checks 253, NOT the buffer size */
snp_complete_psc(svm, ...);
return 1;
}
for (idx = idx_start; idx <= idx_end; idx++) {
entry_start = entries[idx]; /* OOB once idx >= 2 */
...
}
With a 24-byte buffer only two entries exist, but the check allows end_entry up
to 252. Set it to 252 and the loop walks about 2 KB past the allocation, across
neighbouring slab objects.
Each step past the end reinterprets the next 8 bytes of slab memory as a
psc_entry and runs it through the PSC code. That gives three things:
entry.gfn and entry.operation out of memory the buffer never owned. This
is the slab-out-of-bounds read KASAN catches.KVM_HC_MAP_GPA_RANGE, the completion code writes back into the same OOB
slot: entries[idx].cur_page = entry.pagesize ? 512 : 1. One of two small
values into the low 12 bits of a word the guest picks, repeatable.SW_EXITINFO2 reports the index it stopped at. Bumping end_entry one at a
time leaks, slot by slot, whether adjacent memory decoded to a no-op or to
something that failed, which is enough to find object boundaries and tell zero
from non-zero.Each VMGEXIT re-allocates the scratch buffer, so repeated requests land in different freelist slots and let the guest sweep across neighbours rather than being stuck with one. Put together this yields heap layout disclosure, the constrained write above, and use-after-free across requests.
trigger.c is a guest kernel module. Load it inside an SEV-SNP guest and it
drives four stages against the host from a single insmod:
cur_page
into a zero neighbour and confirming a later request skips it.end_entry=200 and measures how far the OOB
read reaches before hitting non-zero data.entries[3..10] out of bounds, each of
which trips a KASAN report on the host.The module allocates a page, marks it decrypted with set_memory_decrypted(),
uses it as the scratch area, and hand-builds the GHCB PSC request. It exits with
-EAGAIN so it does not stay loaded.
Adjust the OVMF and disk paths to your setup: