Skip to content

Commit

Permalink
Merge pull request #32 from initstring/functions
Browse files Browse the repository at this point in the history
WIP: Adding Cloud Functions
  • Loading branch information
initstring authored Sep 8, 2020
2 parents 98fe026 + a62efe2 commit bb1a4b6
Show file tree
Hide file tree
Showing 6 changed files with 184 additions and 24 deletions.
9 changes: 4 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,7 @@ Currently enumerates the following:
- Open GCP Buckets
- Protected GCP Buckets
- Google App Engine sites

By "open" buckets/containers, I mean those that allow anonymous users to list contents. if you discover a protected bucket/container, it is still worth trying to brute force the contents with another tool.

**IMPORTANT**: Azure Virtual Machine DNS records can span a lot of geo regions. To save time scanning, there is a "REGIONS" variable defined in cloudenum/azure_regions.py. You'll want to look at this file and edit it to be relevant to your own work.
- Cloud Functions (enumerates project/regions with existing functions, then brute forces actual function names)

See it in action in [Codingo](https://github.com/codingo)'s video demo [here](https://www.youtube.com/embed/pTUDJhWJ1m0).

Expand All @@ -43,7 +40,7 @@ The only required argument is at least one keyword. You can use the built-in fuz

You can provide multiple keywords by specifying the `-k` argument multiple times.

Azure Containers required two levels of brute-forcing, both handled automatically by this tool. First, by finding valid accounts (DNS). Then, by brute-forcing container names inside that account (HTTP scraping). The tool uses the same fuzzing file for both by default, but you can specificy individual files separately if you'd like.
Keywords are mutated automatically using strings from `enum_tools/fuzz.txt` or a file you provide with the `-m` flag. Services that require a second-level of brute forcing (Azure Containers and GCP Functions) will also use `fuzz.txt` by default or a file you provide with the `-b` flag.

Let's say you were researching "somecompany" whose website is "somecompany.io" that makes a product called "blockchaindoohickey". You could run the tool like this:

Expand All @@ -57,6 +54,8 @@ HTTP scraping and DNS lookups use 5 threads each by default. You can try increas
cloudenum.py -k keyword -t 10
```

**IMPORTANT**: Some resources (Azure Containers, GCP Functions) are discovered per-region. To save time scanning, there is a "REGIONS" variable defined in `cloudenum/azure_regions.py and cloudenum/gcp_regions.py` that is set by default to use only 1 region. You may want to look at these files and edit them to be relevant to your own work.

**Complete Usage Details**
```
usage: cloud_enum.py [-h] -k KEYWORD [-m MUTATIONS] [-b BRUTE]
Expand Down
19 changes: 5 additions & 14 deletions enum_tools/azure_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -102,8 +102,10 @@ def print_container_response(reply):

# Stop brute forcing accounts without permission
if ('not authorized to perform this operation' in reply.reason or
'not have sufficient permissions' in reply.reason):
print(" [!] Breaking out early, auth errors.")
'not have sufficient permissions' in reply.reason or
'Public access is not permitted' in reply.reason or
'Server failed to authenticate the request' in reply.reason):
print(" [!] Breaking out early, auth required.")
return 'breakout'

# Stop brute forcing unsupported accounts
Expand Down Expand Up @@ -151,18 +153,7 @@ def brute_force_containers(storage_accounts, brute_list, threads):
valid_accounts.append(account)

# Read the brute force file into memory
with open(brute_list, encoding="utf8", errors="ignore") as infile:
names = infile.read().splitlines()

# Clean up the names to usable for containers
banned_chars = re.compile('[^a-z0-9-]')
clean_names = []
for name in names:
name = name.lower()
name = banned_chars.sub('', name)
if 63 >= len(name) >= 3:
if name not in clean_names:
clean_names.append(name)
clean_names = utils.get_brute(brute_list, mini=3)

# Start a counter to report on elapsed time
start_time = utils.start_timer()
Expand Down
6 changes: 6 additions & 0 deletions enum_tools/fuzz.txt
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ graphql
gs
gw
help
iaas
hub
iam
images
Expand All @@ -131,6 +132,7 @@ ios
iot
jira
js
k8s
kube
kubeengine
kubernetes
Expand All @@ -156,6 +158,7 @@ ops
oracle
org
packages
paas
passwords
photos
pics
Expand All @@ -180,6 +183,7 @@ repo
reports
resources
s3
saas
screenshots
scripts
sec
Expand Down Expand Up @@ -210,6 +214,7 @@ store
subversion
support
svn
svc
syslog
tasks
teamcity
Expand All @@ -228,6 +233,7 @@ userpictures
users
ux
videos
vm
web
website
wp
Expand Down
127 changes: 124 additions & 3 deletions enum_tools/gcp_checks.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@
"""

from enum_tools import utils
from enum_tools import gcp_regions

BANNER = '''
++++++++++++++++++++++++++
google checks
++++++++++++++++++++++++++
'''

# Known S3 domain names
# Known GCP domain names
GCP_URL = 'storage.googleapis.com'
APPSPOT_URL = 'appspot.com'
FUNC_URL = 'cloudfunctions.net'

# Hacky, I know. Used to store project/region combos that report at least
# one cloud function, to brute force later on
HAS_FUNCS = []

def print_bucket_response(reply):
"""
Expand Down Expand Up @@ -69,10 +75,12 @@ def print_appspot_response(reply):
"""
if reply.status_code == 404:
pass
elif reply.status_code == 500 or reply.status_code == 503:
elif (str(reply.status_code)[0] == 5):
utils.printc(" Google App Engine app with a 50x error: {}\n"
.format(reply.url), 'orange')
elif reply.status_code == 200 or reply.status_code == 302:
elif (reply.status_code == 200
or reply.status_code == 302
or reply.status_code == 404):
utils.printc(" Google App Engine app: {}\n"
.format(reply.url), 'green')
else:
Expand Down Expand Up @@ -107,6 +115,118 @@ def check_appspot(names, threads):
# Stop the time
utils.stop_timer(start_time)

def print_functions_response1(reply):
"""
Parses the HTTP reply the initial Cloud Functions check
This function is passed into the class object so we can view results
in real-time.
"""
if reply.status_code == 404:
pass
elif reply.status_code == 302:
utils.printc(" Contains at least 1 Cloud Function: {}\n"
.format(reply.url), 'green')
HAS_FUNCS.append(reply.url)
else:
print(" Unknown status codes being received from {}:\n"
" {}: {}"
.format(reply.url, reply.status_code, reply.reason))

def print_functions_response2(reply):
"""
Parses the HTTP reply from the secondary, brute-force Cloud Functions check
This function is passed into the class object so we can view results
in real-time.
"""
if 'accounts.google.com/ServiceLogin' in reply.url:
pass
elif reply.status_code == 403 or reply.status_code == 401:
utils.printc(" Auth required Cloud Function: {}\n"
.format(reply.url), 'orange')
elif reply.status_code == 405:
utils.printc(" UNAUTHENTICATED Cloud Function (POST-Only): {}\n"
.format(reply.url), 'green')
elif reply.status_code == 200 or reply.status_code == 404:
utils.printc(" UNAUTHENTICATED Cloud Function (GET-OK): {}\n"
.format(reply.url), 'green')
else:
print(" Unknown status codes being received from {}:\n"
" {}: {}"
.format(reply.url, reply.status_code, reply.reason))

def check_functions(names, brute_list, threads):
"""
Checks for Google Cloud Functions running on cloudfunctions.net
This is a two-part process. First, we want to find region/project combos
that have existing Cloud Functions. The URL for a function looks like this:
https://[ZONE]-[PROJECT-ID].cloudfunctions.net/[FUNCTION-NAME]
We look for a 302 in [ZONE]-[PROJECT-ID].cloudfunctions.net. That means
there are some functions defined in that region. Then, we brute force a list
of possible function names there.
See gcp_regions.py to define which regions to check. The tool currently
defaults to only 1 region, so you should really modify it for best results.
"""
print("[+] Checking for project/zones with Google Cloud Functions.")

# Start a counter to report on elapsed time
start_time = utils.start_timer()

# Pull the regions from a config file
regions = gcp_regions.REGIONS

print("[*] Testing across {} regions defined in the config file"
.format(len(regions)))

for region in regions:
# Initialize the list of initial URLs to check
candidates = [region + '-' + name + '.' + FUNC_URL for name in names]

# Send the valid names to the batch HTTP processor
utils.get_url_batch(candidates, use_ssl=False,
callback=print_functions_response1,
threads=threads,
redir=False)

# Retun from function if we have not found any valid combos
if not HAS_FUNCS:
utils.stop_timer(start_time)
return

# If we did find something, we'll use the brute list. This will allow people
# to provide a separate fuzzing list if they choose.
print("[*] Brute-forcing function names in {} project/region combos"
.format(len(HAS_FUNCS)))

# Load brute list in memory, based on allowed chars/etc
brute_strings = utils.get_brute(brute_list)

# The global was built in a previous function. We only want to brute force
# project/region combos that we know have existing functions defined
for func in HAS_FUNCS:
print("[*] Brute-forcing {} function names in {}"
.format(len(brute_strings), func))
# Initialize the list of initial URLs to check. Strip out the HTTP
# protocol first, as that is handled in the utility
func = func.replace("http://", "")

# Noticed weird behaviour with functions when a slash is not appended.
# Works for some, but not others. However, appending a slash seems to
# get consistent results. Might need further validation.
candidates = [func + brute + '/' for brute in brute_strings]

# Send the valid names to the batch HTTP processor
utils.get_url_batch(candidates, use_ssl=False,
callback=print_functions_response2,
threads=threads)

# Stop the time
utils.stop_timer(start_time)

def run_all(names, args):
"""
Function is called by main program
Expand All @@ -115,3 +235,4 @@ def run_all(names, args):

check_gcp_buckets(names, args.threads)
check_appspot(names, args.threads)
check_functions(names, args.brute, args.threads)
23 changes: 23 additions & 0 deletions enum_tools/gcp_regions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
"""
File used to track the DNS regions for GCP resources.
"""

# Some enumeration tasks will need to go through the complete list of
# possible DNS names for each region. You may want to modify this file to
# use the regions meaningful to you.
#
# Whatever is listed in the last instance of 'REGIONS' below is what the tool
# will use.


# Here is the list I get when running `gcloud functions regions list`
REGIONS = ['us-central1', 'us-east1', 'us-east4', 'us-west2', 'us-west3',
'us-west4', 'europe-west1', 'europe-west2', 'europe-west3',
'europe-west6', 'asia-east2', 'asia-northeast1', 'asia-northeast2',
'asia-northeast3', 'asia-south1', 'asia-southeast2',
'northamerica-northeast1', 'southamerica-east1',
'australia-southeast1']


# And here I am limiting the search by overwriting this variable:
REGIONS = ['us-central1',]
24 changes: 22 additions & 2 deletions enum_tools/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def init_logfile(logfile):
log_writer.write("\n\n#### CLOUD_ENUM {} ####\n"
.format(now))

def get_url_batch(url_list, use_ssl=False, callback='', threads=5):
def get_url_batch(url_list, use_ssl=False, callback='', threads=5, redir=True):
"""
Processes a list of URLs, sending the results back to the calling
function in real-time via the `callback` parameter
Expand Down Expand Up @@ -66,7 +66,7 @@ def get_url_batch(url_list, use_ssl=False, callback='', threads=5):

# First, grab the pending async request and store it in a dict
for url in batch:
batch_pending[url] = session.get(proto + url)
batch_pending[url] = session.get(proto + url, allow_redirects=redir)

# Then, grab all the results from the queue.
# This is where we need to catch exceptions that occur with large
Expand Down Expand Up @@ -212,6 +212,26 @@ def printc(text, color):
with open(LOGFILE, 'a') as log_writer:
log_writer.write(text.lstrip())

def get_brute(brute_file, mini=1, maxi=63, banned='[^a-z0-9_-]'):
"""
Generates a list of brute-force words based on length and allowed chars
"""
# Read the brute force file into memory
with open(brute_file, encoding="utf8", errors="ignore") as infile:
names = infile.read().splitlines()

# Clean up the names to usable for containers
banned_chars = re.compile(banned)
clean_names = []
for name in names:
name = name.lower()
name = banned_chars.sub('', name)
if maxi >= len(name) >= mini:
if name not in clean_names:
clean_names.append(name)

return clean_names

def start_timer():
"""
Starts a timer for functions in main module
Expand Down

0 comments on commit bb1a4b6

Please sign in to comment.