AWS Security11 min read

    Fixing EC2 Security Issues: A Practical Remediation Guide

    Tarek Cheikh

    Founder & AWS Cloud Architect

    Fixing EC2 security issues: a practical remediation guide

    Part 3 of 3 in the EC2 Security Series

    You ran the scanner from Part 2. You got your scores: each instance graded from 0 to 100 across 46 checks in 8 categories (A through H), plus a separate environment score for account and VPC posture. Some of those scores aren't great.

    Now let's fix everything.

    This guide maps directly to the scanner's findings. AWS CLI commands, Terraform snippets, and console steps you can use right now.

    A word of caution: Some of these changes (VPC Block Public Access, default security group lockdown, IMDSv2 enforcement) can break running workloads if applied blindly. Test in staging first. Audit before you enforce.

    Category A: Instance Security

    A.1 / A.2: Enforce IMDSv2

    This is the single most impactful fix you can make. IMDSv1 was the attack vector behind the Capital One breach.

    For existing instances:

    aws ec2 modify-instance-metadata-options \
      --instance-id i-0123456789abcdef0 \
      --http-tokens required \
      --http-endpoint enabled

    For all instances in a region (audit IMDSv1 usage first, enforcing this will break apps that rely on IMDSv1):

    # First, find instances still using IMDSv1
    aws ec2 describe-instances \
      --query "Reservations[*].Instances[?MetadataOptions.HttpTokens!='required'].[InstanceId,Tags[?Key=='Name'].Value|[0]]" \
      --output table
    
    # Then enforce IMDSv2 on all instances
    for id in $(aws ec2 describe-instances \
      --query "Reservations[*].Instances[*].InstanceId" \
      --output text); do
      aws ec2 modify-instance-metadata-options \
        --instance-id "$id" \
        --http-tokens required \
        --http-endpoint enabled
    done

    For launch templates (A.2):

    aws ec2 create-launch-template-version \
      --launch-template-id lt-0123456789abcdef0 \
      --source-version '$Latest' \
      --launch-template-data '{"MetadataOptions":{"HttpTokens":"required","HttpEndpoint":"enabled"}}'

    Terraform:

    resource "aws_instance" "example" {
      metadata_options {
        http_tokens   = "required"
        http_endpoint = "enabled"
      }
    }

    Account-wide default (prevents new instances from using IMDSv1):

    aws ec2 modify-instance-metadata-defaults \
      --region us-east-1 \
      --http-tokens required

    A.3: Remove Public IPs

    If your instance doesn't need to be directly reachable from the internet, remove the public IP.

    # Disassociate an Elastic IP
    aws ec2 disassociate-address --association-id eipassoc-0123456789abcdef0
    
    # For auto-assigned public IPs: stop the instance, change the subnet setting,
    # or launch in a private subnet behind a NAT Gateway or VPC endpoint.

    Better approach: use AWS Systems Manager Session Manager for access instead of SSH over public IPs.

    A.4: Attach IAM Instance Profiles

    Every EC2 instance that talks to AWS services needs an IAM role. No hardcoded credentials.

    aws ec2 associate-iam-instance-profile \
      --instance-id i-0123456789abcdef0 \
      --iam-instance-profile Name=my-instance-role

    A.8: Remove Secrets from UserData

    There's no “fix” button for this. You need to:

    1. Rotate every credential found in UserData immediately
    2. Move secrets to AWS Secrets Manager or SSM Parameter Store
    3. Update your launch scripts to fetch secrets at runtime
    # Store a secret in SSM Parameter Store
    aws ssm put-parameter \
      --name "/myapp/db-password" \
      --type SecureString \
      --value "your-password"
    # Fetch it in UserData at boot time
    DB_PASS=$(aws ssm get-parameter \
      --name "/myapp/db-password" \
      --with-decryption \
      --query "Parameter.Value" \
      --output text)

    Then clear the old UserData (instance must be stopped):

    aws ec2 stop-instances --instance-ids i-0123456789abcdef0
    aws ec2 modify-instance-attribute \
      --instance-id i-0123456789abcdef0 \
      --attribute userData \
      --value ""
    aws ec2 start-instances --instance-ids i-0123456789abcdef0

    Category B: Network Security

    B.1: Lock Down the Default Security Group

    The VPC default security group should have zero rules. No inbound, no outbound.

    # Get default SG ID
    DEFAULT_SG=$(aws ec2 describe-security-groups \
      --filters "Name=group-name,Values=default" \
                "Name=vpc-id,Values=vpc-0123456789abcdef0" \
      --query "SecurityGroups[0].GroupId" --output text)
    
    # Revoke all inbound rules
    aws ec2 revoke-security-group-ingress \
      --group-id "$DEFAULT_SG" \
      --ip-permissions "$(aws ec2 describe-security-groups \
        --group-ids "$DEFAULT_SG" \
        --query 'SecurityGroups[0].IpPermissions' --output json)"
    
    # Revoke all outbound rules
    aws ec2 revoke-security-group-egress \
      --group-id "$DEFAULT_SG" \
      --ip-permissions "$(aws ec2 describe-security-groups \
        --group-ids "$DEFAULT_SG" \
        --query 'SecurityGroups[0].IpPermissionsEgress' --output json)"

    B.2 / B.3 / B.4 / B.5: Close Open Ports

    Remove rules that allow 0.0.0.0/0 or ::/0 to sensitive ports.

    # Remove SSH from world
    aws ec2 revoke-security-group-ingress \
      --group-id sg-0123456789abcdef0 \
      --protocol tcp --port 22 --cidr 0.0.0.0/0

    Replace with specific CIDR ranges or use EC2 Instance Connect / SSM Session Manager.

    B.6: Enable VPC Flow Logs

    aws ec2 create-flow-logs \
      --resource-type VPC \
      --resource-ids vpc-0123456789abcdef0 \
      --traffic-type ALL \
      --log-destination-type cloud-watch-logs \
      --log-group-name /vpc/flow-logs \
      --deliver-logs-permission-arn arn:aws:iam::123456789012:role/flow-logs-role

    Terraform:

    resource "aws_flow_log" "vpc" {
      vpc_id          = aws_vpc.main.id
      traffic_type    = "ALL"
      log_destination = aws_cloudwatch_log_group.flow_logs.arn
      iam_role_arn    = aws_iam_role.flow_logs.arn
    }

    B.9: Restrict Egress

    Don't allow all outbound traffic by default. Restrict to what your application actually needs.

    # Remove the default "allow all" egress rule
    aws ec2 revoke-security-group-egress \
      --group-id sg-0123456789abcdef0 \
      --ip-permissions '[{"IpProtocol":"-1","IpRanges":[{"CidrIp":"0.0.0.0/0"}]}]'
    
    # Add specific egress rules (e.g., HTTPS only)
    aws ec2 authorize-security-group-egress \
      --group-id sg-0123456789abcdef0 \
      --protocol tcp --port 443 --cidr 0.0.0.0/0

    Category C: Storage Security

    C.1 / C.2: Enable EBS Encryption

    Account-level default (all new volumes encrypted automatically):

    aws ec2 enable-ebs-encryption-by-default --region us-east-1

    Do this in every region:

    for region in $(aws ec2 describe-regions --query "Regions[*].RegionName" --output text); do
      aws ec2 enable-ebs-encryption-by-default --region "$region"
      echo "Enabled EBS encryption in $region"
    done

    For existing unencrypted volumes, you need to create an encrypted snapshot and replace the volume.

    C.3: Fix Public EBS Snapshots

    # Find public snapshots (describe-snapshots doesn't include permissions,
    # so we check each snapshot individually)
    for snap in $(aws ec2 describe-snapshots --owner-ids self \
      --query "Snapshots[*].SnapshotId" --output text); do
      PERM=$(aws ec2 describe-snapshot-attribute \
        --snapshot-id "$snap" \
        --attribute createVolumePermission \
        --query "CreateVolumePermissions[?Group=='all']" \
        --output text)
      [ -n "$PERM" ] && echo "PUBLIC: $snap"
    done
    
    # Remove public access
    aws ec2 modify-snapshot-attribute \
      --snapshot-id snap-0123456789abcdef0 \
      --attribute createVolumePermission \
      --operation-type remove \
      --group-names all

    C.6: Fix Public AMIs

    # Find your public AMIs
    aws ec2 describe-images --owners self \
      --query "Images[?Public==`true`].[ImageId,Name]" --output table
    
    # Make them private
    aws ec2 modify-image-attribute \
      --image-id ami-0123456789abcdef0 \
      --launch-permission "Remove=[{Group=all}]"

    Category D: Access Control

    D.1: Remove Admin Permissions from Instance Roles

    Check what's attached:

    ROLE_NAME="my-instance-role"
    aws iam list-attached-role-policies --role-name "$ROLE_NAME"

    Remove overprivileged policies:

    aws iam detach-role-policy \
      --role-name "$ROLE_NAME" \
      --policy-arn arn:aws:iam::aws:policy/AdministratorAccess

    Replace with least-privilege policies. Use IAM Access Analyzer to generate policies based on actual usage:

    aws accessanalyzer start-policy-generation \
      --policy-generation-details '{"principalArn":"arn:aws:iam::123456789012:role/my-instance-role"}'

    D.3: Disable Serial Console Access

    aws ec2 disable-serial-console-access --region us-east-1

    Category E: Logging & Monitoring

    E.1: Enable CloudTrail

    aws cloudtrail create-trail \
      --name management-trail \
      --s3-bucket-name my-cloudtrail-bucket \
      --is-multi-region-trail \
      --enable-log-file-validation
    
    aws cloudtrail start-logging --name management-trail

    E.3: Enable SSM

    Install the SSM Agent (most Amazon Linux and Windows AMIs have it pre-installed):

    # Verify SSM agent is running
    aws ssm describe-instance-information \
      --query "InstanceInformationList[*].[InstanceId,PingStatus]" \
      --output table

    The instance's IAM role needs the AmazonSSMManagedInstanceCore policy.

    E.4: Enable GuardDuty

    aws guardduty create-detector \
      --enable \
      --features '[{"Name":"RUNTIME_MONITORING","Status":"ENABLED"},{"Name":"EBS_MALWARE_PROTECTION","Status":"ENABLED"}]'

    Category F: Patch & Vulnerability

    F.1: Fix Missing Patches

    # Create a patch baseline
    aws ssm create-patch-baseline \
      --name "production-baseline" \
      --approval-rules '{"PatchRules":[{"PatchFilterGroup":{"PatchFilters":[{"Key":"SEVERITY","Values":["Critical","Important"]}]},"ApproveAfterDays":7}]}'
    
    # Run patching now
    aws ssm send-command \
      --document-name "AWS-RunPatchBaseline" \
      --targets "Key=instanceids,Values=i-0123456789abcdef0" \
      --parameters "Operation=Install"

    F.2: Update Stale AMIs

    AMIs older than 180 days are flagged. Build fresh AMIs regularly:

    # Create a new AMI from a patched instance
    aws ec2 create-image \
      --instance-id i-0123456789abcdef0 \
      --name "my-app-$(date +%Y%m%d)" \
      --no-reboot

    Better: use EC2 Image Builder to automate AMI pipelines.

    F.3: Enable Inspector v2

    aws inspector2 enable --resource-types EC2

    Category G: Network Exposure

    G.1: Release Unused Elastic IPs

    # Find unused EIPs
    aws ec2 describe-addresses \
      --query "Addresses[?AssociationId==null].[AllocationId,PublicIp]" \
      --output table
    
    # Release them
    aws ec2 release-address --allocation-id eipalloc-0123456789abcdef0

    G.3: Disable Subnet Auto-Assign Public IP

    aws ec2 modify-subnet-attribute \
      --subnet-id subnet-0123456789abcdef0 \
      --no-map-public-ip-on-launch

    G.4: Enable VPC Block Public Access

    aws ec2 modify-vpc-block-public-access-options \
      --internet-gateway-block-mode block-bidirectional

    G.5: Disable Transit Gateway Auto-Accept

    aws ec2 modify-transit-gateway \
      --transit-gateway-id tgw-0123456789abcdef0 \
      --options AutoAcceptSharedAttachments=disable

    Category H: Tagging & Inventory

    H.1: Add Required Tags

    aws ec2 create-tags \
      --resources i-0123456789abcdef0 \
      --tags Key=Name,Value=my-app-server \
             Key=Environment,Value=production \
             Key=Owner,Value=platform-team

    Enforce tags at the organization level with AWS Organizations tag policies.

    H.2: Clean Up Stopped Instances

    Instances stopped for over 30 days are flagged. Either:

    1. Terminate them if no longer needed
    2. Create an AMI first, then terminate
    3. Document why they need to stay stopped
    # Create AMI before terminating
    aws ec2 create-image --instance-id i-0123456789abcdef0 \
      --name "backup-before-termination-$(date +%Y%m%d)"
    
    # Then terminate
    aws ec2 terminate-instances --instance-ids i-0123456789abcdef0

    H.3: Remove Unused Security Groups

    # The scanner flags SGs not attached to any ENI
    # Verify and delete
    aws ec2 delete-security-group --group-id sg-0123456789abcdef0

    Priority Order

    Don't try to fix everything at once. Here's the order that matters:

    1. CRITICAL first: Secrets in UserData (-25), public AMIs (-20), public snapshots (-20). These are active data exposure risks. Fix today.
    2. Security group ports: SSH/RDP/high-risk ports open to world (up to -20). Close them or restrict to specific CIDRs.
    3. IMDSv2: Enforce on all instances (-15). The single highest-impact security improvement.
    4. IAM roles: Remove admin/wildcard permissions (-15). Scope down to least privilege.
    5. Encryption: Enable EBS default encryption (-5 to -10). Turn it on everywhere.
    6. Logging: CloudTrail, VPC flow logs, GuardDuty (-10 each). You can't detect threats you can't see.
    7. Everything else: Tags, stopped instances, unused resources. Important for hygiene, lower urgency.

    Automation

    Don't do this manually every time. Set up guardrails:

    • AWS Config Rules: Automatically detect non-compliant resources
    • AWS Organizations SCPs: Prevent insecure configurations at the org level
    • Terraform/CloudFormation: Enforce security in your IaC templates
    • CI/CD pipeline checks: Scan templates before deployment
    • Schedule the scanner: Run weekly, compare scores, track progress
    # Example: weekly scan via cron
    0 6 * * 1 ec2-security-scanner security -p production -r us-east-1 -q

    Wrapping Up

    That's the full EC2 security series. Part 1 showed you the risks. Part 2 gave you the scanner. Part 3 gave you the fixes.

    46 checks. 137 controls. Every fix you need. No excuses left.

    Support the Project

    This series and the scanner behind it are open source and free. If they helped you lock down your account, here is how to give back:

    • Star it on GitHub so more engineers can find it: github.com/TocConsulting/ec2-security-scanner
    • Open a pull request to fix a bug, add a remediation, or tighten a check.
    • Propose a new check or compliance framework by opening an issue. The best ideas come from real production gaps.
    • Share it with your team and your network. Reach is what gives an open-source security tool a fighting chance.

    GitHub: github.com/TocConsulting/ec2-security-scanner  |  PyPI: pypi.org/project/ec2-security-scanner

    Go Deeper: The State of AWS Security 2026

    This article is just the start. Get the full picture with our free whitepaper - 8 chapters covering IAM, S3, VPC, monitoring, agentic AI security, compliance, and a prioritized action plan with 50+ CLI commands.

    EC2AWS SecurityRemediationIMDSv2Compliance