diff --git a/src/modules/discovery/apiserver.py b/src/modules/discovery/apiserver.py index db1f5b0..48c5618 100644 --- a/src/modules/discovery/apiserver.py +++ b/src/modules/discovery/apiserver.py @@ -8,24 +8,34 @@ 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(Discovery): - """Api Server Discovery + """API Server Discovery Checks for the existence of a an API Server """ def __init__(self, event): 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/kubelet.py b/src/modules/discovery/kubelet.py index 653e048..bb14162 100644 --- a/src/modules/discovery/kubelet.py +++ b/src/modules/discovery/kubelet.py @@ -38,8 +38,7 @@ class KubeletDiscovery(Discovery): 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()) diff --git a/src/modules/discovery/ports.py b/src/modules/discovery/ports.py index f9adbaa..1ea99de 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(Discovery): diff --git a/src/modules/hunting/apiserver.py b/src/modules/hunting/apiserver.py index cb3926b..a081cc2 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: @@ -196,13 +205,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 != '': @@ -249,7 +257,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: @@ -294,7 +305,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 0baaa6f..a11f57a 100644 --- a/src/modules/hunting/cvehunter.py +++ b/src/modules/hunting/cvehunter.py @@ -42,7 +42,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 = '' 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