AWS CloudFormation Security Testing: IaC Misconfiguration, Stack Exploitation, and Parameter Exposure

AWS CloudFormation provisions infrastructure at scale — and security weaknesses propagate at the same scale. Misconfigured templates create S3 buckets with public access, overprivileged IAM roles, and unencrypted data stores across every environment that deploys the template. This guide covers CloudFormation-specific security testing: template scanning with cfn-nag and Checkov, parameter secret extraction, cross-stack output exploitation, custom resource Lambda abuse, drift-based attack detection, and StackSets privilege escalation.

Table of Contents

  1. Template Misconfiguration Scanning
  2. Parameter and Output Secret Exposure
  3. Cross-Stack Output Exploitation
  4. Custom Resource Lambda Abuse
  5. CloudFormation Drift and Tampering
  6. StackSets Privilege Escalation
  7. CloudFormation Service Role Abuse
  8. CloudFormation Security Hardening

Template Misconfiguration Scanning

cfn-nag Static Analysis

# Install cfn-nag
gem install cfn-nag

# Scan a single template
cfn_nag_scan --input-filename template.yaml

# Scan all templates recursively
cfn_nag_scan --input-filename "**/*.yaml" --template-pattern "*.template"

# Output in JSON for CI integration
cfn_nag_scan --input-filename template.yaml --output-format json

# Common cfn-nag findings:
# W1: No S3 bucket policy specified
# W2: Security group allows unrestricted ingress
# W9: Security group allows unrestricted egress to 0.0.0.0/0
# W28: Resource found with an explicit name
# F3: IAM role with Action wildcards
# F38: IAM ManagedPolicy with Action wildcards
# F1000: Missing explicit deny for security group

Checkov for CloudFormation

# Scan CloudFormation templates with Checkov
checkov -f template.yaml --framework cloudformation

# Scan entire directory
checkov -d ./cloudformation/ --framework cloudformation

# Output SARIF for GitHub Advanced Security
checkov -d ./cloudformation/ --output sarif --output-file cfn-results.sarif

# Key checks for CloudFormation:
# CKV_AWS_1:  Ensure all EBS volumes have encryption enabled
# CKV_AWS_2:  Ensure all EC2 instances are launched with IAM instance profiles
# CKV_AWS_3:  Ensure all buckets have versioning enabled
# CKV_AWS_17: Ensure RDS is not publicly accessible
# CKV_AWS_18: Ensure S3 bucket has access logging enabled
# CKV_AWS_19: Ensure all S3 buckets have encryption enabled
# CKV_AWS_21: Ensure S3 bucket has versioning enabled
# CKV_AWS_23: Ensure security groups do not allow ingress from 0.0.0.0/0 to port 22/3389

Manual Template Review

# Find security group rules with open ingress
grep -A5 "SecurityGroupIngress" template.yaml | grep -E "0\.0\.0\.0/0|::/0"

# Find IAM policies with wildcards
grep -B2 -A10 "Action:" template.yaml | grep -E '"\*"|".*:\*"'

# Find unencrypted S3 buckets (missing server-side encryption config)
python3 - <<'EOF'
import yaml, sys

with open("template.yaml") as f:
    template = yaml.safe_load(f)

resources = template.get("Resources", {})
for name, resource in resources.items():
    rtype = resource.get("Type", "")
    props = resource.get("Properties", {})

    if rtype == "AWS::S3::Bucket":
        if "BucketEncryption" not in props:
            print(f"WARN: S3 bucket {name} missing encryption")
        if "PublicAccessBlockConfiguration" not in props:
            print(f"WARN: S3 bucket {name} missing PublicAccessBlock")

    if rtype == "AWS::RDS::DBInstance":
        if not props.get("StorageEncrypted", False):
            print(f"CRITICAL: RDS {name} not encrypted at rest")
        if props.get("PubliclyAccessible", True):
            print(f"CRITICAL: RDS {name} publicly accessible")

    if rtype == "AWS::IAM::Role":
        for stmt in props.get("Policies", []):
            doc = stmt.get("PolicyDocument", {})
            for s in doc.get("Statement", []):
                if "*" in str(s.get("Action", "")) and s.get("Effect") == "Allow":
                    print(f"CRITICAL: IAM Role {name} has wildcard Action in policy")
EOF

Parameter and Output Secret Exposure

CloudFormation parameters store configuration passed at deployment time. Parameters of type String are stored in plaintext in the stack's parameter history — visible to anyone with cloudformation:DescribeStacks.

Extracting Stack Parameters

# List all stacks and their parameters
aws cloudformation describe-stacks --query 'Stacks[].{Name:StackName,Status:StackStatus}' --output table

# Get parameters for a specific stack (includes plaintext String parameters)
aws cloudformation describe-stacks --stack-name production-app | \
  python3 -c "
import json,sys
for stack in json.load(sys.stdin)['Stacks']:
    for param in stack.get('Parameters', []):
        print(f\"{param['ParameterKey']} = {param['ParameterValue']}\")
"

# Look for secrets stored as plain String parameters
# Common vulnerable patterns:
# DatabasePassword: my-plaintext-password
# ApiKey: sk-prod-abc123
# SlackWebhookUrl: https://hooks.slack.com/services/...

# Get stack outputs (cross-stack values — may include ARNs, endpoints, tokens)
aws cloudformation describe-stacks --stack-name production-app | \
  python3 -c "
import json,sys
for stack in json.load(sys.stdin)['Stacks']:
    for output in stack.get('Outputs', []):
        print(f\"Key: {output['OutputKey']} Value: {output['OutputValue']}\")
"

Stack Events — Secrets in Event History

# Stack events log resource property values during deployment
# Secrets passed as parameters sometimes appear in event logs

aws cloudformation describe-stack-events --stack-name production-app | \
  python3 -c "
import json,sys
events = json.load(sys.stdin)['StackEvents']
for e in events:
    status_reason = e.get('ResourceStatusReason', '')
    props = e.get('ResourceProperties', '{}')
    # Check for secret-looking values in resource properties
    if any(kw in props.lower() for kw in ['password','secret','token','key']):
        print(f\"Event: {e['LogicalResourceId']} Props: {props[:200]}\")
"

# CloudFormation change sets also log parameter values
aws cloudformation describe-change-set \
  --stack-name production-app \
  --change-set-name my-change-set \
  --query 'Parameters'

Cross-Stack Output Exploitation

CloudFormation stack outputs can be exported and imported by other stacks. Overpermissive IAM access lets an attacker enumerate all exports to discover internal ARNs, endpoints, and credentials.

# List all stack exports (available to any stack in the same account/region)
aws cloudformation list-exports | \
  python3 -c "
import json,sys
for export in json.load(sys.stdin)['Exports']:
    print(f\"Export: {export['Name']} Value: {export['Value']} From: {export['ExportingStackId'].split('/')[-2]}\")
"

# Find sensitive exports
aws cloudformation list-exports | \
  python3 -c "
import json,sys
for e in json.load(sys.stdin)['Exports']:
    if any(kw in e['Name'].lower() for kw in ['arn','endpoint','url','key','secret','token','password']):
        print(f\"{e['Name']} = {e['Value']}\")
"

# Cross-stack output imports can reveal which stacks consume sensitive values
aws cloudformation list-imports --export-name DatabaseEndpointURL

Custom Resource Lambda Abuse

Custom resources allow CloudFormation to invoke Lambda functions during stack operations. The Lambda execution role often has broad permissions, and the function code may process untrusted input from the template.

# Find stacks with custom resources
aws cloudformation describe-stacks --query 'Stacks[].StackName' --output text | \
  while read stack; do
    custom=$(aws cloudformation get-template --stack-name "$stack" | \
      python3 -c "import json,sys; t=json.load(sys.stdin)['TemplateBody']; print(sum(1 for r in t.get('Resources',{}).values() if r.get('Type','').startswith('Custom::')))" 2>/dev/null)
    [ "$custom" -gt 0 ] && echo "Stack: $stack has $custom custom resources"
  done

# Inspect a custom resource Lambda function's IAM role
LAMBDA_ARN=$(aws cloudformation describe-stack-resource \
  --stack-name production-app \
  --logical-resource-id CustomDNSRecord \
  --query 'StackResourceDetail.PhysicalResourceId' --output text)

# Get the Lambda function's execution role
aws lambda get-function --function-name $LAMBDA_ARN \
  --query 'Configuration.{Role:Role,Handler:Handler,Runtime:Runtime}'

# Check the execution role permissions
ROLE_NAME=$(aws lambda get-function --function-name $LAMBDA_ARN \
  --query 'Configuration.Role' --output text | cut -d'/' -f2)
aws iam list-attached-role-policies --role-name $ROLE_NAME
aws iam list-role-policies --role-name $ROLE_NAME
High Risk: Custom resource Lambda functions frequently have AdministratorAccess or broad IAM permissions to provision any resource type. If an attacker can influence template parameters that flow into the Lambda, they may achieve privilege escalation to admin.
# Trigger a custom resource Lambda by updating the stack with modified parameters
# (requires cloudformation:UpdateStack permission)

# Find the custom resource properties that flow to the Lambda
aws cloudformation get-template --stack-name production-app | \
  python3 -c "
import json,sys
t=json.load(sys.stdin)['TemplateBody']
for name, resource in t.get('Resources',{}).items():
    if resource.get('Type','').startswith('Custom::'):
        print(f'Resource: {name}')
        print(f'Properties: {json.dumps(resource.get(\"Properties\",{}), indent=2)}')
"

CloudFormation Drift and Tampering

CloudFormation drift occurs when resources are modified outside of CloudFormation (via AWS Console, CLI, or other means). Drift can reveal unauthorized changes made by an attacker after initial access.

# Initiate drift detection on a stack
DRIFT_ID=$(aws cloudformation detect-stack-drift --stack-name production-app \
  --query 'StackDriftDetectionId' --output text)

# Wait for detection to complete
aws cloudformation describe-stack-drift-detection-status \
  --stack-drift-detection-id $DRIFT_ID

# List drifted resources
aws cloudformation describe-stack-resource-drifts \
  --stack-name production-app \
  --stack-resource-drift-status-filters MODIFIED DELETED | \
  python3 -c "
import json,sys
for drift in json.load(sys.stdin)['StackResourceDrifts']:
    print(f\"Resource: {drift['LogicalResourceId']} ({drift['ResourceType']})\")
    print(f\"Status: {drift['StackResourceDriftStatus']}\")
    if 'PropertyDifferences' in drift:
        for diff in drift['PropertyDifferences']:
            print(f\"  Property: {diff['PropertyPath']}\")
            print(f\"  Expected: {diff['ExpectedValue']}\")
            print(f\"  Actual:   {diff['ActualValue']}\")
    print()
"

StackSets Privilege Escalation

AWS CloudFormation StackSets deploys stacks to multiple accounts and regions from a central management account. StackSet administration permissions can be abused for cross-account privilege escalation.

# List all StackSets (requires cloudformation:ListStackSets)
aws cloudformation list-stack-sets --status ACTIVE

# Get StackSet details including target accounts and IAM roles
aws cloudformation describe-stack-set --stack-set-name production-baseline | \
  python3 -c "
import json,sys
ss=json.load(sys.stdin)['StackSet']
print(f\"Admin Role: {ss.get('AdministrationRoleARN','default')}\")
print(f\"Exec Role: {ss.get('ExecutionRoleName','AWSCloudFormationStackSetExecutionRole')}\")
print(f\"Accounts: {ss.get('OrganizationalUnitIds',[])} or explicit targets\")
"

# List all StackSet instances (target accounts + regions)
aws cloudformation list-stack-instances --stack-set-name production-baseline | \
  python3 -c "
import json,sys
for inst in json.load(sys.stdin)['Summaries']:
    print(f\"Account: {inst['Account']} Region: {inst['Region']} Status: {inst['StackInstanceStatus']['DetailedStatus']}\")
"

# If you have cloudformation:CreateStackInstances permission, deploy a malicious stack to target accounts
aws cloudformation create-stack-instances \
  --stack-set-name production-baseline \
  --accounts '["TARGET_ACCOUNT_ID"]' \
  --regions '["eu-west-1"]' \
  --parameter-overrides 'ParameterKey=MaliciousParam,ParameterValue=exploit'

# The StackSet execution role (AWSCloudFormationStackSetExecutionRole) in target accounts
# typically has AdministratorAccess — full compromise of target account

CloudFormation Service Role Abuse

# CloudFormation service roles allow CFN to assume an IAM role for stack operations
# If a stack has a service role with broad permissions, and you can update the stack,
# you can escalate privileges

# List stacks with service roles
aws cloudformation describe-stacks | \
  python3 -c "
import json,sys
for stack in json.load(sys.stdin)['Stacks']:
    role = stack.get('RoleARN','none')
    if role != 'none':
        print(f\"Stack: {stack['StackName']} Role: {role}\")
"

# Check the service role's permissions
ROLE_NAME="MyCloudFormationRole"
aws iam list-attached-role-policies --role-name $ROLE_NAME
aws iam get-role-policy --role-name $ROLE_NAME --policy-name InlinePolicy 2>/dev/null

# Escalation: update a stack using its service role to create a high-privilege IAM user
# (only possible if you have cloudformation:UpdateStack + cloudformation:PassRole)
aws cloudformation update-stack \
  --stack-name low-privilege-stack \
  --template-body file://malicious_template.yaml \
  --role-arn arn:aws:iam::ACCOUNT:role/BroadServiceRole \
  --capabilities CAPABILITY_IAM

CloudFormation Security Hardening

# Use SecureString parameters for secrets (no plaintext in stack history)
Parameters:
  DatabasePassword:
    Type: AWS::SSM::Parameter::Value
    Default: /prod/rds/master-password
    # Value fetched from SSM at deploy time, never stored in CloudFormation

# Use Secrets Manager dynamic references (zero exposure in CFN)
Resources:
  MyRDSInstance:
    Type: AWS::RDS::DBInstance
    Properties:
      MasterUsername: !Sub '{{resolve:secretsmanager:${SecretArn}:SecretString:username}}'
      MasterUserPassword: !Sub '{{resolve:secretsmanager:${SecretArn}:SecretString:password}}'

# Enable stack policy to prevent critical resource updates
aws cloudformation set-stack-policy --stack-name production-app \
  --stack-policy-body '{
    "Statement": [
      {"Effect": "Deny", "Action": "Update:Replace", "Principal": "*",
       "Resource": "LogicalResourceId/ProductionDatabase"},
      {"Effect": "Allow", "Action": "Update:*", "Principal": "*", "Resource": "*"}
    ]
  }'

# Enable termination protection
aws cloudformation update-termination-protection \
  --enable-termination-protection \
  --stack-name production-app

# Use CloudFormation Guard (cfn-guard) for policy-as-code
cat > rules/security.guard <<'EOF'
rule s3_bucket_encryption {
  AWS::S3::Bucket {
    Properties.BucketEncryption exists
  }
}

rule rds_encryption {
  AWS::RDS::DBInstance {
    Properties.StorageEncrypted == true
  }
}
EOF
cfn-guard validate -d template.yaml -r rules/security.guard
CloudFormation Security Checklist: Scan all templates with cfn-nag and Checkov in CI; use SecureString SSM parameters or Secrets Manager dynamic references (never plain String for secrets); enable stack termination protection on production; restrict StackSets execution role from AdministratorAccess to minimum required; audit cross-stack exports for sensitive values; enable CloudTrail to audit all CFN API calls; run drift detection weekly on production stacks.
Security TestTool/MethodPriority
Template misconfiguration scancfn-nag, CheckovHigh
Stack parameter secret extractionaws cloudformation describe-stacksCritical
Cross-stack export enumerationaws cloudformation list-exportsHigh
Custom resource Lambda role auditaws iam list-attached-role-policiesHigh
StackSets target account reviewaws cloudformation list-stack-instancesHigh
Service role permissions auditaws iam get-role-policyHigh
Stack drift detectionaws cloudformation detect-stack-driftMedium
Stack events for secret leakageaws cloudformation describe-stack-eventsMedium
Termination protection checkaws cloudformation describe-stacksMedium

Automate CloudFormation Security Testing

Ironimo scans CloudFormation templates for misconfigurations, detects exposed stack parameters, and identifies overprivileged service roles and custom resource Lambda functions.

Start free scan