Merge branch 'KubeHunter2'

This commit is contained in:
daniel_sagi
2018-05-24 16:01:43 +03:00
27 changed files with 408 additions and 452 deletions

2
.gitignore vendored
View File

@@ -1,3 +1 @@
.idea/
__pycache__/
*.pyc

View File

@@ -1,39 +0,0 @@
# Kube Hunter
Insecure Kubernetes clusters detection tool.
## Installation
Run the following commands to clone and install pre-requisites:
```bash
git clone git@bitbucket.org:scalock/kube-hunter.git
cd kube-hunter
pip install -R requirements.txt
./kube-hunter -h
```
## Current Features
The following action are currently supported:
### Hunt
Supplied a host IP, the tool will search for open Kubernetes services,
listening to default ports.
For each service found, it will check if it is insecure and grants
capabilities.
```bash
./kube-hunter hunt 127.0.0.1
```
### Scan
Supplied a subnet address (CIDR notation), the tool will scan for
hosts with open Kubernetes services.
## Supported Kubernetes Services
The tool currently supports the following services:
* Kubernetes Dashboard

View File

@@ -1,79 +0,0 @@
from logging import debug, info
from multiprocessing import Process, Queue
from socket import socket
from netaddr import IPNetwork
KUBE_PROXY_PORT = 8001
KUBELET_PORT = 10250
KUBELET_READONLY_PORT = 10255
DASHBOARD_PORT = 30000
DEFAULT_PORTS = [
KUBE_PROXY_PORT,
KUBELET_PORT,
KUBELET_READONLY_PORT,
DASHBOARD_PORT
]
def cidr_to_list(cidr):
host_list = list(IPNetwork(cidr))
return host_list
def test_connection(host, ports):
result = []
for port in ports:
s = socket()
s.settimeout(1)
success = s.connect_ex((str(host), port))
s.close()
if success == 0:
info("{}:{} is open".format(host, port))
result.append("{}:{}".format(host, port))
return result
class Worker(Process):
_count = 0
def __init__(self, queue):
super(Worker, self).__init__()
self.queue = queue
self.name = "Worker #{}".format(Worker._count)
Worker._count += 1
def run(self):
for host, ports, callback in iter(self.queue.get, None):
debug("{}: Checking host {}".format(self.name, host))
for result in test_connection(host, ports):
callback(result)
class HostScanner(object):
def __init__(self, threads=1):
self.threads = threads
def scan(self, cidr, ports, callback):
queue = Queue()
workers = []
debug("Starting workers")
for i in range(self.threads):
workers.append(Worker(queue))
workers[-1].start()
for host in cidr_to_list(cidr):
queue.put((host, ports, callback))
for i in range(self.threads):
queue.put(None)
debug("Waiting for workers to finish")
for worker in workers:
worker.join()
debug("Workers finished")

7
discovery/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from os.path import dirname, basename, isfile
import glob
# dynamically importing all modules in folder
files = glob.glob(dirname(__file__)+"/*.py")
for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')):
exec('from {} import *'.format(module_name))

17
discovery/dashboard.py Normal file
View File

@@ -0,0 +1,17 @@
from events import handler, OpenPortEvent, KubeDashboardEvent
import requests
@handler.subscribe(OpenPortEvent, predicate=lambda x: x.port == 30000)
class KubeDashboard(object):
def __init__(self, event):
self.event = event
self.host = event.host
self.port = event.port
@property
def secure(self):
# TODO: insert logic for detremining a secure/insecure dashboard is there
return False
def execute(self):
handler.publish_event(KubeDashboardEvent())

35
discovery/hosts.py Normal file
View File

@@ -0,0 +1,35 @@
import logging
import sys
import time
from enum import Enum
from netaddr import IPNetwork
from events import HostScanEvent, NewHostEvent, handler
from netifaces import AF_INET, ifaddresses, interfaces
# for comparing prefixes
class InterfaceTypes(Enum):
LOCALHOST = "127.0.0"
@handler.subscribe(HostScanEvent)
class HostDiscovery(object):
def __init__(self, event):
self.event = event
# self.external = event.external
def execute(self):
logging.info("Discovering Open Kubernetes Services...")
handler.publish_event(NewHostEvent(host="acs954agent1.westus2.cloudapp.azure.com")) # test cluster
for ifaceName in interfaces():
for ip in self.generate_addresses(ifaceName):
handler.publish_event(NewHostEvent(host=ip))
def generate_addresses(self, ifaceName):
for address in [i['addr'] for i in ifaddresses(ifaceName).setdefault(AF_INET, [])]:
subnet = IPNetwork('{0}/24'.format(address))
for ip in IPNetwork(subnet):
if not self.event.localhost and InterfaceTypes.LOCALHOST.value in ip.__str__():
continue
yield ip

35
discovery/kubelet.py Normal file
View File

@@ -0,0 +1,35 @@
import json
import logging
import urllib3
from enum import Enum
import requests
from events import ReadOnlyKubeletEvent, SecureKubeletEvent, OpenPortEvent, handler
class KubeletPorts(Enum):
SECURED = 10250
READ_ONLY = 10255
@handler.subscribe(OpenPortEvent, predicate= lambda x: x.port == 10255 or x.port == 10250)
class KubeletDiscovery(object):
def __init__(self, event):
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
self.event = event
@property
def read_only_access(self):
r = requests.get("http://{host}:{port}/pods".format(host=self.event.host, port=self.event.port))
return r.status_code == 200
@property
def secure_access(self):
r = requests.get("https://{host}:{port}/pods".format(host=self.event.host, port=self.event.port), verify=False)
return r.status_code == 200
def execute(self):
logging.debug("secure port on {}".format(self.event.port))
if self.event.port == KubeletPorts.SECURED.value and self.secure_access:
handler.publish_event(SecureKubeletEvent())
elif self.event.port == KubeletPorts.READ_ONLY.value and self.read_only_access:
handler.publish_event(ReadOnlyKubeletEvent())

26
discovery/ports.py Normal file
View File

@@ -0,0 +1,26 @@
from events import handler, NewHostEvent, OpenPortEvent
from socket import socket
default_ports = [8001, 10250, 10255, 30000]
@handler.subscribe(NewHostEvent)
class PortDiscovery(object):
def __init__(self, event):
self.event = event
self.host = event.host
self.port = event.port
def execute(self):
for single_port in default_ports:
if self.test_connection(self.host, single_port):
handler.publish_event(OpenPortEvent(port=single_port))
@staticmethod
def test_connection(host, port):
s = socket()
s.settimeout(1.5)
success = s.connect_ex((str(host), port))
s.close()
if success == 0:
return True
return False

20
discovery/proxy.py Normal file
View File

@@ -0,0 +1,20 @@
from events import handler, OpenPortEvent, KubeProxyEvent
from collections import defaultdict
from requests import get
import logging
@handler.subscribe(OpenPortEvent, predicate=lambda x: x.port == 8001)
class KubeProxy(object):
def __init__(self, event):
self.event = event
self.host = event.host
self.port = event.port or 8001
@property
def accesible(self):
return True
def execute(self):
if self.accesible:
handler.publish_event(KubeProxyEvent())

11
events/__init__.py Normal file
View File

@@ -0,0 +1,11 @@
from os.path import dirname, basename, isfile
import glob
# explicitly importing the event handler
from handler import handler
# dynamically importing all modules in folder
files = glob.glob(dirname(__file__)+"/*.py")
for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')):
if module_name != "handler":
exec('from {} import *'.format(module_name))

61
events/default_types.py Normal file
View File

@@ -0,0 +1,61 @@
import logging
class Event(object):
def __init__(self):
self.previous = None
# newest attribute gets selected first
def __getattr__(self, name):
if name == "previous":
return None
for event in self.history:
if name in event.__dict__:
return event.__dict__[name]
# returns the event history ordered from newest to oldest
@property
def history(self):
previous, history = self.previous, list()
while previous:
history.append(previous)
previous = previous.previous
return history
""" Event Objects """
class NewHostEvent(Event):
def __init__(self, host):
self.host = host
def __str__(self):
return str(self.host)
class OpenPortEvent(Event):
def __init__(self, port):
self.port = port
def __str__(self):
return str(self.port)
class HostScanEvent(Event):
def __init__(self, interal=True, localhost=True):
self.internal = interal
self.localhost = localhost
class KubeDashboardEvent(Event):
def __init__(self, path="/", secure=False):
self.path = path
self.secure
pass
class ReadOnlyKubeletEvent(Event):
def __init__(self):
pass
class SecureKubeletEvent(Event):
def __init__(self):
pass
class KubeProxyEvent(Event):
def __init__(self):
pass

65
events/handler.py Normal file
View File

@@ -0,0 +1,65 @@
import inspect
import logging
from abc import ABCMeta
from collections import defaultdict
from Queue import Queue
from threading import Lock, Thread
# Inherits Queue object, handles events asynchronously
class EventQueue(Queue, object):
def __init__(self, num_worker=10):
super(EventQueue, self).__init__()
self.hooks = defaultdict(list)
self.running = True
for i in range(num_worker):
t = Thread(target=self.worker)
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
def subscribe_event(self, event, hook=None, predicate=None):
logging.debug('{} subscribed to {}'.format(event.__name__, hook))
if hook not in self.hooks[event.__name__]:
self.hooks[event.__name__].append((hook, predicate))
# getting instantiated event object
def publish_event(self, event):
logging.debug('Event {} got published with {}'.format(event.__class__.__name__, event))
event_name = event.__class__.__name__
if event_name in self.hooks:
for hook, predicate in self.hooks[event_name]:
if predicate and not predicate(event):
continue
# access to stack frame, can also be implemented by changing the function call to recieve self.
# TODO: decide whether invisibility to the developer is the best approach
last_frame = inspect.stack()[1][0]
if "self" in last_frame.f_locals:
event.previous = last_frame.f_locals["self"].event
self.put(hook(event))
# executes callbacks on dedicated thread as a daemon
def worker(self):
while self.running:
hook = self.get()
hook.execute()
self.task_done()
# stops execution of all daemons
def free(self):
self.running = False
with self.mutex:
self.queue.clear()
handler = EventQueue(800)

View File

@@ -1,3 +0,0 @@
from .dashboard import *
from .kubelet import *
from .proxy import *

View File

@@ -1,104 +0,0 @@
from io import BytesIO
from logging import info, warning, debug
from PIL import Image
from requests import get
from selenium import webdriver
from selenium.webdriver.support import expected_conditions
from selenium.webdriver.support.ui import WebDriverWait
from hunters.hunter import Hunter
DASHBOARD_PATHS = [
"",
"/ui",
"/api/v1/namespaces/kube-system/services/kubernetes-dashboard/proxy"
]
API = {
"overview": "/api/v1/overview/default?itemsPerPage=100",
"nodes": "/api/v1/node?itemsPerPage=100"
}
XPATH = {
"login_skip": "/html/body/kd-login/form/kd-content-card/div/div/div/kd-content/button[2]"
}
def test_url(url):
r = get(url)
if r.status_code == 200:
return r.url
class Dashboard(Hunter):
def __init__(self, host):
self.host = host
if "://" not in host:
self.host_url = "http://{}".format(host)
else:
self.host_url = host
self._is_auth_required = None
self._base_path = None
def format_url(self, path):
return self.base_path + path
def list_nodes(self):
return [str(n["objectMeta"]["name"]) for n in get(self.format_url(API["nodes"])).json()["nodes"]]
@property
def base_path(self):
if self._base_path:
return self._base_path
for path in DASHBOARD_PATHS:
path = test_url(self.host_url + path)
if path:
self._base_path = path
return path
raise Exception("User interface URL path was not found")
@property
def is_auth_required(self):
if not self._is_auth_required:
overview = get(self.format_url(API["overview"])).json()
if "errors" in overview and overview["errors"]:
self._is_auth_required = any([e["ErrStatus"]["code"] == 403 for e in overview["errors"]])
else:
self._is_auth_required = False
return self._is_auth_required
def take_screenshot(self):
driver = webdriver.Chrome()
driver.fullscreen_window()
waiter = WebDriverWait(driver, 5)
driver.get(self.base_path)
waiter.until(lambda d: "Overview" in d.title or "Sign" in d.title)
skip_buttons = driver.find_elements_by_xpath(XPATH["login_skip"])
if skip_buttons:
skip_buttons[0].click()
waiter.until(expected_conditions.title_contains("Overview"))
result = driver.get_screenshot_as_png()
driver.quit()
return result
def hunt(self, *args, **kwargs):
debug("Hunting dashboard at {}".format(self.host))
debug("Checking authentication...")
if self.is_auth_required:
warning("Authentication is required")
return
debug("Authentication is not required")
debug("Listing nodes on the cluster...")
debug("Nodes: {}".format(self.list_nodes()))
debug("Taking a screenshot...")
Image.open(BytesIO(self.take_screenshot())).show()

View File

@@ -1,9 +0,0 @@
from abc import ABCMeta, abstractmethod
class Hunter(object):
__metaclass__ = ABCMeta
@abstractmethod
def hunt(self, *args, **kwargs):
pass

View File

@@ -1,9 +0,0 @@
from hunters.hunter import Hunter
class Kubelet(Hunter):
def __init__(self, host):
self.host = host
def hunt(self, *args, **kwargs):
raise NotImplementedError()

View File

@@ -1,9 +0,0 @@
from hunters.hunter import Hunter
class Proxy(Hunter):
def __init__(self, host):
self.host = host
def hunt(self, *args, **kwargs):
raise NotImplementedError()

7
hunting/__init__.py Normal file
View File

@@ -0,0 +1,7 @@
from os.path import dirname, basename, isfile
import glob
# dynamically importing all modules in folder
files = glob.glob(dirname(__file__)+"/*.py")
for module_name in (basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')):
exec('from {} import *'.format(module_name))

23
hunting/dashboard.py Normal file
View File

@@ -0,0 +1,23 @@
from events import handler, KubeDashboardEvent
import logging
import requests
@handler.subscribe(KubeDashboardEvent)
class KubeDashboard(object):
def __init__(self, event):
self.event = event
@property
def accessible(self):
protocol = "https" if self.event.secure else "http"
r = requests.get("{protocol}://{host}:{port}{loc}".format(protocol=protocol, host=self.event.host, port=self.event.port, loc=self.event.path))
return r.status_code == 200
def execute(self):
if not self.accessible:
return
if self.event.secure:
logging.info("[OPEN SERVICE] SECURE DASHBOARD - {}:{}{}".format(self.event.host, self.event.port, self.event.path))
else:
logging.info("[OPEN SERVICE] INSECURE DASHBOARD - {}:{}{}".format(self.event.host, self.event.port, self.event.path))

20
hunting/kubelet.py Normal file
View File

@@ -0,0 +1,20 @@
import logging
from events import handler, ReadOnlyKubeletEvent, SecureKubeletEvent
""" dividing ports for seperate hunters """
@handler.subscribe(ReadOnlyKubeletEvent)
class ReadOnlyKubeletPortHunter(object):
def __init__(self, event):
self.event = event
def execute(self):
logging.info("[OPEN SERVICE] INSECURED KUBELET API - {}:{}".format(self.event.host, self.event.port))
@handler.subscribe(SecureKubeletEvent)
class SecurePortKubeletHunter(object):
def __init__(self, event):
self.event = event
def execute(self):
logging.info("[OPEN SERVICE] SECURED KUBELET API - {}:{}".format(self.event.host, self.event.port))

42
hunting/proxy.py Normal file
View File

@@ -0,0 +1,42 @@
from enum import Enum
from requests import get
from events import KubeDashboardEvent, KubeProxyEvent, handler
class Service(Enum):
DASHBOARD = "kubernetes-dashboard"
@handler.subscribe(KubeProxyEvent)
class KubeProxy(object):
def __init__(self, event):
self.event = event
self.api_url = "http://{host}:{port}/api/v1".format(host=self.event.host, port=self.event.port)
def execute(self):
for namespace, services in self.services.items():
for service in services:
curr_path = "api/v1/namespaces/{ns}/services/{sv}/proxy".format(ns=namespace,sv=service) # TODO: check if /proxy is a convention on other services
if service == Service.DASHBOARD.value:
handler.publish_event(KubeDashboardEvent(path=curr_path, secure=False))
@property
def namespaces(self):
resource_json = get(self.api_url + "/namespaces").json()
return self.extract_names(resource_json)
@property
def services(self):
# map between namespaces and service names
services = dict()
for namespace in self.namespaces:
resource_path = "/namespaces/{ns}/services".format(ns=namespace)
resource_json = get(self.api_url + resource_path).json()
services[namespace] = self.extract_names(resource_json)
return services
@staticmethod
def extract_names(resource_json):
names = list()
for item in resource_json["items"]:
names.append(item["metadata"]["name"])
return names

98
kube-hunter.py Executable file → Normal file
View File

@@ -1,79 +1,25 @@
#! /usr/bin/python
#!/bin/env python
import log
from __future__ import print_function
from events import handler, HostScanEvent
from discovery import HostDiscovery
import hunting
import time
import sys
import logging
from argparse import ArgumentParser
from logging import DEBUG, basicConfig, info, warning
def main():
logging.info("Started")
try:
handler.publish_event(HostScanEvent(interal=True, localhost=False))
# Blocking to see discovery output
while(True):
time.sleep(100)
except KeyboardInterrupt:
logging.info("Kube-Hunter Stopped")
finally:
handler.free()
logging.debug("Cleaned Queue")
from discovery import DEFAULT_PORTS, HostScanner
from hunters import Dashboard, Kubelet, Proxy
from services import *
from validation import ip, subnet
import chromedriver_binary
HUNT_MODE = "hunt"
SCAN_MODE = "scan"
def hunt_callback(host):
hunters = {
KUBERNETES_DASHBOARD: Dashboard,
KUBERNETES_KUBELET_HTTPS: Kubelet,
KUBERNETES_KUBELET_HTTP: Kubelet,
KUBERNETES_PROXY: Proxy
}
service_type = identify_service(host)
if service_type == UNKNOWN:
return
if service_type not in hunters:
warning("Unsupported service type: {}".format(describe_service_type(service_type)))
else:
try:
hunters[service_type](host).hunt()
except NotImplementedError:
pass
def scan_callback(host):
print("{} - {}".format(host, describe_service_type(identify_service(host))))
def hunt(*args, **kwargs):
target = args[0]
info("Hunting target {}".format(target))
scanner = HostScanner(threads=1)
scanner.scan(target, DEFAULT_PORTS, hunt_callback)
def scan(*args, **kwargs):
target = args[0]
info("Scanning for targets on {}".format(target))
scanner = HostScanner(threads=20)
scanner.scan(target, DEFAULT_PORTS, scan_callback)
def main(mode, *args, **kwargs):
actions = {
SCAN_MODE: scan,
HUNT_MODE: hunt
}
actions[mode](*args, **kwargs)
if __name__ == "__main__":
basicConfig(level=DEBUG)
parser = ArgumentParser()
subparsers = parser.add_subparsers(dest="action", description="Available actions")
hunt_parser = subparsers.add_parser(HUNT_MODE)
hunt_parser.add_argument("host", type=ip, help="host to hunt")
scan_parser = subparsers.add_parser(SCAN_MODE)
scan_parser.add_argument("subnet", type=subnet, help="subnet to scan (CIDR notation)")
arguments = parser.parse_args()
main(arguments.action, *([i[1] for i in arguments._get_kwargs()[1:]]))
if __name__ == '__main__':
main()

1
log/__init__.py Normal file
View File

@@ -0,0 +1 @@
from config import *

13
log/config.py Normal file
View File

@@ -0,0 +1,13 @@
import logging
import argparse
parser = argparse.ArgumentParser(description='Kubehunter, hunting weak kubernetes clusters')
parser.add_argument('--log', type=str, metavar="LOGLEVEL", default='INFO', help="set output level, options are:\nDEBUG INFO WARNING")
args = parser.parse_args()
try:
loglevel = getattr(logging, args.log.upper())
except:
pass
logging.basicConfig(level=loglevel, format='%(asctime)s - [%(levelname)s]: %(message)s')

View File

@@ -1,4 +1,6 @@
selenium
pillow
netaddr
chromedriver_binary
chromedriver_binary
netifaces
enum

View File

@@ -1,81 +0,0 @@
from requests import get
from urllib3 import disable_warnings
UNKNOWN = 0
KUBERNETES_DASHBOARD = 1
KUBERNETES_PROXY = 2
KUBERNETES_KUBELET_HTTPS = 3
KUBERNETES_KUBELET_HTTP = 4
disable_warnings()
def describe_service_type(service_type):
service_types = {
KUBERNETES_DASHBOARD: "Kubernetes Dashboard",
KUBERNETES_PROXY: "Kubernetes Proxy",
KUBERNETES_KUBELET_HTTPS: "Kubernetes Kubelet",
KUBERNETES_KUBELET_HTTP: "Kubernetes Kubelet (Read only)",
}
return service_types.get(service_type, "Unknown Service")
def is_dashboard(host):
try:
r = get("http://{}/api/v1/login/status".format(host)).json()
return all([
"tokenPresent" in r,
"headerPresent" in r,
"httpsMode" in r
])
except:
return False
def is_proxy(host):
try:
r = get("http://{}/".format(host)).json()
return all([
"paths" in r,
"/api" in r["paths"]
])
except:
return False
def is_kubelet_https(host):
try:
r = get("https://{}/pods".format(host), verify=False).json()
return all([
"kind" in r,
"items" in r
])
except:
return False
def is_kubelet_http(host):
try:
r = get("http://{}/pods".format(host)).json()
return all([
"kind" in r,
"items" in r
])
except:
return False
def identify_service(host):
if is_dashboard(host):
return KUBERNETES_DASHBOARD
if is_proxy(host):
return KUBERNETES_PROXY
if is_kubelet_https(host):
return KUBERNETES_KUBELET_HTTPS
if is_kubelet_http(host):
return KUBERNETES_KUBELET_HTTP
return UNKNOWN

View File

@@ -1,40 +0,0 @@
from argparse import ArgumentTypeError
def ip(string):
error = ArgumentTypeError("{} is not a valid IP address".format(string))
octets = string.split(".")
if len(octets) != 4:
raise error
try:
for o in octets:
o = int(o)
if o < 0 or o > 255:
raise error
except ValueError:
raise error
return string
def subnet(string):
parts = string.split("/")
if len(parts) != 2:
raise ArgumentTypeError("{} is not a valid subnet".format(string))
host, mask = parts
ip(host)
try:
mask = int(mask)
except ValueError:
raise ArgumentTypeError("{} is not an integer".format(mask))
if mask < 0 or mask > 32:
raise ArgumentTypeError("{} is not valid host identifier".format(mask))
return string