{"repro_id":"REPRO-2026-00237","version":6,"title":"OpenZiti privilege escalation via overpermissive enrollment creation","repro_type":"security","status":"published","severity":"high","description":"OpenZiti through 2.0.0 allows authenticated non-admin identities with fine-grained enrollment management permissions to create enrollments for any identity, including the default admin, leading to privilege escalation. Reproduce: deploy an OpenZiti controller from a vulnerable version, create a low-privileged identity with enrollment management permissions, and use the API to create an enrollment for the admin identity. Authenticate with the resulting enrollment to gain admin access.","root_cause":"# CVE-2026-58165 Root Cause Analysis\n\n## Summary\n\nOpenZiti through 2.0.0 allows an authenticated non-admin identity that has been granted fine-grained enrollment-management permissions to create, read, refresh, and delete OTT enrollments for any identity, including identities with the `isAdmin` flag. Because an enrollment mints the one-time token (and JWT) used to authenticate as the target identity, a non-admin with `enrollment` permission can create an enrollment for an admin identity, redeem it through the unauthenticated client `/enroll` endpoint, and obtain a client certificate that authenticates as the targeted admin. This yields full administrative control over the controller and the zero-trust overlay it manages.\n\n## Impact\n\n- **Product/component:** OpenZiti controller (`ziti-controller`), specifically the enrollment management REST API (`/edge/management/v1/enrollments`).\n- **Affected versions:** OpenZiti through 2.0.0 (vulnerable commit `c2e6c8ee3`).\n- **Fixed version:** Commit `3027fdffd` (\"Prevent enrollment-based privilege escalation to admin identities. Fixes #4010\").\n- **Risk level:** High. Any authenticated non-admin user with the `enrollment` permission can escalate to an arbitrary admin identity, including the default administrator, bypassing all role-based access controls.\n- **Consequences:** Full administrative compromise of the controller, including ability to create/modify identities, services, policies, routers, and CA configurations.\n\n## Impact Parity\n\n- **Disclosed/claimed maximum impact:** Privilege escalation from a low-privileged identity to an admin identity via enrollment creation.\n- **Reproduced impact from this run:** Confirmed. A non-admin identity with only `enrollment` permission successfully created an OTT enrollment for a newly created admin identity, redeemed the enrollment, and used the resulting certificate to perform an admin-only operation (creating another identity through the management API). The same workflow on the fixed commit is rejected with HTTP 401 Unauthorized.\n- **Parity:** `full`.\n- **Not demonstrated:** N/A. The reproduction reaches the claimed administrative impact end-to-end.\n\n## Root Cause\n\nThe enrollment creation, read, refresh, and delete handlers in `controller/internal/routes/enrollment_router.go` only verified that the caller had the `enrollment` entity permission and that the target identity existed. They did not check whether the target identity was an administrator or whether the caller was authorized to manage enrollments on behalf of an admin identity. Specifically, `EnrollmentRouter.Create` called `MapCreate` without any guard against admin-targeted enrollments.\n\nThe fix (commit `3027fdffd`) adds two helpers:\n\n- `allowNonAdminEnrollmentForIdentity` — returns an unauthorized error when a non-admin tries to create an enrollment targeting an admin identity.\n- `allowNonAdminAccessToEnrollment` — returns an unauthorized error when a non-admin tries to read, refresh, or delete an enrollment that belongs to an admin identity.\n\nThe list handler is also modified to filter out admin-identity enrollments for non-admin callers by adding `not (identity.isAdmin = true)` to the query predicate.\n\n## Reproduction Steps\n\nThe reproduction is fully automated in `bundle/repro/reproduction_steps.sh`. The script performs the following steps:\n\n1. Reads `bundle/project_cache_context.json` and uses the prepared project cache directory (`/data/pruva/project-cache/92b8f1e5-395e-41fd-8f92-ae0d5f2888dc`).\n2. Installs Go 1.26.4 into the project cache if it is not already present.\n3. Clones (or reuses) the `openziti/ziti` repository at `<project_cache_dir>/repo`.\n4. Resolves the vulnerable commit (`3027fdffd^`) and the fixed commit (`3027fdffd`).\n5. Verifies that the fixed commit contains the patch to `controller/internal/routes/enrollment_router.go` and that the vulnerable commit does not.\n6. Copies `bundle/repro/cve_2026_58165_repro_test.go` into the repo's `tests/` directory at runtime.\n7. Runs the Go integration test `Test_CVE202658165_AdminEnrollmentEscalation` against the vulnerable commit, capturing the result to `bundle/repro/vulnerable_result.json`.\n8. Runs the same test against the fixed commit, capturing the result to `bundle/repro/fixed_result.json`.\n9. Compares the two results and writes `bundle/repro/runtime_manifest.json`.\n\n**Expected evidence of reproduction:**\n- `bundle/repro/vulnerable_result.json` shows `\"vulnerable\": true`, `\"create_status_code\": 201`, and a non-empty `created_identity_id` proving an admin-only operation succeeded.\n- `bundle/repro/fixed_result.json` shows `\"fixed\": true`, `\"create_status_code\": 401`, and the response body contains `\"non-admins may not manage enrollments for admin identities\"`.\n\n## Evidence\n\n- `bundle/logs/reproduction_steps.log` — full script output with commit resolution, test execution, and summary.\n- `bundle/logs/vulnerable_test.log` — Go test output for the vulnerable commit.\n- `bundle/logs/fixed_test.log` — Go test output for the fixed commit.\n- `bundle/repro/vulnerable_result.json` — captured result proving the escalation succeeded.\n- `bundle/repro/fixed_result.json` — captured result proving the escalation is blocked.\n- `bundle/repro/runtime_manifest.json` — runtime evidence manifest describing the API surface and artifacts.\n\nKey excerpt from the vulnerable result:\n\n```json\n{\n  \"vulnerable\": true,\n  \"create_status_code\": 201,\n  \"created_identity_id\": \"...\",\n  \"notes\": \"non-admin created an enrollment for an admin identity, redeemed it, and performed an admin-only operation\"\n}\n```\n\nKey excerpt from the fixed result:\n\n```json\n{\n  \"fixed\": true,\n  \"create_status_code\": 401,\n  \"create_response_body\": \"{\\\"error\\\":{\\\"cause\\\":{\\\"code\\\":\\\"UNHANDLED\\\",\\\"message\\\":\\\"non-admins may not manage enrollments for admin identities\\\"},\\\"code\\\":\\\"UNAUTHORIZED\\\",...}}\",\n  \"notes\": \"non-admin create enrollment for admin identity returned 401 (fixed behavior)\"\n}\n```\n\nEnvironment captured during the run:\n- Go 1.26.4 (installed from `https://go.dev/dl/go1.26.4.linux-amd64.tar.gz`)\n- Repository: `github.com/openziti/ziti`\n- Vulnerable commit: `c2e6c8ee3da42321ef22442c8d25d0413caf4a4f`\n- Fixed commit: `3027fdffd3e57884487b7c46e5e669cfbc8becdf`\n\n## Recommendations / Next Steps\n\n- **Upgrade:** Upgrade the OpenZiti controller to a build that includes commit `3027fdffd` or later.\n- **Temporary mitigation:** Remove the `enrollment` permission from any non-admin identities or role attributes until the patch is deployed.\n- **Testing:** Add the regression test `tests/permissions_enrollment_test.go::Test_Permissions_Enrollment_AdminIdentityEscalation` (introduced in the fix) to CI to prevent future regressions.\n- **Audit:** Review other entity-level permissions for similar authorization gaps where a non-admin can manage resources that belong to admin identities.\n\n## Additional Notes\n\n- The reproduction script was executed twice consecutively and produced the same vulnerable/fixed divergence both times, confirming idempotency.\n- The Go test harness starts a real OpenZiti controller using the checked-out source code, so the reproduction exercises the actual product code path rather than a mock.\n- The custom test file is copied into the repo at runtime and removed after each run to avoid permanently altering the source checkout.\n- Build outputs are cached in the durable project cache, so subsequent runs reuse the already-installed Go toolchain and already-cloned repository.\n","cve_id":"CVE-2026-58165","source_url":"https://github.com/openziti/ziti/issues/4010","reproduced_at":"2026-07-06T08:21:14.683376+00:00","duration_secs":1359.0,"tool_calls":211,"handoffs":2,"total_cost_usd":2.98201181,"agent_costs":{"hypothesis_generator":0.02354235,"judge":0.216961,"repro":0.78394937,"support":0.0448113,"vuln_variant":1.91274779},"cost_breakdown":{"hypothesis_generator":{"accounts/fireworks/models/kimi-k2p7-code":0.02354235},"judge":{"gpt-5.5":0.216961},"repro":{"accounts/fireworks/models/kimi-k2p7-code":0.78394937},"support":{"accounts/fireworks/models/kimi-k2p7-code":0.0448113},"vuln_variant":{"accounts/fireworks/models/kimi-k2p7-code":1.91274779}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-06T08:21:37.502871+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":5699,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7491,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6461,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":9916,"category":"analysis"},{"path":"bundle/artifact_promotion_manifest.json","filename":"artifact_promotion_manifest.json","size":8005,"category":"other"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1307,"category":"other"},{"path":"bundle/repro/vulnerable_result.json","filename":"vulnerable_result.json","size":476,"category":"other"},{"path":"bundle/repro/fixed_result.json","filename":"fixed_result.json","size":644,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":5110,"category":"log"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":623,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":847,"category":"other"},{"path":"bundle/logs/vulnerable_test.log","filename":"vulnerable_test.log","size":110,"category":"log"},{"path":"bundle/logs/fixed_test.log","filename":"fixed_test.log","size":110,"category":"log"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3339,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":1737,"category":"other"},{"path":"bundle/vuln_variant/vulnerable_result.json","filename":"vulnerable_result.json","size":740,"category":"other"},{"path":"bundle/vuln_variant/latest_result.json","filename":"latest_result.json","size":712,"category":"other"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":7079,"category":"documentation"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":777,"category":"other"},{"path":"bundle/vuln_variant/original_fix_result.json","filename":"original_fix_result.json","size":713,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":20871,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_test.log","filename":"vulnerable_test.log","size":110,"category":"log"},{"path":"bundle/logs/vuln_variant/original_fix_test.log","filename":"original_fix_test.log","size":110,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_test.log","filename":"latest_test.log","size":110,"category":"log"}]}