[robustness] -purpose crlsign|ocsphelper infers aux-blind X509_TRUST_COMPAT (x509_vfy.c:2621) → anchor REJECT blacklist ignored
Nobody has claimed this yet.
- 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_certrc=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'snextUpdate
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.
- crlsign — a validator that checks a CRL issuer's chain with purpose
- 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: OKa
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-purposeflag 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-consultingtrust_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_init → X509_check_trust → trust_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:
- Mint the fixtures with stock tooling (the attached
gen/gen.shdoes all of
this with self-checks; the essential shape):
The blacklist entry is verifiable independently of the bug:# 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
openssl asn1parse -in store-reject.pemshows the aux trailer
cont [0] → OBJECT : Any Extended Key Usage(DER
30 08 a0 06 06 04 55 1d 25 00— anX509_CERT_AUXsequence whose
implicit-[0] reject list carries the anyEKU OID, per the template at
crypto/x509/x_x509a.c:26-32). - 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 - Expected (correct): exit 2,
error 28 at 1 depth lookup: certificate rejected. Actual (bug): exit 0,leaf.pem: OK. - 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 againststore-clean.pem→ exit 0 (construction is sound: the
purpose checks themselves pass). - Library framing (optional, proves the defect is below the CLI): compile the
attachedverify_capi_probe.cagainst 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_purpose→X509_STORE_set1_param→ bare
X509_STORE_CTX_init→X509_verify_certreturns accept on the
blacklisted-anchor chain; the same probe withdefault,sslserver, or
anyprintsrc=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,
stderrCN=f015-triage-ca\nerror 28 at 1 depth lookup: certificate rejected;
c2-sslserver-reject.*andc3-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 crlsignarm 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
printrc=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) | VULNERABLE — X509_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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- 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