From 71b33eaf14afeb4a2ba53ac96d2b55dc9efc0918 Mon Sep 17 00:00:00 2001 From: Ramo Date: Fri, 6 Aug 2021 11:38:21 +1000 Subject: [PATCH] Instance profile mitigations for AWS * Issue 341 - Instance profile mitigations for AWS * Undo comment out * Added in some loggin messages * PR suggestions --- libcloudforensics/providers/aws/forensics.py | 41 ++++++++++++++- .../providers/aws/internal/ec2.py | 51 +++++++++++++++++++ .../providers/aws/internal/iam.py | 32 +++++++++++- .../iampolicies/revoke_old_sessions.json | 15 ++++++ tools/aws_cli.py | 10 ++++ tools/cli.py | 10 ++++ 6 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 libcloudforensics/providers/aws/internal/iampolicies/revoke_old_sessions.json diff --git a/libcloudforensics/providers/aws/forensics.py b/libcloudforensics/providers/aws/forensics.py index e2b11760..9969d412 100644 --- a/libcloudforensics/providers/aws/forensics.py +++ b/libcloudforensics/providers/aws/forensics.py @@ -469,7 +469,8 @@ def InstanceNetworkQuarantine( instance. Args: - instance_id (str): : The id (i-xxxxxx) of the virtual machine. + zone (str): AWS Availability Zone the instance is in. + instance_id (str): The id (i-xxxxxx) of the virtual machine. exempted_src_subnets (List[str]): List of subnets that will be permitted Raises: @@ -487,10 +488,48 @@ def InstanceNetworkQuarantine( try: aws_account = account.AWSAccount(zone) vpc = aws_account.ec2.GetInstanceById(instance_id).vpc + logger.info('Creating isolation security group') sg_id = \ aws_account.ec2.CreateIsolationSecurityGroup(vpc, exempted_src_subnets) + logger.info('Replacing attached security groups with isolation group') aws_account.ec2.SetInstanceSecurityGroup(instance_id, sg_id) except errors.ResourceNotFoundError as exception: raise errors.ResourceNotFoundError( 'Cannot qurantine non-existent instance {0:s}: {1!s}'.format(instance_id, exception), __name__) from exception + +def InstanceProfileMitigator( + zone: str, + instance_id: str, + revoke_existing: bool = False + ) -> None: + """Remove an instance profile attachment from an instance. + + Also, optionally revoke existing issued tokens for the profile. + + Args: + zone (str): AWS Availability Zone the instance is in. + instance_id (str): The id (i-xxxxxx) of the virtual machine. + revoke_existing (bool): True to revoke existing tokens for the profile's + role. False otherwise. + + Raises: + ResourceNotFoundError: If the instance cannot be found, or does not have a + profile attachment. + """ + logger.info('Finding profile attachment') + aws_account = account.AWSAccount(zone) + assoc_id, profile = aws_account.ec2.GetInstanceProfileAttachment(instance_id) + + if not profile or not assoc_id: + raise errors.ResourceNotFoundError( + 'Instance not found or does not have a profile attachment: {0:s}'. + format(instance_id), __name__) + + logger.info('Removing profile attachment') + aws_account.ec2.DisassociateInstanceProfile(assoc_id) + + if revoke_existing: + logger.info('Invalidating old tokens') + role_name = profile.split('/')[1] + aws_account.iam.RevokeOldSessionsForRole(role_name) diff --git a/libcloudforensics/providers/aws/internal/ec2.py b/libcloudforensics/providers/aws/internal/ec2.py index 8465275f..a5d0e813 100644 --- a/libcloudforensics/providers/aws/internal/ec2.py +++ b/libcloudforensics/providers/aws/internal/ec2.py @@ -676,6 +676,57 @@ def SetInstanceSecurityGroup( 'Could not modify instance attributes: {0!s}'.format( exception), __name__) from exception + def GetInstanceProfileAttachment( + self, + instance_id: str) -> Tuple[str, str]: + """Get the instance profile attached to the instance, if any. + + Args: + instance_id (str): The instance ID (i-xxxxxx) + + Returns: + Tuple[str, str]: A tuple containing the association id of the profile + attachment, and the Arn of the Instance profile attached. Both are None + if no profile is attached, or the instance cannot be found. + """ + try: + client = self.aws_account.ClientApi(common.EC2_SERVICE) + response = client.describe_iam_instance_profile_associations( + Filters=[{ + 'Name': 'instance-id', + 'Values': [instance_id] + }]) + print(response) + if not response['IamInstanceProfileAssociations']: + return '', '' + return response['IamInstanceProfileAssociations'][0]['AssociationId'],\ + response['IamInstanceProfileAssociations'][0]['IamInstanceProfile']\ + ['Arn'] + except client.exceptions.ClientError as exception: + raise errors.ResourceNotFoundError( + 'Instance does not exist: {0!s}'.format( + exception), __name__) from exception + + def DisassociateInstanceProfile( + self, + assoc_id: str) -> None: + """Remove an instance profile attachment from an instance. + + Args: + assoc_id (str): The instance profile association ID. Can be retrieved via + ec2.GetInstanceProfileAttachment. + + Raises: + ResourceNotFoundError: If the assoc_id cannot be found. + """ + try: + client = self.aws_account.ClientApi(common.EC2_SERVICE) + client.disassociate_iam_instance_profile(AssociationId=assoc_id) + except client.exceptions.ClientError as exception: + raise errors.ResourceNotFoundError( + 'AssociationID {0:s} not found: {1!s}'.format(assoc_id, + exception), __name__) from exception + def GetSnapshotInfo( self, snapshot_id: str, diff --git a/libcloudforensics/providers/aws/internal/iam.py b/libcloudforensics/providers/aws/internal/iam.py index 02d420b4..07589c92 100644 --- a/libcloudforensics/providers/aws/internal/iam.py +++ b/libcloudforensics/providers/aws/internal/iam.py @@ -14,8 +14,9 @@ # limitations under the License. """AWS IAM Functionality""" +import datetime +import json import os - from typing import TYPE_CHECKING, Optional, Tuple from libcloudforensics import errors from libcloudforensics import logging_utils @@ -37,6 +38,9 @@ # Policy doc to allow EC2 to assume the role. Necessary for instance profiles EC2_ASSUME_ROLE_POLICY_DOC = 'ec2_assume_role_policy.json' +# Policy to deny all session tokens generated after a date +IAM_DENY_ALL_AFTER_TOKEN_ISSUE_DATE = 'revoke_old_sessions.json' + class IAM: """Class that represents AWS IAM services""" @@ -292,6 +296,32 @@ def DetachInstanceProfileFromRole(self, role_name: str, profile_name: str) \ pass # It doesn't matter if this fails. + def RevokeOldSessionsForRole(self, role_name: str) -> None: + """Revoke old session tokens for a role. + + This is acheived by adding an inline policy to the role, Deny *:* on the + condition of TokenIssueTime. + + Args: + role_name (str): The role name to act on. + """ + now = datetime.datetime.now().strftime("%Y-%m-%dT%H:%M:%S.000Z") + policy = json.loads(ReadPolicyDoc(IAM_DENY_ALL_AFTER_TOKEN_ISSUE_DATE)) + policy['Statement'][0]['Condition']['DateLessThan']['aws:TokenIssueTime']\ + = now + policy = json.dumps(policy) + + try: + self.client.put_role_policy( + RoleName=role_name, + PolicyName='RevokeOldSessions', + PolicyDocument=policy + ) + except self.client.exceptions.ClientError as exception: + raise errors.ResourceNotFoundError( + 'Could not add inline policy to IAM role {0:s}: {1!s}'.format( + role_name, exception), __name__) from exception + def ReadPolicyDoc(filename: str) -> str: """Read and return the IAM policy doc at filename. diff --git a/libcloudforensics/providers/aws/internal/iampolicies/revoke_old_sessions.json b/libcloudforensics/providers/aws/internal/iampolicies/revoke_old_sessions.json new file mode 100644 index 00000000..1bfcfba0 --- /dev/null +++ b/libcloudforensics/providers/aws/internal/iampolicies/revoke_old_sessions.json @@ -0,0 +1,15 @@ +{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Deny", + "Action": "*", + "Resource": "*", + "Condition": { + "DateLessThan": { + "aws:TokenIssueTime": "DATE" + } + } + } + ] +} diff --git a/tools/aws_cli.py b/tools/aws_cli.py index f292e8fa..ab91a16b 100644 --- a/tools/aws_cli.py +++ b/tools/aws_cli.py @@ -287,3 +287,13 @@ def InstanceNetworkQuarantine(args: 'argparse.Namespace') -> None: return forensics.InstanceNetworkQuarantine(args.zone, args.instance_id, exempted_src_subnets) + +def InstanceProfileMitigator(args: 'argparse.Namespace') -> None: + """Remove an instance profile attachment from an instance. Also, optionally + revoke existing issued tokens for the profile. + + Args: + args (argparse.Namespace): Arguments from ArgumentParser. + """ + + forensics.InstanceProfileMitigator(args.zone, args.instance_id, args.revoke) diff --git a/tools/cli.py b/tools/cli.py index 78ed9b14..347da723 100644 --- a/tools/cli.py +++ b/tools/cli.py @@ -31,6 +31,7 @@ 'deleteinstance': aws_cli.DeleteInstance, 'gcstos3': aws_cli.GCSToS3, 'imageebssnapshottos3': aws_cli.ImageEBSSnapshotToS3, + 'instanceprofilemitigator': aws_cli.InstanceProfileMitigator, 'listdisks': aws_cli.ListVolumes, 'listimages': aws_cli.ListImages, 'listinstances': aws_cli.ListInstances, @@ -255,6 +256,15 @@ def Main() -> None: ('--exempted_src_subnets', 'Comma separated list of source ' 'subnets to exempt from ingress firewall rules.', None) ]) + AddParser('aws', aws_subparsers, 'instanceprofilemitigator', + 'Remove an instance profile from an instance, and optionally ' + 'revoke all previously issued temporary credentials.', + args=[ + ('instance_id', 'ID (i-xxxxxx) of the instance to quarantine.', + None), + ('--revoke', 'Revoke existing temporary creds for the instance' + ' profile.', False) + ]) # Azure parser options az_parser.add_argument('default_resource_group_name',