project 02 · systems & virtualization & stealth

VFIO Gaming VM — GPU Passthrough & Anti-Cheat Stealth

KVM/QEMUVFIOIOMMULinux SMBIOSACPIanti-cheatkernelstealth

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:

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:

/etc/default/grub
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:

/home/sub/start-vm4.sh
#!/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:

/usr/local/bin/vfio-bind.sh
#!/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:

virsh XML — cpu & features
<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 kernel
GRUB_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:

virsh XML — sysinfo block
<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:

cmd — verify SMBIOS
> 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:

virsh XML — qemu:commandline (RAM type 17)
<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:

/usr/local/bin/qemu-win10-stealth
#!/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:

repatch_qemu.py — binary string substitutions
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 / after — ACPI OEM fields
# 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:

start-vm4.sh — live FADT patch (background, timed)
# 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:

virsh XML — disk serial + model override
<!-- 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):

virsh XML — network interface
<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:

virsh XML — PCIe topology & GPU 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>
PortDeviceBus
1e1000e NIC0x01
2nec-xhci USB controller0x02
3RTX 4090 (GPU)0x03
4RTX 4090 (HDMI audio)0x04
5NVMe SSD0x05
6Spare—

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 & VBS
bcdedit /set hypervisorlaunchtype off
bcdedit /set vsmlaunchtype off
Windows (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>nul
Windows (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:

host — offline VirtIO driver deletion via libguestfs
# 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.

al-khaser.exe --check QEMU red flag
fig. 1 — al-khaser.exe --check QEMU red flag
al-khaser defeated
fig. 2 — al-khaser defeated
spoofed
fig. 3 — spoofed
device Manager
fig. 4 — device Manager, succesfully spoofed

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-CheatStatusNotes
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:

SurfaceSeverityDetail
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.