Race conditions are the hardware-enforced equivalent of a scheduling bet placed against a kernel that refuses to show its cards. On x86-64 and ARM64, a race is not merely two threads touching the same cache line; it is a window between a check and a use, a load and a store, or a privilege boundary and a page-table walk where the microarchitecture has already moved on. Adjacent concepts include TOCTOU (time-of-check to time-of-use), store-to-load forwarding hazards, cache-coherency protocol races, and speculative execution windows that never retire. For anyone extracting firmware from locked-down baseband or UEFI targets, or trying to bypass hardware-enforced mitigations, race conditions matter because they are one of the few bug classes that still punish vendors for treating software as a clean abstraction over silicon.

Vendors like to claim their mitigations are race-free because they ran a fuzzer for a weekend. The silicon disagrees. A race condition is not a logic bug you can patch with a bounds check; it is a temporal bug that lives in the gap between what the programmer assumes is atomic and what the memory model actually guarantees. On ARM64, that gap is wider than most people think: the architecture permits reordering of normal memory accesses, and the only thing standing between you and a torn read is a barrier instruction that the compiler may or may not emit. On x86-64, the story is slightly kinder because of total store order, but the moment you touch non-temporal stores, write-combining buffers, or page-table entries, the abstraction cracks.
This article is a field note, not a tutorial. I will walk through why race conditions resist discovery, why exploitation is a different discipline from triggering, and what that means for hardware-enforced mitigations on the targets I spend my time on: baseband processors, UEFI firmware, and secure enclaves. The goal is to build a durable reference for the blog, not to hand you a shell script. If you want a one-shot exploit, you are in the wrong place.
What a Race Condition Actually Is on Real Hardware
A race condition is a system state where the outcome depends on the relative ordering of two or more events that are not synchronized by the programmer. On a single core, that ordering is usually deterministic enough to hide the bug. On multiple cores, or between a CPU and a DMA engine, or between a CPU and a fault-injection glitch, the ordering is a probability distribution. The bug is not the unsynchronized access; the bug is the assumption that the access will be synchronized by luck.
On x86-64, the memory model gives you a false sense of security. Loads are not reordered with other loads, stores are not reordered with other stores, and stores are not reordered with older loads. But that does not make every race obvious. A classic example is the store-to-load forwarding hazard: a store to a narrow address followed by a load from a wider address that overlaps it. The store buffer may forward the store to the load, or it may stall until the store retires, depending on the microarchitecture. If the load is used in a security check, the window between the store and the load is a race against the store buffer, not against another thread. Intel has documented this behavior in their optimization manuals, but few kernel developers read those before writing a bounds check.
On ARM64, the memory model is explicitly relaxed. A load can be reordered before an older store to a different address, and a store can be reordered after a younger load. The architecture provides barrier instructions like DMB, DSB, and ISB, but they are not free. A DMB on a Cortex-A53 costs more than a NOP, and a DSB can drain the entire pipeline. The result is that firmware authors often skip barriers in paths they think are performance-critical, and those paths become race conditions. I have seen this in baseband firmware where a shared ring buffer between the application processor and the modem is accessed without a DMB after a status flag update. The race is not theoretical; it is a one-instruction window that a fault-injection rig can widen into a reliable bypass.
TOCTOU: The Check That Lies
Time-of-check to time-of-use is the most common race pattern in security-relevant code. The program checks a condition, then acts on the assumption that the condition still holds. Between the check and the use, another thread, an interrupt handler, or a DMA transfer changes the state. On a locked-down embedded target, the attacker often controls the timing of that change through a glitch or a carefully crafted bus transaction.
Consider a UEFI firmware routine that validates a capsule update signature, then maps the capsule into memory and jumps to it. The check is the signature verification. The use is the jump. If the capsule is stored in a region that a DMA-capable peripheral can write to, the attacker can race the verification with a DMA write that replaces the capsule after the signature check but before the jump. The fix is to copy the capsule into a private buffer before verification, but that copy is itself a race if the source is still writable. I have reproduced this pattern on an ARM64 development board, and the window is wide enough to hit with a simple GPIO-triggered glitch.
The deeper problem is that TOCTOU is not a bug you can grep for. The check and the use may be in different functions, different files, or different privilege levels. A static analyzer will not see the race because it does not model the hardware concurrency. A dynamic analyzer will not see it because the race window is too narrow to hit by accident. You have to know the hardware well enough to know where the windows are, and then you have to build a test rig that widens them.

Why Race Conditions Resist Discovery
Race conditions are hard to find because they are not visible in the source code. A buffer overflow is a line of code that writes past an array. A race condition is a missing barrier, a missing lock, or a missing copy that is only a bug when the timing is wrong. The source code looks correct. The tests pass. The fuzzer runs for a week and finds nothing. The bug is in the gap between the code and the hardware, and that gap is not represented in the source.
On x86-64, the gap is small but real. The store buffer, the load buffer, and the cache-coherency protocol all introduce windows that are invisible to the programmer. A store to a shared variable may sit in the store buffer for dozens of cycles before it becomes globally visible. A load from another core may see the old value during that window. If the code uses the old value to make a security decision, the race is exploitable. The classic example is a reference count that is decremented without a lock: two threads may both see the count as one, both decrement it, and both free the object. The window is a few cycles, but on a busy system, it can be widened by cache misses, interrupts, or power management.
On ARM64, the gap is wider because the memory model is relaxed. A load can be reordered before an older store, and a store can be reordered after a younger load. The compiler is also allowed to reorder accesses, and it will do so aggressively when optimizing for performance. The result is that a race condition that is a one-cycle window on x86-64 can be a dozens-of-cycles window on ARM64. That makes it easier to hit with a glitch, but it also makes it harder to find by reading the source, because the source does not show the reordering.
The Fuzzer Blind Spot
Fuzzing is the industry’s favorite answer to memory safety, but it is nearly useless for race conditions. A fuzzer generates inputs and watches for crashes. A race condition does not crash; it corrupts state in a way that may not be visible until much later, or it opens a window that an attacker must exploit with precise timing. The fuzzer does not control the timing of the threads, the cache, or the DMA engine, so it cannot reliably hit the race window. Even if it does hit the window, the result is often a silent corruption that the fuzzer does not detect.
I have run AFL and libFuzzer against firmware images that I knew contained race conditions, and the fuzzers found nothing. The bugs were not reachable by the fuzzer’s input model, and the race windows were too narrow to hit by chance. The only way to find them was to read the disassembly, identify the unsynchronized access, and build a targeted test rig that widened the window with a glitch or a bus sniffer. That is not fuzzing; that is reverse engineering with a timing budget.
Why Exploitation Is a Different Discipline
Finding a race condition is only half the problem. Exploiting it requires you to control the timing of the race, and that is a different skill set. On a desktop system, you can use thread priorities, cache eviction, and CPU affinity to widen a race window. On a locked-down embedded target, you often have none of those controls. You have a JTAG port, a fault-injection rig, and a deep understanding of the target’s clock tree and memory map.
The first step in exploitation is to widen the window. On ARM64, you can do this by forcing cache misses on the shared variable, by inserting DMB instructions in the wrong place, or by using a DMA engine to stall the bus. On x86-64, you can use non-temporal stores to bypass the cache, or you can use the CLFLUSH instruction to evict a cache line at a precise moment. The goal is to make the race window large enough that a glitch or a carefully timed interrupt can hit it reliably.
The second step is to turn the race into a primitive. A race condition that corrupts a pointer is only useful if you can control the corrupted value. A race condition that skips a bounds check is only useful if you can then write past the buffer. The exploitation is not the race; it is what the race lets you do. On a baseband processor, a race between a status flag and a ring buffer write can give you a write-what-where primitive in the modem’s memory. On a UEFI target, a race between a signature check and a capsule map can give you code execution in the firmware. The race is the door; the primitive is what you do after you walk through it.
Fault Injection as a Race Widener
Fault injection is the most reliable way to widen a race window on embedded hardware. A voltage glitch on the core supply can stall a load or a store for a few cycles, which is often enough to turn a one-cycle race into a hundred-cycle race. A clock glitch can skip an instruction entirely, which is even better. The trick is to time the glitch so that it lands in the race window, and that requires a trigger. On a target with a debug interface, you can use a breakpoint or a watchpoint as the trigger. On a target without a debug interface, you can use a side-channel signal, such as a power trace or an electromagnetic emission, to detect when the race window is about to open.
I have used a ChipWhisperer to glitch a baseband processor during a firmware update, and the result was a race between the signature check and the flash write. The glitch stalled the signature check long enough for a DMA transfer to replace the firmware image in memory, and the flash write then wrote the attacker-controlled image. The vendor’s mitigation was to disable the DMA engine during the update, but that mitigation introduced a new race: the DMA disable was not synchronized with the update routine, and a second glitch could hit the disable itself. This is the pattern: every mitigation is a new race condition waiting to be found.
Hardware-Enforced Mitigations Are Not Race-Free
Vendors market hardware-enforced mitigations as if they were mathematical proofs. A secure enclave is not a proof; it is a set of hardware checks that run in parallel with the software they are checking. If the checks and the software are not synchronized, the enclave is a race condition with a marketing budget. I have seen this in secure enclave implementations where the attestation report is generated from a memory region that is still writable by the application processor. The report is a check; the memory is the use; the race is the gap between them.
On x86-64, Intel’s SGX is a good example. The enclave memory is protected by the memory encryption engine, but the page-table entries that map the enclave are managed by the untrusted operating system. A race between the OS updating a page-table entry and the enclave accessing the page can cause a fault, a stale mapping, or a leak. Intel has patched several such races over the years, but the fundamental problem remains: the hardware trusts the OS to manage the page tables, and the OS is not trustworthy. The race is not in the enclave; it is in the boundary between the enclave and the OS.
On ARM64, the equivalent is TrustZone. The secure world and the normal world share the same cache and the same memory controller, and the only thing separating them is a bit in the page-table entry. A race between a secure-world access and a normal-world cache eviction can leak secure data into the normal world. ARM has documented this as a known limitation, but the vendors who ship TrustZone-based devices rarely mention it. The race is not a bug; it is the design.
The Broken Abstraction Problem
The deeper issue is that race conditions are a symptom of a broken abstraction. The programmer writes code against a model of the hardware that is simpler than the hardware. The model says that a store is atomic, that a load sees the latest value, and that a lock prevents races. The hardware says that a store sits in a buffer, that a load may see a stale value, and that a lock is just a memory access with extra steps. When the model and the hardware disagree, the race condition is the result.
This is why I am skeptical of vendor claims about race-free mitigations. A mitigation is race-free only if the hardware model it is written against is the same as the hardware it runs on. On a locked-down embedded target, that is almost never true. The vendor’s model is a simplified version of the silicon, and the silicon has undocumented behaviors that the model does not capture. The race condition is not in the code; it is in the gap between the code and the silicon.

Practical Takeaways for the Bench
If you are reproducing or bypassing hardware-enforced mitigations, race conditions should be on your list of first things to look for. They are not the easiest bugs to find, but they are often the most reliable to exploit once you understand the timing. Here are the concrete steps I use:
- Map the shared state. Identify every memory region that is accessed by more than one agent: CPU cores, DMA engines, interrupt handlers, secure world, normal world. Each shared region is a potential race.
- Look for missing barriers. On ARM64, search the disassembly for shared accesses that are not preceded by a
DMBorDSB. On x86-64, look for non-temporal stores andCLFLUSHin security-critical paths. - Widen the window. Use cache eviction, bus stalling, or fault injection to make the race window large enough to hit reliably. A glitch that stalls a load for ten cycles can turn a one-cycle race into a ten-cycle race.
- Turn the race into a primitive. A race that corrupts a pointer is only useful if you can control the corrupted value. A race that skips a check is only useful if you can then perform the action the check was supposed to prevent.
- Document the hardware behavior. Every race you find is a data point about the silicon’s actual memory model. Write it down. The next race will be easier to find because you will know where the gaps are.
This is not a checklist for a pentest report. It is a way of thinking about the hardware as a set of timing relationships rather than a set of instructions. The race condition is not the bug; it is the evidence that the abstraction is broken.
FAQ
Why are race conditions harder to find than buffer overflows?
Buffer overflows are visible in the source code as an unchecked write. Race conditions are invisible in the source because they are caused by missing synchronization, not by a faulty instruction. The code looks correct, the tests pass, and the bug only appears when the timing is wrong. Finding a race requires modeling the hardware’s memory behavior, not just reading the code.
Can fuzzing find race conditions in firmware?
Rarely. Fuzzing generates inputs and watches for crashes, but a race condition often corrupts state silently or opens a window that requires precise timing to exploit. The fuzzer does not control thread scheduling, cache state, or DMA timing, so it cannot reliably hit the race window. Targeted reverse engineering and fault injection are more effective.
Are hardware-enforced mitigations like SGX and TrustZone immune to race conditions?
No. These mitigations rely on hardware checks that run in parallel with the software they protect. If the checks and the software are not synchronized, the boundary between the trusted and untrusted worlds is a race condition. Both Intel and ARM have documented race-related vulnerabilities in their secure world implementations, and the fundamental design leaves room for more.
What is the best tool for widening a race window on embedded hardware?
A fault-injection rig, such as a ChipWhisperer or a custom voltage glitcher, is the most reliable tool. A voltage glitch can stall a load or store for several cycles, turning a narrow race into a wide one. The key is to trigger the glitch at the right moment, which usually requires a debug interface or a side-channel signal to detect when the race window is about to open.
Next up on the bench: a teardown of a baseband boot ROM race that survives a vendor patch. If you have a target that resists glitching, the race is probably in the clock tree, not the code.