Skip to content

Repository files navigation

PhantomHvDetectionLab

PhantomHvDetectionLab is a hypervisor detection and benchmark lab for measuring how visible a type-2 hypervisor is from user mode, optional kernel-mode probes, and project-specific telemetry. I built it for my type-2 hypervisor project, Phantom, and it is part of the broader Phantom lab suite.

The goal is not to promise that a hypervisor is "undetectable." The useful goal is harder and more honest: collect repeatable evidence about CPUID/MSR/exception, timing, stage-2 hook, and kernel architectural surfaces before loading the hypervisor, while it is loaded, and while specific hook workloads are active. Those result files can then be compared to see exactly what changed.

PhantomHvDetectionLab report viewer

PhantomHvDetectionLab has three pieces:

  • A modular user-mode runner: PhantomHvDetectionLab.exe.
  • An optional diagnostic companion driver: PhantomHvDetectionLabProbe.sys.
  • A static report viewer: report\index.html.

The companion probe driver talks through the versioned ABI in shared\hvdetect_abi.h. Phantom-specific telemetry and hook-lab commands use the mirrored Phantom IOCTL ABI in shared\phantom_abi.h.

Public Release Scope

Only PhantomHvDetectionLab is intended to be public. Phantom itself remains a private type-2 hypervisor project. The Phantom-specific IOCTL client in this lab is included as a concrete integration example and as a way to benchmark private Phantom builds, but the lab also works without Phantom through its generic user-mode probes, report viewer, sample captures, and optional companion probe driver.

The project is released under the MIT license. See LICENSE.

What This Is For

Use this project when you want to benchmark your own hypervisor's stealth and correctness surfaces:

  • Does CPUID expose a hypervisor bit, vendor leaf, VMX, SMX, SVM, or inconsistent AMD SVM feature-leaf state?
  • Do exception paths match native hardware behavior, such as invalid XGETBV or OSXSAVE-clear XSETBV cases?
  • Does CPUID-to-RDTSC timing scale naturally when guest work is inserted, or does compensation flatten time in a detectable way?
  • Do the first and second post-CPUID TSC reads show suspicious jumps?
  • Do stage-2 hooks leave EPT/NPT topology, active hook count, or timing signals?
  • Do kernel snapshots of CR registers, XCR0, EFER, PAT, DEBUGCTL, TSC_AUX, descriptor tables, VMX/SVM MSRs, RDMSR, RDPMC, and per-core TSC state agree with the user-mode story?

For Phantom specifically, the tool can query \\.\Phantom for status, memory stats, per-vCPU telemetry counters, detection-probe IOCTLs, EPT/NPT topology, and a private test-page hook workload.

How It Works

Hypervisor detection is usually about contradictions. A single signal is rarely enough. A detector looks for combinations that should not happen on normal hardware or should not happen together under a coherent virtualization policy.

PhantomHvDetectionLab collects those signals into stable JSON/CSV rows. Instead of treating every row as an automatic failure, it records evidence and lets you compare phases:

  • native: driver not loaded.
  • loaded: hypervisor loaded, no hook workload.
  • hooks: hypervisor loaded with hook-lab or another workload active.

The report viewer compares result files by stable row keys, so new probes do not shift unrelated rows. This makes it easy to answer practical questions like: "what changed after loading Phantom?" or "what changed after enabling NPT/EPT hooks?"

Educational Notes

CPUID Surface Checks

CPUID is one of the easiest places for a hypervisor to leak inconsistent state. For example, hiding CPUID.1:ECX[31] removes the common hypervisor-present bit, but other leaves can still expose virtualization state.

Important examples:

  • Intel VMX exposure: CPUID.1:ECX[5].
  • Intel SMX exposure: CPUID.1:ECX[6].
  • AMD SVM exposure: CPUID.80000001:ECX[2].
  • AMD SVM feature leaf availability: CPUID.8000000A.

If SVM is hidden but leaf 0x8000000A is still present, that is a contradiction: the CPU claims SVM is unavailable while still exposing the SVM capability leaf. The lab records this as cpuid.svm_leaf_without_svm.

Exception Behavior

Real CPUs have strict exception behavior for invalid or unavailable operations. Hypervisors that emulate part of a path can accidentally return #GP, #UD, or success where native hardware would do something else.

The lab includes safe and opt-in exception probes such as:

  • XGETBV(0) when OSXSAVE is exposed.
  • Invalid-index XGETBV when --include-fault-probes is used.
  • Phantom debug/test probes for OSXSAVE-clear XSETBV behavior.

The point is not just "did it fault?" The point is "did it fault with the same kind of architectural behavior expected from the exposed CPUID/CR4 state?"

Timing Probes

Timing is noisy, so the lab reports distributions instead of relying on one sample. It records count, min, p50, p95, p99, max, and mean.

The CPUID timing probes look for shape:

  • Raw CPUID latency.
  • CPUID followed by inserted guest work and a serialized TSC read.
  • CPUID followed by inserted guest work and the first post-CPUID TSC read.
  • A double-read probe that compares the first and second post-CPUID TSC reads.

These are useful because naive TSC compensation can make time look too flat. If the guest does real work after CPUID but the measured TSC delta barely grows, that can be a detection signal. If only the first TSC read is compensated, the second read can jump, which is why the double-read probe exists.

Kernel Probe Driver

Some evidence is not available from normal user mode. The companion driver collects read-mostly, fault-guarded snapshots from each logical CPU:

  • CR0, CR2, CR3, CR4.
  • XCR0.
  • EFER, PAT, DEBUGCTL, TSC_AUX.
  • VMX/SVM capability MSRs where readable.
  • GDTR, IDTR, LDTR, TR.
  • RDMSR/RDPMC fault behavior for selected targets.
  • Per-core TSC and kernel CPUID timing snapshots.

The driver pins a system thread to each CPU while collecting that CPU's record. That avoids mixing state from different processors and makes cross-core comparison more meaningful.

Stage-2 Hook Lab

The hook lab uses a Phantom-owned private test page. It does not patch a real application or kernel code page. The workflow is:

  1. Query stage-2 topology before installing the hook.
  2. Install a private test-page hook.
  3. Query topology again.
  4. Uninstall the hook unless --phantom-leave-hook-active is used.
  5. Query topology after uninstall.

Intel runs emit ept_topology.* rows. AMD runs emit npt_topology.* rows. Both emit stage2_topology.* rows so the report viewer can compare vendor-neutral state.

What "Detection" Means Here

A detection row means "this probe observed something worth investigating." It does not automatically prove maliciousness, invisibility failure, or correctness failure. Hardware, firmware, Windows policy, other security products, and debuggers can affect results.

The strongest use of the lab is comparative:

  • Native versus loaded.
  • Loaded versus hooks.
  • Debug build versus Release build.
  • Stealth disabled versus stealth enabled.
  • NPT/EPT hooks disabled versus enabled.

Connecting Your Own Hypervisor

Some of the richest rows in this lab are Phantom-specific because Phantom exposes diagnostic IOCTLs for status, telemetry, detection probes, stage-2 topology, and hook-lab control. If you want to use PhantomHvDetectionLab with another hypervisor, you have two choices:

  1. Use the generic probes only.
  2. Add a small diagnostic IOCTL surface to your hypervisor and write a client module like user\src\clients\phantom_client.cpp.

The generic probes require no hypervisor changes. CPUID, XGETBV, timing, cross-core TSC, JSON/CSV output, report comparison, and the optional PhantomHvDetectionLabProbe.sys companion driver work against any hypervisor. This is enough to compare native versus loaded behavior.

For deeper integration, add a debug/lab-only device interface to your hypervisor. Phantom uses \\.\Phantom; your project can use its own name. Keep this interface separate from production control paths when possible.

Recommended diagnostic surfaces:

  • Status query: CPU count, launched/active vCPU count, failed vCPU count, and basic feature flags.
  • Memory stats: active allocations, active bytes, peak bytes, private heap or pool usage, and allocation failures.
  • Per-vCPU state: vCPU id, processor group/number, launch state, stage-2 root, TSC_AUX, telemetry write index, and telemetry overflow count.
  • Detection probes: debug/test-only checks for internal contradictions that user mode cannot directly see, such as CPUID VMX versus CR4.VMXE policy, CPUID VMX versus VMX capability MSR behavior, AMD SVM feature-leaf policy, or OSXSAVE-clear XSETBV exception behavior.
  • Stage-2 topology: backend kind, EPTP/NCR3, root physical addresses, dynamic split counts, active hook slots, private roots, copied tables, and pending cleanup counters.
  • Hook lab controls: install a hook on a driver-owned private test page, uninstall it by id, optionally leave it active for unload testing, and expose topology before/after.
  • Fault injection: debug/test-only one-shot fault points for rollback paths such as stage-2 flush failure, VMCS/VMCB write failure, or hook publication failure.

ABI design guidance:

  • Put a versioned header at the front of every IOCTL structure.
  • Include Size and Version fields and validate both in the driver.
  • Prefer METHOD_BUFFERED for simple diagnostics that need to be easy to inspect in crash dumps.
  • Return raw evidence, not just pass/fail bits. A good lab row says what happened and why it was considered suspicious.
  • Make dangerous operations explicit. Anything that mutates hooks, page tables, IDT state, MSRs, debug registers, or fault injection should require a lab build and a command-line opt-in.
  • Treat missing IOCTLs as skipped rows in the client. That lets the same binary run against native systems, Phantom, and other hypervisors.

The Phantom mirror in shared\phantom_abi.h is a useful template, but it is not the only supported shape. For another hypervisor, create a new ABI header and a new client module that emits the same kind of flat result rows through ResultSink. The report viewer will compare those rows automatically as long as the category and name keys stay stable across runs.

Minimal client-module checklist:

  • Add a shared ABI header for your hypervisor's diagnostic structs.
  • Add user\src\clients\<your_hypervisor>_client.cpp.
  • Open your device, query status/topology/probes, and convert each field into sink.add_value, sink.add_detection, sink.add_skipped, or sink.add_error.
  • Add CLI flags in user\src\cli\options.*.
  • Call the client from main.cpp after the generic user-mode probes.
  • Document the commands and expected rows in this README.

Phantom Integration Snippets

These snippets show the shape Phantom currently uses. They are intentionally small; use them as a pattern, not as a requirement that every hypervisor expose the exact same fields.

Versioned IOCTL headers:

constexpr std::uint32_t PHANTOM_IOCTL_VERSION = 1;
constexpr std::uint32_t PHANTOM_IOCTL_BASE = 0x800;

struct HV_IOCTL_HEADER
{
    std::uint32_t Size;
    std::uint32_t Version;
    std::uint32_t Flags;
    std::uint32_t Reserved;
};

Every request/result starts with this header. The driver validates Size and Version, and the client initializes them before every call:

template <typename T>
void init_header(T &value)
{
    value.Header.Size = sizeof(value);
    value.Header.Version = PHANTOM_IOCTL_VERSION;
}

Buffered IOCTL helpers:

bool ioctl_buffer(HANDLE device, DWORD code, void *buffer,
                  DWORD buffer_size, DWORD *bytes)
{
    DWORD returned = 0;
    const BOOL ok = DeviceIoControl(device,
                                    code,
                                    buffer,
                                    buffer_size,
                                    buffer,
                                    buffer_size,
                                    &returned,
                                    nullptr);
    if (bytes)
        *bytes = returned;
    return ok != FALSE;
}

Phantom exposes a normal Win32 device path for diagnostics:

HANDLE device = CreateFileW(L"\\\\.\\Phantom",
                            GENERIC_READ | GENERIC_WRITE,
                            0,
                            nullptr,
                            OPEN_EXISTING,
                            FILE_ATTRIBUTE_NORMAL,
                            nullptr);

if (device == INVALID_HANDLE_VALUE)
{
    sink.add_skipped("phantom", "ioctl.open",
                     "Phantom device \\\\.\\Phantom is not available");
    return;
}

A basic status IOCTL gives the lab enough context to know whether the hypervisor is loaded and how many vCPUs are active:

constexpr std::uint32_t IOCTL_HV_QUERY_STATUS =
    CTL_CODE(FILE_DEVICE_UNKNOWN,
             PHANTOM_IOCTL_BASE + 1,
             METHOD_BUFFERED,
             FILE_ANY_ACCESS);

struct HV_STATUS_INFO
{
    HV_IOCTL_HEADER Header;
    std::uint32_t CpuCount;
    std::uint32_t LaunchedCount;
    std::uint32_t FailedCount;
    std::uint32_t Flags;
};

HV_STATUS_INFO status = {};
status.Header.Size = sizeof(status.Header);
status.Header.Version = PHANTOM_IOCTL_VERSION;

DWORD bytes = 0;
if (ioctl_buffer(device, IOCTL_HV_QUERY_STATUS, &status, sizeof(status), &bytes))
{
    sink.add_value("phantom", "status.cpu_count",
                   std::to_string(status.CpuCount));
    sink.add_value("phantom", "status.launched_count",
                   std::to_string(status.LaunchedCount));
    sink.add_value("phantom", "status.failed_count",
                   std::to_string(status.FailedCount));
}

Detection-probe IOCTLs return contradiction flags and raw evidence. Phantom uses this for internal checks that user mode cannot directly perform:

constexpr std::uint32_t HV_DETECTION_PROBE_FLAG_CR4_VMXE_CONTRADICTION = 0x00000008u;
constexpr std::uint32_t HV_DETECTION_PROBE_FLAG_XSETBV_OSXSAVE_CLEAR_UD = 0x00000010u;
constexpr std::uint32_t HV_DETECTION_PROBE_FLAG_VMX_BASIC_MSR_CONTRADICTION = 0x00000200u;

struct HV_DETECTION_PROBES_INFO
{
    HV_IOCTL_HEADER Header;
    std::uint32_t Flags;
    std::uint32_t Reserved;
    std::uint64_t Cr4Before;
    std::uint64_t Cr4AfterVmxeWrite;
    std::uint32_t XsetbvExceptionCode;
    std::uint32_t VmxBasicMsrExceptionCode;
    std::uint64_t VmxBasicMsrValue;
};

The client turns those flags into stable result rows:

const bool vmx_contradiction =
    (probes.Flags & HV_DETECTION_PROBE_FLAG_CR4_VMXE_CONTRADICTION) != 0;

sink.add_detection("phantom",
                   "cpuid_cr4.vmxe_consistency",
                   vmx_contradiction,
                   vmx_contradiction ? "contradiction" : "consistent",
                   "flags=" + hex64(probes.Flags));

Stage-2 topology uses one cross-vendor result shape. Intel fills the EPT fields; AMD fills the NPT fields:

struct HV_EPT_TOPOLOGY_INFO
{
    HV_IOCTL_HEADER Header;
    std::uint32_t VcpuId;
    std::uint32_t DynamicSplitCount;
    std::uint32_t SharedPdCount;
    std::uint32_t PrivatePdCount;
    std::uint32_t SharedPml1SplitCount;
    std::uint32_t Topology;
    std::uint64_t EptPointer;
    std::uint64_t Pml4Pa;
    std::uint64_t Pml3Pa;
    std::uint64_t Pml2Pa;
    std::uint32_t Stage2BackendKind;
    std::uint32_t ActiveHookSlots;
    std::uint32_t ActiveAmdNptStep;
    std::uint32_t Reserved0;
    std::uint64_t Stage2RootPa;
    std::uint64_t Stage2AuxRootPa;
    std::uint32_t NptSharedPml2Count;
    std::uint32_t NptPrivateRootPageCount;
    std::uint32_t NptPrivateCopiedPml2Count;
    std::uint32_t NptDynamicSplitCount;
    std::uint32_t NptActivePrimaryRootCount;
    std::uint32_t NptActiveSecondaryRootCount;
    std::uint32_t NptGlobalPrivateRootPageCount;
    std::uint32_t NptGlobalPrivateCopiedPml2Count;
    std::uint32_t NptGlobalDynamicSplitCount;
    std::uint32_t NptGlobalActiveStepCount;
    std::uint32_t Reserved1;
};

Hook-lab install requests target a driver-owned private test page. This is the important safety property: the lab can benchmark hook mechanics without patching real application or kernel code.

install_request->TargetType = HvUserHookTargetPrivateTestPage;
install_request->PatchLength = 1;
install_request->TestPageFill = 0x90;
install_request->PatchBytes[0] = 0xCC;
install_request->ExecuteMode = HvHookExecuteBoth;
install_request->InstallFlags = HV_HOOK_INSTALL_FLAG_ALLOW_EXECUTE_MODE_FALLBACK;

The pattern to copy is:

  • Keep the hypervisor's diagnostic ABI small and versioned.
  • Return enough raw state for the lab to explain a finding.
  • Convert every field into stable category.name rows.
  • Keep mutation, hook, and fault-injection paths debug/lab-only.

Build

.\tools\PhantomHvDetectionLab\build.ps1 -Configuration Release
.\tools\PhantomHvDetectionLab\build.ps1 -Configuration Debug -Target All

Local smoke:

.\tools\PhantomHvDetectionLab\smoke.ps1 -Configuration Release -Build

Run

Capture native, loaded, and hook phases:

.\tools\PhantomHvDetectionLab\build\bin\Release\PhantomHvDetectionLab.exe --phase native --json build\phantom-hv-detection-lab-native.json --csv build\phantom-hv-detection-lab-native.csv
.\tools\PhantomHvDetectionLab\build\bin\Release\PhantomHvDetectionLab.exe --phase loaded --json build\phantom-hv-detection-lab-loaded.json --csv build\phantom-hv-detection-lab-loaded.csv --phantom
.\tools\PhantomHvDetectionLab\build\bin\Release\PhantomHvDetectionLab.exe --phase hooks --json build\phantom-hv-detection-lab-hooks.json --csv build\phantom-hv-detection-lab-hooks.csv --phantom

Opt-in hook/fault workloads:

.\tools\PhantomHvDetectionLab\build\bin\Release\PhantomHvDetectionLab.exe --phase hooks --phantom-hook-lab --json build\phantom-hv-detection-lab-hook-lab.json --csv build\phantom-hv-detection-lab-hook-lab.csv
.\tools\PhantomHvDetectionLab\build\bin\Release\PhantomHvDetectionLab.exe --phase hooks --phantom-hook-lab --phantom-inject-intel-flush --json build\phantom-hv-detection-lab-intel-flush-fi.json --csv build\phantom-hv-detection-lab-intel-flush-fi.csv
.\tools\PhantomHvDetectionLab\build\bin\Release\PhantomHvDetectionLab.exe --phase hooks --phantom-hook-lab --phantom-leave-hook-active --json build\phantom-hv-detection-lab-active-hook.json --csv build\phantom-hv-detection-lab-active-hook.csv

Use --phantom-leave-hook-active only on an isolated lab host when you are about to test active-hook unload behavior.

Optional kernel probe workflow:

.\tools\PhantomHvDetectionLab\build\bin\Debug\PhantomHvDetectionLab.exe --install-kernel-probe --start-kernel-probe --kernel-probe --probe-driver-path tools\PhantomHvDetectionLab\build\bin\Debug\PhantomHvDetectionLabProbe.sys
.\tools\PhantomHvDetectionLab\build\bin\Debug\PhantomHvDetectionLab.exe --kernel-probe --json build\phantom-hv-detection-lab-kernel.json --csv build\phantom-hv-detection-lab-kernel.csv
.\tools\PhantomHvDetectionLab\build\bin\Debug\PhantomHvDetectionLab.exe --stop-kernel-probe --uninstall-kernel-probe

View Results

Open tools\PhantomHvDetectionLab\report\index.html in a browser and load one or more emitted JSON or CSV files.

Sample captures are included in tools\PhantomHvDetectionLab\samples:

  • phantom-hv-detection-lab-loaded-baseline.json
  • phantom-hv-detection-lab-hooks.json

Load those two files into the report viewer to see a ready-made loaded versus hook-lab comparison.

Useful compare workflow:

  1. Load phantom-hv-detection-lab-native.json.
  2. Load phantom-hv-detection-lab-loaded.json.
  3. Load phantom-hv-detection-lab-hooks.json.
  4. Pick baseline and target in the report viewer.
  5. Use the compare, timing, detections, Phantom, and kernel-probe tabs to inspect differences.

The report viewer is static and uses no external dependencies.

Options

  • --samples N: timing samples per microbenchmark, default 1000.
  • --warmup N: warmup iterations before timing, default 100.
  • --include-fault-probes: enable deliberate user-mode exception probes such as invalid-index XGETBV.
  • --include-privileged: enable fault-guarded user-mode RDMSR/RDPMC probes.
  • --kernel-probe: query \\.\PhantomHvDetectionLabProbe for privileged per-CPU state.
  • --install-kernel-probe, --start-kernel-probe, --stop-kernel-probe, --uninstall-kernel-probe: manage the diagnostic companion driver through SCM.
  • --probe-driver-path PATH: driver image used by --install-kernel-probe.
  • --allow-destructive-kernel-probes: reserved explicit opt-in for future isolated-lab probes. Current kernel probes remain read-mostly.
  • --phantom: query \\.\Phantom for Phantom status, memory stats, telemetry, and debug/test detection probes when available.
  • --phantom-hook-lab: run the private test-page hook workload and topology capture.
  • --phantom-leave-hook-active: leave the hook installed for active-hook unload testing.
  • --phantom-inject-intel-flush: arm HvFaultStage2IntelFlush before the hook lab workload in a Debug/test driver build.
  • --phantom-inject-vmcs-write: arm HvFaultVmcsWrite before the hook lab workload in a Debug/test driver build.
  • --fail-on-detection: return process exit code 1 when any detection flag fires. By default, detections are recorded as benchmark data and the tool exits successfully.

Safety Notes

  • Run hook, fault-injection, and unload tests only on an isolated lab machine.
  • Treat timing detections as leads, not verdicts.
  • Keep native/unloaded baselines for every hardware and Windows configuration you care about.
  • Do not add destructive kernel mutation probes without an explicit opt-in path, clear documentation, and a crash-dump plan.

About

hypervisor detection and benchmark lab for measuring how visible a type-2 hypervisor is from user mode, optional kernel-mode probes, and project-specific telemetry

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages