VYPR
Low severity3.1NVD Advisory· Published Apr 21, 2026· Updated Apr 24, 2026

CVE-2026-39388

CVE-2026-39388

Description

OpenBao is an open source identity-based secrets management system. Prior to version 2.5.3, OpenBao's Certificate authentication method, when a token renewal is requested and disable_binding=true is set, attempts to verify the current request's presented mTLS certificate matches the original. Token renewals for other authentication methods do not require any supplied login information. Due to incorrect matching, the certificate authentication method would allow renewal of tokens for which the attacker had a sibling certificate+key signed by the same CA, but which did not necessarily match the original role or the originally supplied certificate. This implies an attacker could still authenticate to OpenBao in a similar scope, however, token renewal implies that an attacker may be able to extend the lifetime of dynamic leases held by the original token. This attack requires knowledge of either the original token or its accessor. This vulnerability is original from HashiCorp Vault. This is addressed in v2.5.3. As a workaround, ensure privileged roles are tightly scoped to single certificates.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/openbao/openbaoGo
< 0.0.0-20260420160924-abe84e1af4c30.0.0-20260420160924-abe84e1af4c3

Affected products

1

Patches

1
9ab7a066826c

Check certificate match during renewal (#2932) (#2937)

https://github.com/openbao/openbaoJonas KöhnenApr 20, 2026via ghsa
3 files changed · +59 32
  • builtin/credential/cert/backend_test.go+2 5 modified
    @@ -2292,11 +2292,8 @@ func Test_Renew(t *testing.T) {
     	if err != nil {
     		t.Fatal(err)
     	}
    -	if resp == nil {
    -		t.Fatal("got nil response from renew")
    -	}
    -	if !resp.IsError() {
    -		t.Fatal("expected error")
    +	if resp != nil {
    +		t.Fatalf("got non-nil response from renew: %v", resp)
     	}
     }
     
    
  • builtin/credential/cert/path_login.go+54 27 modified
    @@ -33,19 +33,21 @@ type ParsedCert struct {
     	Certificates []*x509.Certificate
     }
     
    +var loginSchema = map[string]*framework.FieldSchema{
    +	"name": {
    +		Type:        framework.TypeString,
    +		Description: "The name of the certificate role to authenticate against.",
    +	},
    +}
    +
     func pathLogin(b *backend) *framework.Path {
     	return &framework.Path{
     		Pattern: "login",
     		DisplayAttrs: &framework.DisplayAttributes{
     			OperationPrefix: operationPrefixCert,
     			OperationVerb:   "login",
     		},
    -		Fields: map[string]*framework.FieldSchema{
    -			"name": {
    -				Type:        framework.TypeString,
    -				Description: "The name of the certificate role to authenticate against.",
    -			},
    -		},
    +		Fields: loginSchema,
     		Operations: map[logical.Operation]framework.OperationHandler{
     			logical.UpdateOperation: &framework.PathOperation{
     				Callback: b.loginPathWrapper(b.pathLogin),
    @@ -145,6 +147,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, data *fra
     	}
     	skid := base64.StdEncoding.EncodeToString(clientCerts[0].SubjectKeyId)
     	akid := base64.StdEncoding.EncodeToString(clientCerts[0].AuthorityKeyId)
    +	cert := base64.StdEncoding.EncodeToString(clientCerts[0].Raw)
     
     	metadata := map[string]string{
     		"cert_name":        matched.Entry.Name,
    @@ -162,6 +165,7 @@ func (b *backend) pathLogin(ctx context.Context, req *logical.Request, data *fra
     
     	auth := &logical.Auth{
     		InternalData: map[string]interface{}{
    +			"certificate":      cert,
     			"subject_key_id":   skid,
     			"authority_key_id": akid,
     		},
    @@ -194,9 +198,40 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f
     		b.updatedConfig(config)
     	}
     
    +	// Get the cert and use its TTL; ensure that it hasn't materially changed
    +	// w.r.t. policies.
    +	cert, err := b.Cert(ctx, req.Storage, req.Auth.Metadata["cert_name"])
    +	if err != nil {
    +		return nil, err
    +	}
    +	if cert == nil {
    +		// User no longer exists, do not renew
    +		return nil, nil
    +	}
    +
    +	if !policyutil.EquivalentPolicies(cert.TokenPolicies, req.Auth.TokenPolicies) {
    +		return nil, errors.New("policies have changed, not renewing")
    +	}
    +
     	if !config.DisableBinding {
    +		certBase64, ok := req.Auth.InternalData["certificate"]
    +		if !ok {
    +			return nil, errors.New("cannot validate renewal for cert auth for non-stored client certificate")
    +		}
    +
    +		// d contains the output from the initial login; this lacks the role
    +		// and is otherwise not useful for verifyCredentials; create a new
    +		// request data and bind the verification to the original
    +		// certificate's role.
    +		roleData := &framework.FieldData{
    +			Raw: map[string]interface{}{
    +				"name": req.Auth.Metadata["cert_name"],
    +			},
    +			Schema: loginSchema,
    +		}
    +
     		var matched *ParsedCert
    -		if verifyResp, resp, err := b.verifyCredentials(ctx, req, d); err != nil {
    +		if verifyResp, resp, err := b.verifyCredentials(ctx, req, roleData); err != nil {
     			return nil, err
     		} else if resp != nil {
     			return resp, nil
    @@ -208,32 +243,24 @@ func (b *backend) pathLoginRenew(ctx context.Context, req *logical.Request, d *f
     			return nil, nil
     		}
     
    +		// At this point we know that the request had a valid certificate and
    +		// it matches the current value of the role from the original request.
    +		// We want to ensure the original request certificate and this
    +		// certificate match. A renewed certificate does not match and thus
    +		// will be rejected.
    +		originalCertRaw, err := base64.StdEncoding.DecodeString(certBase64.(string))
    +		if err != nil {
    +			return nil, fmt.Errorf("failed to base64-decode original certificate: %w", err)
    +		}
    +
     		clientCerts := req.Connection.ConnState.PeerCertificates
     		if len(clientCerts) == 0 {
     			return logical.ErrorResponse("no client certificate found"), nil
     		}
    -		skid := base64.StdEncoding.EncodeToString(clientCerts[0].SubjectKeyId)
    -		akid := base64.StdEncoding.EncodeToString(clientCerts[0].AuthorityKeyId)
     
    -		// Certificate should not only match a registered certificate policy.
    -		// Also, the identity of the certificate presented should match the identity of the certificate used during login
    -		if req.Auth.InternalData["subject_key_id"] != skid && req.Auth.InternalData["authority_key_id"] != akid {
    -			return nil, errors.New("client identity during renewal not matching client identity used during login")
    +		if subtle.ConstantTimeCompare(originalCertRaw, clientCerts[0].Raw) == 0 {
    +			return nil, fmt.Errorf("client identity during renewal not matching client identity used during login:\n\toriginal: %v\n\trenewal: %v", originalCertRaw, matched.Certificates[0].Raw)
     		}
    -
    -	}
    -	// Get the cert and use its TTL
    -	cert, err := b.Cert(ctx, req.Storage, req.Auth.Metadata["cert_name"])
    -	if err != nil {
    -		return nil, err
    -	}
    -	if cert == nil {
    -		// User no longer exists, do not renew
    -		return nil, nil
    -	}
    -
    -	if !policyutil.EquivalentPolicies(cert.TokenPolicies, req.Auth.TokenPolicies) {
    -		return nil, errors.New("policies have changed, not renewing")
     	}
     
     	resp := &logical.Response{Auth: req.Auth}
    
  • changelog/2932.txt+3 0 added
    @@ -0,0 +1,3 @@
    +```release-note:security
    +auth/cert: Prevent token renewal with different-but-valid certificate. GHSA-7ccv-rp6m-rffr / CVE-2026-39388.
    +```
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

6

News mentions

0

No linked articles in our index yet.