Jos

↰ Back to the list of posts

Fingerprinting CPUs Using CPU-Specific Behavior

2026-07-07

The x86 architecture is arguably the most complex general-purpose Instruction Set Architecture ever made. It has thousands of different instructions (depending on how you count). The reference manuals from Intel and AMD are 5,000+ pages. This complexity can be (ab)used to fingerprint CPUs.

Undefined Behavior

One of the most awful parts of x86, in my opinion, is that it is not fully defined: some instructions or specific instruction outputs are undefined, which means they are allowed produce any outcome the CPU manufacturer sees fit. The usual advice is not to rely on such behavior, as it may change in future CPUs.

"Undefined behavior" on CPUs is not the same as undefined behavior in C. In C, the compiler is allowed to assume undefined behavior never occurs, and may decide to delete entire branches of your code (or worse) if it is able to determine that it contains undefined behavior.

On CPUs, "undefined" just means that the architecture makes no guarantee about the result, and different processors are allowed to produce different results. This is closer to "implementation-defined behavior" in C, if you consider every CPU as a different implementation.

For many instructions with undefined behavior, different CPUs do actually behave differently. It can differ between manufacturer (Intel vs. AMD), but also between several different microarchitectures of the same manufacturer. For example, the MUL instruction has four undefined flags: PF, AF, ZF and SF. I can only speculate as to the reason for this, but I would guess that it is simply historical: on the 8086, correctly updating the flags would have cost an additional microcode operation and thus would have made all multiplications one cycle slower, which Intel likely did not consider worthwhile.

Analyzing this instruction on several different architectures, shows us how it behaves differently:

CPUAMD 3900X
AMD 7700X
Intel i9-13900 (P)
Xeon Silver 4110
Intel i9-13900 (E)
PFunchangedparity(x*x)parity(x*x)
AFunchanged00
ZFunchanged0(x*x == 0)
SFunchangedsign(x*x)sign(x*x)

Note that for the Intel i9-13900, which has two different types of CPU cores (efficiency and performance cores), the ZF actually behaves differently depending on which core your OS has scheduled your thread to run on!

Building a fingerprinting program

We can (ab)use differences in CPU-implementations to determine which CPU our code is running on. We will consider only the AMD 3900X, AMD 7700X, Intel i9-13900 (P or E) and Intel Xeon Silver 4110 CPUs, as these are the only CPUs that have been analyzed by libLISA. For this, we need to find a set of instructions whose CPU-specific behavior allows us to uniquely identify which CPU our program is running on. Using libLISA's analysis results, we can find two such instructions that allow us to do this: MUL and VPCLMULHQLQDQ.

First, the MUL instruction allows us to distinguish between three groups of architectures. To ensure we actually do get three different results, we need to pick specific inputs. Using the multiplication 0 * 1 with ZF=0 and PF=0 is one possible input that works. It gives us one of these results:

FlagAMD 3900X
AMD 7700X
Intel i9-13900 (P)
Xeon Silver 4110
Intel i9-13900 (E)
PF011
ZF001

Now we just need to find an instruction that allows us to distinguish between the AMD 3900X and the AMD 7700X, or the Intel i9-13900 (P) and the Xeon Silver 4110. As it turns out, such an instruction exists: VPCLMULHQLQDQ (I recommend the following pronunciation: V-P-C-L-MUL-HQ-LQ-DQ). Although VPCLMULHQLQDQ doesn't have any undefined behavior, it is not supported on the AMD 3900X and the Xeon Silver 4110. This means we can use its (non)-existence to distinguish between the two CPUs within each group.

Putting it all together, our full code becomes:

  xor rdx, rdx
  xor rax, rax
  add dl, 1
  mul eax
  setp r13b
  setz r14b
  lea rdi, [rip + 2f]
  xor r15b, r15b
  vpclmulhqlqdq ymm0,ymm1,ymm2
  mov r15b, 1
2:
  ...

It looks more complicated than it is. The first three instructions set up the correct inputs for the MUL instruction. They result in a CPU state where RAX = 0, RDX = 1, ZF=0 and PF=0:

  xor rdx, rdx
  xor rax, rax
  add dl, 1

Then, SETP and SETZ store the outputs of the PF and ZF flag in registers:

  setp r13b
  setz r14b

For the second observation, we start by loading a jump offset (label '2') into the RDI register and zeroing R15:

  lea rdi, [rip + 2f]
  xor r15b, r15b

If we jump to this offset, the remaining instructions are skipped.

Finally, we execute the VPCLMULHQLQDQ instruction:

  vpclmulhqlqdq ymm0,ymm1,ymm2

If the instruction does not exist, an #UD exception is raised. We can catch this exception with a signal handler. In this signal handler, we set the RIP to RDI, which will effectively jump over the last instruction. The last instruction stores 1 in R15B. This only happens if the previous instruction executed successfully, because it is skipped if the signal handler is invoked. This means that R15B will contain 1 if the instruction exists, or 0 if it does not.

Now, we have ended up with our fingerprint values in R13B, R14B and R15B. We can identify the CPU we are running on, by looking up the results in the following table:

R13BR14BR15BCPU
000AMD 3900X
001AMD 7700X
101Intel i9-13900 (p)
100Intel Xeon Silver 4110
111Intel i9-13900 (e)

This fingerprinting is hard to detect when hidden within a large binary. It doesn't give any indication that it might be trying to fingerprint the CPU: it doesn't perform any syscalls and doesn't call the CPUID instruction.

Security Implications

Differences in CPU implementations pose a security risk. For example, malware could use these differences to try and detect when it is running in an emulator, and disable itself to evade analysis. This is not new: similar tactics have been documented, and malware has been known to do this.

Additionally, these differences can also be used as an obfuscation technique. We can use them to trick decompilers. For example, when viewing this assembly it is clear that it may perform a SYSCALL under some conditions:

.text
.globl f0
.globl main
f0:
    mov    %rdi,%rax
    xor    %rdi,%rdi
    rcl    $0x9,%al
    jno    skip
    mov    $0x3c,%al
    syscall ; <-- HERE!
skip:               
    ret

main:
    mov $1, %rdi
    call f0
    ret

This code relies on undefined behavior in the RCL instruction that differs between Intel and AMD CPUs. On an AMD 3900X, the syscall is always executed. However, there are quite a few decompilers that will happily hide the syscall in their output, because they make assuptions about undefined behavior that are incorrect. For example, here is the output of Hex-Rays:

char __fastcall f0(char a1)
{
  char result; // al

  _AL = a1;
  __asm { rcl     al, 9 }
  return result;
}

Such output may lead us to falsely assume that this code does not perform any syscalls.

The attack surface for these obfuscation tricks is huge: any instruction interpreted incorrectly by analysis tools can be used, whether that is because of undefined behavior or incorrect implementation of semantics. To detect this, more accurate instruction semantics are needed, that also describe undefined behavior.

This post was based on my paper published at OOPSLA'24. It describes libLISA, a tool that aims to obtain accurate semantics by using an actual CPU as the ground truth. libLISA can fully automatically analyze a CPU, enumerate all instructions, and synthesize semantics for many of them. The generated semantics can then either be used directly in binary analysis tools, or be compared with existing implementations to find differences and bugs.