.. /blog
2026-02-23 // 8 min

PM Timer Emulation: The Other Timing Side-Channel

timingstealthmmio

Everyone focuses on RDTSC when they think about timing-based hypervisor detection. And rightfully so -- TSC is the highest-resolution clock on the CPU. But it's not the only clock. The ACPI Power Management Timer is a 3.579545 MHz counter accessible via I/O port 0x408, and it's been sitting there on every x86 system since 1996. Some anti-cheat software reads it as a cross-reference against TSC. If my TSC compensation makes RDTSC say 1000 cycles passed but the PM timer says the equivalent of 2000 cycles, something is clearly wrong.

I needed to bring the PM timer into agreement with my compensated TSC view. Every timing source the guest can access has to tell the same story.

How the PM timer works

The ACPI PM timer is a simple 24-bit (or 32-bit on newer systems) free-running counter that increments at exactly 3.579545 MHz. It's defined in the ACPI specification and accessed through I/O port operations -- typically IN EAX, 0x408 on most systems (the exact port comes from the ACPI FADT table's pm_tmr_blk field).

The timer is completely independent of the CPU clock. It runs off a separate crystal oscillator at that fixed frequency regardless of CPU frequency scaling, turbo boost, or sleep states. That independence is exactly why it's useful as a cross-reference -- and exactly why it's dangerous for me.

// The detection: cross-reference TSC against PM timer
fn detect_tsc_pm_divergence() -> bool {
    let pm1 = inl(0x408);
    let tsc1 = rdtsc();

    // do something that causes a VMEXIT (like CPUID)
    cpuid(0);

    let tsc2 = rdtsc();
    let pm2 = inl(0x408);

    // convert both deltas to nanoseconds
    let tsc_ns = (tsc2 - tsc1) * 1_000_000_000 / tsc_freq;
    let pm_ns = (pm2 - pm1) * 1_000_000_000 / 3_579_545;

    // if TSC says 100ns but PM says 500ns → hypervisor detected
    (pm_ns > tsc_ns * 2) || (tsc_ns > pm_ns * 2)
}

Intercepting the PM timer ports

AMD SVM provides an I/O Permission Map (IOPM) -- a bitmap pointed to by vmcb.control.iopm_base_pa. Each bit corresponds to one I/O port. If the bit is set, an IN or OUT to that port triggers a #VMEXIT.

I set the bits for ports 0x408 through 0x40B (the PM timer can be read as 8, 16, or 32 bits). When the guest reads the PM timer, I intercept it and return a compensated value instead of the real hardware value.

// PM timer compensation: make it consistent with TSC offset
fn handle_pmtimer_read(per_cpu: &PerCpu) -> u32 {
    // read the real PM timer value from hardware
    let real_pm = inl(0x408);

    // compute how many PM timer ticks my TSC offset represents
    // tsc_offset is negative (subtracting cycles from guest view)
    let pm_offset = (per_cpu.tsc_offset as i64)
        * 3_579_545
        / per_cpu.tsc_freq as i64;

    // subtract the same proportional amount from PM timer
    // now PM timer and TSC tell the same story
    (real_pm as i64 + pm_offset) as u32
}

The math is straightforward. My TSC offset is -150 cycles (hiding the VMEXIT cost). At a TSC frequency of 3.8 GHz, 150 cycles is about 39.5 nanoseconds. At the PM timer frequency of 3.579545 MHz, 39.5 nanoseconds is about 0.14 PM timer ticks. So I subtract 0.14 ticks from the PM timer value.

In practice, the PM timer offset is so small that it rounds to zero or one tick. The PM timer only increments every ~279 nanoseconds (1 / 3.579545 MHz), so a 40-nanosecond discrepancy is less than one PM timer tick. This means for short measurements, the PM timer naturally agrees with TSC even without compensation. The compensation matters more for accumulated drift over longer measurement windows.

APERF and MPERF

While I was fixing PM timer, I also added compensation for two other alternative clocks: MSR_APERF (0xE8) and MSR_MPERF (0xE7). These are per-core counters that measure actual cycles (APERF) and maximum cycles (MPERF). They're used by the OS for frequency scaling calculations, but they can also be used as yet another timing cross-reference.

I intercept both MSRs via the MSRPM and apply the same proportional offset. The pattern is identical -- read real value, convert TSC offset to the target counter's tick rate, subtract. The important thing is that all timing sources agree: TSC, PM timer, APERF, MPERF, reference TSC page, Hyper-V TIME_REF_COUNT. If even one of them tells a different story, the detection is trivial.

The timing consistency requirement is the hardest part of hypervisor stealth. It's not enough to compensate one clock -- I have to compensate every clock the guest can read. TSC, PM timer, APERF, MPERF, HPET, the Hyper-V synthetic time MSRs, and KUSER_SHARED_DATA timing fields. Missing even one creates a cross-referencing opportunity. The PM timer was the first "other clock" I fixed because it's the most commonly used cross-reference in anti-cheat code.

Performance impact

The PM timer intercept costs one VMEXIT per read -- about 500 cycles. On bare metal, an IN instruction to port 0x408 takes about 800-1200 cycles (I/O port access is inherently slow because it goes through the chipset). So the overhead is actually less than 50% on top of an already-slow operation. Nobody notices.

The PM timer is also read rarely in modern Windows. The kernel prefers TSC for everything performance-sensitive. The PM timer is a fallback used when TSC is unreliable (like during early boot or on very old CPUs). Anti-cheat software that reads it is doing so specifically to cross-reference, not for regular timing -- so the intercept only fires when someone is actively probing.

Combined with the TSC compensation from the VMCB's tsc_offset field (which is zero-cost because hardware applies it), the total timing stealth surface covers the two most commonly checked clocks. The next post covers HPET -- the third timing source that needs attention.