Feb 27, 2026·7 min read·9 visits
The Rust `rsa` crate (< 0.9.10) failed to reject `1` as a prime factor during private key construction. Because the check was `x < 1` instead of `x <= 1`, the value `1` slipped through. Later arithmetic operations involving `(p-1)` resulted in zero, triggering a panic (DoS) when the library attempted modular inversion or division.
In the world of safe systems programming, Rust is often touted as the silver bullet for memory corruption. However, logic bugs remain the domain of human error. CVE-2026-21895 in the popular Rust `rsa` crate demonstrates this perfectly: a subtle off-by-one error in input validation allowed the number `1` to mask itself as a prime factor. This seemingly harmless integer bypasses checks, infiltrates the cryptographic core, and triggers a division-by-zero panic, effectively crashing any application that processes untrusted private keys.
Rust is the bouncer of the programming world. It checks your ID, pats you down for memory leaks, and ensures you aren't carrying any null pointers. It promises safety, and for the most part, it delivers. But here's the catch: Rust protects memory, not logic. It can stop a buffer overflow, but it can't stop a developer from writing a math equation that divides by zero.
Enter the rsa crate, a pure Rust implementation of the Rivest–Shamir–Adleman cryptosystem. It is widely used in the Rust ecosystem for everything from JWT signing to TLS handshakes. It’s supposed to be the bedrock of security. But prior to version 0.9.10, it had a glass jaw.
The vulnerability isn't a complex heap groom or a race condition. It’s a simple failure to respect the definition of a prime number. In RSA, you need two distinct large prime numbers, $p$ and $q$. By definition, a prime number must be greater than 1. The developer knew this. The code tried to enforce this. But due to a tiny syntactic slip, the number 1 was allowed into the VIP section. Once inside, 1 acts like a saboteur, turning vital cryptographic constants into zeros and causing the entire application to commit suicide via panic!.
Let's talk about number theory for a second. The security of RSA relies on the difficulty of factoring the modulus $n$, which is the product of two large primes $p$ and $q$. When constructing a private key from its components, the library must validate that $p$ and $q$ are valid.
The flaw resided in src/key.rs within the RsaPrivateKey::from_components function. The code iterates over the provided prime factors to ensure they are sane. The check looked something like this:
// The logic prior to the fix
if *prime < BigUint::one() {
return Err(Error::InvalidPrime);
}Do you see the problem? BigUint is an unsigned large integer. It cannot be negative. The condition *prime < 1 only catches one value: 0.
If an attacker provides 1 as a prime factor, the check 1 < 1 evaluates to false. The error is skipped. The library accepts 1 as a valid prime number. The chaos begins when the library calculates the Euler's totient function, $\phi(n) = (p-1)(q-1)$. If $p=1$, then $(p-1) = 0$. Consequently, $\phi(n) = 0$.
RSA relies heavily on modular arithmetic modulo $\phi(n)$ or similar derived values for the Chinese Remainder Theorem (CRT). When the code eventually tries to perform a modular inverse or a division using this zeroed-out value, the underlying num-bigint library hits a hard wall: Division by Zero. In Rust, this isn't an undefined behavior that leads to RCE; it's a panic, which immediately aborts the thread or process. It is the ultimate table flip.
Usually, fixing a cryptographic vulnerability involves checking constant-time implementations or padding oracles. Here, it involved adding an equals sign. The fix was pushed in commit 2926c91bef7cb14a7ccd42220a698cf4b1b692f7.
Here is the diff that saved the crate:
// src/key.rs
for prime in &self.primes {
- if *prime < BigUint::one() {
+ if *prime <= BigUint::one() {
return Err(Error::InvalidPrime);
}
m *= prime;
}That’s it. By changing < (strictly less than) to <= (less than or equal to), the developer ensured that 1 is properly rejected alongside 0.
The fix also included a regression test to ensure this specific scenario—passing 1 as a prime—never happens again without triggering a proper Result::Err instead of a crash:
#[test]
fn test_key_invalid_primes() {
let e = RsaPrivateKey::from_components(
// ... params ...
vec![
BigUint::from_u64(1).unwrap(), // The saboteur
BigUint::from_u64(239).unwrap(),
],
)
.unwrap_err();
assert_eq!(e, Error::InvalidPrime);
}This highlights a critical lesson in input validation: boundaries matter. When dealing with integers, the difference between < and <= is the difference between a stable system and a denial of service.
Exploiting this does not require a debugger, heap spraying, or ROP chains. It requires a JSON editor. If an application accepts RSA keys from a user (for example, uploading a custom signing key, importing a JWK, or handling PKCS#8 files), it is vulnerable.
The Attack Chain:
rsa crate. This could be a server accepting JWT signing keys or a CLI tool processing key files.When the application attempts to use this key—perhaps immediately upon load to precompute CRT parameters, or lazily when the first signature is requested—the process hits the division by zero.
In a web server context (e.g., actix-web or axum), if the panic occurs in the main thread or if the panic handler isn't configured to catch unwinds effectively, the entire service goes down. Even if it's caught, repeated requests can resource-exhaust the logging infrastructure or trigger restart loops.
You might look at the CVSS score of 2.7 and laugh. "It's just a DoS, who cares?"
In modern microservices and serverless architectures, a crashing process is a nuisance. In a monolith or a critical infrastructure component, it's a disaster. If this crate is used in a blockchain node validating transaction signatures, a single malicious transaction could take the node offline. If it's used in an authentication gateway processing user-uploaded keys, the auth service dies.
While this vulnerability does not allow for data exfiltration (you can't steal the server's private keys) or remote code execution (you can't run shellcode), availability is a core pillar of the CIA triad. The "Low" severity rating is technically accurate due to the lack of Confidentiality/Integrity impact, but do not underestimate the annoyance of a service that crashes every time it sees the number 1.
Furthermore, because this is a panic (an abort) rather than a returned Error, it bypasses standard error handling logic. Developers expect Result<T, E> to handle bad inputs. They do not expect their thread to simply cease existing.
The remediation is straightforward: Update your dependencies.
Run the following command in your project root:
cargo update -p rsaEnsure that Cargo.lock reflects a version of rsa greater than or equal to 0.9.10.
If you cannot update immediately, you must implement a sanitation layer before the data reaches the rsa crate. Manually inspect any components used to construct keys:
// Temporary workaround middleware
fn validate_primes(primes: &[BigUint]) -> Result<(), MyError> {
for p in primes {
if p <= &BigUint::one() {
return Err(MyError::BadPrime);
}
}
Ok(())
}However, patching the library is the only robust solution. This bug serves as a reminder to audit Cargo.lock files regularly. Just because Rust is memory-safe doesn't mean it's immune to logic bombs.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N/E:U| Product | Affected Versions | Fixed Version |
|---|---|---|
rsa RustCrypto | < 0.9.10 | 0.9.10 |
| Attribute | Detail |
|---|---|
| CWE ID | CWE-703 |
| Secondary CWE | CWE-369 (Divide by Zero) |
| CVSS v4.0 | 2.7 (Low) |
| Attack Vector | Network |
| Impact | Denial of Service (Panic) |
| Exploit Status | PoC Available |
Improper Check or Handling of Exceptional Conditions