.. /blog
2026-02-25 // 7 min

TPM Emulation: Faking the Trusted Platform Module

tpmstealthmmio

Some anti-cheats don't just check software state -- they query the TPM. Platform Configuration Registers hold measurements of every boot component. If the firmware was tampered with, the PCR values change. I needed to make my TPM look clean even when it wasn't.

The TPM sits at a fixed MMIO address: 0xFED40000. That's been the standard since TPM 1.2. It's a 4KB region that the OS reads and writes to communicate with the TPM hardware. Every TPM command -- read a PCR, extend a measurement, get random bytes -- goes through this memory-mapped interface.

I don't need a full TPM implementation. I just need to intercept the reads that anti-cheats actually perform and return the values they expect. Targeted emulation, not comprehensive simulation.

How TPM MMIO works

The TPM uses a register-based protocol called the TIS (TPM Interface Specification). The OS writes a command buffer to the TPM's MMIO region, sets a "go" bit, then polls a status register until the TPM signals completion. The response sits in a data FIFO register at offset 0x24.

The registers that matter for PCR reads are straightforward. The OS sends a TPM2_PCR_Read command, the TPM processes it internally, and the result comes back through the FIFO. The key offsets I care about are the access register, status register, and the data FIFO.

// TPM TIS register offsets from base 0xFED40000
const TPM_ACCESS:     u32 = 0x00;
const TPM_INT_ENABLE: u32 = 0x08;
const TPM_STS:        u32 = 0x18;
const TPM_DATA_FIFO: u32 = 0x24;
const TPM_DID_VID:   u32 = 0xF00;

The NPT interception

I unmap the TPM's MMIO page in my nested page tables. When the guest tries to read 0xFED40000, there's no valid NPT entry -- the CPU triggers a #NPF (nested page fault) and drops into my exit handler. From there I decode the faulting instruction, figure out which register the guest wanted, and return my own value.

fn handle_tpm_mmio(
    gpa: PhysAddr,
    reg: &mut u64,
    is_write: bool,
) {
    let offset = (gpa.as_u64() & 0xFFF) as u32;

    if is_write {
        // track command bytes for state machine
        track_tpm_command(offset, *reg as u8);
        return;
    }

    // reads -- return faked values for PCR responses
    *reg = match offset {
        0x00 => 0x81,
        0x18 => 0xA0,
        0x24 => read_fake_fifo(),
        0xF00 => 0x1B58_15D1,
        _ => read_real_tpm(offset),
    };
}

Most of the TPM interface I pass through to real hardware. I only intercept the data FIFO reads when I've detected a PCR read command in progress. Everything else -- random number generation, key operations, sealing/unsealing -- goes straight to the physical TPM.

Faking PCR values

PCR values are SHA-256 hashes. PCR 0 holds the SRTM (Static Root of Trust Measurement) -- basically the hash of the firmware code. If I flash a modified BIOS, PCR 0 changes. Anti-cheats that query PCR 0 will see a non-standard hash and flag the machine.

I record the expected PCR values from a clean boot -- before any firmware modifications -- and store them in my hypervisor's data section. When a TPM2_PCR_Read response is being read from the FIFO, I substitute my pre-recorded values.

// pre-recorded PCR values from a clean boot
static CLEAN_PCRS: [PcrValue; 24] = [
    // PCR 0: SRTM -- firmware measurement
    PcrValue { algo: 0x000B, digest: [
        0x3d, 0x45, 0x8c, 0xfe, 0x55, 0xcc, 0x03, 0xea,
        // ... 32 bytes total
    ]},
    // PCR 7: Secure Boot state
    PcrValue { algo: 0x000B, digest: [
        0xa0, 0xb1, 0x1f, 0x4a, 0x22, 0x97, 0xe5, 0x6d,
        // ... 32 bytes total
    ]},
    // PCRs 1-6, 8-23: recorded similarly
    // ...
];
I only fake PCRs 0 and 7. PCR 0 changes with firmware mods (SmmInfect), PCR 7 changes with Secure Boot policy. PCRs 1-6 and 8-23 measure things I don't modify -- option ROMs, bootloader, kernel -- so they're already correct. Less faking = less risk of getting the state machine wrong.

The state machine problem

TPM communication is stateful. The OS writes a command buffer byte by byte into the FIFO, then reads the response byte by byte. I need to track where in the conversation I am: is this the command phase or the response phase? Is this byte 3 of the PCR read response or byte 47?

I built a minimal state machine that tracks three things: current phase (idle/command/response), a rolling buffer of command bytes to detect TPM2_PCR_Read command codes, and a byte counter for the response FIFO. When I see the TPM2_PCR_Read command code (0x0000017E) in the command buffer, I flag the next response as needing substitution.

fn track_tpm_command(offset: u32, byte: u8) {
    match offset {
        0x24 => {
            // command byte going into FIFO
            CMD_BUF[CMD_IDX] = byte;
            CMD_IDX += 1;

            // TPM2_PCR_Read = command code 0x0000017E
            // appears at bytes 6-9 of the command buffer
            if CMD_IDX >= 10 {
                let cmd_code = u32::from_be_bytes(
                    [CMD_BUF[6], CMD_BUF[7], CMD_BUF[8], CMD_BUF[9]]
                );
                if cmd_code == 0x0000017E {
                    INTERCEPT_RESPONSE = true;
                }
            }
        }
        0x18 => {
            // status write -- commandReady resets the state
            if byte & 0x40 != 0 {
                CMD_IDX = 0;
                RESP_IDX = 0;
            }
        }
        _ => {}
    }
}

What anti-cheats actually check

I've seen three patterns in the wild. The simplest is a Tbsi_GetDeviceInfo call that just checks if a TPM exists -- trivially passed since I have a real TPM. The second is a PCR read of registers 0 and 7 through the Windows TBS (TPM Base Services) API, which ultimately hits my MMIO intercept. The third is a direct DeviceIoControl to the TPM driver with a raw TPM2_PCR_Read command -- same path, same intercept.

None of them do attestation. Remote attestation would require the TPM to sign the PCR values with its endorsement key, and I can't fake that signature because I don't have the private key. But remote attestation requires a server-side verifier, and no anti-cheat I've tested uses one. They're all doing local PCR reads, which is exactly what I can intercept.

The limits of this approach: If an anti-cheat ever does TPM remote attestation -- sends a nonce, asks the TPM to sign PCRs with the EK -- I'm done. I can't forge the TPM's signature. But that requires server infrastructure and a registered EK certificate chain. For now, local checks are all I see.

Performance

TPM MMIO accesses are already slow -- the physical TPM runs at maybe 33MHz on LPC/SPI. A typical PCR read takes 50-100ms on real hardware. My intercept adds maybe 2 microseconds per MMIO access, which is completely invisible against the TPM's own latency. Nobody is going to time TPM accesses to detect a hypervisor. The TPM is already the slowest thing in the system.

This was one of the first MMIO devices I emulated. After this worked, I realized the same pattern -- NPT unmap, catch #NPF, decode instruction, emulate -- could handle every MMIO device I needed to intercept. That became the framework I describe in the next post.