├── config └── config.yaml ├── detect ├── __init__.py ├── __about__.py ├── cli.py └── cloudtrail.py ├── docs ├── us-18-Bengtson-Detecting-Credential-Compromise-In-AWS.pdf └── us-18-Bengtson-Detecting-Credential-Compromise-In-AWS-wp.pdf ├── setup.py ├── .gitignore └── README.md /config/config.yaml: -------------------------------------------------------------------------------- 1 | whitelist_ips: 2 | - IP_ADDRESS 3 | -------------------------------------------------------------------------------- /detect/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | log = logging.getLogger('detect') 5 | log.setLevel(os.environ.get('DETECT_LOG_LEVEL', 'DEBUG')) 6 | -------------------------------------------------------------------------------- /docs/us-18-Bengtson-Detecting-Credential-Compromise-In-AWS.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix-Skunkworks/aws-credential-compromise-detection/master/docs/us-18-Bengtson-Detecting-Credential-Compromise-In-AWS.pdf -------------------------------------------------------------------------------- /docs/us-18-Bengtson-Detecting-Credential-Compromise-In-AWS-wp.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Netflix-Skunkworks/aws-credential-compromise-detection/master/docs/us-18-Bengtson-Detecting-Credential-Compromise-In-AWS-wp.pdf -------------------------------------------------------------------------------- /detect/__about__.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import, division, print_function 2 | 3 | __all__ = [ 4 | '__title__', '__summary__', '__uri__', '__version__', '__author__', 5 | '__email__', '__license__', '__copyright__', 6 | ] 7 | 8 | __title__ = 'detect' 9 | __summary__ = ('AWS Credential Compromise Detection Proof of Concept') 10 | __uri__ = 'https://github.com/Netflix-Skunkworks/aws-credential-compromise-detection' 11 | 12 | __version__ = '0.1.0' 13 | 14 | __author__ = 'The developers' 15 | __email__ = 'oss@netflix.com' 16 | 17 | __license__ = 'Apache License, Version 2.0' 18 | __copyright__ = 'Copyright 2018 {0}'.format(__author__) 19 | \ -------------------------------------------------------------------------------- /detect/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import yaml 4 | 5 | import click 6 | import click_log 7 | from click.exceptions import UsageError 8 | 9 | from detect import log 10 | from detect.__about__ import __version__ 11 | from detect.cloudtrail import detect_off_instance_cloudtrail 12 | 13 | 14 | click_log.basic_config(log) 15 | 16 | class YAML(click.ParamType): 17 | name = 'yaml' 18 | 19 | def convert(self, value, param, ctx): 20 | try: 21 | with open(value, 'rb') as f: 22 | return yaml.safe_load(f.read()) 23 | except (IOError, OSError) as e: 24 | self.fail('Could not open file: {0}'.format(value)) 25 | 26 | 27 | @click.command() 28 | @click_log.simple_verbosity_option(log) 29 | @click.option('--config', type=YAML(), help='Configuration file to use.') 30 | @click.option('--directory', type=str, help='Path to directory with CloudTrail files', required=True) 31 | @click.version_option(version=__version__) 32 | def cli(config, directory): 33 | """Detect off instance key usage""" 34 | log.info('Detecting AWS Key Usage off instance...') 35 | 36 | if not os.path.exists(directory): 37 | log.fatal('Invalid Directory Path') 38 | 39 | files = [] 40 | for cloudtrail_file in os.listdir(directory): 41 | files.append(os.path.join(directory, cloudtrail_file)) 42 | 43 | if not config: 44 | config = {} 45 | 46 | api_calls_recorded = detect_off_instance_cloudtrail(config, files) 47 | 48 | 49 | if __name__ == '__main__': 50 | try: 51 | cli() 52 | except KeyboardInterrupt: 53 | logging.debug("Exiting due to KeyboardInterrupt...") 54 | 55 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Netflix 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | import re 15 | import ast 16 | import os.path 17 | import sys 18 | from setuptools import setup, find_packages 19 | 20 | _version_re = re.compile(r'__version__\s+=\s+(.*)') 21 | with open('detect/__about__.py', 'rb') as f: 22 | DETECT_VERSION = str(ast.literal_eval(_version_re.search( 23 | f.read().decode('utf-8')).group(1))) 24 | 25 | ROOT = os.path.realpath(os.path.join(os.path.dirname(__file__))) 26 | 27 | # When executing the setup.py, we need to be able to import ourselves. This 28 | # means that we need to add the src/ directory to the sys.path 29 | 30 | sys.path.insert(0, ROOT) 31 | 32 | install_requirements = [ 33 | 'boto3>=1.5.34', 34 | 'click==6.7', 35 | 'click-log==0.2.1', 36 | 'PyYAML==3.12' 37 | ] 38 | 39 | setup( 40 | name='detect', 41 | version=DETECT_VERSION, 42 | long_description="AWS Credential Compromise Detection Proof of Concept", 43 | packages=find_packages(), 44 | install_requires=install_requirements, 45 | entry_points={ 46 | 'console_scripts': [ 47 | 'detect = detect.cli:cli', 48 | ], 49 | } 50 | ) -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | .DS_Store 7 | 8 | # C extensions 9 | *.so 10 | 11 | # Distribution / packaging 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .coverage 43 | .coverage.* 44 | .cache 45 | nosetests.xml 46 | coverage.xml 47 | *.cover 48 | .hypothesis/ 49 | 50 | # Translations 51 | *.mo 52 | *.pot 53 | 54 | # Django stuff: 55 | *.log 56 | local_settings.py 57 | 58 | # Flask stuff: 59 | instance/ 60 | .webassets-cache 61 | 62 | # Scrapy stuff: 63 | .scrapy 64 | 65 | # Sphinx documentation 66 | docs/_build/ 67 | 68 | # PyBuilder 69 | target/ 70 | 71 | # Jupyter Notebook 72 | .ipynb_checkpoints 73 | 74 | # pyenv 75 | .python-version 76 | 77 | # celery beat schedule file 78 | celerybeat-schedule 79 | 80 | # SageMath parsed files 81 | *.sage.py 82 | 83 | # Environments 84 | .env 85 | .venv 86 | env/ 87 | venv/ 88 | ENV/ 89 | 90 | # Spyder project settings 91 | .spyderproject 92 | .spyproject 93 | 94 | # Rope project settings 95 | .ropeproject 96 | 97 | # mkdocs documentation 98 | /site 99 | 100 | # mypy 101 | .mypy_cache/ 102 | 103 | # jetbrains 104 | .idea/ 105 | 106 | 107 | cloudtrail/ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Detecting Credential Compromise in AWS 2 | 3 | This following code is an example implementation of the method described [here](docs/us-18-Bengtson-Detecting-Credential-Compromise-In-AWS-wp.pdf) 4 | 5 | ## Getting Started 6 | 7 | To get started, clone the repository and `pip` install the package 8 | 9 | `pip install .` 10 | 11 | ## Running the program 12 | 13 | To understand what commands exist, run: 14 | 15 | `detect --help` 16 | 17 | ```detect --help 18 | Usage: detect [OPTIONS] 19 | 20 | Detect off instance key usage 21 | 22 | Options: 23 | -v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG 24 | --config YAML Configuration file to use. 25 | --directory TEXT Path to directory with CloudTrail files [required] 26 | --version Show the version and exit. 27 | --help Show this message and exit. 28 | ``` 29 | 30 | Copy your CloudTrail to a local directory. All files must be in the same folder. 31 | 32 | To run the code over your local CloudTrail files, run the following command: 33 | 34 | `detect --verbosity INFO --directory ` 35 | 36 | You should see something like the following output: 37 | 38 | ```detect --verbosity INFO --directory /tmp/cloudtrail/ 39 | Detecting AWS Key Usage off instance... 40 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0000Z_1gye90eoWO1b1QRG.json.gz 41 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0005Z_LNYW3Mic2zLWETkX.json.gz 42 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0010Z_7V7xcXO6UzW77LwK.json.gz 43 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0015Z_LAJ1Yb1bNyYSWXXA.json.gz 44 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0020Z_t9rx7kgzBtItJhMy.json.gz 45 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0025Z_M0HzhcOov89xY6w3.json.gz 46 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0030Z_CBWEoVc6o54WtOg0.json.gz 47 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0035Z_ksL7pEasuX6bWPHX.json.gz 48 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0040Z_LwJdh1z4HGTH0XJH.json.gz 49 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0045Z_UWCcHKGZO8tndQxi.json.gz 50 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0050Z_bKEN9jPfv0zTVph0.json.gz 51 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0055Z_zj6ZG2zOPpCXKzJX.json.gz 52 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0100Z_UiWFT9ORqfYtdppO.json.gz 53 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T0105Z_mhO8z0wHjDupnp6Y.json.gz 54 | ....... 55 | Compromised Credential: arn:aws:sts::123456789123:assumed-role/testRole1/i-asdf1234adsf1234a - Source IP: 67.178.52.232 56 | Compromised Credential: arn:aws:sts::123456789123:assumed-role/testRole1/i-asdf1234adsf1234a - Source IP: 67.178.52.232 57 | Compromised Credential: arn:aws:sts::123456789123:assumed-role/testRole1/i-asdf1234adsf1234a - Source IP: 67.178.52.232 58 | Compromised Credential: arn:aws:sts::123456789123:assumed-role/testRole1/i-asdf1234adsf1234a - Source IP: 67.178.52.232 59 | ........ 60 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2130Z_OR96it0GfXSDfECJ.json.gz 61 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2135Z_FBudvwUxhu9dv1yh.json.gz 62 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2140Z_w9fFoLIdlCXwnpgc.json.gz 63 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2145Z_achBqdC1o6d6wnQG.json.gz 64 | Potential for a new IP to be seen: arn:aws:sts::123456789123:assumed-role/testRole2/i-1234asdf1224asdf1 65 | ........ 66 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2340Z_GqdLsMcsTkRRxWev.json.gz 67 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2345Z_Ln5pCyldci0nn07X.json.gz 68 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2350Z_hW7tWtYiwbbZdSqd.json.gz 69 | Processing file: /tmp/cloudtrail/123456789123_CloudTrail_us-west-2_20180404T2355Z_q5nS1nqvbGwBN0yT.json.gz 70 | ``` 71 | -------------------------------------------------------------------------------- /detect/cloudtrail.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import gzip 3 | import ipaddress 4 | import json 5 | import time 6 | 7 | from detect import log 8 | 9 | 10 | def ip_in_cidr(ip, cidr): 11 | """Check to see if the provided IP address is in the provided CIDR block""" 12 | return ipaddress.ip_address(ip) in ipaddress.ip_network(cidr) 13 | 14 | 15 | def ip_in_whitelist(whitelist, ip): 16 | """Check the whitelist where we allow calls to come from""" 17 | for cidr in whitelist: 18 | if ip_in_cidr(ip, cidr): 19 | return True 20 | 21 | return False 22 | 23 | 24 | def is_ip_private(ip): 25 | """Check the whitelist where we allow calls to come from""" 26 | private = ['100.64.0.0/10', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16'] 27 | 28 | for cidr in private: 29 | if ip_in_cidr(ip, cidr): 30 | return True 31 | 32 | return False 33 | 34 | 35 | def detect_off_instance_cloudtrail(config, files): 36 | """Detect off use of credential off instance""" 37 | bad_calls = [] 38 | api_calls = {} 39 | associate_ips = [] 40 | 41 | for file in sorted(files): 42 | f = None 43 | log.info('Processing file: {}'.format(file)) 44 | 45 | if file.endswith('.gz'): 46 | f = gzip.open(file, 'r') 47 | else: 48 | f = open(file, 'r') 49 | 50 | try: 51 | cloudtrail = json.load(f) 52 | except Exception as e: 53 | log.error('Invalid JSON File: {} - {}'.format(file, e)) 54 | continue 55 | 56 | records = sorted(cloudtrail['Records'], key=lambda x: datetime.strptime(x['eventTime'], '%Y-%m-%dT%H:%M:%SZ'), reverse=False) 57 | 58 | for record in records: 59 | 60 | try: 61 | if record['eventName'].lower() == 'assumerole' and record['sourceIPAddress'] == 'ec2.amazonaws.com': 62 | 63 | session_name = record['requestParameters']['roleSessionName'] 64 | arn = record['requestParameters']['roleArn'] 65 | account = record['requestParameters']['roleArn'].split(':')[4] 66 | role = record['requestParameters']['roleArn'].split('/')[-1] 67 | 68 | assume_role_session = 'arn:aws:sts::{}:assumed-role/{}/{}'.format(account, role, session_name) 69 | 70 | if not api_calls.get(session_name, None): 71 | api_calls[session_name] = { 72 | 'source_ip': [], 73 | 'arn': assume_role_session, 74 | 'ttl': int(time.time() + 28800) 75 | } 76 | else: 77 | # Set a TTL. This is most useful in DynamoDB 78 | api_calls[session_name]['ttl'] = int(time.time() + 28800) 79 | 80 | if record['userIdentity'].get('type', '') == 'AssumedRole': 81 | session = record['userIdentity']['arn'].split('/')[-1] 82 | if api_calls.get(session, None): 83 | # Check to see if this is a call that would attach a new ENI or IP to the instance 84 | if (record['eventName'].lower() == 'attachnetworkinterface' or record['eventName'].lower() == 'associateaddress') and not record.get('errorMessage', None): 85 | log.info('Potential for a new IP to be seen: {}'.format(record['userIdentity']['arn'])) 86 | 87 | if record['requestParameters']['instanceId'] == session: 88 | associate_ips.append(session) 89 | 90 | # Check to see if the IP is in the whitelist first before we decide to process anything 91 | # if it is, then we can ignore the call 92 | if 'amazonaws' not in record['sourceIPAddress'] and not ip_in_whitelist(config.get('whitelist_ips', []), record['sourceIPAddress']): 93 | if len(api_calls[session].get('source_ip', [])) == 0: 94 | # This is the first call that we've seen since the assume role 95 | # First IP, let's add it to the list, we don't care if it's private 96 | # or public at this point 97 | api_calls[session]['source_ip'].append(record['sourceIPAddress']) 98 | else: 99 | if record['sourceIPAddress'] not in api_calls[session].get('source_ip', []): 100 | # This IP is not in the current lock IP list 101 | # Check to see if this is a private IP 102 | if is_ip_private(record['sourceIPAddress']): 103 | # First check to see if there is already another private IP. We should 104 | # not have this ever 105 | for ip in api_calls[session].get('source_ip', []): 106 | if is_ip_private(ip): 107 | # Uh oh, another private IP, this shouldn't happen 108 | log.info('Compromised Credential: {} - Source IP: {}'.format(assume_role_session, record['sourceIPAddress'])) 109 | log.debug(record) 110 | bad_calls.append(record) 111 | # This is the private IP for the instance communicating over a VPC endpoint 112 | api_calls[session]['source_ip'].append(record['sourceIPAddress']) 113 | continue 114 | # Check to see if we there was an API call to change the instance IP 115 | if session not in associate_ips: 116 | # Uh oh, alert! 117 | log.info('Compromised Credential: {} - Source IP: {}'.format(assume_role_session, record['sourceIPAddress'])) 118 | log.debug(record) 119 | bad_calls.append(record) 120 | else: 121 | # We saw a new IP, but we expected this so removing the session from the allowed 122 | # change table 123 | log.debug('Removing allowed IP change for {}'.format(session)) 124 | api_calls[session]['source_ip'].append(record['sourceIPAddress']) 125 | associate_ips.remove(session) 126 | except Exception as e: 127 | log.fatal('Unknown error on record - {}'.format(record)) 128 | log.fatal('Error - {}'.format(e)) 129 | 130 | # Close file object 131 | f.close() 132 | 133 | return bad_calls 134 | --------------------------------------------------------------------------------