diff --git a/README.md b/README.md index 6667006..0a83a42 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,8 @@ Install module dependencies: ~~~ cd ./kube-hunter pip install -r requirements.txt + +In the case where you have python 3.x in the path as your default, and python2 refers to a python 2.7 executable, use "python2 -m pip install -r requirements.txt" ~~~ Run: `./kube-hunter.py` diff --git a/kube-hunter.py b/kube-hunter.py index ee3bcd0..e8cedf4 100755 --- a/kube-hunter.py +++ b/kube-hunter.py @@ -82,7 +82,7 @@ def list_hunters(): print("\nPassive Hunters:\n----------------") for i, (hunter, docs) in enumerate(handler.passive_hunters.items()): name, docs = parse_docs(hunter, docs) - print("* {}\n {}\n".format( name, docs)) + print("* {}\n {}\n".format(name, docs)) if config.active: print("\n\nActive Hunters:\n---------------") @@ -91,11 +91,13 @@ def list_hunters(): print("* {}\n {}\n".format( name, docs)) +global hunt_started_lock +hunt_started_lock = threading.Lock() hunt_started = False def main(): - global hunt_started + global hunt_started scan_options = [ config.pod, config.cidr, @@ -109,7 +111,10 @@ def main(): if not any(scan_options): if not interactive_set_config(): return + + hunt_started_lock.acquire() hunt_started = True + hunt_started_lock.release() handler.publish_event(HuntStarted()) handler.publish_event(HostScanEvent()) @@ -121,11 +126,16 @@ def main(): except EOFError: logging.error("\033[0;31mPlease run again with -it\033[0m") finally: + hunt_started_lock.acquire() if hunt_started: + hunt_started_lock.release() handler.publish_event(HuntFinished()) handler.join() handler.free() logging.debug("Cleaned Queue") + else: + hunt_started_lock.release() + if __name__ == '__main__': diff --git a/src/core/events/handler.py b/src/core/events/handler.py index 916aeff..dd6e6bc 100644 --- a/src/core/events/handler.py +++ b/src/core/events/handler.py @@ -12,7 +12,8 @@ from ..types import ActiveHunter, Hunter from ...core.events.types import HuntFinished import threading -working_count = 0 +global queue_lock +queue_lock = Lock() # Inherits Queue object, handles events asynchronously class EventQueue(Queue, object): @@ -34,12 +35,12 @@ class EventQueue(Queue, object): t.daemon = True t.start() - # decorator wrapping for easy subscription def subscribe(self, event, hook=None, predicate=None): def wrapper(hook): self.subscribe_event(event, hook=hook, predicate=predicate) return hook + return wrapper # getting uninstantiated event object @@ -72,7 +73,9 @@ class EventQueue(Queue, object): # executes callbacks on dedicated thread as a daemon def worker(self): while self.running: + queue_lock.acquire() hook = self.get() + queue_lock.release() try: hook.execute() except Exception as ex: diff --git a/src/core/events/types/common.py b/src/core/events/types/common.py index 62590e9..ffe60a4 100644 --- a/src/core/events/types/common.py +++ b/src/core/events/types/common.py @@ -65,6 +65,9 @@ class Vulnerability(object): def explain(self): return self.__doc__ + +global event_id_count_lock +event_id_count_lock = threading.Lock() event_id_count = 0 """ Discovery/Hunting Events """ @@ -75,8 +78,10 @@ class NewHostEvent(Event): global event_id_count self.host = host self.cloud = cloud + event_id_count_lock.acquire() self.event_id = event_id_count event_id_count += 1 + event_id_count_lock.release() def __str__(self): return str(self.host) diff --git a/src/modules/discovery/hosts.py b/src/modules/discovery/hosts.py index bf866d3..9a87766 100644 --- a/src/modules/discovery/hosts.py +++ b/src/modules/discovery/hosts.py @@ -15,7 +15,6 @@ from ...core.events import handler from ...core.events.types import Event, NewHostEvent, Vulnerability from ...core.types import Hunter, InformationDisclosure, Azure - class RunningAsPodEvent(Event): def __init__(self): self.name = 'Running from within a pod' @@ -54,7 +53,7 @@ class HostDiscovery(Hunter): def __init__(self, event): self.event = event - def execute(self): + def execute(self): if config.cidr: try: ip, sn = config.cidr.split('/') diff --git a/src/modules/hunting/aks.py b/src/modules/hunting/aks.py index 679fd79..6090295 100644 --- a/src/modules/hunting/aks.py +++ b/src/modules/hunting/aks.py @@ -7,7 +7,7 @@ from kubelet import ExposedRunHandler from ...core.events import handler from ...core.events.types import Event, Vulnerability -from ...core.types import Hunter, ActiveHunter, KubernetesCluster, IdentityTheft, Azure +from ...core.types import Hunter, ActiveHunter, IdentityTheft, Azure class AzureSpnExposure(Vulnerability, Event): diff --git a/src/modules/hunting/etcd.py b/src/modules/hunting/etcd.py index 7070141..73f32c3 100644 --- a/src/modules/hunting/etcd.py +++ b/src/modules/hunting/etcd.py @@ -1,43 +1,11 @@ -import json import logging import requests from ...core.events import handler from ...core.events.types import Vulnerability, Event, OpenPortEvent -from ...core.types import ActiveHunter, Hunter, KubernetesCluster, InformationDisclosure, RemoteCodeExec, UnauthenticatedAccess, AccessRisk - -""" Helper functions """ - -# Will attempt to do request "req1" with the optional parameters. -# If fails it will attempt to do "req2" with the optional parameters. -# If once of the request success this method will return True, if both fail- False. -def helperFuncDo2Requests(req1, req2, is_verify=False, data=None, req_type="get"): - try: - r = helperDoRequest(req1, is_verify, data, req_type) - has_remote_access_gained = (r.status_code == 200 and r.content != "") - if has_remote_access_gained: - return r - except Exception: - try: - r = helperDoRequest(req2, is_verify, data, req_type) - has_remote_access_gained = (r.status_code == 200 and r.content != "") - if has_remote_access_gained: - return r - except Exception: - return False # None of the requests succeded.. - return False - - -def helperDoRequest(req, is_verify, data=None, req_type="get"): - if req_type == "put": - r = requests.put(req, verify=is_verify, timeout=3, data=data) - return r - elif req_type == "get": - r = requests.get(req, verify=is_verify, timeout=3, data=data) - return r - - +from ...core.types import ActiveHunter, Hunter, KubernetesCluster, InformationDisclosure, RemoteCodeExec, \ + UnauthenticatedAccess, AccessRisk """ Vulnerabilities """ class EtcdRemoteWriteAccessEvent(Vulnerability, Event): """Remote write access might grant an attacker full control over the kubernetes cluster""" @@ -57,21 +25,26 @@ class EtcdRemoteVersionDisclosureEvent(Vulnerability, Event): """Remote version disclosure might give an attacker a valuable data to attack a cluster""" def __init__(self, version): - Vulnerability.__init__(self, KubernetesCluster, name="Etcd Remote version disclosure", category=InformationDisclosure) + + Vulnerability.__init__(self, KubernetesCluster, name="Etcd Remote version disclosure", + category=InformationDisclosure) self.evidence = version class EtcdAccessEnabledWithoutAuthEvent(Vulnerability, Event): - """Etcd is accessible without authorization, it would allow a potential attacker to gain access to the etcd""" + """Etcd is accessible using HTTP (without authorization and authentication), it would allow a potential attacker to + gain access to the etcd""" def __init__(self, version): - Vulnerability.__init__(self, KubernetesCluster, name="Etcd is accessible without authorization", category=UnauthenticatedAccess) + Vulnerability.__init__(self, KubernetesCluster, name="Etcd is accessible using insecure connection (HTTP)", + category=UnauthenticatedAccess) self.evidence = version # Active Hunter @handler.subscribe(OpenPortEvent, predicate=lambda p: p.port == 2379) class EtcdRemoteAccessActive(ActiveHunter): - """Checks for remote write access to etcd""" - + """Etcd Remote Access + Checks for remote write access to etcd""" + def __init__(self, event): self.event = event self.write_evidence = '' @@ -81,66 +54,69 @@ class EtcdRemoteAccessActive(ActiveHunter): data = { 'value': 'remotely written data' } - r_secure = "https://{host}:{port}/v2/keys/message".format(host=self.event.host, port=2379) - r_not_secure = "http://{host}:{port}/v2/keys/message".format(host=self.event.host, port=2379) - res = helperFuncDo2Requests(r_secure, r_not_secure) - if res: - self.write_evidence = res.content - return True - return False + try: + r = requests.post("{protocol}://{host}:{port}/v2/keys/message".format(host=self.event.host, port=2379, + protocol=self.protocol), data=data) + self.write_evidence = r.content if r.status_code == 200 and r.content != '' else False + return self.write_evidence + except requests.exceptions.ConnectionError: + return False def execute(self): if self.db_keys_write_access(): self.publish_event(EtcdRemoteWriteAccessEvent(self.write_evidence)) + # Passive Hunter @handler.subscribe(OpenPortEvent, predicate=lambda p: p.port == 2379) class EtcdRemoteAccess(Hunter): """Etcd Remote Access Checks for remote availability of etcd, version, read access, write access """ - # TODO: - # Check the etcd hunter on a remote cluster! (currently everything was checked only at 127.0.0.1:2379) + def __init__(self, event): self.event = event self.version_evidence = '' self.keys_evidence = '' + self.protocol = 'https' def db_keys_disclosure(self): - logging.debug(self.event.host) - logging.debug("Passive hunter is attempting to read etcd keys remotely") - r_secure = "https://{host}:{port}/v2/keys".format(host=self.event.host, port=2379) - r_not_secure = "http://{host}:{port}/v2/keys".format(host=self.event.host, port=2379) - res = helperFuncDo2Requests(r_secure, r_not_secure) - if res: - self.keys_evidence = res.content - return True - return False + logging.debug(self.event.host + " Passive hunter is attempting to read etcd keys remotely") + try: + r = requests.get( + "{protocol}://{host}:{port}/v2/keys".format(protocol=self.protocol, host=self.event.host, port=2379), + verify=False) + self.keys_evidence = r.content if r.status_code == 200 and r.content != '' else False + return self.keys_evidence + except requests.exceptions.ConnectionError: + return False def version_disclosure(self): - logging.debug(self.event.host) - logging.debug("Passive hunter is attempting to check etcd version remotely") - r_secure = "https://{host}:{port}/version".format(host=self.event.host, port=2379) - r_not_secure = "http://{host}:{port}/version".format(host=self.event.host, port=2379) - res = helperFuncDo2Requests(r_secure, r_not_secure) - if res: - self.version_evidence = res.content - return True - return False + logging.debug(self.event.host + " Passive hunter is attempting to check etcd version remotely") + try: + r = requests.get( + "{protocol}://{host}:{port}/version".format(protocol=self.protocol, host=self.event.host, port=2379), + verify=False) + self.version_evidence = r.content if r.status_code == 200 and r.content != '' else False + return self.version_evidence + except requests.exceptions.ConnectionError: + return False - def unauthorized_access(self): - logging.debug(self.event.host) - logging.debug("Passive hunter is attempting to access etcd without authorization") - r_not_secure = "http://{host}:{port}/version".format(host=self.event.host, port=2379) - res = helperFuncDo2Requests(r_not_secure, r_not_secure) # We don't have to do 2 requests this time - if res: - return True - return False + def insecure_access(self): + logging.debug(self.event.host + " Passive hunter is attempting to access etcd insecurely") + try: + r = requests.get("http://{host}:{port}/version".format(host=self.event.host, port=2379), verify=False) + return r.content if r.status_code == 200 and r.content != '' else False + except requests.exceptions.ConnectionError: + return False def execute(self): + if self.insecure_access(): # make a decision between http and https protocol + self.protocol = 'http' if self.version_disclosure(): self.publish_event(EtcdRemoteVersionDisclosureEvent(self.version_evidence)) - if self.unauthorized_access(): + if self.protocol == 'http': self.publish_event(EtcdAccessEnabledWithoutAuthEvent(self.version_evidence)) if self.db_keys_disclosure(): self.publish_event(EtcdRemoteReadAccessEvent(self.keys_evidence)) + diff --git a/src/modules/report/collector.py b/src/modules/report/collector.py index db6b77f..b23e454 100644 --- a/src/modules/report/collector.py +++ b/src/modules/report/collector.py @@ -5,7 +5,13 @@ from src.core.events import handler from src.core.events.types import Event, Service, Vulnerability, HuntFinished, HuntStarted import threading + +global services_lock +services_lock = threading.Lock() services = list() + +global vulnerabilities_lock +vulnerabilities_lock = threading.Lock() vulnerabilities = list() @@ -38,10 +44,13 @@ class Collector(object): def execute(self): """function is called only when collecting data""" - global services, vulnerabilities + global services + global vulnerabilities bases = self.event.__class__.__mro__ if Service in bases: + services_lock.acquire() services.append(self.event) + services_lock.release() import datetime logging.info("|\n| {name}:\n| type: open service\n| service: {name}\n|_ host: {host}:{port}".format( host=self.event.host, @@ -51,7 +60,9 @@ class Collector(object): )) elif Vulnerability in bases: + vulnerabilities_lock.acquire() vulnerabilities.append(self.event) + vulnerabilities_lock.release() logging.info( "|\n| {name}:\n| type: vulnerability\n| host: {host}:{port}\n| description: \n{desc}".format( name=self.event.get_name(), diff --git a/src/modules/report/plain.py b/src/modules/report/plain.py index 37e1838..029213d 100644 --- a/src/modules/report/plain.py +++ b/src/modules/report/plain.py @@ -3,8 +3,7 @@ from __future__ import print_function from prettytable import ALL, PrettyTable from __main__ import config -from collector import services, vulnerabilities -import threading +from collector import services, vulnerabilities, services_lock, vulnerabilities_lock EVIDENCE_PREVIEW = 40 MAX_TABLE_WIDTH = 20 @@ -15,11 +14,20 @@ class PlainReporter(object): def get_report(self): """generates report tables""" output = "" - if len(services): + + vulnerabilities_lock.acquire() + vulnerabilities_len = len(services) + vulnerabilities_lock.release() + + services_lock.acquire() + services_len = len(vulnerabilities) + services_lock.release() + + if services_len: output += self.nodes_table() if not config.mapping: output += self.services_table() - if len(vulnerabilities): + if vulnerabilities_len: output += self.vulns_table() else: output += "\nNo vulnerabilities were found" @@ -38,11 +46,14 @@ class PlainReporter(object): nodes_table.header_style = "upper" # TODO: replace with sets id_memory = list() + services_lock.acquire() for service in services: if service.event_id not in id_memory: nodes_table.add_row(["Node/Master", service.host]) id_memory.append(service.event_id) - return "\nNodes\n{}\n".format(nodes_table) + nodes_ret = "\nNodes\n{}\n".format(nodes_table) + services_lock.release() + return nodes_ret def services_table(self): services_table = PrettyTable(["Service", "Location", "Description"], hrules=ALL) @@ -52,9 +63,12 @@ class PlainReporter(object): services_table.sortby = "Service" services_table.reversesort = True services_table.header_style = "upper" + services_lock.acquire() for service in services: services_table.add_row([service.get_name(), "{}:{}{}".format(service.host, service.port, service.get_path()), service.explain()]) - return "\nDetected Services\n{}\n".format(services_table) + detected_services_ret = "\nDetected Services\n{}\n".format(services_table) + services_lock.release() + return detected_services_ret def vulns_table(self): column_names = ["Location", "Category", "Vulnerability", "Description", "Evidence"] @@ -65,9 +79,12 @@ class PlainReporter(object): vuln_table.reversesort = True vuln_table.padding_width = 1 vuln_table.header_style = "upper" + + vulnerabilities_lock.acquire() for vuln in vulnerabilities: row = ["{}:{}".format(vuln.host, vuln.port) if vuln.host else "", vuln.category.name, vuln.get_name(), vuln.explain()] 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) + vulnerabilities_lock.release() return "\nVulnerabilities\n{}\n".format(vuln_table) diff --git a/src/modules/report/yaml.py b/src/modules/report/yaml.py index 26a9e83..1ffcf58 100644 --- a/src/modules/report/yaml.py +++ b/src/modules/report/yaml.py @@ -2,8 +2,7 @@ import StringIO from ruamel.yaml import YAML -from collector import services, vulnerabilities -import threading +from collector import services, vulnerabilities, services_lock, vulnerabilities_lock class YAMLReporter(object): def get_report(self): @@ -20,25 +19,31 @@ class YAMLReporter(object): def get_nodes(self): nodes = list() node_locations = set() + services_lock.acquire() for service in services: node_location = str(service.host) if node_location not in node_locations: nodes.append({"type": "Node/Master", "location": str(service.host)}) node_locations.add(node_location) + services_lock.release() return nodes def get_services(self): + services_lock.acquire() services_data = [{"service": service.get_name(), "location": "{}:{}{}".format(service.host, service.port, service.get_path()), "description": service.explain()} for service in services] + services_lock.release() return services_data def get_vulenrabilities(self): + vulnerabilities_lock.acquire() vulnerabilities_data = [{"location": "{}:{}".format(vuln.host, vuln.port) if vuln.host else "", "category": vuln.category.name, "vulnerability": vuln.get_name(), "description": vuln.explain(), "evidence": str(vuln.evidence)} for vuln in vulnerabilities] + vulnerabilities_lock.release() return vulnerabilities_data