Feb 14, 2026·7 min read·11 visits
The `rPGP` library (via the `rsa` crate < 0.9.10) contains a logic flaw in validating RSA private key components. By providing a crafted Secret Key Packet where one of the prime factors is set to 1, the library attempts to calculate modulus inverses using (p-1), resulting in a division-by-zero panic. This crashes the application process, leading to a Denial of Service (DoS) against keyservers, email clients, or signing services using the library.
In the world of safe systems programming, Rust is the golden child. It promises to save us from the memory corruption sins of C and C++. But while Rust protects memory, it doesn't protect logic. A critical denial-of-service vulnerability was discovered in the `rPGP` library (and its dependency, the `rsa` crate) where a mathematically impossible RSA key component triggers a hard panic. By setting a prime factor to '1', an attacker can trick the underlying arithmetic engine into a division-by-zero scenario, crashing any application attempting to parse the key. This is a story about how 'safe' languages still need defensive coding.
We love Rust. It gives us warm fuzzy feelings about memory safety. No more use-after-free, no more buffer overflows, just pure, borrow-checked bliss. But here's the dirty secret: panic! is safe. Memory-safe, that is. If a Rust program encounters an unrecoverable state, it unwinds the stack and dies. In a command-line tool, that's annoying. In a high-availability web service processing thousands of PGP keys, that's a disaster.
rPGP is the premier pure-Rust implementation of OpenPGP. It's designed to replace GnuPG in modern stacks. It handles the messy, decades-old RFC 4880 standard so you don't have to. But parsing OpenPGP is like walking through a minefield while blindfolded. The format allows for complex nested packets, and deeply buried within those packets are the cryptographic primitives: RSA, DSA, Ed25519.
This vulnerability isn't about memory corruption. It's about math. specifically, the kind of math that breaks computers. It resides in how the underlying rsa crate handles the components of a private key. It turns out, if you lie to the library about your prime numbers, you can trick it into committing the cardinal sin of arithmetic: dividing by zero. The result? The application vanishes into the void.
To understand this bug, we have to go back to RSA 101. An RSA private key consists of a modulus $n$, a public exponent $e$, and a private exponent $d$. But to optimize calculations (specifically using the Chinese Remainder Theorem, or CRT), the private key usually also stores the two prime factors, $p$ and $q$, such that $n = p \times q$.
When rPGP parses a Secret-Key Packet (Tag 5), it extracts these numbers and passes them to the rsa crate to build an RsaPrivateKey object. The rsa crate, being responsible, tries to validate these numbers. You can't just accept any garbage integers; they have to look like primes.
Here lies the error. In rsa versions prior to 0.9.10, the validation logic checked if the primes were "too small". Specifically, it checked if a prime was effectively zero. But BigUint (unsigned big integers) don't have negative numbers. The check looked something like if prime < 1. This catches 0. But it allows 1.
Why is 1 dangerous? Because nearly every RSA-CRT optimization involves the value $(p-1)$. For example, Euler's totient function $\phi(n) = (p-1)(q-1)$. If $p=1$, then $p-1=0$. Suddenly, you are calculating modulo 0 or dividing by 0. The num-bigint library, which handles the heavy lifting, essentially says "I can't do that" and triggers a thread-terminating panic.
Let's look at the smoking gun. This is found in src/key.rs of the rsa crate. The developers intended to ensure the prime was valid, but they missed an edge case.
The Vulnerable Code (rsa < 0.9.10):
// Iterate over the primes provided in the key components
for prime in &self.primes {
// ALERT: This only errors if prime is 0.
// If prime is 1, this check passes!
if *prime < BigUint::one() {
return Err(Error::InvalidPrime);
}
m *= prime;
}
// ... later, (prime - 1) causes panic ...Because BigUint is an unsigned integer type, < 1 is strictly equivalent to == 0. The integer 1 slips past the guard. Moments later, the code attempts to compute derived values, likely involving prime - 1 for CRT parameters. When that subtraction results in a BigUint of zero, and that zero is used as a divisor, the program crashes.
The Fix (Commit 2926c91):
for prime in &self.primes {
// The fix: change '<' to '<='
// Now, if prime is 0 OR 1, it rejects it.
if *prime <= BigUint::one() {
return Err(Error::InvalidPrime);
}
m *= prime;
}It is almost comical that a single equals sign = stands between a stable server and a denial of service. This highlights a classic issue in input validation: checking for the presence of data rather than the semantic validity of data.
Exploiting this is trivial for anyone who understands the OpenPGP packet format (RFC 4880). We don't need to break encryption; we just need to construct a structurally valid but mathematically suicidal packet.
Secret-Key Packet (Tag 5).d: Arbitrary integer.p: Set this to 1.q: Arbitrary integer.u: Arbitrary integer.When rPGP reads this packet, it deserializes the MPIs. It sees that the key is unencrypted, so it attempts to construct the RsaPrivateKey struct immediately to validate it. It calls RsaPrivateKey::from_components(n, e, d, vec![p, q]).
Inside that function, p (which is 1) passes the Check < 1. Then, the math engine attempts a modular inverse or reduction. BOOM. The panic unwinds the stack. If the calling application hasn't explicitly wrapped this in a catch_unwind (and most don't, because libraries shouldn't panic on inputs), the process terminates.
Why does this matter? "It's just a crash," you say. In the modern cloud era, a crash is a vulnerability.
Consider the following scenarios:
rPGP to sign artifacts. If a malicious dependency or config file injects this key, the build pipeline collapses.This is an unauthenticated remote DoS. It requires zero privileges. The attacker just needs to get the bytes into the parser. While it doesn't leak data (Confidentiality) or allow code execution (Integrity), it completely compromises Availability.
The fix is straightforward but requires action across the dependency tree. The vulnerability is technically in rsa, but pgp (rPGP) is the vector.
For Rust Developers:
cargo tree -i rsa. If you see rsa v0.9.9 or lower, you are vulnerable.cargo update -p rsa. This should pull in v0.9.10 or later, which contains the fix (if *prime <= BigUint::one()).Cargo.lock shows the new version.For System Administrators:
If you are running compiled binaries (like rpgp-cli or custom internal tools), you must recompile them with the updated dependencies. There is no runtime configuration workaround for this, as the panic happens deep inside the compiled logic.
Lesson Learned:
Never assume your cryptographic primitives are doing semantic validation for you. If you are parsing complex structures, wrap them in panic handlers or, better yet, audit the libraries you rely on to ensure they return Result::Err rather than panic! on bad user input.
CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N| Product | Affected Versions | Fixed Version |
|---|---|---|
rsa RustCrypto | < 0.9.10 | 0.9.10 |
pgp rPGP | < 0.14.0 | Dependent on rsa update |
| Attribute | Detail |
|---|---|
| CVE ID | CVE-2026-21895 |
| CWE ID | CWE-703 (Unhandled Exception) |
| CVSS | 7.5 (High) |
| Attack Vector | Network |
| Impact | Denial of Service (Process Crash) |
| Root Cause | Division by Zero Panic |
The software does not handle or incorrectly handles an exceptional condition.