Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Initial version of PSA, and namespace bypass #19

Merged
merged 2 commits into from
Apr 25, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/CREATE_PRIVILEGED_WORKLOAD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# CREATE_PRIVILEGED_WORKLOAD

### Overview

This attack path aims to locate subjects that can create workloads that can be considered `privileged`. These are workloads deliberately configured with known weaknesses in their pod specification that could allow for a container breakout. Upon successful execution, an attacker would gain potentially privileged access to nodes they can deploy workloads upon.

### Description

An attacker with the ability to create a pod could configure the pod to have attributes that would align with the `privileged` Pod Security Standard (PSS). These include configurations that allow for a breakout, for example the `privileged` flag, or access to the host filesystem.

As they are creating the pod themselves, they would also be able to configure the process to run as root and set a custom malicious command to allow code execution on the host as the root user gaining privileged access. This assumes user namespaces are not in effect.

### Defense

PSS should be enforced within the cluster. This could be done through Pod Security Admission (PSA) through labels on the namespace. Should more granularity be required, a validating admission webhook could be used as an alternative.

### Cypher Deep-Dive

```cypher
MATCH (src)-[:GRANTS_PODS_CREATE|{create_workload_query()}]->(ns:Namespace)-[:WITHIN_CLUSTER]->(cluster), (dest:Node) WHERE cluster.major_minor >= 1.25 AND (ns.psa_enforce <> 'restricted' AND ns.psa_enforce <> 'baseline')
```

The above query finds all entities (`src`) that have the CREATE permission against workload resource types within a specified namespace. All CREATE verbs are against the namespace for a namespaced resource. The target node (`dest`) is a node within the cluster.

The Cluster object is also retrieved to have access to the clusters Kubernetes version. Filters are performed to ensure the cluster version is greater than 1.25, where PSA moved to stable, and it is assumed that PSA is the sole enforcer of pod security within the cluster. The query then validates that the namespace does not enforce the `restricted` or `baseline` standards. This leaves `privileged` or blank both of which do not specify any restrictions on a pod specification. IceKube retrieves this configuration from the `pod-security.kubernetes.io/enforce` label of the namespace.

Workload creation is used as opposed to solely pods because various Kubernetes controllers create pods automatically from more abstract workload resources. Configuration of the workload resource also configures the created pod, thus it would allow an attacker to create the desired pod.

Workload creation includes the following:
- `pods`
- `replicationcontrollers`
- `daemonsets`
- `deployments`
- `replicasets`
- `statefulsets`
- `cornjobs`
- `jobs`
41 changes: 41 additions & 0 deletions docs/PATCH_NAMESPACE_TO_BYPASS_PSA.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# PATCH_NAMESPACE_TO_BYPASS_PSA

### Overview

This attack path aims to locate subjects that can both create workloads within a namespace that has Pod Security Admission (PSA) enforced, and the ability to modify the same namespace. This would allow the modification of the PSA policy, and therefore enable the deployment of insecure workloads as described in `CREATE_PRIVILEGED_WORKLOAD` gaining privileged access to nodes within the cluster.

### Description

While Namespace resources are cluster-wide, they are also considered a namespaced resource for certain actions against that particular namespace. This includes `get`, `patch`, `update` and `delete`. An entity with permissions constrained to solely to a namespace using a RoleBinding, could have permissions against the Namespace resource itself. Should this include `patch` or `update`, an attacker can modify the Namespace.

PSA is configured through labels on a namespace. These can be modified by an entity with `patch` or `update` permissions on the namespace. Therefore, an attacker with these permissions on the namespace could modify the PSA to allow all workloads to be deployed.

After such a modification is made, a privileged workload can be created within the namespace as described in `CREATE_PRIVILEGED_WORKLOAD`.

### Defense

Ensure permissions assigned to entities follow the principles of least privilege. Specifically, granting permissions on a namespace within a Role / RoleBinding should be avoided. Due care should be given when utilising wildcards in RBAC as this may inadvertently grant undesired permissions.

### Cypher Deep-Dive

```cypher
MATCH (src)-[:GRANTS_PODS_CREATE|{create_workload_query()}]->(ns:Namespace)-[:WITHIN_CLUSTER]->(cluster), (dest:Node) WHERE (src)-[:GRANTS_PATCH|GRANTS_UPDATE]->(ns) AND cluster.major_minor >= 1.25
```

The above query finds all entities (`src`) that have the CREATE permission against workload resource types within a specified namespace. All CREATE verbs are against the namespace for a namespaced resource. The target node (`dest`) is a node within the cluster.

The Cluster object is also retrieved to have access to the clusters Kubernetes version. Filters are performed to ensure the cluster version is greater than 1.25, where PSA moved to stable, and it is assumed that PSA is the sole enforcer of pod security within the cluster.

The query also validates that the entity has the ability to `patch` or `update` the namespace. Should they have this permission, the specifics of which PSA are currently enforced on the namespace are irrelevant as the attacker can simply change it to a more favourable value.

Workload creation is used as opposed to solely pods because various Kubernetes controllers create pods automatically from more abstract workload resources. Configuration of the workload resource also configures the created pod, thus it would allow an attacker to create the desired pod.

Workload creation includes the following:
- `pods`
- `replicationcontrollers`
- `daemonsets`
- `deployments`
- `replicasets`
- `statefulsets`
- `cornjobs`
- `jobs`
12 changes: 12 additions & 0 deletions icekube/attack_paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ def workload_query(
Relationship.CREATE_POD_WITH_SA: f"""
MATCH (src)-[:GRANTS_PODS_CREATE|{create_workload_query()}]->(ns:Namespace)<-[:WITHIN_NAMESPACE]-(dest:ServiceAccount)
""",
# Subject has permissions to create a workload that can allow breakout onto the underlying
# node
Relationship.CREATE_PRIVILEGED_WORKLOAD: [
# Assume PSA is enabled for cluster versions >= 1.25
f"MATCH (src)-[:GRANTS_PODS_CREATE|{create_workload_query()}]->(ns:Namespace)-[:WITHIN_CLUSTER]->(cluster), (dest:Node) "
"WHERE cluster.major_minor >= 1.25 AND (ns.psa_enforce <> 'restricted' AND ns.psa_enforce <> 'baseline')",
],
# Patch namespace to remove PSA restrictions and create privileged workload
Relationship.PATCH_NAMESPACE_TO_BYPASS_PSA: f"""
MATCH (src)-[:GRANTS_PODS_CREATE|{create_workload_query()}]->(ns:Namespace)-[:WITHIN_CLUSTER]->(cluster), (dest:Node)
WHERE (src)-[:GRANTS_PATCH|GRANTS_UPDATE]->(ns) AND cluster.major_minor >= 1.25
""",
# Subject has permission to update workload within namespace with target
# Service Account
Relationship.UPDATE_WORKLOAD_WITH_SA: f"""
Expand Down
2 changes: 2 additions & 0 deletions icekube/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
clusterrole,
clusterrolebinding,
group,
namespace,
pod,
role,
rolebinding,
Expand All @@ -23,6 +24,7 @@
"clusterrole",
"clusterrolebinding",
"group",
"namespace",
"pod",
"role",
"rolebinding",
Expand Down
7 changes: 6 additions & 1 deletion icekube/models/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from icekube.relationships import Relationship
from icekube.utils import to_camel_case
from kubernetes import client
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field, computed_field, model_validator

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -56,6 +56,11 @@ def __eq__(self, other) -> bool:
def data(self) -> Dict[str, Any]:
return cast(Dict[str, Any], json.loads(self.raw or "{}"))

@computed_field # type: ignore
@property
def labels(self) -> Dict[str, str]:
return cast(Dict[str, str], self.data.get("metadata", {}).get("labels", {}))

@model_validator(mode="before")
def inject_missing_required_fields(cls, values):
if not all(load(values, x) for x in ["apiVersion", "kind", "plural"]):
Expand Down
16 changes: 14 additions & 2 deletions icekube/models/cluster.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from typing import Dict, List
import re
from typing import Any, Dict, List

from icekube.models.base import RELATIONSHIP, Resource
from pydantic import computed_field


class Cluster(Resource):
Expand All @@ -13,12 +15,22 @@ class Cluster(Resource):
def __repr__(self) -> str:
return f"Cluster(name='{self.name}', version='{self.version}')"

@computed_field # type: ignore
@property
def db_labels(self) -> Dict[str, str]:
def major_minor_version(self) -> float:
match = re.match(r"^v?(\d+\.\d+)[^\d]", self.version)
# failed to retrieve, set to a super new version
if not match:
return 100.0
return float(match.groups()[0])

@property
def db_labels(self) -> Dict[str, Any]:
return {
**self.unique_identifiers,
"plural": self.plural,
"version": self.version,
"major_minor": self.major_minor_version,
}

def relationships(
Expand Down
25 changes: 25 additions & 0 deletions icekube/models/namespace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
from __future__ import annotations

from functools import cached_property
from typing import Any, Dict, List, cast

from icekube.models.base import Resource
from pydantic import computed_field


class Namespace(Resource):
supported_api_groups: List[str] = [""]

@computed_field # type: ignore
@cached_property
def psa_enforce(self) -> str:
return cast(
str, self.labels.get("pod-security.kubernetes.io/enforce", "privileged")
)

@property
def db_labels(self) -> Dict[str, Any]:
return {
**super().db_labels,
"psa_enforce": self.psa_enforce,
}
22 changes: 22 additions & 0 deletions icekube/models/policyrule.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,25 @@ def affected_resource_query(
else:
names = [name.replace("*", ".*") for name in self.resourceNames]
yield (tags, generate_query({**find_filter, "name": names}))

# Special case for Namespace objects as they are both cluster-wide and
# namespaced
if namespace and resource == "namespaces":
permitted_namespaced_verbs = set("get patch update delete".split())
namespace_verbs = permitted_namespaced_verbs.intersection(valid_verbs)

namespace_filter = {
k: v for k, v in find_filter.items() if k != "namespace"
}

tags = [
Relationship.generate_grant(verb, sub_resource)
for verb in namespace_verbs
]

# Ensure that any resourceNames still allow the actual namespace name
if self.resourceNames:
if not any(fnmatch("namespaces", x) for x in self.resourceNames):
return

yield (tags, generate_query({**namespace_filter, "name": [namespace]}))
2 changes: 2 additions & 0 deletions icekube/relationships.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ class Relationship:
MOUNTS_SECRET: ClassVar[str] = "MOUNTS_SECRET"
CREATE_POD_WITH_SA: ClassVar[str] = "CREATE_POD_WITH_SA"
UPDATE_WORKLOAD_WITH_SA: ClassVar[str] = "UPDATE_WORKLOAD_WITH_SA"
CREATE_PRIVILEGED_WORKLOAD: ClassVar[str] = "CREATE_PRIVILEGED_WORKLOAD"
PATCH_NAMESPACE_TO_BYPASS_PSA: ClassVar[str] = "PATCH_NAMESPACE_TO_BYPASS_PSA"

EXEC_INTO: ClassVar[str] = "EXEC_INTO"
REPLACE_IMAGE: ClassVar[str] = "REPLACE_IMAGE"
Expand Down
Loading