Skip to content

fix: bound attacker-controlled work factors and break issuer cycles during PKCS#12 import - #11

Merged
ancwrd1 merged 2 commits into
ancwrd1:mainfrom
pablobaro:hardening-guards
Jul 16, 2026
Merged

ancwrd1 merged 2 commits into
ancwrd1:mainfrom
pablobaro:hardening-guards

Conversation

@pablobaro

@pablobaro pablobaro commented Jul 15, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Importing an untrusted PKCS#12 container currently allows two denial-of-service vectors, both reachable before password verification completes — so an attacker only needs to get a crafted .p12/.pfx file parsed, not decrypted. I hit both while integrating p12-keystore as the PKCS#12 backend of an offline PDF-signing application, where the PFX file is untrusted user input.

Two focused commits, each with a dedicated rejection test. The full existing suite still passes (42 tests, unchanged).

1. Issuer-cycle hang → memory exhaustion (src/keystore.rs)

KeyStore::from_pkcs12 builds each certificate chain by repeatedly looking up the certificate whose subject matches the current issuer, with no record of which certificates it has already visited:

while let Some(issuer) = parsed_certs
    .iter()
    .find(|c| c.cert.subject == entry.cert.issuer && !c.trusted)
{
    // ... push issuer, continue ...
}

If two certificates name each other as issuer (cert A issued by B, cert B issued by A — trivial to craft, and also producible by accident with cross-signed CAs), the lookup alternates between them forever. The certs vector grows on every iteration until the process is OOM-killed. This is reachable purely from parsing an untrusted container.

Fix: track visited certificates by their DER identity and stop when an issuer repeats. Acyclic chains keep exactly their previous shape — the change only adds a terminating condition to a loop that could previously run unbounded.

let mut visited = BTreeSet::from([entry.cert.as_der()]);
while let Some(issuer) = /* ... */ {
    if !visited.insert(issuer.cert.as_der()) {
        break;
    }
    // ...
}

2. Unbounded KDF work factors → CPU / memory exhaustion (src/codec.rs)

The MAC iteration count, the PBES1 iterations, the PBES2/PBKDF2 iterationCount, and all three scrypt parameters are read straight from the container DER with no upper bound. A crafted file can therefore request:

  • up to i32::MAX / u32::MAX KDF rounds → the key-derivation runs for effectively forever (CPU exhaustion), or
  • scrypt parameters whose memory cost 128 * N * r bytes is unbounded → immediate memory exhaustion / abort. Note this is not just N: the block size r and parallelism p are u16 values that also come straight from the DER, so bounding N alone leaves the cost unbounded via a large r (e.g. a modest N with r = 65535).

Worth flagging explicitly, because it's easy to assume pkcs5 already covers this: pkcs5 only enforces its MAX_ITERATION_COUNT inside the Pbkdf2Params::hmac_sha256 builder. The DER decode path (TryFrom<AnyRef>) fills the parameters without any check, so values parsed from an untrusted container are never bounded unless the consumer bounds them. That's why the guard has to live here.

Fix: before any derivation runs, reject with Error::InvalidParameters:

  • iteration counts (MAC, PBES1, PBES2/PBKDF2) above 1,000,000;
  • scrypt effective memory 128 * N * r above 1 GiB, computed with checked_mul so an overflowing product is rejected rather than wrapping;
  • scrypt parallelism p above 16.

The limits are deliberately generous — well above what mainstream tooling produces (OpenSSL's PKCS#12 default is 2048 iterations) — so no interoperable, honestly-produced container is rejected.

Testing

  • cargo test — 44 passing, including a rejection test per guard: cyclic-chain import terminates; over-limit MAC, PBES1, and PBES2/PBKDF2 iterations rejected; and scrypt rejected for an oversized N, for a large block size r with an otherwise-valid N, and for excessive parallelism p — each before the KDF runs.
  • Both fixes have been running in production (vendored) against real and adversarial PFX inputs.

Happy to adjust the limits, split the commits differently, or add more test vectors if you'd prefer.

Comment thread src/keystore.rs Resolved
@pablobaro

Copy link
Copy Markdown
Contributor Author

Good call — done, pushed as an update to the first commit. Certificate already derives PartialEq, so certs.contains(&issuer.cert) works directly and drops the extra set plus its import.

One thing I double-checked while switching: the loop skips pushing an issuer whose subject == leaf_cert.subject, so such a cert never lands in certs. That turns out to be safe — to keep the chain going past it, its issuer must differ from its subject (otherwise the existing self-signed break fires), which means the next hop is a non-self-signed cert that does get pushed and so is caught on its second appearance. The two-cert A↔B cycle test still passes.

Comment thread src/codec.rs Outdated
/// Returns `true` when attacker-controlled scrypt parameters stay within the
/// memory and parallelism budget. The effective memory cost is `128 * N * r`
/// bytes; `checked_mul` treats an overflowing product as over budget, since
/// such parameters are by definition far past the limit.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This AI-generated comment looks completely redundant because the function name already explains it.

Comment thread src/codec.rs Outdated

/// Upper bound for attacker-controlled PBES1/PBES2 KDF iteration counts read
/// from container DER; deliberately far above counts produced by mainstream
/// tooling, so no interoperable container is rejected.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rustdoc-style comments (///) are not needed for non-public members. Replace with normal comments (and below as well).

Comment thread src/keystore.rs Outdated
// Stop if this issuer is already in the chain: an issuer
// cycle (A issued by B, B issued by A) would otherwise loop
// forever. The chain is tiny (typically <= 3), so a linear
// scan is cheaper than a separate visited set.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment is too verbose, we don't want every change to be documented like this. It could be skipped completely or be just // prevent infinite loop in case of cycle certificate chains

Comment thread src/codec.rs Outdated
}

/// Builds a PBES2/scrypt AlgorithmIdentifier, letting the caller tamper
/// with the decoded scrypt parameters before re-encoding.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replace with normal comments.

KeyStore::from_pkcs12 follows issuer names without checking whether a
certificate has already been seen. A container whose certificates form
an issuer cycle (A issued by B, B issued by A) makes the resolution
loop alternate between them forever, growing the chain until memory is
exhausted.

Stop when an issuer already present in the collected chain reappears.
The chain is typically <= 3 elements, so a linear scan is cheaper than
a separate visited set. Acyclic chains keep their previous shape.
The MAC iteration count, the PBES1 and PBES2/PBKDF2 iteration counts,
and the scrypt parameters are all read from container DER without
bounds. A crafted PKCS#12 file can request up to i32::MAX/u32::MAX KDF
rounds (CPU exhaustion), or scrypt parameters whose 128*N*r memory cost
is unbounded — note that N, the block size r, and the parallelism p all
come from the DER, so bounding N alone is insufficient.

Reject iteration counts above 1,000,000, scrypt effective memory
(128*N*r) above 1 GiB via checked_mul, and scrypt parallelism above 16,
all with Error::InvalidParameters before any derivation runs. Covered
by rejection tests for each path.
@pablobaro

Copy link
Copy Markdown
Contributor Author

Done — addressed all four: /// on private items switched to plain //, the redundant scrypt_within_limits doc removed, the test helper's comment de-doc'd, and the cycle-guard comment cut down to // prevent infinite loop in case of cycle certificate chains. Pushed.

@ancwrd1
ancwrd1 merged commit 00ad559 into ancwrd1:main Jul 16, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants