Skip to main content

EC2 Instance Internet-Facing with Instance Profile

Overview

This check identifies EC2 instances that have both a public IP address and an attached instance profile (IAM role). When an instance is exposed to the internet and has an IAM role, it creates a larger attack surface. This combination allows potential attackers who compromise the instance to access AWS resources using the attached role's credentials.

Risk

Publicly accessible instances with IAM roles create significant security risks:

  • Credential theft: Attackers can steal IAM credentials from the instance metadata service (IMDS) and use them to access other AWS resources
  • Lateral movement: Compromised credentials enable attackers to move through your AWS environment, accessing databases, S3 buckets, and other services
  • Data exfiltration: Stolen credentials can be used to download sensitive data from connected AWS services
  • Privilege escalation: If the IAM role has broad permissions, attackers gain extensive access to your infrastructure
  • Blast radius expansion: The combination of internet exposure and IAM credentials dramatically increases potential damage from a security incident

Remediation Steps

Prerequisites

  • AWS account access with permissions to modify EC2 instances and IAM role associations
  • Understanding of whether the instance needs the IAM role and/or public access
Required IAM permissions

You will need these permissions:

  • ec2:DescribeInstances - View instance details
  • ec2:DescribeIamInstanceProfileAssociations - List IAM profile associations
  • ec2:DisassociateIamInstanceProfile - Remove IAM profiles from instances
  • ec2:ModifyInstanceAttribute - Modify instance settings
  • ec2:DisassociateAddress - Remove Elastic IP addresses
  • ec2:StopInstances / ec2:StartInstances - Stop and start instances (for subnet changes)

AWS Console Method

You have two options to fix this finding: remove the IAM role from the instance, or remove internet exposure. Choose the approach that fits your use case.


Option A: Remove the IAM Role

Use this approach if the instance does not need AWS API access, or if you can provide credentials another way.

  1. Sign in to the AWS Management Console
  2. Navigate to EC2 > Instances
  3. Select the flagged instance
  4. Click Actions > Security > Modify IAM role
  5. In the IAM role dropdown, select No IAM Role (or leave it blank)
  6. Click Update IAM role

Option B: Remove Internet Exposure

Use this approach if the instance needs the IAM role but does not need direct public access.

Remove an Elastic IP (if attached):

  1. In the EC2 console, select the instance
  2. Go to the Networking tab
  3. Find the Elastic IP address section
  4. Click the Elastic IP link to open it
  5. Click Actions > Disassociate Elastic IP address
  6. Confirm by clicking Disassociate

Move to a private subnet (if using auto-assigned public IP):

  1. Stop the instance (Actions > Instance State > Stop instance)
  2. Go to VPC > Subnets and create or identify a private subnet (one without a route to an Internet Gateway)
  3. Create a new instance in the private subnet with the same configuration
  4. Terminate the old public instance
  5. Use a bastion host, VPN, or AWS Systems Manager Session Manager to access the new private instance

Option C: Implement Defense in Depth (If Public Access is Required)

If your instance genuinely needs both public access and an IAM role:

  1. Enforce IMDSv2: Navigate to the instance, click Actions > Instance settings > Modify instance metadata options, then set IMDSv2 to Required
  2. Place behind a load balancer: Create an Application Load Balancer to front the instance and remove the direct public IP
  3. Attach AWS WAF: Add AWS WAF to the load balancer to filter malicious traffic
  4. Minimize IAM permissions: Review the attached role and apply least-privilege principles
  5. Enable VPC Flow Logs: Monitor network traffic for suspicious activity
AWS CLI (optional)

Find the IAM instance profile association for an instance:

aws ec2 describe-iam-instance-profile-associations \
--filters "Name=instance-id,Values=<your-instance-id>" \
--region us-east-1

This returns the AssociationId you need for the next step.

Remove the IAM role from the instance:

aws ec2 disassociate-iam-instance-profile \
--association-id <association-id> \
--region us-east-1

Replace <association-id> with the value from the previous command (e.g., iip-assoc-05020b59952902f5f).

Disassociate an Elastic IP:

aws ec2 disassociate-address \
--association-id <eip-association-id> \
--region us-east-1

Enforce IMDSv2 on the instance:

aws ec2 modify-instance-metadata-options \
--instance-id <your-instance-id> \
--http-tokens required \
--http-endpoint enabled \
--region us-east-1
CloudFormation (optional)

Use these patterns to ensure new instances are launched securely.

Private instance with IAM role (recommended):

AWSTemplateFormatVersion: '2010-09-09'
Description: EC2 instance in private subnet with IAM role

Parameters:
VpcId:
Type: AWS::EC2::VPC::Id
Description: VPC for the instance
PrivateSubnetId:
Type: AWS::EC2::Subnet::Id
Description: Private subnet (no Internet Gateway route)
InstanceType:
Type: String
Default: t3.micro

Resources:
InstanceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
ManagedPolicyArns:
- arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore # For Session Manager access
# Add only the minimum permissions needed

InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles:
- !Ref InstanceRole

SecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Minimal security group for private instance
VpcId: !Ref VpcId
# No inbound rules - access via Session Manager only

PrivateInstance:
Type: AWS::EC2::Instance
Properties:
InstanceType: !Ref InstanceType
SubnetId: !Ref PrivateSubnetId
IamInstanceProfile: !Ref InstanceProfile
SecurityGroupIds:
- !Ref SecurityGroup
# No public IP - instance is private
MetadataOptions:
HttpTokens: required # Enforce IMDSv2
HttpEndpoint: enabled
Tags:
- Key: Name
Value: private-instance-with-role

Outputs:
InstanceId:
Description: ID of the private instance
Value: !Ref PrivateInstance

Public instance behind ALB with WAF (if public access required):

AWSTemplateFormatVersion: '2010-09-09'
Description: Public-facing instance behind ALB with WAF protection

Parameters:
VpcId:
Type: AWS::EC2::VPC::Id
PublicSubnetIds:
Type: List<AWS::EC2::Subnet::Id>
Description: At least two public subnets for ALB
PrivateSubnetId:
Type: AWS::EC2::Subnet::Id
Description: Private subnet for the instance

Resources:
# Instance in private subnet
AppInstance:
Type: AWS::EC2::Instance
Properties:
InstanceType: t3.micro
SubnetId: !Ref PrivateSubnetId
IamInstanceProfile: !Ref InstanceProfile
MetadataOptions:
HttpTokens: required
HttpEndpoint: enabled
# Instance is private - accessed only through ALB

# ALB in public subnets
ApplicationLoadBalancer:
Type: AWS::ElasticLoadBalancingV2::LoadBalancer
Properties:
Type: application
Subnets: !Ref PublicSubnetIds
SecurityGroups:
- !Ref ALBSecurityGroup

# WAF association
WebACLAssociation:
Type: AWS::WAFv2::WebACLAssociation
Properties:
ResourceArn: !Ref ApplicationLoadBalancer
WebACLArn: !Ref WebACL

WebACL:
Type: AWS::WAFv2::WebACL
Properties:
Scope: REGIONAL
DefaultAction:
Allow: {}
Rules:
- Name: AWSManagedRulesCommonRuleSet
Priority: 1
OverrideAction:
None: {}
Statement:
ManagedRuleGroupStatement:
VendorName: AWS
Name: AWSManagedRulesCommonRuleSet
VisibilityConfig:
CloudWatchMetricsEnabled: true
MetricName: CommonRules
SampledRequestsEnabled: true
VisibilityConfig:
CloudWatchMetricsEnabled: true
MetricName: WebACL
SampledRequestsEnabled: true

InstanceRole:
Type: AWS::IAM::Role
Properties:
AssumeRolePolicyDocument:
Version: '2012-10-17'
Statement:
- Effect: Allow
Principal:
Service: ec2.amazonaws.com
Action: sts:AssumeRole
# Add only minimum required permissions

InstanceProfile:
Type: AWS::IAM::InstanceProfile
Properties:
Roles:
- !Ref InstanceRole

ALBSecurityGroup:
Type: AWS::EC2::SecurityGroup
Properties:
GroupDescription: Security group for ALB
VpcId: !Ref VpcId
SecurityGroupIngress:
- IpProtocol: tcp
FromPort: 443
ToPort: 443
CidrIp: 0.0.0.0/0

Deploy the template:

aws cloudformation deploy \
--template-file ec2-private-instance.yaml \
--stack-name secure-ec2-instance \
--parameter-overrides \
VpcId=vpc-12345678 \
PrivateSubnetId=subnet-12345678 \
--capabilities CAPABILITY_IAM \
--region us-east-1
Terraform (optional)

Private instance with IAM role:

provider "aws" {
region = "us-east-1"
}

variable "vpc_id" {
description = "VPC ID for the instance"
type = string
}

variable "private_subnet_id" {
description = "Private subnet ID (no Internet Gateway route)"
type = string
}

# IAM role with minimum permissions
resource "aws_iam_role" "instance_role" {
name = "private-instance-role"

assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
}]
})
}

# Allow Session Manager access (for remote access without public IP)
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.instance_role.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

resource "aws_iam_instance_profile" "instance_profile" {
name = "private-instance-profile"
role = aws_iam_role.instance_role.name
}

# Security group with minimal access
resource "aws_security_group" "instance_sg" {
name = "private-instance-sg"
description = "Security group for private instance"
vpc_id = var.vpc_id

# No inbound rules - access via Session Manager
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}

# Private instance with IAM role
resource "aws_instance" "private_instance" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
subnet_id = var.private_subnet_id
iam_instance_profile = aws_iam_instance_profile.instance_profile.name
vpc_security_group_ids = [aws_security_group.instance_sg.id]

# No public IP - instance stays private
associate_public_ip_address = false

# Enforce IMDSv2
metadata_options {
http_tokens = "required"
http_endpoint = "enabled"
}

tags = {
Name = "private-instance-with-role"
}
}

data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]

filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}

output "instance_id" {
description = "ID of the private instance"
value = aws_instance.private_instance.id
}

Key Terraform settings to prevent this issue:

# Always set this to false for instances with IAM roles
resource "aws_instance" "example" {
# ...
associate_public_ip_address = false

# Always enforce IMDSv2
metadata_options {
http_tokens = "required"
http_endpoint = "enabled"
}
}

# Use private subnets that don't auto-assign public IPs
resource "aws_subnet" "private" {
# ...
map_public_ip_on_launch = false
}

Verification

After completing the remediation:

  1. Go to EC2 > Instances in the AWS Console
  2. Select the instance you modified
  3. Check the Details tab:
    • If you removed the IAM role: IAM Role should be empty or show "-"
    • If you removed internet exposure: Public IPv4 address should be empty or show "-"
  4. Re-run the Prowler check to confirm the issue is resolved:
    prowler aws -c ec2_instance_internet_facing_with_instance_profile
CLI verification commands

Check if the instance still has an IAM profile:

aws ec2 describe-iam-instance-profile-associations \
--filters "Name=instance-id,Values=<your-instance-id>" \
--region us-east-1

If the IAM role was removed, this returns an empty IamInstanceProfileAssociations array.

Check if the instance has a public IP:

aws ec2 describe-instances \
--instance-ids <your-instance-id> \
--query 'Reservations[0].Instances[0].{PublicIP:PublicIpAddress,IAMProfile:IamInstanceProfile.Arn}' \
--region us-east-1

A secure instance will show either:

  • PublicIP: null (no public exposure), or
  • IAMProfile: null (no IAM role attached)

Check IMDSv2 enforcement:

aws ec2 describe-instances \
--instance-ids <your-instance-id> \
--query 'Reservations[0].Instances[0].MetadataOptions' \
--region us-east-1

Look for "HttpTokens": "required" to confirm IMDSv2 is enforced.

Additional Resources

Notes

  • Evaluate before changing: Understand why the instance has both public access and an IAM role before removing either. Some applications legitimately need this configuration.
  • IMDSv2 is critical: Even if you cannot remove the public IP or IAM role, enforcing IMDSv2 significantly reduces the risk of credential theft through SSRF attacks.
  • Session Manager for access: When moving instances to private subnets, use AWS Systems Manager Session Manager for remote access instead of SSH through a bastion host. It provides auditing and does not require inbound security group rules.
  • Least privilege: Review the permissions of any IAM role attached to internet-facing instances. Reduce permissions to the minimum required.
  • Consider architecture changes: Long-term, consider moving workloads behind load balancers with WAF protection rather than exposing instances directly to the internet.
  • VPC endpoints: For instances that need to call AWS APIs but not general internet access, use VPC endpoints to keep traffic private.