CVE-2025-29922: Kubernetes APIExport VirtualWorkspace Authorization Bypass
Executive Summary
CVE-2025-29922 describes an authorization bypass vulnerability in kcp, a Kubernetes-like control plane. This flaw allows an attacker with low privileges to create or delete objects in arbitrary target workspaces through the APIExport VirtualWorkspace, even without proper APIBinding authorization. This bypass occurs because the APIExport VirtualWorkspace lacks a binding authorizer, which is crucial for enforcing access control based on APIBindings. The vulnerability has a CVSS v3.1 base score of 9.6 (Critical). Versions prior to 0.26.3 and 0.27.0 are affected.
Technical Details
- Affected Systems: kcp (Kubernetes-like control plane)
- Affected Software Versions: kcp versions prior to 0.26.3 and 0.27.0
- Component: APIExport VirtualWorkspace
- Vulnerability Class: CWE-285 (Improper Authorization)
The vulnerability resides in the authorization mechanism of the APIExport VirtualWorkspace. The APIExport feature in kcp allows exporting APIs from one workspace to another. To consume an exported API, a target workspace needs to create an APIBinding, which represents an agreement to use the API. The APIBinding also includes permission claims that define the resources and operations the consumer is allowed to perform.
Prior to the fix, the APIExport VirtualWorkspace lacked a binding authorizer. This meant that requests to create or delete resources in the virtual workspace were not properly checked against the APIBinding configuration. As a result, an attacker with the ability to access the APIExport VirtualWorkspace could bypass the intended authorization controls and manipulate resources in the target workspace, even if no APIBinding existed or if the existing APIBinding explicitly rejected the attacker's permission claims.
Root Cause Analysis
The root cause of CVE-2025-29922 is the absence of a binding authorizer in the APIExport VirtualWorkspace. The intended authorization flow requires that all requests to the virtual workspace be validated against the APIBinding in the target workspace. This validation ensures that the request is allowed based on the permissions granted by the API provider (the workspace owning the APIExport) and accepted by the API consumer (the workspace creating the APIBinding).
Without the binding authorizer, the authorization check is incomplete, leading to the bypass. The code responsible for creating the authorizer chain for the APIExport VirtualWorkspace, specifically in pkg/virtual/apiexport/builder/build.go
, was missing a crucial component.
Before the patch, the newAuthorizer
function looked like this:
func newAuthorizer(kubeClusterClient, deepSARClient kcpkubernetesclientset.ClusterInterface, cachedKcpInformers kcpinformers.SharedInformerFactory) authorizer.Authorizer {
maximalPermissionAuth := virtualapiexportauth.NewMaximalPermissionAuthorizer(deepSARClient, cachedKcpInformers.Apis().V1alpha1().APIExports())
maximalPermissionAuth = authorization.NewDecorator("virtual.apiexport.maxpermissionpolicy.authorization.kcp.io", maximalPermissionAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation()
apiExportsContentAuth := virtualapiexportauth.NewAPIExportsContentAuthorizer(maximalPermissionAuth, kubeClusterClient)
apiExportsContentAuth = authorization.NewDecorator("virtual.apiexport.content.authorization.kcp.io", apiExportsContentAuth).AddAuditLogging().AddAnonymization()
return apiExportsContentAuth
}
This code creates an authorizer chain consisting of a maximal permission authorizer and an API exports content authorizer. However, it lacks the binding authorizer, which is responsible for enforcing the APIBinding-based access control.
Patch Analysis
The fix for CVE-2025-29922 involves adding a binding authorizer to the authorizer chain of the APIExport VirtualWorkspace. This is achieved by introducing a new authorizer, boundAPIAuthorizer
, and incorporating it into the newAuthorizer
function in pkg/virtual/apiexport/builder/build.go
.
The key changes are as follows:
-
Introduction of
boundAPIAuthorizer
: A new file,pkg/virtual/apiexport/authorizer/binding.go
, is created, containing the implementation of theboundAPIAuthorizer
. This authorizer checks if a request is allowed based on the APIBinding configuration in the target workspace.// pkg/virtual/apiexport/authorizer/binding.go package authorizer import ( "context" "fmt" "slices" "strings" "k8s.io/apimachinery/pkg/labels" "k8s.io/apiserver/pkg/authorization/authorizer" genericapirequest "k8s.io/apiserver/pkg/endpoints/request" kcpkubernetesclientset "github.com/kcp-dev/client-go/kubernetes" "github.com/kcp-dev/logicalcluster/v3" dynamiccontext "github.com/kcp-dev/kcp/pkg/virtual/framework/dynamic/context" apisv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" apisv1alpha1informers "github.com/kcp-dev/kcp/sdk/client/informers/externalversions/apis/v1alpha1" ) type boundAPIAuthorizer struct { getAPIBindingByExport func(clusterName, apiExportName, apiExportCluster string) (*apisv1alpha1.APIBinding, error) delegate authorizer.Authorizer } var readOnlyVerbs = []string{"get", "list", "watch"} func NewBoundAPIAuthorizer(delegate authorizer.Authorizer, apiBindingInformer apisv1alpha1informers.APIBindingClusterInformer, kubeClusterClient kcpkubernetesclientset.ClusterInterface) authorizer.Authorizer { apiBindingLister := apiBindingInformer.Lister() return &boundAPIAuthorizer{ delegate: delegate, getAPIBindingByExport: func(clusterName, apiExportName, apiExportCluster string) (*apisv1alpha1.APIBinding, error) { bindings, err := apiBindingLister.Cluster(logicalcluster.Name(clusterName)).List(labels.Everything()) if err != nil { return nil, err } for _, binding := range bindings { if binding == nil { continue } if binding.Spec.Reference.Export != nil && binding.Spec.Reference.Export.Name == apiExportName && binding.Status.APIExportClusterName == apiExportCluster { return binding, nil } } return nil, fmt.Errorf("no suitable binding found") }, } } func (a *boundAPIAuthorizer) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { targetCluster, err := genericapirequest.ValidClusterFrom(ctx) if err != nil { return authorizer.DecisionNoOpinion, "", fmt.Errorf("error getting valid cluster from context: %w", err) } if targetCluster.Wildcard || attr.GetResource() == "" { // if the target is the wildcard cluster or it's a non-resurce URL request, // we can skip checking the APIBinding in the target cluster. return a.delegate.Authorize(ctx, attr) } apiDomainKey := dynamiccontext.APIDomainKeyFrom(ctx) parts := strings.Split(string(apiDomainKey), "/") if len(parts) < 2 { return authorizer.DecisionNoOpinion, "", fmt.Errorf("invalid API domain key") } apiExportCluster, apiExportName := parts[0], parts[1] apiBinding, err := a.getAPIBindingByExport(targetCluster.Name.String(), apiExportName, apiExportCluster) if err != nil { return authorizer.DecisionDeny, "could not find suitable APIBinding in target logical cluster", nil //nolint:nilerr // this is on purpose, we want to deny, not return a server error } // check if request is for a bound resource. for _, resource := range apiBinding.Status.BoundResources { if resource.Group == attr.GetAPIGroup() && resource.Resource == attr.GetResource() { return a.delegate.Authorize(ctx, attr) } } // check if a resource claim for this resource has been accepted. for _, permissionClaim := range apiBinding.Spec.PermissionClaims { if permissionClaim.State != apisv1alpha1.ClaimAccepted { // if the claim is not accepted it cannot be used. continue } if permissionClaim.Group == attr.GetAPIGroup() && permissionClaim.Resource == attr.GetResource() { return a.delegate.Authorize(ctx, attr) } } // special case: APIBindings are always available from an APIExport VW, // but the provider should only be allowed to access them read-only to avoid privilege escalation. if attr.GetAPIGroup() == apisv1alpha1.SchemeGroupVersion.Group && attr.GetResource() == "apibindings" { if !slices.Contains(readOnlyVerbs, attr.GetVerb()) { return authorizer.DecisionNoOpinion, "write access to APIBinding is not allowed from virtual workspace", nil } return a.delegate.Authorize(ctx, attr) } // if we cannot find the API bound to the logical cluster, we deny. // The APIExport owner has not been invited in. return authorizer.DecisionDeny, "failed to find suitable reason to allow access in APIBinding", nil }
The
Authorize
method ofboundAPIAuthorizer
retrieves the APIBinding associated with the target workspace and APIExport. It then checks if the requested resource and operation are allowed based on theBoundResources
andPermissionClaims
in the APIBinding's status and spec, respectively. If no suitable APIBinding is found or the request is not authorized, the authorizer denies the request. -
Modification of
newAuthorizer
function: ThenewAuthorizer
function inpkg/virtual/apiexport/builder/build.go
is modified to include theboundAPIAuthorizer
in the authorizer chain.--- a/pkg/virtual/apiexport/builder/build.go +++ b/pkg/virtual/apiexport/builder/build.go @@ -288,11 +288,14 @@ maximalPermissionAuth := virtualapiexportauth.NewMaximalPermissionAuthorizer(deepSARClient, cachedKcpInformers.Apis().V1alpha1().APIExports()) maximalPermissionAuth = authorization.NewDecorator("virtual.apiexport.maxpermissionpolicy.authorization.kcp.io", maximalPermissionAuth).AddAuditLogging().AddAnonymization().AddReasonAnnotation() apiExportsContentAuth := virtualapiexportauth.NewAPIExportsContentAuthorizer(maximalPermissionAuth, kubeClusterClient) apiExportsContentAuth = authorization.NewDecorator("virtual.apiexport.content.authorization.kcp.io", apiExportsContentAuth).AddAuditLogging().AddAnonymization() - return apiExportsContentAuth + boundApiAuth := virtualapiexportauth.NewBoundAPIAuthorizer(apiExportsContentAuth, kcpInformers.Apis().V1alpha1().APIBindings(), kubeClusterClient) + boundApiAuth = authorization.NewDecorator("virtual.apiexport.boundapi.authorization.kcp.io", boundApiAuth).AddAuditLogging().AddAnonymization() + + return boundApiAuth }
This change adds the
boundAPIAuthorizer
to the chain, ensuring that all requests to the APIExport VirtualWorkspace are now subject to APIBinding-based authorization. TheNewBoundAPIAuthorizer
function takes the previous authorizer in the chain (apiExportsContentAuth
), an APIBinding informer, and a kube client as arguments. The informer is used to look up APIBindings in the target workspace. -
Informer Injection: The
kcpInformers
is now passed to thenewAuthorizer
function.--- a/pkg/virtual/apiexport/builder/build.go +++ b/pkg/virtual/apiexport/builder/build.go @@ -67,7 +67,7 @@ cfg *rest.Config, kubeClusterClient, deepSARClient kcpkubernetesclientset.ClusterInterface, kcpClusterClient kcpclientset.ClusterInterface, - cachedKcpInformers kcpinformers.SharedInformerFactory, + cachedKcpInformers, kcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { if !strings.HasSuffix(rootPathPrefix, "/") { rootPathPrefix += "/" @@ -199,7 +199,7 @@ apiReconciler = apiDefinitionWithCancel(apiReconciler, cancel) return apiReconciler, nil },\ - Authorizer: newAuthorizer(kubeClusterClient, deepSARClient, cachedKcpInformers),\ + Authorizer: newAuthorizer(kubeClusterClient, deepSARClient, cachedKcpInformers, kcpInformers),\ } return []rootapiserver.NamedVirtualWorkspace{\
-
Options Update: The
NewVirtualWorkspaces
function inpkg/virtual/apiexport/options/options.go
andpkg/virtual/options/options.go
is updated to pass thewildcardKcpInformers
to thebuilder.BuildVirtualWorkspace
function.--- a/pkg/virtual/apiexport/options/options.go +++ b/pkg/virtual/apiexport/options/options.go @@ -53,7 +53,7 @@ func (o *APIExport) NewVirtualWorkspaces( rootPathPrefix string, config *rest.Config, - cachedKcpInformers kcpinformers.SharedInformerFactory, + cachedKcpInformers, wildcardKcpInformers kcpinformers.SharedInformerFactory, ) (workspaces []rootapiserver.NamedVirtualWorkspace, err error) { config = rest.AddUserAgent(rest.CopyConfig(config), "apiexport-virtual-workspace") kcpClusterClient, err := kcpclientset.NewForConfig(config) @@ -69,5 +69,5 @@ return nil, err } - return builder.BuildVirtualWorkspace(path.Join(rootPathPrefix, builder.VirtualWorkspaceName), config, kubeClusterClient, deepSARClient, kcpClusterClient, cachedKcpInformers) + return builder.BuildVirtualWorkspace(path.Join(rootPathPrefix, builder.VirtualWorkspaceName), config, kubeClusterClient, deepSARClient, kcpClusterClient, cachedKcpInformers, wildcardKcpInformers) }
--- a/pkg/virtual/options/options.go +++ b/pkg/virtual/options/options.go @@ -62,7 +62,7 @@ func (o *Options) NewVirtualWorkspaces( rootPathPrefix string, wildcardKubeInformers kcpkubernetesinformers.SharedInformerFactory, wildcardKcpInformers, cachedKcpInformers kcpinformers.SharedInformerFactory, ) ([]rootapiserver.NamedVirtualWorkspace, error) { - apiexports, err := o.APIExport.NewVirtualWorkspaces(rootPathPrefix, config, cachedKcpInformers) + apiexports, err := o.APIExport.NewVirtualWorkspaces(rootPathPrefix, config, cachedKcpInformers, wildcardKcpInformers) if err != nil { return nil, err }
These changes ensure that the APIBinding configuration is properly enforced when accessing resources through the APIExport VirtualWorkspace, mitigating the authorization bypass vulnerability.
Exploitation Techniques
Before the patch, an attacker could exploit this vulnerability by directly interacting with the APIExport VirtualWorkspace. The attacker would need:
- Access to the APIExport VirtualWorkspace: This requires the attacker to be able to authenticate and authorize against the kcp control plane and have the ability to discover the APIExport VirtualWorkspace URL.
- Knowledge of the Target Workspace: The attacker needs to know the name or identifier of the target workspace where they want to create or delete resources.
- Knowledge of the API Group and Resource: The attacker needs to know the API group and resource name of the resource they want to manipulate. This information can be obtained by inspecting the APIExport.
Proof-of-Concept (Theoretical):
Let's assume the following:
attacker-workspace
is the workspace where the attacker has access.target-workspace
is the workspace the attacker wants to manipulate.my-api-export
is the name of the APIExport in a provider workspace.- The APIExport VirtualWorkspace URL is
https://kcp.example.com/clusters/attacker-workspace/apis/my-api-export.kcp.io/v1alpha1
. - The attacker wants to create a
ConfigMap
in thedefault
namespace oftarget-workspace
.
The attacker could then use kubectl
or a similar tool to send a request to the APIExport VirtualWorkspace to create the ConfigMap
:
kubectl --kubeconfig=/path/to/attacker/kubeconfig create configmap my-config --namespace=default --from-literal=key=value --server=https://kcp.example.com/clusters/attacker-workspace/apis/my-api-export.kcp.io/v1alpha1 --context=target-workspace
Even if there is no APIBinding in target-workspace
that allows the attacker to create ConfigMap
resources, or if the existing APIBinding explicitly rejects the permission claim, the request would succeed due to the missing binding authorizer.
Attack Scenario:
- An attacker gains access to a workspace with the ability to discover APIExports.
- The attacker identifies an APIExport that exposes a resource they want to manipulate in another workspace.
- The attacker crafts a request to create or delete the resource through the APIExport VirtualWorkspace, specifying the target workspace.
- The request bypasses the intended authorization controls and is executed successfully, allowing the attacker to compromise the target workspace.
Real-World Impacts:
This vulnerability could have significant real-world impacts, including:
- Data Breach: An attacker could create resources that expose sensitive data.
- Denial of Service: An attacker could delete critical resources, causing a denial of service.
- Privilege Escalation: An attacker could create resources that grant them elevated privileges in the target workspace.
- Compliance Violations: Unauthorized access to and manipulation of resources could lead to compliance violations.
Mitigation Strategies
To mitigate CVE-2025-29922, it is crucial to upgrade kcp to version 0.26.3 or 0.27.0, where the fix has been implemented.
Configuration Changes:
No specific configuration changes are required beyond upgrading to the patched version. The fix is implemented in the code and does not rely on any configuration options.
Security Best Practices:
- Principle of Least Privilege: Grant users only the minimum necessary permissions to access and manipulate resources.
- Regular Security Audits: Conduct regular security audits to identify and address potential vulnerabilities.
- Monitoring and Logging: Implement robust monitoring and logging to detect and respond to suspicious activity.
- Stay Updated: Keep kcp and all related components up to date with the latest security patches.
Alternative Solutions (If Upgrading is Not Immediately Possible):
While upgrading is the recommended solution, the following alternative mitigations can be considered as temporary measures:
- Network Segmentation: Isolate the kcp control plane and APIExport VirtualWorkspaces from untrusted networks.
- Strict Access Control: Implement strict access control policies to limit who can access the kcp control plane and APIExport VirtualWorkspaces.
- Web Application Firewall (WAF): Deploy a WAF to inspect and filter traffic to the APIExport VirtualWorkspaces, blocking potentially malicious requests.
Note: These alternative solutions are not a substitute for upgrading to the patched version and should only be considered as temporary measures.
Timeline of Discovery and Disclosure
- Vulnerability Discovered: Date not available.
- Vulnerability Reported: Date not available.
- Patch Released: March 18, 2025 (Commit Hash: 614ecbf35f11db00f65391ab6fbb1547ca8b5d38)
- Public Disclosure: March 20, 2025
References
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-29922
- GitHub Security Advisory: https://github.com/kcp-dev/kcp/security/advisories/GHSA-w2rr-38wv-8rrp
- GitHub Commit: https://github.com/kcp-dev/kcp/commit/614ecbf35f11db00f65391ab6fbb1547ca8b5d38
- GitHub Pull Request: https://github.com/kcp-dev/kcp/pull/3338