DEV Community

Tarek CHEIKH
Tarek CHEIKH

Posted on • Originally published at tarekcheikh.Medium on

Fixing EC2 Security Issues: A Practical Remediation Guide

Part 3 of 3 in the EC2 Security Series

EC2 Remediation

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}}'
Enter fullscreen mode Exit fullscreen mode

Terraform:

resource "aws_instance" "example" {
  metadata_options {
    http_tokens = "required"
    http_endpoint = "enabled"
  }
}
Enter fullscreen mode Exit fullscreen mode

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

aws ec2 modify-instance-metadata-defaults \
  --region us-east-1 \
  --http-tokens required
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 (or Secrets Manager)
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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}]"
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

Remove overprivileged policies:

aws iam detach-role-policy \
  --role-name "$ROLE_NAME" \
  --policy-arn arn:aws:iam::aws:policy/AdministratorAccess
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode

D.3: Disable Serial Console Access

aws ec2 disable-serial-console-access --region us-east-1
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"}]'
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Better: use EC2 Image Builder to automate AMI pipelines.

F.3: Enable Inspector v2

aws inspector2 enable --resource-types EC2
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

G.3: Disable Subnet Auto-Assign Public IP

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

G.4: Enable VPC Block Public Access

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

G.5: Disable Transit Gateway Auto-Accept

aws ec2 modify-transit-gateway \
  --transit-gateway-id tgw-0123456789abcdef0 \
  --options AutoAcceptSharedAttachments=disable
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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: https://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.

Cloud attacks are getting faster and more automated in the AI era. The more contributors and eyes on tools like this, the harder we make it for attackers. Every star, issue, and pull request pushes cloud security forward.

GitHub: https://github.com/TocConsulting/ec2-security-scanner

PyPI : https://pypi.org/project/ec2-security-scanner/

If you found this series useful, follow me for more AWS security content. IAM, RDS, Lambda, and ECS/EKS series are coming next.

Top comments (0)