openssl / openssl/openssl

[robustness] -purpose crlsign|ocsphelper infers aux-blind X509_TRUST_COMPAT (x509_vfy.c:2621) → anchor REJECT blacklist ignored

Open
#32,365 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

branch: master help wanted triaged: feature
Dominant language
C
Stars
30.8k
Forks
11.5k
Avg merge
10m
Merged PRs (30d)
1

Description

Summary

When an OpenSSL 3.5.7 application verifies a certificate chain under the
crlsign or ocsphelper purpose — openssl verify -purpose crlsign …, or the
documented library flow X509_VERIFY_PARAM_set_purpose() +
X509_STORE_set1_param() + a bare X509_STORE_CTX_init() — a REJECT aux entry
on the trust-anchor certificate in the store (the documented blacklist idiom,
openssl x509 -addreject anyExtendedKeyUsage -trustout) is silently never
consulted
: the chain verifies OK against a root the operator explicitly
blacklisted. The mechanism is two stacked defects. First, X509_STORE_CTX_init
carries an init-time purpose→trust inference
(crypto/x509/x509_vfy.c:2621-2627, under its own "XXX: For now" stopgap
comment): when the parameters name a purpose but leave trust at
X509_TRUST_DEFAULT, the trust axis is silently replaced with the purpose
table's default — and exactly two of the ten standard purposes,
X509_PURPOSE_CRL_SIGN and X509_PURPOSE_OCSP_HELPER
(crypto/x509/v3_purp.c:57/:62), map to X509_TRUST_COMPAT. Second, the
COMPAT trust functor trust_compat (crypto/x509/x509_trust.c:240-249)
decides on self-signedness (EXFLAG_SS) alone and never dereferences
x->aux — the reject scan exists only in obj_trust
(crypto/x509/x509_trust.c:256-264), structurally unreachable on the COMPAT
axis. Witnessed end-to-end on the stock release build: openssl verify -purpose crlsign -CAfile store-reject.pem leaf.pem exits 0 with leaf.pem: OK where
exit 2 / error 28: certificate rejected is required; the same store and the
same leaf under the default purpose, -purpose sslserver, or -purpose any
all correctly reject with error 28. A direct C-API witness (no CLI) returns
X509_verify_cert rc=1 on the identical bytes. OpenSSL is the sole outlier
among the tested implementations: BoringSSL and LibreSSL reject the identical
fixtures with error 28; GnuTLS cannot express the control at all. The defect
is live on upstream master (checked 2026-08-11).

AI disclosure: This report was written by a human researcher. Generative AI
tooling was used during investigation and verification; all claims have been
independently confirmed against live PoC output before submission.

OpenSSL versions affected

Tested: OpenSSL 3.5.7 (9 Jun 2026), stock release build. openssl version -a from the tested container (clarinet/openssl-release:3.5.7, image digest
sha256:b2686506412a447214feee546702d0748ca37c99445da4c0054ba6dc3fd62581):

OpenSSL 3.5.7 9 Jun 2026 (Library: OpenSSL 3.5.7 9 Jun 2026)
built on: Sun Aug  9 19:43:34 2026 UTC
platform: linux-x86_64
options:  bn(64,64)
compiler: gcc -fPIC -pthread -m64 -Wa,--noexecstack -Wall -O3 -DOPENSSL_USE_NODELETE -DL_ENDIAN -DOPENSSL_PIC -DOPENSSL_BUILDING_OPENSSL -DNDEBUG
OPENSSLDIR: "/usr/local/openssl/ssl"
ENGINESDIR: "/usr/local/openssl/lib64/engines-3"
MODULESDIR: "/usr/local/openssl/lib64/ossl-modules"
Seeding source: os-specific

The defect is present on upstream master (branch head fetched 2026-08-11):

Component 3.5.7 master
init-time purpose→trust inference ("XXX: For now…" comment intact) crypto/x509/x509_vfy.c:2621-2627 crypto/x509/x509_vfy.c:3018-3027
aux-blind trust_compat crypto/x509/x509_trust.c:240-249 crypto/x509/x509_trust.c:240-249 (line-identical)
purpose table mapping CRL_SIGN / OCSP_HELPER → X509_TRUST_COMPAT crypto/x509/v3_purp.c:57/:62 crypto/x509/v3_purp.c:60/:65

The inference is a long-standing stopgap (its own comment: "XXX: For now,
continue to inherit trust from VPM, but infer from the purpose if this still
yields the default value"), and the COMPAT semantics predate the aux-REJECT
feature entirely (CHANGES.md:18778, 0.9.x era: "X509_TRUST_COMPAT is the old
trust behaviour: only and automatically trust self signed roots in certificate
store."). Every maintained line carrying the inference is expected to be
exposed under the same preconditions; empirical confirmation is 3.5.7-only.

Build configuration

Stock release build, gcc -O3 -DNDEBUG, no sanitizers (the version -a
above). The wrong accept reproduces on this build in both the CLI lanes and a
C-API lane that compiles a small probe in-container against the release
image's static libcrypto.a and calls
X509_VERIFY_PARAM_set_purpose/X509_STORE_set1_param/X509_STORE_CTX_init/X509_verify_cert
directly — so release-liveness is empirical, not inferential. The defect is
pure verification logic: no assert/ossl_assert/OPENSSL_assert sits on the
executed path, and the acceptance oracle (process exit code /
X509_verify_cert return value) is NDEBUG-independent by construction.

Preconditions and assumptions

Effective configuration used in the PoC (CLI witness):

openssl verify -purpose crlsign -no-CApath -no-CAstore \
    -CAfile store-reject.pem leaf.pem
# Precondition Rating Notes
P1 Victim verifies chains with purpose crlsign or ocsphelper via a purpose-bearing X509_VERIFY_PARAM at init (CLI -purpose, or X509_VERIFY_PARAM_set_purpose + X509_STORE_set1_param + bare X509_STORE_CTX_init); the post-init X509_STORE_CTX_set_purpose() route lands on the same axis Moderate Non-default, but these two purposes exist precisely for CRL-signer and OCSP helper-chain validation — the victim class is exactly the deployments that set them. Confined niche: 2 of the 10 xstandard[] purposes map to COMPAT (full-table enumeration, v3_purp.c:46-70); every other purpose lands on an aux-consulting axis.
P2 Operator staged an aux REJECT entry on the anchor (openssl x509 -addreject anyEKU -trustout → TRUSTED CERTIFICATE in the store) Moderate The documented blacklist idiom — the deployment the aux-REJECT feature exists for. (An operator who instead removes the root from the store is not exposed — see Suggested fix / mitigations.)
P3 Attacker chains to / holds a key under the blacklisted root Moderate The premise of blacklisting: the entry exists because the key leaked (or the CA is adversarial). Not an extra assumption.
P4 Anchor is self-signed Easy Standard root-CA shape; required for trust_compat's EXFLAG_SS TRUSTED return. A non-self-signed store root fails trust_compat as UNTRUSTED and the chain does not build at all (fail-closed).
P5 Leaf passes the purpose's own extension checks Easy Attacker-chosen: a leaf with no keyUsage passes crlsign's !ku_reject(x, KU_CRL_SIGN) (v3_purp.c:856); the ocsphelper leaf check returns 1 unconditionally (v3_purp.c:870-873).
Build/config gates None NDEBUG-independent (the focal witness is the stock release image); default provider; default @SECLEVEL (pure trust-axis logic); no OPENSSL_NO_* gate; no FIPS interaction; no verification-flag gap (verification is enabled — the controls reject).

Policy cross-reference
(https://openssl-library.org/policies/general/security-policy/): the
conjunctive precondition stack (non-default niche purpose × operator-staged
aux REJECT entry × the blacklisted-key premise) is the policy's Low row —
"require multiple unlikely conditions to be fulfilled for the attack to
succeed" — and the nearest shape-matching vendor precedents (config-gated
X.509 verification controls silently inoperative) are CVE-2023-0465 and
CVE-2023-0466, both rated "Severity: Low" in their advisory
(secadv/20230328.txt, fetched verbatim 2026-08-11), as is the same-feature-family
CVE-2025-4575 (secadv/20250522.txt: "Severity: Low"). Per the policy, Low
issues "might be developed and fixed in public", so this report is filed for
the public track; no confidential handling is requested.

Attacker's abilities

Actor inventory (A3 = a crafted-file provider, A5 = the target's
operator/config channel, A8 = an embedding application):

# Actor Supplies Trust class
A3 crafted-file provider (CLI witness) leaf.pem chain anchored at the blacklisted root untrusted (AV:L)
A8 embedding application conduit (library framing) attacker-supplied chain into X509_verify_cert with purpose crlsign/ocsphelper (CRL-DP / OCSP helper-chain validators — chain typically fetched over the network) untrusted data via documented API
A5 operator/toolchain the -purpose crlsign invocation / VPM purpose (the legitimate configuration for a CRL or OCSP-helper validator) TRUSTED — but a precondition, not the trigger
A5 operator store-reject.pem — the aux REJECT entry (openssl x509 -addreject anyEKU -trustout) TRUSTED — the victim's deployed defensive control being voided

The two TRUSTED rows are the victim's own configuration and defensive control
— neither is the attack payload; the data that gets wrongly accepted (the
chain) arrives from the untrusted side. For the strongest untrusted actor
(A8/A3 chain supplier):

  • Baseline: a chain anchored at a REJECT-listed root is rejected with
    error 28 / exit 2 — as the controls (default purpose, sslserver, any)
    and both aux-consulting peers (BoringSSL, LibreSSL) demonstrate on identical
    bytes.
  • With bug: the same chain is ACCEPTED (exit 0, leaf.pem: OK,
    X509_verify_cert rc=1) whenever the verifier runs with purpose
    crlsign/ocsphelper — the operator's blacklist of a (presumed-compromised)
    CRL-signing/OCSP-helper root is silently void.
  • Delta: defeat of the documented MUST-reject enforcement that the
    library's own trust code intends to perform — and does perform on every
    other purpose axis over the identical store. The witness is the acceptance
    oracle, not a crash.
Impact

Why this lands at policy Low rather than a confidential security track:
the bypass is real (a documented MUST-reject accepted), but it fires only
under the conjunction of a non-default niche purpose selection, an
operator-staged aux REJECT entry, and the leaked-key premise that motivated
the blacklist — squarely the policy's Low row ("require multiple unlikely
conditions to be fulfilled for the attack to succeed"), with the two
shape-matching vendor precedents (CVE-2023-0465, CVE-2023-0466) and the
same-feature-family CVE-2025-4575 all rated "Severity: Low" in their
advisories (fetched verbatim 2026-08-11). For completeness: an internal review
pass argued for Moderate by analogy to "the CVE-2023-0464/0465/0466
policy-processing trio"; that anchor does not hold because all three of those
CVEs are rated Low upstream (secadv/20230322.txt, secadv/20230328.txt), and
the sibling wrong-accept Moderate precedent (CVE-2022-1343, "Severity:
Moderate", secadv/20220503.txt) concerns the default OCSP
response-verification flow, whereas this defect's consumers are specialized
explicit-purpose selectors. Both readings are stated here so the SRT need not
re-derive the tier.

  • Integrity: the verification decision is wrong for exactly the class the
    operator's blacklist speaks about. Two consumer narratives funnel through
    the identical sink:
    • crlsign — a validator that checks a CRL issuer's chain with purpose
      crlsign accepts a chain anchored at a root the operator blacklisted. One
      step downstream (analytical, not demonstrated here): the holder of the
      leaked root key issues a fresh CRL-signing certificate and signs forged
      CRLs that omit revoked serials — revocation suppression, keeping other
      compromised credentials under the hierarchy accepted by every relying
      party whose revocation input is that CRL, for the CRL's nextUpdate
      window, at zero marginal cost per consumer.
    • ocsphelper — a validator checking an OCSP responder/helper chain with
      purpose ocsphelper accepts the blacklisted hierarchy; one step downstream,
      forged attacker-signed OCSP responses ("good" status for revoked
      certificates) pass responder-chain validation. The stakes are RFC-pinned:
      RFC 6960 §4.2.2.2.1 — "CAs issuing such a certificate should realize that
      a compromise of the responder's key is as serious as the compromise of a
      CA key used to sign CRLs, at least for the validity period of this
      certificate." (fetched verbatim 2026-08-11). What is demonstrated is the
      wrong chain-accept itself.
  • Availability: none. No crash, hang, or resource effect in any lane; the
    witness processes exit normally.
  • Confidentiality: none intrinsic.
  • Silence property: the wrong accept prints the identical leaf.pem: OK a
    legitimate accept prints — no log line, error-stack entry, or exit-code
    residue distinguishes "blacklist consulted and passed" from "blacklist never
    consulted". The same store and leaf under the default purpose exit 2, so
    nothing but the -purpose flag differs; there is nothing for an operator's
    monitoring to reconcile.
  • Persistence: the wrong decision is stateless (recomputed per
    verification; the store is read-only during verification; no cache/SSL_CTX
    mutation). The exposure window persists until patch or mitigation. Note the
    amplifier split versus typical TLS verification bugs: there is no
    session/resumption amplifier, because the blind axis is unreachable from TLS
    purposes (sslserver/sslclient map to the aux-consulting trust_1oidany
    functor — PoC lane c2 rejects with error 28); the amplification lives
    downstream in the blessed artifact's validity window, not in verifier state.

Blast radius as a library: the defect executes in libcrypto's
X509_STORE_CTX_initX509_check_trusttrust_compat, and the C-API
lane proves the documented library flow reproduces it without the CLI — so the
affected set is any application embedding libcrypto 3.5.7 that validates
chains under purpose crlsign or ocsphelper against a trust store carrying aux
REJECT entries
— CRL-signer chain validators and OCSP helper-chain
validators being the canonical classes. The CLI witness is openssl verify -purpose crlsign|ocsphelper. There is no TLS-handshake twin: TLS verification
purposes never select the COMPAT axis.

Quantified witness summary: one wrong accept per ~1 KB PEM chain; attacker
cost is one ordinary, cryptographically valid certificate chain (no malformed
bytes, no timing, no MITM); 100% deterministic (the PoC is two cold full runs
plus one audit re-run, fresh keys each run, byte-identical results).

Steps to reproduce

The attached docker-compose.yml reproduces the full environment (focal 3.5.7
release CLI node, focal C-API node, and the LibreSSL/GnuTLS/BoringSSL peers)
on an isolated Docker network; run.sh regenerates all fixtures with fresh
keys and runs every lane (about 90 seconds). The manual reproducer:

  1. Mint the fixtures with stock tooling (the attached gen/gen.sh does all of
    this with self-checks; the essential shape):
    # Self-signed CA root:
    openssl req -x509 -newkey rsa:2048 -keyout ca-key.pem -out ca.pem \
        -days 3650 -nodes -subj "/CN=f015-triage-ca" \
        -addext "basicConstraints=critical,CA:true" \
        -addext "keyUsage=critical,keyCertSign,cRLSign"
    # End-entity leaf (CA:false, NO keyUsage, NO EKU):
    openssl req -newkey rsa:2048 -keyout leaf-key.pem -out leaf.csr \
        -nodes -subj "/CN=f015-triage-ee"
    printf "basicConstraints=critical,CA:false\n" > leaf.ext
    openssl x509 -req -in leaf.csr -CA ca.pem -CAkey ca-key.pem -CAcreateserial \
        -days 3650 -out leaf.pem -extfile leaf.ext
    # Two store variants of the SAME CA:
    cp ca.pem store-clean.pem
    openssl x509 -in ca.pem -addreject anyExtendedKeyUsage -trustout \
        -out store-reject.pem
    
    The blacklist entry is verifiable independently of the bug:
    openssl asn1parse -in store-reject.pem shows the aux trailer
    cont [0] → OBJECT : Any Extended Key Usage (DER
    30 08 a0 06 06 04 55 1d 25 00 — an X509_CERT_AUX sequence whose
    implicit-[0] reject list carries the anyEKU OID, per the template at
    crypto/x509/x_x509a.c:26-32).
  2. Bug arms:
    openssl verify -purpose crlsign    -no-CApath -no-CAstore \
        -CAfile store-reject.pem leaf.pem
    openssl verify -purpose ocsphelper -no-CApath -no-CAstore \
        -CAfile store-reject.pem leaf.pem
    
  3. Expected (correct): exit 2, error 28 at 1 depth lookup: certificate rejected. Actual (bug): exit 0, leaf.pem: OK.
  4. Discriminating controls on the identical bytes, all behaving correctly:
    same store with no -purpose → exit 2, error 28 (the entry is loaded and
    potent on the default axis); -purpose sslserver → exit 2, error 28 (an
    aux-consulting axis with a purpose named — the mechanism discriminator);
    -purpose any → exit 2, error 28 (ANY maps to DEFAULT); either bug-arm
    purpose against store-clean.pem → exit 0 (construction is sound: the
    purpose checks themselves pass).
  5. Library framing (optional, proves the defect is below the CLI): compile the
    attached verify_capi_probe.c against the static libcrypto
    (gcc -I/usr/local/openssl/include verify_capi_probe.c /usr/local/openssl/lib64/libcrypto.a -lpthread -ldl -o probe) and run
    probe store-reject.pem leaf.pem crlsign → prints
    purpose=crlsign eff_purpose=6 rc=1 err=0 (ok) — the documented flow
    X509_VERIFY_PARAM_set_purposeX509_STORE_set1_param → bare
    X509_STORE_CTX_initX509_verify_cert returns accept on the
    blacklisted-anchor chain; the same probe with default, sslserver, or
    any prints rc=0 err=28 (certificate rejected).

Attached artifacts (paths relative to the PoC directory):

File Role
gen/gen.sh fixture generation with self-checks (runs on the focal image; gates on the asn1 trailer decode and on the default-axis control rejecting before proceeding)
nodes/verify_batch.sh the full CLI lane table (7 lanes + 2 replay lanes), dual-keyed for the LibreSSL peer
verify_capi_probe.c ~90-line direct-X509_verify_cert witness for the library flow
nodes/capi.sh, nodes/gnutls.sh, nodes/boringssl.sh, boringssl_purpose_probe.cc per-node lane runners + the BoringSSL purpose-table feature probe
artifacts/results.json machine-readable verdicts for every node/lane (run completed 2026-08-11T03:45:44Z)
artifacts/gen/ the generated CA/leaf PEMs, both store variants, the asn1parse dump, and sha256 manifest
artifacts/batch/, artifacts/capi/ per-lane stdout/stderr/exit-code files for every node
What is the current bug behavior?

With -purpose crlsign (or ocsphelper) and a trust store whose anchor
carries an aux REJECT entry, X509_STORE_CTX_init silently replaces the trust
axis: the CLI's option glue sets purpose only (apps/lib/opt.c:750-769
X509_VERIFY_PARAM_set_purpose, crypto/x509/x509_vpm.c:298-300, which
touches only param->purpose; trust stays X509_TRUST_DEFAULT), the app
bare-inits the context (apps/verify.c:294; grep-verified: no
set_trust/set_purpose/set_default call exists in apps/verify.c), and
the inference at crypto/x509/x509_vfy.c:2621-2627 maps the purpose to
X509_TRUST_COMPAT (crypto/x509/v3_purp.c:57/:62). The anchor loop
(crypto/x509/x509_vfy.c:967-975) then dispatches X509_check_trust(root, COMPAT, 0) to trust_compat (crypto/x509/x509_trust.c:240-249), which
returns TRUSTED on the self-signed root without ever reading x->aux. The
later re-consult inside check_purpose (x509_vfy.c:549-550, with
X509_TRUST_NO_SS_COMPAT) yields UNTRUSTED — still never REJECTED, because
trust_compat is structurally incapable of producing REJECTED — so the chain
falls through to the plain purpose checks, which pass. The CLI exits 0 with
leaf.pem: OK; the C-API probe returns X509_verify_cert rc=1. The operator's
blacklist is armed, loaded, and enforced on every other purpose axis over the
identical bytes — and silently void on exactly the two purposes whose
canonical consumers are CRL-signer and OCSP helper-chain validators.

What is the expected correct behavior?

The aux trust/reject machinery is an OpenSSL-specific feature, so the violated
norm is OpenSSL's own documented contract (doc/man1/openssl-verification-options.pod,
local 3.5.7 tree, verbatim): a certificate "is considered a trust anchor for
the given use if and only if" (:80-81) among other conditions "It does
not have a negative trust attribute rejecting the given use.
" (:90-91), and
"As of OpenSSL 1.1.0, the last of these [anyExtendedKeyUsage] blocks all
uses when rejected
" (:75-76). doc/man1/openssl-x509.pod.in:583 scopes the
feature — "Trust settings currently are only used with a root CA." — i.e. an
anchor-resident REJECT entry is the documented in-scope case, and
-addreject (:627) "Adds a prohibited trust anchor purpose." The correct
behavior is therefore: the anchor loop's X509_check_trust(root, …) returns
X509_TRUST_REJECTED, the chain fails with X509_V_ERR_CERT_REJECTED (28) at
depth 1, and the CLI exits 2 — exactly what the default/sslserver/any
control lanes produce on the same store, and what BoringSSL and LibreSSL
produce on the identical fixtures. The code-vs-contract mismatch is the
defect; if the COMPAT exemption were deliberate, the honest contract would
document it (nothing does — CHANGES.md:18778 documents the axis's
self-signed semantics from the 0.9.x era, predating the aux-REJECT feature,
and the inference itself carries an "XXX: For now" stopgap comment).

Relevant logs

All logs are in the attached artifacts/ tree from a single containerized run
completed 2026-08-11 (results roll-up stamped 03:45:44 UTC); lanes execute
sequentially per node, so per-lane file ordering plus the results.json
roll-up is the correlation key. Primary references:

  • artifacts/results.json — the full machine-readable matrix (5 nodes: focal
    CLI, focal C-API, LibreSSL, GnuTLS, BoringSSL), with per-lane exit codes and
    stdout/stderr captures.
  • artifacts/batch/openssl-3.5.7.b1-crlsign-reject.{code,out,err} — stock
    CLI, bug arm: exit 0, /artifacts/gen/leaf.pem: OK (the wrong accept);
    b2-ocsphelper-reject.* identical.
  • artifacts/batch/openssl-3.5.7.c1-default-reject.* — control: exit 2,
    stderr CN=f015-triage-ca\nerror 28 at 1 depth lookup: certificate rejected;
    c2-sslserver-reject.* and c3-any-reject.* identical (aux entry potent on
    every aux-consulting axis).
  • The r1/r2 lanes replay the byte-identical artifacts captured during the
    original discovery (independently minted CA/leaf/store with the same
    structure) and record the same split: the -purpose crlsign arm against the
    reject store exits 0, and the no-purpose control against the same store
    exits 2 with error 28.
  • artifacts/capi/openssl-3.5.7-capi.b1-crlsign-reject.out — library witness:
    purpose=crlsign eff_purpose=6 rc=1 err=0 (ok); the c1/c2/c3 probe lanes
    print rc=0 err=28 (certificate rejected).
  • artifacts/gen/asn1.txt — the reject store's aux trailer decode
    (cont [ 0 ] → OBJECT : Any Extended Key Usage);
    artifacts/gen/sha256.txt — fixture fingerprints.
  • Peer logs under artifacts/batch/libressl-4.3.1.* (error 28 on all
    reject-store lanes), artifacts/batch/gnutls-3.8.9.* (control verifies;
    TRUSTED CERTIFICATE store unloadable), artifacts/batch/boringssl.* (err=28
    on all reject-store lanes) + boringssl.featureprobe.txt (its purpose table
    also maps both purposes to COMPAT — the divergence is the missing inference).
Coordination
  • Does this issue affect multiple implementations?

Every node below was executed against the live PoC on byte-identical fixtures
(full data in artifacts/results.json):

Node Version Result
OpenSSL (stock release, CLI verify) 3.5.7 (9 Jun 2026) VULNERABLE — bug arms b1/b2 and the discovery-fixture replay accept the blacklisted-anchor chain; all five controls correct
OpenSSL (C-API library arm) 3.5.7 (release static libcrypto) VULNERABLEX509_verify_cert rc=1 on both bug arms; err=28 on default/sslserver/any
LibreSSL 4.3.1 Not vulnerable — rejects both bug arms with error 28 at depth 1 (its refactored init lacks, or differs in, the purpose→trust inference). Note the mechanism-level split: LibreSSL shares the sibling OCSP-path trust-axis defect reported separately (fork-inherited ocsp_vfy.c) but not this init-inference route — evidence the two triggers are disjoint
GnuTLS 3.8.9 (certtool) Not vulnerable — feature-absent — chain verification works with the fixtures (plain-CA control verifies), but GnuTLS has no aux-REJECT concept and cannot parse TRUSTED CERTIFICATE blocks (gnutls_x509_trust_add_trust_file: No certificate was found.); the control is unexpressible
BoringSSL 922245af6eda (2026-08-07) Not vulnerable (the informative control) — its purpose table also maps crlsign/ocsphelper to X509_TRUST_COMPAT (v3_purp.cc:79/:84, feature-probe artifact) and its trust_compat is likewise aux-blind, but its X509_STORE_CTX_init (x509_vfy.cc:1509-1549) contains no purpose→trust inference, so trust stays DEFAULT and obj_trust(anyEKU) enforces the reject — err=28 on both bug arms, exactly like the default axis

OpenSSL 3.5.7 is the sole outlier. The divergence does not suggest a
protocol-spec ambiguity: the aux trust/reject machinery is an
OpenSSL-specific feature, the violated norm is OpenSSL's own documented
contract, and BoringSSL's behavior pinpoints the defect to the inference block
rather than the COMPAT functor alone — no IETF note is warranted.

  • Have you shared the information with anyone else?

No. This report is the first disclosure. (The LibreSSL datum above is an
observation about a peer's resistance to this defect, not a shared-finding
notification; no peer is affected by this mechanism.)

  • What is your plan to publicize this issue?

No publication plan beyond the project's own handling. Given the policy-Low
assessment, filing as a public GitHub issue is the intended channel from my
side; this report is simultaneously a courtesy copy to the Security Response
Team.

Suggested fix

Make trust_compat (crypto/x509/x509_trust.c:240-249) consult the aux
reject list before the self-signed TRUSTED return — the documented
"anyExtendedKeyUsage blocks all uses when rejected" semantics applied to the
one axis that currently never looks:

--- a/crypto/x509/x509_trust.c
+++ b/crypto/x509/x509_trust.c
@@ -240,8 +240,19 @@
 static int trust_compat(X509_TRUST *trust, X509 *x, int flags)
 {
     /* Call for side-effect of setting EXFLAG_SS for self-signed-certs */
+    X509_CERT_AUX *ax = x->aux;
+    int i;
+
     if (X509_check_purpose(x, -1, 0) != 1)
         return X509_TRUST_UNTRUSTED;
+
+    /* A blanket aux reject (anyEKU) blocks every use, this axis included. */
+    if (ax != NULL && ax->reject != NULL)
+        for (i = 0; i < sk_ASN1_OBJECT_num(ax->reject); i++)
+            if (OBJ_obj2nid(sk_ASN1_OBJECT_value(ax->reject, i))
+                    == NID_anyExtendedKeyUsage)
+                return X509_TRUST_REJECTED;
+
     if ((flags & X509_TRUST_NO_SS_COMPAT) == 0 && (x->ex_flags & EXFLAG_SS))
         return X509_TRUST_TRUSTED;
     else

All needed types and macros are already in scope in this translation unit
(obj_trust in the same file dereferences x->aux->reject and calls
sk_ASN1_OBJECT_num/OBJ_obj2nid at :256-262; NID_anyExtendedKeyUsage is
used at :261). With the fix, the anchor loop's if (trust == X509_TRUST_REJECTED) goto rejected; (x509_vfy.c:973-974) produces error 28
via the existing rejected: path (x509_vfy.c:1024-1027) — no new plumbing.

Why the functor site and not only the trigger: fixing only the inference at
x509_vfy.c:2621-2627 closes the store-VPM route but leaves the second,
disjoint route into the same blind axis open — callers that set the purpose
post-init via X509_STORE_CTX_set_purpose() route through
X509_STORE_CTX_purpose_inherit's trust fill (x509_vfy.c:2446-2447 applied
at :2459-2460) and likewise land on COMPAT (the in-tree instance is
crypto/ocsp/ocsp_vfy.c:57). The trust_compat consult closes every route
into the axis; defense in depth would do both. Correct behavior is unaffected:
a certificate without aux entries takes the same path as today; a certificate
with an anyEKU trust entry is untouched (the trust list is not consulted
here, as before); and a certificate with a use-specific reject (e.g.
serverAuth) is also untouched — COMPAT carries no use identifier, so the
minimal correct semantic is the documented blanket-reject entry. The only
behavior change is that a blanket-blacklisted self-signed certificate stops
being "compatibly trusted" — which is the documented contract. Note for
review: obj_trust's tail calls trust_compat(NULL, x, flags) (:297) only
after its own reject scan, so the added scan cannot fire twice or change
DEFAULT-axis outcomes.

Operator mitigations on unpatched builds (each checked against the PoC's own
controls; none restores the documented semantics under the two purposes):
(1) remove the blacklisted root from the store entirely — chain building fails
closed with error 20 regardless of the trust axis; (2) run the blacklist
decision under the default purpose and the purpose check separately (two-pass)
— the REJECT entry is enforced on the default/sslserver/any axes (PoC
lanes c1/c2/c3); (3) library callers can set an explicit trust axis with
X509_VERIFY_PARAM_set_trust() — the inference fires only when trust is
X509_TRUST_DEFAULT — but note the verify CLI exposes no -trust
selector, so CLI users must use (1) or (2); (4) avoid -purpose crlsign|ocsphelper when REJECT semantics are load-bearing.

Regression coverage fits test/recipes/25-test_verify.t: the suite already
plumbs -purpose and exercises trust/mistrust fixture lanes (:60-106), but
every purpose lane uses sslserver/sslclient — no lane combines a REJECT-carrying
anchor with the two COMPAT-mapped purposes. A lane asserting that
-purpose crlsign and -purpose ocsphelper against an anyEKU-rejected anchor
fail with error 28 runs red before the fix and green after.

Acknowledgements

Qifan Zhang, Palo Alto Networks.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with crypto/x509/x509_vfy.c at the purpose-to-trust inference, then read trust_compat in crypto/x509/x509_trust.c and the purpose mappings in crypto/x509/v3_purp.c. Reproduce with the documented openssl verify crlsign command and the direct C API witness; done means REJECT anchors produce error 28 for crlsign and ocsphelper rather than verifying successfully.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
cryptography, security
Issue type
Bug
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
30/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.