VFIO Gaming VM — GPU Passthrough & Anti-Cheat Stealth
Overview
This project didn't start here. It started as a straightforward GPU passthrough setup — get a bare-metal RTX 4090 running inside a Windows VM on an Arch host, play games. That part worked. Then the first anti-cheat rejection landed, and the scope shifted. Each fix revealed a new detection surface, each detection surface required a deeper fix. What began as "pass the GPU through" became a systematic audit of every layer a kernel-mode driver can see — CPUID, ACPI tables, SMBIOS, PCIe topology, timing, firmware identity — working through them one by one until the guest stopped looking like a VM. The stealth work was not planned upfront; it grew organically out of the passthrough work as each new wall appeared.
A Linux host running KVM/QEMU passes a bare-metal RTX 4090 directly into a Windows 10 VM — then that VM is hardened until modern anti-cheat systems can no longer distinguish it from real hardware. Two layers: first, a dual-GPU rig where the iGPU keeps the Arch host alive while the 4090 is dynamically handed to the VM on demand; second, systematically closing every hypervisor fingerprint until the guest passes kernel-mode anti-cheat scrutiny.
Current status: Almost all major Anti-Cheat Software fully defeated. Riot Vanguard remains the active research target.
Part 1 — The Foundation: Dual-GPU Dynamic Passthrough
The machine runs two GPUs and two monitors. The iGPU (integrated graphics on the Ryzen 9 9950X3D) drives the Arch Linux host display at all times. The RTX 4090 is the passthrough card, connected to a dedicated second screen for the Windows guest. When the VM is running, both displays are live simultaneously — Arch on one, Windows at full bare-metal GPU performance on the other.
What IOMMU and VFIO are. IOMMU (Input–Output Memory Management Unit) is a
hardware unit built into modern CPUs and chipsets that controls which physical memory addresses
a PCIe device is allowed to access directly. It partitions devices into isolation groups —
each group is a set of PCIe devices that share address translation and must move together as a
unit. The critical property for passthrough is that the IOMMU enforces these boundaries in
hardware: a GPU handed to a VM cannot reach host memory outside its assigned range, and the
host kernel can guarantee this without trusting the device or the guest. On AMD platforms it is
activated via the amd_iommu=on kernel flag.
VFIO (Virtual Function I/O) is the Linux kernel framework that sits on top of the IOMMU and
exposes its isolation guarantees to userspace. It rebinds a physical PCIe device away from its
normal driver (e.g. nvidia) onto a special vfio-pci driver, then
hands QEMU a file descriptor it can use to map the device's memory regions and service its
interrupts directly — with the IOMMU ensuring those accesses cannot escape the enforced
boundary. The IOMMU provides the hardware isolation; VFIO provides the kernel interface QEMU
uses to exploit it and present the device to the guest as bare hardware.
The 4090 is not permanently locked to VFIO at boot. When the VM shuts down, the card is freed back to the host — available for LLM inference (llama-server), CUDA workloads, or driving the host display directly. Three distinct operating modes, all without a reboot:
- VM active, split setup: iGPU → HDMI → monitor A (Arch host) | RTX 4090 → DP → monitor B (Windows guest) — both screens live simultaneously
- VM active, dual-screen guest: both monitors switched to their DP inputs; the 4090 drives a full dual-monitor Windows desktop while Arch keeps running headless
- VM off, GPU on host: 4090 freed for llama-server / CUDA inference; DP-1 and DP-2 from the card drive a dual-monitor Arch desktop with the full GPU available for silky smooth rendering
The physical display arrangement is what makes this flexible. Both monitors have multiple inputs — switching between HDMI-1 and DP-1 per screen reconfigures the entire display topology without touching any software. The iGPU's HDMI cable stays permanently connected as a fallback: it keeps the Arch host on-screen during a VM session (split mode), and provides a display output during the brief window when the nvidia driver is unbound as the 4090 is handed off to VFIO.
Step 1 — Enable IOMMU
IOMMU must be enabled in the kernel before any VFIO passthrough is possible. Add the flags to the
kernel command line in /etc/default/grub and regenerate:
GRUB_CMDLINE_LINUX_DEFAULT="... amd_iommu=on iommu=pt"
sudo grub-mkconfig -o /boot/grub/grub.cfg
iommu=pt (passthrough mode) is important — it avoids performance penalties for
devices that don't need IOMMU translation, like the iGPU staying on the host.
After reboot, verify IOMMU groups to confirm the 4090 is isolated in its own group — a prerequisite for clean passthrough:
for g in /sys/kernel/iommu_groups/*; do
echo "Group $(basename $g):"
for d in $g/devices/*; do
echo " $(basename $d) — $(lspci -s $(basename $d) 2>/dev/null | cut -d' ' -f2-)"
done
done
The GPU and its HDMI-audio function must be alone in their group (or only accompanied by each other). If other devices share the group, ACS patching is needed to split them.
Step 2 — CPU Pinning
Without CPU pinning, the VM's vCPUs float across all host cores, causing RDTSC jitter and unpredictable latency — both a performance problem and a timing-based detection vector. The VM is configured with 16 vCPUs pinned to a fixed slice of the host's physical cores, leaving the remaining cores free for the iGPU-driven host desktop:
virsh XML — cputune block<vcpu placement='static'>16</vcpu> <cputune> <vcpupin vcpu='0' cpuset='8'/> <vcpupin vcpu='1' cpuset='24'/> <vcpupin vcpu='2' cpuset='9'/> <vcpupin vcpu='3' cpuset='25'/> <vcpupin vcpu='4' cpuset='10'/> <vcpupin vcpu='5' cpuset='26'/> <vcpupin vcpu='6' cpuset='11'/> <vcpupin vcpu='7' cpuset='27'/> <vcpupin vcpu='8' cpuset='12'/> <vcpupin vcpu='9' cpuset='28'/> <vcpupin vcpu='10' cpuset='13'/> <vcpupin vcpu='11' cpuset='29'/> <vcpupin vcpu='12' cpuset='14'/> <vcpupin vcpu='13' cpuset='30'/> <vcpupin vcpu='14' cpuset='15'/> <vcpupin vcpu='15' cpuset='31'/> <emulatorpin cpuset='4-7'/> </cputune> <cpu mode='host-passthrough' check='none' migratable='off'> <topology sockets='1' dies='1' clusters='1' cores='8' threads='2'/> </cpu>
vCPUs 0–15 map to host cores 8–15 and their SMT siblings (24–31). The QEMU emulator threads
get cores 4–7, keeping them off the VM's dedicated slice. host-passthrough exposes
the real CPU feature flags to the guest — necessary for gaming performance and for the CPU
identity spoofing to be credible.
Step 3 — The Orchestration Script
A single script manages the entire GPU lifecycle: stops host services that hold the card, hands
it to VFIO, boots the VM, waits, then reclaims it on shutdown. The VM starts as a regular user
to preserve PulseAudio access; only the bind step runs as root via a scoped
sudoers rule:
#!/bin/bash
VM_NAME="win10"
export LIBVIRT_DEFAULT_URI=qemu:///system
export XDG_RUNTIME_DIR=/run/user/1000
export PULSE_SERVER=/run/user/1000/pulse/native
if ! virsh dominfo $VM_NAME &>/dev/null; then
echo "ERROR: VM '$VM_NAME' not found"; exit 1
fi
if ! lsmod | grep -q vfio_pci; then sudo modprobe vfio-pci; fi
# stop llama-server so it releases the GPU
killall llama-server 2>/dev/null
sleep 2
echo "Binding 4090 to vfio..."
sudo /usr/local/bin/vfio-bind.sh bind
echo "Starting VM..."
virsh start $VM_NAME || { sudo /usr/local/bin/vfio-bind.sh unbind; exit 1; }
# patch FADT pm_profile in QEMU memory ~5s after start
# (QEMU builds ACPI tables at init; Windows caches them ~15s in — this hits the window)
(sleep 5 && sudo /usr/local/bin/patch-fadt-pm-profile win10 2>&1 | sed 's/^/[fadt] /') &
echo "VM running — waiting for shutdown..."
while true; do
STATE=$(virsh domstate $VM_NAME 2>/dev/null)
[ "$STATE" = "shut off" ] || [ "$STATE" = "crashed" ] && break
sleep 5
done
sudo /usr/local/bin/vfio-bind.sh unbind
echo "Done — 4090 returned to host"
/etc/sudoers.d/vfio
sub ALL=(root) NOPASSWD: /usr/local/bin/vfio-bind.sh sub ALL=(root) NOPASSWD: /usr/local/bin/patch-fadt-pm-profile chmod -rw vfio-bind.sh patch-fadt-pm-profile
Step 4 — The Low-Level Bind Helper
The root helper does the actual PCI driver swap via sysfs. It handles both PCI
functions of the card — the GPU at .0 and its HDMI-audio device at .1,
which share an IOMMU group and must always move together. On the way back to the host, it
performs a PCI device reset before handing to the NVIDIA driver to avoid an
ENODEV after VFIO:
#!/bin/bash
GPU_IDS=("0000:01:00.0" "0000:01:00.1")
NVIDIA_DRIVER="nvidia"
bind_vfio() {
for id in "${GPU_IDS[@]}"; do
[ -e /sys/bus/pci/devices/$id/driver ] && \
echo $id > /sys/bus/pci/devices/$id/driver/unbind
echo "vfio-pci" > /sys/bus/pci/devices/$id/driver_override
echo $id > /sys/bus/pci/drivers/vfio-pci/bind
done
}
unbind_vfio() {
for id in "${GPU_IDS[@]}"; do
echo "" > /sys/bus/pci/devices/$id/driver_override
[ -e /sys/bus/pci/devices/$id/driver ] && \
echo $id > /sys/bus/pci/devices/$id/driver/unbind
# reset device before handing back — avoids ENODEV after VFIO session
[ -e /sys/bus/pci/devices/$id/reset ] && \
echo 1 > /sys/bus/pci/devices/$id/reset
echo $id > /sys/bus/pci/drivers/$NVIDIA_DRIVER/bind
done
}
case "$1" in bind) bind_vfio ;; unbind) unbind_vfio ;; esac
Using driver_override plus explicit unbind/bind — rather than boot-time
blacklisting — is what makes on-demand handoff possible. VFIO never claims the GPU until this
script runs, and the nvidia driver gets it straight back afterwards.
Part 2 — The Rabbit Hole: Anti-Cheat Detection
With stable passthrough in place, the next problem was immediate: anti-cheat software fingerprints hypervisors. A stock KVM/QEMU VM leaks its identity through CPUID flags, ACPI table strings, SMBIOS vendor fields, PCIe device count, disk serials, and NIC MAC OUIs simultaneously. Any single signal is enough for a kernel-mode driver to flag the session. What followed was a systematic process of closing every surface, layer by layer.
CPUID & Hypervisor Bit Hiding
The most obvious leak is CPUID. By default, KVM plants its vendor string
KVMKVMKVM at leaf 0x40000000 and sets the hypervisor-present bit in
CPUID.1:ECX[31]. Any anti-cheat (or even cpuid.exe) can read these.
Three flags strip them entirely:
<features>
<acpi/>
<apic/>
<kvm>
<hidden state='on'/> <!-- removes KVMKVMKVM from CPUID 0x40000000 -->
</kvm>
<vmport state='off'/> <!-- kills VMware backdoor I/O port -->
<smm state='on'/>
</features>
<cpu mode='host-passthrough' check='none' migratable='off'>
<topology sockets='1' dies='1' clusters='1' cores='8' threads='2'/>
<feature policy='require' name='topoext'/>
<feature policy='disable' name='hypervisor'/> <!-- clears ECX[31] -->
</cpu>
After these changes, CPUID.1:ECX[31] returns 0 and the vendor string at
0x40000000 is gone. Windows itself stops reporting that it's running in a
hypervisor-managed environment.
Clock & TSC Hardening
Timing is a classic VM detection vector — a drifting TSC or mismatched clock source is a dead giveaway. The clock block was hardened to native passthrough:
virsh XML — clock<clock offset='localtime'> <timer name='rtc' tickpolicy='catchup'/> <timer name='pit' tickpolicy='delay'/> <timer name='hpet' present='no'/> <!-- no HPET on real consumer boards --> <timer name='hypervclock' present='no'/> <!-- removes HyperV clock entirely --> <timer name='tsc' present='yes' mode='native'/> <!-- host TSC passed through --> </clock>
With mode='native', the guest reads the host's actual TSC via
RDTSC — no emulated counter, no drift. Combined with the CPU pinning from Part 1,
timing reads from the guest are indistinguishable from bare metal.
RDTSC Jitter — Basic Fix
CPU pinning and tsc mode='native' reduce jitter but don't eliminate it on their
own. The host kernel still fires timer interrupts on the pinned cores, and any preemption event
introduces a visible stutter in RDTSC sample deltas — pafish catches this by
running a tight TSC-sampling loop and flagging outliers above a cycle threshold.
The fix is to make those cores invisible to the host scheduler at the kernel level. Three cmdline parameters dedicate the pinned cores to the VM entirely — no scheduler runqueue, no per-CPU tick, no RCU callbacks:
/etc/default/grub — isolate VM cores from host kernelGRUB_CMDLINE_LINUX_DEFAULT="... amd_iommu=on iommu=pt \ isolcpus=8-15,24-31 nohz_full=8-15,24-31 rcu_nocbs=8-15,24-31"
isolcpus removes the cores from the scheduler; nohz_full suppresses
the per-CPU tick interrupt on them; rcu_nocbs offloads RCU callbacks so they
don't land on those cores either. Cores 8–15 and their SMT siblings 24–31 — the exact set
pinned in the cputune block — are now owned exclusively by the VM.
Pafish passes the basic RDTSC jitter check after this change.
SMBIOS — Full Board Identity Spoof
QEMU's default SMBIOS entries expose QEMU as the manufacturer across every type.
Anti-cheat drivers query types 0 (BIOS), 1 (system), 2 (baseboard), 3 (chassis), and 11 (OEM
strings) — all of them. The board was spoofed to an ASUS ROG CROSSHAIR X670E HERO to match the
AM5 platform of the actual host CPU (9950X3D), keeping CPUID and SMBIOS consistent:
<sysinfo type='smbios'>
<bios>
<entry name='vendor'>American Megatrends Inc.</entry>
<entry name='version'>3601</entry>
<entry name='date'>04/03/2025</entry>
</bios>
<system>
<entry name='manufacturer'>ASUSTeK COMPUTER INC.</entry>
<entry name='product'>ROG CROSSHAIR X670E HERO</entry>
<entry name='version'>Rev 1.xx</entry>
<entry name='serial'>MZSKD2DO2Y94</entry>
<entry name='sku'>ROG CROSSHAIR X670E HERO</entry>
<entry name='family'>ASUS MB</entry>
</system>
<baseBoard>
<entry name='manufacturer'>ASUSTeK COMPUTER INC.</entry>
<entry name='product'>ROG CROSSHAIR X670E HERO</entry>
<entry name='version'>Rev 1.xx</entry>
<entry name='serial'>MZSKD2DO2Y94</entry>
</baseBoard>
<chassis>
<entry name='manufacturer'>ASUSTeK COMPUTER INC.</entry>
<entry name='version'>1.0</entry>
<entry name='serial'>MZSKD2DO2Y94</entry>
<entry name='asset'>ATN39627732</entry>
<entry name='sku'>Default string</entry>
</chassis>
<oemStrings>
<entry>www.asus.com</entry>
</oemStrings>
</sysinfo>
Verification inside the VM with wmic — before and after:
> wmic bios get manufacturer,smbiosbiosversion Manufacturer SMBIOSBIOSVersion American Megatrends Inc. 3601 > wmic baseboard get manufacturer,product Manufacturer Product ASUSTeK COMPUTER INC. ROG CROSSHAIR X670E HERO
RAM Modules — SMBIOS Type 17
SMBIOS Type 17 describes individual memory modules. By default QEMU reports nothing useful here,
which is itself a fingerprint (no real system has blank RAM entries). Two Kingston DDR4 sticks
were injected via qemu:commandline to match a typical gaming build:
<qemu:arg value='-smbios'/> <qemu:arg value='type=17,manufacturer=Kingston,part=KF432C16BB/8,speed=3200, loc_pfx=DIMM_A1,bank=BANK 0,serial=0D2B1A3C'/> <qemu:arg value='-smbios'/> <qemu:arg value='type=17,manufacturer=Kingston,part=KF432C16BB/8,speed=3200, loc_pfx=DIMM_B1,bank=BANK 1,serial=1E3C2B4D'/>
ACPI OEM Table Spoofing
What ACPI is, and why it matters here. ACPI (Advanced Configuration and Power
Interface) is a set of binary tables that the BIOS/UEFI assembles during POST and hands to the
OS at the very start of the boot sequence — before any drivers load. These tables are the
firmware's machine description: CPU topology, interrupt routing, power management registers,
device enumeration, PM profile (desktop vs laptop vs server), and more. The OS reads them out
of physical memory using their fixed signature strings (e.g. FACP for the Fixed
ACPI Description Table, DSDT for the Differentiated System Description Table,
MADT for interrupt routing). Kernel-mode drivers — including anti-cheat — query
this data via NtQuerySystemInformation(SystemFirmwareTableInformation) and
compare it against CPUID and SMBIOS to check for consistency. QEMU generates its own complete
ACPI table set at VM initialisation, before the guest OS starts.
QEMU hardcodes BOCHS as the OEM ID and BXPC as the OEM Table ID
across all its generated ACPI tables (FACP, MADT, MCFG, DSDT…). Real ASUS boards with AMI BIOS
use ALASKA and A M I (with trailing spaces to pad to
8 bytes). This is visible to any tool that reads raw ACPI — including
NtQuerySystemInformation, which anti-cheat drivers call.
The fix operates at two levels. The first is a wrapper script that intercepts the
-machine launch argument and injects the OEM strings via Q35 machine parameters
before QEMU initialises the table set. The second is a binary patch of the QEMU executable
itself — necessary because several additional QEMU-identifying strings are hardcoded directly
in the binary for device HIDs and internal table signatures, and are not reachable through any
runtime argument.
Level 1 — wrapper script. Sits between libvirt and the QEMU binary.
Intercepts -machine pc-q35-* and appends the OEM parameters. The wrapper calls
a pre-patched binary (see Level 2) rather than the stock QEMU:
#!/bin/bash
REAL_QEMU=/usr/local/bin/qemu-system-x86_64-patched
ARGS=()
while [[ $# -gt 0 ]]; do
if [[ "$1" == "-machine" && "$2" == pc-q35-* ]]; then
ARGS+=("-machine" "${2},x-oem-id=ALASKA,x-oem-table-id=A M I ")
shift 2
else
ARGS+=("$1")
shift
fi
done
exec "$REAL_QEMU" -L /usr/share/qemu "${ARGS[@]}"
This wrapper is set as the VM's emulator in the libvirt XML:
<devices> <emulator>/usr/local/bin/qemu-win10-stealth</emulator> ... </devices>
Level 2 — binary patch. The x-oem-id / x-oem-table-id
machine parameters fix the header fields of most tables, but QEMU also hardcodes identifying
strings deeper in the binary for specific ACPI device objects and table signatures. These are
not exposed through any argument or config knob — they're compile-time constants baked into the
ELF. A Python script locates and replaces each one in the binary before installing the patched
copy alongside the stock QEMU:
data = bytearray(open('/usr/bin/qemu-system-x86_64', 'rb').read())
patches = {
b'BOCHS ': b'ALASKA', # OEM ID fallback in firmware init code
b'BXPC': b'INTL', # OEM Table ID (assertion-guarded; must be 4 bytes)
b'WAET': b'WFET', # Windows ACPI Emulated Devices Table signature
b'FWCF': b'FWGT', # fw_cfg device _HID in DSDT AML
b'QEMU0001': b'ACPI0001', # pvpanic device _HID
b'QEMU0002': b'ACPI0002', # fw_cfg device _HID (second reference)
}
for old, new in patches.items():
idx = bytes(data).find(old)
if idx != -1:
data[idx:idx+len(old)] = new
open('/tmp/qemu-patched', 'wb').write(data)
subprocess.run(['install', '-m', '755', '/tmp/qemu-patched',
'/usr/local/bin/qemu-system-x86_64-patched'])
The patched binary is a drop-in — same size, same linked libraries, same module search path.
Because the binary lives at /usr/local/bin/ instead of /usr/bin/,
it computes its QEMU module directory as /usr/local/lib/qemu/; a one-time symlink
points that back to the real module tree:
ln -s /usr/lib/qemu /usr/local/lib/qemu
The script is re-run after any QEMU package upgrade — the patch targets are invariant across minor releases and the offsets are found by search, not by hardcoded address, so it handles version bumps automatically.
The result: every QEMU-generated ACPI table reports ALASKA /
A M I in its OEM fields and none of the internal device objects carry a
QEMU-identifying HID. Verified via AIDA64 → Motherboard → ACPI:
# before OEM ID: BOCHS OEM Table ID: BXPC # after OEM ID: ALASKA OEM Table ID: A M I
Live patching a running QEMU process. The OEM wrapper handles most tables,
but one field requires a different approach: the FADT pm_profile byte. This
single byte tells the OS what kind of machine it's running on — 0x00 is
"unspecified", 0x01 is Desktop, 0x02 is Mobile (laptop), and so on.
QEMU leaves it at 0x00; a real ASUS desktop board sets 0x01. The
field can't be set cleanly through the wrapper because it's part of QEMU's internal table
construction logic.
The fix is a live memory patch applied directly to the QEMU process after it starts, but
before Windows reads and caches the tables. QEMU builds the ACPI table set in its own heap at
initialisation time (the moment the VM starts). Windows reads the tables roughly 10–15 seconds
into the boot sequence. There's a window in between. The script
patch-fadt-pm-profile exploits it: it finds the QEMU PID by VM name, opens
/proc/<pid>/mem, scans the process address space for the FADT magic bytes
(FACP signature), then writes 0x01 into the
pm_profile offset of that structure in live process memory — modifying the ACPI
table in RAM before the guest ever sees it. The orchestration script fires this as a detached
background job five seconds after VM start:
# 5s after start: QEMU tables are built, Windows hasn't read them yet (~15s window) (sleep 5 && sudo /usr/local/bin/patch-fadt-pm-profile win10 2>&1 | sed 's/^/[fadt] /') &
This is a surgical write to a running OS process's heap via /proc/mem — patching
firmware tables inside a live hypervisor. Windows never sees a "0 = unspecified" PM profile;
it boots believing it's on a standard desktop machine.
Disk Identity
QEMU reports SATA disks as QEMU HARDDISK with a blank or obviously fake serial.
Both were overridden to match a real Western Digital drive — the model via
qemu:commandline and the serial directly in the disk block:
<!-- Serial in the disk block --> <disk type='file' device='disk'> <driver name='qemu' type='qcow2' discard='unmap'/> <source file='/home/sub/ssd/win10.qcow2'/> <target dev='sda' bus='sata'/> <serial>WD-WX4S033U88CP</serial> </disk> <!-- Model string override via qemu:commandline --> <qemu:arg value='-set'/> <qemu:arg value='device.sata0-0-0.model=WDC WD40EZRZ-00WN9B0'/>cmd — verify disk identity
> wmic diskdrive get model,serialnumber Model SerialNumber WDC WD40EZRZ-00WN9B0 WD-WX4S033U88CP
NIC — Intel OUI MAC Address
QEMU's default NIC uses the OUI prefix 52:54:00 — a well-known QEMU/KVM
identifier. The NIC was replaced with e1000e (emulates a genuine Intel i219 LAN
controller) and given a MAC address with a real Intel OUI (00:1B:21):
<interface type='network'> <mac address='00:1b:21:ae:97:e6'/> <!-- Intel OUI --> <source network='default'/> <model type='e1000e'/> <address type='pci' domain='0x0000' bus='0x01' slot='0x00' function='0x0'/> </interface>
PCIe Topology Reduction
Stock QEMU Q35 exposes 14 PCIe root ports. No real consumer X670E board ships with that many
visible to the OS — it's an immediate fingerprint for anything that enumerates the PCI bus.
The topology was pruned to 6 ports, matching a realistic high-end desktop board, with the GPU
passed through as a hostdev:
<!-- 6 root ports (ioh3420), trimmed from QEMU's default 14 --> <controller type='pci' index='1' model='pcie-root-port'>...bus='0x01'...</controller> <controller type='pci' index='2' model='pcie-root-port'>...bus='0x02'...</controller> <controller type='pci' index='3' model='pcie-root-port'>...bus='0x03'...</controller> <controller type='pci' index='4' model='pcie-root-port'>...bus='0x04'...</controller> <controller type='pci' index='5' model='pcie-root-port'>...bus='0x05'...</controller> <controller type='pci' index='6' model='pcie-root-port'>...bus='0x06'...</controller> <!-- RTX 4090 GPU (.0) + HDMI audio (.1) passed through as bare hardware --> <hostdev mode='subsystem' type='pci' managed='yes'> <driver name='vfio'/> <source><address domain='0x0000' bus='0x01' slot='0x00' function='0x0'/></source> <address type='pci' domain='0x0000' bus='0x03' slot='0x00' function='0x0'/> </hostdev> <hostdev mode='subsystem' type='pci' managed='yes'> <driver name='vfio'/> <source><address domain='0x0000' bus='0x01' slot='0x00' function='0x1'/></source> <address type='pci' domain='0x0000' bus='0x04' slot='0x00' function='0x0'/> </hostdev>
| Port | Device | Bus |
|---|---|---|
| 1 | e1000e NIC | 0x01 |
| 2 | nec-xhci USB controller | 0x02 |
| 3 | RTX 4090 (GPU) | 0x03 |
| 4 | RTX 4090 (HDMI audio) | 0x04 |
| 5 | NVMe SSD | 0x05 |
| 6 | Spare | — |
Secure Boot
Enrolled genuine Microsoft production PKI keys into the OVMF NVRAM so Windows sees Secure Boot as active. Vanguard and some other anti-cheat systems treat a missing or unsigned boot chain as suspicious:
virsh XML — EFI + Secure Boot<os firmware='efi'>
<type arch='x86_64' machine='pc-q35-11.0'>hvm</type>
<firmware>
<feature enabled='no' name='enrolled-keys'/>
<feature enabled='yes' name='secure-boot'/>
</firmware>
<loader readonly='yes' secure='yes' type='pflash' format='raw'
>/usr/share/edk2/x64/OVMF_CODE.secboot.4m.fd</loader>
<nvram format='raw'
>/var/lib/libvirt/qemu/nvram/win10_VARS.fd</nvram>
<boot dev='hd'/>
<smbios mode='sysinfo'/>
</os>
host — enroll Microsoft keys
virt-fw-vars --enroll-redhat \ -i /usr/share/edk2/x64/OVMF_VARS.4m.fd \ -o /var/lib/libvirt/qemu/nvram/win10_VARS.fd
Noise Removal
Everything QEMU adds that has no equivalent on real consumer hardware was stripped. Each item is a registry entry, driver, or device that anti-cheat tools can enumerate:
virsh XML — stripped devices<!-- memballoon off — VirtIO balloon driver is a classic VM tell --> <memballoon model='none'/> <!-- no emulated video — GPU is passed through; VGA emulation would expose QEMU --> <video><model type='none'/></video> <!-- vmport off — kills VMware backdoor I/O port (0x5658) --> <vmport state='off'/> <!-- USB: nec-xhci (NEC/Renesas µPD720201 — real hardware) instead of qemu-xhci --> <controller type='usb' index='0' model='nec-xhci' ports='15'>...</controller> <!-- iTCO watchdog — present on real Intel/AMD chipsets, absent from stock QEMU --> <watchdog model='itco' action='reset'/>
Items also removed that don't appear in the XML at all: ISA serial device, HyperV
enlightenments block (relaxed, vapic, spinlocks),
hypervclock timer, and the VirtIO-serial controller that shows as
Red Hat VirtIO in Device Manager.
Guest-Side Cleanup (Windows)
The host-side XML gets you most of the way, but the guest OS itself can hold leftover references. A batch script run as Administrator inside Windows finalises the cleanup:
Windows (Admin cmd) — disable HyperV & VBSbcdedit /set hypervisorlaunchtype off bcdedit /set vsmlaunchtype offWindows (Admin cmd) — remove QEMU services & registry keys
sc query QEMU-GA && sc delete QEMU-GA sc config vmictimesync start= disabled sc config vmicheartbeat start= disabled reg delete "HKLM\SOFTWARE\Microsoft\Virtual Machine\Guest\Parameters" /f reg delete "HKLM\HARDWARE\ACPI\DSDT\QEMU" /f 2>nul reg delete "HKLM\HARDWARE\ACPI\FADT\QEMU" /f 2>nulWindows (Admin cmd) — reveal and remove ghost VirtIO devices
set DEVMGR_SHOW_NONPRESENT_DEVICES=1 start devmgmt.msc :: View → Show hidden devices :: Uninstall (with "delete driver") anything named VirtIO*, QEMU*, Red Hat VirtIO*
Offline driver file deletion (host-side). The .sys files
themselves — balloon.sys, netkvm.sys, vioser.sys —
are locked by the kernel while Windows is running and cannot be deleted from within the guest
even as Administrator. Attempting to queue them for deletion via
PendingFileRenameOperations also fails if an early-boot driver holds them.
The reliable fix is to delete them offline from the host while the VM is shut down, bypassing
Windows entirely:
# VM must be shut down first virt-customize -d win10 \ --delete '/Windows/System32/drivers/balloon.sys' \ --delete '/Windows/System32/drivers/netkvm.sys' \ --delete '/Windows/System32/drivers/vioser.sys'
virt-customize mounts the qcow2 directly via libguestfs — no Windows, no kernel,
no file locks. The files are gone before the next boot.
Part 3 — Results & The Remaining Wall
Proof of Concept — VM Footage
Screenshots and recordings taken from inside the Windows guest running on the passed-through RTX 4090. Anti-cheat status visible in-game.
Video walkthrough — VM booting with the RTX 4090 bound via VFIO, game launch with anti-cheat active:
fig. 5 — guest booting with the 4090 passed through; EAC title launching cleanly
After all of the above, the VM passes fingerprinting well enough to run protected titles. All major anti-cheat systems are cleared:
| Anti-Cheat | Status | Notes |
|---|---|---|
| Denuvo Anti-Tamper | ✓ Defeated | CPUID spoofing + SMBIOS chain + hypervisor bit removal sufficient |
| Easy Anti-Cheat (EAC) | ✓ Defeated | Kernel driver probes CPUID, SMBIOS, and ACPI — all three hardened |
| Riot Vanguard | ✗ Active research | vgk.sys loads at ring-0 during Windows boot, before userspace |
Why Vanguard is different. Unlike EAC and Denuvo, vgk.sys is
registered as a boot-start kernel service — it loads at ring-0 before any userspace process
initialises, during the earliest phase of the Windows boot sequence. This gives it a privileged
vantage point to inspect hardware state before the system is settled, making runtime CPUID
spoofing techniques insufficient on their own.
Remaining surfaces that Vanguard is likely probing at that early boot stage:
| Surface | Severity | Detail |
|---|---|---|
| TPM manufacturer string | MEDIUM | Software TPM (swtpm) reports IBM as manufacturer. Host TPM passthrough fails: QEMU buffer size is 3968 bytes vs the host's 4096 — the negotiation fails and QEMU falls back to swtpm. |
| ACPI DSDT AML body | MEDIUM | OEM headers are correct (ALASKA/AMI) but the AML device methods inside the DSDT still
contain QEMU-internal device names. Next step: obtain a real ASUS DSDT, decompile with
iasl -d, clean the AML, recompile, and inject via
-acpitable. |
| CPU cache topology | LOW | Host is a Ryzen 9 9950X3D with 3D V-Cache — an unusual cache structure. The 16 vCPU guest leaks this via CPUID cache topology leaves, inconsistent with the spoofed board identity. |
| Emulated NIC | LOW | e1000e has a real Intel device ID but is still emulated. Physical NIC
passthrough via VFIO would be cleaner. |
| RDTSC jitter — VM exit | MEDIUM | Pafish measures the cycle cost of instructions that force a VM exit — most reliably
CPUID. On bare metal the instruction completes in ~150 cycles; inside KVM,
the hypervisor must intercept to spoof the leaf values, service the exit, and resume the
guest — costing 5 000–15 000 cycles. The delta is orders of magnitude above noise and
trivially detectable. Core isolation with isolcpus/nohz_full
eliminates scheduler jitter but cannot touch this: the latency is architectural, imposed
by the VMCS exit-entry cycle itself, not by host-side preemption.
Currently undefeated — pafish still flags this. Approaches being
researched: KVM module patch to suppress RDTSC exits via TSC offsetting
(so the guest TSC can be adjusted without an exit), and disabling CPUID
interception for leaves that don't need spoofing to shrink the measurable exit
surface. |
The project is currently held at snapshot5 — a stable state with almost all major Anti-Cheat Software
cleared, and the basic RDTSC jitter check now passing after core isolation — while the
remaining Vanguard surfaces are researched. The RDTSC VM exit latency is the current blocker:
pafish still detects it and no configuration-only fix exists. TPM passthrough (pending an
upstream QEMU fix for the buffer mismatch), a clean ASUS DSDT, and a KVM patch to reduce the
CPUID exit surface are the next three milestones.
How it maps to security work
This project spans several domains simultaneously. The passthrough layer is hands-on Linux
kernel internals — IOMMU, PCI device trees, sysfs driver binding, runtime module
management, and least-privilege automation. The stealth layer goes deeper: reading and
manipulating ACPI AML bytecode, understanding CPUID at the instruction level, and
systematically enumerating a system's fingerprint the same way an adversarial kernel driver
would.
The anti-cheat research maps directly to reverse engineering and
defensive security. Understanding the detection surface of
vgk.sys requires the same mental model as writing a kernel driver. Enumerating
what a ring-0 service sees during early boot is the same methodology used to audit any
boot-start rootkit or UEFI implant.
Note. Built and run entirely on hardware I own, as a personal lab environment for learning systems internals, kernel driver behaviour, and anti-cheat research.