├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md ├── cloudfrunt.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Exclude the dnsrecon package 2 | dnsrecon/ 3 | 4 | # Exclude files created during scans 5 | results.txt 6 | output.json 7 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "dnsrecon"] 2 | path = dnsrecon 3 | url = https://github.com/darkoperator/dnsrecon.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Matt Westfall 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudFrunt 2 | 3 | CloudFrunt is a tool for identifying misconfigured CloudFront domains. 4 | 5 | #### Background 6 | 7 | CloudFront is a Content Delivery Network (CDN) provided by Amazon Web Services (AWS). CloudFront users create "distributions" that serve content from specific sources (an S3 bucket, for example). 8 | 9 | Each CloudFront distribution has a unique endpoint for users to point their DNS records to (ex. d111111abcdef8.cloudfront.net). All of the domains using a specific distribution need to be listed in the "Alternate Domain Names (CNAMEs)" field in the options for that distribution. 10 | 11 | When a CloudFront endpoint receives a request, it does NOT automatically serve content from the corresponding distribution. Instead, CloudFront uses the HOST header of the request to determine which distribution to use. This means two things: 12 | 13 | 1. If the HOST header does not match an entry in the "Alternate Domain Names (CNAMEs)" field of the intended distribution, the request will fail. 14 | 15 | 2. Any other distribution that contains the specific domain in the HOST header will receive the request and respond to it normally. 16 | 17 | This is what allows the domains to be hijacked. There are many cases where a CloudFront user fails to list all the necessary domains that might be received in the HOST header. For example: 18 | 19 | * The domain "test.disloops.com" is a CNAME record that points to "disloops.com". 20 | * The "disloops.com" domain is set up to use a CloudFront distribution. 21 | * Because "test.disloops.com" was not added to the "Alternate Domain Names (CNAMEs)" field for the distribution, requests to "test.disloops.com" will fail. 22 | * Another user can create a CloudFront distribution and add "test.disloops.com" to the "Alternate Domain Names (CNAMEs)" field to hijack the domain. 23 | 24 | This means that the unique endpoint that CloudFront binds to a single distribution is effectively meaningless. A request to one specific CloudFront subdomain is not limited to the distribution it is associated with. 25 | 26 | #### Disclaimer 27 | 28 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | 30 | #### Installation 31 | 32 | ``` 33 | $ git clone --recursive https://github.com/MindPointGroup/cloudfrunt 34 | $ pip install -r requirements.txt 35 | ``` 36 | 37 | CloudFrunt expects the *dnsrecon* script to be cloned into a subdirectory called *dnsrecon*. 38 | 39 | #### Usage 40 | 41 | ``` 42 | cloudfrunt.py [-h] [-l TARGET_FILE] [-d DOMAINS] [-o ORIGIN] [-i ORIGIN_ID] [-s] [-N] 43 | 44 | -h, --help Show this message and exit 45 | -s, --save Save the results to results.txt 46 | -N, --no-dns Do not use dnsrecon to expand scope 47 | -l, --target-file TARGET_FILE File containing a list of domains (one per line) 48 | -d, --domains DOMAINS Comma-separated list of domains to scan 49 | -o, --origin ORIGIN Add vulnerable domains to new distributions with this origin 50 | -i, --origin-id ORIGIN_ID The origin ID to use with new distributions 51 | ``` 52 | 53 | #### Example 54 | 55 | ``` 56 | $ python cloudfrunt.py -o cloudfrunt.com.s3-website-us-east-1.amazonaws.com -i S3-cloudfrunt -l list.txt 57 | 58 | CloudFrunt v1.0.4 59 | 60 | [+] Enumerating DNS entries for google.com 61 | [-] No issues found for google.com 62 | 63 | [+] Enumerating DNS entries for disloops.com 64 | [+] Found CloudFront domain --> cdn.disloops.com 65 | [+] Found CloudFront domain --> test.disloops.com 66 | [-] Potentially misconfigured CloudFront domains: 67 | [#] --> test.disloops.com 68 | [+] Created new CloudFront distribution EXBC12DE3F45G 69 | [+] Added test.disloops.com to CloudFront distribution EXBC12DE3F45G 70 | ``` 71 | -------------------------------------------------------------------------------- /cloudfrunt.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # MIT License 4 | # Copyright (c) 2017 Matt Westfall (@disloops) 5 | 6 | # Permission is hereby granted, free of charge, to any person obtaining a copy 7 | # of this software and associated documentation files (the "Software"), to deal 8 | # in the Software without restriction, including without limitation the rights 9 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | # copies of the Software, and to permit persons to whom the Software is 11 | # furnished to do so, subject to the following conditions: 12 | 13 | # The above copyright notice and this permission notice shall be included in all 14 | # copies or substantial portions of the Software. 15 | 16 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | # SOFTWARE. 23 | 24 | import os 25 | import sys 26 | import time 27 | import json 28 | import boto3 29 | import socket 30 | import argparse 31 | import textwrap 32 | 33 | try: 34 | # Python 3 35 | from urllib.request import urlopen 36 | from urllib.error import HTTPError, URLError 37 | except ImportError: 38 | # Python 2 39 | from urllib2 import urlopen, HTTPError, URLError 40 | 41 | from subprocess import call 42 | from netaddr import IPNetwork 43 | from botocore.exceptions import ClientError 44 | 45 | __author__ = 'Matt Westfall' 46 | __version__ = '1.0.4' 47 | __email__ = 'disloops@gmail.com' 48 | 49 | # hotfix for dnsrecon (v0.8.12) to avoid user input 50 | def patch_dnsrecon(): 51 | 52 | with open('./dnsrecon/dnsrecon.py', 'r') as f: 53 | dnsrecon_data = f.read() 54 | dnsrecon_data = dnsrecon_data.replace('continue_brt = str(sys.stdin.readline()[:-1])','continue_brt = "n"') 55 | with open('./dnsrecon/dnsrecon.py', 'w') as f: 56 | f.write(dnsrecon_data) 57 | return True 58 | 59 | # parse the input file 60 | def get_domains(input_file): 61 | 62 | with open(input_file, 'r') as f: 63 | domains = f.readlines() 64 | domains = [domain.strip() for domain in domains] 65 | return domains 66 | 67 | # grab all the CloudFront IP ranges 68 | def get_cf_ranges(cf_url): 69 | 70 | response = None 71 | ranges = [] 72 | 73 | while response is None: 74 | try: 75 | response = urlopen(cf_url) 76 | except URLError as e: 77 | print(' [?] Got URLError trying to get CloudFront IP ranges. Retrying...') 78 | except: 79 | print(' [?] Got an unexpected error trying to get CloudFront IP ranges. Exiting...') 80 | raise 81 | 82 | cf_data = json.load(response) 83 | for item in cf_data['prefixes']: 84 | service = item.get('service') 85 | if service == 'CLOUDFRONT': 86 | ranges.append(item.get('ip_prefix')) 87 | 88 | return ranges 89 | 90 | # find more domains and correct for CloudFront 91 | def recon_target(domain,cf_ranges,no_dns): 92 | 93 | dns_records = [] 94 | 95 | if no_dns is not True: 96 | print(' [+] Enumerating DNS entries for ' + domain) 97 | with open(os.devnull, 'w') as devnull: 98 | call(['python','./dnsrecon/dnsrecon.py','-d' + domain,'-tstd,brt','-f','--lifetime=1','-joutput.json'], stdout=devnull, stderr=devnull) 99 | try: 100 | dns_records = json.load(open('output.json')) 101 | os.remove('output.json') 102 | except: 103 | pass 104 | else: 105 | return [domain] if get_cf_domain(domain,cf_ranges) else [] 106 | 107 | if len(dns_records) > 1000: 108 | print(' [?] Is ' + domain + ' a wildcard domain? Skipping...') 109 | return [domain] if get_cf_domain(domain,cf_ranges) else [] 110 | 111 | url_list = [] 112 | for record in dns_records: 113 | if record.get('name') and (record.get('name') not in url_list) and get_cf_domain(record.get('name'),cf_ranges): 114 | url_list.append(str(record.get('name')).lower()) 115 | 116 | return url_list 117 | 118 | # check if domain points to CloudFront 119 | def get_cf_domain(domain,cf_ranges): 120 | 121 | if domain.endswith('cloudfront.net'): 122 | return False 123 | 124 | domain_ips = [] 125 | 126 | try: 127 | domain_ips = socket.gethostbyname_ex(domain)[2] 128 | except: 129 | pass 130 | 131 | for ip in domain_ips: 132 | for ip_range in cf_ranges: 133 | ip_network = IPNetwork(ip_range) 134 | if ip in ip_network: 135 | print(' [+] Found CloudFront domain --> ' + str(domain)) 136 | return True 137 | return False 138 | 139 | # test domains for CloudFront misconfigurations 140 | def find_cf_issues(domains): 141 | 142 | error_domains = [] 143 | 144 | for domain in domains: 145 | try: 146 | response = urlopen('http://' + domain) 147 | except HTTPError as e: 148 | if e.code == 403 and 'Bad request' in e.fp.read(): 149 | try: 150 | response = urlopen('https://' + domain) 151 | except URLError as e: 152 | if 'handshake' in str(e).lower() or e.code == 403 and 'Bad request' in e.fp.read(): 153 | error_domains.append(domain) 154 | except: 155 | pass 156 | except: 157 | pass 158 | 159 | return error_domains 160 | 161 | # add a domain to CloudFront 162 | def add_domain(domain,client,origin,origin_id,distribution_id): 163 | 164 | if not distribution_id: 165 | distribution_id = create_distribution(client,origin,origin_id) 166 | 167 | response = None 168 | while response is None: 169 | try: 170 | response = client.get_distribution_config(Id=distribution_id) 171 | except ClientError as e: 172 | print(' [?] Got boto3 error - ' + e.response['Error']['Code'] + ': ' + e.response['Error']['Message']) 173 | print(' [?] Retrying...') 174 | 175 | aliases = response['DistributionConfig']['Aliases'] 176 | 177 | # default maximum number of CNAMEs for one distribution 178 | if aliases['Quantity'] == 100: 179 | distribution_id = create_distribution(client,origin,origin_id) 180 | response = client.get_distribution_config(Id=distribution_id) 181 | aliases = response['DistributionConfig']['Aliases'] 182 | 183 | if 'Items' in aliases: 184 | aliases['Items'].append(domain) 185 | else: 186 | aliases['Items'] = [domain] 187 | 188 | aliases['Quantity'] += 1 189 | response['DistributionConfig']['Aliases'] = aliases 190 | 191 | added_domain = None 192 | while added_domain is None: 193 | try: 194 | added_domain = client.update_distribution(Id=distribution_id,DistributionConfig=response['DistributionConfig'],IfMatch=response['ETag']) 195 | print(' [+] Added ' + str(domain) + ' to CloudFront distribution ' + str(distribution_id)) 196 | except client.exceptions.CNAMEAlreadyExists as e: 197 | print(' [?] The domain ' + str(domain) + ' is already part of another distribution.') 198 | added_domain = False 199 | except ClientError as e: 200 | print(' [?] Got boto3 error - ' + e.response['Error']['Code'] + ': ' + e.response['Error']['Message']) 201 | print(' [?] Retrying...') 202 | 203 | return distribution_id 204 | 205 | # create a new CloudFront distribution 206 | def create_distribution(client,origin,origin_id): 207 | 208 | # default distribution configuration 209 | base_cf_config = { 210 | 'Comment': '', 211 | 'Aliases': { 212 | 'Quantity': 0, 213 | 'Items': [] 214 | }, 215 | 'Origins': { 216 | 'Quantity': 1, 217 | 'Items': [ 218 | { 219 | 'OriginPath': '', 220 | 'CustomOriginConfig': { 221 | 'OriginSslProtocols': { 222 | 'Items': [ 223 | 'TLSv1', 224 | 'TLSv1.1', 225 | 'TLSv1.2' 226 | ], 227 | 'Quantity': 3 228 | }, 229 | 'OriginProtocolPolicy': 'http-only', 230 | 'OriginReadTimeout': 30, 231 | 'HTTPPort': 80, 232 | 'HTTPSPort': 443, 233 | 'OriginKeepaliveTimeout': 5 234 | }, 235 | 'CustomHeaders': { 236 | 'Quantity': 0 237 | }, 238 | 'Id': origin_id, 239 | 'DomainName': origin 240 | } 241 | ] 242 | }, 243 | 'CacheBehaviors': { 244 | 'Quantity': 0 245 | }, 246 | 'IsIPV6Enabled': True, 247 | 'Logging': { 248 | 'Bucket': '', 249 | 'Prefix': '', 250 | 'Enabled': False, 251 | 'IncludeCookies': False 252 | }, 253 | 'WebACLId': '', 254 | 'DefaultRootObject': '', 255 | 'PriceClass': 'PriceClass_All', 256 | 'Enabled': True, 257 | 'DefaultCacheBehavior': { 258 | 'TrustedSigners': { 259 | 'Enabled': False, 260 | 'Quantity': 0 261 | }, 262 | 'LambdaFunctionAssociations': { 263 | 'Quantity': 0 264 | }, 265 | 'TargetOriginId': origin_id, 266 | 'ViewerProtocolPolicy': 'allow-all', 267 | 'ForwardedValues': { 268 | 'Headers': { 269 | 'Quantity': 0 270 | }, 271 | 'Cookies': { 272 | 'Forward': 'none' 273 | }, 274 | 'QueryStringCacheKeys': { 275 | 'Quantity': 0 276 | }, 277 | 'QueryString': False 278 | }, 279 | 'MaxTTL': 31536000, 280 | 'SmoothStreaming': False, 281 | 'DefaultTTL': 86400, 282 | 'AllowedMethods': { 283 | 'Items': [ 284 | 'HEAD', 285 | 'GET' 286 | ], 287 | 'CachedMethods': { 288 | 'Items': [ 289 | 'HEAD', 290 | 'GET' 291 | ], 292 | 'Quantity': 2 293 | }, 294 | 'Quantity': 2 295 | }, 296 | 'MinTTL': 0, 297 | 'Compress': False 298 | }, 299 | 'CallerReference': str(time.time()*10).replace('.', ''), 300 | 'ViewerCertificate': { 301 | 'CloudFrontDefaultCertificate': True, 302 | 'MinimumProtocolVersion': 'TLSv1', 303 | 'CertificateSource': 'cloudfront' 304 | }, 305 | 'CustomErrorResponses': { 306 | 'Quantity': 0 307 | }, 308 | 'HttpVersion': 'http2', 309 | 'Restrictions': { 310 | 'GeoRestriction': { 311 | 'RestrictionType': 'none', 312 | 'Quantity': 0 313 | } 314 | }, 315 | } 316 | 317 | response = None 318 | while response is None: 319 | try: 320 | response = client.create_distribution(DistributionConfig=base_cf_config) 321 | distribution_id = response['Distribution']['Id'] 322 | print(' [+] Created new CloudFront distribution ' + str(distribution_id)) 323 | except ClientError as e: 324 | print(' [?] Got boto3 error - ' + e.response['Error']['Code'] + ': ' + e.response['Error']['Message']) 325 | print(' [?] Retrying...') 326 | 327 | return distribution_id 328 | 329 | def main(): 330 | 331 | # 1. Setup manual information 332 | 333 | logo_msg = '\n CloudFrunt v' + __version__ 334 | 335 | epilog_msg = ('example:\n' + 336 | ' $ python cloudfrunt.py -l list.txt -s\n' + 337 | logo_msg + '\n A tool for identifying misconfigured CloudFront domains.' + 338 | '\n\n NOTE: There are a couple dependencies for this program to work correctly:\n' + 339 | '\n 1) pip install -r requirements.txt\n' + 340 | '\n 2) If you did not use \"git clone --recursive ...\" you will need to run the following:\n' + 341 | '\n $ git clone https://github.com/darkoperator/dnsrecon.git') 342 | 343 | parser = argparse.ArgumentParser(add_help=False,formatter_class=argparse.RawTextHelpFormatter,epilog=epilog_msg) 344 | parser.add_argument('-h', '--help', dest='show_help', action='store_true', help='Show this message and exit\n\n') 345 | parser.add_argument('-l', '--target-file', help='File containing a list of domains (one per line)\n\n', type=str) 346 | parser.add_argument('-d', '--domains', help='Comma-separated list of domains to scan\n\n', type=str) 347 | parser.add_argument('-o', '--origin', help='Add vulnerable domains to new distributions with this origin\n\n', type=str) 348 | parser.add_argument('-i', '--origin-id', help='The origin ID to use with new distributions\n\n', type=str) 349 | parser.add_argument('-s', '--save', dest='save', action='store_true', help='Save the results to results.txt\n\n') 350 | parser.add_argument('-N', '--no-dns', dest='no_dns', action='store_true', help='Do not use dnsrecon to expand scope\n') 351 | parser.set_defaults(show_help='False') 352 | parser.set_defaults(save='False') 353 | parser.set_defaults(no_dns='False') 354 | args = parser.parse_args() 355 | 356 | if args.show_help is True: 357 | print('') 358 | print(parser.format_help()) 359 | sys.exit(0) 360 | 361 | print(logo_msg) 362 | 363 | # 2. Check input and handle the target list 364 | 365 | target_list = [] 366 | 367 | if not args.target_file and not args.domains: 368 | print('') 369 | parser.error('\n\n Either --target-file or --domains is required.\n Or use --help for more info.\n') 370 | 371 | boto_client = None 372 | distribution_id = '' 373 | 374 | if (args.origin and not args.origin_id) or (args.origin_id and not args.origin): 375 | print('') 376 | parser.error('\n\n Both --origin and --origin-id are required to create new distributions.\n') 377 | elif args.origin and args.origin_id: 378 | boto_client = boto3.client('cloudfront') 379 | 380 | if args.no_dns is not True: 381 | if not os.path.isfile('./dnsrecon/dnsrecon.py'): 382 | print('') 383 | parser.error('\n\n The file \'./dnsrecon/dnsrecon.py\' was not found.\n Use -N to skip dnsrecon or use --help for more info.\n') 384 | else: 385 | patch_dnsrecon() 386 | 387 | if args.target_file: 388 | target_list = get_domains(args.target_file) 389 | 390 | if args.domains: 391 | for domain in [domain.strip() for domain in args.domains.split(',')]: 392 | target_list.append(domain) 393 | 394 | # 3. Adjust the scope and report findings 395 | 396 | cf_ranges = get_cf_ranges('https://ip-ranges.amazonaws.com/ip-ranges.json') 397 | target_list = [target.lower() for target in list(set(target_list))] 398 | 399 | for target in target_list: 400 | 401 | print('') 402 | target_scope = find_cf_issues(recon_target(target,cf_ranges,args.no_dns)) 403 | 404 | if target_scope: 405 | print(' [-] Potentially misconfigured CloudFront domains:') 406 | 407 | for domain in target_scope: 408 | print(' [#] --> ' + domain) 409 | if args.origin: 410 | distribution_id = add_domain(domain,boto_client,args.origin,args.origin_id,distribution_id) 411 | 412 | if args.save is True: 413 | with open('results.txt', 'a') as f: 414 | print(' [-] Writing output to results.txt...') 415 | for domain in target_scope: 416 | f.write(str(domain) + '\n') 417 | else: 418 | print(' [-] No issues found for ' + target) 419 | 420 | print('') 421 | 422 | if __name__ == '__main__': 423 | sys.exit(main()) 424 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | boto3 2 | netaddr 3 | dnspython 4 | --------------------------------------------------------------------------------