From 8c6712f3788c5181d5617723c538a9b153fdc07e Mon Sep 17 00:00:00 2001 From: daniel_sagi Date: Wed, 20 Jun 2018 12:09:36 +0300 Subject: [PATCH 1/6] 1. Changed report methods and renamed "log" module to "", added another report generation in a new json format 2. started to add the --token option to send the finished report. 3. changed a bit of kubelet vulnerability output architecture to match out conventions. 4. added healthz check on kubelets --- kube-hunter.py | 11 +- log/reporter.py | 101 ----------------- {log => report}/__init__.py | 0 report/reporter.py | 187 +++++++++++++++++++++++++++++++ src/core/events/types/common.py | 5 +- src/modules/discovery/hosts.py | 2 - src/modules/discovery/kubelet.py | 25 +---- src/modules/hunting/kubelet.py | 144 ++++++++++++++++-------- 8 files changed, 297 insertions(+), 178 deletions(-) delete mode 100644 log/reporter.py rename {log => report}/__init__.py (100%) create mode 100644 report/reporter.py diff --git a/kube-hunter.py b/kube-hunter.py index 7b9121a..4ebc777 100644 --- a/kube-hunter.py +++ b/kube-hunter.py @@ -8,7 +8,8 @@ import time parser = argparse.ArgumentParser(description='Kube-Hunter, Hunter for weak Kubernetes cluster') parser.add_argument('--pod', action="store_true", help="set hunter as an insider pod") parser.add_argument('--cidr', type=str, help="set manual cidr to scan, example: 192.168.0.0/16") -parser.add_argument('--quick', action="store_true", help="scanning only known small sections of the subnet") +# parser.add_argument('--quick', action="store_true", help="scanning only known small sections of the subnet") +parser.add_argument('--token', type=str, help="token addition") parser.add_argument('--mapping', action="store_true", help="outputs only mapping of cluster's nodes") parser.add_argument('--remote', nargs='+', metavar="HOST", default=list(), help="one or more remote ip/dns to hunt") parser.add_argument('--active', action="store_true", help="enables active hunting") @@ -20,7 +21,7 @@ except: pass logging.basicConfig(level=loglevel, format='%(asctime)s - [%(levelname)s]: %(message)s') -import log +from report import reporter from src.core.events import handler from src.modules.discovery import HostDiscovery from src.modules.discovery.hosts import HostScanEvent @@ -36,7 +37,11 @@ def main(): finally: handler.free() logging.debug("Cleaned Queue") - log.print_results() + reporter.print_tables() + print reporter.generate_report() + # if config.token: + # reporter.send_report(token=config.token) + if config.pod: while True: time.sleep(5) diff --git a/log/reporter.py b/log/reporter.py deleted file mode 100644 index ae9b4cd..0000000 --- a/log/reporter.py +++ /dev/null @@ -1,101 +0,0 @@ -import logging - -from prettytable import ALL, PrettyTable - -from __main__ import config -from src.core.events import handler -from src.core.events.types import Service, Vulnerability - -services = list() -vulnerabilities = list() - -EVIDENCE_PREVIEW = 40 -MAX_WIDTH_VULNS = 70 -MAX_WIDTH_SERVICES = 60 - -@handler.subscribe(Vulnerability) -class VulnerabilityReport(object): - def __init__(self, event): - self.vulnerability = event - - def execute(self): - logging.info("[VULNERABILITY - {name}] {desc}".format( - name=self.vulnerability.get_name(), - desc=self.vulnerability.explain(), - )) - vulnerabilities.append(self.vulnerability) - # TODO: Add ActiveHunter replacement by id, when a vulnerability comes from active hunter, it replaces it's predecessor - -@handler.subscribe(Service) -class OpenServiceReport(object): - def __init__(self, event): - self.service = event - - def execute(self): - logging.info("[OPEN SERVICE - {name}] IP:{host} PORT:{port}".format( - name=self.service.name, - desc=self.service.desc, - host=self.service.host, - port=self.service.port - )) - services.append(self.service) - -def print_nodes(): - nodes_table = PrettyTable(["Type", "Location"], hrules=ALL) - nodes_table.align="l" - nodes_table.max_width=MAX_WIDTH_SERVICES - nodes_table.padding_width=1 - nodes_table.sortby="Type" - nodes_table.reversesort=True - nodes_table.header_style="upper" - - # TODO: replace with sets - id_memory = list() - for service in services: - if service.id not in id_memory: - nodes_table.add_row(["Slave/Master", service.host]) - id_memory.append(service.id) - print "Nodes:" - print nodes_table - print - -def print_services(): - services_table = PrettyTable(["Service", "Location", "Description"], hrules=ALL) - services_table.align="l" - services_table.max_width=MAX_WIDTH_SERVICES - services_table.padding_width=1 - services_table.sortby="Service" - services_table.reversesort=True - services_table.header_style="upper" - for service in services: - services_table.add_row([service.get_name(), "{}:{}{}".format(service.host, service.port, service.get_path()), service.explain()]) - print "Open Services:" - print services_table - print - -def print_vulnerabilities(): - column_names = ["Location", "Category", "Vulnerability", "Description"] - if config.active: column_names.append("Evidence") - vuln_table = PrettyTable(column_names, hrules=ALL) - vuln_table.align="l" - vuln_table.max_width=MAX_WIDTH_VULNS - vuln_table.sortby="Category" - vuln_table.reversesort=True - vuln_table.padding_width=1 - vuln_table.header_style="upper" - for vuln in vulnerabilities: - row = ["{}:{}".format(vuln.host, vuln.port) if vuln.host else "", vuln.component.name, vuln.get_name(), vuln.explain()] - if config.active: - evidence = str(vuln.evidence)[:EVIDENCE_PREVIEW] + "..." if len(str(vuln.evidence)) > EVIDENCE_PREVIEW else str(vuln.evidence) - row.append(evidence) - vuln_table.add_row(row) - print "Vulnerabilities:" - print vuln_table - print - - -def print_results(): - print_nodes() - if not config.mapping: - print_services() - print_vulnerabilities() \ No newline at end of file diff --git a/log/__init__.py b/report/__init__.py similarity index 100% rename from log/__init__.py rename to report/__init__.py diff --git a/report/reporter.py b/report/reporter.py new file mode 100644 index 0000000..544f3ee --- /dev/null +++ b/report/reporter.py @@ -0,0 +1,187 @@ +import json +import logging +from collections import defaultdict + +import requests +from prettytable import ALL, PrettyTable + +from __main__ import config +from src.core.events import handler +from src.core.events.types import Service, Vulnerability + +# [event, ...] +services = list() + +# [(TypeClass, event), ...] +insights = list() + +vulnerabilities = list() + +EVIDENCE_PREVIEW = 40 +MAX_WIDTH_VULNS = 70 +MAX_WIDTH_SERVICES = 60 + +@handler.subscribe(Service) +@handler.subscribe(Vulnerability) +class Reporter(object): + """Reportes can be initiated by the event handler, and by regular decaration. for usage on end of runtime""" + def __init__(self, event=None): + self.event = event + self.insights_by_id = defaultdict(list) + self.services_by_id = defaultdict(list) + + def execute(self): + """function is called only when collecting data""" + global services, insights + bases = self.event.__class__.__mro__ + if Service in bases: + services.append(self.event) + logging.info("[OPEN SERVICE - {name}] IP:{host} PORT:{port}".format( + host=self.event.host, + port=self.event.port, + name=self.event.get_name(), + desc=self.event.explain() + )) + elif Vulnerability in bases: + insights.append((Vulnerability, self.event)) + vulnerabilities.append(self.event) + logging.info("[VULNERABILITY - {name}] {desc}".format( + name=self.event.get_name(), + desc=self.event.explain(), + )) + + def print_tables(self): + """generates report tables and outputs to stdout""" + print_nodes() + if not config.mapping: + print_services() + print_vulnerabilities() + + def build_sub_services(self, services_list): + current_list = list() + for service in services_list: + current_list.append( + { + "type": service.get_name(), + "metadata": { + "port": service.port, + "path": service.get_path() + }, + "description": service.explain() + }) + next_services = self.get_services_by_service(service) + if next_services: + current_list[-1]["services"] = self.build_sub_services(next_services) + current_list[-1]["insights"] = [{ + "type": insight_type.__name__, + "name": insight.get_name(), + "description": insight.explain(), + "evidence": insight.evidence if insight_type == Vulnerability else "" + } for insight_type, insight in self.get_insights_by_service(service)] + + return current_list + + def generate_report(self): + """function generates report structure, for""" + for service in services: + self.services_by_id[service.event_id].append(service) + for insight_type, insight in insights: + self.insights_by_id[insight.event_id].append((insight_type, insight)) + + report = defaultdict(list) + # building first layer of services (nodes) + for _, services_list in self.services_by_id.items(): + service_report = defaultdict(list) + service_report["type"] = "Node" + service_report["metadata"] = { + "host": str(services_list[0].host) + } + # building all sub layers. + service_report["services"] = self.build_sub_services(services_list) + report["services"].append(service_report) + return json.dumps(report, indent=4) + + def send_report(self, token): + report = { + 'results': self.generate_report(), + 'metadata': {} + } + r = requests.put("https://pnmhh30s1b.execute-api.us-east-1.amazonaws.com/v02?token={}".format(token), json=report) + # if r.status_code == 200: + + print "{}: {}".format(r.status_code, r.text) + + # correlating + def get_insights_by_service(self, service): + """generates list of insights related to a given service""" + insights = list() + for insight_type, insight in self.insights_by_id[service.event_id]: + if service in insight.history: + insights.append((insight_type, insight)) + return insights + + def get_services_by_service(self, parent_service): + """generates list of insights related to a given service""" + services = list() + for service in self.services_by_id[parent_service.event_id]: + if service != parent_service and parent_service in service.history: + services.append(service) + self.services_by_id[parent_service.event_id].remove(service) + return services + + +reporter = Reporter() + + +def print_nodes(): + nodes_table = PrettyTable(["Type", "Location"], hrules=ALL) + nodes_table.align="l" + nodes_table.max_width=MAX_WIDTH_SERVICES + nodes_table.padding_width=1 + nodes_table.sortby="Type" + nodes_table.reversesort=True + nodes_table.header_style="upper" + + # TODO: replace with sets + id_memory = list() + for service in services: + if service.event_id not in id_memory: + nodes_table.add_row(["Slave/Master", service.host]) + id_memory.append(service.event_id) + print "Nodes:" + print nodes_table + print + +def print_services(): + services_table = PrettyTable(["Service", "Location", "Description"], hrules=ALL) + services_table.align="l" + services_table.max_width=MAX_WIDTH_SERVICES + services_table.padding_width=1 + services_table.sortby="Service" + services_table.reversesort=True + services_table.header_style="upper" + for service in services: + services_table.add_row([service.get_name(), "{}:{}{}".format(service.host, service.port, service.get_path()), service.explain()]) + print "Open Services:" + print services_table + print + +def print_vulnerabilities(): + column_names = ["Location", "Category", "Vulnerability", "Description"] + if config.active: column_names.append("Evidence") + vuln_table = PrettyTable(column_names, hrules=ALL) + vuln_table.align="l" + vuln_table.max_width=MAX_WIDTH_VULNS + vuln_table.sortby="Category" + vuln_table.reversesort=True + vuln_table.padding_width=1 + vuln_table.header_style="upper" + for vuln in vulnerabilities: + row = ["{}:{}".format(vuln.host, vuln.port) if vuln.host else "", vuln.component.name, vuln.get_name(), vuln.explain()] + if config.active: + evidence = str(vuln.evidence)[:EVIDENCE_PREVIEW] + "..." if len(str(vuln.evidence)) > EVIDENCE_PREVIEW else str(vuln.evidence) + row.append(evidence) + vuln_table.add_row(row) + print "Vulnerabilities:" + print vuln_table + print diff --git a/src/core/events/types/common.py b/src/core/events/types/common.py index a782712..66b4044 100644 --- a/src/core/events/types/common.py +++ b/src/core/events/types/common.py @@ -41,9 +41,6 @@ class Service(object): def explain(self): return self.__doc__ - def proof(self): - return self.name - class Vulnerability(object): def __init__(self, component, name): self.component = component @@ -64,7 +61,7 @@ class NewHostEvent(Event): def __init__(self, host, cloud=None): global event_id_count self.host = host - self.id = event_id_count + self.event_id = event_id_count self.cloud = cloud event_id_count += 1 diff --git a/src/modules/discovery/hosts.py b/src/modules/discovery/hosts.py index d0b922a..bed996e 100644 --- a/src/modules/discovery/hosts.py +++ b/src/modules/discovery/hosts.py @@ -35,12 +35,10 @@ class HostScanEvent(Event): if config.pod: with open("/run/secrets/kubernetes.io/serviceaccount/token") as token_file: return token_file.read() - return None def get_client_cert(self): if config.pod: return "/run/secrets/kubernetes.io/serviceaccount/ca.crt" - return None @handler.subscribe(HostScanEvent) class HostDiscovery(Hunter): diff --git a/src/modules/discovery/kubelet.py b/src/modules/discovery/kubelet.py index 09c8132..aa3a206 100644 --- a/src/modules/discovery/kubelet.py +++ b/src/modules/discovery/kubelet.py @@ -24,22 +24,6 @@ class SecureKubeletEvent(Service, Event): Service.__init__(self, name="Kubelet API") -""" Vulnerabilities """ -class ExposedPodsHandler(Vulnerability, Event): - """Exposes sensitive information about pods that are bound to the node""" - def __init__(self): - Vulnerability.__init__(self, Kubelet, "Exposed /pods") - -class AnonymousAuthEnabled(Vulnerability, Event): - """Anonymous Auth to the kubelet, exposes secure access to all requests on the kubelet""" - def __init__(self): - Vulnerability.__init__(self, Kubelet, "Anonymous Authentication") - - def proof(self): - pass # TODO: decide on an appropriate proof - - - class KubeletPorts(Enum): SECURED = 10250 READ_ONLY = 10255 @@ -53,21 +37,18 @@ class KubeletDiscovery(Hunter): logging.debug(self.event.host) r = requests.get("http://{host}:{port}/pods".format(host=self.event.host, port=self.event.port)) if r.status_code == 200: - self.publish_event(ExposedPodsHandler()) self.publish_event(ReadOnlyKubeletEvent()) def get_secure_access(self): event = SecureKubeletEvent() if self.ping_kubelet(authenticate=False) == 200: - self.publish_event(ExposedPodsHandler()) - self.publish_event(AnonymousAuthEnabled()) - event.anonymous_auth = True + event.secure = False # anonymous authentication is disabled elif self.ping_kubelet(authenticate=True) == 200: - event.anonymous_auth = False + event.secure = True self.publish_event(event) - def ping_kubelet(self, authenticate=False): + def ping_kubelet(self, authenticate): r = requests.Session() if authenticate: if self.event.auth_token: diff --git a/src/modules/hunting/kubelet.py b/src/modules/hunting/kubelet.py index 4b50c4c..d07f416 100644 --- a/src/modules/hunting/kubelet.py +++ b/src/modules/hunting/kubelet.py @@ -8,12 +8,22 @@ import urllib3 from __main__ import config from ...core.events import handler from ...core.events.types import Vulnerability, Event -from ..discovery.kubelet import ReadOnlyKubeletEvent, SecureKubeletEvent, ExposedPodsHandler +from ..discovery.kubelet import ReadOnlyKubeletEvent, SecureKubeletEvent from ...core.types import Hunter, ActiveHunter, KubernetesCluster, Kubelet urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) """ Vulnerabilities """ +class ExposedPodsHandler(Vulnerability, Event): + """Exposes all complete PodSpecs bound to a node""" + def __init__(self): + Vulnerability.__init__(self, Kubelet, "Exposed /pods") + +class AnonymousAuthEnabled(Vulnerability, Event): + """Anonymous Auth to the kubelet, exposes secure access to all requests on the kubelet""" + def __init__(self): + Vulnerability.__init__(self, Kubelet, "Anonymous Authentication") + class ExposedContainerLogsHandler(Vulnerability, Event): """Outputs logs from a running container""" def __init__(self): @@ -50,6 +60,11 @@ class ExposedAttachHandler(Vulnerability, Event): Vulnerability.__init__(self, Kubelet, "Exposed /attach") self.remediation="--enable-debugging-handlers=False On Kubelet" +class ExposedHealthzHandler(Vulnerability, Event): + """By accessing open /healthz handler, an attacker could get the cluster health state""" + def __init__(self): + Vulnerability.__init__(self, Kubelet, "Cluster Health Disclosure") + class K8sVersionDisclosure(Vulnerability, Event): """Discloses the kubernetes version, exposed from a log on the /metrics endpoint""" def __init__(self, version): @@ -70,6 +85,7 @@ class ReadOnlyKubeletPortHunter(Hunter): def __init__(self, event): self.event = event self.path = "http://{}:{}/".format(self.event.host, self.event.port) + self.pods_endpoint_data = "" def get_k8s_version(self): metrics = requests.get(self.path + "metrics").text @@ -82,23 +98,35 @@ class ReadOnlyKubeletPortHunter(Hunter): # returns list of tuples of Privileged container and their pod. def find_privileged_containers(self): - pods = json.loads(requests.get(self.path + "pods").text) privileged_containers = list() - if "items" in pods: - for pod in pods["items"]: + if self.pods_endpoint_data: + for pod in self.pods_endpoint_data["items"]: for container in pod["spec"]["containers"]: if "securityContext" in container and "privileged" in container["securityContext"] and container["securityContext"]["privileged"]: privileged_containers.append((pod["metadata"]["name"], container["name"])) return privileged_containers if len(privileged_containers) > 0 else None + + def get_pods_endpoint(self): + response = requests.get(self.path + "pods") + if "items" in response.text: + return json.loads(response.text) + + def check_healthz_endpoint(self): + return requests.get(self.path + "healthz", verify=False).status_code == 200 def execute(self): + self.pods_endpoint_data = self.get_pods_endpoint() k8s_version = self.get_k8s_version() privileged_containers = self.find_privileged_containers() if k8s_version: self.publish_event(K8sVersionDisclosure(version=k8s_version)) if privileged_containers: self.publish_event(PrivilegedContainers(containers=privileged_containers)) - + if self.pods_endpoint_data: + self.publish_event(ExposedPodsHandler()) + if self.check_healthz_endpoint(): + self.publish_event(ExposedHealthzHandler()) + @handler.subscribe(SecureKubeletEvent) class SecureKubeletPortHunter(Hunter): class DebugHandlers(object): @@ -186,55 +214,69 @@ class SecureKubeletPortHunter(Hunter): self.session = requests.Session() if self.event.secure: self.session.headers.update({"Authorization": "Bearer {}".format(self.event.auth_token)}) - self.session.cert = self.event.client_cert + # self.session.cert = self.event.client_cert self.path = "https://{}:{}/".format(self.event.host, 10250) + self.kubehunter_pod = {"name": "kube-hunter", "namespace": "default", "container": "kube-hunter"} + self.pods_endpoint_data = "" + + def get_pods_endpoint(self): + response = self.session.get(self.path + "pods", verify=False) + if "items" in response.text: + return json.loads(response.text) + + def check_healthz_endpoint(self): + return requests.get(self.path + "healthz", verify=False).status_code == 200 def execute(self): - self.test_debugging_handlers() + self.pods_endpoint_data = self.get_pods_endpoint() + if not self.event.secure: + self.publish_event(AnonymousAuthEnabled()) + if self.pods_endpoint_data: + self.publish_event(ExposedPodsHandler()) + if self.check_healthz_endpoint(): + self.publish_event(ExposedHealthzHandler()) + self.test_handlers() - def test_debugging_handlers(self): + def test_handlers(self): # if kube-hunter runs in a pod, we test with kube-hunter's pod - pod = self.get_self_pod() if config.pod else self.get_random_pod() - debug_handlers = self.DebugHandlers(self.path, pod=pod, session=self.session) - - try: - 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_running_pods(): - self.publish_event(ExposedRunningPodsHandler()) - if debug_handlers.test_port_forward(): - self.publish_event(ExposedPortForwardHandler()) # not implemented - if debug_handlers.test_attach_container(): - self.publish_event(ExposedAttachHandler()) - except Exception as ex: - logging.debug(str(ex.message)) - - def get_self_pod(self): - return {"name": "kube-hunter", - "namespace": "default", - "container": "kube-hunter"} + pod = self.kubehunter_pod if config.pod else self.get_random_pod() + if pod: + debug_handlers = self.DebugHandlers(self.path, pod=pod, session=self.session) + try: + 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_running_pods(): + self.publish_event(ExposedRunningPodsHandler()) + if debug_handlers.test_port_forward(): + self.publish_event(ExposedPortForwardHandler()) # not implemented + if debug_handlers.test_attach_container(): + self.publish_event(ExposedAttachHandler()) + except Exception as ex: + logging.debug(str(ex.message)) + else: + pass # no pod to check on. # trying to get a pod from default namespace, if doesnt exist, gets a kube-system one def get_random_pod(self): - pods_data = json.loads(self.session.get("https://{host}:{port}/pods".format(host=self.event.host, port=self.event.port), verify=False).text)['items'] - # filter running kubesystem pod - is_default_pod = lambda pod: pod["metadata"]["namespace"] == "default" and pod["status"]["phase"] == "Running" - is_kubesystem_pod = lambda pod: pod["metadata"]["namespace"] == "kube-system" and pod["status"]["phase"] == "Running" - pod_data = next((pod_data for pod_data in pods_data if is_default_pod(pod_data)), None) - if not pod_data: - pod_data = next((pod_data for pod_data in pods_data if is_kubesystem_pod(pod_data)), None) - - container_data = (container_data for container_data in pod_data["spec"]["containers"]).next() - return { - "name": pod_data["metadata"]["name"], - "container": container_data["name"], - "namespace": pod_data["metadata"]["namespace"] - } - + if self.pods_endpoint_data: + pods_data = self.pods_endpoint_data["items"] + # filter running kubesystem pod + is_default_pod = lambda pod: pod["metadata"]["namespace"] == "default" and pod["status"]["phase"] == "Running" + is_kubesystem_pod = lambda pod: pod["metadata"]["namespace"] == "kube-system" and pod["status"]["phase"] == "Running" + pod_data = next((pod_data for pod_data in pods_data if is_default_pod(pod_data)), None) + if not pod_data: + pod_data = next((pod_data for pod_data in pods_data if is_kubesystem_pod(pod_data)), None) + + container_data = (container_data for container_data in pod_data["spec"]["containers"]).next() + return { + "name": pod_data["metadata"]["name"], + "container": container_data["name"], + "namespace": pod_data["metadata"]["namespace"] + } @handler.subscribe(ExposedRunHandler) class ProveRunHandler(ActiveHunter): @@ -267,6 +309,16 @@ class ProveRunHandler(ActiveHunter): self.event.evidence = "uname: " + output break + +@handler.subscribe(ExposedHealthzHandler) +class ProvePodsHandler(ActiveHunter): + def __init__(self, event): + self.event = event + + def execute(self): + protocol = "https" if self.event.port == 10250 else "http" + self.event.evidence = requests.get("{protocol}://{host}:{port}/healthz".format(protocol=protocol, host=self.event.host, port=self.event.port), verify=False).text + @handler.subscribe(ExposedPodsHandler) class ProvePodsHandler(ActiveHunter): def __init__(self, event): From 464e7aad1fc5d316e3d5dc8bdb6d05d973240a3d Mon Sep 17 00:00:00 2001 From: daniel_sagi Date: Thu, 21 Jun 2018 13:45:19 +0300 Subject: [PATCH 2/6] Added exception handling and improved help --- kube-hunter.py | 18 ++++++++---------- src/modules/discovery/hosts.py | 17 ++++++++++------- src/modules/hunting/kubelet.py | 2 +- 3 files changed, 19 insertions(+), 18 deletions(-) diff --git a/kube-hunter.py b/kube-hunter.py index 4ebc777..5c38da0 100644 --- a/kube-hunter.py +++ b/kube-hunter.py @@ -5,21 +5,22 @@ import logging import sys import time -parser = argparse.ArgumentParser(description='Kube-Hunter, Hunter for weak Kubernetes cluster') -parser.add_argument('--pod', action="store_true", help="set hunter as an insider pod") +parser = argparse.ArgumentParser(description='Kube-Hunter, Hunter for weak Kubernetes clusters. By default, with no special arguments, Kube Hunter will scan all network interfaces for existing Kubernetes clusters. At the end of the hunt, a report will be printed to your screen.') +parser.add_argument('--pod', action="store_true", help="set hunter as an insider pod in cluster") +parser.add_argument('--container', action="store_true", help="set hunting from a container") parser.add_argument('--cidr', type=str, help="set manual cidr to scan, example: 192.168.0.0/16") -# parser.add_argument('--quick', action="store_true", help="scanning only known small sections of the subnet") -parser.add_argument('--token', type=str, help="token addition") -parser.add_argument('--mapping', action="store_true", help="outputs only mapping of cluster's nodes") +parser.add_argument('--mapping', action="store_true", help="outputs only a mapping of the cluster's nodes") parser.add_argument('--remote', nargs='+', metavar="HOST", default=list(), help="one or more remote ip/dns to hunt") parser.add_argument('--active', action="store_true", help="enables active hunting") -parser.add_argument('--log', type=str, metavar="LOGLEVEL", default='INFO', help="set log level, options are:\nDEBUG INFO WARNING") +parser.add_argument('--log', type=str, metavar="LOGLEVEL", default='INFO', help="set log level, options are: debug, info, warn, none") + config = parser.parse_args() try: loglevel = getattr(logging, config.log.upper()) except: pass -logging.basicConfig(level=loglevel, format='%(asctime)s - [%(levelname)s]: %(message)s') +if config.log.lower() != "none": + logging.basicConfig(level=loglevel, format='%(asctime)s - [%(levelname)s]: %(message)s') from report import reporter from src.core.events import handler @@ -38,9 +39,6 @@ def main(): handler.free() logging.debug("Cleaned Queue") reporter.print_tables() - print reporter.generate_report() - # if config.token: - # reporter.send_report(token=config.token) if config.pod: while True: time.sleep(5) diff --git a/src/modules/discovery/hosts.py b/src/modules/discovery/hosts.py index bed996e..e6513e6 100644 --- a/src/modules/discovery/hosts.py +++ b/src/modules/discovery/hosts.py @@ -33,9 +33,11 @@ class HostScanEvent(Event): def get_auth_token(self): if config.pod: - with open("/run/secrets/kubernetes.io/serviceaccount/token") as token_file: - return token_file.read() - + try: + with open("/run/secrets/kubernetes.io/serviceaccount/token") as token_file: + return token_file.read() + except IOError: + pass def get_client_cert(self): if config.pod: return "/run/secrets/kubernetes.io/serviceaccount/ca.crt" @@ -61,6 +63,8 @@ class HostDiscovery(Hunter): self.azure_metadata_discovery() else: self.traceroute_discovery() + elif config.container: + self.traceroute_discovery() elif len(self.event.predefined_hosts) == 0: self.scan_interfaces() else: @@ -74,11 +78,10 @@ class HostDiscovery(Hunter): def is_azure_pod(self): try: - if requests.get("http://169.254.169.254/metadata/instance?api-version=2017-08-01", headers={"Metadata":"true"}).status_code == 200: + if requests.get("http://169.254.169.254/metadata/instance?api-version=2017-08-01", headers={"Metadata":"true"}, timeout=5).status_code == 200: return True - except Exception as ex: - logging.debug("Not azure cluster " + str(ex.message)) - return False + except requests.exceptions.ConnectionError: + return False # for pod scanning def traceroute_discovery(self): diff --git a/src/modules/hunting/kubelet.py b/src/modules/hunting/kubelet.py index d07f416..5df08d5 100644 --- a/src/modules/hunting/kubelet.py +++ b/src/modules/hunting/kubelet.py @@ -311,7 +311,7 @@ class ProveRunHandler(ActiveHunter): @handler.subscribe(ExposedHealthzHandler) -class ProvePodsHandler(ActiveHunter): +class ProveHealthzHandler(ActiveHunter): def __init__(self, event): self.event = event From aa25e83a990a491a12e6f84d4f375ff532d88205 Mon Sep 17 00:00:00 2001 From: daniel_sagi Date: Thu, 21 Jun 2018 19:47:10 +0300 Subject: [PATCH 3/6] moved report generation functions to inner functions, also added token flag, and the lambda url sending function --- kube-hunter.py | 6 ++- report/reporter.py | 103 +++++++++++++++++++++++---------------------- 2 files changed, 58 insertions(+), 51 deletions(-) diff --git a/kube-hunter.py b/kube-hunter.py index 5c38da0..a1ee10f 100644 --- a/kube-hunter.py +++ b/kube-hunter.py @@ -13,6 +13,7 @@ parser.add_argument('--mapping', action="store_true", help="outputs only a mappi parser.add_argument('--remote', nargs='+', metavar="HOST", default=list(), help="one or more remote ip/dns to hunt") parser.add_argument('--active', action="store_true", help="enables active hunting") parser.add_argument('--log', type=str, metavar="LOGLEVEL", default='INFO', help="set log level, options are: debug, info, warn, none") +parser.add_argument('--token', type=str, metavar="AQUA_TOKEN", help="specify the token retrieved from Aqua, after finished executing, the report will be visible on kube-hunter's site") config = parser.parse_args() try: @@ -38,7 +39,10 @@ def main(): finally: handler.free() logging.debug("Cleaned Queue") - reporter.print_tables() + if config.token: + reporter.send_report(token=config.token) + else: + reporter.print_tables() if config.pod: while True: time.sleep(5) diff --git a/report/reporter.py b/report/reporter.py index 544f3ee..d0abd93 100644 --- a/report/reporter.py +++ b/report/reporter.py @@ -21,6 +21,8 @@ EVIDENCE_PREVIEW = 40 MAX_WIDTH_VULNS = 70 MAX_WIDTH_SERVICES = 60 +AQUA_PUSH_URL = "https://qlyscbqwl7.execute-api.us-east-1.amazonaws.com/Prod?token={}" + @handler.subscribe(Service) @handler.subscribe(Vulnerability) class Reporter(object): @@ -52,12 +54,34 @@ class Reporter(object): def print_tables(self): """generates report tables and outputs to stdout""" - print_nodes() - if not config.mapping: - print_services() - print_vulnerabilities() + if len(services): + print_nodes() + if not config.mapping: + print_services() + print_vulnerabilities() + else: + print "\nKube Hunter couldn't find any clusters" + # print "\nKube Hunter couldn't find any clusters. {}".format("Maybe try with --active?" if not config.active else "") def build_sub_services(self, services_list): + # correlation functions + def get_insights_by_service(service): + """generates list of insights related to a given service""" + insights = list() + for insight_type, insight in self.insights_by_id[service.event_id]: + if service in insight.history: + insights.append((insight_type, insight)) + return insights + + def get_services_by_service(parent_service): + """generates list of insights related to a given service""" + services = list() + for service in self.services_by_id[parent_service.event_id]: + if service != parent_service and parent_service in service.history: + services.append(service) + self.services_by_id[parent_service.event_id].remove(service) + return services + current_list = list() for service in services_list: current_list.append( @@ -69,7 +93,7 @@ class Reporter(object): }, "description": service.explain() }) - next_services = self.get_services_by_service(service) + next_services = get_services_by_service(service) if next_services: current_list[-1]["services"] = self.build_sub_services(next_services) current_list[-1]["insights"] = [{ @@ -77,58 +101,37 @@ class Reporter(object): "name": insight.get_name(), "description": insight.explain(), "evidence": insight.evidence if insight_type == Vulnerability else "" - } for insight_type, insight in self.get_insights_by_service(service)] - + } for insight_type, insight in get_insights_by_service(service)] return current_list - def generate_report(self): - """function generates report structure, for""" - for service in services: - self.services_by_id[service.event_id].append(service) - for insight_type, insight in insights: - self.insights_by_id[insight.event_id].append((insight_type, insight)) - - report = defaultdict(list) - # building first layer of services (nodes) - for _, services_list in self.services_by_id.items(): - service_report = defaultdict(list) - service_report["type"] = "Node" - service_report["metadata"] = { - "host": str(services_list[0].host) - } - # building all sub layers. - service_report["services"] = self.build_sub_services(services_list) - report["services"].append(service_report) - return json.dumps(report, indent=4) - def send_report(self, token): + def generate_report(): + """function generates a report corresponding to specifications of the frontend of kubehunter""" + for service in services: + self.services_by_id[service.event_id].append(service) + for insight_type, insight in insights: + self.insights_by_id[insight.event_id].append((insight_type, insight)) + + # building first layer of services (nodes) + report = defaultdict(list) + for _, services_list in self.services_by_id.items(): + service_report = { + "type": "Node", # on future, determine if slave or master + "metadata": { + "host": str(services_list[0].host) + }, + # then constructing their sub services tree + "services": self.build_sub_services(services_list) + } + report["services"].append(service_report) + return report report = { - 'results': self.generate_report(), + 'results': generate_report(), 'metadata': {} } - r = requests.put("https://pnmhh30s1b.execute-api.us-east-1.amazonaws.com/v02?token={}".format(token), json=report) - # if r.status_code == 200: - - print "{}: {}".format(r.status_code, r.text) - - # correlating - def get_insights_by_service(self, service): - """generates list of insights related to a given service""" - insights = list() - for insight_type, insight in self.insights_by_id[service.event_id]: - if service in insight.history: - insights.append((insight_type, insight)) - return insights + r = requests.put(AQUA_PUSH_URL.format(token), json=report) - def get_services_by_service(self, parent_service): - """generates list of insights related to a given service""" - services = list() - for service in self.services_by_id[parent_service.event_id]: - if service != parent_service and parent_service in service.history: - services.append(service) - self.services_by_id[parent_service.event_id].remove(service) - return services - + print "{}: {}".format(r.status_code, r.text) reporter = Reporter() From 03760724acfaa53593325906a0be423e2f6bb30b Mon Sep 17 00:00:00 2001 From: daniel_sagi Date: Wed, 27 Jun 2018 14:13:49 +0300 Subject: [PATCH 4/6] + added report url handling on end of run, when specifiyng token --- report/reporter.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/report/reporter.py b/report/reporter.py index d0abd93..1f43aab 100644 --- a/report/reporter.py +++ b/report/reporter.py @@ -21,7 +21,8 @@ EVIDENCE_PREVIEW = 40 MAX_WIDTH_VULNS = 70 MAX_WIDTH_SERVICES = 60 -AQUA_PUSH_URL = "https://qlyscbqwl7.execute-api.us-east-1.amazonaws.com/Prod?token={}" +AQUA_PUSH_URL = "https://qlyscbqwl7.execute-api.us-east-1.amazonaws.com/Prod/submit?token={token}" +AQUA_RESULTS_URL = "https://qlyscbqwl7.execute-api.us-east-1.amazonaws.com/Prod/result?token={token}" @handler.subscribe(Service) @handler.subscribe(Vulnerability) @@ -105,6 +106,7 @@ class Reporter(object): return current_list def send_report(self, token): + logging.debug("generating report") def generate_report(): """function generates a report corresponding to specifications of the frontend of kubehunter""" for service in services: @@ -129,9 +131,14 @@ class Reporter(object): 'results': generate_report(), 'metadata': {} } - r = requests.put(AQUA_PUSH_URL.format(token), json=report) + logging.debug("uploading report") + r = requests.put(AQUA_PUSH_URL.format(token=token), json=report) - print "{}: {}".format(r.status_code, r.text) + if r.status_code == 201: # created status + print "\nYour report: \n{}".format(AQUA_RESULTS_URL.format(token=token)) + else: + logging.debug("Failed sending report with:{}, {}".format(r.status_code, r.text)) + print "\nSomething went wrong.\nPlease try hunting again." reporter = Reporter() From 23c03afc02a8c5f081e4a6691ae911e097662ba7 Mon Sep 17 00:00:00 2001 From: daniel_sagi Date: Mon, 2 Jul 2018 16:20:14 +0300 Subject: [PATCH 5/6] added interactive choosing of scanning options --- kube-hunter.py | 58 ++++++++++++++++++++++++++-------- src/modules/discovery/hosts.py | 12 +++---- 2 files changed, 50 insertions(+), 20 deletions(-) diff --git a/kube-hunter.py b/kube-hunter.py index a1ee10f..28ed1f8 100644 --- a/kube-hunter.py +++ b/kube-hunter.py @@ -7,7 +7,7 @@ import time parser = argparse.ArgumentParser(description='Kube-Hunter, Hunter for weak Kubernetes clusters. By default, with no special arguments, Kube Hunter will scan all network interfaces for existing Kubernetes clusters. At the end of the hunt, a report will be printed to your screen.') parser.add_argument('--pod', action="store_true", help="set hunter as an insider pod in cluster") -parser.add_argument('--container', action="store_true", help="set hunting from a container") +parser.add_argument('--internal', action="store_true", help="set hunting of all internal network interfaces") parser.add_argument('--cidr', type=str, help="set manual cidr to scan, example: 192.168.0.0/16") parser.add_argument('--mapping', action="store_true", help="outputs only a mapping of the cluster's nodes") parser.add_argument('--remote', nargs='+', metavar="HOST", default=list(), help="one or more remote ip/dns to hunt") @@ -28,27 +28,59 @@ from src.core.events import handler from src.modules.discovery import HostDiscovery from src.modules.discovery.hosts import HostScanEvent + +def interactive_set_config(): + """Sets config manually, returns True for success""" + options = { + "Remote scanning": "scans one or more specific IPs or DNS names", + "Internal scanning": "scans all network interfaces", + "CIDR scanning": "scans a spesific cidr" + } # maps between option and its explanation + + print "Choose one of the options below:" + for i, (option, explanation) in enumerate(options.items()): + print "{}. {} ({})".format(i+1, option.ljust(20), explanation) + choice = raw_input("Your choice: ") + if choice == '1': + config.remote = raw_input("Remotes (seperated by a ','): ").replace(' ', '').split(',') + elif choice == '2': + config.internal = True + elif choice == '3': + config.cidr = raw_input("CIDR (example - 192.168.1.0/24): ").replace(' ', '') + else: + return False + return True + def main(): - logging.info("Started") + scan_options = [ + config.pod, + config.cidr, + config.remote, + config.internal + ] + hunt_started = False try: - handler.publish_event(HostScanEvent(predefined_hosts=config.remote)) + if not any(scan_options): + if not interactive_set_config(): return + hunt_started = True + logging.info("Started") + handler.publish_event(HostScanEvent()) + # Blocking to see discovery output handler.join() except KeyboardInterrupt: logging.debug("Kube-Hunter stopped by user") finally: - handler.free() - logging.debug("Cleaned Queue") - if config.token: - reporter.send_report(token=config.token) - else: - reporter.print_tables() + if hunt_started: + handler.free() + logging.debug("Cleaned Queue") + if config.token: + reporter.send_report(token=config.token) + else: + reporter.print_tables() if config.pod: while True: time.sleep(5) if __name__ == '__main__': - main() - - -# Proof -> Evidence \ No newline at end of file + main() \ No newline at end of file diff --git a/src/modules/discovery/hosts.py b/src/modules/discovery/hosts.py index e6513e6..9a400cc 100644 --- a/src/modules/discovery/hosts.py +++ b/src/modules/discovery/hosts.py @@ -58,18 +58,16 @@ class HostDiscovery(Hunter): self.publish_event(NewHostEvent(host=ip, cloud=cloud)) except: logging.error("unable to parse cidr") + elif config.internal: + self.scan_interfaces() + elif len(config.remote) > 0: + for host in config.remote: + self.publish_event(NewHostEvent(host=host, cloud=self.get_cloud(host))) elif config.pod: if self.is_azure_pod(): self.azure_metadata_discovery() else: self.traceroute_discovery() - elif config.container: - self.traceroute_discovery() - elif len(self.event.predefined_hosts) == 0: - self.scan_interfaces() - else: - for host in self.event.predefined_hosts: - self.publish_event(NewHostEvent(host=host, cloud=self.get_cloud(host))) def get_cloud(self, host): metadata = requests.get("http://www.azurespeed.com/api/region?ipOrUrl={ip}".format(ip=host)).text From 16537e1ff6b4a453a712e474d46fbf2029bd30b1 Mon Sep 17 00:00:00 2001 From: daniel_sagi Date: Wed, 4 Jul 2018 11:36:16 +0300 Subject: [PATCH 6/6] changed a bit of report uploading process --- kube-hunter.py | 2 +- report/reporter.py | 22 +++++++++++++++++----- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/kube-hunter.py b/kube-hunter.py index 28ed1f8..bcb1d64 100644 --- a/kube-hunter.py +++ b/kube-hunter.py @@ -5,7 +5,7 @@ import logging import sys import time -parser = argparse.ArgumentParser(description='Kube-Hunter, Hunter for weak Kubernetes clusters. By default, with no special arguments, Kube Hunter will scan all network interfaces for existing Kubernetes clusters. At the end of the hunt, a report will be printed to your screen.') +parser = argparse.ArgumentParser(description='Kube-Hunter, Hunter for weak Kubernetes clusters. At the end of the hunt, a report will be printed to your screen.') parser.add_argument('--pod', action="store_true", help="set hunter as an insider pod in cluster") parser.add_argument('--internal', action="store_true", help="set hunting of all internal network interfaces") parser.add_argument('--cidr', type=str, help="set manual cidr to scan, example: 192.168.0.0/16") diff --git a/report/reporter.py b/report/reporter.py index 1f43aab..27fb748 100644 --- a/report/reporter.py +++ b/report/reporter.py @@ -1,5 +1,6 @@ import json import logging +import time from collections import defaultdict import requests @@ -53,6 +54,9 @@ class Reporter(object): desc=self.event.explain(), )) + if config.token: + self.send_report(token=config.token) + def print_tables(self): """generates report tables and outputs to stdout""" if len(services): @@ -106,7 +110,6 @@ class Reporter(object): return current_list def send_report(self, token): - logging.debug("generating report") def generate_report(): """function generates a report corresponding to specifications of the frontend of kubehunter""" for service in services: @@ -127,22 +130,31 @@ class Reporter(object): } report["services"].append(service_report) return report + + finished = (not handler.unfinished_tasks) + logging.debug("generating report") report = { 'results': generate_report(), - 'metadata': {} - } + 'metadata': { + 'finished': finished + } + } logging.debug("uploading report") r = requests.put(AQUA_PUSH_URL.format(token=token), json=report) if r.status_code == 201: # created status - print "\nYour report: \n{}".format(AQUA_RESULTS_URL.format(token=token)) + logging.debug("report was uploaded successfully") + if finished: + print "\nYour report: \n{}".format(AQUA_RESULTS_URL.format(token=token)) else: logging.debug("Failed sending report with:{}, {}".format(r.status_code, r.text)) - print "\nSomething went wrong.\nPlease try hunting again." + if finished: + print "\nCould not send report.\n{}".format(json.loads(r.text).get("status", "")) reporter = Reporter() +""" Tables Generation """ def print_nodes(): nodes_table = PrettyTable(["Type", "Location"], hrules=ALL) nodes_table.align="l"