{ "cells": [ { "cell_type": "markdown", "metadata": {}, "source": [ "# Determine Pod Escape (ipynb)\n", "\n", "**References**\n", "- [Bad Pods: Kubernetes Pod Privilege Escalation](https://bishopfox.com/blog/kubernetes-pod-privilege-escalation)\n", "- [Pod Security Context](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#podsecuritycontext-v1-core)\n", "- [Container Security Context](https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.24/#securitycontext-v1-core)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Install Dependencies" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Install the dependencies `ipywidgets`, `pandas` and `kubectl`. Skip the next cell if they had already been installed." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# install ipywidgets, pandas\n", "!pip3 install ipywidgets pandas" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# install kubectl\n", "!gcloud components install kubectl --quiet" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Imports and Configuration" ] }, { "cell_type": "code", "execution_count": null, "id": "f7fb1bd9", "metadata": {}, "outputs": [], "source": [ "import ipywidgets as widgets\n", "import json\n", "import os\n", "import pandas as pd\n", "\n", "from IPython.display import HTML, display\n", "\n", "# extend width of widgets\n", "display(HTML(''''''))\n", "# extend width of cells\n", "display(HTML(\"\"))\n", "display(HTML(\"\"))\n", "\n", "# extend width and max rows of pandas output\n", "pd.set_option('display.max_colwidth', None)\n", "pd.set_option('display.max_rows', None)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# [OPTIONAL] authenticate using your service account\n", "!gcloud auth activate-service-account --key-file " ] }, { "cell_type": "markdown", "id": "bca0c5b6", "metadata": {}, "source": [ "## Define Environment Variables" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "**Specify the following information**\n", "| Fields | Description |\n", "| ----------- | ----------- |\n", "| `Source Project` | Project id of target project that contains the k8s cluster |\n", "| `Cluster Name` | Name of k8s cluster |\n", "| `Cluster Type` | Type of k8s cluster (i.e. Regional or Zonal) |" ] }, { "cell_type": "code", "execution_count": null, "id": "fba8f19e", "metadata": {}, "outputs": [], "source": [ "# create text boxes for user input\n", "src_project = widgets.Text(description = \"Source Project: \", disabled=False)\n", "cluster_name = widgets.Text(description = \"Cluster Name: \", disabled=False)\n", "cluster_type = widgets.Dropdown(options=['Regional', \"Zonal\"], value='Zonal', description=\"Cluster Type: \", disabled=False)\n", "\n", "display(src_project, cluster_name, cluster_type)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "If `Cluster Type` is `Regional`, specify the `Cluster Region` (e.g. `asia-southeast1`). \n", "Else, if `Cluster Type` is `Zonal`, specify the `Cluster Zone` (e.g. `asia-southeast1-b`)." ] }, { "cell_type": "code", "execution_count": null, "id": "94a90614", "metadata": {}, "outputs": [], "source": [ "# create text boxes for user input\n", "if cluster_type.value == 'Regional':\n", " cluster_region = widgets.Text(description = \"Cluster Region: \", disabled=False)\n", " display(cluster_region)\n", "elif cluster_type.value == 'Zonal':\n", " cluster_zone = widgets.Text(description = \"Cluster Zone: \", disabled=False)\n", " display(cluster_zone)" ] }, { "cell_type": "code", "execution_count": null, "id": "4f0efea9", "metadata": {}, "outputs": [], "source": [ "# store user input in environment variables for use in subsequent comamnds\n", "os.environ['SRC_PROJECT'] = src_project.value\n", "os.environ['CLUSTER_NAME'] = cluster_name.value\n", "\n", "if cluster_type.value == 'Regional':\n", " os.environ['CLUSTER_REGION'] = cluster_region.value\n", "elif cluster_type.value == 'Zonal':\n", " os.environ['CLUSTER_ZONE'] = cluster_zone.value" ] }, { "cell_type": "markdown", "id": "cf20cd88", "metadata": {}, "source": [ "## Get Cluster `nodeConfig`" ] }, { "cell_type": "code", "execution_count": null, "id": "9a2894e1", "metadata": {}, "outputs": [], "source": [ "if cluster_type.value == 'Regional':\n", " !gcloud container clusters describe $CLUSTER_NAME --region $CLUSTER_REGION --project $SRC_PROJECT --format='json' > cluster_descr.json\n", "elif cluster_type.value == 'Zonal':\n", " !gcloud container clusters describe $CLUSTER_NAME --zone $CLUSTER_ZONE --project $SRC_PROJECT --format='json' > cluster_descr.json\n", "\n", "with open('./cluster_descr.json') as infile:\n", " cluster_descr = json.load(infile)\n", "cluster_descr_df = pd.json_normalize(cluster_descr['nodeConfig'])\n", "\n", "columns = ['metadata.disable-legacy-endpoints', 'serviceAccount', 'oauthScopes']\n", "display(cluster_descr_df[columns]\n", " .rename(columns={'metadata.disable-legacy-endpoints': 'disable-legacy-endpoints'}))" ] }, { "cell_type": "markdown", "id": "f3adfa48", "metadata": {}, "source": [ "`disable-legacy-endpoints`\n", "- Ensure that value is `true`\n", "- When `true`, config requires specified header is present when querying GCP metadata service, and disable querying of `v1beta1` endpoints\n", "\n", "`serviceAccount`\n", "- Service account attached to cluster\n", "- Default value is `default`, which is the `<12-digit>-compute@developer.gserviceaccount.com`\n", "- If not `default`, worthwhile to check the IAM roles/permissions granted to this service account\n", "\n", "`oauthScopes`\n", "- Scope of service account attached to cluster\n", "- Ensure that it **IS NOT** https://www.googleapis.com/auth/cloud-platform, which enables the authentication to any API function and leverage the full powers of IAM permissions assigned to the service account\n", "- Default is `devstorage.read_only`, `logging.write`, `monitoring`, `servicecontrol`, `service.management.readonly`, `trace.append`, which prevent the leveraging of full powers of IAM permissions assigned to the service account\n", "- If **NOT** the above, scope is user-customised\n", "- Scope **DOES NOT** matter if the access token of the service account is obtained from the metadata service and used outside of the cluster" ] }, { "cell_type": "markdown", "id": "b68c55af", "metadata": {}, "source": [ "## Connect to Cluster" ] }, { "cell_type": "code", "execution_count": null, "id": "8dcdee34", "metadata": {}, "outputs": [], "source": [ "if cluster_type.value == 'Regional':\n", " !gcloud container clusters get-credentials $CLUSTER_NAME --region $CLUSTER_REGION --project $SRC_PROJECT\n", "elif cluster_type.value == 'Zonal':\n", " !gcloud container clusters get-credentials $CLUSTER_NAME --zone $CLUSTER_ZONE --project $SRC_PROJECT" ] }, { "cell_type": "markdown", "id": "fb4a463f", "metadata": {}, "source": [ "## Get Pods' Security Context" ] }, { "cell_type": "code", "execution_count": null, "id": "0fed9853", "metadata": {}, "outputs": [], "source": [ "def highlight_not_na(value):\n", " if pd.isna(value):\n", " return None\n", " else:\n", " return 'color:white; background-color:purple'\n", "\n", "!kubectl get pods -A --output=json > pods_sc.json\n", "\n", "with open('./pods_sc.json') as infile:\n", " pods_sc = json.load(infile)\n", "pods_sc_df = pd.json_normalize(pods_sc['items'], max_level=3)\n", "\n", "desired_columns=['metadata.name', 'metadata.namespace', 'spec.securityContext.runAsNonRoot', 'spec.securityContext.runAsGroup', 'spec.securityContext.runAsUser', 'spec.securityContext.seLinuxOptions']\n", "columns = list(set(pods_sc_df.columns) & set(desired_columns))\n", "pods_sc_df_formatted = pods_sc_df[columns].rename(columns={'metadata.name': 'Pod Name', \n", " 'metadata.namespace': 'Namespace',\n", " 'spec.securityContext.runAsNonRoot': 'runAsNonRoot',\n", " 'spec.securityContext.runAsGroup': 'runAsGroup',\n", " 'spec.securityContext.runAsUser': 'runAsUser',\n", " 'spec.securityContext.seLinuxOptions': 'seLinuxOptions'}).sort_index(axis=1)\n", " \n", "unwanted_columns = ['Namespace', 'Pod Name']\n", "columns = [x for x in list(pods_sc_df_formatted.columns) if x not in unwanted_columns]\n", "display(pods_sc_df_formatted\n", " .dropna(thresh=3)\n", " .style.format(precision=0).applymap(highlight_not_na, subset=pd.IndexSlice[:, columns]))" ] }, { "cell_type": "markdown", "id": "034b7fe8", "metadata": {}, "source": [ "Due to the potential overwhelming output that the `kubectl` command could return, the output had been parsed to return only values that are not `NA`. Check against the following documentation to determine if these values are of concern.\n", "\n", "`runAsNonRoot` - Indicates that the container must run as a **non-root** user\n", "\n", "`runAsGroup`\n", "- GID to run the entrypoint of the container process\n", "- Uses runtime default if unset\n", "- Often set up in conjunction with volume mounts containing files that have the same ownership IDs\n", "- In GKE, it is normal for `event-exporter-gke`, `konnectivity-agent` and `konnectivity-agent-autoscaler` to have `runAsGroup` value of `1000`\n", "\n", "`runAsUser`\n", "- UID to run the entrypoint of the container process\n", "- Defaults to user specified in image metadata if unspecified\n", "- Enables the viewing of environment variables or file descriptors of processes with the specified UID\n", "- Often set up in conjunction with volume mounts containing files that have the same ownership IDs\n", "- Check `/etc/passwd` of host/node to map uid to username\n", "- In GKE, it is normal for `event-exporter-gke`, `konnectivity-agent` and `konnectivity-agent-autoscaler` to have `runAsUser` value of `1000`\n", "\n", "`seLinuxOptions`\n", "- SELinux is a policy driven system to control access to apps, processes and files on a Linux system\n", "- Implements the Linux Security Modules framework in the Linux kernel\n", "- Based on the concept of labels - it applies these labels to all the elements in the system which group elements together\n", "- Labels are also known as the security context (not to be confused with the Kubernetes `securityContext`)\n", "- Labels consist of a user, role, type, and an optional field level in the format `user:role:type:level`\n", "- SELinux then uses policies to define which processes of a particular context can access other labelled objects in the system\n", "- SELinux can be strictly enforced, in which case access will be denied, or it can be configured in permissive mode where it will log access\n", "- In containers, SELinux typically labels the container process and the container image in such a way as to restrict the process to only access files within the image\n", "- Changing the SELinux labeling for a container could potentially allow the containerized process to escape the container image and access the host filesystem" ] }, { "cell_type": "markdown", "id": "122650ba", "metadata": {}, "source": [ "## Get Containers' Security Context (Precedence over Pods')" ] }, { "cell_type": "code", "execution_count": null, "id": "5801bcb6", "metadata": { "scrolled": true }, "outputs": [], "source": [ "def highlight_not_na(value):\n", " if pd.isna(value):\n", " return None\n", " else:\n", " return 'color:white; background-color:purple'\n", "\n", "with open('./pods_sc.json') as infile:\n", " ctrs_sc = json.load(infile)\n", "\n", "frames = list()\n", "for item in ctrs_sc['items']:\n", " for ctr in item['spec']['containers']:\n", " ctr_series = dict()\n", " ctr_series['Namespace'] = item['metadata']['namespace']\n", " ctr_series['Pod Name'] = item['metadata']['name']\n", " ctr_series['Container Name'] = ctr['name']\n", " if 'securityContext' in ctr:\n", " securityContext = ctr['securityContext']\n", " if 'privileged' in securityContext: ctr_series['privileged'] = securityContext['privileged']\n", " if 'allowPrivilegeEscalation' in securityContext: ctr_series['allowPrivilegeEscalation'] = securityContext['allowPrivilegeEscalation']\n", " if 'capabilities' in securityContext: ctr_series['capabilities'] = securityContext['capabilities']\n", " if 'procMount' in securityContext: ctr_series['procMount'] = securityContext['procMount']\n", " if 'readOnlyRootFilesystem' in securityContext: ctr_series['readOnlyRootFilesystem'] = securityContext['readOnlyRootFilesystem']\n", " if 'runAsGroup' in securityContext: ctr_series['runAsGroup'] = securityContext['runAsGroup']\n", " if 'runAsNonRoot' in securityContext: ctr_series['runAsNonRoot'] = securityContext['runAsNonRoot']\n", " if 'runAsUser' in securityContext: ctr_series['runAsUser'] = securityContext['runAsUser']\n", " if 'seLinuxOptions' in securityContext: ctr_series['seLinuxOptions'] = securityContext['seLinuxOptions']\n", " if 'windowsOptions' in securityContext: ctr_series['windowsOptions'] = securityContext['windowsOptions'] \n", " ctr_series = pd.Series(ctr_series)\n", " frames.append(ctr_series)\n", "ctrs_sc_df = pd.DataFrame(frames)\n", "\n", "unwanted_columns = ['Namespace', 'Pod Name', 'Container Name']\n", "columns = [x for x in list(ctrs_sc_df.columns) if x not in unwanted_columns]\n", "display(ctrs_sc_df\n", " .dropna(thresh=4)\n", " .style.format(precision=0).applymap(highlight_not_na, subset=pd.IndexSlice[:, columns]))" ] }, { "cell_type": "markdown", "id": "90b47ab7", "metadata": {}, "source": [ "Due to the overwhelming output that the `kubectl` command could return, the output had been parsed to return only values that are not `NA`. Amongst the displayed output are pods in `kube-system` namespace which come with the GKE cluster by default and they can be ignored. For others, check against the following to determine if the values are of concern. \n", "\n", "`privileged`\n", "- Runs container in privileged mode\n", "- Processes in privileged containers are essentially equivalent to root on the node/host\n", "- Provides access to `/dev` on the host, which enables the mounting of the node/host filesytem to the privileged pod\n", " - But provides a limited view of the filesystem - files that require privilege escalation (e.g. to root) are not accessible\n", "- Enables multiple options to gaining RCE with root privileges on the node/host\n", "\n", "`allowPrivilegeEscalation`\n", "- Controls whether a process can gain more privileges than its parent process\n", "- This bool directly controls if the `no_new_privs` flag will be set on the container process\n", "- Always `true` when the container is: 1) run as `privileged` 2) has `CAP_SYS_ADMIN`\n", "\n", "`capabilities`\n", "- Kernel level permissions that allow for more granular controls over kernel call permissions than simply running as root\n", "- Capabilities include things like the ability to change file permissions, control the network subsystem, and perform system-wide administration functions\n", "- Can be configured to `drop` or `add` capabilities\n", "\n", "`procMount`\n", "- By default, container runtimes mask certain parts of the `/proc` filesystem from inside a container in order to prevent potential security issues\n", "- However, there are times when access to those parts of `/proc` is required; particularly when using nested containers as is often used as part of an in-cluster build process\n", "- There are only two valid options for this entry:\n", " - `Default`, which maintains the standard container runtime behavior, or\n", " - `Unmasked`, which removes all masking for the /proc filesystem.\n", "\n", "`readOnlyRootFilesystem`\n", "- Default is `false` (represented by `nan` in the output)\n", "- If `true`, limits the actions that an attacker can perform on the container filesystem\n", "\n", "`windowsOptions`\n", "- Windows specific settings applied to all containers" ] }, { "cell_type": "markdown", "id": "5072cf74", "metadata": {}, "source": [ "## Check Pods' `hostPID`, `hostIPC`, `hostNetwork` Config" ] }, { "cell_type": "code", "execution_count": null, "id": "261e2d7b", "metadata": {}, "outputs": [], "source": [ "!kubectl get pods -A --field-selector=metadata.namespace!=kube-system \\\n", " -o custom-columns=Name:.metadata.name,Namespace:.metadata.namespace,HostPID:.spec.hostPID,HostIPC:.spec.hostIPC,HostNetwork:.spec.hostNetwork" ] }, { "cell_type": "markdown", "id": "fb985d68", "metadata": {}, "source": [ "`hostPID`\n", "* **Unable** to get privileged code execution on the host directly with only `hostPID: true`\n", "* If `true`, possible options for attacker\n", " * View processes on host, including processes running in each pod\n", " * View environment variables for each pod on the host (which may contain credentials)\n", " * Applies only to processes running within pods that share the same UID as the `hostPID` pod\n", " * To get the environment variables from processes that do not share the same UID, `hostPID` pod needs to run with the `runAsUser` set to the desired UID\n", " * View file descriptors for each pod on the host (which may contain credentials)\n", " * Permissions about environment variables above applies here as well\n", " * Kill process on the node\n", "\n", "`hostIPC`\n", "* **Unable** to get privileged code execution on the host directly with only `hostIPC: true`\n", "* If any process on the host or any processes in a pod uses the host’s inter-process communication mechanisms (shared memory, semaphore arrays, message queues, etc), these mechanisms can be read or written to\n", "* If `true`, possible options for attacker\n", " * Access data used by any pods that also use the host’s IPC namespace by inspecting `/dev/shm`\n", " * `/dev/shm` is shared between any pod with `hostIPC: true` and the host\n", " * Look for any files in this shared memory location\n", " * Inspect existing IPC facilities - Check to see if any IPC facilities are being used with `/usr/bin/ipcs -a`\n", "\n", "`hostNetwork`\n", "* **Unable** to get privileged code execution on the host directly with only `hostNetwork: true`\n", "* If `true`, possible options for attacker\n", " * Sniff traffic - Use tcpdump to sniff unencrypted traffic on any interface on the host\n", " * Access services bound to localhost - Can reach services that only listen on the host’s loopback interface or that are otherwise blocked by network policies\n", " * Bypass network policy - Pod would be bound to the host’s network interfaces and not the pods/namspaces’" ] }, { "cell_type": "markdown", "id": "ad966a01", "metadata": {}, "source": [ "## Check Pods' `hostpath` Config" ] }, { "cell_type": "code", "execution_count": null, "id": "0481e0af", "metadata": {}, "outputs": [], "source": [ "!kubectl get pods -A --field-selector=metadata.namespace!=kube-system \\\n", " -o custom-columns=Name:.metadata.name,Namespace:.metadata.namespace,HostPath:.spec.volumes[].hostPath" ] }, { "cell_type": "markdown", "id": "fdcf13c6", "metadata": {}, "source": [ "* No results returned if there are no pods with `hostpath` configured\n", "* If the administrator had not limited what can be mounted, the entire host’s filesystem can be mounted\n", "* Provides read/write access on the host’s filesystem (limited to what the administrator defined)\n", "* If configured, possible options for attacker\n", " * Look for kubeconfig files on the host filesystem (may find a cluster-admin config with full access to everything)\n", " * **Not applicable** to GKE as GKE by default **DOES NOT** store kubeconfig files (i.e. `.kube/config`) on the node hosting the pod\n", " * Add persistence \n", " * Add own SSH key\n", " * Add own CRON job\n", " * Crack hashed passwords in `/etc/shadow`\n", "* Mount point can be found with\n", " * `kubectl describe pod hostpath-exec-pod | sed -ne '/Mounts/,/Conditions/p'`" ] } ], "metadata": { "interpreter": { "hash": "e774977668b7c0ae8309835a5187aa7fbf7669e7d0bb59755bc63e573643edcd" }, "kernelspec": { "display_name": "Python 3.7.9 64-bit", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.7.9" }, "widgets": { "application/vnd.jupyter.widget-state+json": { "state": {}, "version_major": 2, "version_minor": 0 } } }, "nbformat": 4, "nbformat_minor": 4 }