From da832df36d1c44d0251d3414350356204e39f94f Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Mon, 13 May 2019 12:23:23 +0100 Subject: [PATCH 1/8] Test for insecure port being open on port 8080 --- src/modules/discovery/apiserver.py | 24 +++++++++++++++++------- src/modules/discovery/ports.py | 2 +- src/modules/hunting/apiserver.py | 21 ++++++++++++++++----- src/modules/hunting/cvehunter.py | 2 +- 4 files changed, 35 insertions(+), 14 deletions(-) diff --git a/src/modules/discovery/apiserver.py b/src/modules/discovery/apiserver.py index a6c50fa..bc5442e 100644 --- a/src/modules/discovery/apiserver.py +++ b/src/modules/discovery/apiserver.py @@ -8,13 +8,14 @@ from ...core.events.types import OpenPortEvent, Service, Event class ApiServer(Service, Event): """The API server is in charge of all operations on the cluster.""" - def __init__(self): + def __init__(self, protocol="https"): Service.__init__(self, name="API Server") + self.protocol=protocol # Other devices could have this port open, but we can check to see if it looks like a Kubernetes node # A Kubernetes API server will respond with a JSON message that includes a "code" field for the HTTP status code -@handler.subscribe(OpenPortEvent, predicate=lambda x: x.port==443 or x.port==6443) +@handler.subscribe(OpenPortEvent, predicate=lambda x: x.port==443 or x.port==6443 or x.port==8080) class ApiServerDiscovery(Hunter): """Api Server Discovery Checks for the existence of a an API Server @@ -23,9 +24,18 @@ class ApiServerDiscovery(Hunter): self.event = event def execute(self): - logging.debug("Attempting to discover an API server") - main_request = requests.get("https://{}:{}".format(self.event.host, self.event.port), verify=False).text - if '"code"' in main_request: - self.event.role = "Master" - self.publish_event(ApiServer()) + logging.debug("Attempting to discover an API server on {}:{}".format(self.event.host, self.event.port)) + self.make_request(protocol="https") + self.make_request(protocol="http") + + def make_request(self, protocol): + try: + r = requests.get("{}://{}:{}".format(protocol, self.event.host, self.event.port), verify=False) + if ('k8s' in r.text) or ('"code"' in r.text and r.status_code is not 200): + self.event.role = "Master" + self.publish_event(ApiServer(protocol=protocol)) + except requests.exceptions.SSLError: + logging.debug("{} protocol not accepted on {}:{}".format(protocol, self.event.host, self.event.port)) + except Exception as e: + logging.debug("{} on {}:{}".format(e, self.event.host, self.event.port)) diff --git a/src/modules/discovery/ports.py b/src/modules/discovery/ports.py index cd1170b..5af972a 100644 --- a/src/modules/discovery/ports.py +++ b/src/modules/discovery/ports.py @@ -7,7 +7,7 @@ from ...core.events import handler from ...core.events.types import NewHostEvent, OpenPortEvent -default_ports = [8001, 10250, 10255, 30000, 443, 6443, 2379] +default_ports = [8001, 8080, 10250, 10255, 30000, 443, 6443, 2379] @handler.subscribe(NewHostEvent) class PortDiscovery(Hunter): diff --git a/src/modules/hunting/apiserver.py b/src/modules/hunting/apiserver.py index d8bb1bc..9436706 100644 --- a/src/modules/hunting/apiserver.py +++ b/src/modules/hunting/apiserver.py @@ -27,6 +27,15 @@ class ServerApiAccess(Vulnerability, Event): Vulnerability.__init__(self, KubernetesCluster, name=name, category=category) self.evidence = evidence +class ServerApiHTTPAccess(Vulnerability, Event): + """ The API Server port is accessible over HTTP, and therefore unencrypted. Depending on your RBAC settings this could expose access to or control of your cluster. """ + + def __init__(self, evidence): + name = "Insecure (HTTP) access to API" + category = UnauthenticatedAccess + Vulnerability.__init__(self, KubernetesCluster, name=name, category=category) + self.evidence = evidence + class ApiInfoDisclosure(Vulnerability, Event): def __init__(self, evidence, using_token, name): if using_token: @@ -195,13 +204,12 @@ class AccessApiServer(Hunter): def __init__(self, event): self.event = event - self.path = "https://{}:{}".format(self.event.host, self.event.port) + self.path = "{}://{}:{}".format(self.event.protocol, self.event.host, self.event.port) self.headers = {} self.with_token = False def access_api_server(self): - logging.debug('Passive Hunter is attempting to access the API at {host}:{port}'.format(host=self.event.host, - port=self.event.port)) + logging.debug('Passive Hunter is attempting to access the API at {}'.format(self.path)) try: r = requests.get("{path}/api".format(path=self.path), headers=self.headers, verify=False) if r.status_code == 200 and r.content != '': @@ -248,7 +256,10 @@ class AccessApiServer(Hunter): def execute(self): api = self.access_api_server() if api: - self.publish_event(ServerApiAccess(api, self.with_token)) + if self.event.protocol == "http": + self.publish_event(ServerApiHTTPAccess(api)) + else: + self.publish_event(ServerApiAccess(api, self.with_token)) namespaces = self.get_items("{path}/api/v1/namespaces".format(path=self.path)) if namespaces: @@ -293,7 +304,7 @@ class AccessApiServerActive(ActiveHunter): def __init__(self, event): self.event = event - self.path = "https://{}:{}".format(self.event.host, self.event.port) + self.path = "{}://{}:{}".format(self.event.protocol, self.event.host, self.event.port) def create_item(self, path, name, data): headers = { diff --git a/src/modules/hunting/cvehunter.py b/src/modules/hunting/cvehunter.py index a3f7c34..9dd5d84 100644 --- a/src/modules/hunting/cvehunter.py +++ b/src/modules/hunting/cvehunter.py @@ -40,7 +40,7 @@ class IsVulnerableToCVEAttack(Hunter): # From within a Pod we may have extra credentials if self.event.auth_token: self.headers = {'Authorization': 'Bearer ' + self.event.auth_token} - self.path = "https://{}:{}".format(self.event.host, self.event.port) + self.path = "{}://{}:{}".format(self.event.protocol, self.event.host, self.event.port) self.api_server_evidence = '' self.k8sVersion = '' From bfb14e229a991759786d952c378a6d7305c9d40b Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Mon, 13 May 2019 12:23:53 +0100 Subject: [PATCH 2/8] Combine two debug messages, for clarity --- src/modules/discovery/kubelet.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/modules/discovery/kubelet.py b/src/modules/discovery/kubelet.py index ecf39b6..6a9c2f6 100644 --- a/src/modules/discovery/kubelet.py +++ b/src/modules/discovery/kubelet.py @@ -38,8 +38,7 @@ class KubeletDiscovery(Hunter): self.event = event def get_read_only_access(self): - logging.debug(self.event.host) - logging.debug("Passive hunter is attempting to get kubelet read access") + logging.debug("Passive hunter is attempting to get kubelet read access at {}:{}".format(self.event.host, self.event.port)) r = requests.get("http://{host}:{port}/pods".format(host=self.event.host, port=self.event.port)) if r.status_code == 200: self.publish_event(ReadOnlyKubeletEvent()) From 1db39fd966098ccb41f036624e3224e182f4c961 Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Mon, 13 May 2019 12:24:28 +0100 Subject: [PATCH 3/8] Include evidence on exposed run handler --- src/modules/hunting/kubelet.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/modules/hunting/kubelet.py b/src/modules/hunting/kubelet.py index 2ec0fbc..d91ab86 100644 --- a/src/modules/hunting/kubelet.py +++ b/src/modules/hunting/kubelet.py @@ -50,8 +50,8 @@ class ExposedExecHandler(Vulnerability, Event): class ExposedRunHandler(Vulnerability, Event): """An attacker could run an arbitrary command inside a container""" - def __init__(self): - Vulnerability.__init__(self, Kubelet, "Exposed Run Inside Container", category=RemoteCodeExec) + def __init__(self, evidence): + Vulnerability.__init__(self, Kubelet, "Exposed Run Inside Container", category=RemoteCodeExec, evidence=evidence) class ExposedPortForwardHandler(Vulnerability, Event): @@ -221,8 +221,11 @@ class SecureKubeletPortHunter(Hunter): containerName=self.pod["container"], cmd = "" ) - status_code = requests.post(run_url, allow_redirects=False, verify=False).status_code - return (status_code != 404 and status_code != 401) + r = requests.post(run_url, allow_redirects=False, verify=False) + if r.status_code not in [200, 201, 202]: + return None + + return r.text # returns list of currently running pods def test_running_pods(self): @@ -285,8 +288,9 @@ class SecureKubeletPortHunter(Hunter): self.publish_event(ExposedContainerLogsHandler()) if debug_handlers.test_exec_container(): self.publish_event(ExposedExecHandler()) - if debug_handlers.test_run_container(): - self.publish_event(ExposedRunHandler()) + r = debug_handlers.test_run_container() + if r: + self.publish_event(ExposedRunHandler(r)) if debug_handlers.test_port_forward(): self.publish_event(ExposedPortForwardHandler()) # not implemented if debug_handlers.test_attach_container(): From fd5ed8a16654e11d49302f11bf06b698eab46bfa Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Mon, 13 May 2019 12:26:22 +0100 Subject: [PATCH 4/8] .gitignore additions --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 1dee0a2..78da266 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ *.pyc .dockerignore *aqua* +.vscode +.Python From 5cf68a318f646db5c74b8c56d7669193bf438f68 Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Mon, 13 May 2019 13:18:03 +0100 Subject: [PATCH 5/8] Tests for insecure port access --- tests/discovery/test_apiserver.py | 40 ++++++++++++++++++++++++-- tests/hunting/test_apiserver_hunter.py | 2 ++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/tests/discovery/test_apiserver.py b/tests/discovery/test_apiserver.py index 7652afc..9e7322b 100644 --- a/tests/discovery/test_apiserver.py +++ b/tests/discovery/test_apiserver.py @@ -12,9 +12,10 @@ def test_ApiServer(): counter = 0 with requests_mock.Mocker() as m: m.get('https://mockOther:443', text='elephant') - m.get('https://mockKubernetes:443', text='{"code":403}') + m.get('https://mockKubernetes:443', text='{"code":403}', status_code=403) e = Event() + e.protocol = "https" e.port = 443 e.host = 'mockOther' @@ -33,10 +34,11 @@ def test_ApiServerWithServiceAccountToken(): counter = 0 with requests_mock.Mocker() as m: m.get('https://mockKubernetes:443', request_headers={'Authorization':'Bearer very_secret'}, text='{"code":200}') - m.get('https://mockKubernetes:443', text='{"code":403}') + m.get('https://mockKubernetes:443', text='{"code":403}', status_code=403) m.get('https://mockOther:443', text='elephant') e = Event() + e.protocol = "https" e.port = 443 # We should discover an API Server regardless of whether we have a token @@ -60,10 +62,44 @@ def test_ApiServerWithServiceAccountToken(): assert counter == 2 +def test_InsecureApiServer(): + global counter + counter = 0 + with requests_mock.Mocker() as m: + m.get('http://mockOther:8080', text='elephant') + m.get('http://mockKubernetes:8080', text="""{ + "paths": [ + "/api", + "/api/v1", + "/apis", + "/apis/", + "/apis/admissionregistration.k8s.io", + "/apis/admissionregistration.k8s.io/v1beta1", + "/apis/apiextensions.k8s.io" + ]}""") + + e = Event() + e.protocol = "http" + e.port = 8080 + e.host = 'mockOther' + + a = ApiServerDiscovery(e) + a.execute() + + e.host = 'mockKubernetes' + a.execute() + + # Allow the events to be processed. Only the one to mockKubernetes should trigger an event + time.sleep(0.1) + assert counter == 1 + + + # We should only generate an ApiServer event for a response that looks like it came from a Kubernetes node @handler.subscribe(ApiServer) class testApiServer(object): def __init__(self, event): + print("Event") assert event.host == 'mockKubernetes' global counter counter += 1 \ No newline at end of file diff --git a/tests/hunting/test_apiserver_hunter.py b/tests/hunting/test_apiserver_hunter.py index 07bcdad..7b79c25 100644 --- a/tests/hunting/test_apiserver_hunter.py +++ b/tests/hunting/test_apiserver_hunter.py @@ -35,6 +35,7 @@ def test_AccessApiServer(): e = ApiServer() e.host = "mockKubernetes" e.port = 443 + e.protocol = "https" with requests_mock.Mocker() as m: m.get('https://mockKubernetes:443/api', text='{}') @@ -151,6 +152,7 @@ def test_AccessApiServerActive(): e = ApiServerPassiveHunterFinished(namespaces=["hello-namespace"]) e.host = "mockKubernetes" e.port = 443 + e.protocol = "https" with requests_mock.Mocker() as m: # TODO more tests here with real responses From 50dfbd0daaeb87378a4e65de46350b626f86addd Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Mon, 13 May 2019 13:52:51 +0100 Subject: [PATCH 6/8] Update requirements.txt --- requirements.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 9a54bee..0cfb31e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,9 +1,11 @@ netaddr netifaces scapy==2.4.3rc1 -requests +requests>=2.21 PrettyTable -urllib3 +urllib3<1.25,>=1.21.1 ruamel.yaml requests_mock future +pathlib2 + From 229ff40a01c058187fbe461cc250c8645baba27b Mon Sep 17 00:00:00 2001 From: Liz Rice Date: Tue, 14 May 2019 14:07:33 +0100 Subject: [PATCH 7/8] Fix bad merge And a typo while I'm here --- src/modules/discovery/apiserver.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/modules/discovery/apiserver.py b/src/modules/discovery/apiserver.py index 2749269..48c5618 100644 --- a/src/modules/discovery/apiserver.py +++ b/src/modules/discovery/apiserver.py @@ -16,8 +16,8 @@ class ApiServer(Service, Event): # Other devices could have this port open, but we can check to see if it looks like a Kubernetes node # A Kubernetes API server will respond with a JSON message that includes a "code" field for the HTTP status code @handler.subscribe(OpenPortEvent, predicate=lambda x: x.port==443 or x.port==6443 or x.port==8080) -class ApiServerDiscovery(Hunter): - """Api Server Discovery +class ApiServerDiscovery(Discovery): + """API Server Discovery Checks for the existence of a an API Server """ def __init__(self, event): From faf1db3d164204758d2b1992d799d4039f726d49 Mon Sep 17 00:00:00 2001 From: Daniel Sagi Date: Tue, 11 Jun 2019 17:40:44 +0300 Subject: [PATCH 8/8] cleaned files to match master branch updates, also removed change of ExposedRunHandler evidence handling --- .gitignore | 2 -- requirements.txt | 7 ++----- src/core/events/handler.py | 1 + src/core/events/types/common.py | 1 + src/core/types.py | 14 ++++++++++---- src/modules/hunting/kubelet.py | 26 +++++++------------------- src/modules/report/base.py | 3 ++- 7 files changed, 23 insertions(+), 31 deletions(-) diff --git a/.gitignore b/.gitignore index 78da266..1dee0a2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,3 @@ *.pyc .dockerignore *aqua* -.vscode -.Python diff --git a/requirements.txt b/requirements.txt index 9b03ebf..78b341e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,10 @@ -enum34 +enum34 ; python_version<='3.4' netaddr netifaces scapy==2.4.3rc1 -requests>=2.21 +requests PrettyTable -chardet==3.0.2 urllib3>=1.24.2,<1.25 ruamel.yaml requests_mock future -pathlib2 - diff --git a/src/core/events/handler.py b/src/core/events/handler.py index 3113966..62867c1 100644 --- a/src/core/events/handler.py +++ b/src/core/events/handler.py @@ -72,6 +72,7 @@ class EventQueue(Queue, object): if caller: event.previous = caller.event + event.hunter = caller.__class__ if config.statistics and caller: if Vulnerability in event.__class__.__mro__: diff --git a/src/core/events/types/common.py b/src/core/events/types/common.py index da8ed61..61d54c0 100644 --- a/src/core/events/types/common.py +++ b/src/core/events/types/common.py @@ -8,6 +8,7 @@ from src.core.types import InformationDisclosure, DenialOfService, RemoteCodeExe class Event(object): def __init__(self): self.previous = None + self.hunter = None # newest attribute gets selected first def __getattr__(self, name): diff --git a/src/core/types.py b/src/core/types.py index 5fc7848..fc4b4ab 100644 --- a/src/core/types.py +++ b/src/core/types.py @@ -1,9 +1,7 @@ class HunterBase(object): publishedVulnerabilities = 0 - def publish_event(self, event): - handler.publish_event(event, caller=self) - + @staticmethod def parse_docs(docs): """returns tuple of (name, docs)""" if not docs: @@ -12,7 +10,15 @@ class HunterBase(object): for i, line in enumerate(docs): docs[i] = line.strip() return docs[0], ' '.join(docs[1:]) if len(docs[1:]) else "" - + + @classmethod + def get_name(cls): + name, _ = cls.parse_docs(cls.__doc__) + return name + + def publish_event(self, event): + handler.publish_event(event, caller=self) + class ActiveHunter(HunterBase): pass diff --git a/src/modules/hunting/kubelet.py b/src/modules/hunting/kubelet.py index d91ab86..a43add1 100644 --- a/src/modules/hunting/kubelet.py +++ b/src/modules/hunting/kubelet.py @@ -50,8 +50,8 @@ class ExposedExecHandler(Vulnerability, Event): class ExposedRunHandler(Vulnerability, Event): """An attacker could run an arbitrary command inside a container""" - def __init__(self, evidence): - Vulnerability.__init__(self, Kubelet, "Exposed Run Inside Container", category=RemoteCodeExec, evidence=evidence) + def __init__(self): + Vulnerability.__init__(self, Kubelet, "Exposed Run Inside Container", category=RemoteCodeExec) class ExposedPortForwardHandler(Vulnerability, Event): @@ -86,17 +86,9 @@ class PrivilegedContainers(Vulnerability, Event): def __init__(self, containers): Vulnerability.__init__(self, KubernetesCluster, "Privileged Container", category=AccessRisk) self.containers = containers - self.evidence = "pod: {}, container: {}".format(containers[0][0], containers[0][1]) + self.evidence = "pod: {}, container: {}, count: {}".format(containers[0][0], containers[0][1], len(containers)) -class PrivilegeEscalation(Vulnerability, Event): - """Privilege escalation allows an attacker to grant root permissions and control the cluster""" - - def __init__(self, containers): - Vulnerability.__init__(self, KubernetesCluster, "Privilege Escalation", category=PrivilegeEscalation) - self.containers = containers - self.evidence = "pod: {}, container: {}".format(containers[0][0], containers[0][1]) - """ dividing ports for seperate hunters """ @handler.subscribe(ReadOnlyKubeletEvent) @@ -221,11 +213,8 @@ class SecureKubeletPortHunter(Hunter): containerName=self.pod["container"], cmd = "" ) - r = requests.post(run_url, allow_redirects=False, verify=False) - if r.status_code not in [200, 201, 202]: - return None - - return r.text + status_code = requests.post(run_url, allow_redirects=False, verify=False).status_code + return (status_code != 404 and status_code != 401) # returns list of currently running pods def test_running_pods(self): @@ -288,9 +277,8 @@ class SecureKubeletPortHunter(Hunter): self.publish_event(ExposedContainerLogsHandler()) if debug_handlers.test_exec_container(): self.publish_event(ExposedExecHandler()) - r = debug_handlers.test_run_container() - if r: - self.publish_event(ExposedRunHandler(r)) + 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(): diff --git a/src/modules/report/base.py b/src/modules/report/base.py index 73c31eb..4a1dcaa 100644 --- a/src/modules/report/base.py +++ b/src/modules/report/base.py @@ -30,7 +30,8 @@ class BaseReporter(object): "severity": vuln.get_severity(), "vulnerability": vuln.get_name(), "description": vuln.explain(), - "evidence": str(vuln.evidence)} + "evidence": str(vuln.evidence), + "hunter": vuln.hunter.get_name()} for vuln in vulnerabilities] vulnerabilities_lock.release() return vulnerabilities_data