Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Added support for AWS Credentials profiles and enhanced Ctrl-C handling #8

Open
wants to merge 5 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions enumerate-iam.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
#!/usr/bin/env python
#!/usr/bin/env python3
import sys
import json
import logging
import argparse

from boto3 import Session
from enumerate_iam.main import enumerate_iam
from enumerate_iam.utils.json_utils import json_encoder

def main():
parser = argparse.ArgumentParser(description='Enumerate IAM permissions')
Expand All @@ -13,6 +16,10 @@ def main():
parser.add_argument('--secret-key', help='AWS secret key if profile was not used')
parser.add_argument('--session-token', help='STS session token')
parser.add_argument('--region', help='AWS region to send API requests to', default='us-east-1')
parser.add_argument('--output', help='File to write output JSON containing all of the collected permissions')
parser.add_argument('--timeout', help='Timeout in minutes for permissions brute-forcing activity. Def: 15.', type=int, default=15)
#parser.add_argument('--verbose', action='store_true', help='Enable verbose output.')
parser.add_argument('--debug', action='store_true', help='Enable debug output.')

args = parser.parse_args()

Expand All @@ -34,10 +41,20 @@ def main():
secret_key = currcreds.secret_key
session_token = currcreds.token

enumerate_iam(access_key,
level = logging.INFO
if args.debug:
level = logging.DEBUG

output = enumerate_iam(access_key,
secret_key,
session_token,
args.region)
args.region,
args.timeout * 60,
level)

if args.output:
with open(args.output, 'w') as f:
f.write(json.dumps(output, indent=4, default=json_encoder))

if __name__ == '__main__':
main()
87 changes: 66 additions & 21 deletions enumerate_iam/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,17 @@

from botocore.client import Config
from botocore.endpoint import MAX_POOL_CONNECTIONS
from multiprocessing.dummy import Pool as ThreadPool
from multiprocessing import TimeoutError
from multiprocessing.dummy import Pool as ThreadPool, Manager, Value

from enumerate_iam.utils.remove_metadata import remove_metadata
from enumerate_iam.utils.json_utils import json_encoder
from enumerate_iam.bruteforce_tests import BRUTEFORCE_TESTS

MAX_THREADS = 25
MAX_THREADS = 1
mgeeky marked this conversation as resolved.
Show resolved Hide resolved
CLIENT_POOL = {}
STOP_SIGNAL = False
MANAGER = Manager()
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we can move the manager and stop_signal variables to a different scope? Using variables with global scope should be avoided as much as possible.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which particular scope would you think of?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Where you wrote: global STOP_SIGNAL

Replace with:

manager = Manager()
stop_signal = manager.Value('i', 0)

And remove the old MANAGER and STOP_SIGNAL. Did not check if that works, please confirm :-)

STOP_SIGNAL = MANAGER.Value('i', 0)


def report_arn(candidate):
Expand All @@ -60,7 +62,7 @@ def report_arn(candidate):
return None, None, None


def enumerate_using_bruteforce(access_key, secret_key, session_token, region):
def enumerate_using_bruteforce(access_key, secret_key, session_token, region, timeout):
"""
Attempt to brute-force common describe calls.
"""
Expand All @@ -75,13 +77,17 @@ def enumerate_using_bruteforce(access_key, secret_key, session_token, region):
pool = ThreadPool(MAX_THREADS)
signal.signal(signal.SIGINT, original_sigint_handler)

args_generator = generate_args(access_key, secret_key, session_token, region)
args_generator = generate_args(access_key, secret_key, session_token, region, STOP_SIGNAL)

try:
results = pool.map_async(check_one_permission, args_generator)
results.get(600)
results.get(timeout)
except TimeoutError:
logger.info('Brute-forcing permissions timed out.')
STOP_SIGNAL.value = 1

except KeyboardInterrupt:
STOP_SIGNAL = True
STOP_SIGNAL.value = 1
print('')

results = []
Expand All @@ -94,7 +100,6 @@ def enumerate_using_bruteforce(access_key, secret_key, session_token, region):
pool.join()
except KeyboardInterrupt:
print('')
STOP_SIGNAL = True
return output

for thread_result in results:
Expand All @@ -110,7 +115,7 @@ def enumerate_using_bruteforce(access_key, secret_key, session_token, region):
return output


def generate_args(access_key, secret_key, session_token, region):
def generate_args(access_key, secret_key, session_token, region, stop_signal):

service_names = list(BRUTEFORCE_TESTS.keys())

Expand All @@ -121,7 +126,7 @@ def generate_args(access_key, secret_key, session_token, region):
random.shuffle(actions)

for action in actions:
yield access_key, secret_key, session_token, region, service_name, action
yield access_key, secret_key, session_token, region, stop_signal, service_name, action


def get_client(access_key, secret_key, session_token, service_name, region):
Expand Down Expand Up @@ -159,10 +164,10 @@ def get_client(access_key, secret_key, session_token, service_name, region):


def check_one_permission(arg_tuple):
access_key, secret_key, session_token, region, service_name, operation_name = arg_tuple
access_key, secret_key, session_token, region, stop_signal, service_name, operation_name = arg_tuple
logger = logging.getLogger()

if STOP_SIGNAL:
if stop_signal.value == 1:
return

service_client = get_client(access_key, secret_key, session_token, service_name, region)
Expand All @@ -180,7 +185,7 @@ def check_one_permission(arg_tuple):
logger.debug('Testing %s.%s() in region %s' % (service_name, operation_name, region))

try:
if STOP_SIGNAL:
if stop_signal.value == 1:
mgeeky marked this conversation as resolved.
Show resolved Hide resolved
return
action_response = action_function()
except (botocore.exceptions.ClientError,
Expand All @@ -201,9 +206,9 @@ def check_one_permission(arg_tuple):
return key, remove_metadata(action_response)


def configure_logging():
def configure_logging(level):
logging.basicConfig(
level=logging.INFO,
level=level,
format='%(asctime)s - %(process)d - [%(levelname)s] %(message)s',
)

Expand All @@ -222,17 +227,17 @@ def configure_logging():
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)


def enumerate_iam(access_key, secret_key, session_token, region):
def enumerate_iam(access_key, secret_key, session_token, region, timeout, level = logging.INFO):
"""IAM Account Enumerator.

This code provides a mechanism to attempt to validate the permissions assigned
to a given set of AWS tokens.
"""
output = dict()
configure_logging()
configure_logging(level)

output['iam'] = enumerate_using_iam(access_key, secret_key, session_token, region)
output['bruteforce'] = enumerate_using_bruteforce(access_key, secret_key, session_token, region)
output['bruteforce'] = enumerate_using_bruteforce(access_key, secret_key, session_token, region, timeout)

return output

Expand All @@ -258,8 +263,8 @@ def enumerate_using_iam(access_key, secret_key, session_token, region):
botocore.exceptions.ReadTimeoutError):
pass
else:
logger.info('Run for the hills, get_account_authorization_details worked!')
logger.info('-- %s', json.dumps(everything, indent=4, default=json_encoder))
logger.debug('Run for the hills, get_account_authorization_details worked!')
logger.debug('%s', json.dumps(everything, indent=4, default=json_encoder))

output['iam.get_account_authorization_details'] = remove_metadata(everything)

Expand Down Expand Up @@ -308,6 +313,7 @@ def enumerate_role(iam_client, output):
pass
else:
output['iam.list_attached_role_policies'] = remove_metadata(role_policies)
logger.debug('%s', json.dumps(role_policies, indent=4, default=json_encoder))

logger.info(
'Role "%s" has %0d attached policies',
Expand All @@ -319,6 +325,15 @@ def enumerate_role(iam_client, output):
for policy in role_policies['AttachedPolicies']:
logger.info('-- Policy "%s" (%s)', policy['PolicyName'], policy['PolicyArn'])
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the others changed from info to debug, maybe this one should be debug too?

I'm not sure why you changed some of the calls from logger.info to logger.debug but I'll trust you on those changes and that the whole tool will use the same "rules" to decide what is info and what is debug.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So right, I've seen that running the tool by default on verbose output is fine to the general UX, but as soon as iam's get-account-authorization-details results gets dumped, they will generate such a big JSON that would effectively make reading program's output cumbersome. So I started using logger.debug anywhere we've been dumping JSON contents. Whereas policy's name does not affect programs output to the point of making it being of a debug level.

If we wish to have the user know what's going on during program's non-verbose execution, then we shall go with logger.info only for essential or short enough outputs. Should a user want to learn more what the program learns as it goes, debug would provide him with all we've got to say there.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally agree with your decision, lets keep it like that: debug contains JSON, info contains short messages.

Just one more thing: for the places where you removed the logger.info(), is there a short message we could use? For example:

logger.info('Run for the hills, get_account_authorization_details worked!')

Could still be info.


get_policy = iam.get_role_policy(PolicyName=policy['PolicyName'])
policy_version = iam_client.get_policy_version(PolicyArn=policy['PolicyArn'], VersionId=policy['DefaultVersionId'])
mgeeky marked this conversation as resolved.
Show resolved Hide resolved
logger.debug('Role attached policy: {}'.format(policy['PolicyName']))
logger.debug('%s', json.dumps(policy_version, indent=4, default=json_encoder))

key = 'iam.role_attached_policies'
if key not in output.keys(): output[key] = []
output[key].append(remove_metadata(policy_version))

# Attempt to get inline policies for this user.
try:
role_policies = iam_client.list_role_policies(RoleName=role_name)
Expand All @@ -328,7 +343,7 @@ def enumerate_role(iam_client, output):
output['iam.list_role_policies'] = remove_metadata(role_policies)

logger.info(
'User "%s" has %0d inline policies',
'Role "%s" has %0d inline policies',
role['Role']['RoleName'],
len(role_policies['PolicyNames'])
)
Expand All @@ -337,6 +352,13 @@ def enumerate_role(iam_client, output):
for policy in role_policies['PolicyNames']:
logger.info('-- Policy "%s"', policy)

get_policy = iam_client.get_user_policy(RoleName=role_name, PolicyName=policy)
mgeeky marked this conversation as resolved.
Show resolved Hide resolved
logger.debug('Role inline policy:\n%s', json.dumps(get_policy['PolicyDocument'], indent=4, default=json_encoder))

key = 'iam.role_inline_policies'
if key not in output.keys(): output[key] = []
output[key].append(remove_metadata(get_policy['PolicyDocument']))

return output


Expand Down Expand Up @@ -390,6 +412,14 @@ def enumerate_user(iam_client, output):
for policy in user_policies['AttachedPolicies']:
logger.info('-- Policy "%s" (%s)', policy['PolicyName'], policy['PolicyArn'])

get_policy = iam_client.get_policy(PolicyArn=policy['PolicyArn'])
policy_version = iam_client.get_policy_version(PolicyArn=policy['PolicyArn'], VersionId=get_policy['Policy']['DefaultVersionId'])
mgeeky marked this conversation as resolved.
Show resolved Hide resolved
logger.debug('User attached policy:\n%s', json.dumps(policy_version['PolicyVersion'], indent=4, default=json_encoder))

key = 'iam.user_attached_policies'
if key not in output.keys(): output[key] = []
output[key].append(remove_metadata(policy_version['PolicyVersion']))

# Attempt to get inline policies for this user.
try:
user_policies = iam_client.list_user_policies(UserName=user_name)
Expand All @@ -408,6 +438,13 @@ def enumerate_user(iam_client, output):
for policy in user_policies['PolicyNames']:
logger.info('-- Policy "%s"', policy)

get_policy = iam_client.get_user_policy(UserName=user_name, PolicyName=policy)
logger.debug('User inline policy:\n%s', json.dumps(get_policy['PolicyDocument'], indent=4, default=json_encoder))
mgeeky marked this conversation as resolved.
Show resolved Hide resolved

key = 'iam.user_inline_policies'
if key not in output.keys(): output[key] = []
output[key].append(remove_metadata(get_policy['PolicyDocument']))

# Attempt to get the groups attached to this user.
user_groups = dict()
user_groups['Groups'] = []
Expand Down Expand Up @@ -443,6 +480,14 @@ def enumerate_user(iam_client, output):
# List all group policy names.
for policy in group_policy['PolicyNames']:
logger.info('---- Policy "%s"', policy)

get_policy = iam_client.get_group_policy(GroupName=group['GroupName'], PolicyName=policy)
mgeeky marked this conversation as resolved.
Show resolved Hide resolved
logger.debug('Group inline policy:\n%s', json.dumps(get_policy['PolicyDocument'], indent=4, default=json_encoder))

key = 'iam.group_inline_policies'
if key not in output.keys(): output[key] = []
output[key].append(remove_metadata(get_policy['PolicyDocument']))

except botocore.exceptions.ClientError as err:
pass

Expand Down
2 changes: 0 additions & 2 deletions enumerate_iam/utils/json_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@

DEFAULT_ENCODING = 'utf-8'


def map_nested_dicts(ob, func):
if isinstance(ob, collections.Mapping):
return {k: map_nested_dicts(v, func) for k, v in ob.iteritems()}
else:
return func(ob)


def json_encoder(o):
if type(o) is datetime.date or type(o) is datetime.datetime:
return o.isoformat()
Expand Down