From 5745f4a32bad14cdfc20d4bc49215591957d156c Mon Sep 17 00:00:00 2001 From: Tommy McCormick <16668493+jan0ski@users.noreply.github.com> Date: Wed, 21 Apr 2021 13:57:17 -0400 Subject: [PATCH] Add discovery for AWS metadata (#447) --- README.md | 2 +- docs/_kb/KHV053.md | 24 +++++ kube_hunter/core/types.py | 6 ++ kube_hunter/modules/discovery/hosts.py | 112 +++++++++++++++++++- tests/discovery/test_hosts.py | 138 +++++++++++++++++++++++-- 5 files changed, 269 insertions(+), 13 deletions(-) create mode 100644 docs/_kb/KHV053.md diff --git a/README.md b/README.md index 50afd70..d735e8f 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Available dispatch methods are: ### Advanced Usage #### Azure Quick Scanning -When running **as a Pod in an Azure environment**, kube-hunter will fetch subnets from the Instance Metadata Service. Naturally this makes the discovery process take longer. +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 diff --git a/docs/_kb/KHV053.md b/docs/_kb/KHV053.md new file mode 100644 index 0000000..37d2ab9 --- /dev/null +++ b/docs/_kb/KHV053.md @@ -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) \ No newline at end of file diff --git a/kube_hunter/core/types.py b/kube_hunter/core/types.py index 2a7df58..c4612e9 100644 --- a/kube_hunter/core/types.py +++ b/kube_hunter/core/types.py @@ -50,6 +50,12 @@ class Kubelet(KubernetesCluster): name = "Kubelet" +class AWS(KubernetesCluster): + """AWS Cluster""" + + name = "AWS" + + class Azure(KubernetesCluster): """Azure Cluster""" diff --git a/kube_hunter/modules/discovery/hosts.py b/kube_hunter/modules/discovery/hosts.py index 5302aa6..559967a 100644 --- a/kube_hunter/modules/discovery/hosts.py +++ b/kube_hunter/modules/discovery/hosts.py @@ -10,7 +10,7 @@ from netifaces import AF_INET, ifaddresses, interfaces, gateways from kube_hunter.conf import get_config from kube_hunter.core.events import handler from kube_hunter.core.events.types import Event, NewHostEvent, Vulnerability -from kube_hunter.core.types import Discovery, InformationDisclosure, Azure +from kube_hunter.core.types import Discovery, InformationDisclosure, AWS, Azure logger = logging.getLogger(__name__) @@ -40,6 +40,21 @@ class RunningAsPodEvent(Event): 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=InformationDisclosure, + 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""" @@ -107,6 +122,10 @@ class FromPodHostDiscovery(Discovery): cloud = None if self.is_azure_pod(): subnets, cloud = self.azure_metadata_discovery() + elif self.is_aws_pod_v1(): + subnets, cloud = self.aws_metadata_v1_discovery() + elif self.is_aws_pod_v2(): + subnets, cloud = self.aws_metadata_v2_discovery() else: subnets = self.gateway_discovery() @@ -122,6 +141,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: @@ -144,6 +203,57 @@ class FromPodHostDiscovery(Discovery): """ 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() + 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 + 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.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 cidr, "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("/") + + 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 cidr, "AWS" + # querying azure's interface metadata api | works only from a pod def azure_metadata_discovery(self): config = get_config() diff --git a/tests/discovery/test_hosts.py b/tests/discovery/test_hosts.py index 2c49dec..c9ce2a2 100644 --- a/tests/discovery/test_hosts.py +++ b/tests/discovery/test_hosts.py @@ -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())