CVE-2026-23519

Betrayal by Optimization: How LLVM Broke Rust's Constant-Time Promises

Amit Schendel
Amit Schendel
Senior Security Researcher

Jan 15, 2026·6 min read

Executive Summary (TL;DR)

The Rust compiler is usually your friend, but in this case, it was the mole. On specific 32-bit ARM chips (Cortex-M0), LLVM optimized the `cmov` crate's constant-time logic into a conditional branch (`bne`). This introduced a timing side-channel into foundational cryptography libraries, allowing attackers to recover private keys from embedded devices simply by watching how long the CPU takes to think.

A critical side-channel vulnerability in the Rust `cmov` crate where LLVM optimizations inadvertently introduced conditional branches into constant-time logic on ARM Cortex-M0 targets, exposing cryptographic secrets.

The Hook: The Traitor in the Toolchain

We trust Rust. We trust it to yell at us when we borrow variables wrong. We trust it to stop buffer overflows. And in the world of cryptography, we trust libraries like cmov to handle the one thing that memory safety doesn't cover: Constant-Time Execution.

cmov (Conditional Move) is a tiny, foundational crate in the RustCrypto ecosystem. Its job is simple: move data from A to B based on a condition, but take exactly the same amount of time regardless of whether the condition is true or false. This is the bedrock of side-channel resistance. If your crypto code runs faster when a key bit is 0 vs 1, you might as well tweet your private key.

But here's the kicker: The code was correct. The logic was sound. The developer did everything right. But on January 14, 2026, we found out that the compiler—our supposed ally—decided to get "clever." On specifically thumbv6m targets (like the ubiquitous Cortex M0), LLVM looked at our beautiful constant-time bit-twiddling and said, "You know what would be faster? A jump instruction." And just like that, the safety guarantee evaporated.

The Flaw: When Smart Compilers Act Dumb

To understand the breakage, you have to look at how we emulate conditional moves on architectures that don't support them natively. The Cortex-M0 is a stripped-down processor; it lacks the CMOV instruction found in x86 or higher-end ARMs. So, cmov uses a bitwise trick.

The logic relies on a macro called bitnz!. It takes a value and turns it into a 1 if the value is non-zero, or 0 if it is zero, using purely bitwise math:

macro_rules! bitnz {
    ($value:expr, $bits:expr) => {
        ($value | $value.wrapping_neg()) >> ($bits - 1)
    };
}

This is a standard constant-time idiom. val | -val propagates the sign bit if the value is non-zero. Shifting it down gives you a clean boolean integer. No if statements, no jumps. Just math.

However, LLVM's Value Range Analysis is too smart for its own good. It looked at this macro and realized: "Hey, the output of this is always strictly 0 or 1." Since the target architecture (Thumb-v6M) makes bitwise math slightly expensive but branching cheap, the optimizer rewrote the assembly. It inserted a bne (Branch if Not Equal) instruction to skip the operation if the value was zero.

Congratulations, optimization pass: you saved one CPU cycle and destroyed the security model of the entire application.

The Code: The Smoking Gun

Let's look at the crime scene. We can see exactly where the transformation happens by comparing the intended Rust logic against the generated Assembly.

The Vulnerable Rust Code:

// This is supposed to be constant time
a.cmovnz(&b, c);

The Generated Assembly (Thumb-v6M):

    cmp  r2, #0     @ Check if the condition is zero
    beq  .LBB0_2    @ <--- THE FATAL FLAW: Branch if Equal
    mov  r0, r1     @ Perform the move only if condition met
.LBB0_2:
    @ Continue execution

Do you see the beq? That's a conditional branch. If the CPU takes the branch, it flushes the pipeline (or simply takes fewer cycles depending on the microarchitecture). If it doesn't take the branch, it executes the move.

The Execution Flow Difference:

  • Condition False (0): CMP -> BEQ (Jump) -> Done. (Fast)
  • Condition True (1): CMP -> BEQ (No Jump) -> MOV -> Done. (Slower)

This timing difference is measurable. If cmov is used to check a password or a cryptographic signature, an attacker can guess the key byte-by-byte simply by measuring how long the device takes to reject the guess.

The Exploit: Measuring the Heartbeat

Exploiting this on a Cortex-M0 is terrifyingly reliable because these chips are often simple, in-order execution pipelines with minimal noise.

The Scenario: Imagine a smart door lock using RustCrypto to verify an HMAC-SHA256 signature. The comparison function uses cmov to accumulate differences between the user's input and the real signature in a variable res. At the end, it checks if res == 0.

The Attack:

  1. Setup: The attacker connects to the lock's debug port or monitors its power consumption (Simple Power Analysis - SPA) or simply uses high-precision network timing.
  2. Payload: Send a signature attempt: 0x00....
  3. Measurement: The vulnerable code executes. Because of the beq instruction injected by LLVM, the loop handling the comparison will have slightly different timing characteristics depending on when the bytes mismatch.
  4. Iteration: The attacker changes the first byte. 0x01..., 0x02....
  5. The Tell: When the attacker guesses the correct first byte, the internal state inside the comparison logic changes, causing the cmov (or lack thereof) to trigger a different branch path. The power trace spikes or the timing shifts by a few clock cycles.

Because the jitter on these embedded devices is low, the signal-to-noise ratio is huge. You don't need a million requests; you might only need a few hundred.

The Fix: Telling the Compiler to Back Off

The fix required a two-step approach: first, a slap on the wrist for the compiler, and second, a restraining order.

Phase 1: The Black Box (v0.4.4) The immediate mitigation involved wrapping the calculation in core::hint::black_box. This intrinsic is essentially a way of telling LLVM, "This is a magical value. Do not analyze it. Do not optimize it. Just pass it through."

macro_rules! bitnz {
    ($value:expr, $bits:expr) => {
        // "Don't look at me!"
        black_box(($value | $value.wrapping_neg()) >> ($bits - 1))
    };
}

Phase 2: The Nuclear Option (v0.4.5) While black_box works, it relies on compiler heuristics that technically could change. The developers of cmov decided not to take chances. In version 0.4.5, they replaced the Rust logic entirely with Inline Assembly for ARM targets.

By writing the assembly manually, the developers bypass the optimizer entirely. They explicitly write the logical AND/OR/NOT instructions, guaranteeing that no branch instructions can ever be inserted. It's the only way to be sure.

Fix Analysis (1)

Technical Appendix

CVSS Score
8.9/ 10
CVSS:4.0/AV:N/AC:H/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
EPSS Probability
4.20%
Top 15% most exploited

Affected Systems

RustCrypto Utilities (cmov crate)ARM Cortex-M0/M0+/M1 (thumbv6m-none-eabi)Embedded IoT Devices using RustHardware Wallets relying on pure-Rust crypto

Affected Versions Detail

Product
Affected Versions
Fixed Version
cmov
RustCrypto
< 0.4.40.4.5
AttributeDetail
CWE IDCWE-208 (Observable Timing Discrepancy)
Attack VectorNetwork / Physical (Side-Channel)
CVSS v4.08.9 (High)
ArchitectureARM Thumb-v6M (32-bit)
Root CauseLLVM Optimization (Value Range Analysis)
ImpactKey Extraction via Timing Analysis
CWE-208
Observable Timing Discrepancy

The product performs a calculation or other operation in a way that allows an attacker to gain information about the internal state or data by measuring the time it takes to execute.

Vulnerability Timeline

Initial zeroize_stack mitigation proposed
2026-01-07
Vulnerability Confirmed (RUSTSEC-2026-0003)
2026-01-14
Fix Commit Pushed (Use black_box)
2026-01-14
Public Disclosure & CVE Reserved
2026-01-15

Subscribe to updates

Get the latest CVE analysis reports delivered to your inbox.