Compare commits

..

5 Commits

Author SHA1 Message Date
Liz Rice
16103bfbcf Merge branch 'master' into fix-hunting-bugs 2020-09-04 12:15:52 +01:00
Liz Rice
129ac8d0eb Merge branch 'master' into fix-hunting-bugs 2020-09-04 12:02:12 +01:00
Liz Rice
19c00e9ee2 Merge branch 'master' into fix-hunting-bugs 2020-09-04 09:44:14 +01:00
Daniel Sagi
ab40d90b13 changed self.protocol in other places on etcd hunting. this is a typo, protocol is a property of events, not hunters 2020-08-21 05:46:28 -07:00
Daniel Sagi
45a92a9577 fixed etcd version hunting typo 2020-08-21 05:18:12 -07:00
35 changed files with 145 additions and 321 deletions

View File

@@ -1,14 +0,0 @@
name: Greetings
on: [pull_request, issues]
jobs:
greeting:
runs-on: ubuntu-latest
steps:
- uses: actions/first-interaction@v1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
issue-message: "Hola! @${{ github.actor }} 🥳 , You've just created an Issue!🌟 Thanks for making the Project Better"
pr-message: 'Submitted a PR already ?? @${{ github.actor }} . Sit tight until one of our amazing maintainers review it. Make sure you read the contributing guide'

View File

@@ -1,12 +0,0 @@
name: Lint
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-20.04
steps:
- uses: actions/checkout@v2
- uses: actions/setup-python@v2
- uses: pre-commit/action@v2.0.0

View File

@@ -1,54 +0,0 @@
name: Test
on: [push, pull_request]
env:
FORCE_COLOR: 1
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
python-version: ["3.6", "3.7", "3.8", "3.9"]
os: [ubuntu-20.04, ubuntu-18.04, ubuntu-16.04]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Get pip cache dir
id: pip-cache
run: |
echo "::set-output name=dir::$(pip cache dir)"
- name: Cache
uses: actions/cache@v2
with:
path: ${{ steps.pip-cache.outputs.dir }}
key:
${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('requirements-dev.txt') }}
restore-keys: |
${{ matrix.os }}-${{ matrix.python-version }}-
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -U wheel
python -m pip install -r requirements.txt
python -m pip install -r requirements-dev.txt
- name: Test
shell: bash
run: |
make test
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
name: ${{ matrix.os }} Python ${{ matrix.python-version }}

1
.gitignore vendored
View File

@@ -24,7 +24,6 @@ var/
*.egg
*.spec
.eggs
pip-wheel-metadata
# Directory Cache Files
.DS_Store

View File

@@ -5,7 +5,6 @@ python:
- "3.6"
- "3.7"
- "3.8"
- "3.9"
install:
- pip install -r requirements.txt
- pip install -r requirements-dev.txt

View File

@@ -34,7 +34,6 @@ Table of Contents
* [Prerequisites](#prerequisites)
* [Container](#container)
* [Pod](#pod)
* [Contribution](#contribution)
## Hunting
@@ -175,8 +174,5 @@ The example `job.yaml` file defines a Job that will run kube-hunter in a pod, us
* Find the pod name with `kubectl describe job kube-hunter`
* View the test results with `kubectl logs <pod name>`
## Contribution
To read the contribution guidelines, <a href="https://github.com/aquasecurity/kube-hunter/blob/master/CONTRIBUTING.md"> Click here </a>
## License
This repository is available under the [Apache License 2.0](https://github.com/aquasecurity/kube-hunter/blob/master/LICENSE).

View File

@@ -12,7 +12,7 @@ Kubernetes API was accessed with Pod Service Account or without Authentication (
## Remediation
Secure access to your Kubernetes API.
Secure acess to your Kubernetes API.
It is recommended to explicitly specify a Service Account for all of your workloads (`serviceAccountName` in `Pod.Spec`), and manage their permissions according to the least privilege principal.
@@ -21,4 +21,4 @@ Consider opting out automatic mounting of SA token using `automountServiceAccoun
## References
- [Configure Service Accounts for Pods](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/)
- [Configure Service Accounts for Pods](https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/)

View File

@@ -1,23 +0,0 @@
---
vid: KHV052
title: Exposed Pods
categories: [Information Disclosure]
---
# {{ page.vid }} - {{ page.title }}
## Issue description
An attacker could view sensitive information about pods that are bound to a Node using the exposed /pods endpoint
This can be done either by accessing the readonly port (default 10255), or from the secure kubelet port (10250)
## Remediation
Ensure kubelet is protected using `--anonymous-auth=false` kubelet flag. Allow only legitimate users using `--client-ca-file` or `--authentication-token-webhook` kubelet flags. This is usually done by the installer or cloud provider.
Disable the readonly port by using `--read-only-port=0` kubelet flag.
## References
- [Kubelet configuration](https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet/)
- [Kubelet authentication/authorization](https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet-authentication-authorization/)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 230 KiB

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View File

@@ -18,7 +18,6 @@ config = Config(
cidr=args.cidr,
include_patched_versions=args.include_patched_versions,
interface=args.interface,
log_file=args.log_file,
mapping=args.mapping,
network_timeout=args.network_timeout,
pod=args.pod,
@@ -26,7 +25,7 @@ config = Config(
remote=args.remote,
statistics=args.statistics,
)
setup_logger(args.log, args.log_file)
setup_logger(args.log)
set_config(config)
# Running all other registered plugins before execution
@@ -73,13 +72,13 @@ def list_hunters():
print("\nPassive Hunters:\n----------------")
for hunter, docs in handler.passive_hunters.items():
name, doc = hunter.parse_docs(docs)
print(f"* {name}\n {doc}\n")
print("* {}\n {}\n".format(name, doc))
if config.active:
print("\n\nActive Hunters:\n---------------")
for hunter, docs in handler.active_hunters.items():
name, doc = hunter.parse_docs(docs)
print(f"* {name}\n {doc}\n")
print("* {}\n {}\n".format(name, doc))
hunt_started_lock = threading.Lock()

View File

@@ -13,7 +13,6 @@ class Config:
- interface: Interface scanning mode
- list_hunters: Print a list of existing hunters
- log_level: Log level
- log_file: Log File path
- mapping: Report only found components
- network_timeout: Timeout for network operations
- pod: From pod scanning mode
@@ -28,7 +27,6 @@ class Config:
dispatcher: Optional[Any] = None
include_patched_versions: bool = False
interface: bool = False
log_file: Optional[str] = None
mapping: bool = False
network_timeout: float = 5.0
pod: bool = False

View File

@@ -1,5 +1,6 @@
import logging
DEFAULT_LEVEL = logging.INFO
DEFAULT_LEVEL_NAME = logging.getLevelName(DEFAULT_LEVEL)
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
@@ -9,7 +10,7 @@ logging.getLogger("scapy.runtime").setLevel(logging.CRITICAL)
logging.getLogger("scapy.loading").setLevel(logging.CRITICAL)
def setup_logger(level_name, logfile):
def setup_logger(level_name):
# Remove any existing handlers
# Unnecessary in Python 3.8 since `logging.basicConfig` has `force` parameter
for h in logging.getLogger().handlers[:]:
@@ -21,9 +22,6 @@ def setup_logger(level_name, logfile):
else:
log_level = getattr(logging, level_name.upper(), None)
log_level = log_level if isinstance(log_level, int) else None
if logfile is None:
logging.basicConfig(level=log_level or DEFAULT_LEVEL, format=LOG_FORMAT)
else:
logging.basicConfig(filename=logfile, level=log_level or DEFAULT_LEVEL, format=LOG_FORMAT)
logging.basicConfig(level=log_level or DEFAULT_LEVEL, format=LOG_FORMAT)
if not log_level:
logging.warning(f"Unknown log level '{level_name}', using {DEFAULT_LEVEL_NAME}")

View File

@@ -56,13 +56,6 @@ def parser_add_arguments(parser):
help="Set log level, options are: debug, info, warn, none",
)
parser.add_argument(
"--log-file",
type=str,
default=None,
help="Path to a log file to output all logs to",
)
parser.add_argument(
"--report",
type=str,

View File

@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
# Inherits Queue object, handles events asynchronously
class EventQueue(Queue):
def __init__(self, num_worker=10):
super().__init__()
super(EventQueue, self).__init__()
self.passive_hunters = dict()
self.active_hunters = dict()
self.all_hunters = dict()

View File

@@ -5,7 +5,8 @@ import requests
from enum import Enum
from netaddr import IPNetwork, IPAddress, AddrFormatError
from netifaces import AF_INET, ifaddresses, interfaces, gateways
from netifaces import AF_INET, ifaddresses, interfaces
from scapy.all import ICMP, IP, Ether, srp1
from kube_hunter.conf import get_config
from kube_hunter.core.events import handler
@@ -36,7 +37,7 @@ class RunningAsPodEvent(Event):
try:
with open(f"/var/run/secrets/kubernetes.io/serviceaccount/{file}") as f:
return f.read()
except OSError:
except IOError:
pass
@@ -52,7 +53,7 @@ class AzureMetadataApi(Vulnerability, Event):
vid="KHV003",
)
self.cidr = cidr
self.evidence = f"cidr: {cidr}"
self.evidence = "cidr: {}".format(cidr)
class HostScanEvent(Event):
@@ -108,7 +109,7 @@ class FromPodHostDiscovery(Discovery):
if self.is_azure_pod():
subnets, cloud = self.azure_metadata_discovery()
else:
subnets = self.gateway_discovery()
subnets = self.traceroute_discovery()
should_scan_apiserver = False
if self.event.kubeservicehost:
@@ -140,9 +141,14 @@ class FromPodHostDiscovery(Discovery):
return False
# for pod scanning
def gateway_discovery(self):
""" Retrieving default gateway of pod, which is usually also a contact point with the host """
return [[gateways()["default"][AF_INET][0], "24"]]
def traceroute_discovery(self):
config = get_config()
node_internal_ip = srp1(
Ether() / IP(dst="1.1.1.1", ttl=1) / ICMP(),
verbose=0,
timeout=config.network_timeout,
)[IP].src
return [[node_internal_ip, "24"]]
# querying azure's interface metadata api | works only from a pod
def azure_metadata_discovery(self):

View File

@@ -46,16 +46,11 @@ class AzureSpnHunter(Hunter):
logger.debug("failed getting pod info")
else:
pods_data = r.json().get("items", [])
suspicious_volume_names = []
for pod_data in pods_data:
for volume in pod_data["spec"].get("volumes", []):
if volume.get("hostPath"):
path = volume["hostPath"]["path"]
if "/etc/kubernetes/azure.json".startswith(path):
suspicious_volume_names.append(volume["name"])
for container in pod_data["spec"]["containers"]:
for mount in container.get("volumeMounts", []):
if mount["name"] in suspicious_volume_names:
for mount in container["volumeMounts"]:
path = mount["mountPath"]
if "/etc/kubernetes/azure.json".startswith(path):
return {
"name": container["name"],
"pod": pod_data["metadata"]["name"],

View File

@@ -56,19 +56,16 @@ class ServerApiHTTPAccess(Vulnerability, Event):
class ApiInfoDisclosure(Vulnerability, Event):
"""Information Disclosure depending upon RBAC permissions and Kube-Cluster Setup"""
def __init__(self, evidence, using_token, name):
category = InformationDisclosure
if using_token:
name += " using default service account token"
name += " using service account token"
else:
name += " as anonymous user"
Vulnerability.__init__(
self,
KubernetesCluster,
name=name,
category=category,
category=InformationDisclosure,
vid="KHV007",
)
self.evidence = evidence
@@ -346,7 +343,7 @@ class AccessApiServer(Hunter):
else:
self.publish_event(ServerApiAccess(api, self.with_token))
namespaces = self.get_items(f"{self.path}/api/v1/namespaces")
namespaces = self.get_items("{path}/api/v1/namespaces".format(path=self.path))
if namespaces:
self.publish_event(ListNamespaces(namespaces, self.with_token))
@@ -374,7 +371,7 @@ class AccessApiServerWithToken(AccessApiServer):
"""
def __init__(self, event):
super().__init__(event)
super(AccessApiServerWithToken, self).__init__(event)
assert self.event.auth_token
self.headers = {"Authorization": f"Bearer {self.event.auth_token}"}
self.category = InformationDisclosure

View File

@@ -43,7 +43,7 @@ class ArpSpoofHunter(ActiveHunter):
def detect_l3_on_host(self, arp_responses):
""" returns True for an existence of an L3 network plugin """
logger.debug("Attempting to detect L3 network plugin using ARP")
unique_macs = list({response[ARP].hwsrc for _, response in arp_responses})
unique_macs = list(set(response[ARP].hwsrc for _, response in arp_responses))
# if LAN addresses not unique
if len(unique_macs) == 1:

View File

@@ -8,13 +8,11 @@ from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event, Service
logger = logging.getLogger(__name__)
email_pattern = re.compile(rb"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)")
email_pattern = re.compile(rb"([a-z0-9]+@[a-z0-9]+\.[a-z0-9]+)")
class CertificateEmail(Vulnerability, Event):
"""The Kubernetes API Server advertises a public certificate for TLS.
This certificate includes an email address, that may provide additional information for an attacker on your
organization, or be abused for further email based attacks."""
"""Certificate includes an email address"""
def __init__(self, email):
Vulnerability.__init__(
@@ -25,7 +23,7 @@ class CertificateEmail(Vulnerability, Event):
vid="KHV021",
)
self.email = email
self.evidence = f"email: {self.email}"
self.evidence = "email: {}".format(self.email)
@handler.subscribe(Service)

View File

@@ -104,7 +104,7 @@ class IncompleteFixToKubectlCpVulnerability(Vulnerability, Event):
vid="KHV027",
)
self.binary_version = binary_version
self.evidence = f"kubectl version: {self.binary_version}"
self.evidence = "kubectl version: {}".format(self.binary_version)
class KubectlCpVulnerability(Vulnerability, Event):
@@ -120,7 +120,7 @@ class KubectlCpVulnerability(Vulnerability, Event):
vid="KHV028",
)
self.binary_version = binary_version
self.evidence = f"kubectl version: {self.binary_version}"
self.evidence = "kubectl version: {}".format(self.binary_version)
class CveUtils:

View File

@@ -25,7 +25,7 @@ class PossibleDnsSpoofing(Vulnerability, Event):
vid="KHV030",
)
self.kubedns_pod_ip = kubedns_pod_ip
self.evidence = f"kube-dns at: {self.kubedns_pod_ip}"
self.evidence = "kube-dns at: {}".format(self.kubedns_pod_ip)
# Only triggered with RunningAsPod base event

View File

@@ -35,7 +35,10 @@ class ExposedPodsHandler(Vulnerability, Event):
def __init__(self, pods):
Vulnerability.__init__(
self, component=Kubelet, name="Exposed Pods", category=InformationDisclosure, vid="KHV052"
self,
component=Kubelet,
name="Exposed Pods",
category=InformationDisclosure,
)
self.pods = pods
self.evidence = f"count: {len(self.pods)}"
@@ -81,7 +84,7 @@ class ExposedRunningPodsHandler(Vulnerability, Event):
vid="KHV038",
)
self.count = count
self.evidence = f"{self.count} running pods"
self.evidence = "{} running pods".format(self.count)
class ExposedExecHandler(Vulnerability, Event):
@@ -344,23 +347,27 @@ class SecureKubeletPortHunter(Hunter):
# need further investigation on websockets protocol for further implementation
def test_port_forward(self):
pass
config = get_config()
headers = {
"Upgrade": "websocket",
"Connection": "Upgrade",
"Sec-Websocket-Key": "s",
"Sec-Websocket-Version": "13",
"Sec-Websocket-Protocol": "SPDY",
}
pf_url = self.path + KubeletHandlers.PORTFORWARD.value.format(
pod_namespace=self.pod["namespace"],
pod_id=self.pod["name"],
port=80,
)
self.session.get(
pf_url,
headers=headers,
verify=False,
stream=True,
timeout=config.network_timeout,
).status_code == 200
# TODO: what to return?
# Example starting code:
#
# config = get_config()
# headers = {
# "Upgrade": "websocket",
# "Connection": "Upgrade",
# "Sec-Websocket-Key": "s",
# "Sec-Websocket-Version": "13",
# "Sec-Websocket-Protocol": "SPDY",
# }
# pf_url = self.path + KubeletHandlers.PORTFORWARD.value.format(
# pod_namespace=self.pod["namespace"],
# pod_id=self.pod["name"],
# port=80,
# )
# executes one command and returns output
def test_run_container(self):
@@ -371,9 +378,8 @@ class SecureKubeletPortHunter(Hunter):
container_name="test",
cmd="",
)
# if we get this message, we know we passed Authentication and Authorization, and that the endpoint is enabled.
status_code = self.session.post(run_url, verify=False, timeout=config.network_timeout).status_code
return status_code == requests.codes.NOT_FOUND
# if we get a Method Not Allowed, we know we passed Authentication and Authorization.
return self.session.get(run_url, verify=False, timeout=config.network_timeout).status_code == 405
# returns list of currently running pods
def test_running_pods(self):
@@ -526,7 +532,7 @@ class ProveAnonymousAuth(ActiveHunter):
def __init__(self, event):
self.event = event
self.base_url = f"https://{self.event.host}:10250/"
self.base_url = "https://{host}:10250/".format(host=self.event.host)
def get_request(self, url, verify=False):
config = get_config()
@@ -565,7 +571,7 @@ class ProveAnonymousAuth(ActiveHunter):
return ProveAnonymousAuth.has_no_error(result) and ProveAnonymousAuth.has_no_exception(result)
def cat_command(self, run_request_url, full_file_path):
return self.post_request(run_request_url, {"cmd": f"cat {full_file_path}"})
return self.post_request(run_request_url, {"cmd": "cat {}".format(full_file_path)})
def process_container(self, run_request_url):
service_account_token = self.cat_command(run_request_url, "/var/run/secrets/kubernetes.io/serviceaccount/token")
@@ -602,7 +608,7 @@ class ProveAnonymousAuth(ActiveHunter):
for container_data in pod_data["spec"]["containers"]:
container_name = container_data["name"]
run_request_url = self.base_url + f"run/{pod_namespace}/{pod_id}/{container_name}"
run_request_url = self.base_url + "run/{}/{}/{}".format(pod_namespace, pod_id, container_name)
extracted_data = self.process_container(run_request_url)
@@ -611,11 +617,11 @@ class ProveAnonymousAuth(ActiveHunter):
environment_variables = extracted_data["environment_variables"]
temp_message += (
f"\n\nPod namespace: {pod_namespace}"
+ f"\n\nPod ID: {pod_id}"
+ f"\n\nContainer name: {container_name}"
+ f"\n\nService account token: {service_account_token}"
+ f"\nEnvironment variables: {environment_variables}"
"\n\nPod namespace: {}".format(pod_namespace)
+ "\n\nPod ID: {}".format(pod_id)
+ "\n\nContainer name: {}".format(container_name)
+ "\n\nService account token: {}".format(service_account_token)
+ "\nEnvironment variables: {}".format(environment_variables)
)
first_check = container_data.get("securityContext", {}).get("privileged")
@@ -640,7 +646,7 @@ class ProveAnonymousAuth(ActiveHunter):
if temp_message:
message = "The following containers have been successfully breached." + temp_message
self.event.evidence = f"{message}"
self.event.evidence = "{}".format(message)
if exposed_existing_privileged_containers:
self.publish_event(
@@ -660,7 +666,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
def __init__(self, event, seconds_to_wait_for_os_command=1):
self.event = event
self.base_url = f"https://{self.event.host}:10250/"
self.base_url = "https://{host}:10250/".format(host=self.event.host)
self.seconds_to_wait_for_os_command = seconds_to_wait_for_os_command
self.number_of_rm_attempts = 5
self.number_of_rmdir_attempts = 5
@@ -679,7 +685,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
return "Exception: " + str(ex)
def cat_command(self, run_request_url, full_file_path):
return self.post_request(run_request_url, {"cmd": f"cat {full_file_path}"})
return self.post_request(run_request_url, {"cmd": "cat {}".format(full_file_path)})
def clean_attacked_exposed_existing_privileged_container(
self,
@@ -695,7 +701,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
self.rm_command(
run_request_url,
f"{directory_created}/etc/cron.daily/{file_created}",
"{}/etc/cron.daily/{}".format(directory_created, file_created),
number_of_rm_attempts,
seconds_to_wait_for_os_command,
)
@@ -723,7 +729,9 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
def rm_command(self, run_request_url, file_to_remove, number_of_rm_attempts, seconds_to_wait_for_os_command):
if self.check_file_exists(run_request_url, file_to_remove):
for _ in range(number_of_rm_attempts):
command_execution_outcome = self.post_request(run_request_url, {"cmd": f"rm -f {file_to_remove}"})
command_execution_outcome = self.post_request(
run_request_url, {"cmd": "rm -f {}".format(file_to_remove)}
)
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
@@ -750,10 +758,10 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
return False
def chmod_command(self, run_request_url, permissions, file):
return self.post_request(run_request_url, {"cmd": f"chmod {permissions} {file}"})
return self.post_request(run_request_url, {"cmd": "chmod {} {}".format(permissions, file)})
def touch_command(self, run_request_url, file_to_create):
return self.post_request(run_request_url, {"cmd": f"touch {file_to_create}"})
return self.post_request(run_request_url, {"cmd": "touch {}".format(file_to_create)})
def attack_exposed_existing_privileged_container(
self, run_request_url, directory_created, number_of_rm_attempts, seconds_to_wait_for_os_command, file_name=None
@@ -761,7 +769,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
if file_name is None:
file_name = "kube-hunter" + str(uuid.uuid1())
file_name_with_path = f"{directory_created}/etc/cron.daily/{file_name}"
file_name_with_path = "{}/etc/cron.daily/{}".format(directory_created, file_name)
file_created = self.touch_command(run_request_url, file_name_with_path)
@@ -789,7 +797,9 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
):
if self.check_directory_exists(run_request_url, directory_to_remove):
for _ in range(number_of_rmdir_attempts):
command_execution_outcome = self.post_request(run_request_url, {"cmd": f"rmdir {directory_to_remove}"})
command_execution_outcome = self.post_request(
run_request_url, {"cmd": "rmdir {}".format(directory_to_remove)}
)
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
@@ -816,7 +826,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
return False
def ls_command(self, run_request_url, file_or_directory):
return self.post_request(run_request_url, {"cmd": f"ls {file_or_directory}"})
return self.post_request(run_request_url, {"cmd": "ls {}".format(file_or_directory)})
def umount_command(
self,
@@ -834,7 +844,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
for _ in range(number_of_umount_attempts):
# Ref: http://man7.org/linux/man-pages/man2/umount.2.html
command_execution_outcome = self.post_request(
run_request_url, {"cmd": f"umount {file_system_or_partition} {directory}"}
run_request_url, {"cmd": "umount {} {}".format(file_system_or_partition, directory)}
)
if seconds_to_wait_for_os_command:
@@ -865,16 +875,16 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
def mount_command(self, run_request_url, file_system_or_partition, directory):
# Ref: http://man7.org/linux/man-pages/man1/mkdir.1.html
return self.post_request(run_request_url, {"cmd": f"mount {file_system_or_partition} {directory}"})
return self.post_request(run_request_url, {"cmd": "mount {} {}".format(file_system_or_partition, directory)})
def mkdir_command(self, run_request_url, directory_to_create):
# Ref: http://man7.org/linux/man-pages/man1/mkdir.1.html
return self.post_request(run_request_url, {"cmd": f"mkdir {directory_to_create}"})
return self.post_request(run_request_url, {"cmd": "mkdir {}".format(directory_to_create)})
def findfs_command(self, run_request_url, file_system_or_partition_type, file_system_or_partition):
# Ref: http://man7.org/linux/man-pages/man8/findfs.8.html
return self.post_request(
run_request_url, {"cmd": f"findfs {file_system_or_partition_type}{file_system_or_partition}"}
run_request_url, {"cmd": "findfs {}{}".format(file_system_or_partition_type, file_system_or_partition)}
)
def get_root_values(self, command_line):
@@ -933,7 +943,9 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
)
if ProveAnonymousAuth.has_no_error_nor_exception(mounted_file_system_or_partition):
host_name = self.cat_command(run_request_url, f"{directory_created}/etc/hostname")
host_name = self.cat_command(
run_request_url, "{}/etc/hostname".format(directory_created)
)
if ProveAnonymousAuth.has_no_error_nor_exception(host_name):
return {
@@ -967,7 +979,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
pod_id = exposed_existing_privileged_containers["pod_id"]
container_name = exposed_existing_privileged_containers["container_name"]
run_request_url = self.base_url + f"run/{pod_namespace}/{pod_id}/{container_name}"
run_request_url = self.base_url + "run/{}/{}/{}".format(pod_namespace, pod_id, container_name)
is_exposed_existing_privileged_container_privileged = self.process_exposed_existing_privileged_container(
run_request_url,
@@ -1017,7 +1029,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
+ temp_message
)
self.event.evidence = f"{message}"
self.event.evidence = "{}".format(message)
else:
message = (
"The following exposed existing privileged containers"
@@ -1026,7 +1038,7 @@ class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
+ temp_message
)
self.event.evidence = f"{message}"
self.event.evidence = "{}".format(message)
@handler.subscribe(ExposedRunHandler)
@@ -1133,16 +1145,11 @@ class ProveSystemLogs(ActiveHunter):
f"{self.base_url}/" + KubeletHandlers.LOGS.value.format(path="audit/audit.log"),
verify=False,
timeout=config.network_timeout,
)
# TODO: add more methods for proving system logs
if audit_logs.status_code == requests.status_codes.codes.OK:
logger.debug(f"Audit log of host {self.event.host}: {audit_logs.text[:10]}")
# iterating over proctitles and converting them into readable strings
proctitles = []
for proctitle in re.findall(r"proctitle=(\w+)", audit_logs.text):
proctitles.append(bytes.fromhex(proctitle).decode("utf-8").replace("\x00", " "))
self.event.proctitles = proctitles
self.event.evidence = f"audit log: {proctitles}"
else:
self.event.evidence = "Could not parse system logs"
).text
logger.debug(f"Audit log of host {self.event.host}: {audit_logs[:10]}")
# iterating over proctitles and converting them into readable strings
proctitles = []
for proctitle in re.findall(r"proctitle=(\w+)", audit_logs):
proctitles.append(bytes.fromhex(proctitle).decode("utf-8").replace("\x00", " "))
self.event.proctitles = proctitles
self.event.evidence = f"audit log: {proctitles}"

View File

@@ -32,7 +32,7 @@ class WriteMountToVarLog(Vulnerability, Event):
vid="KHV047",
)
self.pods = pods
self.evidence = "pods: {}".format(", ".join(pod["metadata"]["name"] for pod in self.pods))
self.evidence = "pods: {}".format(", ".join((pod["metadata"]["name"] for pod in self.pods)))
class DirectoryTraversalWithKubelet(Vulnerability, Event):
@@ -47,7 +47,7 @@ class DirectoryTraversalWithKubelet(Vulnerability, Event):
category=PrivilegeEscalation,
)
self.output = output
self.evidence = f"output: {self.output}"
self.evidence = "output: {}".format(self.output)
@handler.subscribe(ExposedPodsHandler)

View File

@@ -7,9 +7,6 @@ from kube_hunter.modules.report.collector import (
vulnerabilities_lock,
)
BASE_KB_LINK = "https://avd.aquasec.com/"
FULL_KB_LINK = "https://avd.aquasec.com/kube-hunter/{vid}/"
class BaseReporter:
def get_nodes(self):
@@ -41,7 +38,6 @@ class BaseReporter:
"vulnerability": vuln.get_name(),
"description": vuln.explain(),
"evidence": str(vuln.evidence),
"avd_reference": FULL_KB_LINK.format(vid=vuln.get_vid().lower()),
"hunter": vuln.hunter.get_name(),
}
for vuln in vulnerabilities
@@ -67,4 +63,6 @@ class BaseReporter:
if statistics:
report["hunter_statistics"] = self.get_hunter_statistics()
report["kburl"] = "https://aquasecurity.github.io/kube-hunter/kb/{vid}"
return report

View File

@@ -1,6 +1,6 @@
from prettytable import ALL, PrettyTable
from kube_hunter.modules.report.base import BaseReporter, BASE_KB_LINK
from kube_hunter.modules.report.base import BaseReporter
from kube_hunter.modules.report.collector import (
services,
vulnerabilities,
@@ -9,8 +9,9 @@ from kube_hunter.modules.report.collector import (
vulnerabilities_lock,
)
EVIDENCE_PREVIEW = 100
EVIDENCE_PREVIEW = 40
MAX_TABLE_WIDTH = 20
KB_LINK = "https://github.com/aquasecurity/kube-hunter/tree/master/docs/_kb"
class PlainReporter(BaseReporter):
@@ -59,7 +60,7 @@ class PlainReporter(BaseReporter):
if service.event_id not in id_memory:
nodes_table.add_row(["Node/Master", service.host])
id_memory.add(service.event_id)
nodes_ret = f"\nNodes\n{nodes_table}\n"
nodes_ret = "\nNodes\n{}\n".format(nodes_table)
services_lock.release()
return nodes_ret
@@ -113,7 +114,7 @@ class PlainReporter(BaseReporter):
return (
"\nVulnerabilities\n"
"For further information about a vulnerability, search its ID in: \n"
f"{BASE_KB_LINK}\n{vuln_table}\n"
f"{KB_LINK}\n{vuln_table}\n"
)
def hunters_table(self):

View File

@@ -22,8 +22,6 @@ classifiers =
Programming Language :: Python :: 3.6
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3 :: Only
Topic :: Security
[options]

View File

@@ -11,13 +11,12 @@ def test_setup_logger_level():
("NOTEXISTS", logging.INFO),
("BASIC_FORMAT", logging.INFO),
]
logFile = None
for level, expected in test_cases:
setup_logger(level, logFile)
setup_logger(level)
actual = logging.getLogger().getEffectiveLevel()
assert actual == expected, f"{level} level should be {expected} (got {actual})"
def test_setup_logger_none():
setup_logger("NONE", None)
setup_logger("NONE")
assert logging.getLogger().manager.disable == logging.CRITICAL

View File

@@ -123,7 +123,7 @@ def test_InsecureApiServer():
# We should only generate an ApiServer event for a response that looks like it came from a Kubernetes node
@handler.subscribe(ApiServer)
class testApiServer:
class testApiServer(object):
def __init__(self, event):
print("Event")
assert event.host == "mockKubernetes"

View File

@@ -90,7 +90,7 @@ class TestDiscoveryUtils:
def test_generate_hosts_valid_ignore():
remove = IPAddress("192.168.1.8")
scan = "192.168.1.0/24"
expected = {ip for ip in IPNetwork(scan) if ip != remove}
expected = set(ip for ip in IPNetwork(scan) if ip != remove)
actual = set(HostDiscoveryHelpers.generate_hosts([scan, f"!{str(remove)}"]))

View File

@@ -1,56 +0,0 @@
# flake8: noqa: E402
import requests_mock
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.modules.hunting.kubelet import ExposedRunHandler
from kube_hunter.modules.hunting.aks import AzureSpnHunter
def test_AzureSpnHunter():
e = ExposedRunHandler()
e.host = "mockKubernetes"
e.port = 443
e.protocol = "https"
pod_template = '{{"items":[ {{"apiVersion":"v1","kind":"Pod","metadata":{{"name":"etc","namespace":"default"}},"spec":{{"containers":[{{"command":["sleep","99999"],"image":"ubuntu","name":"test","volumeMounts":[{{"mountPath":"/mp","name":"v"}}]}}],"volumes":[{{"hostPath":{{"path":"{}"}},"name":"v"}}]}}}} ]}}'
bad_paths = ["/", "/etc", "/etc/", "/etc/kubernetes", "/etc/kubernetes/azure.json"]
good_paths = ["/yo", "/etc/yo", "/etc/kubernetes/yo.json"]
for p in bad_paths:
with requests_mock.Mocker() as m:
m.get("https://mockKubernetes:443/pods", text=pod_template.format(p))
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c
for p in good_paths:
with requests_mock.Mocker() as m:
m.get("https://mockKubernetes:443/pods", text=pod_template.format(p))
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None
with requests_mock.Mocker() as m:
pod_no_volume_mounts = '{"items":[ {"apiVersion":"v1","kind":"Pod","metadata":{"name":"etc","namespace":"default"},"spec":{"containers":[{"command":["sleep","99999"],"image":"ubuntu","name":"test"}],"volumes":[{"hostPath":{"path":"/whatever"},"name":"v"}]}} ]}'
m.get("https://mockKubernetes:443/pods", text=pod_no_volume_mounts)
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None
with requests_mock.Mocker() as m:
pod_no_volumes = '{"items":[ {"apiVersion":"v1","kind":"Pod","metadata":{"name":"etc","namespace":"default"},"spec":{"containers":[{"command":["sleep","99999"],"image":"ubuntu","name":"test"}]}} ]}'
m.get("https://mockKubernetes:443/pods", text=pod_no_volumes)
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None
with requests_mock.Mocker() as m:
pod_other_volume = '{"items":[ {"apiVersion":"v1","kind":"Pod","metadata":{"name":"etc","namespace":"default"},"spec":{"containers":[{"command":["sleep","99999"],"image":"ubuntu","name":"test","volumeMounts":[{"mountPath":"/mp","name":"v"}]}],"volumes":[{"emptyDir":{},"name":"v"}]}} ]}'
m.get("https://mockKubernetes:443/pods", text=pod_other_volume)
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None

View File

@@ -122,7 +122,7 @@ def test_AccessApiServer():
@handler.subscribe(ListNamespaces)
class test_ListNamespaces:
class test_ListNamespaces(object):
def __init__(self, event):
print("ListNamespaces")
assert event.evidence == ["hello"]
@@ -135,7 +135,7 @@ class test_ListNamespaces:
@handler.subscribe(ListPodsAndNamespaces)
class test_ListPodsAndNamespaces:
class test_ListPodsAndNamespaces(object):
def __init__(self, event):
print("ListPodsAndNamespaces")
assert len(event.evidence) == 2
@@ -158,7 +158,7 @@ class test_ListPodsAndNamespaces:
# Should never see this because the API call in the test returns 403 status code
@handler.subscribe(ListRoles)
class test_ListRoles:
class test_ListRoles(object):
def __init__(self, event):
print("ListRoles")
assert 0
@@ -169,7 +169,7 @@ class test_ListRoles:
# Should only see this when we have a token because the API call returns an empty list of items
# in the test where we have no token
@handler.subscribe(ListClusterRoles)
class test_ListClusterRoles:
class test_ListClusterRoles(object):
def __init__(self, event):
print("ListClusterRoles")
assert event.auth_token == "so-secret"
@@ -178,7 +178,7 @@ class test_ListClusterRoles:
@handler.subscribe(ServerApiAccess)
class test_ServerApiAccess:
class test_ServerApiAccess(object):
def __init__(self, event):
print("ServerApiAccess")
if event.category == UnauthenticatedAccess:
@@ -191,7 +191,7 @@ class test_ServerApiAccess:
@handler.subscribe(ApiServerPassiveHunterFinished)
class test_PassiveHunterFinished:
class test_PassiveHunterFinished(object):
def __init__(self, event):
print("PassiveHunterFinished")
assert event.namespaces == ["hello"]
@@ -276,12 +276,12 @@ def test_AccessApiServerActive():
@handler.subscribe(CreateANamespace)
class test_CreateANamespace:
class test_CreateANamespace(object):
def __init__(self, event):
assert "abcde" in event.evidence
@handler.subscribe(DeleteANamespace)
class test_DeleteANamespace:
class test_DeleteANamespace(object):
def __init__(self, event):
assert "2019-02-26" in event.evidence

View File

@@ -37,6 +37,6 @@ rceJuGsnJEQ=
@handler.subscribe(CertificateEmail)
class test_CertificateEmail:
class test_CertificateEmail(object):
def __init__(self, event):
assert event.email == b"build@nodejs.org0"

View File

@@ -41,7 +41,7 @@ def test_K8sCveHunter():
@handler.subscribe(ServerApiVersionEndPointAccessPE)
class test_CVE_2018_1002105:
class test_CVE_2018_1002105(object):
def __init__(self, event):
global cve_counter
cve_counter += 1

View File

@@ -270,7 +270,7 @@ def test_proveanonymousauth_connectivity_issues():
@handler.subscribe(ExposedExistingPrivilegedContainersViaSecureKubeletPort)
class ExposedPrivilegedContainersViaAnonymousAuthEnabledInSecureKubeletPortEventCounter:
class ExposedPrivilegedContainersViaAnonymousAuthEnabledInSecureKubeletPortEventCounter(object):
def __init__(self, event):
global counter
counter += 1
@@ -371,9 +371,9 @@ def test_attack_exposed_existing_privileged_container_success():
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
file_name = "kube-hunter-mock" + str(uuid.uuid1())
file_name_with_path = f"{directory_created}/etc/cron.daily/{file_name}"
file_name_with_path = "{}/etc/cron.daily/{}".format(directory_created, file_name)
session_mock.post(run_url + urllib.parse.quote(f"touch {file_name_with_path}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("touch {}".format(file_name_with_path), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("chmod {} {}".format("755", file_name_with_path), safe=""), text=""
)
@@ -395,12 +395,12 @@ def test_attack_exposed_existing_privileged_container_failure_when_touch():
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
file_name = "kube-hunter-mock" + str(uuid.uuid1())
file_name_with_path = f"{directory_created}/etc/cron.daily/{file_name}"
file_name_with_path = "{}/etc/cron.daily/{}".format(directory_created, file_name)
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
session_mock.post(
run_url + urllib.parse.quote(f"touch {file_name_with_path}", safe=""),
run_url + urllib.parse.quote("touch {}".format(file_name_with_path), safe=""),
text="Operation not permitted",
)
@@ -420,11 +420,11 @@ def test_attack_exposed_existing_privileged_container_failure_when_chmod():
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
file_name = "kube-hunter-mock" + str(uuid.uuid1())
file_name_with_path = f"{directory_created}/etc/cron.daily/{file_name}"
file_name_with_path = "{}/etc/cron.daily/{}".format(directory_created, file_name)
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
session_mock.post(run_url + urllib.parse.quote(f"touch {file_name_with_path}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("touch {}".format(file_name_with_path), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("chmod {} {}".format("755", file_name_with_path), safe=""),
text="Permission denied",
@@ -547,12 +547,12 @@ def test_process_exposed_existing_privileged_container_success():
session_mock.post(run_url + urllib.parse.quote("cat /proc/cmdline", safe=""), text=cat_proc_cmdline)
session_mock.post(run_url + urllib.parse.quote("findfs LABEL=Mock", safe=""), text="/dev/mock_fs")
session_mock.post(run_url + urllib.parse.quote(f"mkdir {directory_created}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("mkdir {}".format(directory_created), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("mount {} {}".format("/dev/mock_fs", directory_created), safe=""), text=""
)
session_mock.post(
run_url + urllib.parse.quote(f"cat {directory_created}/etc/hostname", safe=""), text="mockhostname"
run_url + urllib.parse.quote("cat {}/etc/hostname".format(directory_created), safe=""), text="mockhostname"
)
return_value = class_being_tested.process_exposed_existing_privileged_container(
@@ -619,7 +619,9 @@ def test_process_exposed_existing_privileged_container_failure_when_mkdir():
session_mock.post(run_url + urllib.parse.quote("cat /proc/cmdline", safe=""), text=cat_proc_cmdline)
session_mock.post(run_url + urllib.parse.quote("findfs LABEL=Mock", safe=""), text="/dev/mock_fs")
session_mock.post(run_url + urllib.parse.quote(f"mkdir {directory_created}", safe=""), text="Permission denied")
session_mock.post(
run_url + urllib.parse.quote("mkdir {}".format(directory_created), safe=""), text="Permission denied"
)
return_value = class_being_tested.process_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
@@ -642,7 +644,7 @@ def test_process_exposed_existing_privileged_container_failure_when_mount():
session_mock.post(run_url + urllib.parse.quote("cat /proc/cmdline", safe=""), text=cat_proc_cmdline)
session_mock.post(run_url + urllib.parse.quote("findfs LABEL=Mock", safe=""), text="/dev/mock_fs")
session_mock.post(run_url + urllib.parse.quote(f"mkdir {directory_created}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("mkdir {}".format(directory_created), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("mount {} {}".format("/dev/mock_fs", directory_created), safe=""),
text="Permission denied",
@@ -669,12 +671,12 @@ def test_process_exposed_existing_privileged_container_failure_when_cat_hostname
session_mock.post(run_url + urllib.parse.quote("cat /proc/cmdline", safe=""), text=cat_proc_cmdline)
session_mock.post(run_url + urllib.parse.quote("findfs LABEL=Mock", safe=""), text="/dev/mock_fs")
session_mock.post(run_url + urllib.parse.quote(f"mkdir {directory_created}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("mkdir {}".format(directory_created), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("mount {} {}".format("/dev/mock_fs", directory_created), safe=""), text=""
)
session_mock.post(
run_url + urllib.parse.quote(f"cat {directory_created}/etc/hostname", safe=""),
run_url + urllib.parse.quote("cat {}/etc/hostname".format(directory_created), safe=""),
text="Permission denied",
)
@@ -697,18 +699,18 @@ def test_maliciousintentviasecurekubeletport_success():
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
file_name = "kube-hunter-mock" + str(uuid.uuid1())
file_name_with_path = f"{directory_created}/etc/cron.daily/{file_name}"
file_name_with_path = "{}/etc/cron.daily/{}".format(directory_created, file_name)
session_mock.post(run_url + urllib.parse.quote("cat /proc/cmdline", safe=""), text=cat_proc_cmdline)
session_mock.post(run_url + urllib.parse.quote("findfs LABEL=Mock", safe=""), text="/dev/mock_fs")
session_mock.post(run_url + urllib.parse.quote(f"mkdir {directory_created}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("mkdir {}".format(directory_created), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("mount {} {}".format("/dev/mock_fs", directory_created), safe=""), text=""
)
session_mock.post(
run_url + urllib.parse.quote(f"cat {directory_created}/etc/hostname", safe=""), text="mockhostname"
run_url + urllib.parse.quote("cat {}/etc/hostname".format(directory_created), safe=""), text="mockhostname"
)
session_mock.post(run_url + urllib.parse.quote(f"touch {file_name_with_path}", safe=""), text="")
session_mock.post(run_url + urllib.parse.quote("touch {}".format(file_name_with_path), safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("chmod {} {}".format("755", file_name_with_path), safe=""), text=""
)