OpenBao's cross-namespace lease revocation via legacy sys/revoke path bypasses ACL
Description
# Impact
OpenBao's namespaces provide multi-tenant separation. A tenant who intentionally leaks lease identifiers can have their lease and underlying credential revoked or renewed by a user in another tenant via the legacy, undocumented sys/revoke and sys/renew endpoints.
# Patch
This will be addressed in v2.5.4.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
OpenBao namespaces can be bypassed via legacy sys/revoke and sys/renew endpoints, allowing cross-namespace lease manipulation.
Vulnerability
The legacy, undocumented sys/revoke and sys/renew endpoints in OpenBao do not enforce namespace boundaries, allowing a user in one namespace to revoke or renew leases created in another namespace if they know the lease identifier [1][2]. This affects all versions prior to v2.5.4.
Exploitation
An attacker with any authenticated user account in any namespace can exploit this by obtaining a lease identifier from another namespace (e.g., through intentional leakage or prior compromise) and then sending a request to the legacy endpoints sys/revoke or sys/renew with that lease ID [2]. No special privileges beyond a valid token are required.
Impact
Successful exploitation allows an attacker to revoke or renew credentials belonging to other tenants, breaking multi-tenant isolation. This can lead to denial of service (revocation) or unauthorized extension of lease validity [1][2].
Mitigation
The fix was released in OpenBao v2.5.4, which removes the legacy endpoints [3][4]. Users should upgrade to v2.5.4 or later. As a workaround, if upgrading is not immediately possible, network policies can restrict access to the sys/revoke and sys/renew paths, but the only complete fix is the upgrade.
AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2Patches
1c0495646b41cRemove legacy cross-namespace lease endpoints (#3152)
14 files changed · +35 −62
api/plugin_helpers.go+1 −1 modified@@ -256,7 +256,7 @@ func IsSudoPath(path string) bool { } // Some sudo paths have templated fields in them. - // (e.g. /sys/revoke-prefix/{prefix}) + // (e.g. /sys/leases/revoke-prefix/{prefix}) // The values in the sudoPaths map are actually regular expressions, // so we can check if our path matches against them. for _, sudoPathRegexp := range sudoPaths {
changelog/3152.txt+3 −0 added@@ -0,0 +1,3 @@ +```release-note:security +core: Remove legacy lease endpoints (`sys/revoke`, `sys/renew`, `sys/revoke-prefix`, and `sys/revoke-force`) due to cross-namespace lease modification. GHSA-v8v8-cm84-m686 / CVE-2026-45808. +```
helper/testhelpers/logical/testing.go+1 −1 modified@@ -328,7 +328,7 @@ func Test(tt TestT, c TestCase) { // Revoke this secret later revoke = append(revoke, &logical.Request{ Operation: logical.UpdateOperation, - Path: "sys/revoke/" + resp.Secret.LeaseID, + Path: "sys/leases/revoke/" + resp.Secret.LeaseID, }) }
http/sys_lease_test.go+2 −10 modified@@ -36,14 +36,6 @@ func TestSysRenew(t *testing.T) { LeaseID string `json:"lease_id"` Data map[string]interface{} `json:"data"` } - resp = testHttpPut(t, token, addr+"/v1/sys/renew/"+result.LeaseID, nil) - testResponseStatus(t, resp, 200) - if err := jsonutil.DecodeJSONFromReader(resp.Body, &renewResult); err != nil { - t.Fatal(err) - } - if result.LeaseID != renewResult.LeaseID { - t.Fatal("lease id changed in renew request") - } resp = testHttpPut(t, token, addr+"/v1/sys/leases/renew/"+result.LeaseID, nil) testResponseStatus(t, resp, 200) @@ -61,7 +53,7 @@ func TestSysRevoke(t *testing.T) { defer ln.Close() TestServerAuth(t, addr, token) - resp := testHttpPut(t, token, addr+"/v1/sys/revoke/secret/foo/1234", nil) + resp := testHttpPut(t, token, addr+"/v1/sys/leases/revoke/secret/foo/1234", nil) testResponseStatus(t, resp, 204) } @@ -71,6 +63,6 @@ func TestSysRevokePrefix(t *testing.T) { defer ln.Close() TestServerAuth(t, addr, token) - resp := testHttpPut(t, token, addr+"/v1/sys/revoke-prefix/secret/foo/1234", nil) + resp := testHttpPut(t, token, addr+"/v1/sys/leases/revoke-prefix/secret/foo/1234", nil) testResponseStatus(t, resp, 204) }
ui/tests/unit/serializers/policy-test.js+3 −3 modified@@ -30,15 +30,15 @@ module('Unit | Serializer | policy', function (hooks) { const POLICY_SHOW_RESPONSE = { name: 'default', rules: - '\n# Allow tokens to look up their own properties\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n# Allow tokens to renew themselves\npath "auth/token/renew-self" {\n capabilities = ["update"]\n}\n\n# Allow tokens to revoke themselves\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up its own capabilities on a path\npath "sys/capabilities-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to renew a lease via lease_id in the request body\npath "sys/renew" {\n capabilities = ["update"]\n}\n\n# Allow a token to manage its own cubbyhole\npath "cubbyhole/*" {\n capabilities = ["create", "read", "update", "delete", "list"]\n}\n\n# Allow a token to list its cubbyhole (not covered by the splat above)\npath "cubbyhole" {\n capabilities = ["list"]\n}\n\n# Allow a token to wrap arbitrary values in a response-wrapping token\npath "sys/wrapping/wrap" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up the creation time and TTL of a given\n# response-wrapping token\npath "sys/wrapping/lookup" {\n capabilities = ["update"]\n}\n\n# Allow a token to unwrap a response-wrapping token. This is a convenience to\n# avoid client token swapping since this is also part of the response wrapping\n# policy.\npath "sys/wrapping/unwrap" {\n capabilities = ["update"]\n}\n', + '\n# Allow tokens to look up their own properties\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n# Allow tokens to renew themselves\npath "auth/token/renew-self" {\n capabilities = ["update"]\n}\n\n# Allow tokens to revoke themselves\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up its own capabilities on a path\npath "sys/capabilities-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to renew a lease via lease_id in the request body\npath "sys/leases/renew" {\n capabilities = ["update"]\n}\n\n# Allow a token to manage its own cubbyhole\npath "cubbyhole/*" {\n capabilities = ["create", "read", "update", "delete", "list"]\n}\n\n# Allow a token to list its cubbyhole (not covered by the splat above)\npath "cubbyhole" {\n capabilities = ["list"]\n}\n\n# Allow a token to wrap arbitrary values in a response-wrapping token\npath "sys/wrapping/wrap" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up the creation time and TTL of a given\n# response-wrapping token\npath "sys/wrapping/lookup" {\n capabilities = ["update"]\n}\n\n# Allow a token to unwrap a response-wrapping token. This is a convenience to\n# avoid client token swapping since this is also part of the response wrapping\n# policy.\npath "sys/wrapping/unwrap" {\n capabilities = ["update"]\n}\n', request_id: '890eabf8-d418-07af-f978-928d328a7e64', lease_id: '', renewable: false, lease_duration: 0, data: { name: 'default', rules: - '\n# Allow tokens to look up their own properties\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n# Allow tokens to renew themselves\npath "auth/token/renew-self" {\n capabilities = ["update"]\n}\n\n# Allow tokens to revoke themselves\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up its own capabilities on a path\npath "sys/capabilities-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to renew a lease via lease_id in the request body\npath "sys/renew" {\n capabilities = ["update"]\n}\n\n# Allow a token to manage its own cubbyhole\npath "cubbyhole/*" {\n capabilities = ["create", "read", "update", "delete", "list"]\n}\n\n# Allow a token to list its cubbyhole (not covered by the splat above)\npath "cubbyhole" {\n capabilities = ["list"]\n}\n\n# Allow a token to wrap arbitrary values in a response-wrapping token\npath "sys/wrapping/wrap" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up the creation time and TTL of a given\n# response-wrapping token\npath "sys/wrapping/lookup" {\n capabilities = ["update"]\n}\n\n# Allow a token to unwrap a response-wrapping token. This is a convenience to\n# avoid client token swapping since this is also part of the response wrapping\n# policy.\npath "sys/wrapping/unwrap" {\n capabilities = ["update"]\n}\n', + '\n# Allow tokens to look up their own properties\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n# Allow tokens to renew themselves\npath "auth/token/renew-self" {\n capabilities = ["update"]\n}\n\n# Allow tokens to revoke themselves\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up its own capabilities on a path\npath "sys/capabilities-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to renew a lease via lease_id in the request body\npath "sys/leases/renew" {\n capabilities = ["update"]\n}\n\n# Allow a token to manage its own cubbyhole\npath "cubbyhole/*" {\n capabilities = ["create", "read", "update", "delete", "list"]\n}\n\n# Allow a token to list its cubbyhole (not covered by the splat above)\npath "cubbyhole" {\n capabilities = ["list"]\n}\n\n# Allow a token to wrap arbitrary values in a response-wrapping token\npath "sys/wrapping/wrap" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up the creation time and TTL of a given\n# response-wrapping token\npath "sys/wrapping/lookup" {\n capabilities = ["update"]\n}\n\n# Allow a token to unwrap a response-wrapping token. This is a convenience to\n# avoid client token swapping since this is also part of the response wrapping\n# policy.\npath "sys/wrapping/unwrap" {\n capabilities = ["update"]\n}\n', }, wrap_info: null, warnings: null, @@ -48,7 +48,7 @@ module('Unit | Serializer | policy', function (hooks) { const EMBER_DATA_EXPECTS_FOR_POLICY_SHOW = { name: 'default', rules: - '\n# Allow tokens to look up their own properties\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n# Allow tokens to renew themselves\npath "auth/token/renew-self" {\n capabilities = ["update"]\n}\n\n# Allow tokens to revoke themselves\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up its own capabilities on a path\npath "sys/capabilities-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to renew a lease via lease_id in the request body\npath "sys/renew" {\n capabilities = ["update"]\n}\n\n# Allow a token to manage its own cubbyhole\npath "cubbyhole/*" {\n capabilities = ["create", "read", "update", "delete", "list"]\n}\n\n# Allow a token to list its cubbyhole (not covered by the splat above)\npath "cubbyhole" {\n capabilities = ["list"]\n}\n\n# Allow a token to wrap arbitrary values in a response-wrapping token\npath "sys/wrapping/wrap" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up the creation time and TTL of a given\n# response-wrapping token\npath "sys/wrapping/lookup" {\n capabilities = ["update"]\n}\n\n# Allow a token to unwrap a response-wrapping token. This is a convenience to\n# avoid client token swapping since this is also part of the response wrapping\n# policy.\npath "sys/wrapping/unwrap" {\n capabilities = ["update"]\n}\n', + '\n# Allow tokens to look up their own properties\npath "auth/token/lookup-self" {\n capabilities = ["read"]\n}\n\n# Allow tokens to renew themselves\npath "auth/token/renew-self" {\n capabilities = ["update"]\n}\n\n# Allow tokens to revoke themselves\npath "auth/token/revoke-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up its own capabilities on a path\npath "sys/capabilities-self" {\n capabilities = ["update"]\n}\n\n# Allow a token to renew a lease via lease_id in the request body\npath "sys/leases/renew" {\n capabilities = ["update"]\n}\n\n# Allow a token to manage its own cubbyhole\npath "cubbyhole/*" {\n capabilities = ["create", "read", "update", "delete", "list"]\n}\n\n# Allow a token to list its cubbyhole (not covered by the splat above)\npath "cubbyhole" {\n capabilities = ["list"]\n}\n\n# Allow a token to wrap arbitrary values in a response-wrapping token\npath "sys/wrapping/wrap" {\n capabilities = ["update"]\n}\n\n# Allow a token to look up the creation time and TTL of a given\n# response-wrapping token\npath "sys/wrapping/lookup" {\n capabilities = ["update"]\n}\n\n# Allow a token to unwrap a response-wrapping token. This is a convenience to\n# avoid client token swapping since this is also part of the response wrapping\n# policy.\npath "sys/wrapping/unwrap" {\n capabilities = ["update"]\n}\n', }; test('it transforms a list request payload', function (assert) {
vault/core_test.go+1 −14 modified@@ -2718,19 +2718,6 @@ func TestCore_RenewSameLease(t *testing.T) { original := resp.Secret.LeaseID // Renew the lease - req = logical.TestRequest(t, logical.UpdateOperation, "sys/renew/"+resp.Secret.LeaseID) - req.ClientToken = root - resp, err = c.HandleRequest(namespace.RootContext(t.Context()), req) - if err != nil { - t.Fatalf("err: %v", err) - } - - // Verify the lease did not change - if resp.Secret.LeaseID != original { - t.Fatalf("lease id changed: %s %s", original, resp.Secret.LeaseID) - } - - // Renew the lease (alternate path) req = logical.TestRequest(t, logical.UpdateOperation, "sys/leases/renew/"+resp.Secret.LeaseID) req.ClientToken = root resp, err = c.HandleRequest(namespace.RootContext(t.Context()), req) @@ -2775,7 +2762,7 @@ func TestCore_RenewToken_SingleRegister(t *testing.T) { } // Revoke using the renew prefix - req = logical.TestRequest(t, logical.UpdateOperation, "sys/revoke-prefix/auth/token/renew/") + req = logical.TestRequest(t, logical.UpdateOperation, "sys/leases/revoke-prefix/auth/token/renew/") req.ClientToken = root _, err = c.HandleRequest(namespace.RootContext(t.Context()), req) if err != nil {
vault/expiration_test.go+4 −4 modified@@ -2680,14 +2680,14 @@ func TestExpiration_RevokeForce(t *testing.T) { } req.Operation = logical.UpdateOperation - req.Path = "sys/revoke-prefix/badrenew/creds" + req.Path = "sys/leases/revoke-prefix/badrenew/creds" _, err = core.HandleRequest(namespace.RootContext(t.Context()), req) if err == nil { t.Fatal("expected error") } - req.Path = "sys/revoke-force/badrenew/creds" + req.Path = "sys/leases/revoke-force/badrenew/creds" _, err = core.HandleRequest(namespace.RootContext(t.Context()), req) if err != nil { t.Fatalf("got error: %s", err) @@ -2745,14 +2745,14 @@ func TestExpiration_RevokeForceSingle(t *testing.T) { t.Fatalf("expected id %q, got %q", leaseID, resp.Data["id"].(string)) } - req.Path = "sys/revoke-prefix/" + leaseID + req.Path = "sys/leases/revoke-prefix/" + leaseID _, err = core.HandleRequest(namespace.RootContext(t.Context()), req) if err == nil { t.Fatal("expected error") } - req.Path = "sys/revoke-force/" + leaseID + req.Path = "sys/leases/revoke-force/" + leaseID _, err = core.HandleRequest(namespace.RootContext(t.Context()), req) if err != nil { t.Fatalf("got error: %s", err)
vault/logical_system_integ_test.go+0 −5 modified@@ -96,11 +96,6 @@ func TestSystemBackend_InternalUIResultantACL(t *testing.T) { "update", }, }, - "sys/renew": map[string]interface{}{ - "capabilities": []interface{}{ - "update", - }, - }, "sys/tools/hash": map[string]interface{}{ "capabilities": []interface{}{ "update",
vault/logical_system_paths.go+4 −4 modified@@ -2672,7 +2672,7 @@ func (b *SystemBackend) leasePaths() []*framework.Path { }, { - Pattern: "(leases/)?renew" + framework.OptionalParamRegex("url_lease_id"), + Pattern: "leases/renew" + framework.OptionalParamRegex("url_lease_id"), DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "leases", @@ -2712,7 +2712,7 @@ func (b *SystemBackend) leasePaths() []*framework.Path { }, { - Pattern: "(leases/)?revoke" + framework.OptionalParamRegex("url_lease_id"), + Pattern: "leases/revoke" + framework.OptionalParamRegex("url_lease_id"), DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "leases", @@ -2753,7 +2753,7 @@ func (b *SystemBackend) leasePaths() []*framework.Path { }, { - Pattern: "(leases/)?revoke-force/(?P<prefix>.+)", + Pattern: "leases/revoke-force/(?P<prefix>.+)", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "leases", @@ -2786,7 +2786,7 @@ func (b *SystemBackend) leasePaths() []*framework.Path { }, { - Pattern: "(leases/)?revoke-prefix/(?P<prefix>.+)", + Pattern: "leases/revoke-prefix/(?P<prefix>.+)", DisplayAttrs: &framework.DisplayAttributes{ OperationPrefix: "leases",
vault/logical_system_test.go+11 −11 modified@@ -1554,7 +1554,7 @@ func TestSystemBackend_renew(t *testing.T) { } // Test orig path - req2 = logical.TestRequest(t, logical.UpdateOperation, "renew") + req2 = logical.TestRequest(t, logical.UpdateOperation, "leases/renew") req2.Data["lease_id"] = resp.Secret.LeaseID resp2, err = b.HandleRequest(namespace.RootContext(t.Context()), req2) if err != nil { @@ -1600,7 +1600,7 @@ func TestSystemBackend_renew_invalidID_origUrl(t *testing.T) { b := testSystemBackend(t) // Attempt renew - req := logical.TestRequest(t, logical.UpdateOperation, "renew/foobarbaz") + req := logical.TestRequest(t, logical.UpdateOperation, "leases/renew/foobarbaz") resp, err := b.HandleRequest(namespace.RootContext(t.Context()), req) if err != logical.ErrInvalidRequest { t.Fatalf("err: %v", err) @@ -1610,7 +1610,7 @@ func TestSystemBackend_renew_invalidID_origUrl(t *testing.T) { } // Attempt renew with other method - req = logical.TestRequest(t, logical.UpdateOperation, "renew") + req = logical.TestRequest(t, logical.UpdateOperation, "leases/renew") req.Data["lease_id"] = "foobarbaz" resp, err = b.HandleRequest(namespace.RootContext(t.Context()), req) if err != logical.ErrInvalidRequest { @@ -1653,7 +1653,7 @@ func TestSystemBackend_revoke(t *testing.T) { } // Attempt revoke - req2 := logical.TestRequest(t, logical.UpdateOperation, "revoke/"+resp.Secret.LeaseID) + req2 := logical.TestRequest(t, logical.UpdateOperation, "leases/revoke/"+resp.Secret.LeaseID) resp2, err := b.HandleRequest(namespace.RootContext(t.Context()), req2) if err != nil { t.Fatalf("err: %v %#v", err, resp2) @@ -1663,7 +1663,7 @@ func TestSystemBackend_revoke(t *testing.T) { } // Attempt renew - req3 := logical.TestRequest(t, logical.UpdateOperation, "renew/"+resp.Secret.LeaseID) + req3 := logical.TestRequest(t, logical.UpdateOperation, "leases/renew/"+resp.Secret.LeaseID) resp3, err := b.HandleRequest(namespace.RootContext(t.Context()), req3) if err != logical.ErrInvalidRequest { t.Fatalf("err: %v", err) @@ -1688,7 +1688,7 @@ func TestSystemBackend_revoke(t *testing.T) { } // Test the other route path - req2 = logical.TestRequest(t, logical.UpdateOperation, "revoke") + req2 = logical.TestRequest(t, logical.UpdateOperation, "leases/revoke") req2.Data["lease_id"] = resp.Secret.LeaseID resp2, err = b.HandleRequest(namespace.RootContext(t.Context()), req2) if err != nil { @@ -1763,7 +1763,7 @@ func TestSystemBackend_revoke_invalidID_origUrl(t *testing.T) { b := testSystemBackend(t) // Attempt revoke - req := logical.TestRequest(t, logical.UpdateOperation, "revoke/foobarbaz") + req := logical.TestRequest(t, logical.UpdateOperation, "leases/revoke/foobarbaz") resp, err := b.HandleRequest(namespace.RootContext(t.Context()), req) if err != nil { t.Fatalf("err: %v", err) @@ -1773,7 +1773,7 @@ func TestSystemBackend_revoke_invalidID_origUrl(t *testing.T) { } // Attempt revoke with other method - req = logical.TestRequest(t, logical.UpdateOperation, "revoke") + req = logical.TestRequest(t, logical.UpdateOperation, "leases/revoke") req.Data["lease_id"] = "foobarbaz" resp, err = b.HandleRequest(namespace.RootContext(t.Context()), req) if err != nil { @@ -1873,7 +1873,7 @@ func TestSystemBackend_revokePrefix_origUrl(t *testing.T) { } // Attempt revoke - req2 := logical.TestRequest(t, logical.UpdateOperation, "revoke-prefix/secret/") + req2 := logical.TestRequest(t, logical.UpdateOperation, "leases/revoke-prefix/secret/") resp2, err := b.HandleRequest(namespace.RootContext(t.Context()), req2) if err != nil { t.Fatalf("err: %v %#v", err, resp2) @@ -1883,7 +1883,7 @@ func TestSystemBackend_revokePrefix_origUrl(t *testing.T) { } // Attempt renew - req3 := logical.TestRequest(t, logical.UpdateOperation, "renew/"+resp.Secret.LeaseID) + req3 := logical.TestRequest(t, logical.UpdateOperation, "leases/renew/"+resp.Secret.LeaseID) resp3, err := b.HandleRequest(namespace.RootContext(t.Context()), req3) if err != logical.ErrInvalidRequest { t.Fatalf("err: %v", err) @@ -2008,7 +2008,7 @@ func TestSystemBackend_revokePrefixAuth_origUrl(t *testing.T) { t.Fatalf("err: %v", err) } - req := logical.TestRequest(t, logical.UpdateOperation, "revoke-prefix/auth/github/") + req := logical.TestRequest(t, logical.UpdateOperation, "leases/revoke-prefix/auth/github/") resp, err := b.HandleRequest(ctx, req) if err != nil { t.Fatalf("err: %v %v", err, resp)
vault/policy/policy_store.go+0 −3 modified@@ -90,9 +90,6 @@ path "sys/internal/ui/resultant-acl" { # Allow a token to renew a lease via lease_id in the request body; old path for # old clients, new path for newer -path "sys/renew" { - capabilities = ["update"] -} path "sys/leases/renew" { capabilities = ["update"] }
vault/request_handling.go+1 −2 modified@@ -1290,8 +1290,7 @@ func (c *Core) handleRequest(ctx context.Context, req *logical.Request) (retResp // If there is a secret, we must register it with the expiration manager. // We exclude renewal of a lease, since it does not need to be re-registered - if resp != nil && resp.Secret != nil && !strings.HasPrefix(req.Path, "sys/renew") && - !strings.HasPrefix(req.Path, "sys/leases/renew") { + if resp != nil && resp.Secret != nil && !strings.HasPrefix(req.Path, "sys/leases/renew") { // KV mounts should return the TTL but not register // for a lease as this provides a massive slowdown registerLease := true
vault/token_store.go+3 −3 modified@@ -3192,14 +3192,14 @@ func (ts *TokenStore) handleCreateCommon(ctx context.Context, req *logical.Reque } // handleRevokeSelf handles the auth/token/revoke-self path for revocation of tokens -// in a way that revokes all child tokens. Normally, using sys/revoke/leaseID will revoke +// in a way that revokes all child tokens. Normally, using sys/leases/revoke/leaseID will revoke // the token and all children anyways, but that is only available when there is a lease. func (ts *TokenStore) handleRevokeSelf(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { return ts.revokeCommon(ctx, req, data, req.ClientToken) } // handleRevokeTree handles the auth/token/revoke/id path for revocation of tokens -// in a way that revokes all child tokens. Normally, using sys/revoke/leaseID will revoke +// in a way that revokes all child tokens. Normally, using sys/leases/revoke/leaseID will revoke // the token and all children anyways, but that is only available when there is a lease. func (ts *TokenStore) handleRevokeTree(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { id := data.Get("token").(string) @@ -3250,7 +3250,7 @@ func (ts *TokenStore) revokeCommon(ctx context.Context, req *logical.Request, da } // handleRevokeOrphan handles the auth/token/revoke-orphan/id path for revocation of tokens -// in a way that leaves child tokens orphaned. Normally, using sys/revoke/leaseID will revoke +// in a way that leaves child tokens orphaned. Normally, using sys/leases/revoke/leaseID will revoke // the token and all children. func (ts *TokenStore) handleRevokeOrphan(ctx context.Context, req *logical.Request, data *framework.FieldData) (*logical.Response, error) { // Parse the id
website/content/docs/internals/token.mdx+1 −1 modified@@ -27,7 +27,7 @@ Each token maintains the source path, or the login path, that was used to create the token. This is used to allow source based revocation. For example, if we believe our GitHub organization was compromised, we may want to revoke all tokens generated via `auth/github/login`. This would be done by using the -`sys/revoke-prefix/` API with the `auth/github/` prefix. Revoking the +`sys/leases/revoke-prefix/` API with the `auth/github/` prefix. Revoking the prefix will revoke all client tokens generated at that path, as well as all dynamic secrets generated by those tokens. This provides a powerful "break glass" procedure during a potential compromise.
Vulnerability mechanics
Root cause
"Legacy lease endpoints (`sys/revoke`, `sys/renew`, `sys/revoke-prefix`, `sys/revoke-force`) did not enforce namespace scoping, allowing a user in one tenant to renew or revoke leases belonging to another tenant."
Attack vector
OpenBao's namespaces provide multi-tenant separation, but the legacy, undocumented `sys/revoke` and `sys/renew` endpoints (and their `-prefix`/`-force` variants) operated without namespace scoping [ref_id=1]. A tenant who intentionally leaks a lease identifier enables an attacker in another tenant to call these legacy endpoints to renew or revoke that lease and its underlying credential. The attacker only needs the lease ID string and network access to the OpenBao API; no additional cross-tenant authorization is required because the legacy paths bypass namespace isolation.
Affected code
The vulnerable route patterns are defined in `vault/logical_system_paths.go` where the regex `(leases/)?renew`, `(leases/)?revoke`, `(leases/)?revoke-force`, and `(leases/)?revoke-prefix` matched both the legacy bare paths (e.g., `sys/renew`) and the namespaced paths (e.g., `sys/leases/renew`) [patch_id=2964630]. The default policy in `vault/policy/policy_store.go` also granted `update` capability on `sys/renew`.
What the fix does
The patch removes the legacy route patterns by changing the path regex from `(leases/)?renew`, `(leases/)?revoke`, `(leases/)?revoke-force`, and `(leases/)?revoke-prefix` to require the `leases/` prefix (e.g., `leases/renew`, `leases/revoke`) in `vault/logical_system_paths.go` [patch_id=2964630]. This eliminates the undocumented bare `sys/renew`, `sys/revoke`, `sys/revoke-prefix`, and `sys/revoke-force` endpoints that lacked namespace enforcement. The commit message notes these endpoints were "largely undocumented except for tests" and that removing them "reduce[s] noise in OpenAPI" [ref_id=1]. The fix also updates the default policy in `vault/policy/policy_store.go` to reference `sys/leases/renew` instead of `sys/renew`.
Preconditions
- networkThe attacker must have network access to the OpenBao API.
- inputThe attacker must obtain a valid lease ID from another tenant (e.g., via intentional leak or information disclosure).
- authThe attacker must have a valid token or authentication that can reach the legacy sys/revoke or sys/renew endpoints (the default policy allowed update on sys/renew).
Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5News mentions
0No linked articles in our index yet.