CVE-2022-46167 (published 2022-12-02T19:15Z)

Capsule is a multi-tenancy and policy-based framework for Kubernetes. Prior to version 0.1.3, a ServiceAccount deployed in a Tenant Namespace, when granted with `PATCH` capabilities on its own Namespace, is able to edit it and remove the Owner Reference, breaking the reconciliation of the Capsule Operator and removing all the enforcement like Pod Security annotations, Network Policies, Limit Range and Resource Quota items. An attacker could detach the Namespace from a Tenant that is forbidding starting privileged Pods using the Pod Security labels by removing the OwnerReference, removing the enforcement labels, and being able to start privileged containers that would be able to start a generic Kubernetes privilege escalation. Patches have been released for version 0.1.3. No known workarounds are available.\n

CVSS v3 base score: 8.8, HIGH (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)

Weakness information:
cwe_id: CWE-863
cwe_name: Incorrect Authorization
description: The product performs an authorization check when an actor attempts to access a resource or perform an action, but it does not correctly perform the check. This allows attackers to bypass intended access restrictions.
extended_description: 
            Assuming a user with a given identity, authorization is the process of determining whether that user can access a given resource, based on the user's privileges and any permissions or other access-control specifications that apply to the resource.
            When access control checks are incorrectly applied, users are able to access data or perform actions that they should not be allowed to perform. This can lead to a wide range of problems, including information exposures, denial of service, and arbitrary code execution.
         
         

Fix information:
repo_url: https://github.com/clastix/capsule
commit_details: repo_url: https://github.com/clastix/capsule
author: Dario Tranchitella
author_date: 2022-11-23 15:29:38+01:00
msg: test: preventing serviceaccount privilege escalation
repository: repo_url: https://github.com/clastix/capsule
repo_name: projectcapsule/capsule
description: Multi-tenancy and policy-based framework for Kubernetes.
repo_language: Go
owner: clastix
file_changes: filename: sa_prevent_privilege_escalation_test.go
change_type: ModificationType.ADD
diff: @@ -0,0 +1,146 @@
+//go:build e2e
+
+// Copyright 2020-2021 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package e2e
+
+import (
+	"context"
+	"fmt"
+	"time"
+
+	. "github.com/onsi/ginkgo"
+	. "github.com/onsi/gomega"
+	rbacv1 "k8s.io/api/rbac/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/types"
+	"k8s.io/apimachinery/pkg/util/uuid"
+	"k8s.io/client-go/kubernetes"
+	"sigs.k8s.io/controller-runtime/pkg/client/config"
+
+	capsulev1beta1 "github.com/clastix/capsule/api/v1beta1"
+)
+
+var _ = Describe("trying to escalate from a Tenant Namespace ServiceAccount", func() {
+	tnt := &capsulev1beta1.Tenant{
+		ObjectMeta: metav1.ObjectMeta{
+			Name: "sa-privilege-escalation",
+		},
+		Spec: capsulev1beta1.TenantSpec{
+			Owners: capsulev1beta1.OwnerListSpec{
+				{
+					Name: "mario",
+					Kind: "User",
+				},
+			},
+			NodeSelector: map[string]string{
+				"kubernetes.io/os": "linux",
+			},
+		},
+	}
+
+	ns := NewNamespace("attack")
+
+	JustBeforeEach(func() {
+		EventuallyCreation(func() error {
+			return k8sClient.Create(context.TODO(), tnt)
+		}).Should(Succeed())
+
+		NamespaceCreation(ns, tnt.Spec.Owners[0], defaultTimeoutInterval).Should(Succeed())
+		TenantNamespaceList(tnt, defaultTimeoutInterval).Should(ContainElement(ns.GetName()))
+	})
+
+	JustAfterEach(func() {
+		Expect(k8sClient.Delete(context.TODO(), tnt)).Should(Succeed())
+	})
+
+	It("should block Namespace changes", func() {
+		role := rbacv1.Role{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "ns-update-role",
+				Namespace: ns.GetName(),
+			},
+			Rules: []rbacv1.PolicyRule{
+				{
+					Verbs:         []string{"update"},
+					APIGroups:     []string{""},
+					Resources:     []string{"namespaces"},
+					ResourceNames: []string{ns.GetName()},
+				},
+			},
+		}
+
+		EventuallyCreation(func() error {
+			return k8sClient.Create(context.Background(), &role)
+		}).Should(Succeed())
+
+		rolebinding := rbacv1.RoleBinding{
+			ObjectMeta: metav1.ObjectMeta{
+				Name:      "attacker-rolebinding",
+				Namespace: ns.GetName(),
+			},
+			Subjects: []rbacv1.Subject{
+				{
+					Kind:      "ServiceAccount",
+					Name:      "attacker",
+					Namespace: ns.GetName(),
+				},
+			},
+			RoleRef: rbacv1.RoleRef{
+				APIGroup: "rbac.authorization.k8s.io",
+				Kind:     "Role",
+				Name:     role.GetName(),
+			},
+		}
+
+		EventuallyCreation(func() error {
+			return k8sClient.Create(context.Background(), &rolebinding)
+		}).Should(Succeed())
+
+		c, err := config.GetConfig()
+		Expect(err).ToNot(HaveOccurred())
+		c.Impersonate.Groups = []string{"system:serviceaccounts"}
+		c.Impersonate.UserName = fmt.Sprintf("system:serviceaccount:%s:%s", rolebinding.Subjects[0].Namespace, rolebinding.Subjects[0].Name)
+		saClient, err := kubernetes.NewForConfig(c)
+		Expect(err).ToNot(HaveOccurred())
+		// Changing Owner Reference is forbidden
+		Consistently(func() error {
+			if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: ns.GetName()}, ns); err != nil {
+				return err
+			}
+
+			ns.OwnerReferences[0].UID = uuid.NewUUID()
+
+			_, err = saClient.CoreV1().Namespaces().Update(context.Background(), ns, metav1.UpdateOptions{})
+
+			return err
+		}, 10*time.Second, time.Second).ShouldNot(Succeed())
+		// Removing Owner Reference is forbidden
+		Consistently(func() error {
+			if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: ns.GetName()}, ns); err != nil {
+				return err
+			}
+
+			ns.SetOwnerReferences(nil)
+
+			_, err = saClient.CoreV1().Namespaces().Update(context.Background(), ns, metav1.UpdateOptions{})
+
+			return err
+		}, 10*time.Second, time.Second).ShouldNot(Succeed())
+		// Breaking nodeSelector is forbidden
+		Consistently(func() error {
+			if err := k8sClient.Get(context.Background(), types.NamespacedName{Name: ns.GetName()}, ns); err != nil {
+				return err
+			}
+
+			ns.SetAnnotations(map[string]string{
+				"scheduler.alpha.kubernetes.io/node-selector": "kubernetes.io/os=forbidden",
+			})
+
+			_, err = saClient.CoreV1().Namespaces().Update(context.Background(), ns, metav1.UpdateOptions{})
+
+			return err
+		}, 10*time.Second, time.Second).ShouldNot(Succeed())
+	})
+})

programming_language: Go
repo_url: https://github.com/clastix/capsule
commit_details: repo_url: https://github.com/clastix/capsule
author: Dario Tranchitella
author_date: 2022-11-23 11:51:54+01:00
msg: fix: preventing serviceaccount privilege escalation
repository: repo_url: https://github.com/clastix/capsule
repo_name: projectcapsule/capsule
description: Multi-tenancy and policy-based framework for Kubernetes.
repo_language: Go
owner: clastix
file_changes: filename: namespaces.go
change_type: ModificationType.MODIFY
diff: @@ -17,6 +17,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/controller/controllerutil"
 
 	capsulev1beta1 "github.com/clastix/capsule/api/v1beta1"
+	"github.com/clastix/capsule/pkg/utils"
 )
 
 // Ensuring all annotations are applied to each Namespace handled by the Tenant.
@@ -72,11 +73,7 @@ func (r *Manager) syncNamespaceMetadata(ctx context.Context, namespace string, t
 			}
 
 			if tnt.Spec.NodeSelector != nil {
-				var selector []string
-				for k, v := range tnt.Spec.NodeSelector {
-					selector = append(selector, fmt.Sprintf("%s=%s", k, v))
-				}
-				annotations["scheduler.alpha.kubernetes.io/node-selector"] = strings.Join(selector, ",")
+				annotations = utils.BuildNodeSelector(tnt, annotations)
 			}
 
 			if tnt.Spec.IngressOptions.AllowedClasses != nil {

programming_language: Go
filename: main.go
change_type: ModificationType.MODIFY
diff: @@ -205,7 +205,7 @@ func main() {
 		route.Service(service.Handler()),
 		route.NetworkPolicy(utils.InCapsuleGroups(cfg, networkpolicy.Handler())),
 		route.Tenant(tenant.NameHandler(), tenant.RoleBindingRegexHandler(), tenant.IngressClassRegexHandler(), tenant.StorageClassRegexHandler(), tenant.ContainerRegistryRegexHandler(), tenant.HostnameRegexHandler(), tenant.FreezedEmitter(), tenant.ServiceAccountNameHandler(), tenant.ForbiddenAnnotationsRegexHandler(), tenant.ProtectedHandler()),
-		route.OwnerReference(utils.InCapsuleGroups(cfg, ownerreference.Handler(cfg))),
+		route.OwnerReference(utils.InCapsuleGroups(cfg, namespacewebhook.OwnerReferenceHandler(), ownerreference.Handler(cfg))),
 		route.Cordoning(tenant.CordoningHandler(cfg), tenant.ResourceCounterHandler()),
 		route.Node(utils.InCapsuleGroups(cfg, node.UserMetadataHandler(cfg, kubeVersion))),
 	)

programming_language: Go
filename: node_selector.go
change_type: ModificationType.ADD
diff: @@ -0,0 +1,35 @@
+// Copyright 2020-2021 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package utils
+
+import (
+	"fmt"
+	"sort"
+	"strings"
+
+	capsulev1beta1 "github.com/clastix/capsule/api/v1beta1"
+)
+
+const (
+	NodeSelectorAnnotation = "scheduler.alpha.kubernetes.io/node-selector"
+)
+
+func BuildNodeSelector(tnt *capsulev1beta1.Tenant, nsAnnotations map[string]string) map[string]string {
+	if nsAnnotations == nil {
+		nsAnnotations = make(map[string]string)
+	}
+
+	selector := make([]string, 0, len(tnt.Spec.NodeSelector))
+
+	for k, v := range tnt.Spec.NodeSelector {
+		selector = append(selector, fmt.Sprintf("%s=%s", k, v))
+	}
+	// Sorting the resulting slice: iterating over maps is randomized, and we could end-up
+	// in multiple reconciliations upon multiple node selectors.
+	sort.Strings(selector)
+
+	nsAnnotations[NodeSelectorAnnotation] = strings.Join(selector, ",")
+
+	return nsAnnotations
+}

programming_language: Go
filename: freezed.go
change_type: ModificationType.MODIFY
diff: @@ -69,7 +69,7 @@ func (r *freezedHandler) OnDelete(c client.Client, _ *admission.Decoder, recorde
 
 		tnt := tntList.Items[0]
 
-		if tnt.IsCordoned() && utils.IsCapsuleUser(req, r.configuration.UserGroups()) {
+		if tnt.IsCordoned() && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserGroups()) {
 			recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be deleted, the current Tenant is freezed", req.Name)
 
 			response := admission.Denied("the selected Tenant is freezed")
@@ -101,7 +101,7 @@ func (r *freezedHandler) OnUpdate(c client.Client, decoder *admission.Decoder, r
 
 		tnt := tntList.Items[0]
 
-		if tnt.IsCordoned() && utils.IsCapsuleUser(req, r.configuration.UserGroups()) {
+		if tnt.IsCordoned() && utils.IsCapsuleUser(ctx, req, c, r.configuration.UserGroups()) {
 			recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "Namespace %s cannot be updated, the current Tenant is freezed", ns.GetName())
 
 			response := admission.Denied("the selected Tenant is freezed")

programming_language: Go
filename: owner_reference.go
change_type: ModificationType.ADD
diff: @@ -0,0 +1,64 @@
+// Copyright 2020-2021 Clastix Labs
+// SPDX-License-Identifier: Apache-2.0
+
+package namespace
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/client-go/tools/record"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
+
+	capsulewebhook "github.com/clastix/capsule/pkg/webhook"
+	"github.com/clastix/capsule/pkg/webhook/utils"
+)
+
+type ownerReferenceHandler struct{}
+
+func OwnerReferenceHandler() capsulewebhook.Handler {
+	return &ownerReferenceHandler{}
+}
+
+func (r *ownerReferenceHandler) OnCreate(client.Client, *admission.Decoder, record.EventRecorder) capsulewebhook.Func {
+	return func(ctx context.Context, req admission.Request) *admission.Response {
+		return nil
+	}
+}
+
+func (r *ownerReferenceHandler) OnDelete(client.Client, *admission.Decoder, record.EventRecorder) capsulewebhook.Func {
+	return func(ctx context.Context, req admission.Request) *admission.Response {
+		return nil
+	}
+}
+
+func (r *ownerReferenceHandler) OnUpdate(_ client.Client, decoder *admission.Decoder, _ record.EventRecorder) capsulewebhook.Func {
+	return func(ctx context.Context, req admission.Request) *admission.Response {
+		oldNs := &corev1.Namespace{}
+		if err := decoder.DecodeRaw(req.OldObject, oldNs); err != nil {
+			return utils.ErroredResponse(err)
+		}
+
+		newNs := &corev1.Namespace{}
+		if err := decoder.Decode(req, newNs); err != nil {
+			return utils.ErroredResponse(err)
+		}
+
+		if len(newNs.OwnerReferences) == 0 {
+			response := admission.Errored(http.StatusBadRequest, fmt.Errorf("the OwnerReference cannot be removed"))
+
+			return &response
+		}
+
+		if oldNs.GetOwnerReferences()[0].UID != newNs.GetOwnerReferences()[0].UID {
+			response := admission.Errored(http.StatusBadRequest, fmt.Errorf("the OwnerReference cannot be changed"))
+
+			return &response
+		}
+
+		return nil
+	}
+}

programming_language: Go
filename: patch.go
change_type: ModificationType.MODIFY
diff: @@ -1,5 +1,6 @@
 // Copyright 2020-2021 Clastix Labs
 // SPDX-License-Identifier: Apache-2.0
+
 package namespace
 
 import (

programming_language: Go
filename: user_metadata.go
change_type: ModificationType.MODIFY
diff: @@ -112,6 +112,25 @@ func (r *userMetadataHandler) OnUpdate(client client.Client, decoder *admission.
 			}
 		}
 
+		if len(tnt.Spec.NodeSelector) > 0 {
+			v, ok := newNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"]
+			if !ok {
+				response := admission.Denied("the node-selector annotation is enforced, cannot be removed")
+
+				recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodeSelectorDeletion", string(response.Result.Reason))
+
+				return &response
+			}
+
+			if v != oldNs.GetAnnotations()["scheduler.alpha.kubernetes.io/node-selector"] {
+				response := admission.Denied("the the node-selector annotation is enforced, cannot be updated")
+
+				recorder.Eventf(tnt, corev1.EventTypeWarning, "ForbiddenNodeSelectorUpdate", string(response.Result.Reason))
+
+				return &response
+			}
+		}
+
 		var labels, annotations map[string]string
 
 		for key, value := range newNs.GetLabels() {

programming_language: Go
filename: patching.go
change_type: ModificationType.MODIFY
diff: @@ -49,7 +49,7 @@ func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder, rec
 
 func (h *handler) OnUpdate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) capsulewebhook.Func {
 	return func(ctx context.Context, req admission.Request) *admission.Response {
-		return h.setOwnerRef(ctx, req, client, decoder, recorder)
+		return nil
 	}
 }
 

programming_language: Go
filename: cordoning.go
change_type: ModificationType.MODIFY
diff: @@ -44,7 +44,7 @@ func (h *cordoningHandler) cordonHandler(ctx context.Context, clt client.Client,
 	}
 
 	tnt := tntList.Items[0]
-	if tnt.IsCordoned() && utils.IsCapsuleUser(req, h.configuration.UserGroups()) {
+	if tnt.IsCordoned() && utils.IsCapsuleUser(ctx, req, clt, h.configuration.UserGroups()) {
 		recorder.Eventf(&tnt, corev1.EventTypeWarning, "TenantFreezed", "%s %s/%s cannot be %sd, current Tenant is freezed", req.Kind.String(), req.Namespace, req.Name, strings.ToLower(string(req.Operation)))
 
 		response := admission.Denied(fmt.Sprintf("tenant %s is freezed: please, reach out to the system administrator", tnt.GetName()))

programming_language: Go
filename: in_capsule_groups.go
change_type: ModificationType.MODIFY
diff: @@ -28,7 +28,7 @@ type handler struct {
 
 func (h *handler) OnCreate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) webhook.Func {
 	return func(ctx context.Context, req admission.Request) *admission.Response {
-		if !IsCapsuleUser(req, h.configuration.UserGroups()) {
+		if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups()) {
 			return nil
 		}
 
@@ -44,7 +44,7 @@ func (h *handler) OnCreate(client client.Client, decoder *admission.Decoder, rec
 
 func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) webhook.Func {
 	return func(ctx context.Context, req admission.Request) *admission.Response {
-		if !IsCapsuleUser(req, h.configuration.UserGroups()) {
+		if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups()) {
 			return nil
 		}
 
@@ -60,7 +60,7 @@ func (h *handler) OnDelete(client client.Client, decoder *admission.Decoder, rec
 
 func (h *handler) OnUpdate(client client.Client, decoder *admission.Decoder, recorder record.EventRecorder) webhook.Func {
 	return func(ctx context.Context, req admission.Request) *admission.Response {
-		if !IsCapsuleUser(req, h.configuration.UserGroups()) {
+		if !IsCapsuleUser(ctx, req, client, h.configuration.UserGroups()) {
 			return nil
 		}
 

programming_language: Go
filename: is_capsule_user.go
change_type: ModificationType.MODIFY
diff: @@ -1,12 +1,19 @@
 package utils
 
 import (
+	"context"
+	"strings"
+
+	"k8s.io/apimachinery/pkg/fields"
+	"k8s.io/apimachinery/pkg/util/sets"
+	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
 
+	capsulev1beta1 "github.com/clastix/capsule/api/v1beta1"
 	"github.com/clastix/capsule/pkg/utils"
 )
 
-func IsCapsuleUser(req admission.Request, userGroups []string) bool {
+func IsCapsuleUser(ctx context.Context, req admission.Request, clt client.Client, userGroups []string) bool {
 	groupList := utils.NewUserGroupList(req.UserInfo.Groups)
 	// if the user is a ServiceAccount belonging to the kube-system namespace, definitely, it's not a Capsule user
 	// and we can skip the check in case of Capsule user group assigned to system:authenticated
@@ -14,6 +21,23 @@ func IsCapsuleUser(req admission.Request, userGroups []string) bool {
 	if groupList.Find("system:serviceaccounts:kube-system") {
 		return false
 	}
+	// nolint:nestif
+	if sets.NewString(req.UserInfo.Groups...).Has("system:serviceaccounts") {
+		parts := strings.Split(req.UserInfo.Username, ":")
+
+		targetNamespace := parts[2]
+
+		if len(targetNamespace) > 0 {
+			tl := &capsulev1beta1.TenantList{}
+			if err := clt.List(ctx, tl, client.MatchingFieldsSelector{Selector: fields.OneTermEqualSelector(".status.namespaces", targetNamespace)}); err != nil {
+				return false
+			}
+
+			if len(tl.Items) == 1 {
+				return true
+			}
+		}
+	}
 
 	for _, group := range userGroups {
 		if groupList.Find(group) {

programming_language: Go