The full writeup behind Bypassing VM detection. Internal specifics are scrubbed; everything here is generic enough to run on your own gear.
Harden a KVM guest until an independent detection library reports bare metal, and tune it without giving up any of the hiding. Covers AMD and Intel hosts, Windows 11 guests, libvirt 10.x+ with QEMU 11.x, and patched KVM modules for the last three software detections. Includes the measurement harness, because a hidden guest that stutters is a failed guest.
Revised 2026-09-05: the guest now scores 1/85 on both VMAware v2.8.1 and HEAD, the packages are three AUR-shaped packages with DKMS modules, and section 19's earlier claim that
TIMERwas unfixable was wrong - see section 19 for what changed.
1. Overview#
A stock hardened KVM guest scores around 10/85 on VMAware. This gets it to 1/85, where the library's own conclusion reads Running on bare metal with VM confirmation: false and a likeliness of 20%. The one survivor is the GPU gamma-ramp check, which needs a real card passed through.
The work splits into four layers, each of which can be taken alone:
- Guest settings. Disable the guest's own hypervisor and VBS. Free, and it makes the VM faster.
- Domain XML. Hide the hypervisor bit, correct the CPU feature set, size and pin the vCPUs.
- Patched QEMU. Rewrite the device and firmware identity QEMU stamps on everything.
- Patched KVM modules. Two architectural divergences that are guest-observable.
What works:
- 2/85 with the full stack, verdict flipped to bare metal, brand unresolvable.
- 7/85 with domain XML only - no patched binaries, so nothing to maintain.
- Hardening costs no measurable performance against an unhardened, fully-enlightened ceiling.
- Both KVM patches are architectural corrections, written to be acceptable upstream.
- Everything reverts. No host resource is held while the guest is off.
What does not:
TIMERis structural on AMD. It measures the world switch itself, on two independent conditions that OR together, and both are over threshold here; see section 19.- A guest sized to every host thread boots with a slow clock about half the time; see section 14.
GPU_CAPABILITIESneeds a real GPU passed through. It is oneGetDeviceCapscall, not a GPU enumeration.- Intel hosts are covered for tuning and XML, but the two KVM patches are AMD-only - they touch
svm/. Intel would needvmx/equivalents that do not exist yet.
Risk note: running a game with kernel-level anti-cheat from a hardened VM breaks its terms of service, and a hardware ban is not appealable and can travel across a publisher's whole catalogue. Nothing here changes that. Use an account you are willing to lose.
2. The Short Path#
Everything in this guide is packaged. If you want the result and not the reasoning, this section is the whole procedure and the rest of the page is why each piece is there.
git clone https://git.archworks.co/sandwich/vfio-native
cd vfio-native/packaging
(cd vfio-native && makepkg -si)
(cd vfio-native-qemu && makepkg -si)
(cd vfio-native-kvm-dkms && makepkg -si)| Package | |
|---|---|
vfio-native | vm-native-setup, vm-native-verify, vm-native-gpu, the ACPI tables, the benchmark, the libvirt hook |
vfio-native-qemu | QEMU 11.1.1 with the platform-identity patches, in /opt/qemu-native |
vfio-native-kvm-dkms | the patched KVM modules, rebuilt by DKMS for every installed 7.2.x kernel |
The modules are built but not loaded. With every VM off:
sudo modprobe -r kvm_amd kvm && sudo modprobe kvm_amdThen, once, inside the guest - this is the single most valuable change on the page and it makes the VM faster (section 6):
bcdedit /set hypervisorlaunchtype off
Disable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -NoRestart
reg add "HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard" /v EnableVirtualizationBasedSecurity /t REG_DWORD /d 0 /fReboot twice, uninstall the QEMU and SPICE guest agents, and move the disk to emulated NVMe with a <serial>. Then, once, on the host:
sudo cpupower frequency-set -g performanceNow configure the domain. It has to be shut off first:
virsh -c qemu:///system shutdown win11
vm-native-setupIt is an interview with a default for every answer, and every flag answers one in advance. It reads the host CPU layout itself - AMD CCDs or Intel P/E cores - gives the guest the cache domain with the most L3, pins each vCPU onto a real SMT pair, keeps the emulator off the vCPU cores, moves the disks to emulated NVMe, replaces the virtio device set, wires Secure Boot with a key store it generates, writes the SMBIOS and ACPI identity, and gives the guest a CPU identity from the host's own generation whose thread count matches what it actually has. -r gives the deployment its own serials and MAC, -u auto passes keyboard and mouse through. Scripted:
vm-native-setup -d win11 -p full -c 8 -m 16 -r -yThe guest side, in the order that boots (hypervisor off, SSH and RDP on, stornvme boot-start before the disk moves, virtio drivers out after), is docs/GUEST-SETUP.md in the repo.
It backs the domain up before touching it and prints the revert command. Re-running it is safe and produces the same XML.
Boot the guest, leave it alone for a minute, then switch on the CPUID passthrough with the two lines vm-native-setup printed - after boot, never before, and off again (echo N) before the next boot:
echo 'AMD Ryzen 7 7700X 8-Core Processor' | sudo tee /sys/module/kvm_amd/parameters/brand_string
echo Y | sudo tee /sys/module/kvm_amd/parameters/cpuid_passthroughand measure:
vm-native-verifykvm_amd cpuid_passthrough=Y brand='AMD Ryzen 7 7700X 8-Core Processor'
results:
OK QPC cost (ns) 14.5
OK rdtsc cost (ns) 6.6
OK 1-thread (Mops) 5012.1
OK L3 latency (ns) 10.27
OK jitter p99.99 (us) 1.400
OK stalls >100us 0If QPC comes back over 1000 ns, do not change anything yet - reboot and measure again. That reading is a race, and section 14 is about why.
For the detection score, run VMAware in the guest from the console session rather than over SSH. Section 3 explains why that distinction decides whether the number is real.
Not on Arch? The scripts in scripts/ are plain shell with no packaging dependency, and the KVM sources build as arch/x86/kvm against any 7.2.x tree. The packaging is convenience.
3. Scoring Methodology#
Every hardening project ships a checklist of the things it already fixed. Scoring against that is circular.
VMAware is the independent library those projects are written against. It runs 85 techniques and, in the debug build, prints why each one fired.
vmaware_debug.exe --all --no-ansiThree rules make the difference between a real number and a comfortable one.
3.1 Score from the console session#
OpenSSH on Windows drops you in session 0, the services session. That is not where a game runs and it has no real display. Run the scanner as a scheduled task instead:
schtasks /create /tn VMAware /tr "C:\path\run.cmd" /sc once /st 00:00 /it /rl highest /f
schtasks /run /tn VMAwareschtasks /run reports success regardless of where the task actually executed, so confirm from inside it:
(Get-Process -Id $PID).SessionId # must be 1Run elevated either way; several checks need it.
3.2 Read the debug line, not the count#
[ DETECTED ] with no debug line above it does not mean cleared - some checks return silently. And a check firing with an unexpected string usually means your model of it is wrong rather than your fix being incomplete.
3.3 One change per boot#
The ACPI failure mode is a guest that powers itself off after roughly 70 seconds having read nothing from disk. No error, no log line. The only way to attribute it is to have changed exactly one thing.
Snapshot before any firmware change:
qemu-img snapshot -c pre-acpi-$(date +%Y%m%d) /path/to/guest.qcow24. Prerequisites#
4.1 Host#
- AMD CPU with SVM, or Intel with VT-x and VT-d. The two KVM patches are AMD-only.
- IOMMU enabled in firmware,
amd_iommu=on iommu=ptorintel_iommu=onon the kernel command line. - libvirt 10.x or newer, QEMU 11.x for the patch series as published.
mingw-w64-gccif you want to build the benchmark yourself.
4.2 Guest#
- Windows 11, UEFI, on an emulated NVMe disk. The patched QEMU changes a PCI ID that Linux firmware uses to recognise virtio disks, so a virtio-backed guest stops booting.
- No QEMU or SPICE guest agents installed.
4.3 Packages#
sudo pacman -S --needed qemu-full libvirt edk2-ovmf swtpm dnsmasq virt-manager \
mingw-w64-gcc cpupower5. Hardening Levels#
Three levels. All get identical performance tuning; the level changes only how well the guest hides.
| Level | Score | Requires | Upkeep |
|---|---|---|---|
| tuned | 13/85 | nothing | none |
| hidden | 7/85 | nothing | none |
| full | 2/85 | patched QEMU + patched KVM modules | rebuild modules after each kernel update |
Measured on one guest with one set of tuning, changing only the level.
hidden is domain XML only. It survives any host update untouched and is the right default for anyone who would rather not maintain patched binaries.
full adds the patched QEMU and the two KVM patches. It is the only level that reaches bare-metal, and the only one with ongoing maintenance.
6. Guest Preparation#
Do this before anything else. It is the single most valuable change in the whole guide and it makes the VM faster, because VBS costs 5-15% in games.
If Windows runs its own hypervisor it reports that truthfully, and nothing on the host side can hide it.
bcdedit /set hypervisorlaunchtype off
Disable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V-All -NoRestart
reg add "HKLM\SYSTEM\CurrentControlSet\Control\DeviceGuard" /v EnableVirtualizationBasedSecurity /t REG_DWORD /d 0 /fReboot twice. Windows needs the second one to finish removing Hyper-V.
Then:
- Uninstall the QEMU and SPICE guest agents.
qemu-ga.exeandvdagent.exeare direct tells. You lose clipboard sharing and auto-resize. - Clear the leftover device records. Windows remembers every device it has ever seen, and removing a virtual device does not remove its registry entry.
- Move the disk to
bus='nvme', delete the disk's<address>, add a<serial>. The serial is required and doubles as a fake disk serial.
6.1 Fast Startup will bugcheck you#
A hybrid shutdown resumes the old kernel session against new ACPI tables. Before any firmware change, force a cold boot - but do not use powercfg /h off, because deleting hiberfil.sys re-arms the sleep-state detection.
reg add "HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Power" /v HiberbootEnabled /t REG_DWORD /d 0 /fpowercfg /a should still list Hibernate and S3, with Fast Startup gone.
7. Domain XML: CPU#
<vcpu placement='static'>16</vcpu>
<cpu mode='host-passthrough' check='partial' migratable='off'>
<topology sockets='1' dies='1' clusters='1' cores='8' threads='2'/>
<feature policy='disable' name='hypervisor'/>
<feature policy='require' name='topoext'/>
<feature policy='require' name='invtsc'/>
<feature policy='disable' name='spec-ctrl'/>
<feature policy='disable' name='stibp'/>
<feature policy='disable' name='ssbd'/>
</cpu>
<features>
<acpi/>
<apic/>
<kvm><hidden state='on'/></kvm>
<vmport state='off'/>
<smm state='on'/>
</features>
<pm>
<suspend-to-mem enabled='yes'/>
<suspend-to-disk enabled='yes'/>
</pm>topoext is required on AMD or the guest does not see SMT correctly. Intel does not need it.
The <pm> block matters on its own: a machine with every sleep state disabled is a pattern no physical PC has.
7.1 The mitigation features#
The three disabled mitigation features are not a security decision. VMAware's AMD path reads CPUID leaf 7 subleaf 0, EDX and fires if bit 26, 27 or 31 is set, because AMD reserves those to zero and enumerates the same mitigations in 0x80000008.EBX instead.
KVM synthesises the Intel-style interface on AMD, and host-passthrough dutifully enables it. Measured on a Zen 4 host:
| leaf 7.0 EDX bits 26/27/31 | 0x80000008.EBX 12/14/15/24/25 | |
|---|---|---|
| Bare metal | 0 0 0 | 1 1 1 1 1 |
| Guest, before | set by KVM | passed through |
| Guest, after | 0 0 0 | 1 1 1 1 1 |
The guest keeps every AMD-native mitigation bit, which is the interface Windows actually uses on AMD. Nothing is lost. All three lines are required - the check ORs the bits, so clearing two still fires.
7.2 Do not disable svm#
Common advice says to remove svm from the guest CPU. Do not. The SVM_EXCEPTIONS check compares what CPUID claims about SVM against what VMLOAD actually does, and a guest that hides svm while still faulting like an SVM-enabled one reads as "SVM hypervisor hiding CPU capabilities" - a heavier detection than the one you started with. Fix the exception instead (section 12).
8. Domain XML: Memory#
Do not carry a <memoryBacking> block with memfd and shared unless you actually have a virtiofs share. It is a common leftover, and it silently blocks transparent hugepages: shared mappings are not anonymous, so THP-for-anon never applies and the entire guest runs on 4 KiB pages.
Measured on an 8 GiB guest:
| Memory backing | AnonHugePages |
|---|---|
memfd + shared | 0 MiB |
| none | 8110 MiB |
Check it directly:
pid=$(pgrep -f "[q]emu-system-x86_64" | head -1)
sudo awk '/AnonHugePages/ {s+=$2} END {print s/1024 " MiB"}' /proc/$pid/smapsNote the [q] - an unbracketed pgrep -f matches your own shell and hands you the wrong PID.
Static hugepages are deliberately not used. They reserve host memory permanently, THP already covers the guest completely once the shared backing is gone, and a guest that is switched off should leave the host whole.
9. Domain XML: Firmware Identity#
A real PC reports a motherboard, a BIOS, fans and temperature sensors. A stock VM reports none of it, and the absence is itself the tell.
These work on stock QEMU - no patched build needed.
| Argument | Purpose |
|---|---|
-smbios type=0 | BIOS vendor, version, date |
-smbios type=1 | System manufacturer, product, serial, UUID |
-smbios type=2 | Baseboard - the motherboard a real machine reports |
-smbios type=3 | Chassis |
-smbios type=4 | Processor. Keep in step with the CPUID brand string, section 9.1 |
-smbios type=17 | Memory module - manufacturer, part number, speed |
-smbios file=type7-*.bin | L1/L2/L3 cache descriptors; cannot be expressed on the command line |
-smbios file=type26/27/28/29.bin | Voltage, cooling, temperature and current probes |
-acpitable file=*.aml | Thermal zones, fans, a battery |
The five file= tables are raw SMBIOS structures that QEMU's smbios_entry_add() cannot build from structured CLI arguments. scripts/generate-tables.py emits them.
Take the values from a real machine with dmidecode, and change the serial numbers - you do not want a real board serial reaching a game vendor.
9.1 The CPUID brand string#
Sizing the guest to 16 threads (section 14) contradicts a 16-core part. The thread-count check reads the CPUID brand string, not SMBIOS, so the injected SMBIOS type 4 string is irrelevant to it.
Claim a CPU that genuinely has that many threads. An 8-core, 16-thread part is honest for a 16-vCPU guest pinned to one cache domain.
libvirt has no XML element for the brand string, so it goes in as a second -cpu:
<qemu:arg value='-cpu'/>
<qemu:arg value='host,migratable=off,hypervisor=off,topoext=on,invtsc=on,spec-ctrl=off,stibp=off,ssbd=off,kvm=off,model-id=AMD Ryzen 7 7700X 8-Core Processor'/>Two traps:
- A second
-cpureplaces the first outright. It does not merge. Every flag libvirt generates has to be repeated or it is silently dropped. Verify againstvirsh domxml-to-native --format qemu-argvafter any change to the<cpu>block. - It is incompatible with
<hyperv>, because it would wipe everyhv-*flag libvirt adds. That only matters at thetunedlevel, which does not use the override.
The domain also needs the QEMU namespace on its root element:
<domain type='kvm' xmlns:qemu='http://libvirt.org/schemas/domain/qemu/1.0'>virt-xml-validate rejects <qemu:commandline> even where libvirt accepts it. Trust virsh define.
10. CPU Pinning and Host Isolation#
10.1 vCPU pinning#
QEMU numbers x86 vCPUs with the thread index varying fastest, so vCPU 2k and 2k+1 are the two threads of guest core k. They must land on a real host SMT pair, or the guest's idea of its own cache topology is a lie.
Find the layout:
lscpu -e=CPU,CORE,CACHE # group by the last cache columnOn a 16-core Zen 4 desktop part, CCD0 is CPUs 0-7,16-23, CCD1 is 8-15,24-31, and sibling(n) = n+16.
<iothreads>1</iothreads>
<cputune>
<vcpupin vcpu='0' cpuset='0'/> <vcpupin vcpu='1' cpuset='16'/>
<vcpupin vcpu='2' cpuset='1'/> <vcpupin vcpu='3' cpuset='17'/>
<!-- one pair per guest core -->
<vcpupin vcpu='14' cpuset='7'/> <vcpupin vcpu='15' cpuset='23'/>
<emulatorpin cpuset='14,30'/>
<iothreadpin iothread='1' cpuset='15,31'/>
</cputune>The emulator thread must not share a core with any vCPU. It runs all device-model work, so if it competes with a game thread you get a frame hitch on every disk access. Put it on the far cache domain, which is free anyway.
On Intel hybrid parts, give the guest P-cores and the emulator an E-core - that is exactly what E-cores are good for.
iothreadpinis inert for an NVMe-backed guest. libvirt rejects IOThreads on the NVMe bus outright: "IOThreads not available for bus nvme". All disk work therefore rides the emulator thread, which makesemulatorpinthe load-bearing setting.
10.2 Host isolation#
Pinning stops the guest wandering. It does not stop the host landing on the guest's cores. A libvirt hook confines the systemd slices to the complement for as long as the guest runs:
systemctl set-property --runtime -- system.slice AllowedCPUs=8-13,24-29
systemctl set-property --runtime -- user.slice AllowedCPUs=8-13,24-29
systemctl set-property --runtime -- init.scope AllowedCPUs=8-13,24-29Measured effect: stalls over 10 microseconds in a 10 second window fell from about 100 to 36, with a ~2% throughput gain.
Derive the set from the domain's own <cputune> rather than hardcoding it, and include the emulator and iothread cores - the host should stay off those too.
Restore on every exit path: clean shutdown, hard destroy, and crash. A hook that confines the host and fails to restore is worse than no hook. Use --runtime so a reboot clears it regardless.
Never call
virshfrom inside a libvirt hook. libvirt blocks waiting for the hook to return while the hook waits on libvirt. The deadlock wedges domain start, which wedgesvirsh list, which is what virt-manager reads - so the visible symptom is virt-manager showing no VMs at all, which looks nothing like the cause.
10.3 Host governor#
sudo cpupower frequency-set -g performanceWorth 2-3% and a good deal of measurement noise.
11. Patched QEMU#
QEMU stamps its own name on everything it emulates. Disks report QEMU HARDDISK, ACPI tables report BOCHS, PCI subsystem IDs report Red Hat.
Build it into /opt so a package upgrade never touches it:
# in a clean QEMU 11.1.1 checkout
for p in patches/qemu/0*.patch; do patch -p1 < "$p"; done
./configure --prefix=/opt/qemu-native --target-list=x86_64-softmmu
ninja -C build && ninja -C build installPoint the domain at it:
<emulator>/opt/qemu-native/bin/qemu-system-x86_64</emulator>The patches are grouped by the detection each clears:
| Patchset | Clears |
|---|---|
01-firmware | FIRMWARE - ACPI link-device names, PNP0A06 stubs, FADT C-state latencies |
02-disk-identity | VIRTUAL_DISK, DISK_SERIAL |
03-pci-ids | PCI_VENDOR, DEVICES |
04-fw-cfg | QEMU_FW_CFG |
05-usb-hid | QEMU_USB |
06-audio | DEVICES |
07-edid, 08-cpu-misc, 09-net | nothing; carried for other detectors and build wiring |
11.1 FIRMWARE is a chain#
FIRMWARE walks about a dozen fingerprints and returns on the first hit. Fixing one link costs a full rebuild and a boot, and only reveals the next.
Bundle every edit into one rebuild. The links that mattered:
- The SMI resource-reservation
_UIDstring PRTP/PRTArouting-table symmetry- Sequential PIRQ link-device names - the check wants all four of
LNKE,LNKH,GSIE,GSIH - The two
PNP0A06resource-reservation stubs - FADT C2/C3 latencies sitting exactly on
0x0FFF
For link 3, rename the GSI* half and leave LNK* alone: real firmware has LNKA-LNKH, the GSI set is QEMU's invention. Those names are generated from one three-character prefix, so changing the prefix moves every reference with it.
Do not pick IRQ as the replacement - _SB.PCI0.IRQA is already the APIC routing package and a bare NameSeg would bind to it.
For link 5, 0xffe keeps "C-state not supported" semantics while dodging the check's exact-equality test against 0x0FFF.
11.2 Two ACPI traps#
_UID must stay unique among devices sharing a _HID. Giving both PNP0A06 devices the same replacement string is a namespace collision, and the guest powers itself off after ~70 seconds having read nothing from disk.
Rename values, not structure. Renaming _UID strings and routing-table names is safe because every reference moves with them. That rule is often stated as "never rename the devices themselves", which is too broad - the two resource-reservation stubs are leaves carrying only _HID/_UID/_STA/_CRS and rename fine. The distinction is whether anything else references the name.
Keep WAET out. "WAET" is a scanned target string and the table's signature sits at offset 0, so re-adding it is an immediate detection.
12. Patched KVM Modules#
Four detections, five patches for upstream and one that is not. All are places where KVM diverges from the architecture in a guest-observable way, so the patches are corrections rather than spoofing. patches/kvm/ in the repo carries them with a cover letter and a selftest; they pass checkpatch.pl --strict.
| Patch | Clears |
|---|---|
0001 intercept #GP when guest EFER.SVME is clear | SVM_EXCEPTIONS |
0002 emulator: #UD, not #GP, for VMCALL at CPL > 0 | KVM_INTERCEPTION |
0003 #UD for KVM hypercalls issued at CPL > 0 | KVM_INTERCEPTION |
0004 intercept ICEBP and skip it before injecting its #DB | DBVM |
0005 selftest for 0004 | - |
EXPERIMENTAL-0006 runtime CPUID passthrough with brand-string override | TIMER |
You do not need a whole custom kernel. KVM is a loadable module, and the vfio-native-kvm-dkms package builds arch/x86/kvm plus virt/kvm from the vanilla 7.2.3 sources against whatever 7.2.x headers are installed. Two Makefile paths that assume an in-tree build are redirected (ccflags-y += -I $(src), TRACE_INCLUDE_PATH .), and the result is srcversion-identical to the same patches built inside a full CachyOS tree. By hand, in a kernel tree:
cp /usr/lib/modules/$(uname -r)/build/.config .config
cp /usr/lib/modules/$(uname -r)/build/Module.symvers . # modpost fails without this
for p in patches/kvm/000[1234]*.patch patches/kvm/EXPERIMENTAL-*.patch; do patch -p1 < "$p"; done
make -s modules_prepare -j$(nproc)
make M=arch/x86/kvm modules -j$(nproc)All domains must be off to swap them - modprobe -r kvm needs the refcount at zero. Check both modules afterwards: the SVM fixes land in kvm-amd.ko and the hypercall fixes in kvm.ko.
12.1 SVM_EXCEPTIONS#
svm_set_efer() unconditionally sets EFER_SVME in the VMCB, because hardware requires it to run the guest at all. A guest that never enabled SVM therefore still runs with it set from the CPU's point of view.
So when the guest executes an SVM instruction at CPL3, hardware passes the SVME check, sees CPL is not zero, and injects #GP before the instruction intercept can fire. Bare metal raises #UD, and #UD is the one exception the check treats as innocent.
KVM already carries a FIXME describing exactly this. The fix is to intercept #GP while the guest's EFER.SVME is clear, so gp_interception() can decode the instruction and route it to its real handler, which injects the #UD the architecture specifies:
if (!(vcpu->arch.efer & EFER_SVME)) {
svm_set_intercept(svm, INTERCEPT_VMLOAD);
svm_set_intercept(svm, INTERCEPT_VMSAVE);
svm_set_intercept(svm, INTERCEPT_CLGI);
svm_set_intercept(svm, INTERCEPT_STGI);
set_exception_intercept(svm, GP_VECTOR); /* added */
} else {The shipped patch also keeps the intercept across a guest clearing SVME later, drops it on SVME=1 where the erratum workaround does not need it, and never arms it for SEV guests, whose instructions KVM cannot decode.
There is a no-rebuild shortcut - kvm.enable_vmware_backdoor=1 arms the same intercept. Do not use it. It permanently opens two well-known VMware tells in every guest on the host: port 0x5658 stops faulting at CPL3, and RDPMC of the pseudo-counters returns host TSC, which is architecturally impossible on real silicon. VMAware is blind to both. Other tools are not.
12.2 KVM_INTERCEPTION#
This one's debug string is misleading. It reports "KVM attempting to patch instructions on the fly", which points at KVM_X86_QUIRK_FIX_HYPERCALL_INSN. Disabling that quirk is a measured no-op.
The check runs at CPL3. KVM's emulator declares VMCALL as Priv without PrivUD:
if ((ctxt->d & Priv) && ops->cpl(ctxt)) {
if (ctxt->d & PrivUD)
rc = emulate_ud(ctxt);
else
rc = emulate_gp(ctxt, 0);
goto done;
}So it takes the #GP(0) branch, Windows surfaces that as an access violation, and the quirk code sits downstream of the CPL check and never runs.
Two patches are needed, and either alone leaves the detection standing, because the check tries two stubs and reports whichever misbehaves first:
- Add
PrivUDto theVMCALLentry, so a CPL3VMCALLraises#UDlike hardware. - Make
kvm_emulate_hypercall()inject#UDfor a CPL>0 caller instead of returning-KVM_EPERMsilently, which the check reads as a generic hypervisor.
Place the second check after the Xen and Hyper-V dispatch so enlightened guests keep their own semantics. kvm_hv_hypercall() already does the identical thing for the Hyper-V ABI, so this makes the KVM PV path consistent with it rather than inventing behaviour.
That "either alone does nothing" property is why previous single-sided attempts are recorded in various projects as having no effect. They were correct patches, tested in isolation.
12.3 DBVM#
Added to VMAware after v2.8.1. It executes icebp; ret under SEH and checks that the exception context's RIP is the byte after the ICEBP, which is what a trap-like #DB reports on hardware.
On SVM the #DB exception intercept never sees ICEBP - the guest normally takes it directly. But when the delivery is interrupted by a VM-exit, typically a nested page fault on a cold IDT or handler page, the exit carries the pending #DB with RIP saved on the ICEBP, and KVM re-injects it as-is. The guest handler then sees the ICEBP's own address, sporadically, whenever a page happens to be unmapped. The fix enables SVM's dedicated ICEBP intercept, which fires before the #DB exists, skips the instruction and queues the #DB - the shape VMX has had for years. A KVM selftest in the series fails on unpatched SVM with 0x402ec4 != 0x402ec5 and passes with the patch.
12.4 TIMER, and why the passthrough is opt-in#
TIMER times an intercepted CPUID against LFENCE with a cross-core cache counter, so it measures the world switch itself and no clock can be adjusted to hide it. EXPERIMENTAL-0006 stops intercepting CPUID once cpuid_passthrough=1, and handles the three things raw CPUID breaks: the brand string reverts to the host SKU (the AMD Processor Name String MSRs 0xC0010030-35 are reprogrammed on the pinned core at entry and restored at vcpu_put), raw CPUID advertises RDPRU which KVM otherwise #UDs on (the RDPRU intercept is dropped with it), and Windows hangs if the synthetic leaf-1 bits vanish during boot (it is a runtime switch, on after boot only). It applies itself only to vCPU threads confined to exactly one host CPU, checked on every entry, so an unpinned guest gets nothing rather than something wrong.
It is not upstream material and never will be: it hands the guest raw host CPUID. It is in the DKMS build, off by default, and section 2 has the two lines that turn it on.
13. GPU Passthrough#
The guest wants a real GPU both to play anything and because GPU_CAPABILITIES checks for a display reporting a gamma ramp.
One GPU is the common case. The host gives the card up while the guest runs: stop the display manager, unbind the console framebuffers, unload the GPU driver, and let libvirt bind it to vfio-pci with managed='yes'. Reverse on stop. The host has no display while the guest runs, so have SSH working first.
Two GPUs with one spare is easier and safer. Bind it to vfio-pci at boot so the host never claims it:
# /etc/modprobe.d/vfio.conf
options vfio-pci ids=1002:xxxx,1002:yyyy
softdep amdgpu pre: vfio-pci
softdep nvidia pre: vfio-pciAdd vfio_pci to MODULES in /etc/mkinitcpio.conf, rebuild the initramfs, reboot.
Check nothing on the host holds the card first - a compositor often keeps every DRM device open even with no monitor attached to it:
sudo fuser -v /dev/dri/by-path/pci-0000:0f:00.0-*The whole IOMMU group goes to the guest together. Remove the emulated display at the same time:
<video><model type='none'/></video>Do not substitute an emulated adapter to satisfy the check: qxl and virtio-gpu are on the device blacklist, and any display device can arm a boot-logo detection by giving OVMF a GOP.
The full single-GPU treatment, including the reset bugs that matter when buying, is in Single-GPU Passthrough on Linux.
14. Host Headroom and the TSC Race#
Windows calibrates the TSC at boot. That calibration is a race: if the host cannot schedule the guest's vCPU threads cleanly through it, Windows abandons the TSC and QueryPerformanceCounter costs about 1300 ns instead of 15 for the rest of that boot.
QPC is what a game calls for frame timing, every frame, often several times. A flat 85x tax on it is not a rounding error.
The determining factor is how much of the host the guest leaves alone. Measured over four cold boots at each size, same XML every time, on a 16-core host:
| Guest | Host keeps | Boots with a fast clock |
|---|---|---|
| 24 vCPU | 4 cores | 4 / 4 |
| 28 vCPU | 2 cores | 3 / 4 |
| 32 vCPU | 0 cores | 2 / 4 |
Leave the host at least four physical cores. Within that, use as many as you want. 24 of 32 threads is reliable, which matters when the guest does CPU-heavy work rather than only gaming.
14.1 It is a race, so measure it more than once#
A single boot proves nothing. The same XML that reads 15 ns on one boot reads 1250 ns on the next when headroom is tight. If a measurement comes back slow, reboot and measure again before changing any configuration.
This is easy to get wrong. An earlier version of this page claimed a hard 16-vCPU ceiling, derived from two data points - 16 and 32 - with nothing tested in between and one sample at each. Both halves of that were wrong: the intermediate sizes work, and the behaviour is probabilistic rather than a threshold.
Rule of thumb: two data points do not describe a curve, and one sample does not describe a race. Anything boot-dependent gets four boots per configuration before it is written down.
14.2 What does not fix it#
Measured, none of these change the outcome:
useplatformclock false,useplatformtick no,disabledynamictick yes- adding
invtscto the CPU model - pinning the vCPUs one-to-one without leaving the host anything
- which cache domain the vCPUs sit on
14.3 Realtime vCPU scheduling makes it worse#
The obvious idea, given the race, is to stop the vCPU threads being preempted at all:
<cputune>
<vcpusched vcpus='0-31' scheduler='fifo' priority='1'/>
</cputune>Do not do this on every vCPU. Tested on a 32-vCPU guest that used every host thread: the guest booted once, then failed to boot twice, ran away to 800% CPU and left systemd itself unresponsive to systemctl until QEMU was killed by hand.
FIFO scheduling on as many threads as the host has cores means nothing else can run, including the emulator thread the guest depends on. The Arch Wiki's chrt -r 1 suggestion is aimed at a guest that leaves cores free; applied to a guest sized to the whole machine it is a way to hang the host.
If you want to experiment with it, leave real headroom first, and have a way in over SSH.
15. Performance Results#
15.1 Hardening is free#
Against the same guest with the fidelity work abandoned entirely - hypervisor visible, the full Hyper-V enlightenment set, hypervclock - medians of three sequential runs each:
| Hardened (2/85) | Ceiling (fully visible) | |
|---|---|---|
| int latency, Mops | 880 | 882 |
| int throughput, Mops | 5258 | 5294 |
| fp, Mops | 3512 | 3566 |
| QPC cost, ns | 15.1 | 14.5 |
| jitter p99.99, us | 1.7 | 1.4 |
| stalls >100us per 10 s | 0-2 | 0-1 |
The spread between runs of one configuration is wider than the gap between the two.
The enlightenments buy nothing, and section 14 says why: the one place they should matter is the clock, and once the guest is sized correctly Windows uses the TSC by itself, leaving hypervclock nothing to improve.
The patched KVM modules are free as well, measured against stock modules with identical XML.
15.2 Cumulative effect of the tuning#
From an untuned 32-vCPU guest to the tuned configuration:
| Before | After | |
|---|---|---|
| QPC cost | 1310 ns | 14.5 ns |
| DRAM latency (64M) | 87.6 ns | 60.4 ns |
| memory read | 49.0 GB/s | 52.1 GB/s |
| jitter p99.99 | 87.3 us | 1.3 us |
| stalls >10us per 10 s | 2532 | 36 |
| stalls >100us per 10 s | 452 | 0 |
16. Benchmarking Methodology#
16.1 Why not a game benchmark#
The benchmark guest has no GPU, so there is no frame rate to measure. Everything here is a CPU, memory, scheduling or clock proxy. Each is chosen because it maps onto something a game does:
| Measurement | What it stands for |
|---|---|
| QPC cost | the timing call made every frame |
| single-thread throughput | the main thread |
| memory latency | the dominant cost in most engine work |
| core-to-core latency | job-system handoffs |
| jitter tail | microstutter |
| storage IO | level loading, texture streaming |
The harness is C, cross-compiled with mingw-w64 and copied in. Nothing is installed in the guest - no Cinebench, no AIDA64, nothing that is itself a detectable artefact.
16.2 How the harness avoids lying to you#
- A volatile sink on every loop result, so the optimiser cannot delete the work.
- Pointer-chase over a random single cycle for memory latency, one node per 64-byte line. A strided walk gets prefetched and measures bandwidth instead.
QueryPerformanceCounterfor timing, neverrdtsc. TSC behaviour is one of the things being changed, so timing with it would measure the instrument.- Percentiles, not means, for jitter. The tail is the entire point.
- Warm-up passes before every timed section; minimum across repetitions for floors, interquartile mean for distributions.
16.3 The mistakes that produce fake numbers#
Never run benchmarks concurrently. A repeat loop written as
vmbench clock & vmbench cpu & vmbench jitter 0 10runs three benchmarks fighting over the same cores. Both configurations get measured the same way so the comparison looks controlled, and the artefact still leaks through because they schedule contention differently. This produced a phantom "3% cost of hardening" that vanished entirely on sequential runs.
Benchmark a settled guest. A machine that just booted is indexing, patching and starting services, and all of it lands in the stall counts. Wait a minute.
Set the host governor first. powersave costs 2-3% and adds noise.
Name your host confounders. A machine also running something heavy - a compute stack, a build farm - is the likely source of multi-millisecond outliers in the jitter tail. Say so rather than hiding it.
17. Verification#
Two separate questions. Both need answering.
17.1 Is it fast#
| Reading | Expected | If wrong |
|---|---|---|
| QPC cost | ~15 ns | over 1000 ns means the boot calibration lost its race; reboot and re-measure (section 14) |
| rdtsc cost | ~7 ns | - |
| 1 thread | ~5300 Mops | host governor is not performance |
| L1 / L2 / L3 | ~0.8 / ~3.4 / ~10 ns | high L3 means vCPUs across two cache domains |
| DRAM | ~60-90 ns | check transparent hugepages are applying (section 8) |
| memory read | ~50 GB/s | - |
| jitter p99.99 | ~2 us | pinning missing, or emulator sharing a vCPU core |
| stalls >100us | 0-2 per 10 s | same causes; this is what stutter feels like |
The two that matter are QPC cost and the jitter tail. Mean throughput hides both, which is why a VM can benchmark well and still play badly.
17.2 Is it hidden#
Section 3. Score from the console session, read the debug line, one change per boot.
17.3 Is the host whole again#
systemctl show --property=AllowedCPUs system.slice
nproc
free -gWith the guest off, all three must read as if no VM existed. This is not optional - a hung domain start can leave the host confined with nothing running, so verify both the clean-shutdown and hard-destroy paths rather than assuming.
18. Maintenance#
| Event | tuned / hidden | full |
|---|---|---|
| Kernel update | nothing | rebuild the modules |
| QEMU package update | nothing | nothing |
| Rebasing onto newer QEMU | nothing | re-apply patches/qemu/ |
After a kernel update the patched modules stop loading and the stock ones take over silently. The guest boots fine and just scores worse. Nothing warns you.
cat /sys/module/kvm/srcversion
modinfo -F srcversion /usr/lib/modules/$(uname -r)/updates/kvm.ko.zstDiffer? Rebuild. A pacman hook is worth installing precisely because the failure is invisible.
Check both kvm and kvm-amd: the #GP fix lands in kvm-amd.ko and the hypercall fixes in kvm.ko, so verifying one reports success on a stale build of the other.
To undo everything: remove the modules from updates/ and run depmod, point <emulator> back at /usr/bin/qemu-system-x86_64, restore the saved XML.
19. What Cannot Be Cleared#
Correction, 2026-09-05. An earlier version of this section listed
TIMERas unfixable, on two claims: that its exception-latency detector read 6.2 against a 2.5 threshold on stock KVM, and that the instruction-latency detector was a hardware floor. The first was a broken probe - it usedRaiseExceptionunder a vectored handler where VMAware usesZwRaiseExceptioninside an SEH frame, and rebuilt to VMAware's own mechanism it reads 1.4 on stock KVM. The second is real but avoidable by not interceptingCPUIDat all, section 12.4. The guest is now at 1/85.
GPU_CAPABILITIES. One GetDeviceCaps(hdc, COLORMGMTCAPS) call that wants a gamma ramp, and the stub display a headless guest has does not have one. Not a GPU enumeration despite the name. Every emulated adapter is either on the device blacklist or gives OVMF a boot logo, which arms another check, so the only clear is a real GPU passed through with vm-native-gpu. Without a spare card to pass through it stays.
That is the whole list.
20. Troubleshooting#
Guest powers off after ~70 seconds with no disk reads. An ACPI namespace failure, almost always a duplicate _UID. Revert to the snapshot and change one thing at a time.
Bugcheck after a firmware change. Fast Startup resumed the old kernel session against new tables. Section 6.1.
Score got worse for no reason. The kernel updated and the patched modules stopped loading. Section 18.
virt-manager shows no VMs. Something is holding a libvirt lock. If you added a libvirt hook recently, check it does not call virsh - see section 10.2. Restart libvirtd and verify the PID actually changed.
nproc reports fewer CPUs than the machine has. The isolation hook is active and the guest is running. It also means anything reading nproc inside a hook gets the confined count, which will silently shrink the host's allowed set further on every start if you use it to compute the complement. Read /sys/devices/system/cpu/present instead.
Patched OVMF hangs at 100% CPU before console init. Known, undiagnosed. Use stock firmware; it costs only the boot-logo warning.
vmic* services flagged as Hyper-V. False positive. They ship with every Windows install including on bare metal, and deleting them makes the guest less realistic.
Guest will not boot after the QEMU patch. The disk is still virtio. Move it to emulated NVMe first.
21. Sources#
Detection:
- VMAware - the library this is scored against
- VMAware technique list
Hardening projects:
- vfio-native-nix - maintained patches and table generator
- BarelyMetal - NixOS reference
Kernel:
- Documentation/virt/kvm/api.rst
- AMD64 Architecture Programmer's Manual Vol 2: System Programming - SVM instruction exceptions
- KVM CPUID interface
QEMU and libvirt:
- libvirt Domain XML: CPU model and topology
- libvirt Domain XML: CPU tuning
- libvirt hooks
- QEMU SMBIOS options
Performance:
- Arch Wiki: PCI passthrough via OVMF
- Acquiring high-resolution timestamps - Microsoft
- Red Hat: Scaling virtio-blk with IOThread Virtqueue Mapping
- Performance tweaks - Mathias Hueber
ACPI:
- ACPI Specification 6.5 -
_UIDuniqueness, FADT fields - ACPICA iasl documentation
Related: