June 2, 2026
Fixing EC2 Security Issues: A Practical Remediation Guide
Part 3 of 3 in the EC2 Security Series
Tarek CHEIKH
7 min read
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 enabledaws ec2 modify-instance-metadata-options \
--instance-id i-0123456789abcdef0 \
--http-tokens required \
--http-endpoint enabledFor 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# 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
doneFor 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"}}'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"
}
}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 requiredaws ec2 modify-instance-metadata-defaults \
--region us-east-1 \
--http-tokens requiredA.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.# 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-roleaws ec2 associate-iam-instance-profile \
--instance-id i-0123456789abcdef0 \
--iam-instance-profile Name=my-instance-roleA.8: Remove Secrets from UserData
There's no "fix" button for this. You need to:
- Rotate every credential found in UserData immediately
- Move secrets to AWS Secrets Manager or SSM Parameter Store
- 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)# 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)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-0123456789abcdef0aws 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-0123456789abcdef0Category 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)"# 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# Remove SSH from world
aws ec2 revoke-security-group-ingress \
--group-id sg-0123456789abcdef0 \
--protocol tcp --port 22 --cidr 0.0.0.0/0Replace 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-roleaws 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-roleTerraform:
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
}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# 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/0Category 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-1aws ec2 enable-ebs-encryption-by-default --region us-east-1Do 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"
donefor 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"
doneFor 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# 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 allC.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}]"# 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"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/AdministratorAccessaws iam detach-role-policy \
--role-name "$ROLE_NAME" \
--policy-arn arn:aws:iam::aws:policy/AdministratorAccessReplace 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"}'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-1aws ec2 disable-serial-console-access --region us-east-1Category 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-trailaws 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-trailE.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# Verify SSM agent is running
aws ssm describe-instance-information \
--query "InstanceInformationList[*].[InstanceId,PingStatus]" \
--output tableThe 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"}]'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"# 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# Create a new AMI from a patched instance
aws ec2 create-image \
--instance-id i-0123456789abcdef0 \
--name "my-app-$(date +%Y%m%d)" \
--no-rebootBetter: use EC2 Image Builder to automate AMI pipelines.
F.3: Enable Inspector v2
aws inspector2 enable --resource-types EC2aws inspector2 enable --resource-types EC2Category 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# Find unused EIPs
aws ec2 describe-addresses \
--query "Addresses[?AssociationId==null].[AllocationId,PublicIp]" \
--output table
# Release them
aws ec2 release-address --allocation-id eipalloc-0123456789abcdef0G.3: Disable Subnet Auto-Assign Public IP
aws ec2 modify-subnet-attribute \
--subnet-id subnet-0123456789abcdef0 \
--no-map-public-ip-on-launchaws ec2 modify-subnet-attribute \
--subnet-id subnet-0123456789abcdef0 \
--no-map-public-ip-on-launchG.4: Enable VPC Block Public Access
aws ec2 modify-vpc-block-public-access-options \
--internet-gateway-block-mode block-bidirectionalaws ec2 modify-vpc-block-public-access-options \
--internet-gateway-block-mode block-bidirectionalG.5: Disable Transit Gateway Auto-Accept
aws ec2 modify-transit-gateway \
--transit-gateway-id tgw-0123456789abcdef0 \
--options AutoAcceptSharedAttachments=disableaws ec2 modify-transit-gateway \
--transit-gateway-id tgw-0123456789abcdef0 \
--options AutoAcceptSharedAttachments=disableCategory 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-teamaws ec2 create-tags \
--resources i-0123456789abcdef0 \
--tags Key=Name,Value=my-app-server \
Key=Environment,Value=production \
Key=Owner,Value=platform-teamEnforce 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:
- Terminate them if no longer needed
- Create an AMI first, then terminate
- 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# 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-0123456789abcdef0H.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# The scanner flags SGs not attached to any ENI
# Verify and delete
aws ec2 delete-security-group --group-id sg-0123456789abcdef0Priority Order
Don't try to fix everything at once. Here's the order that matters:
-
CRITICAL first: Secrets in UserData (-25), public AMIs (-20), public snapshots (-20). These are active data exposure risks. Fix today.
-
Security group ports: SSH/RDP/high-risk ports open to world (up to -20). Close them or restrict to specific CIDRs.
-
IMDSv2: Enforce on all instances (-15). The single highest-impact security improvement.
-
IAM roles: Remove admin/wildcard permissions (-15). Scope down to least privilege.
-
Encryption: Enable EBS default encryption (-5 to -10). Turn it on everywhere.
-
Logging: CloudTrail, VPC flow logs, GuardDuty (-10 each). You can't detect threats you can't see.
-
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# Example: weekly scan via cron
0 6 * * 1 ec2-security-scanner security -p production -r us-east-1 -qWrapping 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.