mirror of
https://github.com/aquasecurity/kube-hunter.git
synced 2026-05-15 05:37:24 +00:00
Merge branch 'KubeHunter2'
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1 @@
|
||||
.idea/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
39
README.md
39
README.md
@@ -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
|
||||
79
discovery.py
79
discovery.py
@@ -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
7
discovery/__init__.py
Normal 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
17
discovery/dashboard.py
Normal 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
35
discovery/hosts.py
Normal 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
35
discovery/kubelet.py
Normal 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
26
discovery/ports.py
Normal 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
20
discovery/proxy.py
Normal 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
11
events/__init__.py
Normal 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
61
events/default_types.py
Normal 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
65
events/handler.py
Normal 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)
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
from .dashboard import *
|
||||
from .kubelet import *
|
||||
from .proxy import *
|
||||
@@ -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()
|
||||
@@ -1,9 +0,0 @@
|
||||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
|
||||
class Hunter(object):
|
||||
__metaclass__ = ABCMeta
|
||||
|
||||
@abstractmethod
|
||||
def hunt(self, *args, **kwargs):
|
||||
pass
|
||||
@@ -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()
|
||||
@@ -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
7
hunting/__init__.py
Normal 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
23
hunting/dashboard.py
Normal 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
20
hunting/kubelet.py
Normal 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
42
hunting/proxy.py
Normal 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
98
kube-hunter.py
Executable file → Normal 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
1
log/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from config import *
|
||||
13
log/config.py
Normal file
13
log/config.py
Normal 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')
|
||||
@@ -1,4 +1,6 @@
|
||||
selenium
|
||||
pillow
|
||||
netaddr
|
||||
chromedriver_binary
|
||||
chromedriver_binary
|
||||
netifaces
|
||||
enum
|
||||
81
services.py
81
services.py
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user