TPM Emulation: Faking the Trusted Platform Module
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
// ...
];
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.
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.