├── .gitignore ├── README.md ├── config └── config.yaml ├── images └── attack_simulation_ct.png ├── setup.py └── trailblazer ├── __about__.py ├── __init__.py ├── attack.py ├── boto ├── __init__.py ├── service.py ├── sts.py └── util.py ├── cli.py ├── cloudtrail.py └── enumerate.py /.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 | config/test-config.yaml -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AWS TrailBlazer 2 | 3 | TrailBlazer is a tool written to determine what AWS API calls are logged by CloudTrail and what they are logged as. You can also use TrailBlazer as an attack simulation framework. 4 | 5 | ## How does it work? 6 | 7 | TrailBlazer uses the python AWS SDK library called `boto3` in order to make the API calls into AWS. It enumerates the services provided in the SDK, regions the services are available, and then determines what API calls there are for the given service by exploring the function set. In order to enumerate the entire AWS SDK, TrailBlazer bypasses the `boto3` client-side validation to make mostly improper requests into AWS. Mostly is the keyword here due to the fact that if an API call does not require a parameter, the API call sent by TrailBlazer will be 100% valid. Due to the way AWS logs, these requests will be logged as `Invalid Parameters` or `Unauthorized` due to the inconsistency in CloudTrail logging. 8 | 9 | ## Getting Started 10 | 11 | Install TrailBlazer: 12 | 13 | `pip install trailblazer-aws` 14 | 15 | ### Setup AWS Permissions 16 | 17 | Create an AWS Role called `trailblazer` 18 | 19 | Create an inline policy with the following JSON object 20 | ```json 21 | { 22 | "Version": "2012-10-17", 23 | "Statement": [ 24 | { 25 | "Effect": "Allow", 26 | "Action": "sts:AssumeRole", 27 | "Resource": "arn:aws:iam:::role/trailblazer" 28 | }, 29 | { 30 | "Effect": "Deny", 31 | "Action": "*", 32 | "Resource": "*" 33 | } 34 | ] 35 | } 36 | ``` 37 | 38 | Setup a trust relationship so that you it `AssumeRole` to itself: 39 | ```json 40 | { 41 | "Version": "2012-10-17", 42 | "Statement": [ 43 | { 44 | "Effect": "Allow", 45 | "Principal": { 46 | "AWS": "arn:aws:iam:::role/trailblazer" 47 | }, 48 | "Action": "sts:AssumeRole", 49 | "Condition": {} 50 | } 51 | ] 52 | } 53 | ``` 54 | 55 | ### Clone the AWS Botocore Github project 56 | 57 | TrailBlazer uses the JSON files found under the `botocore` project `data` directory to determine if a parameter is necessary for the `API` call. 58 | 59 | ```cd /tmp 60 | git clone git@github.com:boto/botocore.git 61 | ``` 62 | 63 | ## Command Line Options 64 | 65 | You can determine what options are available by issuing the following command: 66 | 67 | ``` 68 | trailblazer --help 69 | Usage: trailblazer [OPTIONS] COMMAND [ARGS]... 70 | 71 | Options: 72 | -v, --verbosity LVL Either CRITICAL, ERROR, WARNING, INFO or DEBUG 73 | --config YAML Configuration file to use. 74 | --dry-run Run command without persisting anything. 75 | --version Show the version and exit. 76 | --help Show this message and exit. 77 | 78 | Commands: 79 | cloudtrail 80 | enumerate 81 | simulate 82 | ``` 83 | 84 | ## Enumerate API calls for a given service 85 | 86 | The following example will enumerate all calls for the `EC2` service: 87 | 88 | `trailblazer --verbosity INFO --config enumerate cloudtrail_calls --services ec2` 89 | 90 | You will see output similar to the following: 91 | 92 | ```trailblazer --verbosity INFO --config config/test-config.yaml enumerate cloudtrail_calls --services ec2 93 | Starting enumeration for CloudTrail... 94 | Creating ec2 client... 95 | Calling ec2.accept_reserved_instances_exchange_quote with params {} in us-west-2 96 | Calling ec2.accept_vpc_endpoint_connections with params {} in us-west-2 97 | Calling ec2.accept_vpc_peering_connection with params {} in us-west-2 98 | Calling ec2.allocate_address with params {} in us-west-2 99 | Calling ec2.allocate_hosts with params {} in us-west-2 100 | Calling ec2.assign_ipv6_addresses with params {} in us-west-2 101 | Calling ec2.assign_private_ip_addresses with params {} in us-west-2 102 | Calling ec2.associate_address with params {} in us-west-2 103 | Calling ec2.associate_dhcp_options with params {} in us-west-2 104 | Calling ec2.associate_iam_instance_profile with params {} in us-west-2 105 | Calling ec2.associate_route_table with params {} in us-west-2 106 | Calling ec2.associate_subnet_cidr_block with params {} in us-west-2 107 | Calling ec2.associate_vpc_cidr_block with params {} in us-west-2 108 | Calling ec2.attach_classic_link_vpc with params {} in us-west-2 109 | Calling ec2.attach_internet_gateway with params {} in us-west-2 110 | Calling ec2.attach_network_interface with params {} in us-west-2 111 | Calling ec2.attach_volume with params {} in us-west-2 112 | Calling ec2.attach_vpn_gateway with params {} in us-west-2 113 | Calling ec2.authorize_security_group_egress with params {} in us-west-2 114 | Calling ec2.authorize_security_group_ingress with params {} in us-west-2 115 | Calling ec2.bundle_instance with params {} in us-west-2 116 | Calling ec2.cancel_bundle_task with params {} in us-west-2 117 | Calling ec2.cancel_conversion_task with params {} in us-west-2 118 | Calling ec2.cancel_export_task with params {} in us-west-2 119 | Calling ec2.cancel_import_task with params {} in us-west-2 120 | Calling ec2.cancel_reserved_instances_listing with params {} in us-west-2 121 | Calling ec2.cancel_spot_fleet_requests with params {} in us-west-2 122 | Calling ec2.cancel_spot_instance_requests with params {} in us-west-2 123 | ... 124 | ``` 125 | 126 | ## Determine what was logged in CloudTrail 127 | 128 | `trailblazer --verbosity INFO --config config/test-config.yaml cloudtrail process --directory /tmp/cloudtrail --arn arn:aws:sts::123456789123:assumed-role/trailblazer` 129 | 130 | You should see an output similar to below: 131 | 132 | ```Processing CloudTrail... 133 | EventSource, EventName, Recorded Name, Match 134 | ec2, AuthorizeSecurityGroupEgress, authorizesecuritygroupegress, True 135 | ec2, AssociateIamInstanceProfile, associateiaminstanceprofile, True 136 | ec2, AssignIpv6Addresses, assignipv6addresses, True 137 | ec2, AcceptVpcPeeringConnection, acceptvpcpeeringconnection, True 138 | ec2, CreateFpgaImage, createfpgaimage, True 139 | ec2, AttachClassicLinkVpc, attachclassiclinkvpc, True 140 | ec2, AssociateDhcpOptions, associatedhcpoptions, True 141 | ec2, CancelExportTask, cancelexporttask, True 142 | ec2, CreateEgressOnlyInternetGateway, createegressonlyinternetgateway, True 143 | ec2, CancelBundleTask, cancelbundletask, True 144 | ec2, CancelSpotInstanceRequests, cancelspotinstancerequests, True 145 | ec2, AttachNetworkInterface, attachnetworkinterface, True 146 | ec2, CreateCustomerGateway, createcustomergateway, True 147 | ec2, CreateDefaultSubnet, createdefaultsubnet, True 148 | ec2, CancelReservedInstancesListing, cancelreservedinstanceslisting, True 149 | ec2, CopySnapshot, copysnapshot, True 150 | ec2, AttachVolume, attachvolume, True 151 | ec2, CancelSpotFleetRequests, cancelspotfleetrequests, True 152 | ec2, BundleInstance, bundleinstance, True 153 | ec2, CopyFpgaImage, copyfpgaimage, True 154 | ec2, AcceptReservedInstancesExchangeQuote, acceptreservedinstancesexchangequote, True 155 | ec2, CancelConversionTask, cancelconversiontask, True 156 | ec2, AssociateAddress, associateaddress, True 157 | ec2, CreateDhcpOptions, createdhcpoptions, True 158 | ec2, AssociateRouteTable, associateroutetable, True 159 | ec2, AssociateSubnetCidrBlock, associatesubnetcidrblock, True 160 | ec2, AuthorizeSecurityGroupIngress, authorizesecuritygroupingress, True 161 | ec2, AcceptVpcEndpointConnections, acceptvpcendpointconnections, True 162 | ec2, ConfirmProductInstance, confirmproductinstance, True 163 | ec2, AllocateAddress, allocateaddress, True 164 | ec2, AttachInternetGateway, attachinternetgateway, True 165 | ec2, AllocateHosts, allocatehosts, True 166 | ec2, AssociateVpcCidrBlock, associatevpccidrblock, True 167 | ec2, AttachVpnGateway, attachvpngateway, True 168 | ec2, CreateDefaultVpc, createdefaultvpc, True 169 | ec2, AssignPrivateIpAddresses, assignprivateipaddresses, True 170 | ec2, CopyImage, copyimage, True 171 | ... 172 | ``` 173 | 174 | 175 | # Using TrailBlazer for Attack Simulation 176 | 177 | TrailBlazer can be used to model attacks in your environment to aid in testing monitoring. Due to the way that TrailBlazer makes calls, you can safely use TrailBlazer in your production environment to model an attacker in your environment. 178 | 179 | You can use the attack simulation mode in two ways: 180 | 181 | * CLI 182 | * Library 183 | 184 | To use the CLI, issue the following command: 185 | 186 | `trailblazer --verbosity INFO --config simulate attack` 187 | 188 | The `config.yaml` file provided under `config/` shows an example attach chain: 189 | 190 | ```yaml 191 | attack_chain: 192 | - call: sts.get_caller_identity 193 | time_delay: 2 194 | - call: cloudtrail.describe_trails 195 | time_delay: 1 196 | - call: s3.list_buckets 197 | time_delay: 3 198 | - call: ec2.describe_instances 199 | time_delay: 3 200 | ``` 201 | 202 | This attack chain would call `sts.get_caller_identity`, wait 2 seconds, call `cloudtrail.describe_trails`, wait 1 second, and conitnue making the calls until finished. The `call` is defined by the `boto3` function names. More on `boto3` can be found [here](https://boto3.readthedocs.io/en/latest/). 203 | 204 | When you want to run tests in your environment to make sure you are able to detect certain API calls or chains of API calls, you can use TrailBlazer into your own automation framework. 205 | 206 | To use TrailBlazer as a library, you can do something like: 207 | 208 | ```python 209 | from trailblazer.attack import simulate_attack 210 | 211 | config = { 212 | 'botocore_document_json_path': '/tmp/botocore/botocore/data' 213 | } 214 | 215 | commands = [ 216 | { 217 | 'call': 'sts.get_caller_identity', 218 | 'time_delay': 1 219 | }, 220 | { 221 | 'call': 'cloudtrail.describe_trails', 222 | 'time_delay': 1 223 | } 224 | ] 225 | 226 | simulate_attack(config, commands) 227 | ``` 228 | 229 | You should see these calls then show up in your CloudTrail 230 | 231 | ![Attack Simulate CloudTrail](/images/attack_simulation_ct.png "Attack Simulate CloudTrail") 232 | 233 | 234 | -------------------------------------------------------------------------------- /config/config.yaml: -------------------------------------------------------------------------------- 1 | log_level: DEBUG 2 | 3 | botocore_document_json_path: PATH_TO_BOTOCORE_DATA_DIR 4 | 5 | account_number: ACCOUNT_NUMBER 6 | account_role: trailblazer 7 | 8 | attack_chain: 9 | - call: sts.get_caller_identity 10 | time_delay: 2 11 | - call: cloudtrail.describe_trails 12 | time_delay: 1 13 | - call: s3.list_buckets 14 | time_delay: 3 15 | - call: ec2.describe_instances 16 | time_delay: 3 17 | -------------------------------------------------------------------------------- /images/attack_simulation_ct.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willbengtson/trailblazer-aws/bc264a71587d1ebc86cd4b8eaa92541546920a73/images/attack_simulation_ct.png -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Will Bengtson 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('trailblazer/__about__.py', 'rb') as f: 22 | TRAILBLAZER_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='trailblazer-aws', 41 | version=TRAILBLAZER_VERSION, 42 | long_description="CloudTrail enumeration and AWS attack platform", 43 | packages=find_packages(), 44 | install_requires=install_requirements, 45 | entry_points={ 46 | 'console_scripts': [ 47 | 'trailblazer = trailblazer.cli:cli', 48 | ], 49 | } 50 | ) -------------------------------------------------------------------------------- /trailblazer/__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__ = 'trailblazer-aws' 9 | __summary__ = ('Blazing Cloudtrail since 2018') 10 | __uri__ = 'https://github.com/willbengtson/trailblazer-aws' 11 | 12 | __version__ = '1.0.1' 13 | 14 | __author__ = 'Will Bengtson' 15 | __email__ = 'william.bengtson@gmail.com' 16 | 17 | __license__ = 'Apache License, Version 2.0' 18 | __copyright__ = 'Copyright 2018 {0}'.format(__author__) 19 | \ -------------------------------------------------------------------------------- /trailblazer/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import logging 3 | 4 | log = logging.getLogger('trailblazer') 5 | log.setLevel(os.environ.get('TRAILBLAZER_LOG_LEVEL', 'DEBUG')) 6 | -------------------------------------------------------------------------------- /trailblazer/attack.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import time 4 | 5 | import boto3 6 | 7 | from trailblazer import log 8 | from trailblazer.boto.service import get_boto_functions, get_service_json_files, get_service_call_params, make_api_call 9 | from trailblazer.boto.util import botocore_config 10 | 11 | 12 | def make_call(config, session, service, command, region=None): 13 | # Grab a region to use for the calls. This should be us-west-2 14 | if not region: 15 | region = session.get_available_regions(service)[-1] 16 | 17 | if config.get('user_agent', None): 18 | botocore_config.user_agent = config['user_agent'] 19 | 20 | # Create a client with parameter validation off 21 | client = session.client(service, region_name=region, config=botocore_config) 22 | 23 | # Get the functions that you can call 24 | functions_list = get_boto_functions(client) 25 | 26 | # Get the service file 27 | service_file_json = get_service_json_files(config) 28 | 29 | # Get a list of params needed to make the serialization pass in botocore 30 | service_call_params = get_service_call_params(service_file_json[service]) 31 | 32 | 33 | for function in functions_list: 34 | if function[0] == command: 35 | 36 | # The service_file_json doesn't have underscores in names so let's remove them 37 | function_key = function[0].replace('_','') 38 | 39 | # We need to pull out the parameters needed in the requestUri, ex. /{Bucket}/{Key+} -> ['Bucket', 'Key'] 40 | params = re.findall('\{(.*?)\}', service_call_params.get(function_key, '/')) 41 | params = [p.strip('+') for p in params] 42 | 43 | func_params = {} 44 | 45 | for param in params: 46 | func_params[param] = 'testparameter' 47 | 48 | try: 49 | make_api_call(service, function, region, func_params) 50 | except ClientError as e: 51 | log.debug(e) 52 | except boto3.exceptions.S3UploadFailedError as e: 53 | log.debug(e) 54 | except TypeError as e: 55 | log.debug(e) 56 | except KeyError as e: 57 | log.debug('Unknown Exception: {}.{} - {}'.format(service, function[0], e)) 58 | 59 | return 60 | 61 | 62 | def simulate_attack(config, commands, dry_run=False): 63 | log.debug('Attack chain to be executed:') 64 | log.debug(json.dumps(commands, indent=4)) 65 | 66 | session = boto3.Session() 67 | 68 | for command in commands: 69 | service_event = command['call'].split('.') 70 | 71 | service = service_event[0] 72 | api_call = service_event[1] 73 | 74 | delay = command.get('time_delay', 0) 75 | region = command.get('region', None) 76 | 77 | log.info('Making call - {}.{}'.format(service, api_call)) 78 | if not dry_run: 79 | make_call(config, session, service, api_call, region) 80 | log.info('Sleeping {} until next call'.format(delay)) 81 | if not dry_run: 82 | time.sleep(delay) 83 | 84 | -------------------------------------------------------------------------------- /trailblazer/boto/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/willbengtson/trailblazer-aws/bc264a71587d1ebc86cd4b8eaa92541546920a73/trailblazer/boto/__init__.py -------------------------------------------------------------------------------- /trailblazer/boto/service.py: -------------------------------------------------------------------------------- 1 | from inspect import getmembers, isfunction, ismethod 2 | import json 3 | import os 4 | import re 5 | import time 6 | 7 | import boto3 8 | import botocore 9 | from botocore.exceptions import ClientError 10 | 11 | from trailblazer import log 12 | from trailblazer.boto.util import botocore_config 13 | 14 | 15 | def get_service_json_files(config): 16 | service_file = {} 17 | 18 | root_dir = config['botocore_document_json_path'] 19 | 20 | for service_dir in os.listdir(root_dir): 21 | if os.path.isdir(os.path.join(root_dir,service_dir)): 22 | date_dirs = os.listdir(os.path.join(root_dir,service_dir)) 23 | date_dir = None 24 | if len(date_dirs) > 1: 25 | date_dir = date_dirs[-1] 26 | else: 27 | date_dir = date_dirs[0] 28 | if os.path.exists(os.path.join(root_dir, service_dir, date_dir, 'service-2.json')): 29 | service_file[service_dir] = os.path.join(root_dir, service_dir, date_dir, 'service-2.json') 30 | else: 31 | service_file[service_dir] = None 32 | 33 | return service_file 34 | 35 | def get_service_call_params(service_json_file): 36 | 37 | services = {} 38 | 39 | json_data = json.load(open(service_json_file)) 40 | 41 | for key, value in json_data.get('operations', {}).items(): 42 | services[key.lower()] = value.get('http', {}).get('requestUri', '/') 43 | 44 | return services 45 | 46 | 47 | def get_service_call_mutation(service_json_file): 48 | 49 | services = {} 50 | 51 | json_data = json.load(open(service_json_file)) 52 | 53 | for key, value in json_data.get('operations', {}).items(): 54 | 55 | method = value.get('http', {}).get('method', 'UNKOWN') 56 | 57 | if method == 'GET': 58 | services[key.lower()] = 'nonmutating' 59 | else: 60 | services[key.lower()] = 'mutating' 61 | 62 | return services 63 | 64 | 65 | def get_boto_functions(client): 66 | """Loop through the client member functions and pull out what we can actually call""" 67 | functions_list = [o for o in getmembers(client) if ( ( isfunction(o[1]) or ismethod(o[1]) ) 68 | and not o[0].startswith('_') and not o[0] == 'can_paginate' and not o[0] == 'get_paginator' 69 | and not o[0] == 'get_waiter' and 'presigned' not in o[0])] 70 | 71 | return functions_list 72 | 73 | 74 | def make_api_call(service, function, region, func_params): 75 | 76 | if function[0] == 'generate_presigned_url': 77 | func_params['ClientMethod'] = 'get_object' 78 | 79 | if service == 's3': 80 | if function[0] == 'copy': 81 | copy_source = { 82 | 'Bucket': 'mybucket', 83 | 'Key': 'mykey' 84 | } 85 | function[1](copy_source, 'testbucket', 'testkey') 86 | time.sleep(.1) 87 | return 88 | elif function[0] == 'download_file': 89 | function[1]('testbucket', 'testkey', 'test') 90 | time.sleep(.1) 91 | return 92 | elif function[0] == 'download_fileobj': 93 | with open('testfile', 'wb') as data: 94 | function[1]('testbucket', 'testkey', data) 95 | time.sleep(.1) 96 | return 97 | elif function[0] == 'upload_file': 98 | function[1](service_file_json[service], 'testbucket', 'testkey') 99 | time.sleep(.1) 100 | return 101 | elif function[0] == 'upload_fileobj': 102 | with open(service_file_json[service], 'rb') as data: 103 | function[1](data, 'testbucket', 'testkey') 104 | time.sleep(.1) 105 | return 106 | elif service == 'ec2': 107 | if function[0] == 'copy_snapshot': 108 | function[1](SourceRegion=region, **func_params) 109 | time.sleep(.1) 110 | 111 | function[1](**func_params) 112 | time.sleep(.1) 113 | return 114 | -------------------------------------------------------------------------------- /trailblazer/boto/sts.py: -------------------------------------------------------------------------------- 1 | import boto3 2 | from botocore.exceptions import ClientError 3 | 4 | from trailblazer import log 5 | 6 | 7 | def get_assume_role_session(account_number, role, session_id): 8 | arn = "arn:aws:iam::{}:role/{}".format(account_number, role) 9 | 10 | try: 11 | session = boto3.Session() 12 | sts = session.client('sts') 13 | assumed_role = sts.assume_role(RoleArn=arn, RoleSessionName=session_id) 14 | except ClientError as e: 15 | log.fatal(e) 16 | else: 17 | session = boto3.Session( 18 | aws_access_key_id=assumed_role['Credentials']['AccessKeyId'], 19 | aws_secret_access_key=assumed_role['Credentials']['SecretAccessKey'], 20 | aws_session_token=assumed_role['Credentials']['SessionToken'] 21 | ) 22 | return session 23 | -------------------------------------------------------------------------------- /trailblazer/boto/util.py: -------------------------------------------------------------------------------- 1 | import botocore 2 | 3 | from trailblazer.__about__ import __version__ 4 | 5 | 6 | # Create custom botocore session with a custom config 7 | botocore_config = botocore.config.Config( 8 | parameter_validation = False, 9 | user_agent = 'Trailblazer/{}'.format(__version__) 10 | ) 11 | -------------------------------------------------------------------------------- /trailblazer/cli.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import json 3 | from inspect import getmembers, isfunction, ismethod 4 | import os 5 | import sys 6 | import yaml 7 | 8 | import boto3 9 | import click 10 | import click_log 11 | from click.exceptions import UsageError 12 | 13 | from trailblazer import log 14 | from trailblazer.__about__ import __version__ 15 | from trailblazer.boto.service import get_service_json_files, get_service_call_params 16 | from trailblazer.attack import simulate_attack 17 | from trailblazer.cloudtrail import process_cloudtrail, record_cloudtrail 18 | from trailblazer.enumerate import enumerate_services 19 | 20 | 21 | click_log.basic_config(log) 22 | 23 | 24 | class YAML(click.ParamType): 25 | name = 'yaml' 26 | 27 | def convert(self, value, param, ctx): 28 | try: 29 | with open(value, 'rb') as f: 30 | return yaml.safe_load(f.read()) 31 | except (IOError, OSError) as e: 32 | self.fail('Could not open file: {0}'.format(value)) 33 | 34 | 35 | class CommaList(click.ParamType): 36 | name = 'commalist' 37 | 38 | def convert(self, value, param, ctx): 39 | return value.split(',') 40 | 41 | 42 | class AppContext(object): 43 | def __init__(self): 44 | self.config = None 45 | self.dry_run = False 46 | 47 | 48 | pass_context = click.make_pass_decorator(AppContext, ensure=True) 49 | 50 | 51 | @click.group() 52 | @click_log.simple_verbosity_option(log) 53 | @click.option('--config', type=YAML(), help='Configuration file to use.') 54 | @click.option('--dry-run', type=bool, default=False, is_flag=True, help='Run command without persisting anything.') 55 | @click.version_option(version=__version__) 56 | @pass_context 57 | def cli(ctx, config, dry_run): 58 | 59 | if not ctx.config: 60 | ctx.config = config 61 | 62 | if not ctx.dry_run: 63 | ctx.dry_run = dry_run 64 | 65 | log.debug('Current context. DryRun: {} Config: {}'.format(ctx.dry_run, json.dumps(ctx.config, indent=2))) 66 | 67 | if not ctx.config.get('botocore_document_json_path', None): 68 | log.fatal('botocore_document_json_path not defined in config file') 69 | 70 | 71 | @cli.group() 72 | def enumerate(): 73 | pass 74 | 75 | 76 | @cli.group() 77 | def simulate(): 78 | pass 79 | 80 | 81 | @cli.group() 82 | def cloudtrail(): 83 | pass 84 | 85 | 86 | @enumerate.command() 87 | @click.option('--services', type=CommaList(), help='Comma delimited list of services') 88 | @pass_context 89 | def cloudtrail_calls(ctx, services): 90 | """Enumerate all calls for AWS Services to determine what shows up in CloudTrail""" 91 | log.info('Starting enumeration for CloudTrail...') 92 | 93 | session = boto3.Session() 94 | 95 | if not services: 96 | services = session.get_available_services() 97 | 98 | enumerate_services(ctx.config, services, dry_run=ctx.dry_run) 99 | 100 | log.info('Enumeration complete') 101 | 102 | 103 | @simulate.command() 104 | @pass_context 105 | def attack(ctx): 106 | """Simulate an attack by making the calls described in the config""" 107 | if not ctx.config.get('attack_chain', None): 108 | log.fatal('attack_chain not found in config file') 109 | 110 | log.info('Starting attack simulation...') 111 | 112 | attack_commands = ctx.config['attack_chain'] 113 | 114 | simulate_attack(ctx.config, attack_commands, dry_run=ctx.dry_run) 115 | 116 | log.info('Attack simulation complete') 117 | 118 | 119 | @cloudtrail.command() 120 | @click.option('--directory', type=str, help='Path to directory with CloudTrail files', required=True) 121 | @click.option('--arn', type=str, help='User ARN making calls', required=True) 122 | @pass_context 123 | def process(ctx, directory, arn): 124 | """Process cloudtrail files""" 125 | log.info('Processing CloudTrail...') 126 | 127 | if not os.path.exists(directory): 128 | log.fatal('Invalid Directory Path') 129 | 130 | files = [] 131 | for cloudtrail_file in os.listdir(directory): 132 | files.append(os.path.join(directory, cloudtrail_file)) 133 | 134 | api_calls_logged = process_cloudtrail(arn, files) 135 | 136 | 137 | @cloudtrail.command() 138 | @click.option('--directory', type=str, help='Path to directory with CloudTrail files', required=True) 139 | @click.option('--arn', type=str, help='User ARN making calls', required=True) 140 | @click.option('--output', type=str, help='Output File Name') 141 | @pass_context 142 | def record(ctx, directory, arn, output): 143 | """Create attack simulation from CloudTrail""" 144 | log.info('Recording CloudTrail...') 145 | 146 | if not os.path.exists(directory): 147 | log.fatal('Invalid Directory Path') 148 | 149 | files = [] 150 | for cloudtrail_file in os.listdir(directory): 151 | files.append(os.path.join(directory, cloudtrail_file)) 152 | 153 | api_calls_recorded = record_cloudtrail(arn, files) 154 | 155 | if output: 156 | with open(output, 'w') as yaml_file: 157 | yaml.dump( 158 | { 159 | 'attack_chain': api_calls_recorded 160 | }, 161 | yaml_file, 162 | default_flow_style=False 163 | ) 164 | 165 | if __name__ == '__main__': 166 | try: 167 | cli() 168 | except KeyboardInterrupt: 169 | logging.debug("Exiting due to KeyboardInterrupt...") 170 | 171 | -------------------------------------------------------------------------------- /trailblazer/cloudtrail.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | import gzip 3 | import json 4 | 5 | from trailblazer import log 6 | 7 | 8 | def process_cloudtrail(arn, files): 9 | 10 | api_calls = [] 11 | 12 | log.info('EventSource, EventName, Recorded Name, Match') 13 | 14 | for file in files: 15 | f = None 16 | log.debug('Processing file: {}'.format(file)) 17 | 18 | if file.endswith('.gz'): 19 | f = gzip.open(file, 'r') 20 | else: 21 | f = open(file, 'r') 22 | 23 | try: 24 | cloudtrail = json.load(f) 25 | except Exception as e: 26 | log.error('Invalid JSON File: {} - {}'.format(file, e)) 27 | continue 28 | 29 | for record in cloudtrail['Records']: 30 | if record.get('userIdentity', {}).get('arn', '').startswith(arn): 31 | event_source = record['eventSource'].split('.')[0] 32 | event_name = record['eventName'] 33 | 34 | call = '{}.{}'.format(event_source, event_name) 35 | 36 | if call not in api_calls: 37 | session = record['userIdentity']['arn'].split('/')[-1] 38 | match = (record['eventName'].lower() == session) 39 | log.info('{}, {}, {}, {}'.format(record['eventSource'].split('.')[0], record['eventName'], session, match)) 40 | api_calls.append(call) 41 | 42 | f.close() 43 | 44 | return api_calls 45 | 46 | 47 | def pairwise(lst): 48 | """ yield item i and item i+1 in lst. e.g. 49 | (lst[0], lst[1]), (lst[1], lst[2]), ..., (lst[-1], None) 50 | """ 51 | if not lst: return 52 | #yield None, lst[0] 53 | for i in range(len(lst)-1): 54 | yield lst[i], lst[i+1] 55 | yield lst[-1], None 56 | 57 | 58 | def record_cloudtrail(arn, files): 59 | 60 | api_calls = [] 61 | 62 | for file in files: 63 | f = None 64 | log.info('Processing file: {}'.format(file)) 65 | 66 | if file.endswith('.gz'): 67 | f = gzip.open(file, 'r') 68 | else: 69 | f = open(file, 'r') 70 | 71 | try: 72 | cloudtrail = json.load(f) 73 | except Exception as e: 74 | log.error('Invalid JSON File: {} - {}'.format(file, e)) 75 | continue 76 | 77 | records = sorted(cloudtrail['Records'], key=lambda x: datetime.strptime(x['eventTime'], '%Y-%m-%dT%H:%M:%SZ'), reverse=False) 78 | 79 | for record, next_record in pairwise(records): 80 | if record.get('userIdentity', {}).get('arn', '').startswith(arn): 81 | event_source = record['eventSource'].split('.')[0] 82 | event_name = record['eventName'] 83 | 84 | time_delay = 0 85 | if next_record: 86 | time_delta = datetime.strptime(next_record['eventTime'], '%Y-%m-%dT%H:%M:%SZ') - datetime.strptime(record['eventTime'], '%Y-%m-%dT%H:%M:%SZ') 87 | time_delay = time_delta.seconds 88 | 89 | call = '{}.{}'.format(event_source, event_name) 90 | 91 | log.info('{}.{} - {}'.format(record['eventSource'].split('.')[0], record['eventName'], record['userIdentity']['arn'])) 92 | 93 | api_calls.append( 94 | { 95 | 'call': '{}.{}'.format(event_source, event_name), 96 | 'time_delay': time_delay 97 | } 98 | ) 99 | 100 | f.close() 101 | 102 | return api_calls 103 | -------------------------------------------------------------------------------- /trailblazer/enumerate.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import boto3 4 | from botocore.exceptions import ClientError 5 | 6 | from trailblazer import log 7 | from trailblazer.boto.util import botocore_config 8 | from trailblazer.boto.service import get_boto_functions, get_service_call_params, \ 9 | get_service_json_files, make_api_call, get_service_call_mutation 10 | from trailblazer.boto.sts import get_assume_role_session 11 | 12 | 13 | def enumerate_services(config, services, dry_run=False): 14 | 15 | # Create a boto3 session to use for enumeration 16 | session = boto3.Session() 17 | 18 | authorized_calls = [] 19 | 20 | for service in services: 21 | 22 | if len(session.get_available_regions(service)) == 0: 23 | log.debug('Skipping {} - No regions exist for this service'.format(service)) 24 | continue 25 | 26 | # Create a service client 27 | log.info('Creating {} client...'.format(service)) 28 | 29 | # Grab a region to use for the calls. This should be us-west-2 30 | region = session.get_available_regions(service)[-1] 31 | 32 | # Set the user-agent if specified in the config 33 | if config.get('user_agent', None): 34 | botocore_config.user_agent = config['user_agent'] 35 | 36 | # Create a client with parameter validation off 37 | client = session.client(service, region_name=region, config=botocore_config) 38 | 39 | # Get the functions that you can call 40 | functions_list = get_boto_functions(client) 41 | 42 | # Get the service file 43 | service_file_json = get_service_json_files(config) 44 | 45 | # Get a list of params needed to make the serialization pass in botocore 46 | service_call_params = get_service_call_params(service_file_json[service]) 47 | 48 | # Loop through all the functions and call them 49 | for function in functions_list: 50 | 51 | # The service_file_json doesn't have underscores in names so let's remove them 52 | function_key = function[0].replace('_','') 53 | 54 | # Session Name Can only be 64 characters long 55 | if len(function_key) > 64: 56 | session_name = function_key[:63] 57 | log.info('Session Name {} is for {}'.format(session_name, function_key)) 58 | else: 59 | session_name = function_key 60 | 61 | # Set the session to the name of the API call we are making 62 | session = get_assume_role_session( 63 | account_number=config['account_number'], 64 | role=config['account_role'], 65 | session_id=session_name 66 | ) 67 | 68 | new_client = session.client(service, region_name=region, config=botocore_config) 69 | new_functions_list = get_boto_functions(new_client) 70 | 71 | for new_func in new_functions_list: 72 | if new_func[0] == function[0]: 73 | 74 | # We need to pull out the parameters needed in the requestUri, ex. /{Bucket}/{Key+} -> ['Bucket', 'Key'] 75 | params = re.findall('\{(.*?)\}', service_call_params.get(function_key, '/')) 76 | params = [p.strip('+') for p in params] 77 | 78 | try: 79 | func_params = {} 80 | 81 | for param in params: 82 | # Set something because we have to 83 | func_params[param] = 'testparameter' 84 | 85 | log.info('Calling {}.{} with params {} in {}'.format(service, new_func[0], func_params, region)) 86 | 87 | if not dry_run: 88 | make_api_call(service, new_func, region, func_params) 89 | 90 | except ClientError as e: 91 | log.debug(e) 92 | except boto3.exceptions.S3UploadFailedError as e: 93 | log.debug(e) 94 | except TypeError as e: 95 | log.debug(e) 96 | except KeyError as e: 97 | log.debug('Unknown Exception: {}.{} - {}'.format(service, new_func[0], e)) 98 | --------------------------------------------------------------------------------